diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index ff42352b..cdecf68d 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -611,6 +611,12 @@ export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Arr [`${glyphs.updown} / j k`, 'select'], [glyphs.enter, 'attach — on a RECENT row, resume that conversation'], ['1-9', 'jump and attach'], + // The web UI's tab switching, as close as a terminal can carry it: Alt+N + // matches exactly, while Alt+[ / Alt+] cannot be transmitted (ESC+[ IS the + // CSI introducer) so the brackets do that job unmodified. + ['alt+1-9', 'switch to that session, without attaching'], + ['[ / ]', 'previous / next session'], + ['tab', 'next session'], // The one key that is not the TUI's: an attach hands the terminal to tmux, // and leaving it is the question every first attach asks. [context.detach ?? detachChord(), 'detach from an attached session, back to here'], @@ -1431,6 +1437,31 @@ class TuiApp { this.afterInput(); } + /** + * An Alt chord: `Alt+1`..`Alt+9` switch session, everything else is replayed. + * + * Alt+N SELECTS rather than attaches. In the web UI Alt+N switches which tab + * you are looking at, which is cheap and reversible; the terminal equivalent + * is moving the selection and its preview, not handing the whole terminal to + * a pane. Bare 1-9 keeps its documented jump-and-attach meaning. + * + * ⚠️ Every OTHER chord is replayed as `escape` then the character, and that + * fallback is load-bearing rather than tidiness. A terminal encodes Alt+x as + * ESC then x, so a real Esc that lands in the same read as the next keystroke + * is byte-identical to a chord. Without the replay, "Esc then q" typed + * quickly decoded as Alt+Q, matched nothing, and was swallowed — the e2e test + * caught it as the dashboard refusing to quit. Replaying keeps every overlay + * dismissal and every existing key working exactly as before. + */ + private handleAlt(value: string): void { + if (this.model.mode === 'list' && value >= '1' && value <= '9') { + this.model.cursorToIndex(Number.parseInt(value, 10)); + return; + } + this.handle({ type: 'escape' }); + this.handle({ type: 'char', value }); + } + /** Every key can change the selection or the mode, and both steer the preview. */ private afterInput(): void { if (this.exiting) return; @@ -1440,6 +1471,10 @@ class TuiApp { private handle(event: TuiInputEvent): void { if (this.exiting) return; + if (event.type === 'alt') { + this.handleAlt(event.value); + return; + } switch (this.model.mode) { case 'confirm-kill': this.handleConfirm(event); @@ -1494,6 +1529,11 @@ class TuiApp { case 'char': this.handleListChar(event.value); return; + case 'tab': + // Ctrl+Tab in the web UI. A terminal cannot report the Ctrl, so plain + // Tab carries it: nothing else in the list wants the key. + this.model.moveCursor(1); + return; default: return; } @@ -1523,6 +1563,15 @@ class TuiApp { case 'k': this.model.moveCursor(-1); return; + // The web UI's Alt+[ / Alt+] for previous/next tab. WITHOUT the Alt, + // because ESC+[ is byte-identical to the CSI introducer every arrow key + // arrives on, so the chord cannot be transmitted by a terminal at all. + case '[': + this.model.moveCursor(-1); + return; + case ']': + this.model.moveCursor(1); + return; case 'q': this.quit(0); return; diff --git a/src/tui/tui-keys.ts b/src/tui/tui-keys.ts index 2435f0c4..2ea9cb8e 100644 --- a/src/tui/tui-keys.ts +++ b/src/tui/tui-keys.ts @@ -38,6 +38,7 @@ export type TuiInputEvent = | { type: 'backspace' } | { type: 'escape' } | { type: 'ctrl'; key: string } + | { type: 'alt'; value: string } | { type: 'key'; name: TuiNamedKey } | { type: 'mouse'; kind: TuiMouseKind; x: number; y: number; button: number }; @@ -107,9 +108,24 @@ export function createKeyParser(): TuiKeyParser { return { consumed: 3, events: name ? [{ type: 'key', name }] : NOTHING }; } - // Anything that is not a CSI is a lone ESC as far as we are concerned; the - // next byte then parses on its own (so Alt+x reads as Escape then `x`). - if (second !== 0x5b) return { consumed: 1, events: [{ type: 'escape' }] }; + // ESC followed by a printable character IN THE SAME READ is Alt+that key: + // that is how every terminal sends a meta chord. A lone Esc cannot look + // like this, because a buffer holding only ESC returns 'incomplete' above + // and is flushed as `escape` when the read ends, which is the standard way + // to tell the two apart without a timer. + // + // ⚠️ Three characters are deliberately NOT treated as Alt chords, because + // the terminal uses them to introduce sequences and a chord is + // indistinguishable from one: `[` (CSI) and `O` (SS3) would swallow every + // arrow key, and `]` (OSC) would swallow a terminal's colour-query reply. + // Alt+[ and Alt+] therefore cannot exist in a terminal at all, which is why + // the list binds bare `[` and `]` for the same job. + if (second !== 0x5b) { + if (second >= 0x20 && second <= 0x7e && second !== 0x4f && second !== 0x5d) { + return { consumed: 2, events: [{ type: 'alt', value: String.fromCharCode(second) }] }; + } + return { consumed: 1, events: [{ type: 'escape' }] }; + } let j = 2; while (j < buf.length && buf[j] >= 0x30 && buf[j] <= 0x3f) j++; diff --git a/test/tui/tui-keys.test.ts b/test/tui/tui-keys.test.ts index e94af8c2..f4c2c8ff 100644 --- a/test/tui/tui-keys.test.ts +++ b/test/tui/tui-keys.test.ts @@ -121,8 +121,11 @@ describe('escape sequences', () => { expect(decode('\x1b[M !!x')).toEqual([{ type: 'char', value: 'x' }]); }); - it('reads ESC followed by a letter as Escape then that letter', () => { - expect(decode('\x1bx')).toEqual([{ type: 'escape' }, { type: 'char', value: 'x' }]); + it('reads ESC followed by a letter as the Alt chord it is', () => { + // Changed deliberately: this used to decode as Escape + `x`, which made + // Alt+N unreachable. A lone Esc is still separable because it is HELD until + // the caller's timer flushes it (see the 'lone escape' suite). + expect(decode('\x1bx')).toEqual([{ type: 'alt', value: 'x' }]); }); }); @@ -208,3 +211,35 @@ describe('torn reads', () => { expect(parser.feed('\x1b[A')).toEqual([{ type: 'key', name: 'up' }]); }); }); + +describe('Alt chords', () => { + it('reads ESC + a printable character in one read as Alt+that key', () => { + expect(decode('\x1b1')).toEqual([{ type: 'alt', value: '1' }]); + expect(decode('\x1bk')).toEqual([{ type: 'alt', value: 'k' }]); + }); + + it('never steals the sequence introducers, or every arrow key would break', () => { + // ESC [ is CSI and ESC O is SS3: both are Up, not Alt+[ / Alt+O. + expect(decode('\x1b[A')).toEqual([{ type: 'key', name: 'up' }]); + expect(decode('\x1bOA')).toEqual([{ type: 'key', name: 'up' }]); + }); + + it('leaves ESC ] alone, so a terminal colour reply is never read as a chord', () => { + // OSC introducer: decoded as Escape then `]`, exactly as before. + expect(decode('\x1b]')).toEqual([{ type: 'escape' }, { type: 'char', value: ']' }]); + }); + + it('keeps a lone ESC held, which is what separates it from a chord', () => { + const parser = createKeyParser(); + expect(parser.feed('\x1b')).toEqual([]); + expect(parser.flush()).toEqual([{ type: 'escape' }]); + }); + + it('decodes a chord torn across two reads as Escape then the character', () => { + // The unavoidable ambiguity, resolved the standard way: same read = chord. + const parser = createKeyParser(); + expect(parser.feed('\x1b')).toEqual([]); + expect(parser.flush()).toEqual([{ type: 'escape' }]); + expect(parser.feed('1')).toEqual([{ type: 'char', value: '1' }]); + }); +});