fix(tui): restore Alt+Enter for newlines (#87066)
* fix(tui): restore Alt+Enter for newlines Restore Alt+Enter support for inserting a new line in the TUI after the behavior was lost during newer input-handling updates. Legacy terminals encode Alt+Enter as ESC followed by carriage return. Preserve those bytes as a single tokenizer sequence and parse the result as Return with the Meta modifier so TextInput inserts a newline instead of submitting. Keep plain CR and LF mapped to unmodified Return, and cover the legacy ESC+CR sequence with a regression test. * fix(tui): scope legacy Alt+Enter tokenization
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
25
ui-tui/packages/hermes-ink/src/ink/termio/parser.test.ts
Normal file
25
ui-tui/packages/hermes-ink/src/ink/termio/parser.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { Parser } from './parser.js'
|
||||
|
||||
const renderedText = (actions: ReturnType<Parser['feed']>): 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`)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user