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 e6cd9235..06400af4 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -29,7 +29,7 @@ import { import reconciler from '../reconciler.js' import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js' import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js' -import { TerminalQuerier, xtversion } from '../terminal-querier.js' +import { decrqm, TerminalQuerier, xtversion } from '../terminal-querier.js' import { isExtendedKeysCapableByXtversion, isXtermJs, @@ -44,7 +44,7 @@ import { FOCUS_IN, FOCUS_OUT } from '../termio/csi.js' -import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, SHOW_CURSOR } from '../termio/dec.js' +import { DBP, DEC, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, SHOW_CURSOR } from '../termio/dec.js' import AppContext from './AppContext.js' import { ClockProvider } from './ClockContext.js' @@ -331,6 +331,12 @@ export default class App extends PureComponent { // init sequence completes — avoids interleaving with alt-screen/mouse // tracking enable writes that may happen in the same render cycle. setImmediate(() => { + // Rides the same batch as XTVERSION: the answer tells the key + // parser whether paste markers can be trusted, which decides + // whether an unmarked byte run is typing or an unmarkable paste. + // The reply is routed into keyParseState by the parser itself. + void this.querier.send(decrqm(DEC.BRACKETED_PASTE)) + void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => { let rePushed = false 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 index 7291eab2..19ace416 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.test.ts @@ -9,6 +9,13 @@ import { describe, expect, it } from 'vitest' import { INITIAL_STATE, parseMultipleKeypresses } from './parse-keypress.js' import { PASTE_END, PASTE_START } from './termio/csi.js' +const DECRPM_2004_SET = '\x1b[?2004;1$y' + +// A plain text run reaches the app as one keypress per code point, so text +// assertions join the run back together instead of indexing a single key. +const joinKeys = (events: ReturnType[0]) => + events.map(e => (e.kind === 'key' ? e.sequence : '')).join('') + 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) @@ -125,19 +132,23 @@ describe('fragmented SGR mouse recovery', () => { expect.objectContaining({ kind: 'mouse', button: 35, col: 124, row: 26 }), expect.objectContaining({ kind: 'mouse', button: 35, col: 119, row: 26 }) ]) - expect(events[4]).toMatchObject({ kind: 'key', sequence: 'typed' }) + expect(joinKeys(events.slice(4))).toBe('typed') }) it('keeps isolated semicolon text that only resembles a prefixless mouse report', () => { - const [[key]] = parseMultipleKeypresses(INITIAL_STATE, 'see 1;2;3M for details') + const text = 'see 1;2;3M for details' + const [events] = parseMultipleKeypresses(INITIAL_STATE, text) - expect(key).toMatchObject({ kind: 'key', sequence: 'see 1;2;3M for details' }) + expect(events.every(e => e.kind === 'key')).toBe(true) + expect(joinKeys(events)).toBe(text) }) it('does not match prefixless fragments inside longer digit runs', () => { - const [[key]] = parseMultipleKeypresses(INITIAL_STATE, '1234;56;78M9;10;11M') + const text = '1234;56;78M9;10;11M' + const [events] = parseMultipleKeypresses(INITIAL_STATE, text) - expect(key).toMatchObject({ kind: 'key', sequence: '1234;56;78M9;10;11M' }) + expect(events.every(e => e.kind === 'key')).toBe(true) + expect(joinKeys(events)).toBe(text) }) }) @@ -231,3 +242,173 @@ describe('modifier+enter parsing (tui-composer-multiline regression lock)', () = expect(key).toMatchObject({ name: 'return', ctrl: false, meta: false, shift: false }) }) }) + +// Typing and pasting are indistinguishable in the raw byte stream, so a run +// of plain bytes is split into one keypress per code point instead of being +// merged into a single nameless keypress. Bracketed paste (DEC 2004) is the +// only reliable paste signal; when the terminal is confirmed to support it, +// every unmarked run is typing. When it is not, short printable runs are +// still typing but anything carrying control bytes or long enough to be a +// paste stays whole for the paste path. +describe('plain text run splitting', () => { + const seqs = (keys: ReturnType[0]) => + keys.map(k => (k.kind === 'key' ? k.sequence : '')) + + describe('bracketed paste unconfirmed (default)', () => { + it('splits a printable run into one keypress per character', () => { + const [keys] = parseMultipleKeypresses(INITIAL_STATE, 'abc') + + expect(seqs(keys)).toEqual(['a', 'b', 'c']) + expect(keys.map(k => (k.kind === 'key' ? k.name : ''))).toEqual(['a', 'b', 'c']) + }) + + it('splits multi-byte characters without breaking them apart', () => { + const [keys] = parseMultipleKeypresses(INITIAL_STATE, '你好') + + expect(seqs(keys)).toEqual(['你', '好']) + }) + + it('keeps a run carrying a control byte whole for the paste path', () => { + const [keys] = parseMultipleKeypresses(INITIAL_STATE, 'there\r') + + expect(seqs(keys)).toEqual(['there\r']) + }) + + it('keeps an auto-repeated backspace burst whole', () => { + const [keys] = parseMultipleKeypresses(INITIAL_STATE, '\x7f\x7f\x7f') + + expect(seqs(keys)).toEqual(['\x7f\x7f\x7f']) + }) + + it('keeps a run longer than a typing burst whole', () => { + const long = 'x'.repeat(33) + const [keys] = parseMultipleKeypresses(INITIAL_STATE, long) + + expect(seqs(keys)).toEqual([long]) + }) + + it('splits a run at the typing-burst limit', () => { + const [keys] = parseMultipleKeypresses(INITIAL_STATE, 'x'.repeat(32)) + + expect(keys).toHaveLength(32) + }) + }) + + describe('bracketed paste confirmed', () => { + // The parser learns the capability from the terminal's own DECRPM answer, + // so every case here starts from the state that reply produced. + const confirmed = parseMultipleKeypresses(INITIAL_STATE, DECRPM_2004_SET)[1] + + it('records the capability from the DECRQM answer', () => { + expect(confirmed.bracketedPaste).toBe('confirmed') + }) + + it('rejects an answer saying the mode is off - App enabled it before asking', () => { + const [, state] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[?2004;2$y') + + expect(state.bracketedPaste).toBe('unknown') + }) + + it('accepts an answer saying the mode is permanently on', () => { + const [, state] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[?2004;3$y') + + expect(state.bracketedPaste).toBe('confirmed') + }) + + it('rejects an answer saying the mode is unknown to the terminal', () => { + const [, state] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[?2004;0$y') + + expect(state.bracketedPaste).toBe('unknown') + }) + + it('rejects an answer saying the mode can never be turned on', () => { + const [, state] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[?2004;4$y') + + expect(state.bracketedPaste).toBe('unknown') + }) + + it('ignores a DECRQM answer for an unrelated mode', () => { + const [, state] = parseMultipleKeypresses(INITIAL_STATE, '\x1b[?2026;1$y') + + expect(state.bracketedPaste).toBe('unknown') + }) + + it('keeps the capability across later reads', () => { + const [, next] = parseMultipleKeypresses(confirmed, 'ab') + + expect(next.bracketedPaste).toBe('confirmed') + }) + + it('splits trailing control bytes into their own keypresses', () => { + const [keys] = parseMultipleKeypresses(confirmed, 'there\r') + + expect(seqs(keys)).toEqual(['t', 'h', 'e', 'r', 'e', '\r']) + expect(keys[5]).toMatchObject({ name: 'return' }) + }) + + it('splits an auto-repeated backspace burst into individual backspaces', () => { + const [keys] = parseMultipleKeypresses(confirmed, '\x7f\x7f\x7f') + + expect(keys.map(k => (k.kind === 'key' ? k.name : ''))).toEqual([ + 'backspace', + 'backspace', + 'backspace' + ]) + }) + + it('splits a leading control byte from the text that followed it', () => { + const [keys] = parseMultipleKeypresses(confirmed, '\x01ab') + + expect(keys[0]).toMatchObject({ name: 'a', ctrl: true }) + expect(seqs(keys).slice(1)).toEqual(['a', 'b']) + }) + + it('keeps raw alt+enter ESC+CR as one keypress', () => { + const [keys] = parseMultipleKeypresses(confirmed, '\x1b\r') + + expect(keys).toHaveLength(1) + expect(keys[0]).toMatchObject({ kind: 'key', name: '', sequence: '\x1b\r' }) + }) + + it('keeps raw alt+enter ESC+LF as one keypress', () => { + const [keys] = parseMultipleKeypresses(confirmed, '\x1b\n') + + expect(keys).toHaveLength(1) + expect(keys[0]).toMatchObject({ kind: 'key', name: '', sequence: '\x1b\n' }) + }) + + it('keeps raw meta+backspace ESC+DEL as one keypress', () => { + const [keys] = parseMultipleKeypresses(confirmed, '\x1b\x7f') + + expect(keys).toHaveLength(1) + expect(keys[0]).toMatchObject({ name: 'backspace', sequence: '\x1b\x7f', meta: true }) + }) + + it('keeps raw meta+backspace ESC+BS as one keypress', () => { + const [keys] = parseMultipleKeypresses(confirmed, '\x1b\b') + + expect(keys).toHaveLength(1) + expect(keys[0]).toMatchObject({ name: 'backspace', sequence: '\x1b\b', meta: true }) + }) + + it('splits a run longer than a typing burst', () => { + const [keys] = parseMultipleKeypresses(confirmed, 'x'.repeat(33)) + + expect(keys).toHaveLength(33) + }) + + it('leaves bracketed paste content whole', () => { + const [keys] = parseMultipleKeypresses(confirmed, PASTE_START + 'ab' + PASTE_END) + + expect(keys).toHaveLength(1) + expect(keys[0]).toMatchObject({ isPasted: true, raw: 'ab' }) + }) + }) + + it('leaves a single character alone in both modes', () => { + expect(parseMultipleKeypresses(INITIAL_STATE, 'a')[0]).toHaveLength(1) + expect( + parseMultipleKeypresses(parseMultipleKeypresses(INITIAL_STATE, DECRPM_2004_SET)[1], 'a')[0] + ).toHaveLength(1) + }) +}) 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 003a6457..5ce217e4 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -13,8 +13,20 @@ import { Buffer } from 'buffer' import { PASTE_END, PASTE_START } from './termio/csi.js' +import { DEC } from './termio/dec.js' import { createTokenizer, type Tokenizer } from './termio/tokenize.js' +// Longest plain-byte run still read as typing while bracketed paste is +// unconfirmed. Above it a run is far more likely to be a paste the terminal +// could not mark, and staying whole keeps the paste path's placeholder +// collapsing and dropped-path detection working. +const MAX_TYPED_RUN = 32 + +// eslint-disable-next-line no-control-regex +const CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/ + +const RAW_ESCAPE_CONTROL_SUFFIXES = new Set(['\r', '\n', '\b', '\x7f']) + // eslint-disable-next-line no-control-regex const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/ @@ -191,10 +203,14 @@ function splitNumericParams(params: string): number[] { return params.split(';').map(p => parseInt(p, 10)) } +/** Whether this terminal is known to implement bracketed paste (DEC 2004). */ +export type BracketedPasteState = 'confirmed' | 'unknown' + export type KeyParseState = { mode: 'NORMAL' | 'IN_PASTE' incomplete: string pasteBuffer: string + bracketedPaste: BracketedPasteState // Internal tokenizer instance _tokenizer?: Tokenizer } @@ -202,7 +218,69 @@ export type KeyParseState = { export const INITIAL_STATE: KeyParseState = { mode: 'NORMAL', incomplete: '', - pasteBuffer: '' + pasteBuffer: '', + bracketedPaste: 'unknown' +} + +/** + * Bracketed paste is trustworthy only once the terminal reports mode 2004 as + * actually on. App enables it (EBP) during raw-mode setup, before this query + * goes out, so an answer of RESET means the enable did not take and no paste + * marker will ever arrive -- the case where trusting markers would be worst. + */ +function confirmsBracketedPaste(response: TerminalResponse): boolean { + return ( + response.type === 'decrpm' && + response.mode === DEC.BRACKETED_PASTE && + (response.status === DECRPM_STATUS.SET || response.status === DECRPM_STATUS.PERMANENTLY_SET) + ) +} + +/** + * Typing and pasting are the same bytes on stdin, so merging a run into one + * nameless keypress mis-reads a coalesced burst (SSH / tmux / a blocked event + * loop) as a paste. With bracketed paste confirmed, every unmarked run is + * typing and splits unconditionally. Without it, only runs that could not be + * a paste line -- short and free of control bytes -- are safe to split. + */ +function splitsIntoKeystrokes(text: string, bracketedPaste: BracketedPasteState): boolean { + if (text.length < 2) { + return false + } + + if (bracketedPaste === 'confirmed') { + return true + } + + return text.length <= MAX_TYPED_RUN && !CONTROL_BYTE_RE.test(text) +} + +function pushTextKeys(out: ParsedInput[], text: string, bracketedPaste: BracketedPasteState): void { + if (!splitsIntoKeystrokes(text, bracketedPaste)) { + out.push(parseKeypress(text)) + + return + } + + const codePoints = [...text] + + for (let index = 0; index < codePoints.length; index++) { + const codePoint = codePoints[index]! + const nextCodePoint = codePoints[index + 1] + + if ( + codePoint === '\x1b' && + nextCodePoint !== undefined && + RAW_ESCAPE_CONTROL_SUFFIXES.has(nextCodePoint) + ) { + out.push(parseKeypress(codePoint + nextCodePoint)) + index++ + + continue + } + + out.push(parseKeypress(codePoint)) + } } function inputToString(input: Buffer | string): string { @@ -227,6 +305,7 @@ export function parseMultipleKeypresses( prevState: KeyParseState, input: Buffer | string | null = '' ): [ParsedInput[], KeyParseState] { + let bracketedPaste = prevState.bracketedPaste const isFlush = input === null const inputString = isFlush ? '' : inputToString(input) @@ -260,6 +339,10 @@ export function parseMultipleKeypresses( const response = parseTerminalResponse(token.value) if (response) { + if (confirmsBracketedPaste(response)) { + bracketedPaste = 'confirmed' + } + keys.push({ kind: 'response', sequence: token.value, response }) } else { const mouse = parseMouseEvent(token.value) @@ -275,7 +358,7 @@ export function parseMultipleKeypresses( if (inPaste) { pasteBuffer += token.value } else { - const mouseFragments = parseTextWithSgrMouseFragments(token.value) + const mouseFragments = parseTextWithSgrMouseFragments(token.value, bracketedPaste) if (mouseFragments) { keys.push(...mouseFragments) @@ -288,7 +371,7 @@ export function parseMultipleKeypresses( const resynthesized = '\x1b' + token.value keys.push(parseKeypress(resynthesized)) } else { - keys.push(parseKeypress(token.value)) + pushTextKeys(keys, token.value, bracketedPaste) } } } @@ -311,6 +394,7 @@ export function parseMultipleKeypresses( mode: inPaste ? 'IN_PASTE' : 'NORMAL', incomplete: tokenizer.buffer(), pasteBuffer, + bracketedPaste, _tokenizer: tokenizer } @@ -649,7 +733,10 @@ function parseSgrMouseFragment(fragment: string): ParsedInput { return parseMouseEvent(sequence) ?? parseKeypress(sequence) } -function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null { +function parseTextWithSgrMouseFragments( + text: string, + bracketedPaste: BracketedPasteState +): ParsedInput[] | null { SGR_MOUSE_FRAGMENT_RE.lastIndex = 0 const matches = [...text.matchAll(SGR_MOUSE_FRAGMENT_RE)] @@ -682,7 +769,7 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null { } if (first.index! > cursor) { - parsed.push(parseKeypress(text.slice(cursor, first.index!))) + pushTextKeys(parsed, text.slice(cursor, first.index!), bracketedPaste) } for (const match of run) { @@ -698,7 +785,7 @@ function parseTextWithSgrMouseFragments(text: string): ParsedInput[] | null { } if (cursor < text.length) { - parsed.push(parseKeypress(text.slice(cursor))) + pushTextKeys(parsed, text.slice(cursor), bracketedPaste) } return parsed diff --git a/ui-tui/src/__tests__/textInputFastAppend.test.ts b/ui-tui/src/__tests__/textInputFastAppend.test.ts new file mode 100644 index 00000000..46418f90 --- /dev/null +++ b/ui-tui/src/__tests__/textInputFastAppend.test.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// Portions Copyright (c) 2025 Nous Research (hermes-agent, MIT). +// Modifications Copyright (c) 2026 EverMind. +// See NOTICES.md and LICENSES/MIT-hermes-agent.txt. + +import { describe, expect, it } from 'vitest' + +import { fitsFastAppend } from '../components/textInput.js' + +// Fast append writes the character straight to the terminal and defers the +// React update to the next frame, so a keystroke that takes it costs no +// render. The guard only has to hold what the terminal itself does not: the +// insert lands at the end of a single line, and the caret stays on that line. +describe('fitsFastAppend', () => { + const COLS = 80 + + it('takes an ASCII character appended at the end of a line', () => { + expect(fitsFastAppend('abc', 3, 'd', 3, COLS)).toBe(true) + }) + + it('takes a wide character - the terminal advances two cells on its own', () => { + expect(fitsFastAppend('ab', 2, '你', 2, COLS)).toBe(true) + }) + + it('takes an emoji that occupies one grapheme', () => { + expect(fitsFastAppend('ab', 2, '🙂', 2, COLS)).toBe(true) + }) + + it('rejects an insert away from the end of the value', () => { + expect(fitsFastAppend('abc', 1, 'd', 3, COLS)).toBe(false) + }) + + it('rejects a value that already spans several lines', () => { + expect(fitsFastAppend('a\nb', 3, 'c', 1, COLS)).toBe(false) + }) + + it('rejects the first character of an empty value', () => { + expect(fitsFastAppend('', 0, 'a', 0, COLS)).toBe(false) + }) + + it('rejects a character that would reach the last column', () => { + expect(fitsFastAppend('x'.repeat(79), 79, 'y', 79, COLS)).toBe(false) + }) + + it('rejects a wide character with only one column left', () => { + expect(fitsFastAppend('x'.repeat(78), 78, '你', 78, COLS)).toBe(false) + }) + + it('rejects more than one grapheme', () => { + expect(fitsFastAppend('ab', 2, 'cd', 2, COLS)).toBe(false) + }) + + it('rejects a zero-width combining mark', () => { + expect(fitsFastAppend('ae', 2, '́', 2, COLS)).toBe(false) + }) +}) diff --git a/ui-tui/src/__tests__/textInputTypingBurst.test.tsx b/ui-tui/src/__tests__/textInputTypingBurst.test.tsx new file mode 100644 index 00000000..e88690b3 --- /dev/null +++ b/ui-tui/src/__tests__/textInputTypingBurst.test.tsx @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +// Modifications Copyright (c) 2026 EverMind. + +import { renderSync } from '@hermes/ink' +import React from 'react' +import { PassThrough } from 'stream' +import { describe, expect, it } from 'vitest' + +import { TextInput } from '../components/textInput.js' + +// SSH, tmux and a blocked event loop all coalesce keystrokes, so a burst of +// typed characters reaches stdin as one chunk. It has to land in the composer +// as typing, not as a paste held back by the paste debounce. +const flush = () => new Promise(resolve => setImmediate(resolve)) + +const mount = () => { + const changes: string[] = [] + const stdin = new PassThrough() + const stdout = new PassThrough() + + // Non-TTY stdout keeps fast echo out of the picture, so every accepted + // character reaches onChange synchronously and the assertion needs no timer. + Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 }) + Object.assign(stdin, { isTTY: true, ref: () => {}, setRawMode: () => {}, unref: () => {} }) + stdout.resume() + + const Harness = () => { + const [value, setValue] = React.useState('') + + return React.createElement(TextInput, { + focus: true, + onChange: (next: string) => { + changes.push(next) + setValue(next) + }, + value + }) + } + + const instance = renderSync(React.createElement(Harness), { + patchConsole: false, + stdin: stdin as unknown as NodeJS.ReadStream, + stdout: stdout as unknown as NodeJS.WriteStream + }) + + return { + changes, + type: async (s: string) => { + stdin.write(s) + await flush() + }, + unmount: () => { + instance.unmount() + instance.cleanup() + } + } +} + +describe('typing burst reaching the composer', () => { + it('accepts a coalesced run of characters without waiting on the paste debounce', async () => { + const h = mount() + + try { + await h.type('hello') + + expect(h.changes).toEqual(['h', 'he', 'hel', 'hell', 'hello']) + } finally { + h.unmount() + } + }) + + it('accepts a coalesced run of wide characters', async () => { + const h = mount() + + try { + await h.type('你好') + + expect(h.changes).toEqual(['你', '你好']) + } finally { + h.unmount() + } + }) +}) diff --git a/ui-tui/src/__tests__/virtualHistoryIdentity.test.tsx b/ui-tui/src/__tests__/virtualHistoryIdentity.test.tsx new file mode 100644 index 00000000..a85e95ea --- /dev/null +++ b/ui-tui/src/__tests__/virtualHistoryIdentity.test.tsx @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +// Modifications Copyright (c) 2026 EverMind. + +import { render } from 'ink-testing-library' +import React from 'react' +import { beforeEach, describe, expect, it } from 'vitest' + +import type { ScrollBoxHandle } from '../types/hermes-ink.js' + +import { useVirtualHistory } from '../hooks/useVirtualHistory.js' + +// useMainApp feeds this hook's return value straight into the appTranscript +// memo, so a fresh object on every render invalidates the memo and re-renders +// the whole transcript on each keystroke. Identity has to survive a render +// that changed nothing the hook depends on. +let results: unknown[] = [] + +function IdentitySpy({ items }: { items: readonly { key: string }[] }) { + const scrollRef = React.useRef(null) + + results.push(useVirtualHistory(scrollRef, items, 80)) + + return null +} + +describe('useVirtualHistory return identity', () => { + beforeEach(() => { + results = [] + }) + + it('returns the same object when a re-render changes none of its inputs', () => { + const items = [{ key: 'a' }, { key: 'b' }] + const { rerender } = render(React.createElement(IdentitySpy, { items })) + + const settled = results.length + + rerender(React.createElement(IdentitySpy, { items })) + + expect(results.length).toBeGreaterThan(settled) + expect(results.at(-1)).toBe(results[settled - 1]) + }) + + it('returns a new object once the item list changes', () => { + const items = [{ key: 'a' }] + const { rerender } = render(React.createElement(IdentitySpy, { items })) + + const settled = results.length + + rerender(React.createElement(IdentitySpy, { items: [{ key: 'a' }, { key: 'b' }] })) + + expect(results.at(-1)).not.toBe(results[settled - 1]) + }) +}) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 83d27eac..d87f1b22 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -206,6 +206,43 @@ export function isLfReturn(sequence: string | undefined): boolean { return sequence === '\n' } +/** + * Layout half of the fast-echo guard: whether writing `text` straight to the + * terminal leaves the caret where a re-render would have put it. Exported for + * unit testing; the component pairs it with focus / TTY state. + * + * A wide character is fine here — the terminal advances two cells for it on + * its own, and `sw` already carries that width into the column check. What + * the terminal cannot do for us is wrap or reflow, so the insert has to land + * at the end of a single line that still has room. + */ +export function fitsFastAppend( + current: string, + cursor: number, + text: string, + lineWidth: number, + columns: number +): boolean { + const sw = stringWidth(text) + + if (sw < 1 || sw > 2 || !isSingleGrapheme(text)) { + return false + } + + return ( + cursor === current.length && + current.length > 0 && + !current.includes('\n') && + lineWidth + sw < Math.max(1, columns) + ) +} + +function isSingleGrapheme(text: string): boolean { + const it = seg().segment(text)[Symbol.iterator]() + + return it.next().value?.segment === text +} + function renderWithCursor(value: string, cursor: number, cursorColor?: string) { const pos = Math.max(0, Math.min(cursor, value.length)) @@ -474,18 +511,8 @@ export function TextInput({ const canFastEchoBase = () => focus && termFocus && !selected && !mask && !!stdout?.isTTY - const canFastAppend = (current: string, cursor: number, text: string) => { - const sw = stringWidth(text) - - return ( - canFastEchoBase() && - cursor === current.length && - current.length > 0 && - !current.includes('\n') && - sw === text.length && - lineWidthRef.current + sw < Math.max(1, columns) - ) - } + const canFastAppend = (current: string, cursor: number, text: string) => + canFastEchoBase() && fitsFastAppend(current, cursor, text, lineWidthRef.current, columns) const canFastBackspace = (current: string, cursor: number) => { if (!canFastEchoBase() || cursor !== current.length || cursor <= 0 || current.includes('\n')) { diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 2a4a1d0e..f30a51d8 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -11,6 +11,7 @@ import { useDeferredValue, useEffect, useLayoutEffect, + useMemo, useRef, useState, useSyncExternalStore @@ -516,14 +517,24 @@ export function useVirtualHistory( } }, [effEnd, effStart, items, liveTailActive, measuredHeightVersion, n, offsets, scrollRef, sticky, total, vp]) - return { - bottomSpacer: Math.max(0, total - (offsets[effEnd] ?? total)), - end: effEnd, - measureRef, - offsets, - start: effStart, - topSpacer: offsets[effStart] ?? 0 - } + const bottomSpacer = Math.max(0, total - (offsets[effEnd] ?? total)) + const topSpacer = offsets[effStart] ?? 0 + + // Memoized because useMainApp passes this object into the appTranscript + // memo: a fresh identity on every render would re-render the transcript on + // every keystroke. offsets is a cached array and measureRef a stable + // callback, so the deps only move when the window actually moves. + return useMemo( + () => ({ + bottomSpacer, + end: effEnd, + measureRef, + offsets, + start: effStart, + topSpacer + }), + [bottomSpacer, effEnd, effStart, measureRef, offsets, topSpacer] + ) } interface MeasuredNode {