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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
4
ui-tui/src/types/hermes-ink.d.ts
vendored
4
ui-tui/src/types/hermes-ink.d.ts
vendored
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user