@mrclrchtr/supi-ask-user 1.3.1 → 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.
Files changed (51) hide show
  1. package/README.md +163 -67
  2. package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +15 -13
  5. package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
  6. package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/index.ts +15 -13
  9. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +42 -10
  11. package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
  12. package/package.json +2 -2
  13. package/src/api.ts +19 -0
  14. package/src/ask-user.ts +71 -131
  15. package/src/index.ts +23 -1
  16. package/src/normalize.ts +153 -142
  17. package/src/render/result.ts +102 -0
  18. package/src/render/transcript.ts +65 -0
  19. package/src/render/tree-summary.ts +10 -0
  20. package/src/schema.ts +41 -38
  21. package/src/session/controller.ts +281 -0
  22. package/src/session/lock.ts +19 -0
  23. package/src/tool/guidance.ts +15 -0
  24. package/src/types.ts +56 -55
  25. package/src/ui/choose-renderer.ts +11 -0
  26. package/src/ui/overlay-actions.ts +42 -0
  27. package/src/ui/overlay-component.ts +400 -0
  28. package/src/ui/overlay-render.ts +219 -0
  29. package/src/ui/overlay-view.ts +313 -0
  30. package/src/ui/overlay.ts +28 -0
  31. package/src/ui/types.ts +38 -0
  32. package/src/flow.ts +0 -224
  33. package/src/format.ts +0 -66
  34. package/src/render/ui-rich-render-editor.ts +0 -51
  35. package/src/render/ui-rich-render-env.ts +0 -15
  36. package/src/render/ui-rich-render-footer.ts +0 -55
  37. package/src/render/ui-rich-render-markdown.ts +0 -33
  38. package/src/render/ui-rich-render-notes.ts +0 -80
  39. package/src/render/ui-rich-render-types.ts +0 -17
  40. package/src/render/ui-rich-render.ts +0 -323
  41. package/src/render.ts +0 -95
  42. package/src/result.ts +0 -90
  43. package/src/ui/ui-rich-handlers.ts +0 -369
  44. package/src/ui/ui-rich-inline.ts +0 -77
  45. package/src/ui/ui-rich-state.ts +0 -179
  46. package/src/ui/ui-rich.ts +0 -144
  47. /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  48. /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  49. /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  50. /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  51. /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
@@ -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
+ }
@@ -4,7 +4,7 @@
4
4
  // factory function. The generic settings UI reads them via `getRegisteredSettings()`.
5
5
 
6
6
  import type { SettingItem } from "@earendil-works/pi-tui";
7
- import { createRegistry } from "./registry-utils.ts";
7
+ import { createRegistry } from "../registry-utils.ts";
8
8
 
9
9
  export type SettingsScope = "project" | "global";
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-ask-user",
3
- "version": "1.3.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.3.1"
24
+ "@mrclrchtr/supi-core": "1.5.0"
25
25
  },
