mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
fix(tui): sweep an attach status bar a killed terminal left behind
restore() runs after spawnSync returns, which covers detaching and the agent exiting inside the pane, but not the terminal dying while attached. Closing the window or dropping the SSH kills the TUI where it stands, and the bar it installed stays pinned on the session: the next attach wears a stale bar naming a different session, and the pane is a row shorter for good. Seen on the beta, where the tester closed the window instead of detaching. One sweep at startup, fire-and-forget so it can neither delay the first frame nor fail a start. Only a bar carrying our own marker is touched, and the marker is now the single source of the bar's own wording so the two cannot drift; a user's hand-written status bar on the same session is left exactly as it is. The session goes back to `status off`, which is how Codeman creates every pane it owns and the only state this bar is ever applied over.
This commit is contained in:
+6
-1
@@ -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<number>((resolve) => {
|
||||
this.resolveExit = resolve;
|
||||
});
|
||||
|
||||
@@ -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<number> {
|
||||
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<void> {
|
||||
try {
|
||||
await this.exec('tmux', ['-L', this.socket, 'set-option', ...args]);
|
||||
|
||||
@@ -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<string, string>): { 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
|
||||
|
||||
Reference in New Issue
Block a user