From afb20a1d67d2f64876e488d36be14b3a6f2c8eec Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 28 Apr 2026 22:06:27 -0500 Subject: [PATCH] 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 e5a13bdb68..1d238b40f7 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 0000000000..58745b8c40 --- /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 ca77058d66..56976d8a84 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 38232220a5..eb75858741 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 =