mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +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;
|
approval?: TuiApprovalKeys;
|
||||||
/** tmux's detach chord as this socket reports it. Defaults to the stock `Ctrl+B D`. */
|
/** tmux's detach chord as this socket reports it. Defaults to the stock `Ctrl+B D`. */
|
||||||
detach?: string;
|
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':
|
case 'confirm-kill':
|
||||||
return ['type the name', `${glyphs.enter} confirm`, 'esc cancel'];
|
return ['type the name', `${glyphs.enter} confirm`, 'esc cancel'];
|
||||||
case 'message':
|
case 'message':
|
||||||
return ['esc dismiss'];
|
return context.resumeOffer ? ['r resume', 'esc dismiss'] : ['esc dismiss'];
|
||||||
case 'new-session':
|
case 'new-session':
|
||||||
return [`${glyphs.updown} select`, `${glyphs.enter} choose`, 'type to filter', 'esc cancel'];
|
return [`${glyphs.updown} select`, `${glyphs.enter} choose`, 'type to filter', 'esc cancel'];
|
||||||
case 'prompt':
|
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 {
|
private keymapContext(): TuiKeymapContext {
|
||||||
const approval = this.model.selectedSession()?.approval;
|
const approval = this.model.selectedSession()?.approval;
|
||||||
return {
|
return {
|
||||||
server: this.model.connection !== 'degraded',
|
server: this.model.connection !== 'degraded',
|
||||||
approval: approval ? (approval.kind === 'idle' ? 'idle' : 'menu') : null,
|
approval: approval ? (approval.kind === 'idle' ? 'idle' : 'menu') : null,
|
||||||
detach: this.detachChordLabel,
|
detach: this.detachChordLabel,
|
||||||
|
resumeOffer: this.resumeOffer !== null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1437,8 +1451,18 @@ class TuiApp {
|
|||||||
case 'digest':
|
case 'digest':
|
||||||
this.handleDigest(event);
|
this.handleDigest(event);
|
||||||
return;
|
return;
|
||||||
case 'help':
|
|
||||||
case 'message':
|
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
|
// Any key dismisses; the footer says esc because that is the one key
|
||||||
// every overlay in the app answers to.
|
// every overlay in the app answers to.
|
||||||
if (event.type !== 'mouse') this.model.closeOverlay();
|
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
|
// hands the terminal to a pane that reads nothing, which a beta tester
|
||||||
// experienced as the TUI freezing with no way out.
|
// experienced as the TUI freezing with no way out.
|
||||||
if (await this.client.isPaneDead(muxName)) {
|
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(
|
this.message(
|
||||||
'err',
|
'err',
|
||||||
`${rowLabel(row.session)} has exited — its pane is dead, so there is nothing there to type into. ` +
|
`${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
|
// ⚠️ message() only sets state. The keypress that got us here painted
|
||||||
// BEFORE this await resolved, so without a paint of our own the refusal
|
// 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);
|
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> {
|
private async startSession(caseName: string, mode: TuiRunMode): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const result = await this.client.quickStart({
|
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', () => {
|
describe('the one-key way out', () => {
|
||||||
it('names a single key with no modifier at all', () => {
|
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
|
// The whole point: three beta rounds died on a chord that had to be typed
|
||||||
|
|||||||
Reference in New Issue
Block a user