fix(tui): keep the way out on the bar, and make Alt+1..9 actually switch

Four faults, all reported at once, and three of them were mine from the last
two commits.

THE HINT VANISHED. Two independent causes. First, a leaked F1 binding: an
attach whose TUI was killed leaves `F1 -> detach-client` in tmux's root table,
and the claim treated "already bound" as someone else's key, so every later
attach fell back to advertising the tmux chord — the bar stopped saying F1
while F1 still worked. A key already bound to `detach-client` now counts as
ours. Second, width: tmux truncates a status line that overflows and drops the
RIGHT-aligned segment, which is the hint. The strip now gets a budget measured
from the terminal's width minus the hint, and it drops tabs from the far end
until it fits. ⚠️ Measured on VISIBLE columns, not format bytes: `#[reverse]`
costs zero columns, and counting it made a strip that "fitted" still truncate
the hint at 80, 100, 120 and 176 columns on a real terminal.

ALT+N DID NOT SWITCH. On the dashboard, a bare digit meant jump AND ATTACH, and
a terminal sends Alt+N as ESC then N: when those land in separate reads —
routine over SSH — the chord decodes as Escape plus a bare digit, so "switch to
tab 2" threw the user into tab 2's pane. A digit now SELECTS, matching what
Alt+N means in the web UI; Enter is how you go in. Inside a pane the keys never
reached the TUI at all, since tmux owns the terminal, so the attach now binds
Alt+1..9 in tmux's root table to `switch-client` — the strip is usable rather
than decorative. ⚠️ The bar is applied to every session the strip can reach,
each highlighting its own tab: with it on the attached session only, switching
landed the user in a pane with no strip and no way out on screen.

⚠️ The leaked-state sweep was missing `status-position`, so it removed the
marker and left the position behind — and with no marker the leftover no longer
matched, making it permanently unsweepable. Found by diffing every session's
options after a detach.
This commit is contained in:
Codeman maintainer
2026-08-20 02:40:05 +02:00
parent 4eb13ba493
commit c18ef66544
5 changed files with 185 additions and 33 deletions
+34 -2
View File
@@ -161,7 +161,7 @@ describe('footerKeysFor', () => {
it('advertises only the verbs this build implements', () => {
const keys = footerKeysFor('list', GLYPHS, { server: true }).join(' ');
expect(keys).toContain('attach');
expect(keys).toContain('1-9 jump');
expect(keys).toContain('1-9 switch');
expect(keys).toContain('n new');
expect(keys).toContain('p prompt');
expect(keys).toContain('/ search');
@@ -179,7 +179,7 @@ describe('footerKeysFor', () => {
expect(keys).toContain('1-9 option');
// `n` cannot mean two things at once, and denying is what it does here.
expect(keys).not.toContain('n new');
expect(keys).not.toContain('1-9 jump');
expect(keys).not.toContain('1-9 switch');
});
it('sends an idle prompt to the composer instead of offering approve/deny', () => {
@@ -673,6 +673,38 @@ describe('the way out of an attach', () => {
expect(crowded['status-format[0]']).toContain('#[bold]F1#[nobold] back to the codeman dashboard');
});
it('fits the strip to the terminal, measuring VISIBLE columns not format bytes', () => {
// The test above only checks the hint is in the format STRING, which it
// always was. tmux truncates what it cannot fit and drops the right-aligned
// segment, so on a real terminal the hint vanished at every width tested
// while that assertion stayed green.
const hint = ' F1 back to the codeman dashboard ';
for (const cols of [80, 100, 120, 176]) {
const bar = buildAttachBanner({
oneKey: 'F1',
cols,
tabs: Array.from({ length: 12 }, (_, i) => ({
index: i + 1,
label: `w${i + 1}-session-name`,
active: i === 1,
})),
})['status-format[0]'];
const visible = bar.replace(/#\[[^\]]*\]/g, '');
expect({ cols, fits: visible.length <= cols }).toEqual({ cols, fits: true });
expect(visible.length).toBeGreaterThanOrEqual(hint.length);
}
});
it('keeps at least one tab even when the hint eats almost the whole bar', () => {
const bar = buildAttachBanner({
oneKey: 'F1',
cols: 40,
tabs: [{ index: 1, label: 'w1-case', active: true }],
})['status-format[0]'];
expect(bar).toContain('1 w1-case');
expect(bar).toContain('back to the codeman dashboard');
});
it("tells the help overlay how to get back, in the socket's own prefix", () => {
const keys = helpKeysFor(GLYPHS, { server: true, detach: 'Ctrl+A then d' });
const detach = keys.find(([key]) => key === 'Ctrl+A then d');
+5
View File
@@ -659,6 +659,11 @@ describe('TuiClient.clearLeakedAttachBanners', () => {
// leaves an EMPTY array, which renders as a blank bar.
expect(sets.some((args) => args.includes('-u') && args.includes('status-format'))).toBe(true);
expect(sets.some((args) => args.includes('-u') && args.includes('status-style'))).toBe(true);
// ⚠️ Every option the banner writes must be undone by the pass that
// recognises it. `status-position` was missing, so a sweep removed the
// marker and left the position behind — and with no marker the leftover
// stopped matching, making it permanently unsweepable.
expect(sets.some((args) => args.includes('-u') && args.includes('status-position'))).toBe(true);
expect(sets.some((args) => args.join(' ').endsWith('status off'))).toBe(true);
expect(sets.every((args) => !args.includes('status-format[0]'))).toBe(true);
});
+2 -2
View File
@@ -158,7 +158,7 @@ describe('renderFrame structure', () => {
' │',
' │',
' │',
' ↑↓ select · ↵ attach · 1-9 jump · y/n answer · p prompt · n new · x kill · / search · g digest · ?',
' ↑↓ select · ↵ attach · 1-9 switch · y/n answer · p prompt · n new · x kill · / search · g digest ·',
]);
});
@@ -183,7 +183,7 @@ describe('renderFrame structure', () => {
'',
'',
'',
' ↑↓ select · ↵ attach · 1-9 jump · y/n answe',
' ↑↓ select · ↵ attach · 1-9 switch · y/n ans',
]);
});