fix(tui): treat every ctrl chord as a binding name, never typed text

Widen #115382 from bare C0 bytes to every ctrl-modified keypress: a kitty
CSI u / xterm modifyOtherKeys Ctrl+L (`ESC [ 108 ; 5 u`) reaches parseKey
with ctrl set and name `l` exactly like the 0x0c redraw byte, so the
composer typed an `l` for it too. `isControlChord` is ctrl && !isPasted;
bracketed pastes stay text. Test trimmed to two invariants (chord is
bindable and refused by the insert gate; typed text and pastes still land).
This commit is contained in:
teknium1
2026-09-19 23:38:01 -07:00
committed by Teknium
parent addf694901
commit d6a69ea5a3
5 changed files with 71 additions and 111 deletions

View File

@@ -1,83 +0,0 @@
import { describe, expect, it } from 'vitest'
import { INITIAL_STATE, parseMultipleKeypresses } from '../parse-keypress.js'
import { InputEvent } from './input-event.js'
// Regression: #115284 — a stray `l` in the TUI composer after a session
// resume / tab switch / window restore.
//
// The dashboard writes the PTY force-redraw byte (Ctrl+L, 0x0c —
// `hermes_cli/pty_session.py` ``TUI_FORCE_REDRAW``) into the TUI's stdin on
// every re-attach (`hermes_cli/web_routers/chat_ws.py`:
// ``session.attach(ws, force_redraw=not _created)``). parse-keypress names a
// control byte after the letter it encodes (0x0c → 'l') and InputEvent hands
// that name to ``input`` so bindings can match ctrl+<letter> on the raw byte —
// so the byte arrived at the composer looking like typed text and landed in the
// input box as a solitary `l`, prefixed to whatever the user typed next.
const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
/** Mirror the composer's insert gate (ui-tui/src/components/textInput.tsx). */
const composerWouldInsert = (event: InputEvent): boolean =>
!event.isControlByteChord && (event.keypress.isPasted || event.input.length > 0) && PRINTABLE.test(event.input)
function parseOne(bytes: string): InputEvent {
const [keys] = parseMultipleKeypresses({ ...INITIAL_STATE }, bytes)
return new InputEvent(keys[0] as never)
}
describe('control bytes are chords, not typed text (#115284)', () => {
it('does not offer the re-attach redraw byte (Ctrl+L) to inserters', () => {
const event = parseOne('\x0c')
expect(event.key.ctrl).toBe(true)
expect(event.input).toBe('l') // binding name Ink derives from the byte
expect(composerWouldInsert(event)).toBe(false)
expect(event.isControlByteChord).toBe(true)
})
it('still inserts a typed l', () => {
const event = parseOne('l')
expect(event.input).toBe('l')
expect(event.isControlByteChord).toBe(false)
expect(composerWouldInsert(event)).toBe(true)
})
it('keeps the derived letter on `input` so ctrl chords stay bindable', () => {
// Clearing `input` instead of flagging it would break the composer's own
// ctrl+a/e/u/k/w/z/y branches and the global ctrl+c/x/o/t pass-through.
const chords: Array<[string, string]> = [
['\x01', 'a'],
['\x05', 'e'],
['\x0b', 'k'],
['\x15', 'u'],
['\x17', 'w'],
['\x1a', 'z']
]
for (const [byte, letter] of chords) {
const event = parseOne(byte)
expect(event.input, JSON.stringify(byte)).toBe(letter)
expect(event.key.ctrl, JSON.stringify(byte)).toBe(true)
}
})
it('leaves extended-protocol chords and pastes unflagged', () => {
// kitty CSI u Ctrl+L is multi-byte, so it is not a bare control byte.
const kitty = parseOne('\x1b[108;5u')
expect(kitty.key.ctrl).toBe(true)
expect(kitty.isControlByteChord).toBe(false)
// Bracketed paste content is text, never a chord.
const [keys] = parseMultipleKeypresses({ ...INITIAL_STATE }, '\x1b[200~\x0c\x1b[201~')
const pasted = new InputEvent(keys[0] as never)
expect(pasted.keypress.isPasted).toBe(true)
expect(pasted.isControlByteChord).toBe(false)
})
})

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { INITIAL_STATE, parseMultipleKeypresses } from '../parse-keypress.js'
import { InputEvent } from './input-event.js'
// Regression: #115284 — a stray `l` in the TUI composer after a session
// resume / tab switch / window restore. The dashboard writes the PTY
// force-redraw byte (Ctrl+L, 0x0c — `hermes_cli/pty_session.py`
// ``TUI_FORCE_REDRAW``) into the TUI's stdin on every re-attach
// (`hermes_cli/web_routers/chat_ws.py`: ``session.attach(ws, force_redraw=not
// _created)``). parse-keypress names the byte after the letter it encodes and
// InputEvent hands that name to ``input`` so bindings can match ctrl+<letter>,
// so the byte reached the composer looking like typed text.
const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
/** Mirror the composer's insert gate (ui-tui/src/components/textInput.tsx). */
const composerWouldInsert = (event: InputEvent): boolean =>
!event.isControlChord && (event.keypress.isPasted || event.input.length > 0) && PRINTABLE.test(event.input)
function parseOne(bytes: string): InputEvent {
const [keys] = parseMultipleKeypresses({ ...INITIAL_STATE }, bytes)
return new InputEvent(keys[0] as never)
}
describe('ctrl chords are bindable but never typed text (#115284)', () => {
it('keeps the binding name on input for the redraw byte and its kitty twin, and refuses to insert either', () => {
for (const bytes of ['\x0c', '\x1b[108;5u']) {
const event = parseOne(bytes)
expect(event.key.ctrl, JSON.stringify(bytes)).toBe(true)
expect(event.input, JSON.stringify(bytes)).toBe('l') // binding name Ink derives from the chord
expect(event.isControlChord, JSON.stringify(bytes)).toBe(true)
expect(composerWouldInsert(event), JSON.stringify(bytes)).toBe(false)
}
})
it('still inserts a typed l and a bracketed paste carrying a control byte', () => {
const typed = parseOne('l')
expect(typed.isControlChord).toBe(false)
expect(composerWouldInsert(typed)).toBe(true)
const [keys] = parseMultipleKeypresses({ ...INITIAL_STATE }, '\x1b[200~\x0c\x1b[201~')
const pasted = new InputEvent(keys[0] as never)
expect(pasted.keypress.isPasted).toBe(true)
expect(pasted.isControlChord).toBe(false)
})
})

