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:
h8hawk
2026-08-17 02:31:32 +03:30
committed by GitHub
parent 5229975438
commit 298f662108
5 changed files with 79 additions and 5 deletions

View File

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

View File

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

View 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`)
})
})

View File

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

View File

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