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
+19 -3
View File
@@ -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++;