From 84f8a8a2fee944952d2d65988ca3294e5ba0cc3f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 01:08:49 +0200 Subject: [PATCH] feat(tui): offer r to resume a session whose pane has died MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refusing the attach stopped the freeze but told the user to throw the session away (`x` to close, `n` for new), which loses the conversation. tmux's own dead-pane screen already says what to do instead: `claude --resume ""`. The Error card now offers `r` when the row can actually be resumed (claude, with a conversation id and a working directory), and the footer says so. One press resumes into a fresh pane and attaches to it, so a dead end becomes recovery. ⚠️ Three things keep this from becoming the resume runaway that once spawned 35 sessions in 40 seconds. The offer holds a session ID, not a row, and is re-resolved from the model when the key is pressed: a row captured when the card opened is stale by then. It disarms BEFORE anything async, so a second `r` cannot start a second resume. And it routes through resumeSelected(), which owns the `resuming` flag and ends in attachToSession() rather than the group dispatch. ⚠️ The `r` branch has to run BEFORE the generic dismiss, because a message overlay is dismissed by ANY key: without that ordering the offer is consumed as "some key was pressed" and the card merely closes. `help` keeps the any-key behaviour, so the two modes no longer share a case. Verified end to end against a genuinely dead claude pane: card, footer, one press, one new session, and F12 back to the dashboard. --- src/tui/tui-app.ts | 60 ++++++++++++++++++++++++++++++++++++++-- test/tui/tui-app.test.ts | 10 +++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index 900484f1..8d7b8f8a 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -561,6 +561,8 @@ export interface TuiKeymapContext { approval?: TuiApprovalKeys; /** tmux's detach chord as this socket reports it. Defaults to the stock `Ctrl+B D`. */ detach?: string; + /** A dead-row card is offering `r` to resume, so the footer has to say so. */ + resumeOffer?: boolean; } /** @@ -576,7 +578,7 @@ export function footerKeysFor(mode: TuiUiMode, glyphs: TuiGlyphSet, context: Tui case 'confirm-kill': return ['type the name', `${glyphs.enter} confirm`, 'esc cancel']; case 'message': - return ['esc dismiss']; + return context.resumeOffer ? ['r resume', 'esc dismiss'] : ['esc dismiss']; case 'new-session': return [`${glyphs.updown} select`, `${glyphs.enter} choose`, 'type to filter', 'esc cancel']; case 'prompt': @@ -1339,12 +1341,24 @@ class TuiApp { }); } + /** + * The session a dead-row error card is offering to resume, by id. + * + * An id rather than the row: by the time the key is pressed the model has + * resynced at least once, and acting on a captured row would resume whatever + * that stale object still pointed at. Armed only while the card is up, and + * cleared the moment anything else happens, so `r` can never resume a session + * the user is no longer looking at. + */ + private resumeOffer: string | null = null; + private keymapContext(): TuiKeymapContext { const approval = this.model.selectedSession()?.approval; return { server: this.model.connection !== 'degraded', approval: approval ? (approval.kind === 'idle' ? 'idle' : 'menu') : null, detach: this.detachChordLabel, + resumeOffer: this.resumeOffer !== null, }; } @@ -1437,8 +1451,18 @@ class TuiApp { case 'digest': this.handleDigest(event); return; - case 'help': case 'message': + // ⚠️ `r` is checked BEFORE the dismiss, because a message overlay is + // dismissed by ANY key: without this branch the offer would be consumed + // as "some key was pressed" and the card would just close. + if (this.resumeOffer && event.type === 'char' && event.value === 'r') { + this.takeResumeOffer(); + return; + } + this.resumeOffer = null; + if (event.type !== 'mouse') this.model.closeOverlay(); + return; + case 'help': // Any key dismisses; the footer says esc because that is the one key // every overlay in the app answers to. if (event.type !== 'mouse') this.model.closeOverlay(); @@ -1971,10 +1995,17 @@ class TuiApp { // 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)) { + // Its conversation usually survives the pane: tmux's own dead-pane screen + // says `claude --resume ""`. Offering that turns a dead end into + // recovery, instead of telling the user to throw the work away. + const offer = planResume(row.session).kind === 'resume'; + this.resumeOffer = offer ? row.session.sessionId : null; 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.' + (offer + ? 'Press r to resume the conversation in a fresh pane, or x to close the row.' + : '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 @@ -2172,6 +2203,29 @@ class TuiApp { if (caseName && mode) void this.startSession(caseName, mode.id); } + /** + * Act on the dead-row card's offer: resume the conversation whose pane died. + * + * ⚠️ Re-resolved from the model by id and disarmed BEFORE anything async, so + * a second `r` cannot start a second resume. `resumeSelected()` owns the rest + * of the loop safety (its own `resuming` flag, and it ends in + * `attachToSession`, never the group dispatch that once produced 35 sessions + * in 40 seconds). + */ + private takeResumeOffer(): void { + const sessionId = this.resumeOffer; + this.resumeOffer = null; + this.model.closeOverlay(); + if (!sessionId) return; + const row = this.model.rows().find((candidate) => candidate.session.sessionId === sessionId); + if (!row) { + this.message('warn', 'that row is gone; the list has moved on since the card opened'); + this.paint(); + return; + } + void this.resumeSelected(row); + } + private async startSession(caseName: string, mode: TuiRunMode): Promise { try { const result = await this.client.quickStart({ diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index d66a1ed7..c220d308 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -460,6 +460,16 @@ describe('buildListLines', () => { }); }); +describe('the dead-row resume offer', () => { + it('advertises r on the message footer only while an offer is armed', () => { + const base = { server: true } as const; + expect(footerKeysFor('message', GLYPHS, { ...base, resumeOffer: true })).toEqual(['r resume', 'esc dismiss']); + // Without an offer the card is a plain notice, and a footer promising `r` + // would be advertising a key that does nothing. + expect(footerKeysFor('message', GLYPHS, base)).toEqual(['esc dismiss']); + }); +}); + describe('the one-key way out', () => { it('names a single key with no modifier at all', () => { // The whole point: three beta rounds died on a chord that had to be typed