@mrclrchtr/supi-ask-user 1.4.0 → 1.5.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 +108 -70
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +42 -10
- package/package.json +2 -2
- package/src/ask-user.ts +6 -0
- package/src/render/result.ts +5 -1
- package/src/session/controller.ts +122 -4
- package/src/types.ts +7 -0
- package/src/ui/overlay-component.ts +400 -0
- package/src/ui/overlay-render.ts +29 -6
- package/src/ui/overlay-view.ts +106 -9
- package/src/ui/overlay.ts +13 -373
- package/src/ui/types.ts +6 -3
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
|
-
-
|
|
23
|
+
- **`ask_user`** — open a blocking decision form during a run
|
|
25
24
|
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
##
|
|
60
|
+
## Questions
|
|
46
61
|
|
|
47
|
-
|
|
62
|
+
Each question has a `type`, `id`, `header`, and `prompt`. Two question types are supported:
|
|
48
63
|
|
|
49
|
-
|
|
64
|
+
### `choice` — fixed options
|
|
50
65
|
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
+
## Result
|
|
67
86
|
|
|
68
|
-
|
|
69
|
-
- `initial`
|
|
70
|
-
- `placeholder`
|
|
87
|
+
A completed form returns a result with `details.status` set to one of:
|
|
71
88
|
|
|
72
|
-
|
|
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
|
-
|
|
97
|
+
`details.answersById` maps question IDs to their answers. Each answer has a `kind` and type-specific data:
|
|
75
98
|
|
|
76
|
-
- `
|
|
77
|
-
- `
|
|
78
|
-
- `
|
|
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.
|
|
103
|
+
`details.missingQuestionIds` lists any required questions that were left unanswered on a partial submit.
|
|
83
104
|
|
|
84
105
|
## Behavior
|
|
85
106
|
|
|
86
|
-
- interactive
|
|
87
|
-
- `ask_user`
|
|
88
|
-
-
|
|
89
|
-
-
|
|
90
|
-
-
|
|
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
|
-
|
|
115
|
+
The tool registers the following prompt guidance that the model sees:
|
|
93
116
|
|
|
94
|
-
|
|
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
|
|
99
|
-
- `Space`
|
|
100
|
-
- `
|
|
101
|
-
- `
|
|
102
|
-
- `←`
|
|
103
|
-
- `Esc`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
+
Back and cancel are keyboard-only (`←`, `Esc`) — no visible rows.
|
|
115
147
|
|
|
116
148
|
### Text questions
|
|
117
149
|
|
|
118
|
-
-
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
-
|
|
122
|
-
-
|
|
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
|
-
|
|
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/
|
|
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` —
|
|
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
|
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "1.5.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.
|
|
24
|
+
"@mrclrchtr/supi-core": "1.5.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") {
|
package/src/render/result.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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 {
|