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.
This commit is contained in:
Brooklyn Nicholson 2026-04-28 22:06:27 -05:00
parent cd7150a195
commit afb20a1d67
4 changed files with 65 additions and 15 deletions

View file

@ -1,4 +1,4 @@
import React, { PureComponent, type ReactNode } from 'react' import { PureComponent, type ReactNode } from 'react'
import { updateLastInteractionTime } from '../../bootstrap/state.js' import { updateLastInteractionTime } from '../../bootstrap/state.js'
import { logForDebugging } from '../../utils/debug.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 { TerminalFocusEvent } from '../events/terminal-focus-event.js'
import { import {
INITIAL_STATE, INITIAL_STATE,
parseMultipleKeypresses,
type ParsedInput, type ParsedInput,
type ParsedKey, type ParsedKey,
type ParsedMouse, type ParsedMouse
parseMultipleKeypresses
} from '../parse-keypress.js' } from '../parse-keypress.js'
import reconciler from '../reconciler.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 { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js'
import { TerminalQuerier, xtversion } from '../terminal-querier.js' import { TerminalQuerier, xtversion } from '../terminal-querier.js'
import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js' import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js'
@ -316,8 +316,10 @@ export default class App extends PureComponent<Props, State> {
// Clear the timer reference // Clear the timer reference
this.incompleteEscapeTimer = null this.incompleteEscapeTimer = null
// Only proceed if we have incomplete sequences // Only proceed if we have an incomplete escape sequence or an unterminated
if (!this.keyParseState.incomplete) { // 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 return
} }
@ -335,8 +337,8 @@ export default class App extends PureComponent<Props, State> {
return return
} }
// Process incomplete as a flush operation (input=null) // Process incomplete/paste state as a flush operation (input=null).
// This reuses all existing parsing logic // This reuses all existing parsing logic.
this.processInput(null) this.processInput(null)
} }
@ -355,8 +357,10 @@ export default class App extends PureComponent<Props, State> {
reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined) reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined)
} }
// If we have incomplete escape sequences, set a timer to flush them // If we have incomplete escape sequences or an unterminated paste, set a
if (this.keyParseState.incomplete) { // 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 // Cancel any existing timer first
if (this.incompleteEscapeTimer) { if (this.incompleteEscapeTimer) {
clearTimeout(this.incompleteEscapeTimer) clearTimeout(this.incompleteEscapeTimer)

View file

@ -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('')
})
})

View file

@ -288,9 +288,14 @@ export function parseMultipleKeypresses(
} }
} }
// If flushing and still in paste mode, emit what we have // If a terminal drops the paste-end marker, the App watchdog flushes the
if (isFlush && inPaste && pasteBuffer) { // partial paste and returns to normal input instead of swallowing all future
keys.push(createPasteKey(pasteBuffer)) // keystrokes as paste content.
if (isFlush && inPaste) {
if (pasteBuffer) {
keys.push(createPasteKey(pasteBuffer))
}
inPaste = false inPaste = false
pasteBuffer = '' pasteBuffer = ''
} }

View file

@ -1,5 +1,5 @@
import { Box, NoSelect, Text } from '@hermes/ink' 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 spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { THINKING_COT_MAX } from '../config/limits.js' import { THINKING_COT_MAX } from '../config/limits.js'
@ -873,7 +873,7 @@ export const ToolTrail = memo(function ToolTrail({
const hasTools = groups.length > 0 const hasTools = groups.length > 0
const hasSubagents = subagents.length > 0 const hasSubagents = subagents.length > 0
const hasMeta = meta.length > 0 const hasMeta = meta.length > 0
const hasThinking = !!cot || reasoningActive || busy const hasThinking = !!cot || reasoningActive || reasoningStreaming
const thinkingLive = reasoningActive || reasoningStreaming const thinkingLive = reasoningActive || reasoningStreaming
const tokenCount = const tokenCount =