From afb20a1d67d2f64876e488d36be14b3a6f2c8eec Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Apr 2026 22:06:27 -0500 Subject: [PATCH 1/5] fix(tui): recover from stuck paste mode Prevent unterminated bracketed paste input from swallowing future keystrokes, and avoid rendering an empty Thinking panel before reasoning arrives. --- .../hermes-ink/src/ink/components/App.tsx | 24 ++++++----- .../hermes-ink/src/ink/parse-keypress.test.ts | 41 +++++++++++++++++++ .../hermes-ink/src/ink/parse-keypress.ts | 11 +++-- ui-tui/src/components/thinking.tsx | 4 +- 4 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index e5a13bdb680ef..1d238b40f71fb 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -1,4 +1,4 @@ -import React, { PureComponent, type ReactNode } from 'react' +import { PureComponent, type ReactNode } from 'react' import { updateLastInteractionTime } from '../../bootstrap/state.js' import { logForDebugging } from '../../utils/debug.js' @@ -11,13 +11,13 @@ import { InputEvent } from '../events/input-event.js' import { TerminalFocusEvent } from '../events/terminal-focus-event.js' import { INITIAL_STATE, + parseMultipleKeypresses, type ParsedInput, type ParsedKey, - type ParsedMouse, - parseMultipleKeypresses + type ParsedMouse } from '../parse-keypress.js' import reconciler from '../reconciler.js' -import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js' +import { finishSelection, hasSelection, startSelection, type SelectionState } from '../selection.js' import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js' import { TerminalQuerier, xtversion } from '../terminal-querier.js' import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js' @@ -316,8 +316,10 @@ export default class App extends PureComponent { // Clear the timer reference this.incompleteEscapeTimer = null - // Only proceed if we have incomplete sequences - if (!this.keyParseState.incomplete) { + // Only proceed if we have an incomplete escape sequence or an unterminated + // bracketed paste. Missing paste-end markers otherwise leave every later + // keystroke trapped in the paste buffer. + if (!this.keyParseState.incomplete && this.keyParseState.mode !== 'IN_PASTE') { return } @@ -335,8 +337,8 @@ export default class App extends PureComponent { return } - // Process incomplete as a flush operation (input=null) - // This reuses all existing parsing logic + // Process incomplete/paste state as a flush operation (input=null). + // This reuses all existing parsing logic. this.processInput(null) } @@ -355,8 +357,10 @@ export default class App extends PureComponent { reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined) } - // If we have incomplete escape sequences, set a timer to flush them - if (this.keyParseState.incomplete) { + // If we have incomplete escape sequences or an unterminated paste, set a + // timer to flush/reset them. Paste starts are complete CSI sequences, so + // checking only `incomplete` would never arm the watchdog. + if (this.keyParseState.incomplete || this.keyParseState.mode === 'IN_PASTE') { // Cancel any existing timer first if (this.incompleteEscapeTimer) { clearTimeout(this.incompleteEscapeTimer) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts new file mode 100644 index 0000000000000..58745b8c40842 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' + +import { INITIAL_STATE, parseMultipleKeypresses } from './parse-keypress.js' +import { PASTE_END, PASTE_START } from './termio/csi.js' + +describe('parseMultipleKeypresses bracketed paste recovery', () => { + it('emits empty bracketed pastes when the terminal sends both markers', () => { + const [keys, state] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START + PASTE_END) + + expect(keys).toHaveLength(1) + expect(keys[0]).toMatchObject({ isPasted: true, raw: '' }) + expect(state.mode).toBe('NORMAL') + }) + + it('flushes unterminated paste content back to normal input mode', () => { + const [pendingKeys, pendingState] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START + 'hello') + + expect(pendingKeys).toEqual([]) + expect(pendingState.mode).toBe('IN_PASTE') + + const [keys, state] = parseMultipleKeypresses(pendingState, null) + + expect(keys).toHaveLength(1) + expect(keys[0]).toMatchObject({ isPasted: true, raw: 'hello' }) + expect(state.mode).toBe('NORMAL') + expect(state.pasteBuffer).toBe('') + }) + + it('resets an empty unterminated paste start instead of staying stuck', () => { + const [pendingKeys, pendingState] = parseMultipleKeypresses(INITIAL_STATE, PASTE_START) + + expect(pendingKeys).toEqual([]) + expect(pendingState.mode).toBe('IN_PASTE') + + const [keys, state] = parseMultipleKeypresses(pendingState, null) + + expect(keys).toEqual([]) + expect(state.mode).toBe('NORMAL') + expect(state.pasteBuffer).toBe('') + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts index ca77058d6658e..56976d8a8482b 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -288,9 +288,14 @@ export function parseMultipleKeypresses( } } - // If flushing and still in paste mode, emit what we have - if (isFlush && inPaste && pasteBuffer) { - keys.push(createPasteKey(pasteBuffer)) + // If a terminal drops the paste-end marker, the App watchdog flushes the + // partial paste and returns to normal input instead of swallowing all future + // keystrokes as paste content. + if (isFlush && inPaste) { + if (pasteBuffer) { + keys.push(createPasteKey(pasteBuffer)) + } + inPaste = false pasteBuffer = '' } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 38232220a5d2a..eb758587415f6 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,5 +1,5 @@ import { Box, NoSelect, Text } from '@hermes/ink' -import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' +import { memo, useEffect, useMemo, useState, type ReactNode } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { THINKING_COT_MAX } from '../config/limits.js' @@ -873,7 +873,7 @@ export const ToolTrail = memo(function ToolTrail({ const hasTools = groups.length > 0 const hasSubagents = subagents.length > 0 const hasMeta = meta.length > 0 - const hasThinking = !!cot || reasoningActive || busy + const hasThinking = !!cot || reasoningActive || reasoningStreaming const thinkingLive = reasoningActive || reasoningStreaming const tokenCount = From ce2cc7302e896b1f4657c91ff6b13783cce594f1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Apr 2026 22:10:40 -0500 Subject: [PATCH 2/5] fix(tui): stabilize sticky prompt tracking Keep the latest prompt sticky while the viewport is in live assistant output beyond history, and clear stale sticky state at the real bottom using fresh scroll height. --- ui-tui/src/__tests__/viewport.test.ts | 27 ++++++++++++++++++++++ ui-tui/src/__tests__/viewportStore.test.ts | 16 +++++++++++++ ui-tui/src/domain/viewport.ts | 14 +++++++---- ui-tui/src/lib/viewportStore.ts | 11 +++++++-- ui-tui/src/types/hermes-ink.d.ts | 1 + 5 files changed, 62 insertions(+), 7 deletions(-) diff --git a/ui-tui/src/__tests__/viewport.test.ts b/ui-tui/src/__tests__/viewport.test.ts index d8500c8d205df..eca079470d91b 100644 --- a/ui-tui/src/__tests__/viewport.test.ts +++ b/ui-tui/src/__tests__/viewport.test.ts @@ -28,4 +28,31 @@ describe('stickyPromptFromViewport', () => { expect(stickyPromptFromViewport(messages, offsets, 16, 20, false)).toBe('current prompt') }) + + it('shows the last prompt once the viewport starts after the history tail', () => { + const messages = [ + { role: 'user' as const, text: 'current prompt' }, + { role: 'assistant' as const, text: 'completed answer' } + ] + + expect(stickyPromptFromViewport(messages, [0, 2, 5], 8, 14, false)).toBe('current prompt') + }) + + it('shows a prompt as soon as its full row is above the viewport', () => { + const messages = [ + { role: 'user' as const, text: 'current prompt' }, + { role: 'assistant' as const, text: 'current answer' } + ] + + expect(stickyPromptFromViewport(messages, [0, 2, 10], 2, 8, false)).toBe('current prompt') + }) + + it('hides the sticky prompt at the bottom', () => { + const messages = [ + { role: 'user' as const, text: 'current prompt' }, + { role: 'assistant' as const, text: 'current answer' } + ] + + expect(stickyPromptFromViewport(messages, [0, 2, 10], 8, 10, true)).toBe('') + }) }) diff --git a/ui-tui/src/__tests__/viewportStore.test.ts b/ui-tui/src/__tests__/viewportStore.test.ts index 16031c9672e30..7889b65cdea18 100644 --- a/ui-tui/src/__tests__/viewportStore.test.ts +++ b/ui-tui/src/__tests__/viewportStore.test.ts @@ -35,4 +35,20 @@ describe('viewportStore', () => { }) expect(viewportSnapshotKey(snap)).toBe('0:16:5:40:3') }) + + it('uses fresh scroll height to clear stale non-bottom state', () => { + const handle = { + getFreshScrollHeight: () => 20, + getPendingDelta: () => 0, + getScrollHeight: () => 40, + getScrollTop: () => 15, + getViewportHeight: () => 5, + isSticky: () => false + } + + const snap = getViewportSnapshot(handle as any) + + expect(snap.atBottom).toBe(true) + expect(snap.scrollHeight).toBe(20) + }) }) diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts index 48d7427fd13ee..4fdbfcc930742 100644 --- a/ui-tui/src/domain/viewport.ts +++ b/ui-tui/src/domain/viewport.ts @@ -26,21 +26,25 @@ export const stickyPromptFromViewport = ( return '' } - const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) - const last = Math.max(first, Math.min(messages.length - 1, upperBound(offsets, bottom) - 1)) + const first = Math.max(0, upperBound(offsets, top) - 1) + const last = Math.max(first, upperBound(offsets, bottom) - 1) + const visibleStart = Math.min(messages.length, first) + const visibleEnd = Math.min(messages.length - 1, last) - for (let i = first; i <= last; i++) { + for (let i = visibleStart; i <= visibleEnd; i++) { if (messages[i]?.role === 'user') { return '' } } - for (let i = first - 1; i >= 0; i--) { + for (let i = Math.min(messages.length - 1, visibleStart - 1); i >= 0; i--) { if (messages[i]?.role !== 'user') { continue } - return (offsets[i] ?? 0) + 1 < top ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : '' + return (offsets[i + 1] ?? (offsets[i] ?? 0) + 1) <= top + ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() + : '' } return '' diff --git a/ui-tui/src/lib/viewportStore.ts b/ui-tui/src/lib/viewportStore.ts index 0281e059b9ca5..b25ef581f47a1 100644 --- a/ui-tui/src/lib/viewportStore.ts +++ b/ui-tui/src/lib/viewportStore.ts @@ -28,11 +28,18 @@ export function getViewportSnapshot(s?: ScrollBoxHandle | null): ViewportSnapsho const pending = s.getPendingDelta() const top = Math.max(0, s.getScrollTop() + pending) const viewportHeight = Math.max(0, s.getViewportHeight()) - const scrollHeight = Math.max(viewportHeight, s.getScrollHeight()) + const cachedScrollHeight = Math.max(viewportHeight, s.getScrollHeight()) + let scrollHeight = cachedScrollHeight const bottom = top + viewportHeight + let atBottom = s.isSticky() || bottom >= scrollHeight - 2 + + if (!atBottom) { + scrollHeight = Math.max(viewportHeight, s.getFreshScrollHeight?.() ?? cachedScrollHeight) + atBottom = s.isSticky() || bottom >= scrollHeight - 2 + } return { - atBottom: s.isSticky() || bottom >= scrollHeight - 2, + atBottom, bottom, pending, scrollHeight, diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 4a0bd75f1cb2e..c8038576d3a84 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -83,6 +83,7 @@ declare module '@hermes/ink' { readonly getScrollTop: () => number readonly getPendingDelta: () => number readonly getScrollHeight: () => number + readonly getFreshScrollHeight: () => number readonly getViewportHeight: () => number readonly getViewportTop: () => number readonly getLastManualScrollAt: () => number From d7ae8dfd0adb8dfbcdba97f8e15a62d6223280ae Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Apr 2026 22:15:57 -0500 Subject: [PATCH 3/5] style(tui): remove steer queued emoji Keep the /steer acknowledgement plain text so it reads like the rest of the TUI status copy. --- ui-tui/src/app/slash/commands/core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 2cad70b9a5d9d..1b2936636176a 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -503,7 +503,7 @@ export const coreCommands: SlashCommand[] = [ ctx.guarded(r => { if (r?.status === 'queued') { ctx.transcript.sys( - `⏩ steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"` + `steer queued — arrives after next tool call: "${payload.slice(0, 50)}${payload.length > 50 ? '…' : ''}"` ) } else { ctx.transcript.sys('steer rejected') From f542d17b0040cf28e388a3e935d36e14de9801a0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Apr 2026 22:18:26 -0500 Subject: [PATCH 4/5] style(tui): apply npm run fix Run the TUI lint autofix and formatter on the PR branch after the sticky prompt and paste recovery changes. --- ui-tui/packages/hermes-ink/index.d.ts | 2 +- .../packages/hermes-ink/src/entry-exports.ts | 2 +- .../hermes-ink/src/ink/components/App.tsx | 6 ++--- ui-tui/packages/hermes-ink/src/ink/root.ts | 2 ++ .../createGatewayEventHandler.test.ts | 4 +-- ui-tui/src/__tests__/terminalSetup.test.ts | 8 ++++++ ui-tui/src/__tests__/theme.test.ts | 8 +++--- ui-tui/src/app/createGatewayEventHandler.ts | 1 + ui-tui/src/app/slash/commands/session.ts | 26 +++++++++---------- ui-tui/src/app/useConfigSync.ts | 4 +-- ui-tui/src/app/useInputHandlers.ts | 2 ++ ui-tui/src/app/useSubmission.ts | 14 +++------- ui-tui/src/components/agentsOverlay.tsx | 4 +-- ui-tui/src/components/appChrome.tsx | 2 +- ui-tui/src/components/appLayout.tsx | 7 ++++- ui-tui/src/components/sessionPicker.tsx | 7 ++++- ui-tui/src/components/thinking.tsx | 2 +- ui-tui/src/lib/forceTruecolor.ts | 7 ++--- ui-tui/src/lib/terminalSetup.ts | 1 + ui-tui/src/theme.ts | 3 ++- 20 files changed, 60 insertions(+), 52 deletions(-) diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts index 92aeb0a4f2de5..637c4bb43b6fe 100644 --- a/ui-tui/packages/hermes-ink/index.d.ts +++ b/ui-tui/packages/hermes-ink/index.d.ts @@ -30,7 +30,7 @@ export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts' export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts' export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts' export { default as measureElement } from './src/ink/measure-element.ts' -export { createRoot, default as render, forceRedraw, renderSync } from './src/ink/root.ts' +export { createRoot, forceRedraw, default as render, renderSync } from './src/ink/root.ts' export type { Instance, RenderOptions, Root } from './src/ink/root.ts' export { stringWidth } from './src/ink/stringWidth.ts' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 6bfb0e4955e6b..355faa16f97e0 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -23,7 +23,7 @@ export { useTerminalTitle } from './ink/hooks/use-terminal-title.js' export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js' export { default as measureElement } from './ink/measure-element.js' export { scrollFastPathStats, type ScrollFastPathStats } from './ink/render-node-to-output.js' -export { createRoot, default as render, forceRedraw, renderSync } from './ink/root.js' +export { createRoot, forceRedraw, default as render, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' export { isXtermJs } from './ink/terminal.js' export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index 1d238b40f71fb..a8984e13caf90 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -11,13 +11,13 @@ import { InputEvent } from '../events/input-event.js' import { TerminalFocusEvent } from '../events/terminal-focus-event.js' import { INITIAL_STATE, - parseMultipleKeypresses, type ParsedInput, type ParsedKey, - type ParsedMouse + type ParsedMouse, + parseMultipleKeypresses } from '../parse-keypress.js' import reconciler from '../reconciler.js' -import { finishSelection, hasSelection, startSelection, type SelectionState } from '../selection.js' +import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js' import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js' import { TerminalQuerier, xtversion } from '../terminal-querier.js' import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js' diff --git a/ui-tui/packages/hermes-ink/src/ink/root.ts b/ui-tui/packages/hermes-ink/src/ink/root.ts index fdfb97a6e7d70..1d7af3803b4f0 100644 --- a/ui-tui/packages/hermes-ink/src/ink/root.ts +++ b/ui-tui/packages/hermes-ink/src/ink/root.ts @@ -75,11 +75,13 @@ export type Root = { export const forceRedraw = (stdout: NodeJS.WriteStream = process.stdout): boolean => { const instance = instances.get(stdout) + if (!instance) { return false } instance.forceRedraw() + return true } diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index f2326dc5ac697..441caf6076928 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -714,9 +714,7 @@ describe('createGatewayEventHandler', () => { } as any) // Pre-interrupt todos should land in turn state. - expect(getTurnState().todos).toEqual([ - { content: 'pre-interrupt', id: 'todo-1', status: 'pending' } - ]) + expect(getTurnState().todos).toEqual([{ content: 'pre-interrupt', id: 'todo-1', status: 'pending' }]) turnController.interruptTurn({ appendMessage: (msg: Msg) => appended.push(msg), diff --git a/ui-tui/src/__tests__/terminalSetup.test.ts b/ui-tui/src/__tests__/terminalSetup.test.ts index 5508e94ce1386..59e725e98877b 100644 --- a/ui-tui/src/__tests__/terminalSetup.test.ts +++ b/ui-tui/src/__tests__/terminalSetup.test.ts @@ -141,6 +141,7 @@ describe('configureTerminalKeybindings', () => { // it overlaps any context, including our terminal scope. We must NOT // silently add a terminal-scoped cmd+c that would shadow it. const mkdir = vi.fn().mockResolvedValue(undefined) + const readFile = vi.fn().mockResolvedValue( JSON.stringify([ { @@ -149,6 +150,7 @@ describe('configureTerminalKeybindings', () => { } ]) ) + const writeFile = vi.fn().mockResolvedValue(undefined) const copyFile = vi.fn().mockResolvedValue(undefined) @@ -170,6 +172,7 @@ describe('configureTerminalKeybindings', () => { // would shadow ours. Treat as a conflict even though the strings // aren't identical. const mkdir = vi.fn().mockResolvedValue(undefined) + const readFile = vi.fn().mockResolvedValue( JSON.stringify([ { @@ -179,6 +182,7 @@ describe('configureTerminalKeybindings', () => { } ]) ) + const writeFile = vi.fn().mockResolvedValue(undefined) const copyFile = vi.fn().mockResolvedValue(undefined) @@ -198,6 +202,7 @@ describe('configureTerminalKeybindings', () => { // logically disjoint from our copy-forwarding binding, which requires // terminalTextSelected. const mkdir = vi.fn().mockResolvedValue(undefined) + const readFile = vi.fn().mockResolvedValue( JSON.stringify([ { @@ -208,6 +213,7 @@ describe('configureTerminalKeybindings', () => { } ]) ) + const writeFile = vi.fn().mockResolvedValue(undefined) const copyFile = vi.fn().mockResolvedValue(undefined) @@ -226,6 +232,7 @@ describe('configureTerminalKeybindings', () => { // clauses don't overlap. A user's pre-existing cmd+c binding scoped to // editor focus should NOT block our terminal-scoped cmd+c binding. const mkdir = vi.fn().mockResolvedValue(undefined) + const readFile = vi.fn().mockResolvedValue( JSON.stringify([ { @@ -235,6 +242,7 @@ describe('configureTerminalKeybindings', () => { } ]) ) + const writeFile = vi.fn().mockResolvedValue(undefined) const copyFile = vi.fn().mockResolvedValue(undefined) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index e3e3cea889778..888bd9142ae01 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -16,14 +16,16 @@ const RELEVANT_ENV = [ 'HERMES_TUI_THEME', 'HERMES_TUI_BACKGROUND', 'COLORFGBG', - 'TERM_PROGRAM', + 'TERM_PROGRAM' ] as const async function importThemeWithCleanEnv() { for (const key of RELEVANT_ENV) { vi.stubEnv(key, '') } + vi.resetModules() + return import('../theme.js') } @@ -165,9 +167,7 @@ describe('detectLightMode', () => { expect(detectLightMode({ TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true) // Dark COLORFGBG must beat the allow-list. - expect( - detectLightMode({ COLORFGBG: '15;0', TERM_PROGRAM: 'Apple_Terminal' }, allowList), - ).toBe(false) + expect(detectLightMode({ COLORFGBG: '15;0', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(false) }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 3abfc185616f1..8018623631fe3 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -373,6 +373,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // 120-char clip used for `gateway.stderr` activity entries. const STDERR_LINE_CAP = 120 const STDERR_LINES_MAX = 8 + const tailLines = (stderrTail ?? '') .split('\n') .map(l => l.trim()) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 5acd417de2554..ecd1b7866f6a7 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -290,21 +290,19 @@ export const sessionCommands: SlashCommand[] = [ return ctx.transcript.sys(`usage: /indicator [${INDICATOR_STYLES.join('|')}]`) } - ctx.gateway - .rpc('config.set', { key: 'indicator', value }) - .then( - ctx.guarded(r => { - if (!r.value) { - return - } + ctx.gateway.rpc('config.set', { key: 'indicator', value }).then( + ctx.guarded(r => { + if (!r.value) { + return + } - // Hot-swap the running TUI immediately so the next render - // uses the new style without waiting for the 5s mtime poll - // to re-apply config.full. - patchUiState({ indicatorStyle: value as IndicatorStyle }) - ctx.transcript.sys(`indicator → ${r.value}`) - }) - ) + // Hot-swap the running TUI immediately so the next render + // uses the new style without waiting for the 5s mtime poll + // to re-apply config.full. + patchUiState({ indicatorStyle: value as IndicatorStyle }) + ctx.transcript.sys(`indicator → ${r.value}`) + }) + ) } }, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 1b9d930b53b5c..8695855759e21 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -11,11 +11,11 @@ import type { import { asRpcResult } from '../lib/rpc.js' import { + type BusyInputMode, DEFAULT_INDICATOR_STYLE, INDICATOR_STYLES, - type BusyInputMode, type IndicatorStyle, - type StatusBarMode, + type StatusBarMode } from './interfaces.js' import { turnController } from './turnController.js' import { patchUiState } from './uiStore.js' diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 84978b98c40d0..6fdd04ea6144a 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -366,6 +366,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (isCtrl(key, ch, 'x') && cState.queueEditIdx !== null) { cActions.removeQueue(cState.queueEditIdx) + return cActions.clearIn() } @@ -393,6 +394,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (isAction(key, ch, 'l')) { clearSelection() forceRedraw(terminal.stdout ?? process.stdout) + return } diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 2c2c6d48d93f5..df6acfadbe86c 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -227,6 +227,7 @@ export function useSubmission(opts: UseSubmissionOptions) { (full: string, opts: { fallbackToFront?: boolean } = {}) => { const live = getUiState() const mode = live.busyInputMode + const fallback = (note: string) => { if (opts.fallbackToFront) { composerRefs.queueRef.current.unshift(full) @@ -234,6 +235,7 @@ export function useSubmission(opts: UseSubmissionOptions) { } else { composerActions.enqueue(full) } + sys(note) } @@ -350,17 +352,7 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [ - appendMessage, - composerActions, - composerRefs, - handleBusyInput, - interpolate, - send, - sendQueued, - shellExec, - slashRef - ] + [appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef] ) const submit = useCallback( diff --git a/ui-tui/src/components/agentsOverlay.tsx b/ui-tui/src/components/agentsOverlay.tsx index a3a075afb9915..a1b349827ccc3 100644 --- a/ui-tui/src/components/agentsOverlay.tsx +++ b/ui-tui/src/components/agentsOverlay.tsx @@ -671,9 +671,7 @@ function DiffView({ {diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)} - - {diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)} - + {diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)} {diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)} diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index e85a0fb718f48..cf8328bc8f96a 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -5,8 +5,8 @@ import unicodeSpinners from 'unicode-animations' import { $delegationState } from '../app/delegationStore.js' import type { IndicatorStyle } from '../app/interfaces.js' -import { $uiState } from '../app/uiStore.js' import { useTurnSelector } from '../app/turnStore.js' +import { $uiState } from '../app/uiStore.js' import { FACES } from '../content/faces.js' import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 84470c4ccf2ef..16d96f390b246 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -224,7 +224,12 @@ const ComposerPane = memo(function ComposerPane({ ))} - + {sh ? ( {promptLabel} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index c5696a068aea7..fd29d9e7ecfc7 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -133,7 +133,12 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) - + {s.title || s.preview || '(untitled)'} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index eb758587415f6..4204ff56a0fa4 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,5 +1,5 @@ import { Box, NoSelect, Text } from '@hermes/ink' -import { memo, useEffect, useMemo, useState, type ReactNode } from 'react' +import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { THINKING_COT_MAX } from '../config/limits.js' diff --git a/ui-tui/src/lib/forceTruecolor.ts b/ui-tui/src/lib/forceTruecolor.ts index 78ff5bd719b1b..3e99b6b18495e 100644 --- a/ui-tui/src/lib/forceTruecolor.ts +++ b/ui-tui/src/lib/forceTruecolor.ts @@ -21,14 +21,11 @@ * no effect. */ -if ( - process.env.HERMES_TUI_TRUECOLOR !== '0' && - !process.env.NO_COLOR && - !process.env.FORCE_COLOR -) { +if (process.env.HERMES_TUI_TRUECOLOR !== '0' && !process.env.NO_COLOR && !process.env.FORCE_COLOR) { if (!process.env.COLORTERM) { process.env.COLORTERM = 'truecolor' } + process.env.FORCE_COLOR = '3' } diff --git a/ui-tui/src/lib/terminalSetup.ts b/ui-tui/src/lib/terminalSetup.ts index dc00512b6b45e..7d387797d0406 100644 --- a/ui-tui/src/lib/terminalSetup.ts +++ b/ui-tui/src/lib/terminalSetup.ts @@ -336,6 +336,7 @@ export async function configureTerminalKeybindings( } const targets = targetBindings(platform) + const conflicts = targets.filter(target => keybindings.some(existing => isKeybinding(existing) && bindingsConflict(existing, target)) ) diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index e87e6eeb613a2..e14b8d2a52297 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -219,6 +219,7 @@ function backgroundLuminance(raw: string): null | number { } const hex = v.startsWith('#') ? v.slice(1) : v + const rgb = HEX_6_RE.test(hex) ? [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)] : HEX_3_RE.test(hex) @@ -254,7 +255,7 @@ export function detectLightMode( env: NodeJS.ProcessEnv = process.env, // Injectable so tests can prove the COLORFGBG-over-TERM_PROGRAM // precedence rule even though the production allow-list is empty. - lightDefaultTermPrograms: ReadonlySet = LIGHT_DEFAULT_TERM_PROGRAMS, + lightDefaultTermPrograms: ReadonlySet = LIGHT_DEFAULT_TERM_PROGRAMS ): boolean { const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase() From 10ad7006b67c0e5339fa8355807c30db72f36496 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Apr 2026 22:21:44 -0500 Subject: [PATCH 5/5] fix(tui): use paste timeout when rearming paste watchdog Match the buffered-stdin rearm cadence to IN_PASTE state so large pastes do not spin the normal escape timeout while waiting for readable data to drain. --- ui-tui/packages/hermes-ink/src/ink/components/App.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index a8984e13caf90..5851c4bef660f 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -332,7 +332,10 @@ export default class App extends PureComponent { // drain stdin next and clear this timer. Prevents both the spurious // Escape key and the lost scroll event. if (this.props.stdin.readableLength > 0) { - this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT) + this.incompleteEscapeTimer = setTimeout( + this.flushIncomplete, + this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT + ) return }