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
+1
View File
@@ -30,6 +30,7 @@ export const ASK_TOOL_PROMPT_GUIDELINES = [
interface ValidateParamsOptions {
allowFreeform?: boolean;
presentSingleAsMulti?: boolean;
}
export function validateParams(
+5 -1
View File
@@ -13,6 +13,7 @@ import {
successfulResponse,
validateParams,
} from "./ask-tool-helpers.ts";
import { getAskConfigStore } from "./config/store.ts";
import { AskParamsSchema } from "./schema.ts";
import type { AskParams } from "./types.ts";
import { runAskFlow } from "./ui/controller.ts";
@@ -48,7 +49,10 @@ async function executeAskTool(
_onUpdate: unknown,
ctx: ExtensionContext
) {
const validation = validateParams(params);
const config = await getAskConfigStore().getConfig();
const validation = validateParams(params, {
presentSingleAsMulti: config.behaviour.presentSingleAsMulti,
});
if (!validation.ok) {
return invalidPayloadResponse(params, validation.issues);
}
+10 -4
View File
@@ -4,7 +4,7 @@ import {
} from "../constants/keymaps.ts";
import type {
AskConfig,
AskConfigFileV4,
AskConfigFileV5,
AskConfigKeymaps,
AskNotificationChannel,
} from "./schema.ts";
@@ -27,6 +27,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
presentSingleAsMulti: false,
showFooterHints: true,
},
keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS),
@@ -37,7 +38,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
};
export function normalizeAskConfig(
config?: Partial<AskConfigFileV4> | AskConfig
config?: Partial<AskConfigFileV5> | AskConfig
): AskConfig {
return {
answer: {
@@ -68,6 +69,9 @@ export function normalizeAskConfig(
showFooterHints:
config?.behaviour?.showFooterHints ??
DEFAULT_ASK_CONFIG.behaviour.showFooterHints,
presentSingleAsMulti:
config?.behaviour?.presentSingleAsMulti ??
DEFAULT_ASK_CONFIG.behaviour.presentSingleAsMulti,
},
keymaps: mergeKeymaps(config?.keymaps),
notifications: {
@@ -79,10 +83,10 @@ export function normalizeAskConfig(
};
}
export function toAskConfigFileV4(config: AskConfig): AskConfigFileV4 {
export function toAskConfigFileV5(config: AskConfig): AskConfigFileV5 {
const normalized = normalizeAskConfig(config);
return {
schemaVersion: 4,
schemaVersion: 5,
answer: {
extractionModels: normalized.answer.extractionModels,
extractionRetries: normalized.answer.extractionRetries,
@@ -94,6 +98,7 @@ export function toAskConfigFileV4(config: AskConfig): AskConfigFileV4 {
confirmDismissWhenDirty: normalized.behaviour.confirmDismissWhenDirty,
doublePressReviewShortcuts:
normalized.behaviour.doublePressReviewShortcuts,
presentSingleAsMulti: normalized.behaviour.presentSingleAsMulti,
showFooterHints: normalized.behaviour.showFooterHints,
},
keymaps: cloneKeymaps(normalized.keymaps),
@@ -117,6 +122,7 @@ function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
},
main: {
cancel: [...keymaps.main.cancel],
changeQuestionType: [...keymaps.main.changeQuestionType],
confirm: [...keymaps.main.confirm],
nextOption: [...keymaps.main.nextOption],
nextTab: [...keymaps.main.nextTab],
+4 -4
View File
@@ -5,8 +5,8 @@ import {
type AskConfigVersionMigrationResult,
migrateAskConfigFileToCurrent,
} from "./migrations/index.ts";
import type { AskConfig, AskConfigFileV4 } from "./schema.ts";
import { validateAskConfigFileV4 } from "./schema.ts";
import type { AskConfig, AskConfigFileV5 } from "./schema.ts";
import { validateAskConfigFileV5 } from "./schema.ts";
export class AskConfigMigrationError extends Error {
readonly reason: "invalid_or_unsupported" | "migration_failed";
@@ -39,14 +39,14 @@ export function migrateAskConfig(raw: unknown): AskConfigMigrationResult {
);
}
if (!validateAskConfigFileV4.Check(migratedFile.config)) {
if (!validateAskConfigFileV5.Check(migratedFile.config)) {
throw new AskConfigMigrationError(
"Config was invalid or unsupported.",
"invalid_or_unsupported"
);
}
const currentFile = migratedFile.config as AskConfigFileV4;
const currentFile = migratedFile.config as AskConfigFileV5;
const config = normalizeAskConfig(currentFile);
const keymapsResult = normalizeConfiguredKeymaps(currentFile.keymaps);
if (!keymapsResult.ok) {
+33 -1
View File
@@ -1,7 +1,7 @@
import { normalizeLegacyFlatKeymaps } from "../../constants/keymaps.ts";
import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts";
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 4;
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 5;
const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
{
@@ -33,8 +33,40 @@ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
schemaVersion: 4,
}),
},
{
from: 4,
to: 5,
migrate: (config) => ({
...config,
behaviour: {
...((config.behaviour as Record<string, unknown> | undefined) ?? {}),
presentSingleAsMulti: false,
},
keymaps: addV5Keymaps(config.keymaps),
schemaVersion: 5,
}),
},
];
function addV5Keymaps(keymaps: unknown): unknown {
if (!(keymaps && typeof keymaps === "object" && "main" in keymaps)) {
return keymaps;
}
const current = keymaps as Record<string, unknown>;
const main = current.main;
if (!(main && typeof main === "object")) {
return keymaps;
}
return {
...current,
main: {
...(main as Record<string, unknown>),
changeQuestionType: (main as Record<string, unknown>)
.changeQuestionType ?? ["t"],
},
};
}
export class AskConfigVersionMigrationError extends Error {
readonly reason: "invalid_or_unsupported" | "migration_failed";
+76 -3
View File
@@ -21,7 +21,7 @@ const AskKeyBindingSchema = Type.Union([
Type.Array(Type.String()),
]);
const AskConfigKeymapsSchema = Type.Object({
const AskConfigKeymapsV4Schema = Type.Object({
global: Type.Object({
dismiss: Type.Optional(AskKeyBindingSchema),
settings: Type.Optional(AskKeyBindingSchema),
@@ -61,6 +61,76 @@ const AskConfigKeymapsSchema = Type.Object({
}),
});
const AskConfigKeymapsSchema = Type.Object({
global: Type.Object({
dismiss: Type.Optional(AskKeyBindingSchema),
settings: Type.Optional(AskKeyBindingSchema),
}),
main: Type.Object({
cancel: Type.Optional(AskKeyBindingSchema),
changeQuestionType: Type.Optional(AskKeyBindingSchema),
confirm: Type.Optional(AskKeyBindingSchema),
nextOption: Type.Optional(AskKeyBindingSchema),
nextTab: Type.Optional(AskKeyBindingSchema),
optionNote: Type.Optional(AskKeyBindingSchema),
previousOption: Type.Optional(AskKeyBindingSchema),
previousTab: Type.Optional(AskKeyBindingSchema),
questionNote: Type.Optional(AskKeyBindingSchema),
toggle: Type.Optional(AskKeyBindingSchema),
}),
editor: Type.Object({
close: Type.Optional(AskKeyBindingSchema),
nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
submit: Type.Optional(AskKeyBindingSchema),
}),
noteEditor: Type.Object({
close: Type.Optional(AskKeyBindingSchema),
nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
save: Type.Optional(AskKeyBindingSchema),
}),
settingsModal: Type.Object({
close: Type.Optional(AskKeyBindingSchema),
nextOption: Type.Optional(AskKeyBindingSchema),
previousOption: Type.Optional(AskKeyBindingSchema),
toggle: Type.Optional(AskKeyBindingSchema),
}),
});
export const AskConfigFileV5Schema = Type.Object({
schemaVersion: Type.Literal(5),
answer: Type.Optional(
Type.Object({
extractionModels: Type.Optional(
Type.Array(AskAnswerModelPreferenceSchema)
),
extractionRetries: Type.Optional(Type.Number()),
extractionTimeoutMs: Type.Optional(Type.Number()),
})
),
behaviour: Type.Optional(
Type.Object({
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
confirmDismissWhenDirty: Type.Optional(Type.Boolean()),
doublePressReviewShortcuts: Type.Optional(Type.Boolean()),
presentSingleAsMulti: Type.Optional(Type.Boolean()),
showFooterHints: Type.Optional(Type.Boolean()),
})
),
keymaps: Type.Optional(AskConfigKeymapsSchema),
notifications: Type.Optional(
Type.Object({
channels: Type.Optional(Type.Array(AskNotificationChannelSchema)),
enabled: Type.Optional(Type.Boolean()),
})
),
});
export const AskConfigFileV4Schema = Type.Object({
schemaVersion: Type.Literal(4),
answer: Type.Optional(
@@ -80,7 +150,7 @@ export const AskConfigFileV4Schema = Type.Object({
showFooterHints: Type.Optional(Type.Boolean()),
})
),
keymaps: Type.Optional(AskConfigKeymapsSchema),
keymaps: Type.Optional(AskConfigKeymapsV4Schema),
notifications: Type.Optional(
Type.Object({
channels: Type.Optional(Type.Array(AskNotificationChannelSchema)),
@@ -131,6 +201,7 @@ export const AskConfigFileV2Schema = Type.Omit(AskConfigFileV3Schema, [
"schemaVersion",
]);
export type AskConfigFileV5 = Static<typeof AskConfigFileV5Schema>;
export type AskConfigFileV4 = Static<typeof AskConfigFileV4Schema>;
export type AskConfigFileV3 = Static<typeof AskConfigFileV3Schema>;
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & {
@@ -167,6 +238,7 @@ export interface AskConfigKeymaps {
};
main: {
cancel: string[];
changeQuestionType: string[];
confirm: string[];
nextOption: string[];
nextTab: string[];
@@ -202,6 +274,7 @@ export interface AskConfig {
autoSubmitWhenAnsweredWithoutNotes: boolean;
confirmDismissWhenDirty: boolean;
doublePressReviewShortcuts: boolean;
presentSingleAsMulti: boolean;
showFooterHints: boolean;
};
keymaps: AskConfigKeymaps;
@@ -211,4 +284,4 @@ export interface AskConfig {
};
}
export const validateAskConfigFileV4 = Compile(AskConfigFileV4Schema);
export const validateAskConfigFileV5 = Compile(AskConfigFileV5Schema);
+2 -2
View File
@@ -7,7 +7,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
import {
DEFAULT_ASK_CONFIG,
normalizeAskConfig,
toAskConfigFileV4,
toAskConfigFileV5,
} from "./defaults.ts";
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts";
@@ -66,7 +66,7 @@ export class AskConfigStore {
async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
const normalized = normalizeAskConfig(config);
const content = JSON.stringify(
toAskConfigFileV4(normalized),
toAskConfigFileV5(normalized),
null,
2
).concat("\n");
+15
View File
@@ -94,6 +94,7 @@ export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
main: {
confirm: ["enter"],
cancel: ["esc"],
changeQuestionType: ["t"],
toggle: ["space"],
nextTab: ["tab", "right"],
previousTab: ["shift+tab", "left"],
@@ -134,6 +135,7 @@ const DESCRIPTIONS: Record<AskKeymapContext, Record<string, string>> = {
main: {
confirm: "Confirm selection, continue, or submit",
cancel: "Cancel flow",
changeQuestionType: "Change current question type",
toggle: "Toggle selected option",
nextTab: "Switch to next tab",
previousTab: "Switch to previous tab",
@@ -172,6 +174,8 @@ const footerHint = (
label = binding.label
) => `${label} ${action}`;
const footerKeyIdLabel = (binding: AskKeyBinding) => binding.keys.join(" / ");
export function formatKeybindingLabel(key: string): string {
if (key === "up") {
return "↑";
@@ -336,6 +340,11 @@ export function renderFooterKeymaps(
footerHint(main.nextTab, "tab", tabNavigationLabel),
footerHint(main.previousOption, "select", optionNavigationLabel),
footerHint(main.toggle, "toggle"),
footerHint(
main.changeQuestionType,
"question type",
footerKeyIdLabel(main.changeQuestionType)
),
footerHint(main.confirm, "continue"),
footerHint(main.optionNote, "note", noteNavigationLabel),
footerHint(main.cancel, "dismiss"),
@@ -344,6 +353,11 @@ export function renderFooterKeymaps(
default: [
footerHint(main.nextTab, "tab", tabNavigationLabel),
footerHint(main.previousOption, "select", optionNavigationLabel),
footerHint(
main.changeQuestionType,
"question type",
footerKeyIdLabel(main.changeQuestionType)
),
footerHint(main.confirm, "confirm"),
footerHint(main.optionNote, "note", noteNavigationLabel),
footerHint(main.cancel, "dismiss"),
@@ -607,6 +621,7 @@ function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
main: {
confirm: [...keymaps.main.confirm],
cancel: [...keymaps.main.cancel],
changeQuestionType: [...keymaps.main.changeQuestionType],
toggle: [...keymaps.main.toggle],
nextTab: [...keymaps.main.nextTab],
previousTab: [...keymaps.main.previousTab],
+23
View File
@@ -8,6 +8,8 @@ export function formatResultLines(
): string[] {
const lines: string[] = [];
let hasPresentationOverride = false;
for (const question of result.questions) {
const answer = result.answers[question.id];
if (!answer) {
@@ -19,6 +21,10 @@ export function formatResultLines(
lines.push(answerLine);
}
if (hasPresentedTypeOverride(question.type, question.presentedType)) {
hasPresentationOverride = true;
}
const questionNoteLine = formatQuestionNoteLine(
question.label,
answer.note,
@@ -31,6 +37,10 @@ export function formatResultLines(
lines.push(...formatOptionNoteLines(question.label, answer, options.mode));
}
if (hasPresentationOverride) {
lines.push(formatPresentationNoteLine(options.mode));
}
return lines;
}
@@ -52,6 +62,19 @@ function formatAnswerLine(
return `✓ ${questionLabel}: ${answerText}`;
}
function hasPresentedTypeOverride(
type: string,
presentedType: string | undefined
): boolean {
return !!presentedType && presentedType !== type;
}
function formatPresentationNoteLine(mode: "summary" | "render"): string {
const text =
"Note: Some questions were presented as multi-select by user preference.";
return mode === "summary" ? text : ` ${text}`;
}
function formatQuestionNoteLine(
questionLabel: string,
note: string | undefined,
+1
View File
@@ -4,6 +4,7 @@ import { createInitialState as createBaseState } from "./transitions.ts";
interface CreateInitialStateOptions {
allowFreeform?: boolean;
presentSingleAsMulti?: boolean;
}
export function createInitialState(
+15 -3
View File
@@ -13,6 +13,7 @@ interface IssueCollector {
interface ValidationOptions {
allowFreeform?: boolean;
presentSingleAsMulti?: boolean;
}
export function normalizeQuestions(
@@ -23,7 +24,9 @@ export function normalizeQuestions(
if (issues.length > 0) {
throw new Error(issues[0]?.message ?? "Invalid ask_user payload");
}
return params.questions.map(normalizeQuestion);
return params.questions.map((question, index) =>
normalizeQuestion(question, index, options)
);
}
export function collectValidationIssues(
@@ -37,13 +40,22 @@ export function collectValidationIssues(
function normalizeQuestion(
question: AskQuestionInput,
index: number
index: number,
options: ValidationOptions = {}
): AskQuestion {
const requestedType = normalizeQuestionType(question.type);
const presentedType =
options.presentSingleAsMulti && requestedType === "single"
? "multi"
: requestedType;
return {
id: question.id.trim(),
label: question.label?.trim() || `Q${index + 1}`,
prompt: question.prompt.trim(),
type: normalizeQuestionType(question.type),
type: presentedType,
...(presentedType === requestedType
? {}
: { requestedType, presentedType }),
required: question.required ?? false,
options: question.options.map(normalizeOption),
};
+128
View File
@@ -0,0 +1,128 @@
import type { AskQuestionType, AskState, AskStateAnswer } from "../types.ts";
import { isAnswerEmpty } from "./answers.ts";
import { getAnswer, getCurrentQuestion, isSubmitTab } from "./selectors.ts";
export interface QuestionTypeChangeResult {
needsConfirmation: boolean;
notice?: string;
state: AskState;
}
export function cycleCurrentQuestionType(
state: AskState,
options: { confirmed?: boolean } = {}
): QuestionTypeChangeResult {
if (isSubmitTab(state)) {
return { needsConfirmation: false, state };
}
const question = getCurrentQuestion(state);
if (!question) {
return { needsConfirmation: false, state };
}
const nextType = getNextQuestionType(question);
if (nextType === question.type) {
return { needsConfirmation: false, state };
}
const answer = getAnswer(state, question.id);
const requiresConfirmation =
nextType === "single" && countCommittedAnswers(answer) > 1;
if (requiresConfirmation && !options.confirmed) {
return {
needsConfirmation: true,
notice:
"Switching to single-select will clear selected options for this question. Press the type key again to confirm.",
state,
};
}
return {
needsConfirmation: false,
notice: getQuestionTypeChangeNotice(question.type, nextType),
state: setQuestionType(state, question.id, nextType, {
clearSelectedOptions: requiresConfirmation,
}),
};
}
function getNextQuestionType(question: {
requestedType?: AskQuestionType;
type: AskQuestionType;
}): AskQuestionType {
const requestedType = question.requestedType ?? question.type;
if (requestedType === "preview") {
return question.type === "preview" ? "multi" : "preview";
}
switch (question.type) {
case "single":
return "multi";
case "multi":
return "single";
case "preview":
return "multi";
default:
return "multi";
}
}
function countCommittedAnswers(answer: AskStateAnswer | undefined): number {
if (!answer) {
return 0;
}
return (
answer.selected.length +
(answer.customSelected && answer.customText?.trim() ? 1 : 0)
);
}
function setQuestionType(
state: AskState,
questionId: string,
type: AskQuestionType,
options: { clearSelectedOptions: boolean }
): AskState {
const questions = state.questions.map((question) => {
if (question.id !== questionId) {
return question;
}
const requestedType = question.requestedType ?? question.type;
return {
...question,
type,
requestedType,
presentedType: type === requestedType ? undefined : type,
};
});
const currentAnswer = state.answers[questionId];
if (!(options.clearSelectedOptions && currentAnswer)) {
return { ...state, questions };
}
const nextAnswer = {
...currentAnswer,
customSelected: currentAnswer.customText?.trim() ? true : undefined,
selected: [],
};
const answers = { ...state.answers };
if (isAnswerEmpty(nextAnswer)) {
delete answers[questionId];
} else {
answers[questionId] = nextAnswer;
}
return {
...state,
questions,
answers,
};
}
function getQuestionTypeChangeNotice(
from: AskQuestionType,
to: AskQuestionType
): string {
return `Question type changed from ${from} to ${to}.`;
}
+10 -2
View File
@@ -54,7 +54,11 @@ export function toAskResult(state: AskState): AskResult {
id: question.id,
label: question.label,
prompt: question.prompt,
type: question.type,
type: question.requestedType ?? question.type,
...(question.presentedType &&
question.presentedType !== question.requestedType
? { presentedType: question.presentedType }
: {}),
})),
answers,
continuation:
@@ -186,7 +190,11 @@ function createElaborationQuestionContext(
id: question.id,
label: question.label,
prompt: question.prompt,
type: question.type,
type: question.requestedType ?? question.type,
...(question.presentedType &&
question.presentedType !== question.requestedType
? { presentedType: question.presentedType }
: {}),
options: question.options.map(cloneOption),
};
}
+3
View File
@@ -35,6 +35,8 @@ export interface AskValidationError {
export interface AskQuestion
extends Omit<AskQuestionInput, "type" | "required" | "label"> {
label: string;
presentedType?: AskQuestionType;
requestedType?: AskQuestionType;
required: boolean;
type: AskQuestionType;
}
@@ -65,6 +67,7 @@ export interface AskResultAnswer {
export interface AskQuestionSummary {
id: string;
label: string;
presentedType?: AskQuestionType;
prompt: string;
type: AskQuestionType;
}
+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,