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 | | `@` | Editors | File-reference affordance |
| Arrow keys / `Tab` | Non-empty editor | Stay in editor for cursor movement | | 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. 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 ```json
{ {
"schemaVersion": 4, "schemaVersion": 5,
"answer": { "answer": {
"extractionModels": [ "extractionModels": [
{ "provider": "openai-codex", "id": "gpt-5.4-mini" }, { "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, "autoSubmitWhenAnsweredWithoutNotes": false,
"confirmDismissWhenDirty": true, "confirmDismissWhenDirty": true,
"doublePressReviewShortcuts": true, "doublePressReviewShortcuts": true,
"presentSingleAsMulti": false,
"showFooterHints": true "showFooterHints": true
}, },
"keymaps": { "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"], "confirm": ["enter"],
"cancel": ["esc"], "cancel": ["esc"],
"toggle": ["space"], "toggle": ["space"],
"changeQuestionType": ["t"],
"nextTab": ["tab", "right"], "nextTab": ["tab", "right"],
"previousTab": ["shift+tab", "left"], "previousTab": ["shift+tab", "left"],
"nextOption": ["down"], "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 - submit is never blocked by unanswered questions
- single-select answers serialize as arrays - 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 - single-select free-form answers replace selected options for that question
- multi-select free-form answers augment selected options instead of clearing them - 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 - 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 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 - 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 - 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/` - 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 - 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 - 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 ```json
{ {
"schemaVersion": 4, "schemaVersion": 5,
"answer": { "answer": {
"extractionModels": [ "extractionModels": [
{ "provider": "openai-codex", "id": "gpt-5.4-mini" }, { "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, "autoSubmitWhenAnsweredWithoutNotes": false,
"confirmDismissWhenDirty": true, "confirmDismissWhenDirty": true,
"doublePressReviewShortcuts": true, "doublePressReviewShortcuts": true,
"presentSingleAsMulti": false,
"showFooterHints": true "showFooterHints": true
}, },
"keymaps": { "keymaps": {
@@ -50,6 +51,7 @@ Unsupported future versions or invalid files are backed up and defaults are load
"confirm": ["enter"], "confirm": ["enter"],
"cancel": ["esc"], "cancel": ["esc"],
"toggle": ["space"], "toggle": ["space"],
"changeQuestionType": ["t"],
"nextTab": ["tab", "right"], "nextTab": ["tab", "right"],
"previousTab": ["shift+tab", "left"], "previousTab": ["shift+tab", "left"],
"nextOption": ["down"], "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 - 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 - 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` ### `behaviour.showFooterHints`
- type: boolean - type: boolean
@@ -201,6 +213,7 @@ Arrays are aliases: any listed key triggers the same action.
"main": { "main": {
"confirm": ["enter"], "confirm": ["enter"],
"cancel": ["esc"], "cancel": ["esc"],
"changeQuestionType": ["t"],
"toggle": ["space"], "toggle": ["space"],
"nextTab": ["tab", "right"], "nextTab": ["tab", "right"],
"previousTab": ["shift+tab", "left"], "previousTab": ["shift+tab", "left"],
@@ -307,7 +320,7 @@ Invalid keymaps include:
```json ```json
{ {
"schemaVersion": 4, "schemaVersion": 5,
"answer": { "answer": {
"extractionRetries": 1, "extractionRetries": 1,
"extractionTimeoutMs": 30000, "extractionTimeoutMs": 30000,
@@ -319,6 +332,7 @@ Invalid keymaps include:
"autoSubmitWhenAnsweredWithoutNotes": true, "autoSubmitWhenAnsweredWithoutNotes": true,
"confirmDismissWhenDirty": true, "confirmDismissWhenDirty": true,
"doublePressReviewShortcuts": true, "doublePressReviewShortcuts": true,
"presentSingleAsMulti": false,
"showFooterHints": false "showFooterHints": false
}, },
"keymaps": { "keymaps": {
@@ -330,6 +344,7 @@ Invalid keymaps include:
"confirm": ["ctrl+k"], "confirm": ["ctrl+k"],
"cancel": ["q"], "cancel": ["q"],
"toggle": ["ctrl+t"], "toggle": ["ctrl+t"],
"changeQuestionType": ["t"],
"nextTab": ["tab", "right"], "nextTab": ["tab", "right"],
"previousTab": ["shift+tab", "left"], "previousTab": ["shift+tab", "left"],
"nextOption": ["down"], "nextOption": ["down"],
@@ -377,7 +392,7 @@ Invalid keymaps include:
When editing this config for a user: When editing this config for a user:
- preserve unrelated fields - preserve unrelated fields
- keep `schemaVersion` at `4` - keep `schemaVersion` at `5`
- preserve `answer.extractionModels` as explicit provider/id pairs - preserve `answer.extractionModels` as explicit provider/id pairs
- keep `answer.extractionRetries` between `0` and `3` - keep `answer.extractionRetries` between `0` and `3`
- do not assign fixed numeric shortcuts (`1` through `9`) to configurable actions - 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; label: string;
prompt: string; prompt: string;
type: "single" | "multi" | "preview"; type: "single" | "multi" | "preview";
presentedType?: "single" | "multi" | "preview";
}>; }>;
answers: Record< answers: Record<
string, string,
@@ -99,6 +100,7 @@ This document defines the stable external behavior. It does not explain internal
label: string; label: string;
prompt: string; prompt: string;
type: "single" | "multi" | "preview"; type: "single" | "multi" | "preview";
presentedType?: "single" | "multi" | "preview";
options: Array<{ options: Array<{
value: string; value: string;
label: string; label: string;
@@ -124,6 +126,7 @@ This document defines the stable external behavior. It does not explain internal
label: string; label: string;
prompt: string; prompt: string;
type: "single" | "multi" | "preview"; type: "single" | "multi" | "preview";
presentedType?: "single" | "multi" | "preview";
options: Array<{ options: Array<{
value: string; value: string;
label: 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.affectedQuestionIds` lists the only questions that should be revisited
- `continuation.questionStates` marks each question as `answered`, `needs_clarification`, or `unanswered` - `continuation.questionStates` marks each question as `answered`, `needs_clarification`, or `unanswered`
- single-select answers still use arrays - 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 - `indices` are 1-based rendered option positions
- `customText` stores the free-form answer - `customText` stores the free-form answer
- on single-select questions, saving free-form text clears selected options for that question - 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 - tabbed multi-question flow
- single-select, multi-select, and preview questions - 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 - inline free-form answers for all question types
- native pi-style `@` file path autocomplete inside free-form answer and note editors - native pi-style `@` file path autocomplete inside free-form answer and note editors
- question notes via `Shift+N` - 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: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 - 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 - `?` 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 - `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 - 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 - 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.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.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.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` - `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` - `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 - 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. 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 ## Notifications
+1
View File
@@ -30,6 +30,7 @@ export const ASK_TOOL_PROMPT_GUIDELINES = [
interface ValidateParamsOptions { interface ValidateParamsOptions {
allowFreeform?: boolean; allowFreeform?: boolean;
presentSingleAsMulti?: boolean;
} }
export function validateParams( export function validateParams(
+5 -1
View File
@@ -13,6 +13,7 @@ import {
successfulResponse, successfulResponse,
validateParams, validateParams,
} from "./ask-tool-helpers.ts"; } from "./ask-tool-helpers.ts";
import { getAskConfigStore } from "./config/store.ts";
import { AskParamsSchema } from "./schema.ts"; import { AskParamsSchema } from "./schema.ts";
import type { AskParams } from "./types.ts"; import type { AskParams } from "./types.ts";
import { runAskFlow } from "./ui/controller.ts"; import { runAskFlow } from "./ui/controller.ts";
@@ -48,7 +49,10 @@ async function executeAskTool(
_onUpdate: unknown, _onUpdate: unknown,
ctx: ExtensionContext ctx: ExtensionContext
) { ) {
const validation = validateParams(params); const config = await getAskConfigStore().getConfig();
const validation = validateParams(params, {
presentSingleAsMulti: config.behaviour.presentSingleAsMulti,
});
if (!validation.ok) { if (!validation.ok) {
return invalidPayloadResponse(params, validation.issues); return invalidPayloadResponse(params, validation.issues);
} }
+10 -4
View File
@@ -4,7 +4,7 @@ import {
} from "../constants/keymaps.ts"; } from "../constants/keymaps.ts";
import type { import type {
AskConfig, AskConfig,
AskConfigFileV4, AskConfigFileV5,
AskConfigKeymaps, AskConfigKeymaps,
AskNotificationChannel, AskNotificationChannel,
} from "./schema.ts"; } from "./schema.ts";
@@ -27,6 +27,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
autoSubmitWhenAnsweredWithoutNotes: false, autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: true, confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true, doublePressReviewShortcuts: true,
presentSingleAsMulti: false,
showFooterHints: true, showFooterHints: true,
}, },
keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS), keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS),
@@ -37,7 +38,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
}; };
export function normalizeAskConfig( export function normalizeAskConfig(
config?: Partial<AskConfigFileV4> | AskConfig config?: Partial<AskConfigFileV5> | AskConfig
): AskConfig { ): AskConfig {
return { return {
answer: { answer: {
@@ -68,6 +69,9 @@ export function normalizeAskConfig(
showFooterHints: showFooterHints:
config?.behaviour?.showFooterHints ?? config?.behaviour?.showFooterHints ??
DEFAULT_ASK_CONFIG.behaviour.showFooterHints, DEFAULT_ASK_CONFIG.behaviour.showFooterHints,
presentSingleAsMulti:
config?.behaviour?.presentSingleAsMulti ??
DEFAULT_ASK_CONFIG.behaviour.presentSingleAsMulti,
}, },
keymaps: mergeKeymaps(config?.keymaps), keymaps: mergeKeymaps(config?.keymaps),
notifications: { notifications: {
@@ -79,10 +83,10 @@ export function normalizeAskConfig(
}; };
} }
export function toAskConfigFileV4(config: AskConfig): AskConfigFileV4 { export function toAskConfigFileV5(config: AskConfig): AskConfigFileV5 {
const normalized = normalizeAskConfig(config); const normalized = normalizeAskConfig(config);
return { return {
schemaVersion: 4, schemaVersion: 5,
answer: { answer: {
extractionModels: normalized.answer.extractionModels, extractionModels: normalized.answer.extractionModels,
extractionRetries: normalized.answer.extractionRetries, extractionRetries: normalized.answer.extractionRetries,
@@ -94,6 +98,7 @@ export function toAskConfigFileV4(config: AskConfig): AskConfigFileV4 {
confirmDismissWhenDirty: normalized.behaviour.confirmDismissWhenDirty, confirmDismissWhenDirty: normalized.behaviour.confirmDismissWhenDirty,
doublePressReviewShortcuts: doublePressReviewShortcuts:
normalized.behaviour.doublePressReviewShortcuts, normalized.behaviour.doublePressReviewShortcuts,
presentSingleAsMulti: normalized.behaviour.presentSingleAsMulti,
showFooterHints: normalized.behaviour.showFooterHints, showFooterHints: normalized.behaviour.showFooterHints,
}, },
keymaps: cloneKeymaps(normalized.keymaps), keymaps: cloneKeymaps(normalized.keymaps),
@@ -117,6 +122,7 @@ function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
}, },
main: { main: {
cancel: [...keymaps.main.cancel], cancel: [...keymaps.main.cancel],
changeQuestionType: [...keymaps.main.changeQuestionType],
confirm: [...keymaps.main.confirm], confirm: [...keymaps.main.confirm],
nextOption: [...keymaps.main.nextOption], nextOption: [...keymaps.main.nextOption],
nextTab: [...keymaps.main.nextTab], nextTab: [...keymaps.main.nextTab],
+4 -4
View File
@@ -5,8 +5,8 @@ import {
type AskConfigVersionMigrationResult, type AskConfigVersionMigrationResult,
migrateAskConfigFileToCurrent, migrateAskConfigFileToCurrent,
} from "./migrations/index.ts"; } from "./migrations/index.ts";
import type { AskConfig, AskConfigFileV4 } from "./schema.ts"; import type { AskConfig, AskConfigFileV5 } from "./schema.ts";
import { validateAskConfigFileV4 } from "./schema.ts"; import { validateAskConfigFileV5 } from "./schema.ts";
export class AskConfigMigrationError extends Error { export class AskConfigMigrationError extends Error {
readonly reason: "invalid_or_unsupported" | "migration_failed"; 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( throw new AskConfigMigrationError(
"Config was invalid or unsupported.", "Config was invalid or unsupported.",
"invalid_or_unsupported" "invalid_or_unsupported"
); );
} }
const currentFile = migratedFile.config as AskConfigFileV4; const currentFile = migratedFile.config as AskConfigFileV5;
const config = normalizeAskConfig(currentFile); const config = normalizeAskConfig(currentFile);
const keymapsResult = normalizeConfiguredKeymaps(currentFile.keymaps); const keymapsResult = normalizeConfiguredKeymaps(currentFile.keymaps);
if (!keymapsResult.ok) { if (!keymapsResult.ok) {
+33 -1
View File
@@ -1,7 +1,7 @@
import { normalizeLegacyFlatKeymaps } from "../../constants/keymaps.ts"; import { normalizeLegacyFlatKeymaps } from "../../constants/keymaps.ts";
import type { AskConfigMigration, VersionedAskConfigFile } from "./types.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[] = [ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
{ {
@@ -33,8 +33,40 @@ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
schemaVersion: 4, 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 { export class AskConfigVersionMigrationError extends Error {
readonly reason: "invalid_or_unsupported" | "migration_failed"; readonly reason: "invalid_or_unsupported" | "migration_failed";
+76 -3
View File
@@ -21,7 +21,7 @@ const AskKeyBindingSchema = Type.Union([
Type.Array(Type.String()), Type.Array(Type.String()),
]); ]);
const AskConfigKeymapsSchema = Type.Object({ const AskConfigKeymapsV4Schema = Type.Object({
global: Type.Object({ global: Type.Object({
dismiss: Type.Optional(AskKeyBindingSchema), dismiss: Type.Optional(AskKeyBindingSchema),
settings: 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({ export const AskConfigFileV4Schema = Type.Object({
schemaVersion: Type.Literal(4), schemaVersion: Type.Literal(4),
answer: Type.Optional( answer: Type.Optional(
@@ -80,7 +150,7 @@ export const AskConfigFileV4Schema = Type.Object({
showFooterHints: Type.Optional(Type.Boolean()), showFooterHints: Type.Optional(Type.Boolean()),
}) })
), ),
keymaps: Type.Optional(AskConfigKeymapsSchema), keymaps: Type.Optional(AskConfigKeymapsV4Schema),
notifications: Type.Optional( notifications: Type.Optional(
Type.Object({ Type.Object({
channels: Type.Optional(Type.Array(AskNotificationChannelSchema)), channels: Type.Optional(Type.Array(AskNotificationChannelSchema)),
@@ -131,6 +201,7 @@ export const AskConfigFileV2Schema = Type.Omit(AskConfigFileV3Schema, [
"schemaVersion", "schemaVersion",
]); ]);
export type AskConfigFileV5 = Static<typeof AskConfigFileV5Schema>;
export type AskConfigFileV4 = Static<typeof AskConfigFileV4Schema>; export type AskConfigFileV4 = Static<typeof AskConfigFileV4Schema>;
export type AskConfigFileV3 = Static<typeof AskConfigFileV3Schema>; export type AskConfigFileV3 = Static<typeof AskConfigFileV3Schema>;
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & { export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & {
@@ -167,6 +238,7 @@ export interface AskConfigKeymaps {
}; };
main: { main: {
cancel: string[]; cancel: string[];
changeQuestionType: string[];
confirm: string[]; confirm: string[];
nextOption: string[]; nextOption: string[];
nextTab: string[]; nextTab: string[];
@@ -202,6 +274,7 @@ export interface AskConfig {
autoSubmitWhenAnsweredWithoutNotes: boolean; autoSubmitWhenAnsweredWithoutNotes: boolean;
confirmDismissWhenDirty: boolean; confirmDismissWhenDirty: boolean;
doublePressReviewShortcuts: boolean; doublePressReviewShortcuts: boolean;
presentSingleAsMulti: boolean;
showFooterHints: boolean; showFooterHints: boolean;
}; };
keymaps: AskConfigKeymaps; 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 { import {
DEFAULT_ASK_CONFIG, DEFAULT_ASK_CONFIG,
normalizeAskConfig, normalizeAskConfig,
toAskConfigFileV4, toAskConfigFileV5,
} from "./defaults.ts"; } from "./defaults.ts";
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts"; import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts"; import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts";
@@ -66,7 +66,7 @@ export class AskConfigStore {
async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> { async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
const normalized = normalizeAskConfig(config); const normalized = normalizeAskConfig(config);
const content = JSON.stringify( const content = JSON.stringify(
toAskConfigFileV4(normalized), toAskConfigFileV5(normalized),
null, null,
2 2
).concat("\n"); ).concat("\n");
+15
View File
@@ -94,6 +94,7 @@ export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
main: { main: {
confirm: ["enter"], confirm: ["enter"],
cancel: ["esc"], cancel: ["esc"],
changeQuestionType: ["t"],
toggle: ["space"], toggle: ["space"],
nextTab: ["tab", "right"], nextTab: ["tab", "right"],
previousTab: ["shift+tab", "left"], previousTab: ["shift+tab", "left"],
@@ -134,6 +135,7 @@ const DESCRIPTIONS: Record<AskKeymapContext, Record<string, string>> = {
main: { main: {
confirm: "Confirm selection, continue, or submit", confirm: "Confirm selection, continue, or submit",
cancel: "Cancel flow", cancel: "Cancel flow",
changeQuestionType: "Change current question type",
toggle: "Toggle selected option", toggle: "Toggle selected option",
nextTab: "Switch to next tab", nextTab: "Switch to next tab",
previousTab: "Switch to previous tab", previousTab: "Switch to previous tab",
@@ -172,6 +174,8 @@ const footerHint = (
label = binding.label label = binding.label
) => `${label} ${action}`; ) => `${label} ${action}`;
const footerKeyIdLabel = (binding: AskKeyBinding) => binding.keys.join(" / ");
export function formatKeybindingLabel(key: string): string { export function formatKeybindingLabel(key: string): string {
if (key === "up") { if (key === "up") {
return "↑"; return "↑";
@@ -336,6 +340,11 @@ export function renderFooterKeymaps(
footerHint(main.nextTab, "tab", tabNavigationLabel), footerHint(main.nextTab, "tab", tabNavigationLabel),
footerHint(main.previousOption, "select", optionNavigationLabel), footerHint(main.previousOption, "select", optionNavigationLabel),
footerHint(main.toggle, "toggle"), footerHint(main.toggle, "toggle"),
footerHint(
main.changeQuestionType,
"question type",
footerKeyIdLabel(main.changeQuestionType)
),
footerHint(main.confirm, "continue"), footerHint(main.confirm, "continue"),
footerHint(main.optionNote, "note", noteNavigationLabel), footerHint(main.optionNote, "note", noteNavigationLabel),
footerHint(main.cancel, "dismiss"), footerHint(main.cancel, "dismiss"),
@@ -344,6 +353,11 @@ export function renderFooterKeymaps(
default: [ default: [
footerHint(main.nextTab, "tab", tabNavigationLabel), footerHint(main.nextTab, "tab", tabNavigationLabel),
footerHint(main.previousOption, "select", optionNavigationLabel), footerHint(main.previousOption, "select", optionNavigationLabel),
footerHint(
main.changeQuestionType,
"question type",
footerKeyIdLabel(main.changeQuestionType)
),
footerHint(main.confirm, "confirm"), footerHint(main.confirm, "confirm"),
footerHint(main.optionNote, "note", noteNavigationLabel), footerHint(main.optionNote, "note", noteNavigationLabel),
footerHint(main.cancel, "dismiss"), footerHint(main.cancel, "dismiss"),
@@ -607,6 +621,7 @@ function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
main: { main: {
confirm: [...keymaps.main.confirm], confirm: [...keymaps.main.confirm],
cancel: [...keymaps.main.cancel], cancel: [...keymaps.main.cancel],
changeQuestionType: [...keymaps.main.changeQuestionType],
toggle: [...keymaps.main.toggle], toggle: [...keymaps.main.toggle],
nextTab: [...keymaps.main.nextTab], nextTab: [...keymaps.main.nextTab],
previousTab: [...keymaps.main.previousTab], previousTab: [...keymaps.main.previousTab],
+23
View File
@@ -8,6 +8,8 @@ export function formatResultLines(
): string[] { ): string[] {
const lines: string[] = []; const lines: string[] = [];
let hasPresentationOverride = false;
for (const question of result.questions) { for (const question of result.questions) {
const answer = result.answers[question.id]; const answer = result.answers[question.id];
if (!answer) { if (!answer) {
@@ -19,6 +21,10 @@ export function formatResultLines(
lines.push(answerLine); lines.push(answerLine);
} }
if (hasPresentedTypeOverride(question.type, question.presentedType)) {
hasPresentationOverride = true;
}
const questionNoteLine = formatQuestionNoteLine( const questionNoteLine = formatQuestionNoteLine(
question.label, question.label,
answer.note, answer.note,
@@ -31,6 +37,10 @@ export function formatResultLines(
lines.push(...formatOptionNoteLines(question.label, answer, options.mode)); lines.push(...formatOptionNoteLines(question.label, answer, options.mode));
} }
if (hasPresentationOverride) {
lines.push(formatPresentationNoteLine(options.mode));
}
return lines; return lines;
} }
@@ -52,6 +62,19 @@ function formatAnswerLine(
return `✓ ${questionLabel}: ${answerText}`; 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( function formatQuestionNoteLine(
questionLabel: string, questionLabel: string,
note: string | undefined, note: string | undefined,
+1
View File
@@ -4,6 +4,7 @@ import { createInitialState as createBaseState } from "./transitions.ts";
interface CreateInitialStateOptions { interface CreateInitialStateOptions {
allowFreeform?: boolean; allowFreeform?: boolean;
presentSingleAsMulti?: boolean;
} }
export function createInitialState( export function createInitialState(
+15 -3
View File
@@ -13,6 +13,7 @@ interface IssueCollector {
interface ValidationOptions { interface ValidationOptions {
allowFreeform?: boolean; allowFreeform?: boolean;
presentSingleAsMulti?: boolean;
} }
export function normalizeQuestions( export function normalizeQuestions(
@@ -23,7 +24,9 @@ export function normalizeQuestions(
if (issues.length > 0) { if (issues.length > 0) {
throw new Error(issues[0]?.message ?? "Invalid ask_user payload"); 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( export function collectValidationIssues(
@@ -37,13 +40,22 @@ export function collectValidationIssues(
function normalizeQuestion( function normalizeQuestion(
question: AskQuestionInput, question: AskQuestionInput,
index: number index: number,
options: ValidationOptions = {}
): AskQuestion { ): AskQuestion {
const requestedType = normalizeQuestionType(question.type);
const presentedType =
options.presentSingleAsMulti && requestedType === "single"
? "multi"
: requestedType;
return { return {
id: question.id.trim(), id: question.id.trim(),
label: question.label?.trim() || `Q${index + 1}`, label: question.label?.trim() || `Q${index + 1}`,
prompt: question.prompt.trim(), prompt: question.prompt.trim(),
type: normalizeQuestionType(question.type), type: presentedType,
...(presentedType === requestedType
? {}
: { requestedType, presentedType }),
required: question.required ?? false, required: question.required ?? false,
options: question.options.map(normalizeOption), 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, id: question.id,
label: question.label, label: question.label,
prompt: question.prompt, prompt: question.prompt,
type: question.type, type: question.requestedType ?? question.type,
...(question.presentedType &&
question.presentedType !== question.requestedType
? { presentedType: question.presentedType }
: {}),
})), })),
answers, answers,
continuation: continuation:
@@ -186,7 +190,11 @@ function createElaborationQuestionContext(
id: question.id, id: question.id,
label: question.label, label: question.label,
prompt: question.prompt, 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), options: question.options.map(cloneOption),
}; };
} }
+3
View File
@@ -35,6 +35,8 @@ export interface AskValidationError {
export interface AskQuestion export interface AskQuestion
extends Omit<AskQuestionInput, "type" | "required" | "label"> { extends Omit<AskQuestionInput, "type" | "required" | "label"> {
label: string; label: string;
presentedType?: AskQuestionType;
requestedType?: AskQuestionType;
required: boolean; required: boolean;
type: AskQuestionType; type: AskQuestionType;
} }
@@ -65,6 +67,7 @@ export interface AskResultAnswer {
export interface AskQuestionSummary { export interface AskQuestionSummary {
id: string; id: string;
label: string; label: string;
presentedType?: AskQuestionType;
prompt: string; prompt: string;
type: AskQuestionType; type: AskQuestionType;
} }
+45 -1
View File
@@ -13,6 +13,7 @@ import {
submitEditorDraft, submitEditorDraft,
syncStateToSelection, syncStateToSelection,
} from "../state/editor.ts"; } from "../state/editor.ts";
import { cycleCurrentQuestionType } from "../state/question-type.ts";
import { toAskResult } from "../state/result.ts"; import { toAskResult } from "../state/result.ts";
import { import {
getCurrentOption, getCurrentOption,
@@ -58,6 +59,7 @@ type Keybindings = CustomCallbackArgs[2];
type Done = (result: AskResult) => void; type Done = (result: AskResult) => void;
interface AskFlowOptions { interface AskFlowOptions {
allowFreeform?: boolean; allowFreeform?: boolean;
presentSingleAsMulti?: boolean;
} }
type AskFlowParams = AskParams & type AskFlowParams = AskParams &
@@ -73,6 +75,7 @@ interface AskFlowController {
dismissNotice?: string; dismissNotice?: string;
done: Done; done: Done;
editor: Editor; editor: Editor;
pendingQuestionTypeChangeQuestionId?: string;
pendingReviewShortcutActionIndex?: number; pendingReviewShortcutActionIndex?: number;
settingsOpen: boolean; settingsOpen: boolean;
state: AskState; state: AskState;
@@ -89,13 +92,18 @@ export async function runAskFlow(
): Promise<AskResult> { ): Promise<AskResult> {
const store = getAskConfigStore(); const store = getAskConfigStore();
const config = await store.getConfig(); const config = await store.getConfig();
const flowOptions = {
...options,
presentSingleAsMulti:
options.presentSingleAsMulti ?? config.behaviour.presentSingleAsMulti,
};
return ctx.ui.custom<AskResult>((...args) => return ctx.ui.custom<AskResult>((...args) =>
createAskFlowController(args, { createAskFlowController(args, {
...params, ...params,
config, config,
cwd: ctx.cwd, cwd: ctx.cwd,
ctx, ctx,
flowOptions: options, flowOptions,
}) })
); );
} }
@@ -118,6 +126,7 @@ function createAskFlowController(
settingsOpen: false, settingsOpen: false,
state: createInitialState(params, params.flowOptions), state: createInitialState(params, params.flowOptions),
suppressAutoInputForSelection: false, suppressAutoInputForSelection: false,
pendingQuestionTypeChangeQuestionId: undefined,
pendingReviewShortcutActionIndex: undefined, pendingReviewShortcutActionIndex: undefined,
theme, theme,
tui, tui,
@@ -238,30 +247,41 @@ function handleNavigationCommand(
switch (command.kind) { switch (command.kind) {
case "moveTab": case "moveTab":
clearReviewShortcutPending(controller); clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
commitState(controller, moveTab(controller.state, command.delta)); commitState(controller, moveTab(controller.state, command.delta));
return; return;
case "moveOption": case "moveOption":
clearReviewShortcutPending(controller); clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
commitState(controller, moveOption(controller.state, command.delta)); commitState(controller, moveOption(controller.state, command.delta));
return; return;
case "toggleMulti": case "toggleMulti":
clearReviewShortcutPending(controller); clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
handleToggleCurrentOption(controller); handleToggleCurrentOption(controller);
return; return;
case "changeQuestionType":
clearReviewShortcutPending(controller);
handleChangeQuestionType(controller);
return;
case "openQuestionNote": case "openQuestionNote":
clearQuestionTypeChangePending(controller);
openQuestionNote(controller); openQuestionNote(controller);
return; return;
case "openOptionNote": case "openOptionNote":
clearQuestionTypeChangePending(controller);
openOptionNote(controller); openOptionNote(controller);
return; return;
case "confirm": case "confirm":
clearReviewShortcutPending(controller); clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
commitState(controller, confirmCurrentSelection(controller.state), { commitState(controller, confirmCurrentSelection(controller.state), {
finish: true, finish: true,
}); });
return; return;
case "cancel": case "cancel":
clearReviewShortcutPending(controller); clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
handleExitFlow(controller, cancelFlow(controller.state)); handleExitFlow(controller, cancelFlow(controller.state));
return; return;
case "numberShortcut": case "numberShortcut":
@@ -269,6 +289,7 @@ function handleNavigationCommand(
return; return;
} }
clearReviewShortcutPending(controller); clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
commitState( commitState(
controller, controller,
applyNumberShortcut(controller.state, command.digit) applyNumberShortcut(controller.state, command.digit)
@@ -276,6 +297,7 @@ function handleNavigationCommand(
return; return;
case "dismiss": case "dismiss":
clearReviewShortcutPending(controller); clearReviewShortcutPending(controller);
clearQuestionTypeChangePending(controller);
handleExitFlow(controller, dismissFlow(controller.state)); handleExitFlow(controller, dismissFlow(controller.state));
return; return;
case "showSettings": case "showSettings":
@@ -301,6 +323,24 @@ function handleToggleCurrentOption(controller: AskFlowController) {
commitState(controller, toggleCurrentMultiOption(controller.state)); 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) { function openQuestionNote(controller: AskFlowController) {
const question = getCurrentQuestion(controller.state); const question = getCurrentQuestion(controller.state);
if (!question || isSubmitTab(controller.state)) { if (!question || isSubmitTab(controller.state)) {
@@ -415,6 +455,10 @@ function clearReviewShortcutPending(controller: AskFlowController) {
controller.pendingReviewShortcutActionIndex = undefined; controller.pendingReviewShortcutActionIndex = undefined;
} }
function clearQuestionTypeChangePending(controller: AskFlowController) {
controller.pendingQuestionTypeChangeQuestionId = undefined;
}
function getActiveReviewShortcutHint( function getActiveReviewShortcutHint(
controller: AskFlowController controller: AskFlowController
): string | undefined { ): string | undefined {
+4
View File
@@ -17,6 +17,7 @@ export type AskInputCommand =
| { kind: "openOptionNote" } | { kind: "openOptionNote" }
| { kind: "confirm" } | { kind: "confirm" }
| { kind: "cancel" } | { kind: "cancel" }
| { kind: "changeQuestionType" }
| { kind: "dismiss" } | { kind: "dismiss" }
| { kind: "showSettings" } | { kind: "showSettings" }
| { kind: "numberShortcut"; digit: number } | { kind: "numberShortcut"; digit: number }
@@ -136,6 +137,9 @@ function getNavigationInputCommand(
if (matchesBinding(data, bindings.toggle)) { if (matchesBinding(data, bindings.toggle)) {
return { kind: "toggleMulti" }; return { kind: "toggleMulti" };
} }
if (matchesBinding(data, bindings.changeQuestionType)) {
return { kind: "changeQuestionType" };
}
if (matchesBinding(data, bindings.confirm)) { if (matchesBinding(data, bindings.confirm)) {
return { kind: "confirm" }; return { kind: "confirm" };
} }
+46 -6
View File
@@ -34,11 +34,33 @@ const DESCRIPTION_LINE_COUNT = 3;
const COMPACT_WIDTH = 40; const COMPACT_WIDTH = 40;
const RESET_CONFIRMATION_MS = 2000; 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 = [ const SETTINGS = [
{ {
description: description:
"Auto-submit completed ask flows when no question or option notes were added.", "Auto-submit completed ask flows when no question or option notes were added.",
key: "autoSubmitWhenAnsweredWithoutNotes", key: "autoSubmitWhenAnsweredWithoutNotes",
section: "Live settings",
label: "Auto-submit when answered without notes", label: "Auto-submit when answered without notes",
type: "toggle", type: "toggle",
}, },
@@ -46,6 +68,7 @@ const SETTINGS = [
description: description:
"Require a second cancel or dismiss action before discarding answered or drafted ask content.", "Require a second cancel or dismiss action before discarding answered or drafted ask content.",
key: "confirmDismissWhenDirty", key: "confirmDismissWhenDirty",
section: "Live settings",
label: "Confirm dismiss when dirty", label: "Confirm dismiss when dirty",
type: "toggle", type: "toggle",
}, },
@@ -53,6 +76,7 @@ const SETTINGS = [
description: description:
"Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.", "Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.",
key: "doublePressReviewShortcuts", key: "doublePressReviewShortcuts",
section: "Live settings",
label: "Double-press review shortcuts", label: "Double-press review shortcuts",
type: "toggle", type: "toggle",
}, },
@@ -60,26 +84,36 @@ const SETTINGS = [
description: description:
"Emit one external notification when the ask flow opens and waits for input.", "Emit one external notification when the ask flow opens and waits for input.",
key: "notifications.enabled", key: "notifications.enabled",
section: "Live settings",
label: "Notifications", label: "Notifications",
type: "toggle", type: "toggle",
}, },
{ {
description: "Show footer keymap hints at the bottom of the ask flow.", description: "Show footer keymap hints at the bottom of the ask flow.",
key: "showFooterHints", key: "showFooterHints",
section: "Live settings",
label: "Show footer hints", label: "Show footer hints",
type: "toggle", 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: description:
"Reset behaviour, keymaps, notifications, and extraction settings to defaults. Press twice quickly to confirm.", "Reset behaviour, keymaps, notifications, and extraction settings to defaults. Press twice quickly to confirm.",
key: "resetConfig", key: "resetConfig",
section: "Actions",
label: "Reset config to defaults", label: "Reset config to defaults",
type: "action", type: "action",
}, },
] as const; ] as const satisfies readonly Setting[];
type Setting = (typeof SETTINGS)[number]; type SettingKey = ToggleSettingKey;
type SettingKey = Extract<Setting, { type: "toggle" }>["key"];
export class AskSettingsList { export class AskSettingsList {
private closed = false; private closed = false;
@@ -152,10 +186,16 @@ export class AskSettingsList {
); );
lines.push(this.line("", innerWidth)); lines.push(this.line("", innerWidth));
let previousSection: string | undefined;
for (const [index, setting] of SETTINGS.entries()) { for (const [index, setting] of SETTINGS.entries()) {
if (setting.type === "action") { if (setting.section !== previousSection) {
lines.push(this.line("", innerWidth)); if (previousSection) {
lines.push(this.line("", innerWidth)); 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( for (const settingLine of this.renderSetting(
setting, 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); 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 = { const legacyKeymaps = {
cancel: "q", cancel: "q",
confirm: "ctrl+k", 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", () => { test("config migration maps legacy flat keymaps into context keymaps", () => {
const result = migrateAskConfig({ const result = migrateAskConfig({
schemaVersion: 3, schemaVersion: 3,
+4 -7
View File
@@ -13,7 +13,7 @@ function expectedConfigFile(
overrides: { behaviour?: typeof DEFAULT_ASK_CONFIG.behaviour } = {} overrides: { behaviour?: typeof DEFAULT_ASK_CONFIG.behaviour } = {}
) { ) {
return { return {
schemaVersion: 4, schemaVersion: 5,
answer: DEFAULT_ASK_CONFIG.answer, answer: DEFAULT_ASK_CONFIG.answer,
behaviour: overrides.behaviour ?? DEFAULT_ASK_CONFIG.behaviour, behaviour: overrides.behaviour ?? DEFAULT_ASK_CONFIG.behaviour,
keymaps: DEFAULT_ASK_CONFIG.keymaps, keymaps: DEFAULT_ASK_CONFIG.keymaps,
@@ -48,9 +48,8 @@ test("config store writes full normalized config on save", async () => {
await store.save({ await store.save({
behaviour: { behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
autoSubmitWhenAnsweredWithoutNotes: true, autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
showFooterHints: false, showFooterHints: false,
}, },
keymaps: DEFAULT_ASK_CONFIG.keymaps, keymaps: DEFAULT_ASK_CONFIG.keymaps,
@@ -61,9 +60,8 @@ test("config store writes full normalized config on save", async () => {
JSON.parse(content), JSON.parse(content),
expectedConfigFile({ expectedConfigFile({
behaviour: { behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
autoSubmitWhenAnsweredWithoutNotes: true, autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
showFooterHints: false, 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")), JSON.parse(await readFile(path, "utf-8")),
expectedConfigFile({ expectedConfigFile({
behaviour: { behaviour: {
...DEFAULT_ASK_CONFIG.behaviour,
autoSubmitWhenAnsweredWithoutNotes: true, autoSubmitWhenAnsweredWithoutNotes: true,
confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true,
showFooterHints: false, 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", () => { test("note shortcuts use n for option notes and Shift+N for question notes", () => {
const navigation = createInitialState({ const navigation = createInitialState({
questions: [ questions: [
+4 -2
View File
@@ -225,8 +225,9 @@ test("footer hints wrap into exact lines on narrow screens", () => {
editor: mockEditor(), editor: mockEditor(),
}); });
assert.deepEqual(lines.slice(-5, -1), [ assert.deepEqual(lines.slice(-6, -1), [
"Space toggle", "Space toggle",
"t question type",
"Enter continue", "Enter continue",
"N/Shift+N note", "N/Shift+N note",
"Esc dismiss · ? settings", "Esc dismiss · ? settings",
@@ -253,7 +254,8 @@ test("footer keeps earlier hint chunk on the first wrapped line", () => {
editor: mockEditor(), 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), [ assert.deepEqual(lines.slice(-5, -1), [
"Enter confirm", "Enter confirm",
"N/Shift+N note", "N/Shift+N note",
+100
View File
@@ -7,9 +7,11 @@ import {
applyNumberShortcut, applyNumberShortcut,
enterOptionNoteMode, enterOptionNoteMode,
enterQuestionNoteMode, enterQuestionNoteMode,
moveOption,
moveTab, moveTab,
saveNote, saveNote,
submitCustomAnswer, submitCustomAnswer,
toggleCurrentMultiOption,
} from "../src/state/transitions.ts"; } from "../src/state/transitions.ts";
test("summarizeResult formats selected answers", () => { test("summarizeResult formats selected answers", () => {
@@ -34,6 +36,104 @@ test("summarizeResult formats selected answers", () => {
assert.equal(renderResultText(result), "✓ Style: Rich"); 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", () => { test("summaries include custom text answers", () => {
let state = createInitialState({ let state = createInitialState({
questions: [ questions: [
+5
View File
@@ -13,6 +13,7 @@ const savedConfig: AskConfig = {
autoSubmitWhenAnsweredWithoutNotes: false, autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: true, confirmDismissWhenDirty: true,
doublePressReviewShortcuts: true, doublePressReviewShortcuts: true,
presentSingleAsMulti: false,
showFooterHints: true, showFooterHints: true,
}, },
keymaps: DEFAULT_ASK_CONFIG.keymaps, keymaps: DEFAULT_ASK_CONFIG.keymaps,
@@ -65,9 +66,12 @@ test("settings list renders behaviour settings and config path", () => {
assert(text.includes("╭")); assert(text.includes("╭"));
assert(text.includes("@eko24ive/pi-ask")); 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("Auto-submit when answered without notes"));
assert(text.includes("[off]")); assert(text.includes("[off]"));
assert(text.includes("Confirm dismiss when dirty")); assert(text.includes("Confirm dismiss when dirty"));
assert(text.includes("Present single-select as multi-select"));
assert(text.includes("on")); assert(text.includes("on"));
assert(text.includes("Edit this config file to customize")); assert(text.includes("Edit this config file to customize"));
assert(text.includes("keymaps")); 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.autoSubmitWhenAnsweredWithoutNotes, true);
assert.equal(saved?.behaviour.confirmDismissWhenDirty, true); assert.equal(saved?.behaviour.confirmDismissWhenDirty, true);
assert.equal(saved?.behaviour.doublePressReviewShortcuts, true); assert.equal(saved?.behaviour.doublePressReviewShortcuts, true);
assert.equal(saved?.behaviour.presentSingleAsMulti, false);
assert.equal(saved?.behaviour.showFooterHints, true); assert.equal(saved?.behaviour.showFooterHints, true);
assert.equal(text.includes("Saved"), false); assert.equal(text.includes("Saved"), false);
}); });
+135
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { createInitialState } from "../src/state/create.ts"; 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_QUESTION_ID_RE = /duplicate question id "scope"/;
const DUPLICATE_OPTION_VALUE_RE = /duplicate option value "small"/; 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"); 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", () => { test("submit can complete even when some questions are unanswered", () => {
let state = createInitialState(sampleParams()); let state = createInitialState(sampleParams());
state = moveTab(state, -1); state = moveTab(state, -1);