@mrclrchtr/supi-ask-user 0.1.0 → 0.2.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.
Files changed (43) hide show
  1. package/README.md +115 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  18. package/package.json +14 -7
  19. package/src/ask-user.ts +191 -0
  20. package/{flow.ts → src/flow.ts} +45 -33
  21. package/src/format.ts +66 -0
  22. package/src/index.ts +1 -0
  23. package/src/normalize.ts +229 -0
  24. package/{ui-rich-render-editor.ts → src/render/ui-rich-render-editor.ts} +18 -16
  25. package/src/render/ui-rich-render-env.ts +15 -0
  26. package/src/render/ui-rich-render-footer.ts +55 -0
  27. package/src/render/ui-rich-render-markdown.ts +33 -0
  28. package/{ui-rich-render-notes.ts → src/render/ui-rich-render-notes.ts} +20 -27
  29. package/src/render/ui-rich-render-types.ts +17 -0
  30. package/src/render/ui-rich-render.ts +323 -0
  31. package/{render.ts → src/render.ts} +10 -5
  32. package/{result.ts → src/result.ts} +27 -6
  33. package/{schema.ts → src/schema.ts} +46 -44
  34. package/{types.ts → src/types.ts} +20 -53
  35. package/{ui-rich-handlers.ts → src/ui/ui-rich-handlers.ts} +100 -44
  36. package/{ui-rich-inline.ts → src/ui/ui-rich-inline.ts} +23 -10
  37. package/{ui-rich-state.ts → src/ui/ui-rich-state.ts} +52 -15
  38. package/{ui-rich.ts → src/ui/ui-rich.ts} +36 -11
  39. package/ask-user.ts +0 -131
  40. package/format.ts +0 -95
  41. package/normalize.ts +0 -218
  42. package/ui-fallback.ts +0 -274
  43. package/ui-rich-render.ts +0 -370
