From 5d7ccc8ce2e45c70a36932f048a90419dcb59304 Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Sun, 19 Apr 2026 18:19:04 +0200 Subject: [PATCH] fix: allow empty editor navigation shortcuts --- README.md | 3 +- docs/contract.md | 5 ++- src/ui/controller.ts | 6 ++- src/ui/input.ts | 24 ++++++++-- tests/input.test.ts | 104 ++++++++++++++++++++++++++++++++++--------- 5 files changed, 113 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 222a4f4..149cbd4 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,8 @@ Behavior details: - on multi-select questions, submitted `values` and `labels` keep selected options first and append the free-form answer last when both exist - unanswered questions are omitted from `details.answers` - when the free-form option is selected, it becomes an inline input row with the selected-tab background style spanning the full width -- 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 while text is present so the typing cursor can move naturally +- when a note or free-form editor is empty, `Up`/`Down` navigate options and `Tab`/`Shift+Tab`/`Left`/`Right` navigate tabs without closing the editor first - 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 diff --git a/docs/contract.md b/docs/contract.md index 4cb6b3a..7345b1f 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -115,8 +115,9 @@ 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` +- when editor has text, arrow keys and `Tab` stay in the editor so the cursor can move while typing +- when editor is empty, `Up`/`Down` move options and `Tab`/`Shift+Tab`/`Left`/`Right` move between tabs without requiring `Esc` first +- navigation resumes only after closing the editor with `Esc`, unless the editor is empty and the navigation keys above are used ## Non-interactive mode diff --git a/src/ui/controller.ts b/src/ui/controller.ts index ea112db..211f1dd 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -105,7 +105,11 @@ function renderController( } function handleControllerInput(controller: AskFlowController, data: string) { - const command = getInputCommand(controller.state, data); + const command = getInputCommand( + controller.state, + data, + isEditingView(controller.state) ? controller.editor.getText() : "" + ); if (isEditingView(controller.state)) { handleEditingCommand(controller, command, data); return; diff --git a/src/ui/input.ts b/src/ui/input.ts index 33d65f8..e673b2d 100644 --- a/src/ui/input.ts +++ b/src/ui/input.ts @@ -21,14 +21,15 @@ export type AskInputCommand = export function getInputCommand( state: AskState, - data: string + data: string, + editingText = "" ): AskInputCommand { if (matchesKey(data, Key.ctrl("c"))) { return { kind: "dismiss" }; } if (state.view.kind === "input" || state.view.kind === "note") { - return getEditingInputCommand(data); + return getEditingInputCommand(data, editingText); } return getNavigationInputCommand(data); @@ -46,10 +47,27 @@ export function formatKeybindingLabel(key: string): string { .join("+"); } -function getEditingInputCommand(data: string): AskInputCommand { +function getEditingInputCommand( + data: string, + editingText: string +): AskInputCommand { if (matchesKey(data, Key.escape)) { return { kind: "editClose" }; } + if (editingText.length === 0) { + if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { + return { kind: "editMoveTab", delta: 1 }; + } + if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { + return { kind: "editMoveTab", delta: -1 }; + } + if (matchesKey(data, Key.up)) { + return { kind: "editMoveOption", delta: -1 }; + } + if (matchesKey(data, Key.down)) { + return { kind: "editMoveOption", delta: 1 }; + } + } return { kind: "delegateToEditor" }; } diff --git a/tests/input.test.ts b/tests/input.test.ts index e29071d..ea6a00f 100644 --- a/tests/input.test.ts +++ b/tests/input.test.ts @@ -21,35 +21,59 @@ function inputState() { return state; } -test("typing mode delegates arrow keys to the editor", () => { +test("empty typing mode uses arrows and tab for navigation", () => { const input = inputState(); - assert.deepEqual(getInputCommand(input, "\x1b[A"), { + assert.deepEqual(getInputCommand(input, "\x1b[A", ""), { + kind: "editMoveOption", + delta: -1, + }); + assert.deepEqual(getInputCommand(input, "\x1b[B", ""), { + kind: "editMoveOption", + delta: 1, + }); + assert.deepEqual(getInputCommand(input, "\x1b[C", ""), { + kind: "editMoveTab", + delta: 1, + }); + assert.deepEqual(getInputCommand(input, "\x1b[D", ""), { + kind: "editMoveTab", + delta: -1, + }); + assert.deepEqual(getInputCommand(input, "\t", ""), { + kind: "editMoveTab", + delta: 1, + }); + assert.deepEqual(getInputCommand(input, "\x1b[Z", ""), { + kind: "editMoveTab", + delta: -1, + }); +}); + +test("non-empty typing mode keeps arrows and tab in editor", () => { + const input = inputState(); + + assert.deepEqual(getInputCommand(input, "\x1b[A", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(input, "\x1b[B"), { + assert.deepEqual(getInputCommand(input, "\x1b[B", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(input, "\x1b[C"), { + assert.deepEqual(getInputCommand(input, "\x1b[C", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(input, "\x1b[D"), { + assert.deepEqual(getInputCommand(input, "\x1b[D", "x"), { + kind: "delegateToEditor", + }); + assert.deepEqual(getInputCommand(input, "\t", "x"), { + kind: "delegateToEditor", + }); + assert.deepEqual(getInputCommand(input, "\x1b[Z", "x"), { kind: "delegateToEditor", }); }); -test("typing mode delegates tab to the editor", () => { - const input = inputState(); - - assert.deepEqual(getInputCommand(input, "\t"), { - kind: "delegateToEditor", - }); - assert.deepEqual(getInputCommand(input, "\x1b[Z"), { - kind: "delegateToEditor", - }); -}); - -test("note editing mode delegates arrows and tab to the editor", () => { +test("empty note editing mode uses arrows and tab for navigation", () => { const state = enterQuestionNoteMode( createInitialState({ questions: [ @@ -63,19 +87,55 @@ test("note editing mode delegates arrows and tab to the editor", () => { "q1" ); - assert.deepEqual(getInputCommand(state, "\x1b[A"), { + assert.deepEqual(getInputCommand(state, "\x1b[A", ""), { + kind: "editMoveOption", + delta: -1, + }); + assert.deepEqual(getInputCommand(state, "\x1b[B", ""), { + kind: "editMoveOption", + delta: 1, + }); + assert.deepEqual(getInputCommand(state, "\x1b[C", ""), { + kind: "editMoveTab", + delta: 1, + }); + assert.deepEqual(getInputCommand(state, "\x1b[D", ""), { + kind: "editMoveTab", + delta: -1, + }); + assert.deepEqual(getInputCommand(state, "\t", ""), { + kind: "editMoveTab", + delta: 1, + }); +}); + +test("non-empty note editing mode keeps arrows and tab in editor", () => { + const state = enterQuestionNoteMode( + createInitialState({ + questions: [ + { + id: "q1", + prompt: "Question?", + options: [{ value: "a", label: "A" }], + }, + ], + }), + "q1" + ); + + assert.deepEqual(getInputCommand(state, "\x1b[A", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(state, "\x1b[B"), { + assert.deepEqual(getInputCommand(state, "\x1b[B", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(state, "\x1b[C"), { + assert.deepEqual(getInputCommand(state, "\x1b[C", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(state, "\x1b[D"), { + assert.deepEqual(getInputCommand(state, "\x1b[D", "x"), { kind: "delegateToEditor", }); - assert.deepEqual(getInputCommand(state, "\t"), { + assert.deepEqual(getInputCommand(state, "\t", "x"), { kind: "delegateToEditor", }); });