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,
});
}