mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(tui): offer r to resume a session whose pane has died
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 "<name>"`. 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.
This commit is contained in:
+57
-3
@@ -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 "<name>"`. 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<void> {
|
||||
try {
|
||||
const result = await this.client.quickStart({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user