fix(tui): confirm a kill with y, and make the dialog say what it would kill

Killing demanded the session's NAME typed out in full. That is the right
ceremony for dropping a production database and the wrong one for closing a
pane you are looking at; the beta tester's verdict was "thats stupid, just make
me type Y to confirm". `x` then `y` is already two deliberate keystrokes on a
row the user selected, and the conversation lives in its transcript, which a
kill does not touch.

Everything that is not `y` CANCELS rather than being ignored, so a stray key
closes the dialog instead of leaving a destructive prompt armed and waiting for
whatever gets typed next. Enter cancels too: it is the key most likely to be
hit by reflex, and this is the one dialog that destroys something.

⚠️ Found while verifying the new dialog: it did not name the session. The label
was computed as `row.session.name ?? id.slice(0, 8)`, and `??` falls back only
on null or undefined, so every session the server left with an EMPTY name — all
of them, until the TUI started naming its own — sailed through and the box read
"Kill ?". A destructive prompt that cannot say what it will destroy is worse
than no prompt, and it is now a single keystroke. The caller passes the same
label the LIST shows, so the dialog names the row in front of the user.

The typed-name machinery goes with it: TuiConfirmState.typed, setConfirmInput(),
confirmAccepts() and the 'typing'/'reject' steps are all removed rather than
left as unreachable branches.
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent 7b1150ca4f
commit b2ac6c1bd9
8 changed files with 75 additions and 84 deletions
+21 -30
View File
@@ -508,33 +508,30 @@ export function isSelfSession(sessionId: string, env: { CODEMAN_SESSION_ID?: str
// Kill confirmation (pure)
// ─────────────────────────────────────────────────────────────────────────────
export type TuiConfirmStep =
| { kind: 'typing'; typed: string }
| { kind: 'confirm' }
| { kind: 'reject' }
| { kind: 'cancel' }
| { kind: 'ignore' };
/** Does the typed text authorize the kill? The shown name, or the id prefix a mux name carries. */
export function confirmAccepts(state: TuiConfirmState, typed = state.typed): boolean {
const value = typed.trim();
if (value === '') return false;
return value === state.name || value === state.sessionId.slice(0, 8);
}
export type TuiConfirmStep = { kind: 'confirm' } | { kind: 'cancel' } | { kind: 'ignore' };
/**
* One keystroke of the typed confirmation. Enter on text that does not match is
* a `reject`, never a silent no-op: a confirmation that appears to do nothing
* reads as a broken key.
* One keystroke of the kill confirmation: `y` kills, anything else does not.
*
* ⚠️ It used to demand the session's NAME typed out in full. That is the right
* ceremony for deleting a production database and the wrong one for closing a
* pane you are looking at: the beta tester's verdict was "thats stupid, just
* make me type Y to confirm". Kill is already two deliberate keystrokes (`x`
* then `y`) on a row the user selected, and the session's work lives in its
* transcript, which a kill does not touch.
*
* Everything that is NOT `y` cancels rather than being ignored, so a stray key
* closes the dialog instead of leaving a live kill prompt waiting for whatever
* the user types next.
*/
export function confirmKillStep(state: TuiConfirmState, event: TuiInputEvent): TuiConfirmStep {
export function confirmKillStep(_state: TuiConfirmState, event: TuiInputEvent): TuiConfirmStep {
switch (event.type) {
case 'char':
return { kind: 'typing', typed: state.typed + event.value };
case 'backspace':
return { kind: 'typing', typed: [...state.typed].slice(0, -1).join('') };
return event.value === 'y' || event.value === 'Y' ? { kind: 'confirm' } : { kind: 'cancel' };
case 'enter':
return confirmAccepts(state) ? { kind: 'confirm' } : { kind: 'reject' };
// Enter alone is NOT a confirmation: it is the key most likely to be
// pressed by reflex, and this is the one dialog that destroys something.
return { kind: 'cancel' };
case 'escape':
return { kind: 'cancel' };
case 'ctrl':
@@ -576,7 +573,7 @@ export function footerKeysFor(mode: TuiUiMode, glyphs: TuiGlyphSet, context: Tui
case 'help':
return ['esc close'];
case 'confirm-kill':
return ['type the name', `${glyphs.enter} confirm`, 'esc cancel'];
return ['y kill', 'any other key cancels'];
case 'message':
return context.resumeOffer ? ['r resume', 'esc dismiss'] : ['esc dismiss'];
case 'new-session':
@@ -629,7 +626,7 @@ export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Arr
['/', 'search sessions, events and files'],
['g', 'away digest'],
['n', 'new session'],
['x', 'kill (typed confirmation)']
['x', 'kill (y to confirm)']
);
}
keys.push(['?', 'this help'], ['esc', 'close an overlay'], ['q', 'quit']);
@@ -1606,15 +1603,9 @@ class TuiApp {
}
const step = confirmKillStep(state, event);
switch (step.kind) {
case 'typing':
this.model.setConfirmInput(step.typed);
return;
case 'cancel':
this.model.closeOverlay();
return;
case 'reject':
this.message('warn', `type "${state.name}" exactly, or esc to cancel`);
return;
case 'confirm':
void this.killSession(state.sessionId, state.name);
return;
@@ -2134,7 +2125,7 @@ class TuiApp {
this.message('warn', 'that is the session this TUI is running in');
return;
}
this.model.beginConfirmKill(row);
this.model.beginConfirmKill(row, rowLabel(row.session));
}
private async killSession(sessionId: string, name: string): Promise<void> {
+9 -9
View File
@@ -442,22 +442,22 @@ export class TuiModelStore implements TuiRenderModel {
* for every caller: a second copy here answered the same question differently
* (it refused the id prefix a mux name carries) and nothing consulted it.
*/
beginConfirmKill(row: TuiRow): void {
beginConfirmKill(row: TuiRow, label: string): void {
this.confirm = {
sessionId: row.session.sessionId,
name: row.session.name ?? row.session.sessionId.slice(0, 8),
typed: '',
// ⚠️ Passed in, not derived here. `row.session.name ?? id.slice(0,8)`
// used to compute it, and `??` falls back only on null/undefined: a
// session whose name is the EMPTY STRING (every session the server did
// not name) sailed through it and the dialog read "Kill ?". A destructive
// prompt that cannot say what it is about to destroy is worse than no
// prompt, and it is now one keystroke. The caller passes the same label
// the LIST shows, so the dialog names the row the user is looking at.
name: label,
};
this.mode = 'confirm-kill';
this.touch();
}
setConfirmInput(typed: string): void {
if (!this.confirm) return;
this.confirm = { ...this.confirm, typed };
this.touch();
}
/** Drop whatever overlay owns the keyboard and go back to the list. */
closeOverlay(): void {
this.confirm = null;
+2 -3
View File
@@ -614,7 +614,7 @@ const FOOTER_KEYS: Record<string, (glyphs: TuiGlyphSet) => string> = {
'q quit',
].join(` ${g.separator} `),
help: (g) => `esc ${g.separator} ? close`,
'confirm-kill': (g) => `type the name ${g.separator} ${g.enter} confirm ${g.separator} esc cancel`,
'confirm-kill': (g) => `y kill ${g.separator} any other key cancels`,
message: () => 'esc dismiss',
prompt: (g) => `${g.enter} send ${g.separator} esc cancel`,
search: (g) => `${g.updown} results ${g.separator} ${g.enter} open ${g.separator} esc close`,
@@ -805,10 +805,9 @@ function overlayContent(
}
case 'confirm-kill': {
if (!model.confirm) return null;
const { name, typed } = model.confirm;
return {
title: 'Kill session',
lines: [`Kill ${name}?`, '', 'Type the name to confirm:', ` ${typed}_`],
lines: [`Kill ${model.confirm.name}?`, '', 'press y to kill, any other key cancels'],
};
}
case 'message':
-1
View File
@@ -120,7 +120,6 @@ export interface TuiMessage {
export interface TuiConfirmState {
sessionId: string;
name: string;
typed: string;
}
export interface TuiPickerItem {