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 f8585521cb..3989192b9f 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 @@ -3,6 +3,15 @@ import { describe, expect, it } from 'vitest' import { INITIAL_STATE, parseMultipleKeypresses } from './parse-keypress.js' import { PASTE_END, PASTE_START } from './termio/csi.js' +describe('legacy modified return parsing', () => { + it.each(['\r', '\n'])('parses ESC+%j as one Alt+Enter keypress', lineEnding => { + const sequence = `\x1b${lineEnding}` + const [keys] = parseMultipleKeypresses(INITIAL_STATE, sequence) + + expect(keys).toEqual([expect.objectContaining({ name: 'return', ctrl: false, meta: true, shift: false, sequence })]) + }) +}) + 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) 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 07e31c6f53..421aa4cca9 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -292,7 +292,7 @@ export function parseMultipleKeypresses( const inputString = isFlush ? '' : inputToString(input) // Get or create tokenizer - const tokenizer = prevState._tokenizer ?? createTokenizer({ x10Mouse: true }) + const tokenizer = prevState._tokenizer ?? createTokenizer({ x10Mouse: true, legacyAltEnter: true }) // Tokenize the input const tokens = isFlush ? tokenizer.flush() : tokenizer.feed(inputString) @@ -796,9 +796,10 @@ function parseKeypress(s: string = ''): ParsedKey { return createNavKey(s, 'mouse', false) } - if (s === '\r' || s === '\n') { + if (s === '\r' || s === '\n' || s === '\x1b\r' || s === '\x1b\n') { key.raw = undefined key.name = 'return' + key.meta = s.startsWith('\x1b') } else if (s === '\t') { key.name = 'tab' } else if (s === '\b' || s === '\x1b\b') { diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/parser.test.ts b/ui-tui/packages/hermes-ink/src/ink/termio/parser.test.ts new file mode 100644 index 0000000000..e501189634 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio/parser.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { Parser } from './parser.js' + +const renderedText = (actions: ReturnType): string => + actions + .filter(action => action.type === 'text') + .flatMap(action => action.graphemes) + .map(grapheme => grapheme.value) + .join('') + +describe('output parser line endings after ESC', () => { + it.each(['\r', '\n'])('preserves %j received in the same chunk as ESC', lineEnding => { + const actions = new Parser().feed(`before\x1b${lineEnding}after`) + + expect(renderedText(actions).replaceAll('\x1b', '')).toBe(`before${lineEnding}after`) + }) + + it.each(['\r', '\n'])('preserves %j received in the chunk after ESC', lineEnding => { + const parser = new Parser() + const actions = [...parser.feed('before\x1b'), ...parser.feed(`${lineEnding}after`)] + + expect(renderedText(actions).replaceAll('\x1b', '')).toBe(`before${lineEnding}after`) + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.test.ts b/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.test.ts index b3cf2cb5e8..c514aaaf8e 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.test.ts @@ -3,6 +3,31 @@ import { describe, expect, it } from 'vitest' import { createTokenizer, type Token } from './tokenize.js' describe('tokenizer escape-sequence boundaries', () => { + it.each(['\r', '\n'])('keeps ESC+%j together when received in one feed', lineEnding => { + const t = createTokenizer({ legacyAltEnter: true }) + const sequence = `\x1b${lineEnding}` + + expect(t.feed(sequence)).toEqual([{ type: 'sequence', value: sequence }]) + expect(t.buffer()).toBe('') + }) + + it.each(['\r', '\n'])('reassembles ESC+%j split across two feeds', lineEnding => { + const t = createTokenizer({ legacyAltEnter: true }) + const sequence = `\x1b${lineEnding}` + + expect(t.feed('\x1b')).toEqual([]) + expect(t.feed(lineEnding)).toEqual([{ type: 'sequence', value: sequence }]) + expect(t.buffer()).toBe('') + }) + + it.each(['\r', '\n'])('keeps Escape distinct when it is flushed before %j', lineEnding => { + const t = createTokenizer({ legacyAltEnter: true }) + + expect(t.feed('\x1b')).toEqual([]) + expect(t.flush()).toEqual([{ type: 'sequence', value: '\x1b' }]) + expect(t.feed(lineEnding)).toEqual([{ type: 'text', value: lineEnding }]) + }) + it('reassembles a CSI mouse sequence split across two feeds', () => { const t = createTokenizer({ x10Mouse: true }) diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts b/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts index 03f99cf2f4..f7a09ac06b 100644 --- a/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts +++ b/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts @@ -31,6 +31,11 @@ type TokenizerOptions = { * output streams, and enabling this there swallows display text. Default false. */ x10Mouse?: boolean + /** + * Treat ESC followed by CR or LF as one legacy Alt+Enter key sequence. + * Only enable for keyboard input; output streams must preserve line endings. + */ + legacyAltEnter?: boolean } /** @@ -53,13 +58,14 @@ export function createTokenizer(options?: TokenizerOptions): Tokenizer { // buffer it kept last time (the continuation never arrived), we drop it. let lastFlushedBuffer = '' const x10Mouse = options?.x10Mouse ?? false + const legacyAltEnter = options?.legacyAltEnter ?? false return { feed(input: string): Token[] { // Real bytes arrived — any kept partial is no longer stale. lastFlushedBuffer = '' - const result = tokenize(input, currentState, currentBuffer, false, x10Mouse) + const result = tokenize(input, currentState, currentBuffer, false, x10Mouse, legacyAltEnter) currentState = result.state.state currentBuffer = result.state.buffer @@ -68,7 +74,7 @@ export function createTokenizer(options?: TokenizerOptions): Tokenizer { }, flush(): Token[] { - const result = tokenize('', currentState, currentBuffer, true, x10Mouse) + const result = tokenize('', currentState, currentBuffer, true, x10Mouse, legacyAltEnter) currentState = result.state.state currentBuffer = result.state.buffer @@ -109,7 +115,8 @@ function tokenize( initialState: State, initialBuffer: string, flush: boolean, - x10Mouse: boolean + x10Mouse: boolean, + legacyAltEnter: boolean ): { tokens: Token[]; state: InternalState } { const tokens: Token[] = [] @@ -177,6 +184,13 @@ function tokenize( // 'O' - SS3 result.state = 'ss3' i++ + } else if (legacyAltEnter && (code === C0.CR || code === C0.LF)) { + // Legacy terminals encode Alt+Enter as ESC followed by CR or LF. + // Keep both bytes in one token so the key parser can preserve Alt. + // A standalone Escape is emitted by flush() before a later Enter; + // without that timing boundary the legacy encoding is ambiguous. + i++ + emitSequence(data.slice(seqStart, i)) } else if (isCSIIntermediate(code)) { // Intermediate byte (e.g., ESC ( for charset) - continue buffering result.state = 'escapeIntermediate'