diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx index 38f04b4faa..c475773c1d 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx @@ -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 diff --git a/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts index a3cd3fabec..6e80070e76 100644 --- a/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts +++ b/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts @@ -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 diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 9db3980490..71e3066a47 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -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 } diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts index c60196b8c1..fb683794ff 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -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 { // 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 diff --git a/ui-tui/scripts/profile-tui.mjs b/ui-tui/scripts/profile-tui.mjs new file mode 100644 index 0000000000..7093ef9f49 --- /dev/null +++ b/ui-tui/scripts/profile-tui.mjs @@ -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) }) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 27c49b0d4f..ad4a8f8e46 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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) diff --git a/ui-tui/src/__tests__/scroll.test.ts b/ui-tui/src/__tests__/scroll.test.ts index 22f5d3f125..652cca0973 100644 --- a/ui-tui/src/__tests__/scroll.test.ts +++ b/ui-tui/src/__tests__/scroll.test.ts @@ -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), diff --git a/ui-tui/src/__tests__/turnStore.test.ts b/ui-tui/src/__tests__/turnStore.test.ts new file mode 100644 index 0000000000..13cd0f64b6 --- /dev/null +++ b/ui-tui/src/__tests__/turnStore.test.ts @@ -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') + }) +}) diff --git a/ui-tui/src/__tests__/virtualHistoryClamp.test.ts b/ui-tui/src/__tests__/virtualHistoryClamp.test.ts index 255fad7cb9..d14f308d8f 100644 --- a/ui-tui/src/__tests__/virtualHistoryClamp.test.ts +++ b/ui-tui/src/__tests__/virtualHistoryClamp.test.ts @@ -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 + ) + }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 699a8138b9..267bf8c166 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index f221539184..1904277c98 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -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 { diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index ecc080ca13..4c14fde4f1 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -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' + ) } } diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts index 148a50c196..f6d40bd3b6 100644 --- a/ui-tui/src/app/turnStore.ts +++ b/ui-tui/src/app/turnStore.ts @@ -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(buildTurnState()) export const getTurnState = () => $turnState.get() +const subscribeTurn = (cb: () => void) => $turnState.listen(() => cb()) + +export const useTurnSelector = (selector: (state: TurnState) => T): T => + useSyncExternalStore( + subscribeTurn, + () => selector($turnState.get()), + () => selector($turnState.get()) + ) + export const patchTurnState = (next: Partial | ((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[] diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index d2b8bf2717..fff73d9cfa 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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 | 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) { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 6e07f8f8c1..262b400fa3 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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 diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 046b2316b7..6d9c774087 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -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 diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index f03e0f5ae6..17ba966d8c 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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({ ) : ( {status} )} - │ {model} + │ {modelLabel(model, modelReasoningEffort, modelFast)} {ctxLabel ? │ {ctxLabel} : null} {bar ? ( @@ -337,6 +360,8 @@ interface StatusRuleProps { cols: number cwdLabel: string model: string + modelFast?: boolean + modelReasoningEffort?: string sessionStartedAt?: null | number showCost: boolean status: string diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 744f6e73c9..fe370700dd 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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) => ( - - ))} - - {progress.showStreamingArea && ( - - )} - - {!progress.showStreamingArea && !!progress.streamPendingTools.length && ( - - )} - - ) -}) - const TranscriptPane = memo(function TranscriptPane({ actions, composer, @@ -120,15 +58,15 @@ const TranscriptPane = memo(function TranscriptPane({ {transcript.virtualHistory.bottomSpacer > 0 ? : null} + + @@ -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({ ) }) - -interface StreamingAssistantProps { - busy: boolean - cols: number - compact?: boolean - detailsMode: DetailsMode - detailsModeCommandOverride: boolean - progress: AppLayoutProgressProps - sections?: SectionVisibility - t: Theme -} diff --git a/ui-tui/src/components/streamingAssistant.tsx b/ui-tui/src/components/streamingAssistant.tsx new file mode 100644 index 0000000000..b027998690 --- /dev/null +++ b/ui-tui/src/components/streamingAssistant.tsx @@ -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((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) => ( + + ))} + + {!!activeTools.length && ( + + )} + + {showStreamingArea && ( + + )} + + {!showStreamingArea && !!streamPendingTools.length && ( + + )} + + ) +}) + +export const LiveTodoPanel = memo(function LiveTodoPanel() { + const ui = useStore($uiState) + const todos = useTurnSelector(state => state.todos) + + return +}) + +interface StreamingAssistantProps { + cols: number + compact?: boolean + detailsMode: DetailsMode + detailsModeCommandOverride: boolean + progress: AppLayoutProgressProps + sections?: SectionVisibility +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 984d217854..3b916d3d8d 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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) { diff --git a/ui-tui/src/components/todoPanel.tsx b/ui-tui/src/components/todoPanel.tsx new file mode 100644 index 0000000000..48904fafab --- /dev/null +++ b/ui-tui/src/components/todoPanel.tsx @@ -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 ( + + + + + Todo + {' '} + + ({todos.filter(todo => todo.status === 'completed').length}/{todos.length}) + + + + {todos.map(todo => { + const done = todo.status === 'completed' + const cancel = todo.status === 'cancelled' + const active = todo.status === 'in_progress' + + return ( + + + {todoGlyph(todo.status)}{' '} + + {todo.content} + + ) + })} + + + ) +}) diff --git a/ui-tui/src/config/timing.ts b/ui-tui/src/config/timing.ts index e0bd611b82..e1811e830d 100644 --- a/ui-tui/src/config/timing.ts +++ b/ui-tui/src/config/timing.ts @@ -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 diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ce056040c2..335c172d90 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -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' } diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 17c93a7565..0d98ca5ec3 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -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, 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()) const heights = useRef(new Map()) @@ -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)), diff --git a/ui-tui/src/lib/todo.test.ts b/ui-tui/src/lib/todo.test.ts new file mode 100644 index 0000000000..38d95c9e02 --- /dev/null +++ b/ui-tui/src/lib/todo.test.ts @@ -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('[-]') + }) +}) diff --git a/ui-tui/src/lib/todo.ts b/ui-tui/src/lib/todo.ts new file mode 100644 index 0000000000..b6dc48968c --- /dev/null +++ b/ui-tui/src/lib/todo.ts @@ -0,0 +1,4 @@ +import type { TodoItem } from '../types.js' + +export const todoGlyph = (status: TodoItem['status']) => + status === 'completed' ? '[x]' : status === 'cancelled' ? '[-]' : status === 'in_progress' ? '[>]' : '[ ]' diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 3fdb39b82d..89c83856d1 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -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 tools: Record diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 497bf54b73..c878bdb4ea 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -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