26
26
  "bundledDependencies": [
27
27
  "@mrclrchtr/supi-core"
package/src/api.ts CHANGED
@@ -1 +1,20 @@
1
1
  export { default } from "./ask-user.ts";
2
+ export { AskUserValidationError, normalizeQuestionnaire } from "./normalize.ts";
3
+ export { AskUserParamsSchema } from "./schema.ts";
4
+ export { AskUserController } from "./session/controller.ts";
5
+ export { ActiveQuestionnaireLock } from "./session/lock.ts";
6
+ export type {
7
+ Answer,
8
+ AskUserDetails,
9
+ AskUserErrorDetails,
10
+ AskUserOutcome,
11
+ AskUserStatus,
12
+ AskUserToolDetails,
13
+ ChoiceAnswer,
14
+ CustomAnswer,
15
+ NormalizedChoiceQuestion,
16
+ NormalizedQuestion,
17
+ NormalizedQuestionnaire,
18
+ NormalizedTextQuestion,
19
+ TextAnswer,
20
+ } from "./types.ts";
package/src/ask-user.ts CHANGED
@@ -1,140 +1,107 @@
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";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
2
  import { formatTitle, signalWaiting } from "@mrclrchtr/supi-core/api";
17
- import { ActiveQuestionnaireLock } from "./flow.ts";
18
3
  import { AskUserValidationError, normalizeQuestionnaire } from "./normalize.ts";
19
- import { renderAskUserCall, renderAskUserResult } from "./render.ts";
20
- import { buildErrorResult, buildResult, type HybridResult } from "./result.ts";
4
+ import { type AskUserToolResult, buildErrorResult, buildResult } from "./render/result.ts";
5
+ import { renderAskUserCall, renderAskUserResult } from "./render/transcript.ts";
6
+ import { buildTreeSummaryLabel } from "./render/tree-summary.ts";
21
7
  import { type AskUserParams, AskUserParamsSchema } from "./schema.ts";
22
- import type { NormalizedQuestionnaire } from "./types.ts";
23
- import { type RichUiHost, runRichQuestionnaire } from "./ui/ui-rich.ts";
8
+ import { ActiveQuestionnaireLock } from "./session/lock.ts";
9
+ import { promptGuidelines, promptSnippet, toolDescription } from "./tool/guidance.ts";
10
+ import type { AskUserToolDetails, NormalizedQuestionnaire } from "./types.ts";
11
+ import { runQuestionnaire } from "./ui/choose-renderer.ts";
24
12
 
25
13
  const TOOL_NAME = "ask_user";
26
14
  const TOOL_LABEL = "Ask User";
27
15
 
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 {
16
+ export type AskUserExecutionContext = Pick<ExtensionContext, "cwd" | "hasUI" | "abort"> & {
47
17
  ui: {
48
- custom?: RichUiHost["custom"];
18
+ custom?: unknown;
19
+ notify?(message: string, type?: "info" | "warning" | "error"): void;
49
20
  setWorkingVisible?(visible: boolean): void;
50
- /** Set the terminal window/tab title. */
51
21
  setTitle?(title: string): void;
52
- /** Show a notification to the user. */
53
- notify?(message: string, type?: "info" | "warning" | "error"): void;
22
+ getToolsExpanded?(): boolean;
23
+ setToolsExpanded?(expanded: boolean): void;
54
24
  };
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
- }
25
+ };
63
26
 
64
27
  export default function askUserExtension(pi: ExtensionAPI): void {
65
28
  const lock = new ActiveQuestionnaireLock();
66
29
 
67
- pi.registerTool({
30
+ pi.registerTool<typeof AskUserParamsSchema, AskUserToolDetails>({
68
31
  name: TOOL_NAME,
69
32
  label: TOOL_LABEL,
70
- description: TOOL_DESCRIPTION,
71
- promptSnippet: PROMPT_SNIPPET,
72
- promptGuidelines: PROMPT_GUIDELINES,
33
+ description: toolDescription,
34
+ promptSnippet,
35
+ promptGuidelines,
73
36
  parameters: AskUserParamsSchema,
74
37
  // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
75
38
  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
- );
39
+ return executeAskUser(params, signal, ctx, lock, pi);
83
40
  },
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,
41
+ renderCall: (args, theme) => renderAskUserCall(args, theme),
42
+ renderResult: (result, _options, theme) => renderAskUserResult(result, theme),
90
43
  });
91
44
  }
92
45
 
93
- // biome-ignore lint/complexity/useMaxParams: pi context + pi reference for appendEntry
94
- async function executeAskUser(
46
+ // biome-ignore lint/complexity/useMaxParams: keep the execution boundary explicit for tests
47
+ export async function executeAskUser(
95
48
  params: AskUserParams,
96
49
  signal: AbortSignal | undefined,
97
- ctx: ExtensionUi,
50
+ ctx: AskUserExecutionContext,
98
51
  lock: ActiveQuestionnaireLock,
99
52
  pi: ExtensionAPI,
100
- ): Promise<HybridResult> {
101
- let normalized: NormalizedQuestionnaire;
53
+ ): Promise<AskUserToolResult> {
54
+ let questionnaire: NormalizedQuestionnaire;
102
55
  try {
103
- normalized = normalizeQuestionnaire(params);
104
- } catch (err) {
105
- if (err instanceof AskUserValidationError) return buildErrorResult(`Error: ${err.message}`);
106
- throw err;
56
+ questionnaire = normalizeQuestionnaire(params);
57
+ } catch (error) {
58
+ if (error instanceof AskUserValidationError) {
59
+ return buildErrorResult(`Error: ${error.message}`);
60
+ }
61
+ throw error;
107
62
  }
63
+
108
64
  if (!ctx.hasUI) {
109
65
  return buildErrorResult(
110
- "Error: ask_user requires interactive UI but the current session has none.",
66
+ "Error: ask_user requires an interactive UI session. No user-facing UI is available in the current mode.",
111
67
  );
112
68
  }
113
69
  if (!lock.acquire()) {
114
70
  return buildErrorResult(
115
- "Error: another ask_user interaction is already in flight. Wait for it to complete before calling ask_user again.",
71
+ "Error: another ask_user form is already in flight. Wait for it to complete before calling ask_user again.",
116
72
  );
117
73
  }
74
+
118
75
  signalAttention(ctx);
119
76
  pi.events.emit("supi:ask-user:start", { source: "supi-ask-user" });
77
+
120
78
  try {
121
- // Hide the built-in working loader so it doesn't compete with the overlay.
122
79
  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
- ) {
80
+ const outcome = await runQuestionnaire(questionnaire, {
81
+ ui: {
82
+ custom: asFunction(ctx.ui.custom),
83
+ notify: ctx.ui.notify,
84
+ },
85
+ signal,
86
+ onToggleToolsExpanded:
87
+ ctx.ui.getToolsExpanded && ctx.ui.setToolsExpanded
88
+ ? () => ctx.ui.setToolsExpanded?.(!ctx.ui.getToolsExpanded?.())
89
+ : undefined,
90
+ });
91
+
92
+ if (outcome === "unsupported") {
93
+ return buildErrorResult(
94
+ "Error: ask_user requires a TUI with custom overlay support. Do not use ask_user in non-interactive or degraded UI sessions.",
95
+ );
96
+ }
97
+
98
+ if (outcome.status === "cancelled" || outcome.status === "aborted") {
128
99
  ctx.abort();
129
100
  }
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;
101
+
102
+ pi.appendEntry(buildTreeSummaryLabel(questionnaire));
103
+ return buildResult(questionnaire, outcome);
136
104
  } finally {
137
- // Restore the working loader regardless of how the overlay closed.
138
105
  ctx.ui.setWorkingVisible?.(true);
139
106
  pi.events.emit("supi:ask-user:end", { source: "supi-ask-user" });
140
107
  restoreTerminalTitle(ctx, pi);
@@ -142,50 +109,23 @@ async function executeAskUser(
142
109
  }
143
110
  }
144
111
 
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`);
112
+ function signalAttention(ctx: AskUserExecutionContext): void {
113
+ signalWaiting(ctx, "pi waiting for your input");
148
114
  }
149
115
 
150
- /** Restore the terminal title to pi's native format (session name + cwd). */
151
- function restoreTerminalTitle(ctx: ExtensionUi, pi: ExtensionAPI): void {
116
+ function restoreTerminalTitle(ctx: AskUserExecutionContext, pi: ExtensionAPI): void {
152
117
  ctx.ui.setTitle?.(formatTitle(pi.getSessionName(), ctx.cwd));
153
118
  }
154
119
 
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);
120
+ function asFunction<T extends (...args: never[]) => unknown>(value: unknown): T | undefined {
121
+ return typeof value === "function" ? (value as T) : undefined;
185
122
  }
186
123
 
187
- export { ActiveQuestionnaireLock, QuestionnaireFlow } from "./flow.ts";
188
- // Re-exports used by tests.
189
124
  export { AskUserValidationError, normalizeQuestionnaire } from "./normalize.ts";
190
- export { buildResult } from "./result.ts";
191
- export { PROMPT_GUIDELINES as askUserPromptGuidelines, PROMPT_SNIPPET as askUserPromptSnippet };
125
+ export { buildErrorResult, buildResult } from "./render/result.ts";
126
+ export { AskUserController } from "./session/controller.ts";
127
+ export { ActiveQuestionnaireLock } from "./session/lock.ts";
128
+ export {
129
+ promptGuidelines as askUserPromptGuidelines,
130
+ promptSnippet as askUserPromptSnippet,
131
+ } from "./tool/guidance.ts";
package/src/index.ts CHANGED
@@ -1 +1,23 @@
1
- export { default } from "./ask-user.ts";
1
+ export type {
2
+ Answer,
3
+ AskUserDetails,
4
+ AskUserErrorDetails,
5
+ AskUserOutcome,
6
+ AskUserStatus,
7
+ AskUserToolDetails,
8
+ ChoiceAnswer,
9
+ CustomAnswer,
10
+ NormalizedChoiceQuestion,
11
+ NormalizedQuestion,
12
+ NormalizedQuestionnaire,
13
+ NormalizedTextQuestion,
14
+ TextAnswer,
15
+ } from "./api.ts";
16
+ export {
17
+ ActiveQuestionnaireLock,
18
+ AskUserController,
19
+ AskUserParamsSchema,
20
+ AskUserValidationError,
21
+ default,
22
+ normalizeQuestionnaire,
23
+ } from "./api.ts";