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
+29 -1
View File
@@ -19,7 +19,7 @@ test("config migration framework accepts the current schema version", () => {
assert.deepEqual(result.config, DEFAULT_ASK_CONFIG);
});
test("config migration maps previous-version config files into v4 shape", () => {
test("config migration maps previous-version config files into current shape", () => {
const legacyKeymaps = {
cancel: "q",
confirm: "ctrl+k",
@@ -77,6 +77,34 @@ test("config migration maps previous-version config files into v4 shape", () =>
}
});
test("config migration adds v5 defaults to v4 configs", () => {
const v4MainKeymaps = Object.fromEntries(
Object.entries(DEFAULT_ASK_CONFIG.keymaps.main).filter(
([key]) => key !== "changeQuestionType"
)
);
const result = migrateAskConfig({
schemaVersion: 4,
answer: DEFAULT_ASK_CONFIG.answer,
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
showFooterHints: false,
},
keymaps: {
...DEFAULT_ASK_CONFIG.keymaps,
main: v4MainKeymaps,
},
notifications: DEFAULT_ASK_CONFIG.notifications,
});
assert.equal(result.migrated, true);
assert.equal(result.config.behaviour.presentSingleAsMulti, false);
assert.equal(result.config.behaviour.showFooterHints, false);
assert.deepEqual(result.config.keymaps.main.changeQuestionType, ["t"]);
});
test("config migration maps legacy flat keymaps into context keymaps", () => {
const result = migrateAskConfig({
schemaVersion: 3,
+4 -7
View File
@@ -13,7 +13,7 @@ function expectedConfigFile(
overrides: { behaviour?: typeof DEFAULT_ASK_CONFIG.behaviour } = {}
) {
return {
schemaVersion: 4,
schemaVersion: 5,
answer: DEFAULT_ASK_CONFIG.answer,
behaviour: overrides.behaviour ?? DEFAULT_ASK_CONFIG.behaviour,
keymaps: DEFAULT_ASK_CONFIG.keymaps,
@@ -48,9 +48,8 @@ test("config store writes full normalized config on save", async () => {
await store.save({
behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
showFooterHints: false,
},
keymaps: DEFAULT_ASK_CONFIG.keymaps,
@@ -61,9 +60,8 @@ test("config store writes full normalized config on save", async () => {
JSON.parse(content),
expectedConfigFile({
behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
showFooterHints: false,
},
})
@@ -158,9 +156,8 @@ test("config store migrates the legacy root config path into extensions", async
JSON.parse(await readFile(path, "utf-8")),
expectedConfigFile({
behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
showFooterHints: false,
},
})
+29
View File
@@ -190,6 +190,35 @@ test("question mark opens ask settings outside non-empty editors", () => {
});
});
test("question type shortcut uses configured main keymap", () => {
const state = createInitialState({
questions: [
{
id: "q1",
prompt: "Question?",
options: [{ value: "a", label: "A" }],
},
],
});
const config = {
...DEFAULT_ASK_CONFIG,
keymaps: {
...DEFAULT_ASK_CONFIG.keymaps,
main: {
...DEFAULT_ASK_CONFIG.keymaps.main,
changeQuestionType: ["ctrl+t"],
},
},
};
assert.deepEqual(getInputCommand(state, DEFAULT_ASK_CONFIG, "t"), {
kind: "changeQuestionType",
});
assert.deepEqual(getInputCommand(state, config, "\u0014"), {
kind: "changeQuestionType",
});
});
test("note shortcuts use n for option notes and Shift+N for question notes", () => {
const navigation = createInitialState({
questions: [
+4 -2
View File
@@ -225,8 +225,9 @@ test("footer hints wrap into exact lines on narrow screens", () => {
editor: mockEditor(),
});
assert.deepEqual(lines.slice(-5, -1), [
assert.deepEqual(lines.slice(-6, -1), [
"Space toggle",
"t question type",
"Enter continue",
"N/Shift+N note",
"Esc dismiss · ? settings",
@@ -253,7 +254,8 @@ test("footer keeps earlier hint chunk on the first wrapped line", () => {
editor: mockEditor(),
});
assert.equal(lines.at(-6), " ⇆ tab · ↑↓ select");
assert.equal(lines.at(-7), " ⇆ tab · ↑↓ select");
assert.equal(lines.at(-6), "t question type");
assert.deepEqual(lines.slice(-5, -1), [
"Enter confirm",
"N/Shift+N note",
+100
View File
@@ -7,9 +7,11 @@ import {
applyNumberShortcut,
enterOptionNoteMode,
enterQuestionNoteMode,
moveOption,
moveTab,
saveNote,
submitCustomAnswer,
toggleCurrentMultiOption,
} from "../src/state/transitions.ts";
test("summarizeResult formats selected answers", () => {
@@ -34,6 +36,104 @@ test("summarizeResult formats selected answers", () => {
assert.equal(renderResultText(result), "✓ Style: Rich");
});
test("single questions presented as multi preserve requested type in results", () => {
let state = createInitialState(
{
questions: [
{
id: "scope",
label: "Scope",
prompt: "What scope?",
options: [
{ value: "docs", label: "Docs" },
{ value: "tests", label: "Tests" },
],
},
],
},
{ presentSingleAsMulti: true }
);
state = toggleCurrentMultiOption(state);
state = moveOption(state, 1);
state = toggleCurrentMultiOption(state);
const result = toAskResult(state);
assert.deepEqual(result.questions, [
{
id: "scope",
label: "Scope",
prompt: "What scope?",
type: "single",
presentedType: "multi",
},
]);
assert.equal(
summarizeResult(result),
"Scope: Docs, Tests\nNote: Some questions were presented as multi-select by user preference."
);
assert.equal(
renderResultText(result),
"✓ Scope: Docs, Tests\n Note: Some questions were presented as multi-select by user preference."
);
});
test("presentation override summary uses one compact note", () => {
const result = {
cancelled: false,
mode: "submit" as const,
questions: [
{
id: "apiStyle",
label: "Single Select",
prompt: "Pick one API style.",
type: "single" as const,
presentedType: "multi" as const,
},
{
id: "docsSections",
label: "Multi Select",
prompt: "Which docs sections should be updated?",
type: "multi" as const,
},
{
id: "layout",
label: "Preview",
prompt: "Choose a layout with preview content.",
type: "preview" as const,
presentedType: "multi" as const,
},
],
answers: {
apiStyle: {
values: ["rest", "graphql"],
labels: ["REST", "GraphQL"],
indices: [1, 2],
},
docsSections: {
values: ["readme", "contract", "architecture"],
labels: ["README", "Contract", "Architecture"],
indices: [1, 2, 3],
},
layout: {
values: ["compact", "detailed"],
labels: ["Compact", "Detailed"],
indices: [1, 2],
},
},
};
assert.equal(
summarizeResult(result),
[
"Single Select: REST, GraphQL",
"Multi Select: README, Contract, Architecture",
"Preview: Compact, Detailed",
"Note: Some questions were presented as multi-select by user preference.",
].join("\n")
);
});
test("summaries include custom text answers", () => {
let state = createInitialState({
questions: [
+5
View File
@@ -13,6 +13,7 @@ const savedConfig: AskConfig = {
autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
presentSingleAsMulti: false,
showFooterHints: true,
},
keymaps: DEFAULT_ASK_CONFIG.keymaps,
@@ -65,9 +66,12 @@ test("settings list renders behaviour settings and config path", () => {
assert(text.includes("╭"));
assert(text.includes("@eko24ive/pi-ask"));
assert(text.includes("Live settings"));
assert(text.includes("Defaults for future asks"));
assert(text.includes("Auto-submit when answered without notes"));
assert(text.includes("[off]"));
assert(text.includes("Confirm dismiss when dirty"));
assert(text.includes("Present single-select as multi-select"));
assert(text.includes("on"));
assert(text.includes("Edit this config file to customize"));
assert(text.includes("keymaps"));
@@ -107,6 +111,7 @@ test("settings list saves behaviour changes immediately without success feedback
assert.equal(saved?.behaviour.autoSubmitWhenAnsweredWithoutNotes, true);
assert.equal(saved?.behaviour.confirmDismissWhenDirty, true);
assert.equal(saved?.behaviour.doublePressReviewShortcuts, true);
assert.equal(saved?.behaviour.presentSingleAsMulti, false);
assert.equal(saved?.behaviour.showFooterHints, true);
assert.equal(text.includes("Saved"), false);
});
+135
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createInitialState } from "../src/state/create.ts";
import { cycleCurrentQuestionType } from "../src/state/question-type.ts";
const DUPLICATE_QUESTION_ID_RE = /duplicate question id "scope"/;
const DUPLICATE_OPTION_VALUE_RE = /duplicate option value "small"/;
@@ -298,6 +299,140 @@ test("single-select space toggles the active option without advancing", () => {
assert.equal(state.view.kind, "navigate");
});
test("presentSingleAsMulti handles requested single questions as multi-select", () => {
let state = createInitialState(sampleParams(), {
presentSingleAsMulti: true,
});
assert.equal(state.questions[0].type, "multi");
assert.equal(state.questions[0].requestedType, "single");
assert.equal(state.questions[0].presentedType, "multi");
state = toggleCurrentMultiOption(state);
state = moveOption(state, 1);
state = toggleCurrentMultiOption(state);
assert.equal(state.activeTabIndex, 0);
assert.deepEqual(
state.answers.lang.selected.map((selection) => selection.label),
["Python", "TypeScript"]
);
});
test("question type hotkey toggles single and multi while preserving requested type", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
options: [
{ value: "a", label: "A", preview: "Preview A" },
{ value: "b", label: "B", preview: "Preview B" },
],
},
],
});
let result = cycleCurrentQuestionType(state);
state = result.state;
assert.equal(result.needsConfirmation, false);
assert.equal(state.questions[0].type, "multi");
assert.equal(state.questions[0].requestedType, "single");
assert.equal(state.questions[0].presentedType, "multi");
result = cycleCurrentQuestionType(state);
state = result.state;
assert.equal(state.questions[0].type, "single");
assert.equal(state.questions[0].presentedType, undefined);
});
test("question type hotkey toggles preview questions with multi only", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
type: "preview",
options: [{ value: "a", label: "A", preview: "Preview A" }],
},
],
});
let result = cycleCurrentQuestionType(state);
state = result.state;
assert.equal(result.needsConfirmation, false);
assert.equal(state.questions[0].type, "multi");
assert.equal(state.questions[0].requestedType, "preview");
assert.equal(state.questions[0].presentedType, "multi");
result = cycleCurrentQuestionType(state);
state = result.state;
assert.equal(result.needsConfirmation, false);
assert.equal(state.questions[0].type, "preview");
assert.equal(state.questions[0].requestedType, "preview");
assert.equal(state.questions[0].presentedType, undefined);
});
test("question type hotkey requires confirmation before clearing multi answers", () => {
let state = createInitialState(sampleParams(), {
presentSingleAsMulti: true,
});
state = toggleCurrentMultiOption(state);
state = moveOption(state, 1);
state = toggleCurrentMultiOption(state);
let result = cycleCurrentQuestionType(state);
assert.equal(result.needsConfirmation, true);
assert.deepEqual(
result.state.answers.lang.selected.map((selection) => selection.value),
["py", "ts"]
);
result = cycleCurrentQuestionType(state, { confirmed: true });
state = result.state;
assert.equal(result.needsConfirmation, false);
assert.equal(state.questions[0].type, "single");
assert.equal(state.questions[0].presentedType, undefined);
assert.equal(state.answers.lang, undefined);
});
test("confirmed multi-to-single type change preserves custom text and notes", () => {
let state = createInitialState(
{
questions: [
{
id: "q1",
prompt: "Pick options",
type: "multi",
options: [
{ value: "a", label: "A" },
{ value: "b", label: "B" },
],
},
],
},
{ presentSingleAsMulti: true }
);
state = toggleCurrentMultiOption(state);
state = submitCustomAnswer(
{ ...state, view: { kind: "input", questionId: "q1" } },
"Custom"
);
state = enterQuestionNoteMode(state, "q1");
state = saveNote(state, "Keep note");
const result = cycleCurrentQuestionType(state, { confirmed: true });
state = result.state;
assert.equal(state.questions[0].type, "single");
assert.deepEqual(state.answers.q1.selected, []);
assert.equal(state.answers.q1.customText, "Custom");
assert.equal(state.answers.q1.customSelected, true);
assert.equal(state.answers.q1.note, "Keep note");
});
test("submit can complete even when some questions are unanswered", () => {
let state = createInitialState(sampleParams());
state = moveTab(state, -1);