@mrclrchtr/supi-ask-user 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # @mrclrchtr/supi-ask-user
2
2
 
3
- Adds a redesigned `ask_user` tool to the [pi coding agent](https://github.com/earendil-works/pi).
4
- It lets the model pause and request a small decision form when explicit human input is required.
3
+ Adds a redesigned `ask_user` tool to the [pi coding agent](https://github.com/earendil-works/pi). It lets the model pause and request a small decision form when explicit human input is required.
5
4
 
6
5
  ## Install
7
6
 
@@ -21,108 +20,140 @@ After editing the source, run `/reload`.
21
20
 
22
21
  After install, pi gets one new tool:
23
22
 
24
- - `ask_user` — open a blocking decision form during a run
23
+ - **`ask_user`** — open a blocking decision form during a run
25
24
 
26
- Use cases:
25
+ The tool presents a structured questionnaire in the TUI overlay and blocks the agent turn until the user responds. It is designed for focused decisions, **not** long surveys or open-ended discovery.
27
26
 
28
- - clarify a narrow implementation choice
29
- - confirm a risky or destructive action
30
- - ask for a preference the repo cannot answer
31
- - gather one short cluster of related decisions before proceeding
27
+ Typical use cases:
32
28
 
33
- It is **not** meant for long surveys or open-ended discovery.
29
+ - Clarify a narrow implementation choice
30
+ - Confirm a risky or destructive action
31
+ - Ask for a preference the repo cannot answer
32
+ - Gather one short cluster of related decisions before proceeding
33
+
34
+ ## Package surfaces
35
+
36
+ - `@mrclrchtr/supi-ask-user/extension` — pi extension entrypoint, registers the `ask_user` tool
37
+ - `@mrclrchtr/supi-ask-user/api` — reusable types and utilities
38
+
39
+ Example:
40
+
41
+ ```ts
42
+ import { normalizeQuestionnaire, AskUserController } from "@mrclrchtr/supi-ask-user/api";
43
+
44
+ const questionnaire = normalizeQuestionnaire(params);
45
+ const controller = new AskUserController(questionnaire);
46
+ ```
34
47
 
35
48
  ## Request shape
36
49
 
37
50
  `ask_user` accepts a small form with optional framing text:
38
51
 
39
- - `title` short overall title
40
- - `intro` — why the agent is asking
41
- - `questions` 1-4 related questions
42
- - `allowPartialSubmit` let the user submit partial progress
43
- - `allowDiscuss` let the user switch back into discussion instead of giving a final decision
52
+ | Field | Type | Description |
53
+ |-------|------|-------------|
54
+ | `title` | string (optional) | Short overall title for the form |
55
+ | `intro` | string (optional) | Why the agent is asking |
56
+ | `questions` | array (1–4) | Choice or text questions |
57
+ | `allowPartialSubmit` | boolean (optional) | Let the user submit partial progress |
58
+ | `allowDiscuss` | boolean (optional) | Let the user switch back into discussion instead of giving a final decision |
44
59
 
45
- ## Question types
60
+ ## Questions
46
61
 
47
- ### `choice`
62
+ Each question has a `type`, `id`, `header`, and `prompt`. Two question types are supported:
48
63
 
49
- Use for fixed options.
64
+ ### `choice` fixed options
50
65
 
51
- Supported fields:
66
+ | Field | Type | Description |
67
+ |-------|------|-------------|
68
+ | `options` | array (2–12) | Allowed answers with `value`, `label`, and optional `description`/`preview` |
69
+ | `required` | boolean (default: `true`) | Whether this question must be answered |
70
+ | `multi` | boolean (default: `false`) | Allow selecting multiple options |
71
+ | `allowOther` | boolean | Allow a freeform answer instead of listed options. Single-select only. |
72
+ | `recommendation` | string \| string[] | Recommended option value(s) |
73
+ | `initial` | string \| string[] | Initially selected option value(s) |
52
74
 
53
- - `options`
54
- - `required`
55
- - `multi`
56
- - `allowOther` — single-select only
57
- - `recommendation`
58
- - `initial`
59
- - option `description`
60
- - option `preview`
75
+ Model yes/no questions as a `choice` with `{ value: "yes", label: "Yes" }` and `{ value: "no", label: "No" }`.
61
76
 
62
- ### `text`
77
+ ### `text` — freeform input
63
78
 
64
- Use for freeform input.
79
+ | Field | Type | Description |
80
+ |-------|------|-------------|
81
+ | `required` | boolean (default: `true`) | Whether this question must be answered |
82
+ | `initial` | string | Initial value shown in the editor |
83
+ | `placeholder` | string | Placeholder shown before the user types |
65
84
 
66
- Supported fields:
85
+ ## Result
67
86
 
68
- - `required`
69
- - `initial`
70
- - `placeholder`
87
+ A completed form returns a result with `details.status` set to one of:
71
88
 
72
- ## Result statuses
89
+ | Status | Meaning |
90
+ |--------|---------|
91
+ | `submitted` | Full submit, all required questions answered |
92
+ | `partial` | Partial submit with some required questions unanswered |
93
+ | `discuss` | User wants to continue the conversation instead of deciding |
94
+ | `cancelled` | User explicitly cancelled (aborts the current agent turn) |
95
+ | `aborted` | The interaction was aborted externally (aborts the current agent turn) |
73
96
 
74
- A completed form returns one of these statuses in `details.status`:
97
+ `details.answersById` maps question IDs to their answers. Each answer has a `kind` and type-specific data:
75
98
 
76
- - `submitted` — full submit
77
- - `partial` partial submit with missing required answers
78
- - `discuss` user wants to continue the conversation instead of deciding
79
- - `cancelled` — user explicitly cancelled
80
- - `aborted` — the interaction was aborted externally
99
+ - `{ kind: "choice", selections: [{ value, label, note? }] }` — single or multi-select choice, with optional per-option user notes
100
+ - `{ kind: "custom", value: "..." }` freeform `allowOther` answer
101
+ - `{ kind: "text", value: "..." }` freeform text answer
81
102
 
82
- `details.answersById` contains structured answers keyed by question id.
103
+ `details.missingQuestionIds` lists any required questions that were left unanswered on a partial submit.
83
104
 
84
105
  ## Behavior
85
106
 
86
- - interactive UI with custom overlay support required
87
- - `ask_user` does not provide a degraded dialog fallback
88
- - only one `ask_user` interaction may be active at a time
89
- - cancellation or abort stops the current agent turn
90
- - completed forms are summarized in the session tree
107
+ - Requires pi in interactive (TUI) mode with custom overlay support — no degraded fallback
108
+ - Only one `ask_user` form may be active at a time; calling `ask_user` while another form is in flight returns an error
109
+ - Cancellation or abort stops the current agent turn
110
+ - Completed forms are summarized in the session tree
111
+ - Do not use `ask_user` for open-ended interviews or repo facts the agent can discover on its own
112
+
113
+ ## Tool guidance
91
114
 
92
- ## Rich overlay controls
115
+ The tool registers the following prompt guidance that the model sees:
93
116
 
94
- `ask_user` requires the rich overlay renderer. The current interaction model is:
117
+ - Use ask_user only when explicit user input is required to proceed safely; do not use ask_user for open-ended interviews or repo facts.
118
+ - Use ask_user with 1-4 related questions; prefer one when possible.
119
+ - Use ask_user `choice` for fixed options and ask_user `text` for freeform input; model yes/no as `choice` with `{ value: "yes", label: "Yes" }` and `{ value: "no", label: "No" }`.
120
+ - Use ask_user `allowOther` only on single-select `choice` questions.
121
+ - Use ask_user `allowDiscuss` or `allowPartialSubmit` only when that outcome is actionable.
122
+ - Do not call ask_user while another ask_user form is already in flight.
123
+
124
+ ## UI controls
95
125
 
96
126
  ### Choice questions
97
127
 
98
- - `↑↓` move between rows
99
- - `Space` selects the focused option in single-select mode
100
- - `Space` toggles the focused option in multi-select mode
101
- - `Enter` submits the current choice answer
102
- - `←` goes back to the previous question
103
- - `Esc` cancels the whole form
128
+ - `↑↓` move between options
129
+ - `Space` select the focused option (single-select) or toggle (multi-select)
130
+ - `Enter` submit the current answer
131
+ - `n` edit a note for the focused choice option
132
+ - `←` go back to the previous question
133
+ - `Esc` cancel the whole form (or close the note editor if one is open)
134
+
135
+ On wide terminals, option previews render side-by-side with the option list. On narrow terminals, previews stack below.
104
136
 
105
- On wide terminals, choice previews render side-by-side with the option list. On narrow terminals, previews stack below.
137
+ Notes are available only for real `choice` options. They do not apply to `text` questions, `Other…` freeform answers, or other exceptional action rows. Saving a non-empty note selects the option if needed; clearing a note leaves the current selection alone; deselecting a multi-select option removes its note with the selection.
106
138
 
107
- Visible rows are kept for exceptional paths only:
139
+ Only exceptional action rows are visible:
108
140
 
109
- - `Other…`
110
- - `Discuss instead…`
111
- - `Submit partial answers`
112
- - `Skip question` for optional questions
141
+ - `Other…` — when `allowOther` is enabled
142
+ - `Discuss instead…` — when `allowDiscuss` is enabled
143
+ - `Submit partial answers` — when `allowPartialSubmit` is enabled
144
+ - `Skip question` for optional questions
113
145
 
114
- There is no visible Back row or Cancel row in the overlay.
146
+ Back and cancel are keyboard-only (`←`, `Esc`) no visible rows.
115
147
 
116
148
  ### Text questions
117
149
 
118
- - the text editor is visible immediately
119
- - there is no separate `Enter response…` row
120
- - `Enter` submits the current text answer
121
- - `↓` moves from the editor into any visible exceptional action rows
122
- - `↑` from the first action row returns focus to the editor
123
- - `Esc` cancels the whole form
150
+ - The editor is visible immediately (no separate entry row)
151
+ - `Enter` submit the current text
152
+ - `↓` move from the editor into visible exceptional action rows
153
+ - `↑` from the first action row, return focus to the editor
154
+ - `Esc` cancel the whole form
124
155
 
125
- Text questions may still show exceptional action rows such as `Discuss instead…` or `Submit partial answers` below the editor when those paths are enabled.
156
+ Exceptional action rows (`Discuss instead…`, `Submit partial answers`) may appear below the editor when those paths are enabled.
126
157
 
127
158
  ## Example
128
159
 
@@ -158,12 +189,19 @@ Text questions may still show exceptional action rows such as `Discuss instead
158
189
 
159
190
  ## Source layout
160
191
 
192
+ - `src/extension.ts` — pi extension entrypoint
193
+ - `src/api.ts` — reusable public surface
194
+ - `src/index.ts` — package barrel
161
195
  - `src/ask-user.ts` — tool registration and execution boundary
162
- - `src/schema.ts` — tool-call schema
196
+ - `src/schema.ts` — tool-call parameter schema (TypeBox)
197
+ - `src/types.ts` — internal normalized types and answer shapes
163
198
  - `src/normalize.ts` — validation and lowering into internal types
164
- - `src/session/controller.ts` — headless decision-form state
199
+ - `src/tool/guidance.ts` — prompt guidance and tool description
200
+ - `src/session/controller.ts` — headless decision-form state machine
201
+ - `src/session/lock.ts` — session-scoped concurrency lock
165
202
  - `src/ui/choose-renderer.ts` — custom-overlay capability gate
166
- - `src/ui/overlay.ts` — rich custom interaction orchestration
203
+ - `src/ui/overlay.ts` — overlay runner that creates the custom interaction session
204
+ - `src/ui/overlay-component.ts` — rich custom interaction state and input orchestration
167
205
  - `src/ui/overlay-view.ts` — choice/action row modeling and split-layout helpers
168
206
  - `src/ui/overlay-render.ts` — rich overlay rendering built on `Markdown`, `Editor`, and `SelectList`
169
207
  - `src/ui/overlay-actions.ts` — exceptional-action list wiring for text questions
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,6 +49,7 @@ export {
49
49
  redactDebugData,
50
50
  resetDebugRegistry,
51
51
  } from "./debug-registry.ts";
52
+ export { fileToUri, resolveToolPath, stripToolPathPrefix, uriToFile } from "./path-utils.ts";
52
53
  export type { KnownRootEntry } from "./project-roots.ts";
53
54
  export {
54
55
  buildKnownRootsMap,
@@ -63,6 +64,7 @@ export {
63
64
  sortRootsBySpecificity,
64
65
  walkProject,
65
66
  } from "./project-roots.ts";
67
+ export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
66
68
  export { getActiveBranchEntries } from "./session-utils.ts";
67
69
  export { registerSettingsCommand } from "./settings/settings-command.ts";
68
70
  export type { SettingsScope, SettingsSection } from "./settings/settings-registry.ts";
@@ -49,6 +49,7 @@ export {
49
49
  redactDebugData,
50
50
  resetDebugRegistry,
51
51
  } from "./debug-registry.ts";
52
+ export { fileToUri, resolveToolPath, stripToolPathPrefix, uriToFile } from "./path-utils.ts";
52
53
  export type { KnownRootEntry } from "./project-roots.ts";
53
54
  export {
54
55
  buildKnownRootsMap,
@@ -63,6 +64,7 @@ export {
63
64
  sortRootsBySpecificity,
64
65
  walkProject,
65
66
  } from "./project-roots.ts";
67
+ export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
66
68
  export { getActiveBranchEntries } from "./session-utils.ts";
67
69
  export { registerSettingsCommand } from "./settings/settings-command.ts";
68
70
  export type { SettingsScope, SettingsSection } from "./settings/settings-registry.ts";
@@ -0,0 +1,40 @@
1
+ import * as path from "node:path";
2
+
3
+ /** Strip pi's optional leading `@` file-path prefix from a tool input. */
4
+ export function stripToolPathPrefix(target: string): string {
5
+ return target.startsWith("@") ? target.slice(1) : target;
6
+ }
7
+
8
+ /**
9
+ * Resolve a tool-style file path from a session cwd.
10
+ *
11
+ * Built-in pi file tools accept a leading `@` prefix in path arguments, so
12
+ * shared SuPi path helpers normalize that prefix before resolving relative
13
+ * paths.
14
+ */
15
+ export function resolveToolPath(cwd: string, target: string): string {
16
+ return path.resolve(cwd, stripToolPathPrefix(target));
17
+ }
18
+
19
+ /** Convert a file path to a file:// URI. */
20
+ export function fileToUri(filePath: string): string {
21
+ const resolved = path.resolve(filePath);
22
+ if (process.platform === "win32") {
23
+ return `file:///${resolved.replace(/\\/g, "/")}`;
24
+ }
25
+ return `file://${resolved}`;
26
+ }
27
+
28
+ /** Convert a file:// URI to a file path. */
29
+ export function uriToFile(uri: string): string {
30
+ if (!uri.startsWith("file://")) return uri;
31
+ let filePath = decodeURIComponent(uri.slice(7));
32
+ if (
33
+ process.platform === "win32" &&
34
+ filePath.startsWith("/") &&
35
+ /^[A-Za-z]:/.test(filePath.slice(1))
36
+ ) {
37
+ filePath = filePath.slice(1);
38
+ }
39
+ return filePath;
40
+ }
@@ -5,8 +5,20 @@
5
5
  // Without this, each symlink path gets its own module copy and its own Map,
