fix(tui): stabilize live progress rendering

This commit is contained in:
Brooklyn Nicholson
2026-04-26 15:23:43 -05:00
parent d4dde6b5f2
commit a7831b63db
28 changed files with 619 additions and 154 deletions

View File

@@ -59,6 +59,54 @@ describe('createGatewayEventHandler', () => {
patchUiState({ showReasoning: true })
})
it('keeps todo list visible after final assistant text completes', () => {
const appended: Msg[] = []
const todos = [
{ content: 'Gather ingredients', id: 'prep', status: 'completed' },
{ content: 'Boil water', id: 'boil', status: 'in_progress' },
{ content: 'Make sauce', id: 'sauce', status: 'pending' }
]
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: {}, type: 'message.start' } as any)
onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any)
expect(getTurnState().todos).toEqual(todos)
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)
})
it('keeps the current todo list visible when the next message starts', () => {
const appended: Msg[] = []
const todos = [{ content: 'Boil water', id: 'boil', status: 'in_progress' }]
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any)
expect(getTurnState().todos).toEqual(todos)
onEvent({ payload: {}, type: 'message.start' } as any)
expect(getTurnState().todos).toEqual(todos)
})
it('clears the visible todo list when the todo tool returns an empty list', () => {
const appended: Msg[] = []
const todos = [{ content: 'Boil water', id: 'boil', status: 'in_progress' }]
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { name: 'todo', todos, tool_id: 'todo-1' }, type: 'tool.start' } as any)
expect(getTurnState().todos).toEqual(todos)
onEvent({ payload: { name: 'todo', todos: [], tool_id: 'todo-1' }, type: 'tool.complete' } as any)
expect(getTurnState().todos).toEqual([])
})
it('persists completed tool rows when message.complete lands immediately after tool.complete', () => {
const appended: Msg[] = []
@@ -90,6 +138,31 @@ describe('createGatewayEventHandler', () => {
expect(appended[1]).toMatchObject({ role: 'assistant', text: 'final answer' })
})
it('groups sequential completed tools into one trail when the turn completes', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { context: 'alpha', name: 'search_files', tool_id: 'tool-1' }, type: 'tool.start' } as any)
onEvent({
payload: { name: 'search_files', summary: 'first done', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({ payload: { context: 'beta', name: 'read_file', tool_id: 'tool-2' }, type: 'tool.start' } as any)
onEvent({ payload: { name: 'read_file', summary: 'second done', tool_id: 'tool-2' }, type: 'tool.complete' } as any)
expect(getTurnState().streamSegments.filter(msg => msg.kind === 'trail' && msg.tools?.length)).toHaveLength(1)
expect(getTurnState().streamSegments[0]?.tools).toHaveLength(2)
expect(getTurnState().streamPendingTools).toEqual([])
onEvent({ payload: { text: '' }, type: 'message.complete' } as any)
const toolTrails = appended.filter(msg => msg.kind === 'trail' && msg.tools?.length)
expect(toolTrails).toHaveLength(1)
expect(toolTrails[0]?.tools).toHaveLength(2)
expect(toolTrails[0]?.tools?.[0]).toContain('Search Files')
expect(toolTrails[0]?.tools?.[1]).toContain('Read File')
})
it('keeps tool tokens across handler recreation mid-turn', () => {
const appended: Msg[] = []
@@ -213,7 +286,12 @@ describe('createGatewayEventHandler', () => {
expect(appended).toHaveLength(0)
expect(turnController.segmentMessages).toEqual([
{ role: 'assistant', text: 'Editing the file' },
{ kind: 'diff', role: 'assistant', text: block, tools: ['Patch("foo.ts") ✓'] }
{
kind: 'diff',
role: 'assistant',
text: block,
tools: [expect.stringMatching(/^Patch\("foo\.ts"\)(?: \([^)]+\))? ✓$/)]
}
])
onEvent({ payload: { text: 'patch applied' }, type: 'message.complete' } as any)

View File

@@ -21,6 +21,7 @@ describe('scrollWithSelectionBy', () => {
getScrollTop: vi.fn(() => 9),
getViewportHeight: vi.fn(() => 20)
})
const selection = {
captureScrolledRows: vi.fn(),
getState: vi.fn(() => null),
@@ -39,6 +40,7 @@ describe('scrollWithSelectionBy', () => {
getScrollTop: vi.fn(() => 10),
getViewportHeight: vi.fn(() => 20)
})
const selection = {
captureScrolledRows: vi.fn(),
getState: vi.fn(() => null),

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
freezeTurnRendering,
getRenderableTurnState,
patchTurnState,
resetTurnState,
unfreezeTurnRendering
} from '../app/turnStore.js'
describe('turn render freezing', () => {
it('holds the render snapshot stable while live turn state keeps changing', () => {
resetTurnState()
patchTurnState({ streaming: 'before scroll' })
freezeTurnRendering()
patchTurnState({ reasoning: 'new thinking', streaming: 'new streamed text' })
expect(getRenderableTurnState().streaming).toBe('before scroll')
expect(getRenderableTurnState().reasoning).toBe('')
unfreezeTurnRendering()
expect(getRenderableTurnState().streaming).toBe('new streamed text')
expect(getRenderableTurnState().reasoning).toBe('new thinking')
})
})

View File

@@ -10,4 +10,10 @@ describe('virtual history clamp bounds', () => {
it('sets clamp bounds after manual scroll breaks sticky mode', () => {
expect(shouldSetVirtualClamp({ itemCount: 20, sticky: false, viewportHeight: 10 })).toBe(true)
})
it('does not clamp while a live tail is growing below virtual history', () => {
expect(shouldSetVirtualClamp({ itemCount: 20, liveTailActive: true, sticky: false, viewportHeight: 10 })).toBe(
false
)
})
})

View File

@@ -372,6 +372,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
case 'tool.start':
turnController.recordTodos(ev.payload.todos)
turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '')
return
@@ -384,10 +385,18 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
inlineDiffText,
ev.payload.tool_id,
ev.payload.name,
ev.payload.error
ev.payload.error,
ev.payload.duration_s
)
} else {
turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary)
turnController.recordToolComplete(
ev.payload.tool_id,
ev.payload.name,
ev.payload.error,
ev.payload.summary,
ev.payload.duration_s,
ev.payload.todos
)
}
return

View File

@@ -7,8 +7,6 @@ import type { ImageAttachResponse } from '../gatewayTypes.js'
import type { RpcResult } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import type {
ActiveTool,
ActivityItem,
ApprovalReq,
ClarifyReq,
ConfirmReq,
@@ -19,7 +17,6 @@ import type {
SectionVisibility,
SessionInfo,
SlashCatalog,
SubagentProgress,
SudoReq,
Usage
} from '../types.js'
@@ -308,21 +305,7 @@ export interface AppLayoutComposerProps {
}
export interface AppLayoutProgressProps {
activity: ActivityItem[]
outcome: string
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean
reasoningTokens: number
showProgressArea: boolean
showStreamingArea: boolean
streamPendingTools: string[]
streamSegments: Msg[]
streaming: string
subagents: SubagentProgress[]
toolTokens: number
tools: ActiveTool[]
turnTrail: string[]
}
export interface AppLayoutStatusProps {

View File

@@ -260,7 +260,9 @@ export const coreCommands: SlashCommand[] = [
if (text) {
return sys(`copied ${text.length} characters`)
} else {
return sys('clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence; HERMES_TUI_DEBUG_CLIPBOARD=1 for details')
return sys(
'clipboard copy failed — try HERMES_TUI_FORCE_OSC52=1 to force the escape sequence; HERMES_TUI_DEBUG_CLIPBOARD=1 for details'
)
}
}

View File

@@ -1,6 +1,7 @@
import { atom } from 'nanostores'
import { useSyncExternalStore } from 'react'
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js'
const buildTurnState = (): TurnState => ({
activity: [],
@@ -13,6 +14,7 @@ const buildTurnState = (): TurnState => ({
streamSegments: [],
streaming: '',
subagents: [],
todos: [],
toolTokens: 0,
tools: [],
turnTrail: []
@@ -22,6 +24,15 @@ export const $turnState = atom<TurnState>(buildTurnState())
export const getTurnState = () => $turnState.get()
const subscribeTurn = (cb: () => void) => $turnState.listen(() => cb())
export const useTurnSelector = <T>(selector: (state: TurnState) => T): T =>
useSyncExternalStore(
subscribeTurn,
() => selector($turnState.get()),
() => selector($turnState.get())
)
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) =>
$turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next })
@@ -38,6 +49,7 @@ export interface TurnState {
streamSegments: Msg[]
streaming: string
subagents: SubagentProgress[]
todos: TodoItem[]
toolTokens: number
tools: ActiveTool[]
turnTrail: string[]

View File

@@ -1,6 +1,8 @@
import { useInput } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useRef } from 'react'
import { TYPING_IDLE_MS } from '../config/timing.js'
import type {
ApprovalRespondResponse,
ConfigSetResponse,
@@ -26,6 +28,24 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const overlay = useStore($overlayState)
const isBlocked = useStore($isBlocked)
const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6)
const scrollIdleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const scrollTranscript = (delta: number) => {
if (getUiState().busy) {
turnController.boostStreamingForScroll()
if (scrollIdleTimer.current) {
clearTimeout(scrollIdleTimer.current)
}
scrollIdleTimer.current = setTimeout(() => {
scrollIdleTimer.current = null
turnController.relaxStreaming()
}, TYPING_IDLE_MS)
}
terminal.scrollWithSelection(delta)
}
const copySelection = () => {
// ink's copySelection() already calls setClipboard() which handles
@@ -259,26 +279,26 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
}
if (key.wheelUp) {
return terminal.scrollWithSelection(-wheelStep)
return scrollTranscript(-wheelStep)
}
if (key.wheelDown) {
return terminal.scrollWithSelection(wheelStep)
return scrollTranscript(wheelStep)
}
if (key.shift && key.upArrow) {
return terminal.scrollWithSelection(-1)
return scrollTranscript(-1)
}
if (key.shift && key.downArrow) {
return terminal.scrollWithSelection(1)
return scrollTranscript(1)
}
if (key.pageUp || key.pageDown) {
const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8)
const step = Math.max(4, viewport - 2)
return terminal.scrollWithSelection(key.pageUp ? -step : step)
return scrollTranscript(key.pageUp ? -step : step)
}
if (key.escape && terminal.hasSelection) {

View File

@@ -28,7 +28,7 @@ import { type GatewayRpc, type TranscriptRow } from './interfaces.js'
import { $overlayState, patchOverlayState } from './overlayStore.js'
import { scrollWithSelectionBy } from './scroll.js'
import { turnController } from './turnController.js'
import { $turnState, patchTurnState } from './turnStore.js'
import { $turnState, patchTurnState, useTurnSelector } from './turnStore.js'
import { $uiState, getUiState, patchUiState } from './uiStore.js'
import { useComposerState } from './useComposerState.js'
import { useConfigSync } from './useConfigSync.js'
@@ -108,6 +108,19 @@ export function useMainApp(gw: GatewayClient) {
const overlay = useStore($overlayState)
const turn = useStore($turnState)
const turnLiveTailActive = useTurnSelector(state =>
Boolean(
state.streaming ||
state.streamPendingTools.length ||
state.streamSegments.length ||
state.reasoning.trim() ||
state.reasoningActive ||
state.tools.length ||
state.subagents.length ||
state.todos.length
)
)
const slashFlightRef = useRef(0)
const slashRef = useRef<(cmd: string) => boolean>(() => false)
const colsRef = useRef(cols)
@@ -178,7 +191,7 @@ export function useMainApp(gw: GatewayClient) {
[historyItems, messageId]
)
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols)
const virtualHistory = useVirtualHistory(scrollRef, virtualRows, cols, { liveTailActive: turnLiveTailActive })
const scrollWithSelection = useCallback(
(delta: number) => scrollWithSelectionBy(delta, { scrollRef, selection }),
@@ -587,7 +600,7 @@ export function useMainApp(gw: GatewayClient) {
slashRef.current(`/model ${value} --global`)
}, [])
const hasReasoning = Boolean(turn.reasoning.trim())
const hasReasoning = useTurnSelector(state => Boolean(state.reasoning.trim()))
// Per-section overrides win over the global mode — when every section is
// resolved to hidden, the only thing ToolTrail will surface is the
@@ -597,19 +610,22 @@ export function useMainApp(gw: GatewayClient) {
s => sectionMode(s, ui.detailsMode, ui.sections, ui.detailsModeCommandOverride) !== 'hidden'
)
const showProgressArea = anyPanelVisible
? Boolean(
ui.busy ||
turn.outcome ||
turn.streamPendingTools.length ||
turn.streamSegments.length ||
turn.subagents.length ||
turn.tools.length ||
turn.turnTrail.length ||
hasReasoning ||
turn.activity.length
)
: turn.activity.some(item => item.tone !== 'info')
const showProgressArea = useTurnSelector(state =>
anyPanelVisible
? Boolean(
ui.busy ||
state.outcome ||
state.streamPendingTools.length ||
state.streamSegments.length ||
state.subagents.length ||
state.tools.length ||
state.todos.length ||
state.turnTrail.length ||
hasReasoning ||
state.activity.length
)
: state.activity.some(item => item.tone !== 'info')
)
const appActions = useMemo(
() => ({
@@ -654,10 +670,7 @@ export function useMainApp(gw: GatewayClient) {
return bottom >= scrollHeight - 3
})()
const liveProgress = useMemo(
() => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }),
[turn, showProgressArea]
)
const liveProgress = useMemo(() => ({ showProgressArea }), [showProgressArea])
// Always pass current progress through. Freezing this while offscreen looked
// like a nice scroll optimization, but it also froze the live tail's

View File

@@ -58,6 +58,7 @@ export function useSubmission(opts: UseSubmissionOptions) {
if (!composerState.input && !composerState.inputBuf.length) {
turnController.relaxStreaming()
return
}
@@ -92,9 +93,11 @@ export function useSubmission(opts: UseSubmissionOptions) {
turnController.clearStatusTimer()
maybeGoodVibes(submitText)
setLastUserMsg(text)
if (showUserMessage) {
appendMessage({ role: 'user', text: displayText })
}
patchUiState({ busy: true, status: 'running…' })
turnController.bufRef = ''
turnController.interrupted = false

View File

@@ -139,6 +139,27 @@ function SessionDuration({ startedAt }: { startedAt: number }) {
return fmtDuration(now - startedAt)
}
const effortLabel = (effort?: string) => {
const value = String(effort ?? '')
.trim()
.toLowerCase()
return value && value !== 'medium' && value !== 'normal' && value !== 'default' ? value : ''
}
const shortModelLabel = (model: string) =>
model
.split('/')
.pop()!
.replace(/^claude[-_]/, '')
.replace(/^anthropic[-_]/, '')
.replace(/[-_]/g, ' ')
.replace(/\b(\d+)\s+(\d+)\b/g, '$1.$2')
.trim()
const modelLabel = (model: string, effort?: string, fast?: boolean) =>
[shortModelLabel(model), effortLabel(effort), fast ? 'fast' : ''].filter(Boolean).join(' ')
export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) {
const [active, setActive] = useState(false)
const [color, setColor] = useState(t.color.amber)
@@ -171,6 +192,8 @@ export function StatusRule({
status,
statusColor,
model,
modelFast,
modelReasoningEffort,
usage,
bgCount,
sessionStartedAt,
@@ -201,7 +224,7 @@ export function StatusRule({
) : (
<Text color={statusColor}>{status}</Text>
)}
<Text color={t.color.dim}> {model}</Text>
<Text color={t.color.dim}> {modelLabel(model, modelReasoningEffort, modelFast)}</Text>
{ctxLabel ? <Text color={t.color.dim}> {ctxLabel}</Text> : null}
{bar ? (
<Text color={t.color.dim}>
@@ -337,6 +360,8 @@ interface StatusRuleProps {
cols: number
cwdLabel: string
model: string
modelFast?: boolean
modelReasoningEffort?: string
sessionStartedAt?: null | number
showCost: boolean
status: string

View File

@@ -3,13 +3,11 @@ import { useStore } from '@nanostores/react'
import { memo } from 'react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js'
import type { AppLayoutProps } from '../app/interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import { PLACEHOLDER } from '../content/placeholders.js'
import { inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
import type { Theme } from '../theme.js'
import type { DetailsMode, SectionVisibility } from '../types.js'
import { AgentsOverlay } from './agentsOverlay.js'
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
@@ -17,69 +15,9 @@ import { FloatingOverlays, PromptZone } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js'
import { MessageLine } from './messageLine.js'
import { QueuedMessages } from './queuedMessages.js'
import { LiveTodoPanel, StreamingAssistant } from './streamingAssistant.js'
import { TextInput } from './textInput.js'
const StreamingAssistant = memo(function StreamingAssistant({
busy,
cols,
compact,
detailsMode,
detailsModeCommandOverride,
progress,
sections,
t
}: StreamingAssistantProps) {
if (!progress.showProgressArea && !progress.showStreamingArea) {
return null
}
return (
<>
{progress.streamSegments.map((msg, i) => (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
key={`seg:${i}`}
msg={msg}
sections={sections}
t={t}
/>
))}
{progress.showStreamingArea && (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
isStreaming
msg={{
role: 'assistant',
text: progress.streaming,
...(progress.streamPendingTools.length && { tools: progress.streamPendingTools })
}}
sections={sections}
t={t}
/>
)}
{!progress.showStreamingArea && !!progress.streamPendingTools.length && (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
msg={{ kind: 'trail', role: 'system', text: '', tools: progress.streamPendingTools }}
sections={sections}
t={t}
/>
)}
</>
)
})
const TranscriptPane = memo(function TranscriptPane({
actions,
composer,
@@ -120,15 +58,15 @@ const TranscriptPane = memo(function TranscriptPane({
{transcript.virtualHistory.bottomSpacer > 0 ? <Box height={transcript.virtualHistory.bottomSpacer} /> : null}
<LiveTodoPanel />
<StreamingAssistant
busy={ui.busy}
cols={composer.cols}
compact={ui.compact}
detailsMode={ui.detailsMode}
detailsModeCommandOverride={ui.detailsModeCommandOverride}
progress={progress}
sections={ui.sections}
t={ui.theme}
/>
</Box>
</ScrollBox>
@@ -279,7 +217,9 @@ const StatusRulePane = memo(function StatusRulePane({
busy={ui.busy}
cols={composer.cols}
cwdLabel={status.cwdLabel}
model={ui.info?.model?.split('/').pop() ?? ''}
model={ui.info?.model ?? ''}
modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'}
modelReasoningEffort={ui.info?.reasoning_effort}
sessionStartedAt={status.sessionStartedAt}
showCost={ui.showCost}
status={ui.status}
@@ -331,14 +271,3 @@ export const AppLayout = memo(function AppLayout({
</AlternateScreen>
)
})
interface StreamingAssistantProps {
busy: boolean
cols: number
compact?: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
progress: AppLayoutProgressProps
sections?: SectionVisibility
t: Theme
}

View File

@@ -0,0 +1,119 @@
import { useStore } from '@nanostores/react'
import { memo } from 'react'
import type { AppLayoutProgressProps } from '../app/interfaces.js'
import { useTurnSelector } from '../app/turnStore.js'
import { $uiState } from '../app/uiStore.js'
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
import { MessageLine } from './messageLine.js'
import { TodoPanel } from './todoPanel.js'
const isToolOnly = (msg: Msg | undefined) =>
Boolean(msg && msg.kind === 'trail' && !msg.thinking?.trim() && !msg.text && msg.tools?.length)
const groupedSegments = (segments: Msg[]) =>
segments.reduce<Msg[]>((acc, msg) => {
if (isToolOnly(msg) && isToolOnly(acc.at(-1))) {
const prev = acc.at(-1)!
return [...acc.slice(0, -1), { ...prev, tools: [...(prev.tools ?? []), ...(msg.tools ?? [])] }]
}
return [...acc, msg]
}, [])
export const StreamingAssistant = memo(function StreamingAssistant({
cols,
compact,
detailsMode,
detailsModeCommandOverride,
progress,
sections
}: StreamingAssistantProps) {
const ui = useStore($uiState)
const streamSegments = useTurnSelector(state => state.streamSegments)
const streamPendingTools = useTurnSelector(state => state.streamPendingTools)
const streaming = useTurnSelector(state => state.streaming)
const activeTools = useTurnSelector(state => state.tools)
const showStreamingArea = Boolean(streaming)
if (!progress.showProgressArea && !showStreamingArea && !activeTools.length) {
return null
}
return (
<>
{groupedSegments(streamSegments).map((msg, i) => (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
key={`seg:${i}`}
msg={msg}
sections={sections}
t={ui.theme}
/>
))}
{!!activeTools.length && (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
msg={{ kind: 'trail', role: 'system', text: '' }}
sections={sections}
t={ui.theme}
tools={activeTools}
/>
)}
{showStreamingArea && (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
isStreaming
msg={{
role: 'assistant',
text: streaming,
...(streamPendingTools.length && { tools: streamPendingTools })
}}
sections={sections}
t={ui.theme}
/>
)}
{!showStreamingArea && !!streamPendingTools.length && (
<MessageLine
cols={cols}
compact={compact}
detailsMode={detailsMode}
detailsModeCommandOverride={detailsModeCommandOverride}
msg={{ kind: 'trail', role: 'system', text: '', tools: streamPendingTools }}
sections={sections}
t={ui.theme}
/>
)}
</>
)
})
export const LiveTodoPanel = memo(function LiveTodoPanel() {
const ui = useStore($uiState)
const todos = useTurnSelector(state => state.todos)
return <TodoPanel t={ui.theme} todos={todos} />
})
interface StreamingAssistantProps {
cols: number
compact?: boolean
detailsMode: DetailsMode
detailsModeCommandOverride: boolean
progress: AppLayoutProgressProps
sections?: SectionVisibility
}

View File

@@ -508,7 +508,8 @@ export function TextInput({
curRef.current = c
vRef.current = next
lineWidthRef.current = nextLineWidth ?? stringWidth(next.includes('\n') ? next.slice(next.lastIndexOf('\n') + 1) : next)
lineWidthRef.current =
nextLineWidth ?? stringWidth(next.includes('\n') ? next.slice(next.lastIndexOf('\n') + 1) : next)
if (next !== prev) {
if (syncParent) {

View File

@@ -0,0 +1,46 @@
import { Box, Text } from '@hermes/ink'
import { memo } from 'react'
import { todoGlyph } from '../lib/todo.js'
import type { Theme } from '../theme.js'
import type { TodoItem } from '../types.js'
export const TodoPanel = memo(function TodoPanel({ t, todos }: { t: Theme; todos: TodoItem[] }) {
if (!todos.length) {
return null
}
return (
<Box flexDirection="column" marginBottom={1}>
<Text color={t.color.dim}>
<Text color={t.color.amber}> </Text>
<Text bold color={t.color.cornsilk}>
Todo
</Text>{' '}
<Text color={t.color.statusFg} dim>
({todos.filter(todo => todo.status === 'completed').length}/{todos.length})
</Text>
</Text>
<Box flexDirection="column" marginLeft={2}>
{todos.map(todo => {
const done = todo.status === 'completed'
const cancel = todo.status === 'cancelled'
const active = todo.status === 'in_progress'
return (
<Text
color={done || cancel ? t.color.dim : active ? t.color.cornsilk : t.color.statusFg}
dim={done || cancel}
key={todo.id}
>
<Text color={active ? t.color.amber : done ? t.color.ok : cancel ? t.color.error : t.color.dim}>
{todoGlyph(todo.status)}{' '}
</Text>
{todo.content}
</Text>
)
})}
</Box>
</Box>
)
})

View File

@@ -1,5 +1,6 @@
export const STREAM_BATCH_MS = 16
export const STREAM_IDLE_BATCH_MS = 16
export const STREAM_SCROLL_BATCH_MS = 96
export const STREAM_TYPING_BATCH_MS = 80
export const TYPING_IDLE_MS = 250
export const REASONING_PULSE_MS = 700

View File

@@ -384,9 +384,21 @@ export type GatewayEvent =
| { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }
| { payload: { name?: string }; session_id?: string; type: 'tool.generating' }
| { payload: { context?: string; name?: string; tool_id: string }; session_id?: string; type: 'tool.start' }
| {
payload: { error?: string; inline_diff?: string; name?: string; summary?: string; tool_id: string }
payload: { context?: string; name?: string; tool_id: string; todos?: unknown[] }
session_id?: string
type: 'tool.start'
}
| {
payload: {
duration_s?: number
error?: string
inline_diff?: string
name?: string
summary?: string
tool_id: string
todos?: unknown[]
}
session_id?: string
type: 'tool.complete'
}

View File

@@ -19,13 +19,15 @@ const FREEZE_RENDERS = 2
export const shouldSetVirtualClamp = ({
itemCount,
liveTailActive = false,
sticky,
viewportHeight
}: {
itemCount: number
liveTailActive?: boolean
sticky: boolean
viewportHeight: number
}) => itemCount > 0 && viewportHeight > 0 && !sticky
}) => itemCount > 0 && viewportHeight > 0 && !sticky && !liveTailActive
const upperBound = (arr: number[], target: number) => {
let lo = 0
@@ -44,7 +46,13 @@ export function useVirtualHistory(
scrollRef: RefObject<ScrollBoxHandle | null>,
items: readonly { key: string }[],
columns: number,
{ estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {}
{
estimate = ESTIMATE,
liveTailActive = false,
overscan = OVERSCAN,
maxMounted = MAX_MOUNTED,
coldStartCount = COLD_START
} = {}
) {
const nodes = useRef(new Map<string, unknown>())
const heights = useRef(new Map<string, number>())
@@ -92,7 +100,7 @@ export function useVirtualHistory(
return NaN
}
const b = Math.floor(s.getScrollTop() / QUANTUM)
const b = Math.floor((s.getScrollTop() + s.getPendingDelta()) / QUANTUM)
return s.isSticky() ? -b - 1 : b
},
@@ -131,8 +139,11 @@ export function useVirtualHistory(
const n = items.length
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 vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0)
const sticky = scrollRef.current?.isSticky() ?? true
const recentManual = Date.now() - (scrollRef.current?.getLastManualScrollAt() ?? 0) < 1200
// During a freeze, drop the frozen range if items shrank past its start
// (/clear, compaction) — clamping would collapse to an empty mount and
@@ -149,9 +160,19 @@ export function useVirtualHistory(
} else if (n > 0) {
if (vp <= 0) {
start = Math.max(0, n - coldStartCount)
} else if (sticky && !recentManual) {
const budget = vp + overscan
start = n
while (start > 0 && total - offsets[start - 1]! < budget) {
start--
}
} else {
start = Math.max(0, Math.min(n - 1, upperBound(offsets, Math.max(0, top - overscan)) - 1))
end = Math.max(start + 1, Math.min(n, upperBound(offsets, top + vp + overscan)))
const lo = Math.max(0, Math.min(top, target) - overscan)
const hi = Math.max(top, target) + vp + overscan
start = Math.max(0, Math.min(n - 1, upperBound(offsets, lo) - 1))
end = Math.max(start + 1, Math.min(n, upperBound(offsets, hi)))
}
}
@@ -183,7 +204,7 @@ export function useVirtualHistory(
// 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.
if (s && shouldSetVirtualClamp({ itemCount: n, sticky, viewportHeight: vp })) {
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)
@@ -235,7 +256,7 @@ export function useVirtualHistory(
if (dirty) {
setVer(v => v + 1)
}
}, [end, hasScrollRef, items, n, offsets, scrollRef, start, sticky, total, vp])
}, [end, hasScrollRef, items, liveTailActive, n, offsets, recentManual, scrollRef, start, sticky, total, vp])
return {
bottomSpacer: Math.max(0, total - (offsets[end] ?? total)),

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { todoGlyph } from './todo.js'
describe('todoGlyph', () => {
it('uses fixed-width ASCII markers so the active row does not render wide or emoji-like', () => {
expect(todoGlyph('completed')).toBe('[x]')
expect(todoGlyph('in_progress')).toBe('[>]')
expect(todoGlyph('pending')).toBe('[ ]')
expect(todoGlyph('cancelled')).toBe('[-]')
})
})

4
ui-tui/src/lib/todo.ts Normal file
View File

@@ -0,0 +1,4 @@
import type { TodoItem } from '../types.js'
export const todoGlyph = (status: TodoItem['status']) =>
status === 'completed' ? '[x]' : status === 'cancelled' ? '[-]' : status === 'in_progress' ? '[>]' : '[ ]'

View File

@@ -5,6 +5,12 @@ export interface ActiveTool {
startedAt?: number
}
export interface TodoItem {
content: string
id: string
status: 'cancelled' | 'completed' | 'in_progress' | 'pending'
}
export interface ActivityItem {
id: number
text: string
@@ -133,8 +139,11 @@ export interface McpServerStatus {
export interface SessionInfo {
cwd?: string
fast?: boolean
mcp_servers?: McpServerStatus[]
model: string
reasoning_effort?: string
service_tier?: string
release_date?: string
skills: Record<string, string[]>
tools: Record<string, string[]>

View File

@@ -57,6 +57,7 @@ declare module '@hermes/ink' {
readonly getScrollHeight: () => number
readonly getViewportHeight: () => number
readonly getViewportTop: () => number
readonly getLastManualScrollAt: () => number
readonly isSticky: () => boolean
readonly subscribe: (listener: () => void) => () => void
readonly setClampBounds: (min: number | undefined, max: number | undefined) => void