mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 01:51:44 +00:00
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:
parent
cd7150a195
commit
afb20a1d67
4 changed files with 65 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
41
ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts
Normal file
41
ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 = ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue