feat(tui): archive todos at turn end with incomplete hint

This commit is contained in:
Brooklyn Nicholson
2026-04-26 16:14:58 -05:00
parent 319c1c1691
commit c78b528125
12 changed files with 948 additions and 70 deletions

View File

@@ -59,7 +59,7 @@ describe('createGatewayEventHandler', () => {
patchUiState({ showReasoning: true })
})
it('keeps todo list visible after final assistant text completes', () => {
it('archives incomplete todos into transcript flow at end of turn so they scroll up', () => {
const appended: Msg[] = []
const todos = [
@@ -76,8 +76,12 @@ describe('createGatewayEventHandler', () => {
onEvent({ payload: { text: 'Started a todo list.' }, type: 'message.complete' } as any)
expect(appended[appended.length - 1]).toMatchObject({ role: 'assistant', text: 'Started a todo list.' })
expect(getTurnState().todos).toEqual(todos)
const trail = appended.find(msg => msg.kind === 'trail' && msg.todos?.length)
const finalText = appended.find(msg => msg.role === 'assistant' && msg.text === 'Started a todo list.')
expect(finalText).toBeDefined()
expect(trail).toMatchObject({ kind: 'trail', role: 'system', todos, todoIncomplete: true })
expect(getTurnState().todos).toEqual([])
})
it('archives completed todos into transcript flow at end of turn', () => {

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import {
appendTurnSegment,
archiveDoneTodos,
archiveTodosAtTurnEnd,
getTurnState,
patchTurnState,
resetTurnState,
@@ -20,7 +21,7 @@ describe('turnStore live progress helpers', () => {
]
})
expect(archiveDoneTodos()).toEqual([
expect(archiveTodosAtTurnEnd()).toEqual([
{
kind: 'trail',
role: 'system',
@@ -34,11 +35,25 @@ describe('turnStore live progress helpers', () => {
expect(getTurnState().todos).toEqual([])
})
it('does not archive active todos', () => {
patchTurnState({ todos: [{ content: 'cook', id: 'cook', status: 'in_progress' }] })
it('archives incomplete todos with an incomplete flag so the hint renders', () => {
patchTurnState({
todos: [
{ content: 'cook', id: 'cook', status: 'completed' },
{ content: 'serve', id: 'serve', status: 'in_progress' },
{ content: 'eat', id: 'eat', status: 'pending' }
]
})
const archived = archiveTodosAtTurnEnd()
expect(archived).toHaveLength(1)
expect(archived[0]!.todoIncomplete).toBe(true)
expect(archived[0]!.todos?.map(t => t.id)).toEqual(['cook', 'serve', 'eat'])
expect(getTurnState().todos).toEqual([])
})
it('returns nothing when there are no todos at turn end', () => {
expect(archiveTodosAtTurnEnd()).toEqual([])
expect(archiveDoneTodos()).toEqual([])
expect(getTurnState().todos).toHaveLength(1)
})
it('tracks collapsed state independently of todo content', () => {

View File

@@ -11,7 +11,7 @@ import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { archiveDoneTodos } from './turnStore.js'
import { archiveTodosAtTurnEnd } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
@@ -539,7 +539,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (!wasInterrupted) {
const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]
msgs.forEach(appendMessage)
archiveDoneTodos().forEach(appendMessage)
archiveTodosAtTurnEnd().forEach(appendMessage)
if (bellOnComplete && stdout?.isTTY) {
stdout.write('\x07')

View File

@@ -40,14 +40,22 @@ export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) =>
export const toggleTodoCollapsed = () => patchTurnState(state => ({ ...state, todoCollapsed: !state.todoCollapsed }))
export const archiveDoneTodos = () => {
export const archiveDoneTodos = () => archiveTodosAtTurnEnd()
export const archiveTodosAtTurnEnd = () => {
const state = $turnState.get()
if (!isTodoDone(state.todos)) {
if (!state.todos.length) {
return []
}
const msg: Msg = { kind: 'trail', role: 'system', text: '', todos: state.todos }
const msg: Msg = {
kind: 'trail',
role: 'system',
text: '',
todos: state.todos,
...(isTodoDone(state.todos) ? {} : { todoIncomplete: true })
}
patchTurnState({ todoCollapsed: false, todos: [] })

View File

@@ -37,7 +37,7 @@ export const MessageLine = memo(function MessageLine({
const thinking = msg.thinking?.trim() ?? ''
if (msg.kind === 'trail' && msg.todos?.length) {
return <TodoPanel t={t} todos={msg.todos} />
return <TodoPanel incomplete={msg.todoIncomplete} t={t} todos={msg.todos} />
}
if (msg.kind === 'trail' && (msg.tools?.length || tools.length || thinking)) {

View File

@@ -1,6 +1,7 @@
import { Box, Text } from '@hermes/ink'
import { memo } from 'react'
import { countPendingTodos } from '../lib/liveProgress.js'
import { todoGlyph, todoTone } from '../lib/todo.js'
import type { Theme } from '../theme.js'
import type { TodoItem } from '../types.js'
@@ -13,11 +14,13 @@ const rowColor = (t: Theme, status: TodoItem['status']) => {
export const TodoPanel = memo(function TodoPanel({
collapsed = false,
incomplete = false,
onToggle,
t,
todos
}: {
collapsed?: boolean
incomplete?: boolean
onToggle?: () => void
t: Theme
todos: TodoItem[]
@@ -27,6 +30,7 @@ export const TodoPanel = memo(function TodoPanel({
}
const done = todos.filter(todo => todo.status === 'completed').length
const pending = countPendingTodos(todos)
return (
<Box flexDirection="column" marginBottom={1}>
@@ -39,6 +43,12 @@ export const TodoPanel = memo(function TodoPanel({
<Text color={t.color.statusFg} dim>
({done}/{todos.length})
</Text>
{incomplete && pending > 0 && (
<Text color={t.color.dim} dim>
{' '}
· incomplete · {pending} still {pending === 1 ? 'pending' : 'pending/in_progress'}
</Text>
)}
</Text>
</Box>

View File

@@ -2,9 +2,9 @@ import type { ScrollBoxHandle } from '@hermes/ink'
import {
type RefObject,
useCallback,
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore
@@ -14,8 +14,43 @@ const ESTIMATE = 4
const OVERSCAN = 40
const MAX_MOUNTED = 260
const COLD_START = 40
// Floor on unmeasured row height used when computing coverage — guarantees
// the mounted span physically reaches the viewport bottom regardless of how
// small items actually are (at the cost of over-mounting when items are
// larger; overscan absorbs that).
const PESSIMISTIC = 1
// Tightest safe scrollTop bin for the useSyncExternalStore snapshot. Small
// wheel ticks that don't cross a bin short-circuit React's commit entirely;
// Ink keeps painting via ScrollBox.forceRender + direct scrollTop reads.
// Half of OVERSCAN keeps ≥20 rows of cushion before the mounted range
// would actually need to shift.
const QUANTUM = OVERSCAN >> 1
// Renders to keep the mount range frozen after width change (heights scaled
// but not yet re-measured). Render #1 skips measurement so pre-resize Yoga
// doesn't poison the scaled cache; render #2's useLayoutEffect captures
// post-resize heights; render #3 recomputes range with accurate data.
const FREEZE_RENDERS = 2
// Cap on NEW items mounted per commit when scrolling fast. Without this,
// a single PageUp into unmeasured territory mounts ~190 rows with
// PESSIMISTIC=1 coverage — each row running marked lexer + syntax
// highlighting for ~3ms = ~600ms sync block. Sliding toward the target
// over several commits keeps per-commit mount cost bounded.
const SLIDE_STEP = 25
const NOOP = () => {}
const upperBound = (arr: ArrayLike<number>, target: number) => {
let lo = 0
let hi = arr.length
while (lo < hi) {
const mid = (lo + hi) >> 1
arr[mid]! <= target ? (lo = mid + 1) : (hi = mid)
}
return lo
}
export const shouldSetVirtualClamp = ({
itemCount,
@@ -29,19 +64,6 @@ export const shouldSetVirtualClamp = ({
viewportHeight: number
}) => itemCount > 0 && viewportHeight > 0 && !sticky && !liveTailActive
const upperBound = (arr: number[], target: number) => {
let lo = 0
let hi = arr.length
while (lo < hi) {
const mid = (lo + hi) >> 1
arr[mid]! <= target ? (lo = mid + 1) : (hi = mid)
}
return lo
}
export function useVirtualHistory(
scrollRef: RefObject<ScrollBoxHandle | null>,
items: readonly { key: string }[],
@@ -57,15 +79,28 @@ export function useVirtualHistory(
const nodes = useRef(new Map<string, unknown>())
const heights = useRef(new Map<string, number>())
const refs = useRef(new Map<string, (el: unknown) => void>())
const [ver, setVer] = useState(0)
// Bump whenever heightCache mutates so offsets rebuild on next read.
// Ref (not state) — checked during render phase, zero extra commits.
const offsetVersion = useRef(0)
// Cached offsets: reused Float64Array keyed on (itemCount, version) so we
// only rebuild when something actually changed. Previous approach allocated
// a fresh Array(n+1) every render — at n=10k that's ~80KB/render of GC
// pressure during streaming.
const offsetsCache = useRef<{ arr: Float64Array; n: number; version: number }>({
arr: new Float64Array(0),
n: -1,
version: -1
})
const [hasScrollRef, setHasScrollRef] = useState(false)
const metrics = useRef({ sticky: true, top: 0, vp: 0 })
const lastScrollTopRef = useRef(0)
// Width change: scale cached heights (not clear — clearing forces a
// pessimistic back-walk mounting ~190 rows at once, each a fresh
// marked.lexer + syntax highlight ≈ 3ms). Freeze mount range for 2
// renders so warm memos survive; skip one measurement so useLayoutEffect
// doesn't poison the scaled cache with pre-resize Yoga heights.
// Width change: scale cached heights by oldCols/newCols instead of clearing
// (clearing forces a pessimistic back-walk mounting ~190 rows at once, each
// a fresh marked.lexer + syntax highlight ≈ 3ms). Freeze the mount range
// for 2 renders so warm memos survive; skip one measurement pass so
// useLayoutEffect doesn't poison the scaled cache with pre-resize Yoga
// heights.
const prevColumns = useRef(columns)
const skipMeasurement = useRef(false)
const prevRange = useRef<null | readonly [number, number]>(null)
@@ -80,6 +115,7 @@ export function useVirtualHistory(
heights.current.set(k, Math.max(1, Math.round(h * ratio)))
}
offsetVersion.current++
skipMeasurement.current = true
freezeRenders.current = FREEZE_RENDERS
}
@@ -88,11 +124,18 @@ export function useVirtualHistory(
setHasScrollRef(Boolean(scrollRef.current))
}, [scrollRef])
// Quantized snapshot: same-bin scrolls (most wheel ticks) produce the same
// number → React.Object.is short-circuits the commit entirely. sticky state
// is folded in via the sign bit so sticky→broken transitions also trigger.
// Uses the TARGET (committed + pendingDelta), not committed scrollTop, so
// scrollBy notifications immediately remount for the destination before
// Ink's drain frames need the children.
const subscribe = useCallback(
(cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? NOOP,
[hasScrollRef, scrollRef]
)
useSyncExternalStore(
useCallback(
(cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? (() => () => {}),
[hasScrollRef, scrollRef]
),
subscribe,
() => {
const s = scrollRef.current
@@ -100,9 +143,10 @@ export function useVirtualHistory(
return NaN
}
const b = Math.floor((s.getScrollTop() + s.getPendingDelta()) / QUANTUM)
const target = s.getScrollTop() + s.getPendingDelta()
const bin = Math.floor(target / QUANTUM)
return s.isSticky() ? -b - 1 : b
return s.isSticky() ? ~bin : bin
},
() => NaN
)
@@ -121,26 +165,33 @@ export function useVirtualHistory(
}
if (dirty) {
setVer(v => v + 1)
offsetVersion.current++
}
}, [items])
const offsets = useMemo(() => {
void ver
const out = new Array<number>(items.length + 1).fill(0)
// Offsets: Float64Array reused across renders, invalidated by offsetVersion
// bumps from heightCache writers (measureRef, resize-scale, GC). Binary
// search tolerates either monotone source, so no need to rebuild unless
// something changed.
const n = items.length
for (let i = 0; i < items.length; i++) {
out[i + 1] = out[i]! + Math.max(1, Math.floor(heights.current.get(items[i]!.key) ?? estimate))
if (offsetsCache.current.version !== offsetVersion.current || offsetsCache.current.n !== n) {
const arr = offsetsCache.current.arr.length >= n + 1 ? offsetsCache.current.arr : new Float64Array(n + 1)
arr[0] = 0
for (let i = 0; i < n; i++) {
arr[i + 1] = arr[i]! + Math.max(1, Math.floor(heights.current.get(items[i]!.key) ?? estimate))
}
return out
}, [estimate, items, ver])
offsetsCache.current = { arr, n, version: offsetVersion.current }
}
const n = items.length
const offsets = offsetsCache.current.arr
const total = offsets[n] ?? 0
const top = Math.max(0, scrollRef.current?.getScrollTop() ?? 0)
const pending = scrollRef.current?.getPendingDelta() ?? 0
const target = Math.max(0, top + pending)
const pendingDelta = scrollRef.current?.getPendingDelta() ?? 0
const target = Math.max(0, top + pendingDelta)
const vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0)
const sticky = scrollRef.current?.isSticky() ?? true
const recentManual = Date.now() - (scrollRef.current?.getLastManualScrollAt() ?? 0) < 1200
@@ -168,9 +219,22 @@ export function useVirtualHistory(
start--
}
} else {
const lo = Math.max(0, Math.min(top, target) - overscan)
const hi = Math.max(top, target) + vp + overscan
// User scrolled up. Span [committed..target] so every drain frame is
// covered. Claude-code caps the span at 3×viewport so pendingDelta
// growing unbounded (MX Master free-spin) doesn't blow the mount
// budget; the clamp (setClampBounds) shows edge-of-mounted content
// during catch-up.
const MAX_SPAN = vp * 3
const rawLo = Math.min(top, target)
const rawHi = Math.max(top, target)
const span = rawHi - rawLo
const clampedLo = span > MAX_SPAN ? (pendingDelta < 0 ? rawHi - MAX_SPAN : rawLo) : rawLo
const clampedHi = clampedLo + Math.min(span, MAX_SPAN)
const lo = Math.max(0, clampedLo - overscan)
const hi = clampedHi + vp + overscan
// Binary search — offsets is monotone. Linear walk was O(n) at n=10k+,
// ~2ms per render during scroll.
start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo) - 1))
end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi)))
}
@@ -180,17 +244,144 @@ export function useVirtualHistory(
sticky ? (start = Math.max(0, end - maxMounted)) : (end = Math.min(n, start + maxMounted))
}
// Coverage guarantee: ensure sum(real or pessimistic heights) ≥
// viewportH + 2*overscan so the viewport is physically covered even when
// items are tiny. Pessimistic because uncached items use a floor of 1 —
// over-mounts when items are large, never leaves blank spacer showing.
if (n > 0 && vp > 0 && !frozenRange) {
const needed = vp + 2 * overscan
let coverage = 0
for (let i = start; i < end; i++) {
coverage += heights.current.get(items[i]!.key) ?? PESSIMISTIC
}
if (sticky) {
const minStart = Math.max(0, end - maxMounted)
while (start > minStart && coverage < needed) {
start--
coverage += heights.current.get(items[start]!.key) ?? PESSIMISTIC
}
} else {
const maxEnd = Math.min(n, start + maxMounted)
while (end < maxEnd && coverage < needed) {
coverage += heights.current.get(items[end]!.key) ?? PESSIMISTIC
end++
}
}
}
// Slide cap: limit how many NEW items mount this commit. Gates on scroll
// VELOCITY (|scrollTop delta since last commit| + |pendingDelta| >
// 2×viewport — key-repeat PageUp moves ~viewport/2 per press). Covers
// both scrollBy (pendingDelta) and scrollTo (direct write). Normal single
// PageUp skips this; the clamp holds the viewport at the mounted edge
// during catch-up so there's no blank screen. Only caps range GROWTH;
// shrinking is unbounded.
if (!frozenRange && prevRange.current && vp > 0) {
const velocity = Math.abs(top - lastScrollTopRef.current) + Math.abs(pendingDelta)
if (velocity > vp * 2) {
const [pS, pE] = prevRange.current
if (start < pS - SLIDE_STEP) {
start = pS - SLIDE_STEP
}
if (end > pE + SLIDE_STEP) {
end = pE + SLIDE_STEP
}
// A large jump past the capped end can invert (start > end); mount
// SLIDE_STEP items from the new start so the viewport isn't blank
// during catch-up.
if (start > end) {
end = Math.min(start + SLIDE_STEP, n)
}
}
}
lastScrollTopRef.current = top
if (freezeRenders.current > 0) {
freezeRenders.current--
} else {
prevRange.current = [start, end]
}
// Time-slice range growth via useDeferredValue. Urgent render keeps Ink
// painting with the OLD range (all memo hits, fast); deferred render
// transitions to the NEW range (fresh mounts: Md, syntax highlight) in a
// non-blocking background commit. The clamp (setClampBounds) pins the
// viewport to the mounted edge so there's no visual artifact from the
// deferred range lagging briefly. Only deferral range GROWTH — shrinking
// is cheap (unmount = remove fiber, no parse).
const dStart = useDeferredValue(start)
const dEnd = useDeferredValue(end)
let effStart = start < dStart ? dStart : start
let effEnd = end > dEnd ? dEnd : end
// Inverted range (large jump with deferred value lagging) or sticky snap
// (scrollToBottom needs the tail mounted NOW so maxScroll lands on content,
// not bottomSpacer) — skip deferral.
if (effStart > effEnd || sticky) {
effStart = start
effEnd = end
}
// Scrolling DOWN — bypass effEnd deferral so the tail mounts immediately.
// Without this, the clamp holds scrollTop short of the real bottom and
// the user feels "stuck before bottom". effStart stays deferred so scroll-
// UP keeps time-slicing (older messages parse on mount).
if (pendingDelta > 0) {
effEnd = end
}
// Final O(viewport) enforcement. Deferred+bypass combinations above can
// leak: during sustained PageUp, concurrent mode interleaves dStart updates
// with effEnd=end bypasses across commits and the effective window drifts
// wider than either bound alone. Trim the far edge by viewport position
// (not pendingDelta direction — that flips mid-settle under concurrent
// scheduling and yanks scrollTop).
if (effEnd - effStart > maxMounted && vp > 0) {
const mid = (offsets[effStart]! + offsets[effEnd]!) / 2
if (top < mid) {
effEnd = effStart + maxMounted
} else {
effStart = effEnd - maxMounted
}
}
const measureRef = useCallback((key: string) => {
let fn = refs.current.get(key)
if (!fn) {
fn = (el: unknown) => (el ? nodes.current.set(key, el) : nodes.current.delete(key))
fn = (el: unknown) => {
if (el) {
nodes.current.set(key, el)
return
}
// Measure-at-unmount: the yogaNode is still valid here (reconciler
// calls ref(null) before removeChild → freeRecursive), so we grab
// the final height before WASM release. Without this, items
// scrolled out during fast pan keep a stale estimate in heightCache
// and offset math drifts until the next mount/remount cycle.
const existing = nodes.current.get(key) as MeasuredNode | undefined
const h = Math.ceil(existing?.yogaNode?.getComputedHeight?.() ?? 0)
if (h > 0 && heights.current.get(key) !== h) {
heights.current.set(key, h)
offsetVersion.current++
}
nodes.current.delete(key)
}
refs.current.set(key, fn)
}
@@ -202,25 +393,33 @@ export function useVirtualHistory(
let dirty = false
// Give the renderer the mounted-row coverage for passive scroll clamping.
// Without this, burst wheel/page scroll can race past the React commit that
// updates the virtual range and paint spacer-only frames.
// Clamp MUST use the EFFECTIVE (deferred) range, not the immediate one.
// During fast scroll, immediate [start,end] may already cover the new
// scrollTop position, but children still render at the deferred range.
// If clamp used immediate bounds, render-node-to-output's drain-gate
// would drain past the deferred children's span → viewport lands in
// spacer → white flash.
if (s && shouldSetVirtualClamp({ itemCount: n, liveTailActive, sticky, viewportHeight: vp })) {
const min = offsets[start] ?? 0
const max = Math.max(min, (offsets[end] ?? total) - vp)
s.setClampBounds(min, max)
const effTopSpacer = offsets[effStart] ?? 0
const effBottom = offsets[effEnd] ?? total
// At effEnd=n there's no bottomSpacer — use Infinity so render-node-
// to-output's own Math.min(cur, maxScroll) governs. Using offsets[n]
// here would bake in heightCache (one render behind Yoga), and during
// streaming the tail item's cached height lags its real height —
// sticky-break would then clamp below the real max and push
// streaming text off-viewport.
const clampMin = effStart === 0 ? 0 : effTopSpacer
const clampMax = effEnd === n ? Infinity : Math.max(effTopSpacer, effBottom - vp)
s.setClampBounds(clampMin, clampMax)
} else {
// Sticky bottom often has live, non-virtualized tail content after the
// virtual transcript (streaming answer / thinking / tools). A clamp based
// only on virtual history would cap rendering before that tail and make
// live thinking appear to vanish. No burst-scroll clamp is needed while
// sticky anyway.
s?.setClampBounds(undefined, undefined)
}
if (skipMeasurement.current) {
skipMeasurement.current = false
} else {
for (let i = start; i < end; i++) {
for (let i = effStart; i < effEnd; i++) {
const k = items[i]?.key
if (!k) {
@@ -254,17 +453,17 @@ export function useVirtualHistory(
}
if (dirty) {
setVer(v => v + 1)
offsetVersion.current++
}
}, [end, hasScrollRef, items, liveTailActive, n, offsets, recentManual, scrollRef, start, sticky, total, vp])
})
return {
bottomSpacer: Math.max(0, total - (offsets[end] ?? total)),
end,
bottomSpacer: Math.max(0, total - (offsets[effEnd] ?? total)),
end: effEnd,
measureRef,
offsets,
start,
topSpacer: offsets[start] ?? 0
start: effStart,
topSpacer: offsets[effStart] ?? 0
}
}

View File

@@ -1,5 +1,8 @@
import type { Msg, TodoItem } from '../types.js'
export const countPendingTodos = (todos: readonly TodoItem[]) =>
todos.filter(todo => todo.status === 'in_progress' || todo.status === 'pending').length
export const isTodoDone = (todos: readonly TodoItem[]) =>
todos.length > 0 && todos.every(todo => todo.status === 'completed' || todo.status === 'cancelled')

View File

@@ -117,6 +117,7 @@ export interface Msg {
toolTokens?: number
tools?: string[]
todos?: TodoItem[]
todoIncomplete?: boolean
}
export type Role = 'assistant' | 'system' | 'tool' | 'user'