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 { 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<Props, State> {
// 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<Props, State> {
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<Props, State> {
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)

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 (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 = ''
}

View file

@ -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 =