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

@ -38,6 +38,7 @@ export type ScrollBoxHandle = {
* padding). Used for drag-to-scroll edge detection.
*/
getViewportTop: () => number
getLastManualScrollAt: () => number
/**
* True when scroll is pinned to the bottom. Set by scrollToBottom, the
* initial stickyScroll attribute, and by the renderer when positional
@ -94,6 +95,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
// forces a React render: sticky is attribute-observed, no DOM-only path.
const [, forceRender] = useState(0)
const listenersRef = useRef(new Set<() => void>())
const manualScrollAtRef = useRef(0)
const renderQueuedRef = useRef(false)
const notify = () => {
@ -130,6 +132,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
}
el.stickyScroll = false
manualScrollAtRef.current = Date.now()
el.scrollAnchor = undefined
el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy)
scrollMutated(el)
@ -148,6 +151,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
// Explicit false overrides the DOM attribute so manual scroll
// breaks stickiness. Render code checks ?? precedence.
el.stickyScroll = false
manualScrollAtRef.current = Date.now()
el.pendingScrollDelta = undefined
el.scrollAnchor = undefined
el.scrollTop = Math.max(0, Math.floor(y))
@ -161,6 +165,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
}
box.stickyScroll = false
manualScrollAtRef.current = Date.now()
box.pendingScrollDelta = undefined
box.scrollAnchor = {
el,
@ -205,6 +210,9 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren<
getViewportTop() {
return domRef.current?.scrollViewportTop ?? 0
},
getLastManualScrollAt() {
return manualScrollAtRef.current
},
isSticky() {
const el = domRef.current

View file

@ -120,11 +120,7 @@ function parseKey(keypress: ParsedKey): [Key, string] {
// through key.return/key.escape, and processedAsSpecialSequence bypasses
// the nonAlphanumericKeys clear below, so clear them explicitly here.
input =
keypress.name === 'space'
? ' '
: keypress.name === 'return' || keypress.name === 'escape'
? ''
: keypress.name
keypress.name === 'space' ? ' ' : keypress.name === 'return' || keypress.name === 'escape' ? '' : keypress.name
}
processedAsSpecialSequence = true
@ -143,11 +139,7 @@ function parseKey(keypress: ParsedKey): [Key, string] {
input = ''
} else {
input =
keypress.name === 'space'
? ' '
: keypress.name === 'return' || keypress.name === 'escape'
? ''
: keypress.name
keypress.name === 'space' ? ' ' : keypress.name === 'return' || keypress.name === 'escape' ? '' : keypress.name
}
processedAsSpecialSequence = true

View file

@ -1328,7 +1328,9 @@ export default class Ink {
}
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
console.error('[clipboard] no path reached the clipboard (headless + no tmux?) — set HERMES_TUI_FORCE_OSC52=1 to force the escape sequence')
console.error(
'[clipboard] no path reached the clipboard (headless + no tmux?) — set HERMES_TUI_FORCE_OSC52=1 to force the escape sequence'
)
}
} catch (err) {
if (process.env.HERMES_TUI_DEBUG_CLIPBOARD) {
@ -1799,6 +1801,7 @@ export default class Ink {
if (this.selectionDragCell?.col === col && this.selectionDragCell.row === row) {
this.updateSelectionAutoScroll(row)
return
}
@ -1822,6 +1825,7 @@ export default class Ink {
private updateSelectionAutoScroll(row: number): void {
if (!this.selection.isDragging || !this.altScreenActive) {
this.stopSelectionAutoScroll()
return
}
@ -1829,6 +1833,7 @@ export default class Ink {
if (dir === 0) {
this.stopSelectionAutoScroll()
return
}
@ -1844,6 +1849,7 @@ export default class Ink {
private stepSelectionAutoScroll(): void {
if (!this.selection.isDragging || !this.altScreenActive || this.selectionAutoScrollDir === 0) {
this.stopSelectionAutoScroll()
return
}
@ -1851,6 +1857,7 @@ export default class Ink {
if (!box) {
this.stopSelectionAutoScroll()
return
}
@ -1889,7 +1896,10 @@ export default class Ink {
}
}
this.applySelectionDrag(this.selectionDragCell?.col ?? 0, this.selectionDragCell?.row ?? (this.selectionAutoScrollDir > 0 ? bottom : top))
this.applySelectionDrag(
this.selectionDragCell?.col ?? 0,
this.selectionDragCell?.row ?? (this.selectionAutoScrollDir > 0 ? bottom : top)
)
}
private stopSelectionAutoScroll(): void {
@ -1908,7 +1918,11 @@ export default class Ink {
while (stack.length) {
const node = stack.shift()!
if (node.style.overflowY === 'scroll' && node.scrollHeight !== undefined && node.scrollViewportHeight !== undefined) {
if (
node.style.overflowY === 'scroll' &&
node.scrollHeight !== undefined &&
node.scrollViewportHeight !== undefined
) {
return node
}

View file

@ -87,7 +87,8 @@ export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env
const override = (
env.HERMES_TUI_FORCE_OSC52 ??
env.HERMES_TUI_CLIPBOARD_OSC52 ??
env.HERMES_TUI_COPY_OSC52 ?? ''
env.HERMES_TUI_COPY_OSC52 ??
''
).trim()
if (ENV_ON_RE.test(override)) {
@ -196,16 +197,19 @@ export async function setClipboard(text: string): Promise<ClipboardResult> {
// forever but SSH_CONNECTION is in tmux's default update-environment and
// clears on local attach. Fire-and-forget, but `copyNativeAttempted`
// tells us whether ANY native path will be tried on this platform.
const nativeAttempted =
!process.env['SSH_CONNECTION'] && copyNative(text)
const nativeAttempted = !process.env['SSH_CONNECTION'] && copyNative(text)
const tmuxBufferLoaded = await tmuxLoadBuffer(text)
// Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling
// too, and BEL works everywhere for OSC 52.
const sequence = tmuxBufferLoaded
? (emitSequence ? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) : '')
: (emitSequence ? raw : '')
? emitSequence
? tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`)
: ''
: emitSequence
? raw
: ''
// Success if any path was taken. Native and tmux are fire-and-forget,
// so we can't truly confirm the clipboard was written — but if native

View file

@ -0,0 +1,112 @@
#!/usr/bin/env node
import inspector from 'node:inspector'
import { performance } from 'node:perf_hooks'
import React from 'react'
import { render } from '@hermes/ink'
import { AppLayout } from '../src/components/appLayout.tsx'
import { resetOverlayState } from '../src/app/overlayStore.ts'
import { resetTurnState } from '../src/app/turnStore.ts'
import { resetUiState } from '../src/app/uiStore.ts'
const session = new inspector.Session()
session.connect()
const post = (method, params = {}) => new Promise((resolve, reject) => {
session.post(method, params, (err, result) => err ? reject(err) : resolve(result))
})
class Sink {
columns = Number(process.env.COLS || 120)
rows = Number(process.env.ROWS || 42)
isTTY = true
bytes = 0
writes = 0
listeners = new Map()
write(chunk) {
const s = String(chunk ?? '')
this.bytes += Buffer.byteLength(s)
this.writes++
return true
}
on(event, fn) { this.listeners.set(event, fn); return this }
off(event) { this.listeners.delete(event); return this }
once(event, fn) { this.listeners.set(event, fn); return this }
removeListener(event) { this.listeners.delete(event); return this }
}
const theme = {
brand: { prompt: '' },
color: {
amber: '#d19a66', bronze: '#8b6f47', dim: '#6b7280', error: '#ff5555', gold: '#ffd166', label: '#61afef',
ok: '#98c379', warn: '#e5c07b', cornsilk: '#fff8dc', prompt: '#c678dd', shellDollar: '#98c379',
statusCritical: '#ff5555', statusBad: '#e06c75', statusWarn: '#e5c07b', statusGood: '#98c379',
selectionBg: '#44475a'
}
}
const noop = () => {}
const makeMsg = i => ({ role: i % 5 === 0 ? 'user' : 'assistant', text: `message ${i}\n${'lorem ipsum '.repeat(80)}` })
const historyItems = [{ kind: 'intro', role: 'system', text: '', info: { model: 'test', tools: {}, skills: {}, version: 'test' } }, ...Array.from({ length: Number(process.env.HISTORY || 500) }, (_, i) => makeMsg(i))]
const mkRows = items => items.map((msg, index) => ({ index, key: `m${index}`, msg }))
const scrollRef = { current: {
getScrollTop: () => 0,
getPendingDelta: () => 0,
getScrollHeight: () => Number(process.env.HISTORY || 500) * 4,
getViewportHeight: () => 30,
getViewportTop: () => 0,
isSticky: () => true,
subscribe: () => () => {},
scrollBy: noop,
scrollTo: noop,
scrollToBottom: noop,
setClampBounds: noop,
getLastManualScrollAt: () => 0
} }
const baseProps = streamingText => ({
actions: { answerApproval: noop, answerClarify: noop, answerSecret: noop, answerSudo: noop, onModelSelect: noop, resumeById: noop, setStickyPrompt: noop },
composer: { cols: 120, compIdx: 0, completions: [], empty: false, handleTextPaste: () => null, input: '', inputBuf: [], pagerPageSize: 10, queueEditIdx: null, queuedDisplay: [], submit: noop, updateInput: noop },
mouseTracking: false,
progress: {
activity: [], outcome: '', reasoning: streamingText, reasoningActive: true, reasoningStreaming: true,
reasoningTokens: Math.ceil(streamingText.length / 4), showProgressArea: true, showStreamingArea: true,
streamPendingTools: [], streamSegments: [], streaming: streamingText, subagents: [], toolTokens: 0, tools: [], turnTrail: [], todos: []
},
status: { cwdLabel: '~/repo', goodVibesTick: 0, sessionStartedAt: Date.now(), showStickyPrompt: false, statusColor: theme.color.ok, stickyPrompt: '', turnStartedAt: Date.now(), voiceLabel: 'voice off' },
transcript: {
historyItems,
scrollRef,
virtualHistory: { bottomSpacer: 0, end: historyItems.length, measureRef: () => noop, offsets: historyItems.map((_, i) => i * 4), start: Math.max(0, historyItems.length - Number(process.env.MOUNTED || 120)), topSpacer: 0 },
virtualRows: mkRows(historyItems)
}
})
async function main() {
resetUiState(); resetTurnState(); resetOverlayState()
const stdout = new Sink()
const stdin = { isTTY: true, setRawMode: noop, on: noop, off: noop, resume: noop, pause: noop }
const text = Array.from({ length: Number(process.env.LINES || 1200) }, (_, i) => `stream line ${i} ${'x'.repeat(90)}`).join('\n')
const inst = render(React.createElement(AppLayout, baseProps('')), { stdout, stdin, stderr: stdout, debug: false, exitOnCtrlC: false })
await post('Profiler.enable')
await post('HeapProfiler.enable')
await post('Profiler.start')
const startMem = process.memoryUsage()
const t0 = performance.now()
const iterations = Number(process.env.ITERS || 40)
for (let i = 1; i <= iterations; i++) {
const prefix = text.slice(0, Math.floor(text.length * i / iterations))
inst.rerender(React.createElement(AppLayout, baseProps(prefix)))
await new Promise(r => setImmediate(r))
}
const elapsed = performance.now() - t0
const prof = await post('Profiler.stop')
const endMem = process.memoryUsage()
await post('HeapProfiler.collectGarbage')
const afterGc = process.memoryUsage()
inst.unmount()
session.disconnect()
console.log(JSON.stringify({ elapsedMs: Math.round(elapsed), stdoutBytes: stdout.bytes, stdoutWrites: stdout.writes, startMem, endMem, afterGc, profileNodes: prof.profile.nodes.length }, null, 2))
}
main().catch(err => { console.error(err); process.exit(1) })

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