diff --git a/README.md b/README.md index b118996..a904c2d 100644 --- a/README.md +++ b/README.md @@ -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 - free-form answer editors support pi-style `@` file path autocomplete for quickly mentioning project files - `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 ## Local development diff --git a/docs/contract.md b/docs/contract.md index 768acbd..bfc64ae 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -94,6 +94,7 @@ Main flow: - `Up`, `Down`: move between options - `Enter`: confirm or submit - `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 - `Space`: toggle the active option - `N`: edit the active option note @@ -103,6 +104,7 @@ Editing flow: - `Enter`: save and submit the current input - `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 - navigation resumes only after closing the editor with `Esc` diff --git a/src/state/transitions.ts b/src/state/transitions.ts index f9450be..d1fe295 100644 --- a/src/state/transitions.ts +++ b/src/state/transitions.ts @@ -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( state: AskState, rawValue: string, diff --git a/src/ui/controller.ts b/src/ui/controller.ts index ccc3d87..ea112db 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -14,6 +14,7 @@ import { applyNumberShortcut, cancelFlow, confirmCurrentSelection, + dismissFlow, enterInputMode, enterOptionNoteMode, enterQuestionNoteMode, @@ -117,6 +118,11 @@ function handleEditingCommand( command: AskInputCommand, data: string ) { + if (command.kind === "dismiss") { + commitNavigation(controller, dismissFlow(controller.state)); + maybeFinish(controller); + return; + } if (command.kind === "editMoveTab") { controller.state = saveEditorAndMoveTab(controller, command.delta); syncInputModeWithSelection(controller); @@ -176,6 +182,10 @@ function handleNavigationCommand( applyNumberShortcut(controller.state, command.digit) ); return; + case "dismiss": + commitNavigation(controller, dismissFlow(controller.state)); + maybeFinish(controller); + return; case "ignore": case "editMoveTab": case "editMoveOption": diff --git a/src/ui/input.ts b/src/ui/input.ts index 6bd3546..7708d0b 100644 --- a/src/ui/input.ts +++ b/src/ui/input.ts @@ -11,6 +11,7 @@ export type AskInputCommand = | { kind: "openOptionNote" } | { kind: "confirm" } | { kind: "cancel" } + | { kind: "dismiss" } | { kind: "numberShortcut"; digit: number } | { kind: "editMoveTab"; delta: 1 | -1 } | { kind: "editMoveOption"; delta: 1 | -1 } @@ -22,6 +23,10 @@ export function getInputCommand( state: AskState, data: string ): AskInputCommand { + if (matchesKey(data, Key.ctrl("c"))) { + return { kind: "dismiss" }; + } + if (state.view.kind === "input" || state.view.kind === "note") { return getEditingInputCommand(data); } diff --git a/tests/input.test.ts b/tests/input.test.ts index 8e8fe9a..6e40871 100644 --- a/tests/input.test.ts +++ b/tests/input.test.ts @@ -79,3 +79,27 @@ test("note editing mode delegates arrows and tab to the editor", () => { 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", + }); +}); diff --git a/tests/state.test.ts b/tests/state.test.ts index 94e02d8..b4c4be1 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -7,6 +7,7 @@ import { applyNumberShortcut, cancelFlow, confirmCurrentSelection, + dismissFlow, enterOptionNoteMode, enterQuestionNoteMode, 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.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); +});