feat: support ctrl+c dismissal in ask flow

This commit is contained in:
Erik Kotsiuba
2026-04-19 16:32:27 +02:00
parent 0386239f5e
commit fcd1f1bce5
7 changed files with 72 additions and 0 deletions
+1
View File
@@ -163,6 +163,7 @@ Behavior details:
- while editing a note or free-form answer, arrow keys and `Tab` stay inside the editor so the typing cursor can move naturally - while editing a note or free-form answer, arrow keys and `Tab` stay inside the editor so the typing cursor can move naturally
- free-form answer editors support pi-style `@` file path autocomplete for quickly mentioning project files - free-form answer editors support pi-style `@` file path autocomplete for quickly mentioning project files
- `Esc` closes the editor and returns to navigation mode - `Esc` closes the editor and returns to navigation mode
- `Ctrl+C` dismisses the entire ask flow immediately, even from note/free-form editing, without saving the current draft
- `Space` toggles the active option on single-select questions too, but does not auto-advance - `Space` toggles the active option on single-select questions too, but does not auto-advance
## Local development ## Local development
+2
View File
@@ -94,6 +94,7 @@ Main flow:
- `Up`, `Down`: move between options - `Up`, `Down`: move between options
- `Enter`: confirm or submit - `Enter`: confirm or submit
- `Esc`: cancel the flow - `Esc`: cancel the flow
- `Ctrl+C`: dismiss the entire flow immediately, even while editing a note or free-form answer
- `1..9`: select or toggle the matching option - `1..9`: select or toggle the matching option
- `Space`: toggle the active option - `Space`: toggle the active option
- `N`: edit the active option note - `N`: edit the active option note
@@ -103,6 +104,7 @@ Editing flow:
- `Enter`: save and submit the current input - `Enter`: save and submit the current input
- `Esc`: save draft and close the editor - `Esc`: save draft and close the editor
- `Ctrl+C`: dismiss the entire flow immediately without saving the current editor draft
- arrow keys and `Tab` stay in the editor so the cursor can move while typing - arrow keys and `Tab` stay in the editor so the cursor can move while typing
- navigation resumes only after closing the editor with `Esc` - navigation resumes only after closing the editor with `Esc`
+5
View File
@@ -241,6 +241,11 @@ export function cancelFlow(state: AskState): AskState {
}; };
} }
export function dismissFlow(state: AskState): AskState {
const nextState = cancelFlow(state);
return nextState.completed ? nextState : cancelFlow(nextState);
}
function saveInputValue( function saveInputValue(
state: AskState, state: AskState,
rawValue: string, rawValue: string,
+10
View File
@@ -14,6 +14,7 @@ import {
applyNumberShortcut, applyNumberShortcut,
cancelFlow, cancelFlow,
confirmCurrentSelection, confirmCurrentSelection,
dismissFlow,
enterInputMode, enterInputMode,
enterOptionNoteMode, enterOptionNoteMode,
enterQuestionNoteMode, enterQuestionNoteMode,
@@ -117,6 +118,11 @@ function handleEditingCommand(
command: AskInputCommand, command: AskInputCommand,
data: string data: string
) { ) {
if (command.kind === "dismiss") {
commitNavigation(controller, dismissFlow(controller.state));
maybeFinish(controller);
return;
}
if (command.kind === "editMoveTab") { if (command.kind === "editMoveTab") {
controller.state = saveEditorAndMoveTab(controller, command.delta); controller.state = saveEditorAndMoveTab(controller, command.delta);
syncInputModeWithSelection(controller); syncInputModeWithSelection(controller);
@@ -176,6 +182,10 @@ function handleNavigationCommand(
applyNumberShortcut(controller.state, command.digit) applyNumberShortcut(controller.state, command.digit)
); );
return; return;
case "dismiss":
commitNavigation(controller, dismissFlow(controller.state));
maybeFinish(controller);
return;
case "ignore": case "ignore":
case "editMoveTab": case "editMoveTab":
case "editMoveOption": case "editMoveOption":
+5
View File
@@ -11,6 +11,7 @@ export type AskInputCommand =
| { kind: "openOptionNote" } | { kind: "openOptionNote" }
| { kind: "confirm" } | { kind: "confirm" }
| { kind: "cancel" } | { kind: "cancel" }
| { kind: "dismiss" }
| { kind: "numberShortcut"; digit: number } | { kind: "numberShortcut"; digit: number }
| { kind: "editMoveTab"; delta: 1 | -1 } | { kind: "editMoveTab"; delta: 1 | -1 }
| { kind: "editMoveOption"; delta: 1 | -1 } | { kind: "editMoveOption"; delta: 1 | -1 }
@@ -22,6 +23,10 @@ export function getInputCommand(
state: AskState, state: AskState,
data: string data: string
): AskInputCommand { ): AskInputCommand {
if (matchesKey(data, Key.ctrl("c"))) {
return { kind: "dismiss" };
}
if (state.view.kind === "input" || state.view.kind === "note") { if (state.view.kind === "input" || state.view.kind === "note") {
return getEditingInputCommand(data); return getEditingInputCommand(data);
} }
+24
View File
@@ -79,3 +79,27 @@ test("note editing mode delegates arrows and tab to the editor", () => {
kind: "delegateToEditor", kind: "delegateToEditor",
}); });
}); });
test("ctrl+c dismisses the flow from both navigation and editing modes", () => {
const navigation = createInitialState({
questions: [
{
id: "q1",
prompt: "Question?",
options: [{ value: "a", label: "A" }],
},
],
});
const input = inputState();
const note = enterQuestionNoteMode(navigation, "q1");
assert.deepEqual(getInputCommand(navigation, "\u0003"), {
kind: "dismiss",
});
assert.deepEqual(getInputCommand(input, "\u0003"), {
kind: "dismiss",
});
assert.deepEqual(getInputCommand(note, "\u0003"), {
kind: "dismiss",
});
});
+25
View File
@@ -7,6 +7,7 @@ import {
applyNumberShortcut, applyNumberShortcut,
cancelFlow, cancelFlow,
confirmCurrentSelection, confirmCurrentSelection,
dismissFlow,
enterOptionNoteMode, enterOptionNoteMode,
enterQuestionNoteMode, enterQuestionNoteMode,
moveOption, moveOption,
@@ -473,3 +474,27 @@ test("escape cancels from main flow but only exits input and note modes when edi
assert.equal(state.view.kind, "navigate"); assert.equal(state.view.kind, "navigate");
assert.equal(state.cancelled, false); assert.equal(state.cancelled, false);
}); });
test("dismissFlow cancels the tool even from editing modes", () => {
let state = createInitialState({
questions: [
{ id: "q", prompt: "Q?", options: [{ value: "a", label: "A" }] },
],
});
state = applyNumberShortcut(state, 2);
assert.equal(state.view.kind, "input");
state = dismissFlow(state);
assert.equal(state.cancelled, true);
assert.equal(state.completed, true);
state = createInitialState({
questions: [
{ id: "q", prompt: "Q?", options: [{ value: "a", label: "A" }] },
],
});
state = enterQuestionNoteMode(state, "q");
assert.equal(state.view.kind, "note");
state = dismissFlow(state);
assert.equal(state.cancelled, true);
assert.equal(state.completed, true);
});