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 e5a13bdb6..1d238b40f 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 000000000..58745b8c4 --- /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 ca77058d6..56976d8a8 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 38232220a..eb7585874 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 d8500c8d2..eca079470 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 16031c967..7889b65cd 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 48d7427fd..4fdbfcc93 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 0281e059b..b25ef581f 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 4a0bd75f1..c8038576d 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 2cad70b9a..1b2936636 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 92aeb0a4f..637c4bb43 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 6bfb0e495..355faa16f 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 1d238b40f..a8984e13c 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 fdfb97a6e..1d7af3803 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 f2326dc5a..441caf607 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 5508e94ce..59e725e98 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 e3e3cea88..888bd9142 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 3abfc1856..801862363 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 5acd417de..ecd1b7866 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 1b9d930b5..869585575 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 84978b98c..6fdd04ea6 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 2c2c6d48d..df6acfadb 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 a3a075afb..a1b349827 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 e85a0fb71..cf8328bc8 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 84470c4cc..16d96f390 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 c5696a068..fd29d9e7e 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 eb7585874..4204ff56a 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 78ff5bd71..3e99b6b18 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 dc00512b6..7d387797d 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 e87e6eeb6..e14b8d2a5 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 a8984e13c..5851c4bef 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 }