feat(tui): switch sessions with the web UI's shortcuts

Alt+1..9 switches to that session, and `[` / `]` / Tab step through them, so the
muscle memory from the web UI carries over.

Alt+N SELECTS rather than attaches, which is what the web UI's Alt+N does:
switching which tab you look at is cheap and reversible, and 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.

Two of the web UI's chords cannot cross into a terminal, so the nearest
transmittable keys carry them instead:

  Alt+[ / Alt+]  ESC+[ IS the CSI introducer every arrow key arrives on, and
                 ESC+] is OSC, so neither chord is distinguishable from a
                 sequence. Bare `[` and `]` do the job.
  Ctrl+Tab       a terminal cannot report the Ctrl, so plain Tab carries it.

⚠️ The parser now decodes ESC + a printable character in ONE read as an Alt
chord, and the app replays every chord it does not claim as `escape` then that
character. That fallback is load-bearing, not tidiness: a real Esc landing in
the same read as the next keystroke is byte-identical to a chord, and without
the replay "Esc then q" typed quickly decoded as Alt+Q, matched nothing and was
swallowed. The e2e suite caught exactly that as the dashboard refusing to quit.
A lone Esc is still held and flushed on the caller's timer, which is what keeps
the two separable at all.
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent e7b7e90a1b
commit 95a1f540b5
3 changed files with 105 additions and 5 deletions
+37 -2
View File
@@ -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' }]);
});
});