feat(ask): refine inline answer and navigation UX

This commit is contained in:
Erik Kotsiuba
2026-04-18 21:14:06 +02:00
parent 87cbfa546e
commit 91f28752a4
12 changed files with 337 additions and 53 deletions
+24 -5
View File
@@ -114,9 +114,9 @@ export function enterOptionNoteMode(
});
}
export function toggleCurrentMultiOption(state: AskState): AskState {
export function toggleCurrentOption(state: AskState): AskState {
const question = getCurrentQuestion(state);
if (!question || question.type === "single") {
if (!question) {
return state;
}
@@ -128,9 +128,28 @@ export function toggleCurrentMultiOption(state: AskState): AskState {
return setView(state, inputView(question.id));
}
return updateAnswer(state, question.id, (answer) =>
toggleSelection(answer, option, state.activeOptionIndex),
);
if (question.type === "multi") {
return updateAnswer(state, question.id, (answer) =>
toggleSelection(answer, option, state.activeOptionIndex),
);
}
return updateAnswer(state, question.id, (answer) => {
const isSelected = answer.selected.some(
(selection) => selection.value === option.value,
);
if (isSelected) {
return {
...answer,
selected: [],
};
}
return setSingleSelection(answer, option, state.activeOptionIndex);
});
}
export function toggleCurrentMultiOption(state: AskState): AskState {
return toggleCurrentOption(state);
}
export function confirmCurrentSelection(state: AskState): AskState {
+2 -2
View File
@@ -23,6 +23,6 @@ export const UI_TEXT = {
" ⇆ tab · ↑↓ select · Space toggle · Enter continue · N option note · Ctrl+N question note · Esc dismiss · 1-9 quick toggle",
footerDefault:
" ⇆ tab · ↑↓ select · Enter confirm · N option note · Ctrl+N question note · Esc dismiss",
hintSave: "{newLineHint} newline · Enter save · Esc save and close",
hintSubmit: "{newLineHint} newline · Enter submit · Esc save and close",
editorPlaceholderInput: "Type your answer...",
editorPlaceholderNote: "Add a note...",
} as const;
+21 -10
View File
@@ -25,7 +25,7 @@ import {
} from "../state.ts";
import type { AskParams, AskResult, AskState } from "../types.ts";
import type { AskInputCommand } from "./input.ts";
import { formatKeybindingLabel, getInputCommand } from "./input.ts";
import { getInputCommand } from "./input.ts";
import { renderAskScreen } from "./render.ts";
type CustomCallback = Parameters<ExtensionContext["ui"]["custom"]>[0];
@@ -41,7 +41,6 @@ interface AskFlowController {
state: AskState;
cachedLines: string[] | undefined;
suppressAutoInputForSelection: boolean;
newLineHint: string;
tui: Tui;
theme: Theme;
editor: Editor;
@@ -58,7 +57,7 @@ export async function runAskFlow(
}
function createAskFlowController(
[tui, theme, keybindings, done]: [
[tui, theme, _keybindings, done]: [
Tui,
Theme,
Keybindings,
@@ -70,9 +69,6 @@ function createAskFlowController(
state: createInitialState(params),
cachedLines: undefined,
suppressAutoInputForSelection: false,
newLineHint: formatKeybindingLabel(
keybindings.getKeys("tui.input.newLine")[0] ?? "shift+enter",
),
tui,
theme,
editor: createEditor(tui, theme),
@@ -103,7 +99,6 @@ function renderController(
theme: controller.theme,
width,
editor: controller.editor,
newLineHint: controller.newLineHint,
});
}
return controller.cachedLines;
@@ -129,6 +124,12 @@ function handleEditingCommand(
refresh(controller);
return;
}
if (command.kind === "editMoveOption") {
controller.state = saveEditorAndMoveOption(controller, command.delta);
syncInputModeWithSelection(controller);
refresh(controller);
return;
}
if (command.kind === "editClose") {
controller.state = saveEditorWithoutAdvancing(controller);
controller.suppressAutoInputForSelection =
@@ -154,7 +155,7 @@ function handleNavigationCommand(
navigateOptions(controller, command.delta);
return;
case "toggleMulti":
handleToggleMulti(controller);
handleToggleCurrentOption(controller);
return;
case "openQuestionNote":
openQuestionNote(controller);
@@ -178,6 +179,7 @@ function handleNavigationCommand(
return;
case "ignore":
case "editMoveTab":
case "editMoveOption":
case "editClose":
case "delegateToEditor":
return;
@@ -200,9 +202,9 @@ function navigateOptions(controller: AskFlowController, delta: 1 | -1) {
refresh(controller);
}
function handleToggleMulti(controller: AskFlowController) {
function handleToggleCurrentOption(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
if (question?.type !== "multi") {
if (!question) {
return;
}
controller.suppressAutoInputForSelection = false;
@@ -319,6 +321,15 @@ function saveEditorAndMoveTab(
return moveTab(nextState, delta);
}
function saveEditorAndMoveOption(
controller: AskFlowController,
delta: 1 | -1,
): AskState {
const nextState = saveEditorWithoutAdvancing(controller);
controller.suppressAutoInputForSelection = false;
return moveOption(nextState, delta);
}
function createEditor(tui: Tui, theme: Theme) {
const editorTheme: EditorTheme = {
borderColor: (text) => theme.fg("accent", text),
+7
View File
@@ -11,6 +11,7 @@ export type AskInputCommand =
| { kind: "cancel" }
| { kind: "numberShortcut"; digit: number }
| { kind: "editMoveTab"; delta: 1 | -1 }
| { kind: "editMoveOption"; delta: 1 | -1 }
| { kind: "editClose" }
| { kind: "delegateToEditor" }
| { kind: "ignore" };
@@ -45,6 +46,12 @@ function getEditingInputCommand(data: string): AskInputCommand {
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 };
}
if (matchesKey(data, Key.escape)) {
return { kind: "editClose" };
}
+42 -11
View File
@@ -34,11 +34,12 @@ export function renderInputLine(
line: string,
availableWidth: number,
theme: Theme,
color: ThemeColor = "text",
): string {
const innerWidth = Math.max(4, availableWidth - 2);
const truncated = truncateToWidth(line, innerWidth);
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
return theme.bg("toolPendingBg", ` ${truncated}${padding} `);
return theme.bg("selectedBg", ` ${theme.fg(color, truncated)}${padding} `);
}
export function renderEditorBlock(args: {
@@ -48,23 +49,53 @@ export function renderEditorBlock(args: {
theme: Theme;
indent: string;
availableWidth: number;
hint: string;
placeholder?: string;
placeholderColor?: ThemeColor;
contentColor?: ThemeColor;
isEmpty?: boolean;
}) {
const { lines, editorLines, width, theme, indent, availableWidth, hint } =
args;
for (const editorLine of editorLines) {
const {
lines,
editorLines,
width,
theme,
indent,
availableWidth,
placeholder,
placeholderColor = "muted",
contentColor = "text",
isEmpty = false,
} = args;
const innerLines = editorLines.length >= 2 ? editorLines.slice(1, -1) : editorLines;
if (isEmpty && placeholder) {
lines.push(
truncateToWidth(
`${indent}${renderInputLine(editorLine, availableWidth, theme)}`,
`${indent}${renderInputLine(
placeholder,
availableWidth,
theme,
placeholderColor,
)}`,
width,
),
);
return;
}
for (const editorLine of innerLines) {
lines.push(
truncateToWidth(
`${indent}${renderInputLine(
editorLine,
availableWidth,
theme,
contentColor,
)}`,
width,
),
);
}
lines.push(truncateToWidth(`${indent}${theme.fg("dim", hint)}`, width));
}
export function renderHint(template: string, newLineHint: string): string {
return template.replace("{newLineHint}", newLineHint);
}
export function renderBox(
+44 -20
View File
@@ -15,7 +15,7 @@ import {
mergeColumns,
pushWrappedText,
renderEditorBlock,
renderHint,
renderInputLine,
renderPreviewPaneContent,
} from "./render-helpers.ts";
import type {
@@ -39,7 +39,7 @@ export function renderQuestionScreen(context: QuestionRenderContext) {
}
function renderQuestionNote(context: QuestionRenderContext) {
const { lines, state, question, theme, width, editor, newLineHint } = context;
const { lines, state, question, theme, width, editor } = context;
if (isQuestionNoteOpen(state, question.id)) {
pushWrappedText(
lines,
@@ -62,7 +62,8 @@ function renderQuestionNote(context: QuestionRenderContext) {
theme,
indent: " ",
availableWidth: width - 3,
hint: renderHint(UI_TEXT.hintSave, newLineHint),
placeholder: UI_TEXT.editorPlaceholderNote,
isEmpty: editor.getText().length === 0,
});
lines.push("");
return;
@@ -105,6 +106,10 @@ function renderStandardOption(
const pointer = selected ? "❯ " : " ";
const prefix = getOptionPrefix(question.type, option, isAnsweredOption);
const optionColor = getOptionColor(isAnsweredOption, selected);
if (option.isCustomOption && selected && isInputOpenForQuestion(state, question.id)) {
renderInteractiveCustomOption(context, index, pointer, prefix, optionColor);
return;
}
pushWrappedText(
lines,
@@ -321,25 +326,13 @@ function renderOptionEditor(
context: OptionDetailRenderContext,
renderState: ReturnType<typeof getOptionDetailRenderState>,
): boolean {
const { lines, editor, width, theme, newLineHint } = context;
const { lines, editor, width, theme } = context;
if (renderState.noteOpen) {
renderIndentedEditor(
lines,
editor,
width,
theme,
renderHint(UI_TEXT.hintSave, newLineHint),
);
renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderNote);
return true;
}
if (renderState.inputOpen) {
renderIndentedEditor(
lines,
editor,
width,
theme,
renderHint(UI_TEXT.hintSubmit, newLineHint),
);
renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderInput);
return true;
}
return false;
@@ -389,7 +382,7 @@ function renderIndentedEditor(
editor: QuestionRenderContext["editor"],
width: number,
theme: Theme,
hint: string,
placeholder: string,
) {
renderEditorBlock({
lines,
@@ -403,7 +396,8 @@ function renderIndentedEditor(
theme,
indent: " ",
availableWidth: width - 5,
hint,
placeholder,
isEmpty: editor.getText().length === 0,
});
}
@@ -418,6 +412,36 @@ function getOptionPrefix(
return `[${isAnsweredOption ? "x" : " "}] `;
}
function renderInteractiveCustomOption(
context: QuestionRenderContext,
index: number,
pointer: string,
prefix: string,
optionColor: ReturnType<typeof getOptionColor>,
) {
const { lines, editor, theme, width } = context;
const label = `${index + 1}. ${prefix}`;
const availableWidth = Math.max(
1,
width - visibleWidth(pointer) - visibleWidth(label),
);
const text = editor.getText().replace(/\n/g, " ");
const hasText = text.trim().length > 0;
const inputText = hasText ? text : UI_TEXT.editorPlaceholderInput;
const inputColor = hasText ? "text" : "muted";
lines.push(
truncateToWidth(
`${pointer}${theme.fg(optionColor, label)}${renderInputLine(
inputText,
availableWidth,
theme,
inputColor,
)}`,
width,
),
);
}
function getOptionColor(isAnsweredOption: boolean, selected: boolean) {
if (isAnsweredOption) {
return "success";
-2
View File
@@ -17,7 +17,6 @@ export interface QuestionRenderContext {
theme: Theme;
width: number;
editor: Editor;
newLineHint: string;
}
export interface OptionDetailRenderContext {
@@ -29,7 +28,6 @@ export interface OptionDetailRenderContext {
theme: Theme;
width: number;
editor: Editor;
newLineHint: string;
selected?: boolean;
withGap?: boolean;
}
+1 -3
View File
@@ -14,9 +14,8 @@ export function renderAskScreen(args: {
theme: Theme;
width: number;
editor: QuestionRenderContext["editor"];
newLineHint: string;
}): string[] {
const { state, theme, width, editor, newLineHint } = args;
const { state, theme, width, editor } = args;
const lines: string[] = [];
const question = getCurrentQuestion(state);
const options = getRenderableOptions(question);
@@ -34,7 +33,6 @@ export function renderAskScreen(args: {
theme,
width,
editor,
newLineHint,
});
}
+51
View File
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyNumberShortcut, createInitialState, enterQuestionNoteMode } from "../src/state.ts";
import { getInputCommand } from "../src/ui/input.ts";
function inputState() {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Question?",
options: [{ value: "a", label: "A" }],
},
],
});
state = applyNumberShortcut(state, 2);
return state;
}
test("editing views let up and down move navigation", () => {
const input = inputState();
assert.deepEqual(getInputCommand(input, "\x1b[A"), {
kind: "editMoveOption",
delta: -1,
});
assert.deepEqual(getInputCommand(input, "\x1b[B"), {
kind: "editMoveOption",
delta: 1,
});
});
test("note editing views let up and down move navigation", () => {
const state = enterQuestionNoteMode(createInitialState({
questions: [
{
id: "q1",
prompt: "Question?",
options: [{ value: "a", label: "A" }],
},
],
}), "q1");
assert.deepEqual(getInputCommand(state, "\x1b[A"), {
kind: "editMoveOption",
delta: -1,
});
assert.deepEqual(getInputCommand(state, "\x1b[B"), {
kind: "editMoveOption",
delta: 1,
});
});
+48
View File
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderEditorBlock } from "../src/ui/render-helpers.ts";
function mockTheme() {
return {
fg(color: string, text: string) {
return `<${color}>${text}</${color}>`;
},
bg(color: string, text: string) {
return `{${color}}${text}{/${color}}`;
},
};
}
test("renderEditorBlock strips editor borders", () => {
const lines: string[] = [];
renderEditorBlock({
lines,
editorLines: ["┌──┐", "│abc│", "└──┘"],
width: 40,
theme: mockTheme() as never,
indent: " ",
availableWidth: 40,
});
assert.equal(lines.length, 1);
assert.match(lines[0], /abc/);
assert.doesNotMatch(lines[0], /┌|└/);
});
test("renderEditorBlock shows a muted placeholder when empty", () => {
const lines: string[] = [];
renderEditorBlock({
lines,
editorLines: ["┌──┐", "│ │", "└──┘"],
width: 40,
theme: mockTheme() as never,
indent: " ",
availableWidth: 40,
placeholder: "Type your",
isEmpty: true,
});
assert.equal(lines.length, 1);
assert.match(lines[0], /Type your/);
assert.doesNotMatch(lines[0], /┌|└/);
});
+83
View File
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyNumberShortcut, createInitialState } from "../src/state.ts";
import { getRenderableOptions } from "../src/state/selectors.ts";
import { renderQuestionScreen } from "../src/ui/render-question.ts";
function mockTheme() {
return {
fg(color: string, text: string) {
return `<${color}>${text}</${color}>`;
},
bg(color: string, text: string) {
return `{${color}}${text}{/${color}}`;
},
bold(text: string) {
return text;
},
} as never;
}
function mockEditor(text = "") {
return {
getText() {
return text;
},
render() {
return [];
},
} as never;
}
test("custom option stays labeled before selection", () => {
const state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
options: [{ value: "a", label: "A" }],
},
],
});
const lines: string[] = [];
renderQuestionScreen({
lines,
state,
question: state.questions[0],
options: getRenderableOptions(state.questions[0]),
theme: mockTheme(),
width: 80,
editor: mockEditor(),
});
assert(lines.some((line) => line.includes("Type your own")));
assert(!lines.some((line) => line.includes("Type your answer...")));
});
test("selected custom option becomes inline input", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
options: [{ value: "a", label: "A" }],
},
],
});
state = applyNumberShortcut(state, 2);
const lines: string[] = [];
renderQuestionScreen({
lines,
state,
question: state.questions[0],
options: getRenderableOptions(state.questions[0]),
theme: mockTheme(),
width: 80,
editor: mockEditor(""),
});
assert(lines.some((line) => line.includes("Type your answer...")));
assert(!lines.some((line) => line.includes("Type your own answer")));
});
+14
View File
@@ -122,6 +122,20 @@ test("multi-select space toggles current option and enter advances to submit", (
assert.equal(state.completed, false);
});
test("single-select space toggles the active option without advancing", () => {
let state = createInitialState(sampleParams());
state = toggleCurrentMultiOption(state);
assert.equal(state.answers.lang.selected[0].label, "Python");
assert.equal(state.activeTabIndex, 0);
assert.equal(state.view.kind, "navigate");
state = toggleCurrentMultiOption(state);
assert.equal(state.answers.lang, undefined);
assert.equal(state.activeTabIndex, 0);
assert.equal(state.view.kind, "navigate");
});
test("submit can complete even when some questions are unanswered", () => {
let state = createInitialState(sampleParams());
state = moveTab(state, -1);