feat: add question type presentation controls
This commit is contained in:
@@ -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"],
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user