fix(tui): refuse to attach to a dead pane, and stop naming sessions after CLI noise

Two more from the same beta round, both reported as "basic things are broken".

Attaching to a DEAD pane trapped the user. Codeman sets `remain-on-exit on`, so
a session whose agent has exited does not disappear: the row looks ordinary,
the server still reports it idle, and Enter handed the terminal to a pane that
reads no input. With the detach chord also wrong at the time, that was a hard
freeze with no way out. Enter now probes `#{pane_dead}` first and refuses with
an Error card naming the session and what to do instead. The probe fails OPEN,
so it can never block an attach to a live pane. ⚠️ It also has to paint: the
keypress that reaches attachToSession() has already painted by the time an
awaited probe resolves, so message() alone left the refusal invisible and Enter
looked inert, which is the bug it was added to fix.

A session started from the TUI came out unnamed, because startSession() sent no
sessionName and rowLabel() then fell back to the transcript's first line. A
brand-new session has no prompt to be named after, so the list showed a
perfectly healthy session called "Login interrupted" — the CLI's startup
output, reading like a failure report. Sessions the TUI starts are now named
`w<n>-<case>` like the web UI's, and rowLabel() prefers the case directory over
a scraped prompt for any row with a mux name, since a LIVE pane is identified
by where it runs while a history row genuinely is its prompt.
This commit is contained in:
Codeman maintainer
2026-08-20 00:45:27 +02:00
parent 1b6cab1173
commit 6cce77eebb
5 changed files with 140 additions and 2 deletions
+20
View File
@@ -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<n>-<case> 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<n>- 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');
+33
View File
@@ -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', () => {