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
+49
View File
@@ -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;
+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++;
+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' }]);
});
});