fix: allow empty editor navigation shortcuts

This commit is contained in:
Erik Kotsiuba
2026-04-19 18:19:04 +02:00
parent 050e8800cd
commit 5d7ccc8ce2
5 changed files with 113 additions and 29 deletions
+2 -1
View File
@@ -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 - 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` - 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 - 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 - 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 - `Ctrl+C` dismisses the entire ask flow immediately, even from note/free-form editing, without saving the current draft
+3 -2
View File
@@ -115,8 +115,9 @@ 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 - `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 - when editor has text, 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 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 ## Non-interactive mode
+5 -1
View File
@@ -105,7 +105,11 @@ function renderController(
} }
function handleControllerInput(controller: AskFlowController, data: string) { 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)) { if (isEditingView(controller.state)) {
handleEditingCommand(controller, command, data); handleEditingCommand(controller, command, data);
return; return;
+21 -3
View File
@@ -21,14 +21,15 @@ export type AskInputCommand =
export function getInputCommand( export function getInputCommand(
state: AskState, state: AskState,
data: string data: string,
editingText = ""
): AskInputCommand { ): AskInputCommand {
if (matchesKey(data, Key.ctrl("c"))) { if (matchesKey(data, Key.ctrl("c"))) {
return { kind: "dismiss" }; 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, editingText);
} }
return getNavigationInputCommand(data); return getNavigationInputCommand(data);
@@ -46,10 +47,27 @@ export function formatKeybindingLabel(key: string): string {
.join("+"); .join("+");
} }
function getEditingInputCommand(data: string): AskInputCommand { function getEditingInputCommand(
data: string,
editingText: string
): AskInputCommand {
if (matchesKey(data, Key.escape)) { if (matchesKey(data, Key.escape)) {
return { kind: "editClose" }; 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" }; return { kind: "delegateToEditor" };
} }
+78 -18
View File
@@ -21,35 +21,59 @@ function inputState() {
return state; return state;
} }
test("typing mode delegates arrow keys to the editor", () => { test("empty typing mode uses arrows and tab for navigation", () => {
const input = inputState(); const input = inputState();
assert.deepEqual(getInputCommand(input, "\x1b[A"), { assert.deepEqual(getInputCommand(input, "\x1b[A", ""), {
kind: "delegateToEditor", kind: "editMoveOption",
delta: -1,
}); });
assert.deepEqual(getInputCommand(input, "\x1b[B"), { assert.deepEqual(getInputCommand(input, "\x1b[B", ""), {
kind: "delegateToEditor", kind: "editMoveOption",
delta: 1,
}); });
assert.deepEqual(getInputCommand(input, "\x1b[C"), { assert.deepEqual(getInputCommand(input, "\x1b[C", ""), {
kind: "delegateToEditor", kind: "editMoveTab",
delta: 1,
}); });
assert.deepEqual(getInputCommand(input, "\x1b[D"), { assert.deepEqual(getInputCommand(input, "\x1b[D", ""), {
kind: "delegateToEditor", kind: "editMoveTab",
delta: -1,
});
assert.deepEqual(getInputCommand(input, "\t", ""), {
kind: "editMoveTab",
delta: 1,
});
assert.deepEqual(getInputCommand(input, "\x1b[Z", ""), {
kind: "editMoveTab",
delta: -1,
}); });
}); });
test("typing mode delegates tab to the editor", () => { test("non-empty typing mode keeps arrows and tab in editor", () => {
const input = inputState(); const input = inputState();
assert.deepEqual(getInputCommand(input, "\t"), { assert.deepEqual(getInputCommand(input, "\x1b[A", "x"), {
kind: "delegateToEditor", kind: "delegateToEditor",
}); });
assert.deepEqual(getInputCommand(input, "\x1b[Z"), { assert.deepEqual(getInputCommand(input, "\x1b[B", "x"), {
kind: "delegateToEditor",
});
assert.deepEqual(getInputCommand(input, "\x1b[C", "x"), {
kind: "delegateToEditor",
});
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", 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( const state = enterQuestionNoteMode(
createInitialState({ createInitialState({
questions: [ questions: [
@@ -63,19 +87,55 @@ test("note editing mode delegates arrows and tab to the editor", () => {
"q1" "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", kind: "delegateToEditor",
}); });
assert.deepEqual(getInputCommand(state, "\x1b[B"), { assert.deepEqual(getInputCommand(state, "\x1b[B", "x"), {
kind: "delegateToEditor", kind: "delegateToEditor",
}); });
assert.deepEqual(getInputCommand(state, "\x1b[C"), { assert.deepEqual(getInputCommand(state, "\x1b[C", "x"), {
kind: "delegateToEditor", kind: "delegateToEditor",
}); });
assert.deepEqual(getInputCommand(state, "\x1b[D"), { assert.deepEqual(getInputCommand(state, "\x1b[D", "x"), {
kind: "delegateToEditor", kind: "delegateToEditor",
}); });
assert.deepEqual(getInputCommand(state, "\t"), { assert.deepEqual(getInputCommand(state, "\t", "x"), {
kind: "delegateToEditor", kind: "delegateToEditor",
}); });
}); });