6
6
  // so registrations from one instance are invisible to consumers in another.
7
7
 
8
+ import * as path from "node:path";
9
+
8
10
  const SYMBOL_PREFIX = "@mrclrchtr/supi-core/";
9
11
 
12
+ function getGlobalRegistryMap<T>(name: string): Map<string, T> {
13
+ const key = Symbol.for(SYMBOL_PREFIX + name);
14
+ let map = (globalThis as Record<symbol, unknown>)[key] as Map<string, T> | undefined;
15
+ if (!map) {
16
+ map = new Map<string, T>();
17
+ (globalThis as Record<symbol, unknown>)[key] = map;
18
+ }
19
+ return map;
20
+ }
21
+
10
22
  /**
11
23
  * Create a named registry backed by `globalThis` + `Symbol.for`.
12
24
  *
@@ -18,16 +30,7 @@ const SYMBOL_PREFIX = "@mrclrchtr/supi-core/";
18
30
  * @returns An object with `register`, `getAll`, and `clear` functions.
19
31
  */
20
32
  export function createRegistry<T>(name: string) {
21
- const key = Symbol.for(SYMBOL_PREFIX + name);
22
-
23
- const getMap = (): Map<string, T> => {
24
- let map = (globalThis as Record<symbol, unknown>)[key] as Map<string, T> | undefined;
25
- if (!map) {
26
- map = new Map<string, T>();
27
- (globalThis as Record<symbol, unknown>)[key] = map;
28
- }
29
- return map;
30
- };
33
+ const getMap = (): Map<string, T> => getGlobalRegistryMap<T>(name);
31
34
 
32
35
  return {
33
36
  /**
@@ -52,3 +55,32 @@ export function createRegistry<T>(name: string) {
52
55
  },
53
56
  };
54
57
  }
58
+
59
+ /**
60
+ * Create a named session-state registry keyed by normalized cwd.
61
+ *
62
+ * This helper is intended for session-scoped runtime services that should be
63
+ * shared across duplicate jiti module instances while keeping package-specific
64
+ * state unions and convenience wrappers local to the calling package.
65
+ */
66
+ export function createSessionStateRegistry<TState>(name: string) {
67
+ const getMap = (): Map<string, TState> => getGlobalRegistryMap<TState>(name);
68
+ const normalizeCwd = (cwd: string): string => path.resolve(cwd);
69
+
70
+ return {
71
+ /** Get the current state for one session cwd. */
72
+ get: (cwd: string): TState | undefined => {
73
+ return getMap().get(normalizeCwd(cwd));
74
+ },
75
+
76
+ /** Store the current state for one session cwd. */
77
+ set: (cwd: string, state: TState): void => {
78
+ getMap().set(normalizeCwd(cwd), state);
79
+ },
80
+
81
+ /** Clear the current state for one session cwd. */
82
+ clear: (cwd: string): void => {
83
+ getMap().delete(normalizeCwd(cwd));
84
+ },
85
+ };
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-ask-user",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "SuPi ask-user extension — rich questionnaire UI for structured agent-user decisions",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "main": "src/api.ts",
23
23
  "dependencies": {
24
- "@mrclrchtr/supi-core": "1.4.0"
24
+ "@mrclrchtr/supi-core": "1.6.0"
25
25
  },
26
26
  "bundledDependencies": [
27
27
  "@mrclrchtr/supi-core"
package/src/ask-user.ts CHANGED
@@ -19,6 +19,8 @@ export type AskUserExecutionContext = Pick<ExtensionContext, "cwd" | "hasUI" | "
19
19
  notify?(message: string, type?: "info" | "warning" | "error"): void;
20
20
  setWorkingVisible?(visible: boolean): void;
21
21
  setTitle?(title: string): void;
22
+ getToolsExpanded?(): boolean;
23
+ setToolsExpanded?(expanded: boolean): void;
22
24
  };
23
25
  };
24
26
 
@@ -81,6 +83,10 @@ export async function executeAskUser(
81
83
  notify: ctx.ui.notify,
82
84
  },
83
85
  signal,
86
+ onToggleToolsExpanded:
87
+ ctx.ui.getToolsExpanded && ctx.ui.setToolsExpanded
88
+ ? () => ctx.ui.setToolsExpanded?.(!ctx.ui.getToolsExpanded?.())
89
+ : undefined,
84
90
  });
