feat: add question type presentation controls

This commit is contained in:
Erik Kotsiuba
2026-05-06 12:44:37 +02:00
parent b218b8b188
commit f1a0a5d5e5
28 changed files with 761 additions and 44 deletions
+45 -1
View File
@@ -13,6 +13,7 @@ import {
submitEditorDraft,
syncStateToSelection,
} from "../state/editor.ts";
import { cycleCurrentQuestionType } from "../state/question-type.ts";
import { toAskResult } from "../state/result.ts";
import {
getCurrentOption,
@@ -58,6 +59,7 @@ type Keybindings = CustomCallbackArgs[2];
type Done = (result: AskResult) => void;
interface AskFlowOptions {
allowFreeform?: boolean;
presentSingleAsMulti?: boolean;
}
type AskFlowParams = AskParams &
@@ -73,6 +75,7 @@ interface AskFlowController {
dismissNotice?: string;
done: Done;
editor: Editor;
pendingQuestionTypeChangeQuestionId?: string;
pendingReviewShortcutActionIndex?: number;
settingsOpen: boolean;
state: AskState;
@@ -89,13 +92,18 @@ export async function runAskFlow(
): Promise<AskResult> {
const store = getAskConfigStore();
const config = await store.getConfig();
const flowOptions = {
...options,
presentSingleAsMulti:
options.presentSingleAsMulti ?? config.behaviour.presentSingleAsMulti,
};
return ctx.ui.custom<AskResult>((...args) =>
createAskFlowController(args, {
...params,
config,
cwd: ctx.cwd,
ctx,
flowOptions: options,
flowOptions,
})
);
}
@@ -118,6 +126,7 @@ function createAskFlowController(
settingsOpen: false,
state: createInitialState(params, params.flowOptions),
suppressAutoInputForSelection: false,
pendingQuestionTypeChangeQuestionId: undefined,
pendingReviewShortcutActionIndex: undefined,
theme,
tui,
@@ -238,30 +247,41 @@ function handleNavigationCommand(
switch (command.kind) {
case "moveTab":
clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
commitState(controller, moveTab(controller.state, command.delta));
return;
case "moveOption":
clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
commitState(controller, moveOption(controller.state, command.delta));
return;
case "toggleMulti":
clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
handleToggleCurrentOption(controller);
return;
case "changeQuestionType":
clearReviewShortcutPending(controller);
handleChangeQuestionType(controller);
return;
case "openQuestionNote":
clearQuestionTypeChangePending(controller);
openQuestionNote(controller);
return;
case "openOptionNote":
clearQuestionTypeChangePending(controller);
openOptionNote(controller);
return;
case "confirm":
clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
commitState(controller, confirmCurrentSelection(controller.state), {
finish: true,
});
return;
case "cancel":
clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
handleExitFlow(controller, cancelFlow(controller.state));
return;
case "numberShortcut":
@@ -269,6 +289,7 @@ function handleNavigationCommand(
return;
}
clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
commitState(
controller,
applyNumberShortcut(controller.state, command.digit)
@@ -276,6 +297,7 @@ function handleNavigationCommand(
return;
case "dismiss":
clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
handleExitFlow(controller, dismissFlow(controller.state));
return;
case "showSettings":
@@ -301,6 +323,24 @@ function handleToggleCurrentOption(controller: AskFlowController) {
commitState(controller, toggleCurrentMultiOption(controller.state));
}
function handleChangeQuestionType(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
if (!question || isSubmitTab(controller.state)) {
return;
}
const confirmed =
controller.pendingQuestionTypeChangeQuestionId === question.id;
const result = cycleCurrentQuestionType(controller.state, { confirmed });
controller.dismissNotice = result.notice;
if (result.needsConfirmation) {
controller.pendingQuestionTypeChangeQuestionId = question.id;
refresh(controller);
return;
}
clearQuestionTypeChangePending(controller);
commitState(controller, result.state, { finish: true });
}
function openQuestionNote(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state);
if (!question || isSubmitTab(controller.state)) {
@@ -415,6 +455,10 @@ function clearReviewShortcutPending(controller: AskFlowController) {
controller.pendingReviewShortcutActionIndex = undefined;
}
function clearQuestionTypeChangePending(controller: AskFlowController) {
controller.pendingQuestionTypeChangeQuestionId = undefined;
}
function getActiveReviewShortcutHint(
controller: AskFlowController
): string | undefined {
+4
View File
@@ -17,6 +17,7 @@ export type AskInputCommand =
| { kind: "openOptionNote" }
| { kind: "confirm" }
| { kind: "cancel" }
| { kind: "changeQuestionType" }
| { kind: "dismiss" }
| { kind: "showSettings" }
| { kind: "numberShortcut"; digit: number }
@@ -136,6 +137,9 @@ function getNavigationInputCommand(
if (matchesBinding(data, bindings.toggle)) {
return { kind: "toggleMulti" };
}
if (matchesBinding(data, bindings.changeQuestionType)) {
return { kind: "changeQuestionType" };
}
if (matchesBinding(data, bindings.confirm)) {
return { kind: "confirm" };
}
+46 -6
View File
@@ -34,11 +34,33 @@ const DESCRIPTION_LINE_COUNT = 3;
const COMPACT_WIDTH = 40;
const RESET_CONFIRMATION_MS = 2000;
type SettingSection = "Actions" | "Defaults for future asks" | "Live settings";
type ToggleSettingKey = keyof AskConfig["behaviour"] | "notifications.enabled";
interface BaseSetting {
description: string;
label: string;
section: SettingSection;
}
interface ActionSetting extends BaseSetting {
key: "resetConfig";
type: "action";
}
interface ToggleSetting extends BaseSetting {
key: ToggleSettingKey;
type: "toggle";
}
type Setting = ActionSetting | ToggleSetting;
const SETTINGS = [
{
description:
"Auto-submit completed ask flows when no question or option notes were added.",
key: "autoSubmitWhenAnsweredWithoutNotes",
section: "Live settings",
label: "Auto-submit when answered without notes",
type: "toggle",
},
@@ -46,6 +68,7 @@ const SETTINGS = [
description:
"Require a second cancel or dismiss action before discarding answered or drafted ask content.",
key: "confirmDismissWhenDirty",
section: "Live settings",
label: "Confirm dismiss when dirty",
type: "toggle",
},
@@ -53,6 +76,7 @@ const SETTINGS = [
description:
"Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.",
key: "doublePressReviewShortcuts",
section: "Live settings",
label: "Double-press review shortcuts",
type: "toggle",
},
@@ -60,26 +84,36 @@ const SETTINGS = [
description:
"Emit one external notification when the ask flow opens and waits for input.",
key: "notifications.enabled",
section: "Live settings",
label: "Notifications",
type: "toggle",
},
{
description: "Show footer keymap hints at the bottom of the ask flow.",
key: "showFooterHints",
section: "Live settings",
label: "Show footer hints",
type: "toggle",
},
{
description:
"Default new and replayed ask flows to render single-select questions as multi-select. This is not hot-applied to the current flow; use the type hotkey there.",
key: "presentSingleAsMulti",
section: "Defaults for future asks",
label: "Present single-select as multi-select",
type: "toggle",
},
{
description:
"Reset behaviour, keymaps, notifications, and extraction settings to defaults. Press twice quickly to confirm.",
key: "resetConfig",
section: "Actions",
label: "Reset config to defaults",
type: "action",
},
] as const;
] as const satisfies readonly Setting[];
type Setting = (typeof SETTINGS)[number];
type SettingKey = Extract<Setting, { type: "toggle" }>["key"];
type SettingKey = ToggleSettingKey;
export class AskSettingsList {
private closed = false;
@@ -152,10 +186,16 @@ export class AskSettingsList {
);
lines.push(this.line("", innerWidth));
let previousSection: string | undefined;
for (const [index, setting] of SETTINGS.entries()) {
if (setting.type === "action") {
lines.push(this.line("", innerWidth));
lines.push(this.line("", innerWidth));
if (setting.section !== previousSection) {
if (previousSection) {
lines.push(this.line("", innerWidth));
}
lines.push(
this.line(this.theme.fg("accent", ` ${setting.section}`), innerWidth)
);
previousSection = setting.section;
}
for (const settingLine of this.renderSetting(
setting,