diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 4054619f..04d222a6 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -55,6 +55,7 @@ import { approvalAnswerForKey, newApprovalIds } from './tui-approvals.js'; import { composerScroll, composerStep, composerText, createComposer, type TuiComposerState } from './tui-composer.js'; import { formatAwayDigest } from './tui-digest.js'; import { + ATTACH_BANNER_MARKER, TuiClient, type TuiApprovalAnswer, type TuiEventStream, @@ -316,7 +317,7 @@ export function buildAttachBanner(options: { return { status: 'on', 'status-style': 'bg=default,fg=default', - 'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold] to detach, back to the codeman dashboard${right}#[default]`, + 'status-format[0]': `#[align=left] press #[bold]${chord}#[nobold] to detach, ${ATTACH_BANNER_MARKER}${right}#[default]`, }; } @@ -981,6 +982,10 @@ class TuiApp { await this.refresh(); if (server) this.subscribe(); + // Fire and forget: a bar stranded by a previous run is cosmetic, so it must + // never delay the first frame or fail a start. + void this.client.clearLeakedAttachBanners().catch(() => undefined); + return new Promise((resolve) => { this.resolveExit = resolve; }); diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index fe3dde26..0216cd2b 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -467,6 +467,14 @@ export function parseSessionOptions(stdout: string, keys: readonly string[]): Tu return options; } +/** + * The phrase the attach status bar carries. It doubles as the marker that tells + * OUR bar apart from a user's own when sweeping one that leaked, so + * `buildAttachBanner()` composes its text from this constant rather than + * repeating the words. + */ +export const ATTACH_BANNER_MARKER = 'back to the codeman dashboard'; + /** * The key bound to a bare `detach-client` in `list-keys -T prefix` output, or * null when nothing there detaches. @@ -945,6 +953,59 @@ export class TuiClient { } /** One `set-option`, swallowing failure: the session may be gone by now. */ + /** + * Drop attach status bars that a previous TUI never got to take down. + * + * `restore()` runs after `spawnSync` returns, which covers a detach and an + * agent exiting inside the pane, but not the terminal DYING while attached: + * a closed window or a dropped SSH kills the TUI where it stands, and the bar + * it installed stays pinned on the session (observed on the beta, where the + * tester closed the window instead of detaching and the next attach still + * wore a stale bar naming the wrong session). One sweep at startup makes that + * self-healing. + * + * Only a bar carrying our own marker is touched, and it is put back the way + * Codeman creates its panes (`status off`), which is the only state this bar + * is ever applied over. + */ + async clearLeakedAttachBanners(): Promise { + let names: string[]; + try { + const { stdout } = await this.exec('tmux', ['-L', this.socket, 'list-sessions', '-F', '#{session_name}']); + names = stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => MUX_NAME_PATTERN.test(line)); + } catch { + return 0; + } + let cleared = 0; + for (const name of names) { + let current = ''; + try { + ({ stdout: current } = await this.exec('tmux', [ + '-L', + this.socket, + 'show-options', + '-t', + name, + '-v', + 'status-format[0]', + ])); + } catch { + continue; + } + if (!current.includes(ATTACH_BANNER_MARKER)) continue; + // The array WHOLE, for the same reason restoreSessionOptions() does it: + // unsetting one index leaves an empty array, which renders as a blank bar. + await this.setOption(['-u', '-t', name, 'status-format']); + await this.setOption(['-u', '-t', name, 'status-style']); + await this.setOption(['-t', name, 'status', 'off']); + cleared += 1; + } + return cleared; + } + private async setOption(args: readonly string[]): Promise { try { await this.exec('tmux', ['-L', this.socket, 'set-option', ...args]); diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index b5d695e5..01e274cc 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -23,6 +23,7 @@ import { arrayOptionBase, parseSessionOptions, parseDetachKey, + ATTACH_BANNER_MARKER, parseTmuxSessionList, parseWindowSizing, readCodemanCredentials, @@ -600,6 +601,61 @@ describe('attach window sizing', () => { }); }); +describe('TuiClient.clearLeakedAttachBanners', () => { + const OURS = `#[align=left] press #[bold]Ctrl+B then d#[nobold] to detach, ${ATTACH_BANNER_MARKER} #[default]`; + + function sweeper(formats: Record): { client: TuiClient; calls: string[][] } { + const calls: string[][] = []; + const exec: TuiExecFile = async (_file, args) => { + calls.push([...args]); + if (args.includes('list-sessions')) return { stdout: Object.keys(formats).join('\n'), stderr: '' }; + if (args.includes('show-options')) { + const name = args[args.indexOf('-t') + 1] ?? ''; + return { stdout: formats[name] ?? '', stderr: '' }; + } + return { stdout: '', stderr: '' }; + }; + return { client: new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }), calls }; + } + + it('takes down a bar a killed TUI left behind, and puts status back off', async () => { + // The case that produced this: the tester closed the terminal window while + // attached, so restore() never ran and the bar stayed pinned. + const { client, calls } = sweeper({ 'codeman-aaaa1111': OURS }); + expect(await client.clearLeakedAttachBanners()).toBe(1); + const sets = calls.filter((args) => args.includes('set-option')); + // The array whole, never one index: unsetting `status-format[0]` alone + // 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); + expect(sets.some((args) => args.join(' ').endsWith('status off'))).toBe(true); + expect(sets.every((args) => !args.includes('status-format[0]'))).toBe(true); + }); + + it('never touches a status bar that is not ours', async () => { + const { client, calls } = sweeper({ 'codeman-bbbb2222': '#[align=right] my own bar ' }); + expect(await client.clearLeakedAttachBanners()).toBe(0); + expect(calls.filter((args) => args.includes('set-option'))).toEqual([]); + }); + + it('sweeps only the sessions that leaked, leaving the rest alone', async () => { + const { client } = sweeper({ + 'codeman-aaaa1111': OURS, + 'codeman-bbbb2222': '', + 'codeman-cccc3333': OURS, + }); + expect(await client.clearLeakedAttachBanners()).toBe(2); + }); + + it('reports nothing rather than throwing when there is no tmux server', async () => { + const exec: TuiExecFile = async () => { + throw new Error('no server running on /tmp/tmux-1000/codeman-beta'); + }; + const client = new TuiClient({ baseUrl: BASE_URL, socket: 'codeman-beta', exec }); + await expect(client.clearLeakedAttachBanners()).resolves.toBe(0); + }); +}); + describe('parseDetachKey', () => { // Verbatim from `tmux -L codeman list-keys -T prefix` on tmux 3.4, trimmed to // the two lines that matter. They differ only by case, which is the whole