@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.
- package/README.md +115 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
- package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +14 -7
- package/src/ask-user.ts +191 -0
- package/{flow.ts → src/flow.ts} +45 -33
- package/src/format.ts +66 -0
- package/src/index.ts +1 -0
- package/src/normalize.ts +229 -0
- package/{ui-rich-render-editor.ts → src/render/ui-rich-render-editor.ts} +18 -16
- package/src/render/ui-rich-render-env.ts +15 -0
- package/src/render/ui-rich-render-footer.ts +55 -0
- package/src/render/ui-rich-render-markdown.ts +33 -0
- package/{ui-rich-render-notes.ts → src/render/ui-rich-render-notes.ts} +20 -27
- package/src/render/ui-rich-render-types.ts +17 -0
- package/src/render/ui-rich-render.ts +323 -0
- package/{render.ts → src/render.ts} +10 -5
- package/{result.ts → src/result.ts} +27 -6
- package/{schema.ts → src/schema.ts} +46 -44
- package/{types.ts → src/types.ts} +20 -53
- package/{ui-rich-handlers.ts → src/ui/ui-rich-handlers.ts} +100 -44
- package/{ui-rich-inline.ts → src/ui/ui-rich-inline.ts} +23 -10
- package/{ui-rich-state.ts → src/ui/ui-rich-state.ts} +52 -15
- package/{ui-rich.ts → src/ui/ui-rich.ts} +36 -11
- package/ask-user.ts +0 -131
- package/format.ts +0 -95
- package/normalize.ts +0 -218
- package/ui-fallback.ts +0 -274
- 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.
|
|
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
|
-
"
|
|
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
|
-
"@
|
|
24
|
-
"@
|
|
25
|
-
"
|
|
30
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
31
|
+
"@earendil-works/pi-tui": "*",
|
|
32
|
+
"typebox": "*"
|
|
26
33
|
},
|
|
27
34
|
"devDependencies": {
|
|
28
|
-
"vitest": "
|
|
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
|
}
|
package/src/ask-user.ts
ADDED
|
@@ -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 };
|
package/{flow.ts → src/flow.ts}
RENAMED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Shared questionnaire flow state used by
|
|
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`)
|
|
4
|
-
//
|
|
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(
|
|
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.
|
|
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.
|
|
101
|
+
if (!this.guardNotTerminal()) return false;
|
|
87
102
|
if (!needsReview(this.questions)) return false;
|
|
88
|
-
if (!this.
|
|
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.
|
|
95
|
-
if (!this.
|
|
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.
|
|
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.
|
|
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:
|
|
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 "
|
|
169
|
+
case "choice":
|
|
140
170
|
return {
|
|
141
171
|
questionId: answer.questionId,
|
|
142
|
-
source: "
|
|
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
|
|