Merge pull request #17237 from NousResearch/bb/tui-paste-watchdog

fix(tui): stabilize sticky prompts and paste recovery
This commit is contained in:
brooklyn!
2026-04-28 20:22:44 -07:00
committed by GitHub
28 changed files with 184 additions and 68 deletions

View File

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

View File

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

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'
@@ -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
}
@@ -330,13 +332,16 @@ export default class App extends PureComponent<Props, State> {
// 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
}
// 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 +360,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

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

View File

@@ -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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -503,7 +503,7 @@ export const coreCommands: SlashCommand[] = [
ctx.guarded<SessionSteerResponse>(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')

View File

@@ -290,21 +290,19 @@ export const sessionCommands: SlashCommand[] = [
return ctx.transcript.sys(`usage: /indicator [${INDICATOR_STYLES.join('|')}]`)
}
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'indicator', value })
.then(
ctx.guarded<ConfigSetResponse>(r => {
if (!r.value) {
return
}
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'indicator', value }).then(
ctx.guarded<ConfigSetResponse>(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}`)
})
)
}
},

View File

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

View File

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

View File

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

View File

@@ -671,9 +671,7 @@ function DiffView({
<Text color={t.color.text}>
{diffMetricLine('duration', aTotals.totalDuration, bTotals.totalDuration, n => `${n.toFixed(1)}s`)}
</Text>
<Text color={t.color.text}>
{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}
</Text>
<Text color={t.color.text}>{diffMetricLine('tokens', sumTokens(aTotals), sumTokens(bTotals), fmtTokens)}</Text>
<Text color={t.color.text}>{diffMetricLine('cost', aTotals.costUsd, bTotals.costUsd, dollars)}</Text>
</Box>
</Box>

View File

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

View File

@@ -224,7 +224,12 @@ const ComposerPane = memo(function ComposerPane({
</Box>
))}
<Box onMouseDown={captureInputDrag} onMouseDrag={dragFromPromptRow} onMouseUp={endInputDrag} position="relative">
<Box
onMouseDown={captureInputDrag}
onMouseDrag={dragFromPromptRow}
onMouseUp={endInputDrag}
position="relative"
>
<Box width={promptWidth}>
{sh ? (
<Text color={ui.theme.color.shellDollar}>{promptLabel}</Text>

View File

@@ -133,7 +133,12 @@ export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps)
</Text>
</Box>
<Text bold={selected} color={selected ? t.color.accent : t.color.muted} inverse={selected} wrap="truncate-end">
<Text
bold={selected}
color={selected ? t.color.accent : t.color.muted}
inverse={selected}
wrap="truncate-end"
>
{s.title || s.preview || '(untitled)'}
</Text>
</Box>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string> = LIGHT_DEFAULT_TERM_PROGRAMS,
lightDefaultTermPrograms: ReadonlySet<string> = LIGHT_DEFAULT_TERM_PROGRAMS
): boolean {
const lightFlag = (env.HERMES_TUI_LIGHT ?? '').trim().toLowerCase()

View File

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