diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 04d222a6..33f27edd 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -324,6 +324,24 @@ export function buildAttachBanner(options: { /** Long enough for a session name, short enough to survive a narrow terminal. */ const ATTACH_BANNER_LABEL_MAX = 28; +/** + * The name a newly started session gets: `w-`, the same convention the + * web UI uses, with `n` one past the highest already in use. + * + * A session created with no name at all is not merely unlabelled: rowLabel() + * falls back to the transcript's first line, and a session that has not been + * prompted yet gets named after whatever its CLI printed while starting up. + */ +export function nextSessionName(caseName: string, existing: readonly string[]): string { + let highest = 0; + for (const name of existing) { + const match = /^w(\d+)-/.exec((name ?? '').trim()); + const index = match ? Number.parseInt(match[1] ?? '', 10) : Number.NaN; + if (Number.isSafeInteger(index) && index > highest) highest = index; + } + return `w${highest + 1}-${caseName}`; +} + /** * What pressing Enter on a RECENT row does, decided from the row alone. * @@ -1887,6 +1905,23 @@ class TuiApp { return; } + // A dead pane still LISTS, because Codeman keeps `remain-on-exit on`: the + // row looks ordinary and the server still calls it idle. Attaching to one + // hands the terminal to a pane that reads nothing, which a beta tester + // experienced as the TUI freezing with no way out. + if (await this.client.isPaneDead(muxName)) { + this.message( + 'err', + `${rowLabel(row.session)} has exited — its pane is dead, so there is nothing there to type into. ` + + 'Close the row with x, or start a fresh session with n.' + ); + // ⚠️ message() only sets state. The keypress that got us here painted + // BEFORE this await resolved, so without a paint of our own the refusal + // is invisible and Enter looks like it did nothing at all. + this.paint(); + return; + } + // tmux is about to own this terminal. The dashboard is not on screen, and // the pane the preview would keep re-reading is the one the user is now // looking at directly, so the poll stops for the whole handoff (an attach @@ -2078,7 +2113,19 @@ class TuiApp { private async startSession(caseName: string, mode: TuiRunMode): Promise { try { - const result = await this.client.quickStart({ caseName, mode }); + const result = await this.client.quickStart({ + caseName, + mode, + // Named here rather than left to the server: an unnamed session falls + // back to whatever rowLabel() can find, and before the user has typed + // anything that was the CLI's first line of output. `w-` is + // the web UI's own convention, so a session started from either surface + // reads the same in both. + sessionName: nextSessionName( + caseName, + this.model.sessions().map((session) => session.name ?? '') + ), + }); // The row appears with the next resync; remember which one to select. this.pendingSelectId = result.sessionId; this.message('info', `started ${mode} in ${caseName}`); diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index 0216cd2b..71a75980 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -897,6 +897,36 @@ export class TuiClient { } } + /** + * Is this session's active pane DEAD — the process it ran has exited and tmux + * is holding the corpse on screen? + * + * Codeman sets `remain-on-exit on` for every pane it owns, so a session whose + * agent exited does not disappear: it stays listed, the server still reports + * it `idle`, and attaching hands the terminal to a pane that reads no input. + * A beta tester hit exactly that and could not type or get out. + * + * Fails OPEN (`false`): a probe that cannot run must never block an attach to + * a pane that is perfectly alive. + */ + async isPaneDead(muxName: string): Promise { + if (!MUX_NAME_PATTERN.test(muxName)) return false; + try { + const { stdout } = await this.exec('tmux', [ + '-L', + this.socket, + 'display-message', + '-p', + '-t', + muxName, + '#{pane_dead}', + ]); + return stdout.trim() === '1'; + } catch { + return false; + } + } + /** Snapshot the session-level options an attach is about to overwrite. */ async readSessionOptions(muxName: string, keys: readonly string[]): Promise { if (!MUX_NAME_PATTERN.test(muxName)) return null; diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index 3e0757a4..e393f218 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -254,9 +254,17 @@ export function formatPlanUsage(usage: StatusTelemetry | null | undefined, separ */ export function rowLabel(session: TuiSessionRow): string { if (session.name) return session.name; + const base = (session.workingDir ?? '').split('/').filter(Boolean).pop(); + // ⚠️ A LIVE pane (it has a mux name) is identified by WHERE it runs, never by + // a line scraped out of its transcript. A session created before the user has + // typed anything has no prompt to be named after, so the fallback took + // whatever the CLI happened to print first: a beta tester's new session + // appeared in the list called "Login interrupted", which reads like a failure + // report and was in fact a healthy session. A history row is the opposite + // case, where the prompt IS the identity, so it keeps the old order. + if (session.muxName && base) return base; const prompt = (session.firstPrompt ?? '').trim(); if (prompt && prompt !== '(no content)') return prompt; - const base = (session.workingDir ?? '').split('/').filter(Boolean).pop(); return base || session.sessionId.slice(0, 8); } diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index 28b1d751..9283a078 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -18,6 +18,7 @@ import { confirmAccepts, confirmKillStep, detachChord, + nextSessionName, footerKeysFor, formatPrefixKey, helpKeysFor, @@ -457,6 +458,25 @@ describe('buildListLines', () => { }); }); +describe('naming a session the TUI starts', () => { + it("follows the web UI's w- convention", () => { + expect(nextSessionName('mirofish', [])).toBe('w1-mirofish'); + expect(nextSessionName('mirofish', ['w1-codeman', 'w2-codeman'])).toBe('w3-mirofish'); + }); + + it('counts past names that are not w- at all', () => { + // A session named by hand, or by another surface, must not reset the run. + expect(nextSessionName('demo', ['tui-demo-agent', 'w4-codeman', ''])).toBe('w5-demo'); + }); + + it('never returns an empty name, which is what caused the bad label', () => { + // An unnamed session falls through rowLabel() to the transcript's first + // line, which put "Login interrupted" in the list as a session name. + expect(nextSessionName('c', [])).not.toBe(''); + expect(nextSessionName('c', ['w9007199254740991-x'])).toMatch(/^w\d+-c$/); + }); +}); + describe('the way out of an attach', () => { it('spells the prefix the way a human reads it, and never assumes C-b', () => { expect(formatPrefixKey('C-b')).toBe('Ctrl+B'); diff --git a/test/tui/tui-render.test.ts b/test/tui/tui-render.test.ts index ce34c9f8..36e07cef 100644 --- a/test/tui/tui-render.test.ts +++ b/test/tui/tui-render.test.ts @@ -410,6 +410,39 @@ describe('formatting helpers', () => { ); expect(rowLabel({ sessionId: 'abcdef1234', sources: [] })).toBe('abcdef12'); }); + + it('names a LIVE pane after its case, never after scraped output', () => { + // Regression: a session started from the TUI before the user typed anything + // had no name and no prompt, so the fallback took the CLI's first line of + // output. A healthy new session showed up in the list called + // "Login interrupted", which reads like a failure. + expect( + rowLabel({ + sessionId: 'abcdef12', + firstPrompt: 'Login interrupted', + workingDir: '/home/u/codeman-cases/mirofish', + muxName: 'codeman-abcdef12', + sources: [], + }) + ).toBe('mirofish'); + }); + + it('still names a HISTORY row by its prompt, where the prompt IS the identity', () => { + expect( + rowLabel({ + sessionId: 'abcdef12', + firstPrompt: 'do the thing', + workingDir: '/home/u/codeman-cases/mirofish', + sources: [], + }) + ).toBe('do the thing'); + }); + + it('prefers a real name over both, on a live row and a history row alike', () => { + const base = { sessionId: 'abcdef12', firstPrompt: 'Login interrupted', workingDir: '/a/b/case', sources: [] }; + expect(rowLabel({ ...base, name: 'w2-case' })).toBe('w2-case'); + expect(rowLabel({ ...base, name: 'w2-case', muxName: 'codeman-abcdef12' })).toBe('w2-case'); + }); }); describe('the approval card', () => {