From f1a0a5d5e5eb3591b90200921e88660517830dec Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Wed, 6 May 2026 12:17:04 +0200 Subject: [PATCH] feat: add question type presentation controls --- README.md | 6 +- docs/architecture.md | 4 + docs/configuration.md | 21 ++++- docs/contract.md | 10 ++- src/ask-tool-helpers.ts | 1 + src/ask-tool.ts | 6 +- src/config/defaults.ts | 14 +++- src/config/migrate.ts | 8 +- src/config/migrations/index.ts | 34 ++++++++- src/config/schema.ts | 79 ++++++++++++++++++- src/config/store.ts | 4 +- src/constants/keymaps.ts | 15 ++++ src/result-format.ts | 23 ++++++ src/state/create.ts | 1 + src/state/normalize.ts | 18 ++++- src/state/question-type.ts | 128 +++++++++++++++++++++++++++++++ src/state/result.ts | 12 ++- src/types.ts | 3 + src/ui/controller.ts | 46 ++++++++++- src/ui/input.ts | 4 + src/ui/settings-list.ts | 52 +++++++++++-- tests/config-migrate.test.ts | 30 +++++++- tests/config-store.test.ts | 11 +-- tests/input.test.ts | 29 +++++++ tests/render-frame.test.ts | 6 +- tests/result.test.ts | 100 ++++++++++++++++++++++++ tests/settings-list.test.ts | 5 ++ tests/state.test.ts | 135 +++++++++++++++++++++++++++++++++ 28 files changed, 761 insertions(+), 44 deletions(-) create mode 100644 src/state/question-type.ts diff --git a/README.md b/README.md index 9606a52..d250cf8 100644 --- a/README.md +++ b/README.md @@ -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"], diff --git a/docs/architecture.md b/docs/architecture.md index 9e577a3..012ad14 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index 4c2b625..eebdf3a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/contract.md b/docs/contract.md index 022b28d..764d11b 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -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 diff --git a/src/ask-tool-helpers.ts b/src/ask-tool-helpers.ts index 569747a..ee9edb7 100644 --- a/src/ask-tool-helpers.ts +++ b/src/ask-tool-helpers.ts @@ -30,6 +30,7 @@ export const ASK_TOOL_PROMPT_GUIDELINES = [ interface ValidateParamsOptions { allowFreeform?: boolean; + presentSingleAsMulti?: boolean; } export function validateParams( diff --git a/src/ask-tool.ts b/src/ask-tool.ts index 13f403b..39bf42c 100644 --- a/src/ask-tool.ts +++ b/src/ask-tool.ts @@ -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); } diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 4600383..2915f00 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -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 | AskConfig + config?: Partial | 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], diff --git a/src/config/migrate.ts b/src/config/migrate.ts index 2b41784..f32e2c5 100644 --- a/src/config/migrate.ts +++ b/src/config/migrate.ts @@ -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) { diff --git a/src/config/migrations/index.ts b/src/config/migrations/index.ts index de7bac7..1432a91 100644 --- a/src/config/migrations/index.ts +++ b/src/config/migrations/index.ts @@ -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 | 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; + const main = current.main; + if (!(main && typeof main === "object")) { + return keymaps; + } + return { + ...current, + main: { + ...(main as Record), + changeQuestionType: (main as Record) + .changeQuestionType ?? ["t"], + }, + }; +} + export class AskConfigVersionMigrationError extends Error { readonly reason: "invalid_or_unsupported" | "migration_failed"; diff --git a/src/config/schema.ts b/src/config/schema.ts index 1a6e0cd..134ae1f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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; export type AskConfigFileV4 = Static; export type AskConfigFileV3 = Static; export type AskConfigFileV2 = Static & { @@ -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); diff --git a/src/config/store.ts b/src/config/store.ts index 7a0ce47..3db1dd4 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -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): Promise { const normalized = normalizeAskConfig(config); const content = JSON.stringify( - toAskConfigFileV4(normalized), + toAskConfigFileV5(normalized), null, 2 ).concat("\n"); diff --git a/src/constants/keymaps.ts b/src/constants/keymaps.ts index 6f3a247..d396cba 100644 --- a/src/constants/keymaps.ts +++ b/src/constants/keymaps.ts @@ -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> = { 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], diff --git a/src/result-format.ts b/src/result-format.ts index bb9140a..3dc3a97 100644 --- a/src/result-format.ts +++ b/src/result-format.ts @@ -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, diff --git a/src/state/create.ts b/src/state/create.ts index 42e0a6c..8c496d8 100644 --- a/src/state/create.ts +++ b/src/state/create.ts @@ -4,6 +4,7 @@ import { createInitialState as createBaseState } from "./transitions.ts"; interface CreateInitialStateOptions { allowFreeform?: boolean; + presentSingleAsMulti?: boolean; } export function createInitialState( diff --git a/src/state/normalize.ts b/src/state/normalize.ts index c5aac45..82dcbf9 100644 --- a/src/state/normalize.ts +++ b/src/state/normalize.ts @@ -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), }; diff --git a/src/state/question-type.ts b/src/state/question-type.ts new file mode 100644 index 0000000..73ea576 --- /dev/null +++ b/src/state/question-type.ts @@ -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}.`; +} diff --git a/src/state/result.ts b/src/state/result.ts index 0ab9398..cd02dc2 100644 --- a/src/state/result.ts +++ b/src/state/result.ts @@ -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), }; } diff --git a/src/types.ts b/src/types.ts index ca70dbd..3d72396 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,8 @@ export interface AskValidationError { export interface AskQuestion extends Omit { 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; } diff --git a/src/ui/controller.ts b/src/ui/controller.ts index fb498db..dc7a9e3 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -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 { const store = getAskConfigStore(); const config = await store.getConfig(); + const flowOptions = { + ...options, + presentSingleAsMulti: + options.presentSingleAsMulti ?? config.behaviour.presentSingleAsMulti, + }; return ctx.ui.custom((...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 { diff --git a/src/ui/input.ts b/src/ui/input.ts index bb02677..1e70a3c 100644 --- a/src/ui/input.ts +++ b/src/ui/input.ts @@ -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" }; } diff --git a/src/ui/settings-list.ts b/src/ui/settings-list.ts index 69bcf68..edde52c 100644 --- a/src/ui/settings-list.ts +++ b/src/ui/settings-list.ts @@ -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["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, diff --git a/tests/config-migrate.test.ts b/tests/config-migrate.test.ts index b4e7183..6397801 100644 --- a/tests/config-migrate.test.ts +++ b/tests/config-migrate.test.ts @@ -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, diff --git a/tests/config-store.test.ts b/tests/config-store.test.ts index 028df53..4ec2dce 100644 --- a/tests/config-store.test.ts +++ b/tests/config-store.test.ts @@ -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, }, }) diff --git a/tests/input.test.ts b/tests/input.test.ts index 6ec0d7d..441e38f 100644 --- a/tests/input.test.ts +++ b/tests/input.test.ts @@ -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: [ diff --git a/tests/render-frame.test.ts b/tests/render-frame.test.ts index 873d1c1..024125b 100644 --- a/tests/render-frame.test.ts +++ b/tests/render-frame.test.ts @@ -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", diff --git a/tests/result.test.ts b/tests/result.test.ts index bd8d90e..a14542d 100644 --- a/tests/result.test.ts +++ b/tests/result.test.ts @@ -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: [ diff --git a/tests/settings-list.test.ts b/tests/settings-list.test.ts index 7c668f0..6c71102 100644 --- a/tests/settings-list.test.ts +++ b/tests/settings-list.test.ts @@ -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); }); diff --git a/tests/state.test.ts b/tests/state.test.ts index 50dfff7..31a5126 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -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);