85
91
 
86
92
  if (outcome === "unsupported") {
@@ -42,7 +42,7 @@ export function buildErrorResult(message: string): AskUserToolResult {
42
42
  export function formatAnswerSummary(_question: NormalizedQuestion, answer: Answer): string {
43
43
  switch (answer.kind) {
44
44
  case "choice":
45
- return answer.selections.map((selection) => selection.label).join("; ");
45
+ return answer.selections.map(formatChoiceSelectionSummary).join("; ");
46
46
  case "custom":
47
47
  return `Other — ${answer.value}`;
48
48
  case "text":
@@ -50,6 +50,10 @@ export function formatAnswerSummary(_question: NormalizedQuestion, answer: Answe
50
50
  }
51
51
  }
52
52
 
53
+ function formatChoiceSelectionSummary(selection: { label: string; note?: string }): string {
54
+ return selection.note ? `${selection.label} (note: ${selection.note})` : selection.label;
55
+ }
56
+
53
57
  function summarizeOutcome(questions: NormalizedQuestion[], outcome: AskUserOutcome): string {
54
58
  if (outcome.status === "cancelled") return "User cancelled the form.";
55
59
  if (outcome.status === "aborted") return "The form was aborted before completion.";
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  Answer,
3
+ AnswerSelection,
3
4
  AskUserOutcome,
4
5
  AskUserStatus,
5
6
  NormalizedChoiceQuestion,
@@ -59,6 +60,77 @@ export class AskUserController {
59
60
  .filter((index) => index >= 0);
60
61
  }
61
62
 
63
+ getChoiceOptionNote(questionId: string, optionValue: string): string | undefined {
64
+ const answer = this.answers.get(questionId);
65
+ if (answer?.kind !== "choice") return undefined;
66
+ return answer.selections.find((selection) => selection.value === optionValue)?.note;
67
+ }
68
+
69
+ selectChoiceOption(question: NormalizedChoiceQuestion, optionIndex: number): void {
70
+ if (this.isTerminal) return;
71
+ const option = question.options[optionIndex];
72
+ if (!option) return;
73
+ const existingNote = this.getChoiceOptionNote(question.id, option.value);
74
+ this.commitChoiceSelections(question, [
75
+ buildSelection(option.value, option.label, existingNote),
76
+ ]);
77
+ }
78
+
79
+ toggleChoiceOption(question: NormalizedChoiceQuestion, optionIndex: number): void {
80
+ if (this.isTerminal) return;
81
+ const option = question.options[optionIndex];
82
+ if (!option) return;
83
+ if (!question.multi) {
84
+ this.selectChoiceOption(question, optionIndex);
85
+ return;
86
+ }
87
+
88
+ const selections = this.getStoredChoiceSelections(question.id);
89
+ const filtered = selections.filter((selection) => selection.value !== option.value);
90
+ if (filtered.length !== selections.length) {
91
+ this.commitChoiceSelections(question, filtered);
92
+ return;
93
+ }
94
+
95
+ this.commitChoiceSelections(question, [
96
+ ...selections,
97
+ buildSelection(option.value, option.label),
98
+ ]);
99
+ }
100
+
101
+ setChoiceOptionNote(
102
+ question: NormalizedChoiceQuestion,
103
+ optionIndex: number,
104
+ note: string | undefined,
105
+ ): void {
106
+ if (this.isTerminal) return;
107
+ const option = question.options[optionIndex];
108
+ if (!option) return;
109
+
110
+ const trimmedNote = trimOptional(note);
111
+ const selections = this.getStoredChoiceSelections(question.id);
112
+ const existing = selections.find((selection) => selection.value === option.value);
113
+
114
+ if (existing) {
115
+ this.commitChoiceSelections(
116
+ question,
117
+ selections.map((selection) => {
118
+ if (selection.value !== option.value) return selection;
119
+ return buildSelection(selection.value, selection.label, trimmedNote);
120
+ }),
121
+ );
122
+ return;
123
+ }
124
+
125
+ if (!trimmedNote) return;
126
+
127
+ const nextSelection = buildSelection(option.value, option.label, trimmedNote);
128
+ this.commitChoiceSelections(
129
+ question,
130
+ question.multi ? [...selections, nextSelection] : [nextSelection],
131
+ );
132
+ }
133
+
62
134
  setAnswer(questionId: string, answer: Answer): void {
63
135
  if (this.isTerminal) return;
64
136
  this.answers.set(questionId, normalizeAnswer(answer));
@@ -138,6 +210,29 @@ export class AskUserController {
138
210
  .filter((question) => question.required && !this.answers.has(question.id))
139
211
  .map((question) => question.id);
140
212
  }
213
+
214
+ private getStoredChoiceSelections(questionId: string): AnswerSelection[] {
215
+ const answer = this.answers.get(questionId);
216
+ if (answer?.kind !== "choice") return [];
217
+ return answer.selections.map((selection) => ({ ...selection }));
218
+ }
219
+
220
+ private commitChoiceSelections(
221
+ question: NormalizedChoiceQuestion,
222
+ selections: AnswerSelection[],
223
+ ): void {
224
+ const ordered = orderSelections(question, normalizeChoiceSelections(selections));
225
+ const nextSelections = question.multi ? ordered : ordered.slice(0, 1);
226
+ if (nextSelections.length === 0) {
227
+ this.answers.delete(question.id);
228
+ return;
229
+ }
230
+
231
+ this.answers.set(question.id, {
232
+ kind: "choice",
233
+ selections: nextSelections,
234
+ });
235
+ }
141
236
  }
142
237
 
143
238
  function normalizeAnswer(answer: Answer): Answer {
@@ -145,10 +240,7 @@ function normalizeAnswer(answer: Answer): Answer {
145
240
  case "choice":
146
241
  return {
147
242
  kind: "choice",
148
- selections: answer.selections.map((selection) => ({
149
- value: selection.value.trim(),
150
- label: selection.label.trim(),
151
- })),
243
+ selections: normalizeChoiceSelections(answer.selections),
152
244
  };
153
245
  case "custom":
154
246
  return { kind: "custom", value: answer.value.trim() };
@@ -157,6 +249,32 @@ function normalizeAnswer(answer: Answer): Answer {
157
249
  }
158
250
  }
159
251
 
252
+ function normalizeChoiceSelections(selections: AnswerSelection[]): AnswerSelection[] {
253
+ return selections.map((selection) =>
254
+ buildSelection(selection.value, selection.label, selection.note),
255
+ );
256
+ }
257
+
258
+ function orderSelections(
259
+ question: NormalizedChoiceQuestion,
260
+ selections: AnswerSelection[],
261
+ ): AnswerSelection[] {
262
+ const byValue = new Map(selections.map((selection) => [selection.value, selection]));
263
+ return question.options.flatMap((option) => {
264
+ const selection = byValue.get(option.value);
265
+ return selection ? [selection] : [];
266
+ });
267
+ }
268
+
269
+ function buildSelection(value: string, label: string, note?: string): AnswerSelection {
270
+ const trimmedNote = trimOptional(note);
271
+ return {
272
+ value: value.trim(),
273
+ label: label.trim(),
274
+ ...(trimmedNote ? { note: trimmedNote } : {}),
275
+ };
276
+ }
277
+
160
278
  function trimOptional(value: string | undefined): string | undefined {
161
279
  const trimmed = value?.trim();
162
280
  return trimmed ? trimmed : undefined;
package/src/types.ts CHANGED
@@ -43,9 +43,16 @@ export interface NormalizedQuestionnaire {
43
43
  allowDiscuss: boolean;
44
44
  }
45
45
 
46
+ /**
47
+ * One selected choice option returned from `ask_user`.
48
+ *
49
+ * `note` is optional user-entered context attached to this specific option.
50
+ * It is only used on `choice` answers and is absent for `text` / `custom` answers.
51
+ */
46
52
  export interface AnswerSelection {
47
53
  value: string;
48
54
  label: string;
55
+ note?: string;
49
56
  }
50
57
 
51
58
  export interface ChoiceAnswer {