@@ -0,0 +1,226 @@
1
+ // Generic settings overlay for SuPi extensions.
2
+ //
3
+ // Uses pi-tui's SettingsList with scope toggle (Tab), extension grouping,
4
+ // and search. Each extension declares its settings via registerSettings().
5
+
6
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ Container,
10
+ Input,
11
+ Key,
12
+ matchesKey,
13
+ type SettingItem,
14
+ SettingsList,
15
+ Text,
16
+ } from "@earendil-works/pi-tui";
17
+ import {
18
+ getRegisteredSettings,
19
+ type SettingsScope,
20
+ type SettingsSection,
21
+ } from "./settings-registry.ts";
22
+
23
+ // ── Input submenu component ──────────────────────────────────
24
+
25
+ /**
26
+ * Creates a pi-tui Input-backed submenu component with enter-to-confirm
27
+ * and escape-to-cancel handling.
28
+ *
29
+ * @param currentValue - Initial value for the text input.
30
+ * @param label - Label text displayed above the input.
31
+ * @param done - Callback invoked with the confirmed value, or undefined on cancel.
32
+ */
33
+ export function createInputSubmenu(
34
+ currentValue: string,
35
+ label: string,
36
+ done: (selectedValue?: string) => void,
37
+ ): {
38
+ render: (width: number) => string[];
39
+ invalidate: () => void;
40
+ handleInput: (data: string) => boolean;
41
+ } {
42
+ const input = new Input();
43
+ input.setValue(currentValue);
44
+
45
+ return {
46
+ render: (_width: number) => {
47
+ const lines = [` ${label}`];
48
+ lines.push(...input.render(_width));
49
+ lines.push(" enter confirm • esc cancel");
50
+ return lines;
51
+ },
52
+ invalidate: () => {
53
+ input.invalidate();
54
+ },
55
+ handleInput: (data: string) => {
56
+ if (matchesKey(data, Key.escape)) {
57
+ done();
58
+ return true;
59
+ }
60
+ if (matchesKey(data, Key.enter)) {
61
+ done(input.getValue());
62
+ return true;
63
+ }
64
+ input.handleInput(data);
65
+ return true;
66
+ },
67
+ };
68
+ }
69
+
70
+ // ── Types ────────────────────────────────────────────────────
71
+
72
+ interface OverlayState {
73
+ scope: SettingsScope;
74
+ cwd: string;
75
+ }
76
+
77
+ // ── Pure helpers ─────────────────────────────────────────────
78
+
79
+ function getScopeLabel(scope: SettingsScope): string {
80
+ return scope === "project" ? "Project" : "Global";
81
+ }
82
+
83
+ function buildFlatItems(
84
+ sections: SettingsSection[],
85
+ scope: SettingsScope,
86
+ cwd: string,
87
+ ): SettingItem[] {
88
+ const items: SettingItem[] = [];
89
+ for (const section of sections) {
90
+ const sectionItems = section.loadValues(scope, cwd);
91
+ for (const item of sectionItems) {
92
+ items.push({
93
+ ...item,
94
+ id: `${section.id}.${item.id}`,
95
+ label: `${section.label}: ${item.label}`,
96
+ });
97
+ }
98
+ }
99
+ return items;
100
+ }
101
+
102
+ function findSectionAndId(
103
+ sections: SettingsSection[],
104
+ flatId: string,
105
+ ): { section: SettingsSection; itemId: string } | null {
106
+ const dotIndex = flatId.indexOf(".");
107
+ if (dotIndex === -1) return null;
108
+ const sectionId = flatId.slice(0, dotIndex);
109
+ const itemId = flatId.slice(dotIndex + 1);
110
+ const section = sections.find((s) => s.id === sectionId);
111
+ if (!section) return null;
112
+ return { section, itemId };
113
+ }
114
+
115
+ // ── Component ────────────────────────────────────────────────
116
+
117
+ interface SettingsOverlayDeps {
118
+ state: OverlayState;
119
+ container: Container;
120
+ settingsList: SettingsList | null;
121
+ tui: Parameters<Parameters<ExtensionContext["ui"]["custom"]>[0]>[0];
122
+ theme: Parameters<Parameters<ExtensionContext["ui"]["custom"]>[0]>[1];
123
+ done: () => void;
124
+ }
125
+
126
+ function createSettingsList(deps: SettingsOverlayDeps): SettingsList {
127
+ const sections = getRegisteredSettings();
128
+ const items = buildFlatItems(sections, deps.state.scope, deps.state.cwd);
129
+ const onChange = (flatId: string, newValue: string) => {
130
+ const found = findSectionAndId(sections, flatId);
131
+ if (found) {
132
+ found.section.persistChange(deps.state.scope, deps.state.cwd, found.itemId, newValue);
133
+ }
134
+ // Re-read all values to reflect persisted changes, but keep the list
135
+ // instance (and its selectedIndex) intact.
136
+ const updatedItems = buildFlatItems(sections, deps.state.scope, deps.state.cwd);
137
+ for (const updated of updatedItems) {
138
+ const existing = items.find((i) => i.id === updated.id);
139
+ if (existing && existing.currentValue !== updated.currentValue) {
140
+ settingsList.updateValue(updated.id, updated.currentValue);
141
+ }
142
+ }
143
+ deps.tui.requestRender();
144
+ };
145
+ const settingsList = new SettingsList(
146
+ items,
147
+ Math.min(items.length + 4, 20),
148
+ getSettingsListTheme(),
149
+ onChange,
150
+ () => deps.done(),
151
+ { enableSearch: true },
152
+ );
153
+ return settingsList;
154
+ }
155
+
156
+ function rebuildSettingsList(deps: SettingsOverlayDeps): SettingsList {
157
+ const settingsList = createSettingsList(deps);
158
+ deps.settingsList = settingsList;
159
+
160
+ deps.container.clear();
161
+ deps.container.addChild(createHeaderComponent(deps));
162
+ deps.container.addChild(settingsList);
163
+
164
+ return settingsList;
165
+ }
166
+
167
+ function createHeaderComponent(deps: SettingsOverlayDeps): Text {
168
+ const { theme, state } = deps;
169
+ const scopeLabel = getScopeLabel(state.scope);
170
+ const otherScope = state.scope === "project" ? "Global" : "Project";
171
+ const headerText = new Text(
172
+ `${theme.fg("accent", theme.bold("SuPi Settings"))} ${theme.fg("text", `Scope: ${scopeLabel}`)} ${theme.fg("dim", `(tab → ${otherScope})`)}`,
173
+ 0,
174
+ 0,
175
+ );
176
+ return headerText;
177
+ }
178
+
179
+ function handleScopeToggle(deps: SettingsOverlayDeps): void {
180
+ deps.state.scope = deps.state.scope === "project" ? "global" : "project";
181
+ rebuildSettingsList(deps);
182
+ deps.tui.requestRender();
183
+ }
184
+
185
+ // ── Entry point ──────────────────────────────────────────────
186
+
187
+ export function openSettingsOverlay(ctx: ExtensionContext): void {
188
+ const sections = getRegisteredSettings();
189
+ if (sections.length === 0) {
190
+ ctx.ui.notify("No settings registered by SuPi extensions", "info");
191
+ return;
192
+ }
193
+
194
+ void ctx.ui.custom<void>((tui, theme, _kb, done) => {
195
+ const state: OverlayState = { scope: "project", cwd: ctx.cwd };
196
+ const container = new Container();
197
+
198
+ const deps: SettingsOverlayDeps = {
199
+ state,
200
+ container,
201
+ settingsList: null,
202
+ tui,
203
+ theme,
204
+ done,
205
+ };
206
+
207
+ rebuildSettingsList(deps);
208
+
209
+ const component = {
210
+ render: (width: number) => container.render(width),
211
+ invalidate: () => container.invalidate(),
212
+ handleInput: (data: string) => {
213
+ if (matchesKey(data, Key.tab)) {
214
+ handleScopeToggle(deps);
215
+ return true;
216
+ }
217
+ // Delegate input to the settings list (always set after rebuildSettingsList)
218
+ deps.settingsList?.handleInput?.(data);
219
+ deps.tui.requestRender();
220
+ return true;
221
+ },
222
+ };
223
+
224
+ return component;
225
+ });
226
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared terminal title formatting and signaling utilities.
3
+ *
4
+ * Centralized place for pi title convention (π prefix), completion (✓)
5
+ * and waiting (●) indicators, and the audible terminal bell.
6
+ */
7
+ import path from "node:path";
8
+
9
+ /** Unicode checkmark shown when the agent finishes a turn. */
10
+ export const DONE_SYMBOL = "\u2713";
11
+ /** Unicode dot shown when waiting for user input. */
12
+ export const WAITING_SYMBOL = "\u25CF";
13
+
14
+ /** Minimal UI surface needed for title operations. */
15
+ export interface TitleTarget {
16
+ ui: {
17
+ setTitle?(title: string): void;
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Format pi's canonical terminal title from session name and cwd.
23
+ * Falls back gracefully when either is missing.
24
+ *
25
+ * @example
26
+ * formatTitle("my-session", "/home/projects/foo") // "π - my-session - foo"
27
+ * formatTitle(undefined, "/home/projects/foo") // "π - foo"
28
+ * formatTitle("my-session") // "π - my-session"
29
+ * formatTitle() // "π"
30
+ */
31
+ export function formatTitle(sessionName?: string, cwd?: string): string {
32
+ const base = cwd ? path.basename(cwd) : undefined;
33
+ if (sessionName && base) return `π - ${sessionName} - ${base}`;
34
+ if (sessionName) return `π - ${sessionName}`;
35
+ if (base) return `π - ${base}`;
36
+ return "π";
37
+ }
38
+
39
+ /** Sound the audible terminal bell (ASCII BEL). */
40
+ export function signalBell(): void {
41
+ process.stdout.write("\x07");
42
+ }
43
+
44
+ /**
45
+ * Set the terminal title to indicate the agent is waiting for user input.
46
+ * Prefixes with ● and sounds the terminal bell.
47
+ */
48
+ export function signalWaiting(ctx: TitleTarget, title: string): void {
49
+ ctx.ui.setTitle?.(`${WAITING_SYMBOL} ${title}`);
50
+ signalBell();
51
+ }
52
+
53
+ /**
54
+ * Set the terminal title to indicate the agent turn has completed.
55
+ * Prefixes with ✓ and sounds the terminal bell.
56
+ */
57
+ export function signalDone(ctx: TitleTarget, title: string): void {
58
+ ctx.ui.setTitle?.(`${DONE_SYMBOL} ${title}`);
59
+ signalBell();
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-ask-user",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "SuPi ask-user extension — rich questionnaire UI for structured agent-user decisions",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,20 +16,27 @@
16
16
  "pi-coding-agent"
17
17
  ],
18
18
  "files": [
19
- "*.ts",
19
+ "src/**/*.ts",
20
20
  "!__tests__"
21
21
  ],
22
+ "main": "src/index.ts",
23
+ "dependencies": {
24
+ "@mrclrchtr/supi-core": "workspace:*"
25
+ },
26
+ "bundledDependencies": [
27
+ "@mrclrchtr/supi-core"
28
+ ],
22
29
  "peerDependencies": {
23
- "@mariozechner/pi-coding-agent": "~0.66.0",
24
- "@mariozechner/pi-tui": "~0.66.0",
25
- "@sinclair/typebox": ">=0.34.0"
30
+ "@earendil-works/pi-coding-agent": "*",
31
+ "@earendil-works/pi-tui": "*",
32
+ "typebox": "*"
26
33
  },
27
34
  "devDependencies": {
28
- "vitest": "^4.1.4"
35
+ "vitest": "4.1.5"
29
36
  },
30
37
  "pi": {
31
38
  "extensions": [
32
- "./ask-user.ts"
39
+ "./src/ask-user.ts"
33
40
  ]
34
41
  }
35
42
  }
@@ -0,0 +1,191 @@
1
+ // `ask_user` extension entry point. Registers a single model-callable tool
2
+ // for focused interactive decisions during an agent run. Holds the per-session
3
+ // single-active-questionnaire lock and drives the rich overlay UI.
4
+ //
5
+ // Implementation modules:
6
+ // schema.ts — external (LLM-facing) parameter schema
7
+ // normalize.ts — validation + normalization into the shared internal model
8
+ // flow.ts — shared questionnaire flow + concurrency lock
9
+ // ui-rich.ts — overlay UI via ctx.ui.custom()
10
+ // ui-rich-render.ts — overlay rendering helpers
11
+ // result.ts — hybrid (content + details) result formatting
12
+ // render.ts — custom renderCall / renderResult for the transcript
13
+
14
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
15
+ import type { Component } from "@earendil-works/pi-tui";
16
+ import { formatTitle, signalWaiting } from "@mrclrchtr/supi-core";
17
+ import { ActiveQuestionnaireLock } from "./flow.ts";
18
+ import { AskUserValidationError, normalizeQuestionnaire } from "./normalize.ts";
19
+ import { renderAskUserCall, renderAskUserResult } from "./render.ts";
20
+ import { buildErrorResult, buildResult, type HybridResult } from "./result.ts";
21
+ import { type AskUserParams, AskUserParamsSchema } from "./schema.ts";
22
+ import type { NormalizedQuestionnaire } from "./types.ts";
23
+ import { type RichUiHost, runRichQuestionnaire } from "./ui/ui-rich.ts";
24
+
25
+ const TOOL_NAME = "ask_user";
26
+ const TOOL_LABEL = "Ask User";
27
+
28
+ const TOOL_DESCRIPTION =
29
+ "Ask the user a focused decision question (or up to 4 grouped questions) when explicit user input is required to proceed safely. Use for clarifying intent, picking between options, prioritizing a short set of features, or confirming a destructive action — not for surveys or open-ended discovery. Questions are `choice` (with options; set `multi: true` for multi-select) or `text` (freeform input). Structured questions can add `recommendation`, `default`, `allowOther`, `allowDiscuss`, and option `preview` content.";
30
+
31
+ const PROMPT_SNIPPET =
32
+ "ask_user — pause and request a focused decision (1-4 typed questions) when explicit user input is required to proceed, including rich choice and discuss flows";
33
+
34
+ const PROMPT_GUIDELINES = [
35
+ "Use ask_user only for decisions that require explicit user input — never as a substitute for reading code or thinking through a problem.",
36
+ "Keep questionnaires bounded: 1-4 focused questions with short headers; prefer one decision per call when possible.",
37
+ 'There are two question types: `choice` for picking from options (single-select by default; set `multi: true` for multi-select — use this instead of the now-removed `multichoice`) and `text` for freeform input. For yes/no questions, use `choice` with options `{value: "yes", label: "Yes"}` and `{value: "no", label: "No"}`.',
38
+ "Set `recommendation` when one option or a small set of options is clearly preferable, so the UI can surface that guidance.",
39
+ "Set `default` to pre-select a starting value or option; the user can accept it with a single keystroke. Use it for safe/common defaults, distinct from `recommendation` which highlights what you think is best.",
40
+ "Enable `allowOther` only when a custom answer is genuinely useful, and `allowDiscuss` only when the user may need to talk through the choice instead of deciding immediately.",
41
+ "Use `description` to explain what each option means — it wraps naturally and a few sentences is fine. Reserve `preview` for code, config, or diagrams that need dedicated rendering space in a side pane.",
42
+ "Do not call ask_user while another ask_user interaction is in flight — wait for the previous result before issuing another.",
43
+ ];
44
+
45
+ /** Minimal ui subset needed by executeAskUser — extended with setTitle/notify from ExtensionUIContext. */
46
+ interface ExtensionUi {
47
+ ui: {
48
+ custom?: RichUiHost["custom"];
49
+ setWorkingVisible?(visible: boolean): void;
50
+ /** Set the terminal window/tab title. */
51
+ setTitle?(title: string): void;
52
+ /** Show a notification to the user. */
53
+ notify?(message: string, type?: "info" | "warning" | "error"): void;
54
+ };
55
+ /**
56
+ * Absolute path to the current working directory, available on the full
57
+ * ExtensionContext. Marked optional here to tolerate partial mocks.
58
+ */
59
+ cwd?: string;
60
+ hasUI: boolean;
61
+ abort(): void;
62
+ }
63
+
64
+ export default function askUserExtension(pi: ExtensionAPI): void {
65
+ const lock = new ActiveQuestionnaireLock();
66
+
67
+ pi.registerTool({
68
+ name: TOOL_NAME,
69
+ label: TOOL_LABEL,
70
+ description: TOOL_DESCRIPTION,
71
+ promptSnippet: PROMPT_SNIPPET,
72
+ promptGuidelines: PROMPT_GUIDELINES,
73
+ parameters: AskUserParamsSchema,
74
+ // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
75
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
76
+ return executeAskUser(
77
+ params as AskUserParams,
78
+ signal,
79
+ ctx as unknown as ExtensionUi,
80
+ lock,
81
+ pi,
82
+ );
83
+ },
84
+ renderCall: (args, theme) => renderAskUserCall(args, theme as Theme),
85
+ renderResult: (result, _options, theme) =>
86
+ renderAskUserResult(
87
+ result as { details?: unknown; content: { type: string; text?: string }[] },
88
+ theme as Theme,
89
+ ) as unknown as Component,
90
+ });
91
+ }
92
+
93
+ // biome-ignore lint/complexity/useMaxParams: pi context + pi reference for appendEntry
94
+ async function executeAskUser(
95
+ params: AskUserParams,
96
+ signal: AbortSignal | undefined,
97
+ ctx: ExtensionUi,
98
+ lock: ActiveQuestionnaireLock,
99
+ pi: ExtensionAPI,
100
+ ): Promise<HybridResult> {
101
+ let normalized: NormalizedQuestionnaire;
102
+ try {
103
+ normalized = normalizeQuestionnaire(params);
104
+ } catch (err) {
105
+ if (err instanceof AskUserValidationError) return buildErrorResult(`Error: ${err.message}`);
106
+ throw err;
107
+ }
108
+ if (!ctx.hasUI) {
109
+ return buildErrorResult(
110
+ "Error: ask_user requires interactive UI but the current session has none.",
111
+ );
112
+ }
113
+ if (!lock.acquire()) {
114
+ return buildErrorResult(
115
+ "Error: another ask_user interaction is already in flight. Wait for it to complete before calling ask_user again.",
116
+ );
117
+ }
118
+ signalAttention(ctx);
119
+ pi.events.emit("supi:ask-user:start", { source: "supi-ask-user" });
120
+ try {
121
+ // Hide the built-in working loader so it doesn't compete with the overlay.
122
+ ctx.ui.setWorkingVisible?.(false);
123
+ const result = await driveQuestionnaire(normalized, signal, ctx);
124
+ if (
125
+ result.details.terminalState !== "submitted" &&
126
+ result.details.terminalState !== "skipped"
127
+ ) {
128
+ ctx.abort();
129
+ }
130
+ // Append a tree-friendly custom entry so /tree shows a readable
131
+ // summary (question headers) instead of raw JSON tool-call arguments.
132
+ // Custom entries are hidden in the default tree filter, visible in
133
+ // "all" mode (Ctrl+O).
134
+ pi.appendEntry(treeSummaryLabel(normalized));
135
+ return result;
136
+ } finally {
137
+ // Restore the working loader regardless of how the overlay closed.
138
+ ctx.ui.setWorkingVisible?.(true);
139
+ pi.events.emit("supi:ask-user:end", { source: "supi-ask-user" });
140
+ restoreTerminalTitle(ctx, pi);
141
+ lock.release();
142
+ }
143
+ }
144
+
145
+ /** Set terminal title and play alert bell to signal the user needs to respond. */
146
+ function signalAttention(ctx: ExtensionUi): void {
147
+ signalWaiting(ctx, `pi — waiting for your input`);
148
+ }
149
+
150
+ /** Restore the terminal title to pi's native format (session name + cwd). */
151
+ function restoreTerminalTitle(ctx: ExtensionUi, pi: ExtensionAPI): void {
152
+ ctx.ui.setTitle?.(formatTitle(pi.getSessionName(), ctx.cwd));
153
+ }
154
+
155
+ /** Build a concise custom-entry label readable in the /tree "all" filter. */
156
+ function treeSummaryLabel(q: NormalizedQuestionnaire): string {
157
+ const count = q.questions.length;
158
+ const s = count === 1 ? "" : "s";
159
+ const headers = q.questions.map((q) => q.header).join(", ");
160
+ if (headers.length > 70) {
161
+ return `ask_user · ${count} question${s} · ${headers.slice(0, 67)}...`;
162
+ }
163
+ return `ask_user · ${count} question${s} · ${headers}`;
164
+ }
165
+
166
+ async function driveQuestionnaire(
167
+ questionnaire: NormalizedQuestionnaire,
168
+ signal: AbortSignal | undefined,
169
+ ctx: ExtensionUi,
170
+ ): Promise<HybridResult> {
171
+ const questions = questionnaire.questions;
172
+ if (typeof ctx.ui.custom !== "function") {
173
+ return buildErrorResult(
174
+ "Error: ask_user requires a TUI with custom overlay support. Do not use ask_user in non-interactive or degraded UI sessions.",
175
+ );
176
+ }
177
+ const richHost: RichUiHost = { custom: ctx.ui.custom.bind(ctx.ui) };
178
+ const outcome = await runRichQuestionnaire(questionnaire, { ui: richHost, signal });
179
+ if (outcome === "unsupported") {
180
+ return buildErrorResult(
181
+ "Error: ask_user requires a TUI with custom overlay support. Do not use ask_user in non-interactive or degraded UI sessions.",
182
+ );
183
+ }
184
+ return buildResult(questions, outcome);
185
+ }
186
+
187
+ export { ActiveQuestionnaireLock, QuestionnaireFlow } from "./flow.ts";
188
+ // Re-exports used by tests.
189
+ export { AskUserValidationError, normalizeQuestionnaire } from "./normalize.ts";
190
+ export { buildResult } from "./result.ts";
191
+ export { PROMPT_GUIDELINES as askUserPromptGuidelines, PROMPT_SNIPPET as askUserPromptSnippet };
@@ -1,7 +1,7 @@
1
- // Shared questionnaire flow state used by both UI paths and the
1
+ // Shared questionnaire flow state used by the overlay UI and the
2
2
  // single-active-questionnaire concurrency guard. The flow owns terminal-state
3
- // transitions (`submitted`, `cancelled`, `aborted`) so overlay and fallback
4
- // cannot drift apart on cancellation/abort semantics.
3
+ // transitions (`submitted`, `cancelled`, `aborted`) to keep cancellation/abort
4
+ // semantics consistent.
5
5
 
6
6
  import type { Answer, NormalizedQuestion, QuestionnaireOutcome, TerminalState } from "./types.ts";
7
7
  import { needsReview } from "./types.ts";
@@ -14,12 +14,23 @@ export class QuestionnaireFlow {
14
14
  private mode: FlowMode = "answering";
15
15
  private terminalState: TerminalState | null = null;
16
16
 
17
- constructor(public readonly questions: NormalizedQuestion[]) {
17
+ constructor(
18
+ public readonly questions: NormalizedQuestion[],
19
+ public readonly allowSkip = false,
20
+ ) {
18
21
  if (questions.length === 0) {
19
22
  throw new Error("QuestionnaireFlow requires at least one question.");
20
23
  }
21
24
  }
22
25
 
26
+ get hasOptionalQuestions(): boolean {
27
+ return this.questions.some((q) => !q.required);
28
+ }
29
+
30
+ get showSkip(): boolean {
31
+ return this.allowSkip || this.hasOptionalQuestions;
32
+ }
33
+
23
34
  get currentIndex(): number {
24
35
  return this.index;
25
36
  }
@@ -48,6 +59,10 @@ export class QuestionnaireFlow {
48
59
  return this.questions.every((q) => this.answers.has(q.id));
49
60
  }
50
61
 
62
+ allRequiredAnswered(): boolean {
63
+ return this.questions.every((q) => !q.required || this.answers.has(q.id));
64
+ }
65
+
51
66
  setAnswer(answer: Answer): void {
52
67
  this.answers.set(answer.questionId, normalizeAnswer(answer));
53
68
  }
@@ -55,7 +70,7 @@ export class QuestionnaireFlow {
55
70
  advance(): boolean {
56
71
  if (this.mode !== "answering") return false;
57
72
  const current = this.currentQuestion;
58
- if (current && !this.answers.has(current.id)) return false;
73
+ if (current?.required && !this.answers.has(current.id)) return false;
59
74
  if (this.index < this.questions.length - 1) {
60
75
  this.index += 1;
61
76
  return true;
@@ -69,7 +84,7 @@ export class QuestionnaireFlow {
69
84
  }
70
85
 
71
86
  goBack(): boolean {
72
- if (this.mode === "terminal") return false;
87
+ if (!this.guardNotTerminal()) return false;
73
88
  if (this.mode === "reviewing") {
74
89
  this.mode = "answering";
75
90
  this.index = this.questions.length - 1;
@@ -83,28 +98,35 @@ export class QuestionnaireFlow {
83
98
  }
84
99
 
85
100
  enterReview(): boolean {
86
- if (this.mode === "terminal") return false;
101
+ if (!this.guardNotTerminal()) return false;
87
102
  if (!needsReview(this.questions)) return false;
88
- if (!this.allAnswered()) return false;
103
+ if (!this.allRequiredAnswered()) return false;
89
104
  this.mode = "reviewing";
90
105
  return true;
91
106
  }
92
107
 
93
108
  submit(): boolean {
94
- if (this.mode === "terminal") return false;
95
- if (!this.allAnswered()) return false;
109
+ if (!this.guardNotTerminal()) return false;
110
+ if (!this.allRequiredAnswered()) return false;
96
111
  this.markSubmitted();
97
112
  return true;
98
113
  }
99
114
 
115
+ skip(): boolean {
116
+ if (!this.guardNotTerminal()) return false;
117
+ this.mode = "terminal";
118
+ this.terminalState = "skipped";
119
+ return true;
120
+ }
121
+
100
122
  cancel(): void {
101
- if (this.mode === "terminal") return;
123
+ if (!this.guardNotTerminal()) return;
102
124
  this.mode = "terminal";
103
125
  this.terminalState = "cancelled";
104
126
  }
105
127
 
106
128
  abort(): void {
107
- if (this.mode === "terminal") return;
129
+ if (!this.guardNotTerminal()) return;
108
130
  this.mode = "terminal";
109
131
  this.terminalState = "aborted";
110
132
  }
@@ -117,10 +139,18 @@ export class QuestionnaireFlow {
117
139
  const state = this.terminalState ?? "cancelled";
118
140
  return {
119
141
  terminalState: state,
120
- answers: state === "submitted" ? this.collectAnswers() : [...this.answers.values()],
142
+ answers:
143
+ state === "submitted" || state === "skipped"
144
+ ? this.collectAnswers()
145
+ : [...this.answers.values()],
146
+ ...(state === "skipped" ? { skipped: true } : {}),
121
147
  };
122
148
  }
123
149
 
150
+ private guardNotTerminal(): boolean {
151
+ return this.mode !== "terminal";
152
+ }
153
+
124
154
  private markSubmitted(): void {
125
155
  this.mode = "terminal";
126
156
  this.terminalState = "submitted";
@@ -136,20 +166,10 @@ export class QuestionnaireFlow {
136
166
 
137
167
  function normalizeAnswer(answer: Answer): Answer {
138
168
  switch (answer.source) {
139
- case "option":
169
+ case "choice":
140
170
  return {
141
171
  questionId: answer.questionId,
142
- source: "option",
143
- value: answer.value.trim(),
144
- optionIndex: answer.optionIndex,
145
- note: trimOptional(answer.note),
146
- };
147
- case "options":
148
- return {
149
- questionId: answer.questionId,
150
- source: "options",
151
- values: answer.values.map((value) => value.trim()),
152
- optionIndexes: [...answer.optionIndexes],
172
+ source: "choice",
153
173
  selections: answer.selections.map((selection) => ({
154
174
  value: selection.value.trim(),
155
175
  optionIndex: selection.optionIndex,
@@ -174,14 +194,6 @@ function normalizeAnswer(answer: Answer): Answer {
174
194
  source: "text",
175
195
  value: answer.value.trim(),
176
196
  };
177
- case "yesno":
178
- return {
179
- questionId: answer.questionId,
180
- source: "yesno",
181
- value: answer.value,
182
- optionIndex: answer.optionIndex,
183
- note: trimOptional(answer.note),
184
- };
185
197
  }
186
198
  }
187
199