View File

@@ -173,33 +173,24 @@ function parseKey(keypress: ParsedKey): [Key, string] {
}
/**
* A bare C0 control byte (0x00–0x1f) is a chord, never typed text — but
* `parseKeypress` names it after the letter the byte encodes (0x0c → 'l') and
* `parseKey` above hands that name to `input`, so bindings can still match
* ctrl+<letter> on the raw byte. Text inserters must not read the name as
* input: the dashboard writes the PTY force-redraw byte Ctrl+L (0x0c,
* `hermes_cli/pty_session.py` TUI_FORCE_REDRAW) into the TUI's stdin on every
* re-attach, which typed a solitary `l` into the composer after a session
* resume / tab switch / window restore (#115284).
*
* Only a single-byte sequence qualifies: kitty CSI u / xterm modifyOtherKeys
* chords and bracketed pastes keep their sequence, so they are unaffected.
* A ctrl chord is never typed text — but `parseKeypress` names a C0 control
* byte after the letter it encodes (0x0c → 'l'), an extended-protocol chord
* (kitty CSI u / xterm modifyOtherKeys `ESC [ 108 ; 5 u`) after its keycode,
* and `parseKey` above hands that name to `input` so bindings can still match
* ctrl+<letter>. Text inserters must not read the name as input: the dashboard
* writes the PTY force-redraw byte Ctrl+L (0x0c, `hermes_cli/pty_session.py`
* TUI_FORCE_REDRAW) into the TUI's stdin on every re-attach, which typed a
* solitary `l` into the composer after a session resume / tab switch / window
* restore (#115284). Bracketed pastes are text even when a control byte rides
* inside them, so they are never a chord.
*/
function isBareControlByteChord(keypress: ParsedKey): boolean {
const sequence = keypress.sequence
return (
keypress.ctrl &&
!keypress.isPasted &&
typeof sequence === 'string' &&
sequence.length === 1 &&
sequence.charCodeAt(0) < 0x20
)
function isControlChord(keypress: ParsedKey): boolean {
return keypress.ctrl && !keypress.isPasted
}
export class InputEvent extends Event {
/** `input` is a control byte's binding name, not text the user typed. */
readonly isControlByteChord: boolean
/** `input` is a ctrl chord's binding name, not text the user typed. */
readonly isControlChord: boolean
readonly keypress: ParsedKey
readonly key: Key
readonly input: string
@@ -208,7 +199,7 @@ export class InputEvent extends Event {
super()
const [key, input] = parseKey(keypress)
this.isControlByteChord = isBareControlByteChord(keypress)
this.isControlChord = isControlChord(keypress)
this.keypress = keypress
this.key = key
this.input = input

View File

@@ -1473,7 +1473,7 @@ export function TextInput({
const delFwd = k.delete || fwdDel.current
const isPrintableInput =
!event.isControlByteChord &&
!event.isControlChord &&
(event.keypress.isPasted || inp.length > 0) &&
PRINTABLE.test(inp.replace(BRACKET_PASTE, ''))
@@ -1615,7 +1615,7 @@ export function TextInput({
} else {
;({ cursor: c, value: v } = killToLineEnd(v, c))
}
} else if (event.keypress.isPasted || (inp.length > 0 && !event.isControlByteChord)) {
} else if (event.keypress.isPasted || (inp.length > 0 && !event.isControlChord)) {
const bracketed = event.keypress.isPasted || inp.includes('[200~')
const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')

View File

@@ -27,8 +27,8 @@ declare module '@hermes/ink' {
export type InputEvent = {
readonly input: string
/** `input` is a control byte's binding name, not typed text (#115284). */
readonly isControlByteChord: boolean
/** `input` is a ctrl chord's binding name, not typed text (#115284). */
readonly isControlChord: boolean
readonly key: Key
readonly keypress: { readonly isPasted?: boolean; readonly name?: string; readonly raw?: string }
}