feat: add question type presentation controls
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -67,6 +67,8 @@ The codebase is split so the implementation reads through file boundaries and na
|
||||
|
||||
- submit is never blocked by unanswered questions
|
||||
- single-select answers serialize as arrays
|
||||
- when `behaviour.presentSingleAsMulti` is enabled, future single-select questions are handled with multi-select state semantics while result metadata preserves the requested `type` and adds `presentedType`
|
||||
- active-flow question type changes are per-question runtime overrides handled in state/controller logic; they do not mutate the stored source payload or global config
|
||||
- single-select free-form answers replace selected options for that question
|
||||
- multi-select free-form answers augment selected options instead of clearing them
|
||||
- preview questions keep their preview-pane behavior while also supporting the synthetic custom-answer option
|
||||
@@ -79,6 +81,8 @@ The codebase is split so the implementation reads through file boundaries and na
|
||||
- invalid persisted keymaps fall back to default keymaps for the current session without discarding valid behaviour, notification, or answer settings
|
||||
- invalid notification channels are skipped and fall back to the default bell channel if none are valid
|
||||
- ask settings behaviour and notification enabled changes persist immediately from the settings list; config reset is guarded by a short double-press confirmation
|
||||
- `presentSingleAsMulti` is applied at ask-flow creation; toggling it does not rewrite already-normalized questions in an open flow
|
||||
- `main.changeQuestionType` changes the active question type live (non-preview: `single <-> multi`; preview: `preview <-> multi`) and may require confirmation before destructive multi-to-single conversion
|
||||
- when the ask config file is missing, the first ask use writes a default persisted config snapshot under `~/.pi/agent/extensions/`
|
||||
- legacy root config files move into `~/.pi/agent/extensions/` only when the current config file is absent
|
||||
- live config updates can affect an in-progress ask flow immediately
|
||||
|
||||
+18
-3
@@ -28,7 +28,7 @@ Unsupported future versions or invalid files are backed up and defaults are load
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"schemaVersion": 5,
|
||||
"answer": {
|
||||
"extractionModels": [
|
||||
{ "provider": "openai-codex", "id": "gpt-5.4-mini" },
|
||||
@@ -42,6 +42,7 @@ Unsupported future versions or invalid files are backed up and defaults are load
|
||||
"autoSubmitWhenAnsweredWithoutNotes": false,
|
||||
"confirmDismissWhenDirty": true,
|
||||
"doublePressReviewShortcuts": true,
|
||||
"presentSingleAsMulti": false,
|
||||
"showFooterHints": true
|
||||
},
|
||||
"keymaps": {
|
||||
@@ -50,6 +51,7 @@ Unsupported future versions or invalid files are backed up and defaults are load
|
||||
"confirm": ["enter"],
|
||||
"cancel": ["esc"],
|
||||
"toggle": ["space"],
|
||||
"changeQuestionType": ["t"],
|
||||
"nextTab": ["tab", "right"],
|
||||
"previousTab": ["shift+tab", "left"],
|
||||
"nextOption": ["down"],
|
||||
@@ -133,6 +135,16 @@ These settings affect only the `/answer` command. Normal `ask_user` tool calls d
|
||||
- effect: when enabled, review-tab number shortcuts (`1`, `2`, `3`) only trigger after pressing the same key twice
|
||||
- the review screen shows an inline hint and keeps the pending shortcut armed until another review shortcut is pressed or the user leaves the tab
|
||||
|
||||
### `behaviour.presentSingleAsMulti`
|
||||
|
||||
- type: boolean
|
||||
- default: `false`
|
||||
- effect: future ask flows render requested `single` questions as `multi` questions
|
||||
- result: the question keeps `type: "single"` and adds `presentedType: "multi"`; result text uses one compact note when any answered questions were presented differently
|
||||
- scope: this is a default presentation policy for new/replayed ask flows; it is not hot-applied to the current ask flow
|
||||
- current-flow override: use the configurable main-flow `changeQuestionType` hotkey (default `t`) to change the active question type live: non-preview questions toggle `single <-> multi`; preview questions toggle `preview <-> multi`
|
||||
- destructive confirmation: changing `multi -> single` with multiple selected answers requires pressing the type hotkey again; the pending confirmation has no timeout and clears when another navigation/action is used
|
||||
|
||||
### `behaviour.showFooterHints`
|
||||
|
||||
- type: boolean
|
||||
@@ -201,6 +213,7 @@ Arrays are aliases: any listed key triggers the same action.
|
||||
"main": {
|
||||
"confirm": ["enter"],
|
||||
"cancel": ["esc"],
|
||||
"changeQuestionType": ["t"],
|
||||
"toggle": ["space"],
|
||||
"nextTab": ["tab", "right"],
|
||||
"previousTab": ["shift+tab", "left"],
|
||||
@@ -307,7 +320,7 @@ Invalid keymaps include:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"schemaVersion": 5,
|
||||
"answer": {
|
||||
"extractionRetries": 1,
|
||||
"extractionTimeoutMs": 30000,
|
||||
@@ -319,6 +332,7 @@ Invalid keymaps include:
|
||||
"autoSubmitWhenAnsweredWithoutNotes": true,
|
||||
"confirmDismissWhenDirty": true,
|
||||
"doublePressReviewShortcuts": true,
|
||||
"presentSingleAsMulti": false,
|
||||
"showFooterHints": false
|
||||
},
|
||||
"keymaps": {
|
||||
@@ -330,6 +344,7 @@ Invalid keymaps include:
|
||||
"confirm": ["ctrl+k"],
|
||||
"cancel": ["q"],
|
||||
"toggle": ["ctrl+t"],
|
||||
"changeQuestionType": ["t"],
|
||||
"nextTab": ["tab", "right"],
|
||||
"previousTab": ["shift+tab", "left"],
|
||||
"nextOption": ["down"],
|
||||
@@ -377,7 +392,7 @@ Invalid keymaps include:
|
||||
When editing this config for a user:
|
||||
|
||||
- preserve unrelated fields
|
||||
- keep `schemaVersion` at `4`
|
||||
- keep `schemaVersion` at `5`
|
||||
- preserve `answer.extractionModels` as explicit provider/id pairs
|
||||
- keep `answer.extractionRetries` between `0` and `3`
|
||||
- do not assign fixed numeric shortcuts (`1` through `9`) to configurable actions
|
||||
|
||||
+8
-2
@@ -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
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export const ASK_TOOL_PROMPT_GUIDELINES = [
|
||||
|
||||
interface ValidateParamsOptions {
|
||||
allowFreeform?: boolean;
|
||||
presentSingleAsMulti?: boolean;
|
||||
}
|
||||
|
||||
export function validateParams(
|
||||
|
||||
+5
-1
@@ -13,6 +13,7 @@ import {
|
||||
successfulResponse,
|
||||
validateParams,
|
||||
} from "./ask-tool-helpers.ts";
|
||||
import { getAskConfigStore } from "./config/store.ts";
|
||||
import { AskParamsSchema } from "./schema.ts";
|
||||
import type { AskParams } from "./types.ts";
|
||||
import { runAskFlow } from "./ui/controller.ts";
|
||||
@@ -48,7 +49,10 @@ async function executeAskTool(
|
||||
_onUpdate: unknown,
|
||||
ctx: ExtensionContext
|
||||
) {
|
||||
const validation = validateParams(params);
|
||||
const config = await getAskConfigStore().getConfig();
|
||||
const validation = validateParams(params, {
|
||||
presentSingleAsMulti: config.behaviour.presentSingleAsMulti,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
return invalidPayloadResponse(params, validation.issues);
|
||||
}
|
||||
|
||||
+10
-4
@@ -4,7 +4,7 @@ import {
|
||||
} from "../constants/keymaps.ts";
|
||||
import type {
|
||||
AskConfig,
|
||||
AskConfigFileV4,
|
||||
AskConfigFileV5,
|
||||
AskConfigKeymaps,
|
||||
AskNotificationChannel,
|
||||
} from "./schema.ts";
|
||||
@@ -27,6 +27,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
|
||||
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||
confirmDismissWhenDirty: true,
|
||||
doublePressReviewShortcuts: true,
|
||||
presentSingleAsMulti: false,
|
||||
showFooterHints: true,
|
||||
},
|
||||
keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS),
|
||||
@@ -37,7 +38,7 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
|
||||
};
|
||||
|
||||
export function normalizeAskConfig(
|
||||
config?: Partial<AskConfigFileV4> | AskConfig
|
||||
config?: Partial<AskConfigFileV5> | AskConfig
|
||||
): AskConfig {
|
||||
return {
|
||||
answer: {
|
||||
@@ -68,6 +69,9 @@ export function normalizeAskConfig(
|
||||
showFooterHints:
|
||||
config?.behaviour?.showFooterHints ??
|
||||
DEFAULT_ASK_CONFIG.behaviour.showFooterHints,
|
||||
presentSingleAsMulti:
|
||||
config?.behaviour?.presentSingleAsMulti ??
|
||||
DEFAULT_ASK_CONFIG.behaviour.presentSingleAsMulti,
|
||||
},
|
||||
keymaps: mergeKeymaps(config?.keymaps),
|
||||
notifications: {
|
||||
@@ -79,10 +83,10 @@ export function normalizeAskConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function toAskConfigFileV4(config: AskConfig): AskConfigFileV4 {
|
||||
export function toAskConfigFileV5(config: AskConfig): AskConfigFileV5 {
|
||||
const normalized = normalizeAskConfig(config);
|
||||
return {
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
answer: {
|
||||
extractionModels: normalized.answer.extractionModels,
|
||||
extractionRetries: normalized.answer.extractionRetries,
|
||||
@@ -94,6 +98,7 @@ export function toAskConfigFileV4(config: AskConfig): AskConfigFileV4 {
|
||||
confirmDismissWhenDirty: normalized.behaviour.confirmDismissWhenDirty,
|
||||
doublePressReviewShortcuts:
|
||||
normalized.behaviour.doublePressReviewShortcuts,
|
||||
presentSingleAsMulti: normalized.behaviour.presentSingleAsMulti,
|
||||
showFooterHints: normalized.behaviour.showFooterHints,
|
||||
},
|
||||
keymaps: cloneKeymaps(normalized.keymaps),
|
||||
@@ -117,6 +122,7 @@ function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
|
||||
},
|
||||
main: {
|
||||
cancel: [...keymaps.main.cancel],
|
||||
changeQuestionType: [...keymaps.main.changeQuestionType],
|
||||
confirm: [...keymaps.main.confirm],
|
||||
nextOption: [...keymaps.main.nextOption],
|
||||
nextTab: [...keymaps.main.nextTab],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { normalizeLegacyFlatKeymaps } from "../../constants/keymaps.ts";
|
||||
import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts";
|
||||
|
||||
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 4;
|
||||
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 5;
|
||||
|
||||
const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
|
||||
{
|
||||
@@ -33,8 +33,40 @@ const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
|
||||
schemaVersion: 4,
|
||||
}),
|
||||
},
|
||||
{
|
||||
from: 4,
|
||||
to: 5,
|
||||
migrate: (config) => ({
|
||||
...config,
|
||||
behaviour: {
|
||||
...((config.behaviour as Record<string, unknown> | undefined) ?? {}),
|
||||
presentSingleAsMulti: false,
|
||||
},
|
||||
keymaps: addV5Keymaps(config.keymaps),
|
||||
schemaVersion: 5,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function addV5Keymaps(keymaps: unknown): unknown {
|
||||
if (!(keymaps && typeof keymaps === "object" && "main" in keymaps)) {
|
||||
return keymaps;
|
||||
}
|
||||
const current = keymaps as Record<string, unknown>;
|
||||
const main = current.main;
|
||||
if (!(main && typeof main === "object")) {
|
||||
return keymaps;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
main: {
|
||||
...(main as Record<string, unknown>),
|
||||
changeQuestionType: (main as Record<string, unknown>)
|
||||
.changeQuestionType ?? ["t"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class AskConfigVersionMigrationError extends Error {
|
||||
readonly reason: "invalid_or_unsupported" | "migration_failed";
|
||||
|
||||
|
||||
+76
-3
@@ -21,7 +21,7 @@ const AskKeyBindingSchema = Type.Union([
|
||||
Type.Array(Type.String()),
|
||||
]);
|
||||
|
||||
const AskConfigKeymapsSchema = Type.Object({
|
||||
const AskConfigKeymapsV4Schema = Type.Object({
|
||||
global: Type.Object({
|
||||
dismiss: Type.Optional(AskKeyBindingSchema),
|
||||
settings: Type.Optional(AskKeyBindingSchema),
|
||||
@@ -61,6 +61,76 @@ const AskConfigKeymapsSchema = Type.Object({
|
||||
}),
|
||||
});
|
||||
|
||||
const AskConfigKeymapsSchema = Type.Object({
|
||||
global: Type.Object({
|
||||
dismiss: Type.Optional(AskKeyBindingSchema),
|
||||
settings: Type.Optional(AskKeyBindingSchema),
|
||||
}),
|
||||
main: Type.Object({
|
||||
cancel: Type.Optional(AskKeyBindingSchema),
|
||||
changeQuestionType: Type.Optional(AskKeyBindingSchema),
|
||||
confirm: Type.Optional(AskKeyBindingSchema),
|
||||
nextOption: Type.Optional(AskKeyBindingSchema),
|
||||
nextTab: Type.Optional(AskKeyBindingSchema),
|
||||
optionNote: Type.Optional(AskKeyBindingSchema),
|
||||
previousOption: Type.Optional(AskKeyBindingSchema),
|
||||
previousTab: Type.Optional(AskKeyBindingSchema),
|
||||
questionNote: Type.Optional(AskKeyBindingSchema),
|
||||
toggle: Type.Optional(AskKeyBindingSchema),
|
||||
}),
|
||||
editor: Type.Object({
|
||||
close: Type.Optional(AskKeyBindingSchema),
|
||||
nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
submit: Type.Optional(AskKeyBindingSchema),
|
||||
}),
|
||||
noteEditor: Type.Object({
|
||||
close: Type.Optional(AskKeyBindingSchema),
|
||||
nextOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
nextTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
previousOptionWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
previousTabWhenEmpty: Type.Optional(AskKeyBindingSchema),
|
||||
save: Type.Optional(AskKeyBindingSchema),
|
||||
}),
|
||||
settingsModal: Type.Object({
|
||||
close: Type.Optional(AskKeyBindingSchema),
|
||||
nextOption: Type.Optional(AskKeyBindingSchema),
|
||||
previousOption: Type.Optional(AskKeyBindingSchema),
|
||||
toggle: Type.Optional(AskKeyBindingSchema),
|
||||
}),
|
||||
});
|
||||
|
||||
export const AskConfigFileV5Schema = Type.Object({
|
||||
schemaVersion: Type.Literal(5),
|
||||
answer: Type.Optional(
|
||||
Type.Object({
|
||||
extractionModels: Type.Optional(
|
||||
Type.Array(AskAnswerModelPreferenceSchema)
|
||||
),
|
||||
extractionRetries: Type.Optional(Type.Number()),
|
||||
extractionTimeoutMs: Type.Optional(Type.Number()),
|
||||
})
|
||||
),
|
||||
behaviour: Type.Optional(
|
||||
Type.Object({
|
||||
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
|
||||
confirmDismissWhenDirty: Type.Optional(Type.Boolean()),
|
||||
doublePressReviewShortcuts: Type.Optional(Type.Boolean()),
|
||||
presentSingleAsMulti: Type.Optional(Type.Boolean()),
|
||||
showFooterHints: Type.Optional(Type.Boolean()),
|
||||
})
|
||||
),
|
||||
keymaps: Type.Optional(AskConfigKeymapsSchema),
|
||||
notifications: Type.Optional(
|
||||
Type.Object({
|
||||
channels: Type.Optional(Type.Array(AskNotificationChannelSchema)),
|
||||
enabled: Type.Optional(Type.Boolean()),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const AskConfigFileV4Schema = Type.Object({
|
||||
schemaVersion: Type.Literal(4),
|
||||
answer: Type.Optional(
|
||||
@@ -80,7 +150,7 @@ export const AskConfigFileV4Schema = Type.Object({
|
||||
showFooterHints: Type.Optional(Type.Boolean()),
|
||||
})
|
||||
),
|
||||
keymaps: Type.Optional(AskConfigKeymapsSchema),
|
||||
keymaps: Type.Optional(AskConfigKeymapsV4Schema),
|
||||
notifications: Type.Optional(
|
||||
Type.Object({
|
||||
channels: Type.Optional(Type.Array(AskNotificationChannelSchema)),
|
||||
@@ -131,6 +201,7 @@ export const AskConfigFileV2Schema = Type.Omit(AskConfigFileV3Schema, [
|
||||
"schemaVersion",
|
||||
]);
|
||||
|
||||
export type AskConfigFileV5 = Static<typeof AskConfigFileV5Schema>;
|
||||
export type AskConfigFileV4 = Static<typeof AskConfigFileV4Schema>;
|
||||
export type AskConfigFileV3 = Static<typeof AskConfigFileV3Schema>;
|
||||
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema> & {
|
||||
@@ -167,6 +238,7 @@ export interface AskConfigKeymaps {
|
||||
};
|
||||
main: {
|
||||
cancel: string[];
|
||||
changeQuestionType: string[];
|
||||
confirm: string[];
|
||||
nextOption: string[];
|
||||
nextTab: string[];
|
||||
@@ -202,6 +274,7 @@ export interface AskConfig {
|
||||
autoSubmitWhenAnsweredWithoutNotes: boolean;
|
||||
confirmDismissWhenDirty: boolean;
|
||||
doublePressReviewShortcuts: boolean;
|
||||
presentSingleAsMulti: boolean;
|
||||
showFooterHints: boolean;
|
||||
};
|
||||
keymaps: AskConfigKeymaps;
|
||||
@@ -211,4 +284,4 @@ export interface AskConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export const validateAskConfigFileV4 = Compile(AskConfigFileV4Schema);
|
||||
export const validateAskConfigFileV5 = Compile(AskConfigFileV5Schema);
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
DEFAULT_ASK_CONFIG,
|
||||
normalizeAskConfig,
|
||||
toAskConfigFileV4,
|
||||
toAskConfigFileV5,
|
||||
} from "./defaults.ts";
|
||||
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
|
||||
import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts";
|
||||
@@ -66,7 +66,7 @@ export class AskConfigStore {
|
||||
async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
|
||||
const normalized = normalizeAskConfig(config);
|
||||
const content = JSON.stringify(
|
||||
toAskConfigFileV4(normalized),
|
||||
toAskConfigFileV5(normalized),
|
||||
null,
|
||||
2
|
||||
).concat("\n");
|
||||
|
||||
@@ -94,6 +94,7 @@ export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
|
||||
main: {
|
||||
confirm: ["enter"],
|
||||
cancel: ["esc"],
|
||||
changeQuestionType: ["t"],
|
||||
toggle: ["space"],
|
||||
nextTab: ["tab", "right"],
|
||||
previousTab: ["shift+tab", "left"],
|
||||
@@ -134,6 +135,7 @@ const DESCRIPTIONS: Record<AskKeymapContext, Record<string, string>> = {
|
||||
main: {
|
||||
confirm: "Confirm selection, continue, or submit",
|
||||
cancel: "Cancel flow",
|
||||
changeQuestionType: "Change current question type",
|
||||
toggle: "Toggle selected option",
|
||||
nextTab: "Switch to next tab",
|
||||
previousTab: "Switch to previous tab",
|
||||
@@ -172,6 +174,8 @@ const footerHint = (
|
||||
label = binding.label
|
||||
) => `${label} ${action}`;
|
||||
|
||||
const footerKeyIdLabel = (binding: AskKeyBinding) => binding.keys.join(" / ");
|
||||
|
||||
export function formatKeybindingLabel(key: string): string {
|
||||
if (key === "up") {
|
||||
return "↑";
|
||||
@@ -336,6 +340,11 @@ export function renderFooterKeymaps(
|
||||
footerHint(main.nextTab, "tab", tabNavigationLabel),
|
||||
footerHint(main.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(main.toggle, "toggle"),
|
||||
footerHint(
|
||||
main.changeQuestionType,
|
||||
"question type",
|
||||
footerKeyIdLabel(main.changeQuestionType)
|
||||
),
|
||||
footerHint(main.confirm, "continue"),
|
||||
footerHint(main.optionNote, "note", noteNavigationLabel),
|
||||
footerHint(main.cancel, "dismiss"),
|
||||
@@ -344,6 +353,11 @@ export function renderFooterKeymaps(
|
||||
default: [
|
||||
footerHint(main.nextTab, "tab", tabNavigationLabel),
|
||||
footerHint(main.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(
|
||||
main.changeQuestionType,
|
||||
"question type",
|
||||
footerKeyIdLabel(main.changeQuestionType)
|
||||
),
|
||||
footerHint(main.confirm, "confirm"),
|
||||
footerHint(main.optionNote, "note", noteNavigationLabel),
|
||||
footerHint(main.cancel, "dismiss"),
|
||||
@@ -607,6 +621,7 @@ function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
|
||||
main: {
|
||||
confirm: [...keymaps.main.confirm],
|
||||
cancel: [...keymaps.main.cancel],
|
||||
changeQuestionType: [...keymaps.main.changeQuestionType],
|
||||
toggle: [...keymaps.main.toggle],
|
||||
nextTab: [...keymaps.main.nextTab],
|
||||
previousTab: [...keymaps.main.previousTab],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createInitialState as createBaseState } from "./transitions.ts";
|
||||
|
||||
interface CreateInitialStateOptions {
|
||||
allowFreeform?: boolean;
|
||||
presentSingleAsMulti?: boolean;
|
||||
}
|
||||
|
||||
export function createInitialState(
|
||||
|
||||
+15
-3
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface AskValidationError {
|
||||
export interface AskQuestion
|
||||
extends Omit<AskQuestionInput, "type" | "required" | "label"> {
|
||||
label: string;
|
||||
presentedType?: AskQuestionType;
|
||||
requestedType?: AskQuestionType;
|
||||
required: boolean;
|
||||
type: AskQuestionType;
|
||||
}
|
||||
@@ -65,6 +67,7 @@ export interface AskResultAnswer {
|
||||
export interface AskQuestionSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
presentedType?: AskQuestionType;
|
||||
prompt: string;
|
||||
type: AskQuestionType;
|
||||
}
|
||||
|
||||
+45
-1
@@ -13,6 +13,7 @@ import {
|
||||
submitEditorDraft,
|
||||
syncStateToSelection,
|
||||
} from "../state/editor.ts";
|
||||
import { cycleCurrentQuestionType } from "../state/question-type.ts";
|
||||
import { toAskResult } from "../state/result.ts";
|
||||
import {
|
||||
getCurrentOption,
|
||||
@@ -58,6 +59,7 @@ type Keybindings = CustomCallbackArgs[2];
|
||||
type Done = (result: AskResult) => void;
|
||||
interface AskFlowOptions {
|
||||
allowFreeform?: boolean;
|
||||
presentSingleAsMulti?: boolean;
|
||||
}
|
||||
|
||||
type AskFlowParams = AskParams &
|
||||
@@ -73,6 +75,7 @@ interface AskFlowController {
|
||||
dismissNotice?: string;
|
||||
done: Done;
|
||||
editor: Editor;
|
||||
pendingQuestionTypeChangeQuestionId?: string;
|
||||
pendingReviewShortcutActionIndex?: number;
|
||||
settingsOpen: boolean;
|
||||
state: AskState;
|
||||
@@ -89,13 +92,18 @@ export async function runAskFlow(
|
||||
): Promise<AskResult> {
|
||||
const store = getAskConfigStore();
|
||||
const config = await store.getConfig();
|
||||
const flowOptions = {
|
||||
...options,
|
||||
presentSingleAsMulti:
|
||||
options.presentSingleAsMulti ?? config.behaviour.presentSingleAsMulti,
|
||||
};
|
||||
return ctx.ui.custom<AskResult>((...args) =>
|
||||
createAskFlowController(args, {
|
||||
...params,
|
||||
config,
|
||||
cwd: ctx.cwd,
|
||||
ctx,
|
||||
flowOptions: options,
|
||||
flowOptions,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -118,6 +126,7 @@ function createAskFlowController(
|
||||
settingsOpen: false,
|
||||
state: createInitialState(params, params.flowOptions),
|
||||
suppressAutoInputForSelection: false,
|
||||
pendingQuestionTypeChangeQuestionId: undefined,
|
||||
pendingReviewShortcutActionIndex: undefined,
|
||||
theme,
|
||||
tui,
|
||||
@@ -238,30 +247,41 @@ function handleNavigationCommand(
|
||||
switch (command.kind) {
|
||||
case "moveTab":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
commitState(controller, moveTab(controller.state, command.delta));
|
||||
return;
|
||||
case "moveOption":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
commitState(controller, moveOption(controller.state, command.delta));
|
||||
return;
|
||||
case "toggleMulti":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
handleToggleCurrentOption(controller);
|
||||
return;
|
||||
case "changeQuestionType":
|
||||
clearReviewShortcutPending(controller);
|
||||
handleChangeQuestionType(controller);
|
||||
return;
|
||||
case "openQuestionNote":
|
||||
clearQuestionTypeChangePending(controller);
|
||||
openQuestionNote(controller);
|
||||
return;
|
||||
case "openOptionNote":
|
||||
clearQuestionTypeChangePending(controller);
|
||||
openOptionNote(controller);
|
||||
return;
|
||||
case "confirm":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
commitState(controller, confirmCurrentSelection(controller.state), {
|
||||
finish: true,
|
||||
});
|
||||
return;
|
||||
case "cancel":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
handleExitFlow(controller, cancelFlow(controller.state));
|
||||
return;
|
||||
case "numberShortcut":
|
||||
@@ -269,6 +289,7 @@ function handleNavigationCommand(
|
||||
return;
|
||||
}
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
commitState(
|
||||
controller,
|
||||
applyNumberShortcut(controller.state, command.digit)
|
||||
@@ -276,6 +297,7 @@ function handleNavigationCommand(
|
||||
return;
|
||||
case "dismiss":
|
||||
clearReviewShortcutPending(controller);
|
||||
clearQuestionTypeChangePending(controller);
|
||||
handleExitFlow(controller, dismissFlow(controller.state));
|
||||
return;
|
||||
case "showSettings":
|
||||
@@ -301,6 +323,24 @@ function handleToggleCurrentOption(controller: AskFlowController) {
|
||||
commitState(controller, toggleCurrentMultiOption(controller.state));
|
||||
}
|
||||
|
||||
function handleChangeQuestionType(controller: AskFlowController) {
|
||||
const question = getCurrentQuestion(controller.state);
|
||||
if (!question || isSubmitTab(controller.state)) {
|
||||
return;
|
||||
}
|
||||
const confirmed =
|
||||
controller.pendingQuestionTypeChangeQuestionId === question.id;
|
||||
const result = cycleCurrentQuestionType(controller.state, { confirmed });
|
||||
controller.dismissNotice = result.notice;
|
||||
if (result.needsConfirmation) {
|
||||
controller.pendingQuestionTypeChangeQuestionId = question.id;
|
||||
refresh(controller);
|
||||
return;
|
||||
}
|
||||
clearQuestionTypeChangePending(controller);
|
||||
commitState(controller, result.state, { finish: true });
|
||||
}
|
||||
|
||||
function openQuestionNote(controller: AskFlowController) {
|
||||
const question = getCurrentQuestion(controller.state);
|
||||
if (!question || isSubmitTab(controller.state)) {
|
||||
@@ -415,6 +455,10 @@ function clearReviewShortcutPending(controller: AskFlowController) {
|
||||
controller.pendingReviewShortcutActionIndex = undefined;
|
||||
}
|
||||
|
||||
function clearQuestionTypeChangePending(controller: AskFlowController) {
|
||||
controller.pendingQuestionTypeChangeQuestionId = undefined;
|
||||
}
|
||||
|
||||
function getActiveReviewShortcutHint(
|
||||
controller: AskFlowController
|
||||
): string | undefined {
|
||||
|
||||
@@ -17,6 +17,7 @@ export type AskInputCommand =
|
||||
| { kind: "openOptionNote" }
|
||||
| { kind: "confirm" }
|
||||
| { kind: "cancel" }
|
||||
| { kind: "changeQuestionType" }
|
||||
| { kind: "dismiss" }
|
||||
| { kind: "showSettings" }
|
||||
| { kind: "numberShortcut"; digit: number }
|
||||
@@ -136,6 +137,9 @@ function getNavigationInputCommand(
|
||||
if (matchesBinding(data, bindings.toggle)) {
|
||||
return { kind: "toggleMulti" };
|
||||
}
|
||||
if (matchesBinding(data, bindings.changeQuestionType)) {
|
||||
return { kind: "changeQuestionType" };
|
||||
}
|
||||
if (matchesBinding(data, bindings.confirm)) {
|
||||
return { kind: "confirm" };
|
||||
}
|
||||
|
||||
+46
-6
@@ -34,11 +34,33 @@ const DESCRIPTION_LINE_COUNT = 3;
|
||||
const COMPACT_WIDTH = 40;
|
||||
const RESET_CONFIRMATION_MS = 2000;
|
||||
|
||||
type SettingSection = "Actions" | "Defaults for future asks" | "Live settings";
|
||||
type ToggleSettingKey = keyof AskConfig["behaviour"] | "notifications.enabled";
|
||||
|
||||
interface BaseSetting {
|
||||
description: string;
|
||||
label: string;
|
||||
section: SettingSection;
|
||||
}
|
||||
|
||||
interface ActionSetting extends BaseSetting {
|
||||
key: "resetConfig";
|
||||
type: "action";
|
||||
}
|
||||
|
||||
interface ToggleSetting extends BaseSetting {
|
||||
key: ToggleSettingKey;
|
||||
type: "toggle";
|
||||
}
|
||||
|
||||
type Setting = ActionSetting | ToggleSetting;
|
||||
|
||||
const SETTINGS = [
|
||||
{
|
||||
description:
|
||||
"Auto-submit completed ask flows when no question or option notes were added.",
|
||||
key: "autoSubmitWhenAnsweredWithoutNotes",
|
||||
section: "Live settings",
|
||||
label: "Auto-submit when answered without notes",
|
||||
type: "toggle",
|
||||
},
|
||||
@@ -46,6 +68,7 @@ const SETTINGS = [
|
||||
description:
|
||||
"Require a second cancel or dismiss action before discarding answered or drafted ask content.",
|
||||
key: "confirmDismissWhenDirty",
|
||||
section: "Live settings",
|
||||
label: "Confirm dismiss when dirty",
|
||||
type: "toggle",
|
||||
},
|
||||
@@ -53,6 +76,7 @@ const SETTINGS = [
|
||||
description:
|
||||
"Require pressing 1, 2, or 3 twice on the review tab before triggering Submit, Elaborate, or Cancel.",
|
||||
key: "doublePressReviewShortcuts",
|
||||
section: "Live settings",
|
||||
label: "Double-press review shortcuts",
|
||||
type: "toggle",
|
||||
},
|
||||
@@ -60,26 +84,36 @@ const SETTINGS = [
|
||||
description:
|
||||
"Emit one external notification when the ask flow opens and waits for input.",
|
||||
key: "notifications.enabled",
|
||||
section: "Live settings",
|
||||
label: "Notifications",
|
||||
type: "toggle",
|
||||
},
|
||||
{
|
||||
description: "Show footer keymap hints at the bottom of the ask flow.",
|
||||
key: "showFooterHints",
|
||||
section: "Live settings",
|
||||
label: "Show footer hints",
|
||||
type: "toggle",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Default new and replayed ask flows to render single-select questions as multi-select. This is not hot-applied to the current flow; use the type hotkey there.",
|
||||
key: "presentSingleAsMulti",
|
||||
section: "Defaults for future asks",
|
||||
label: "Present single-select as multi-select",
|
||||
type: "toggle",
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Reset behaviour, keymaps, notifications, and extraction settings to defaults. Press twice quickly to confirm.",
|
||||
key: "resetConfig",
|
||||
section: "Actions",
|
||||
label: "Reset config to defaults",
|
||||
type: "action",
|
||||
},
|
||||
] as const;
|
||||
] as const satisfies readonly Setting[];
|
||||
|
||||
type Setting = (typeof SETTINGS)[number];
|
||||
type SettingKey = Extract<Setting, { type: "toggle" }>["key"];
|
||||
type SettingKey = ToggleSettingKey;
|
||||
|
||||
export class AskSettingsList {
|
||||
private closed = false;
|
||||
@@ -152,10 +186,16 @@ export class AskSettingsList {
|
||||
);
|
||||
|
||||
lines.push(this.line("", innerWidth));
|
||||
let previousSection: string | undefined;
|
||||
for (const [index, setting] of SETTINGS.entries()) {
|
||||
if (setting.type === "action") {
|
||||
lines.push(this.line("", innerWidth));
|
||||
lines.push(this.line("", innerWidth));
|
||||
if (setting.section !== previousSection) {
|
||||
if (previousSection) {
|
||||
lines.push(this.line("", innerWidth));
|
||||
}
|
||||
lines.push(
|
||||
this.line(this.theme.fg("accent", ` ${setting.section}`), innerWidth)
|
||||
);
|
||||
previousSection = setting.section;
|
||||
}
|
||||
for (const settingLine of this.renderSetting(
|
||||
setting,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user