fix(tui): stabilize live progress rendering
This commit is contained in:
parent
d4dde6b5f2
commit
a7831b63db
28 changed files with 619 additions and 154 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
112
ui-tui/scripts/profile-tui.mjs
Normal file
112
ui-tui/scripts/profile-tui.mjs
Normal 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) })
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
27
ui-tui/src/__tests__/turnStore.test.ts
Normal file
27
ui-tui/src/__tests__/turnStore.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
119
ui-tui/src/components/streamingAssistant.tsx
Normal file
119
ui-tui/src/components/streamingAssistant.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
46
ui-tui/src/components/todoPanel.tsx
Normal file
46
ui-tui/src/components/todoPanel.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
12
ui-tui/src/lib/todo.test.ts
Normal file
12
ui-tui/src/lib/todo.test.ts
Normal 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
4
ui-tui/src/lib/todo.ts
Normal 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' ? '[>]' : '[ ]'
|
||||
|
|
@ -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[]>
|
||||
|
|
|
|||
1
ui-tui/src/types/hermes-ink.d.ts
vendored
1
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue