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
+4 -2
View File
@@ -113,13 +113,13 @@ Fixed bindings:
| `@` | Editors | File-reference affordance |
| Arrow keys / `Tab` | Non-empty editor | Stay in editor for cursor movement |
Review-tab shortcuts can optionally require the same number key twice via `behaviour.doublePressReviewShortcuts`.
Review-tab shortcuts can optionally require the same number key twice via `behaviour.doublePressReviewShortcuts`. `behaviour.presentSingleAsMulti` can render future single-select questions as multi-select while preserving the requested type in results; use `main.changeQuestionType` (`t` by default) to change the active question type live.
You can edit the config file yourself, ask pi to edit it for you, or use `/ask-settings` to find the exact config path, toggle behaviour/notification settings, or reset config to defaults with a guarded double press.
```json
{
"schemaVersion": 4,
"schemaVersion": 5,
"answer": {
"extractionModels": [
{ "provider": "openai-codex", "id": "gpt-5.4-mini" },
@@ -133,6 +133,7 @@ You can edit the config file yourself, ask pi to edit it for you, or use `/ask-s
"autoSubmitWhenAnsweredWithoutNotes": false,
"confirmDismissWhenDirty": true,
"doublePressReviewShortcuts": true,
"presentSingleAsMulti": false,
"showFooterHints": true
},
"keymaps": {
@@ -141,6 +142,7 @@ You can edit the config file yourself, ask pi to edit it for you, or use `/ask-s
"confirm": ["enter"],
"cancel": ["esc"],
"toggle": ["space"],
"changeQuestionType": ["t"],
"nextTab": ["tab", "right"],
"previousTab": ["shift+tab", "left"],
"nextOption": ["down"],
+4
View File
@@ -67,6 +67,8 @@ The codebase is split so the implementation reads through file boundaries and na
- submit is never blocked by unanswered questions
- single-select answers serialize as arrays
- when `behaviour.presentSingleAsMulti` is enabled, future single-select questions are handled with multi-select state semantics while result metadata preserves the requested `type` and adds `presentedType`
- active-flow question type changes are per-question runtime overrides handled in state/controller logic; they do not mutate the stored source payload or global config
- single-select free-form answers replace selected options for that question
- multi-select free-form answers augment selected options instead of clearing them
- preview questions keep their preview-pane behavior while also supporting the synthetic custom-answer option
@@ -79,6 +81,8 @@ The codebase is split so the implementation reads through file boundaries and na
- invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour, notification, or answer settings
- invalid notification channels are skipped and fall back to the default bell channel if none are valid
- ask settings behaviour and notification enabled changes persist immediately from the settings list; config reset is guarded by a short double-press confirmation
- `presentSingleAsMulti` is applied at ask-flow creation; toggling it does not rewrite already-normalized questions in an open flow
- `main.changeQuestionType` changes the active question type live (non-preview: `single <-> multi`; preview: `preview <-> multi`) and may require confirmation before destructive multi-to-single conversion
- when the ask config file is missing, the first ask use writes a default persisted config snapshot under `~/.pi/agent/extensions/`
- legacy root config files move into `~/.pi/agent/extensions/` only when the current config file is absent
- live config updates can affect an in-progress ask flow immediately
+18 -3
View File
@@ -28,7 +28,7 @@ Unsupported future versions or invalid files are backed up and defaults are load
```json
{
"schemaVersion": 4,
"schemaVersion": 5,
"answer": {
"extractionModels": [
{ "provider": "openai-codex", "id": "gpt-5.4-mini" },
@@ -42,6 +42,7 @@ Unsupported future versions or invalid files are backed up and defaults are load
"autoSubmitWhenAnsweredWithoutNotes": false,
"confirmDismissWhenDirty": true,
"doublePressReviewShortcuts": true,
"presentSingleAsMulti": false,
"showFooterHints": true
},
"keymaps": {
@@ -50,6 +51,7 @@ Unsupported future versions or invalid files are backed up and defaults are load
"confirm": ["enter"],
"cancel": ["esc"],
"toggle": ["space"],
"changeQuestionType": ["t"],
"nextTab": ["tab", "right"],
"previousTab": ["shift+tab", "left"],
"nextOption": ["down"],
@@ -133,6 +135,16 @@ These settings affect only the `/answer` command. Normal `ask_user` tool calls d
- effect: when enabled, review-tab number shortcuts (`1`, `2`, `3`) only trigger after pressing the same key twice
- the review screen shows an inline hint and keeps the pending shortcut armed until another review shortcut is pressed or the user leaves the tab
### `behaviour.presentSingleAsMulti`
- type: boolean
- default: `false`
- effect: future ask flows render requested `single` questions as `multi` questions
- result: the question keeps `type: "single"` and adds `presentedType: "multi"`; result text uses one compact note when any answered questions were presented differently
- scope: this is a default presentation policy for new/replayed ask flows; it is not hot-applied to the current ask flow
- current-flow override: use the configurable main-flow `changeQuestionType` hotkey (default `t`) to change the active question type live: non-preview questions toggle `single <-> multi`; preview questions toggle `preview <-> multi`
- destructive confirmation: changing `multi -> single` with multiple selected answers requires pressing the type hotkey again; the pending confirmation has no timeout and clears when another navigation/action is used
### `behaviour.showFooterHints`
- type: boolean
@@ -201,6 +213,7 @@ Arrays are aliases: any listed key triggers the same action.
"main": {
"confirm": ["enter"],
"cancel": ["esc"],
"changeQuestionType": ["t"],
"toggle": ["space"],
"nextTab": ["tab", "right"],
"previousTab": ["shift+tab", "left"],
@@ -307,7 +320,7 @@ Invalid keymaps include:
```json
{
"schemaVersion": 4,
"schemaVersion": 5,
"answer": {
"extractionRetries": 1,
"extractionTimeoutMs": 30000,
@@ -319,6 +332,7 @@ Invalid keymaps include:
"autoSubmitWhenAnsweredWithoutNotes": true,
"confirmDismissWhenDirty": true,
"doublePressReviewShortcuts": true,
"presentSingleAsMulti": false,
"showFooterHints": false
},
"keymaps": {
@@ -330,6 +344,7 @@ Invalid keymaps include:
"confirm": ["ctrl+k"],
"cancel": ["q"],
"toggle": ["ctrl+t"],
"changeQuestionType": ["t"],
"nextTab": ["tab", "right"],
"previousTab": ["shift+tab", "left"],
"nextOption": ["down"],
@@ -377,7 +392,7 @@ Invalid keymaps include:
When editing this config for a user:
- preserve unrelated fields
- keep `schemaVersion` at `4`
- keep `schemaVersion` at `5`
- preserve `answer.extractionModels` as explicit provider/id pairs
- keep `answer.extractionRetries` between `0` and `3`
- do not assign fixed numeric shortcuts (`1` through `9`) to configurable actions
+8 -2
View File
@@ -61,6 +61,7 @@ This document defines the stable external behavior. It does not explain internal
label: string;
prompt: string;
type: "single" | "multi" | "preview";
presentedType?: "single" | "multi" | "preview";
}>;
answers: Record<
string,
@@ -99,6 +100,7 @@ This document defines the stable external behavior. It does not explain internal
label: string;
prompt: string;
type: "single" | "multi" | "preview";
presentedType?: "single" | "multi" | "preview";
options: Array<{
value: string;
label: string;
@@ -124,6 +126,7 @@ This document defines the stable external behavior. It does not explain internal
label: string;
prompt: string;
type: "single" | "multi" | "preview";
presentedType?: "single" | "multi" | "preview";
options: Array<{
value: string;
label: string;
@@ -167,6 +170,7 @@ This document defines the stable external behavior. It does not explain internal
- `continuation.affectedQuestionIds` lists the only questions that should be revisited
- `continuation.questionStates` marks each question as `answered`, `needs_clarification`, or `unanswered`
- single-select answers still use arrays
- when `behaviour.presentSingleAsMulti` is enabled, requested single-select questions are presented and handled as multi-select in future/replayed ask flows; result question metadata keeps the requested `type`, adds `presentedType` when final presentation differs, and result text uses one compact note when any answered questions were presented differently
- `indices` are 1-based rendered option positions
- `customText` stores the free-form answer
- on single-select questions, saving free-form text clears selected options for that question
@@ -193,6 +197,7 @@ This document defines the stable external behavior. It does not explain internal
- tabbed multi-question flow
- single-select, multi-select, and preview questions
- active question type changes via configurable `main.changeQuestionType` hotkey, default `t`; non-preview questions toggle `single <-> multi`; preview questions toggle `preview <-> multi`
- inline free-form answers for all question types
- native pi-style `@` file path autocomplete inside free-form answer and note editors
- question notes via `Shift+N`
@@ -208,7 +213,7 @@ This document defines the stable external behavior. It does not explain internal
- `/ask:replay` command to replay the latest real `ask_user` form on the current branch
- ask settings list with binary behaviour/notification toggles and a guarded reset-to-defaults action
- `?` in the ask flow and `/ask-settings` in pi open the same lightweight ask settings overlay
- settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, and `Show footer hints`; resetting config to defaults requires pressing the reset action twice within a short confirmation window
- settings persist immediately when changed: `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, and `Show footer hints`; `Present single-select as multi-select` persists immediately but applies only to new/replayed ask flows; resetting config to defaults requires pressing the reset action twice within a short confirmation window
- `Keymaps` is a persisted, context-aware config section for global, main-flow, editor, note-editor, and settings-modal actions
- the settings list shows the absolute config file path for customizing keymaps, notifications, and extraction settings
- if the flow is already on the review tab, all questions are answered, and no notes exist, enabling auto-submit can complete the current ask flow immediately
@@ -223,6 +228,7 @@ Main flow:
- `main.nextTab` / `main.previousTab` move between tabs; defaults: `Tab`/`Right`, `Shift+Tab`/`Left`
- `main.nextOption` / `main.previousOption` move between options or review actions; defaults: `Down`, `Up`
- `main.confirm`, `main.cancel`, and `main.toggle` confirm, cancel, or toggle; defaults: `Enter`, `Esc`, `Space`
- `main.changeQuestionType` changes the active question type (non-preview: `single <-> multi`; preview: `preview <-> multi`); default: `t`; destructive `multi -> single` changes require pressing the type hotkey again, with no timeout, and the pending confirmation clears on other navigation/actions
- `main.optionNote` and `main.questionNote` open option/question notes; defaults: `n`, `Shift+N`
- `1..9` is fixed and selects or toggles the matching option; on the review tab, `1`, `2`, and `3` trigger `Submit`, `Elaborate`, and `Cancel`
- when `Double-press review shortcuts` is enabled, review-tab `1`, `2`, and `3` require the same key twice without a timeout, and the review screen shows an inline hint for the pending action
@@ -255,7 +261,7 @@ If `ctx.hasUI === false`, the tool returns a `Needs user input` message in `cont
Validation is handled inside the tool so malformed calls produce the same structured error shape as other invalid payloads instead of relying on pre-execution schema failures.
The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, `Show footer hints`, resetting config to defaults, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks.
The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, `Show footer hints`, resetting config to defaults, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks. `Present single-select as multi-select` is applied when an ask flow is created and does not rewrite question semantics for an already-open flow; use `main.changeQuestionType` for live per-question changes.
## Notifications
+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,
+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);