@xynogen/pix-ask 0.1.1 → 0.1.3

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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +91 -84
  3. package/src/once.ts +26 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ask",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Pi tool — structured questionnaire UI (ask_user)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
 
4
4
  import { buildResponseText } from "./helpers.js";
5
+ import { once } from "./once.ts";
5
6
  import { AskQuestionnaire } from "./questionnaire.js";
6
7
  import { rpcFallback } from "./rpc.js";
7
8
  import type { Params } from "./schema.js";
@@ -33,98 +34,104 @@ export type {
33
34
  // ── Tool registration ──────────────────────────────────────────────────
34
35
 
35
36
  export default function registerAsk(pi: ExtensionAPI): void {
36
- pi.registerTool({
37
- name: "ask_user",
38
- label: "Ask",
39
- description: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous.`,
40
- promptSnippet: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`,
41
- promptGuidelines: [
42
- `Use ask whenever the user's request is underspecified and you cannot proceed without concrete decisions — you can ask up to ${MAX_QUESTIONS} questions per invocation.`,
43
- `Each question MUST have ${MIN_OPTIONS}-${MAX_OPTIONS} options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer ("${SENTINEL_FREEFORM}" row is appended automatically to single-select questions) or pick "${SENTINEL_CHAT}" to abandon the questionnaire.`,
44
- `Set multiSelect: true when multiple answers are valid; this suppresses the "${SENTINEL_FREEFORM}" row. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) — single-select only. NOTE: any non-empty preview on a single-select question ALSO suppresses the "${SENTINEL_FREEFORM}" row (no room in the side-by-side layout); "${SENTINEL_CHAT}" remains the escape hatch. If you recommend a specific option, make it the first option and append "(Recommended)" to its label.`,
45
- "Do not stack multiple ask calls back-to-backgroup all clarifying questions into one invocation.",
46
- ],
47
- executionMode: "sequential",
48
- parameters: ParamsSchema,
37
+ once(pi, "pix-ask", () => {
38
+ pi.registerTool({
39
+ name: "ask_user",
40
+ label: "Ask",
41
+ description: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous.`,
42
+ promptSnippet: `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`,
43
+ promptGuidelines: [
44
+ `Use ask whenever the user's request is underspecified and you cannot proceed without concrete decisions you can ask up to ${MAX_QUESTIONS} questions per invocation.`,
45
+ `Each question MUST have ${MIN_OPTIONS}-${MAX_OPTIONS} options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer ("${SENTINEL_FREEFORM}" row is appended automatically to single-select questions) or pick "${SENTINEL_CHAT}" to abandon the questionnaire.`,
46
+ `Set multiSelect: true when multiple answers are valid; this suppresses the "${SENTINEL_FREEFORM}" row. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) single-select only. NOTE: any non-empty preview on a single-select question ALSO suppresses the "${SENTINEL_FREEFORM}" row (no room in the side-by-side layout); "${SENTINEL_CHAT}" remains the escape hatch. If you recommend a specific option, make it the first option and append "(Recommended)" to its label.`,
47
+ "Do not stack multiple ask calls back-to-back — group all clarifying questions into one invocation.",
48
+ ],
49
+ executionMode: "sequential",
50
+ parameters: ParamsSchema,
49
51
 
50
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
51
- if (signal?.aborted) {
52
- return {
53
- content: [{ type: "text", text: "Cancelled" }],
54
- details: { answers: [], cancelled: true },
55
- };
56
- }
52
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
53
+ if (signal?.aborted) {
54
+ return {
55
+ content: [{ type: "text", text: "Cancelled" }],
56
+ details: { answers: [], cancelled: true },
57
+ };
58
+ }
57
59
 
58
- const typed = params as unknown as Params;
60
+ const typed = params as unknown as Params;
59
61
 
60
- if (!Array.isArray(typed.questions) || typed.questions.length === 0) {
61
- return {
62
- content: [
63
- { type: "text", text: "At least one question is required." },
64
- ],
65
- isError: true,
66
- details: { answers: [], cancelled: true },
67
- };
68
- }
62
+ if (!Array.isArray(typed.questions) || typed.questions.length === 0) {
63
+ return {
64
+ content: [
65
+ { type: "text", text: "At least one question is required." },
66
+ ],
67
+ isError: true,
68
+ details: { answers: [], cancelled: true },
69
+ };
70
+ }
69
71
 
70
- if (!ctx.hasUI) {
71
- const result = await rpcFallback(ctx.ui, typed);
72
- const text = result.cancelled
73
- ? "User cancelled the questionnaire"
74
- : buildResponseText(result.answers, typed.questions);
75
- return { content: [{ type: "text", text }], details: result };
76
- }
72
+ if (!ctx.hasUI) {
73
+ const result = await rpcFallback(ctx.ui, typed);
74
+ const text = result.cancelled
75
+ ? "User cancelled the questionnaire"
76
+ : buildResponseText(result.answers, typed.questions);
77
+ return { content: [{ type: "text", text }], details: result };
78
+ }
77
79
 
78
- const result = await ctx.ui.custom<QuestionnaireResult | null>(
79
- (tui, theme, keybindings, done) => {
80
- if (signal) {
81
- signal.addEventListener(
82
- "abort",
83
- () => done({ answers: [], cancelled: true }),
84
- { once: true },
85
- );
86
- }
87
- return new AskQuestionnaire(typed, tui, theme, keybindings, done);
88
- },
89
- );
80
+ const result = await ctx.ui.custom<QuestionnaireResult | null>(
81
+ (tui, theme, keybindings, done) => {
82
+ if (signal) {
83
+ signal.addEventListener(
84
+ "abort",
85
+ () => done({ answers: [], cancelled: true }),
86
+ { once: true },
87
+ );
88
+ }
89
+ return new AskQuestionnaire(typed, tui, theme, keybindings, done);
90
+ },
91
+ );
90
92
 
91
- if (!result || result.cancelled) {
92
- return {
93
- content: [{ type: "text", text: "User cancelled the questionnaire" }],
94
- details: result ?? { answers: [], cancelled: true },
95
- };
96
- }
93
+ if (!result || result.cancelled) {
94
+ return {
95
+ content: [
96
+ { type: "text", text: "User cancelled the questionnaire" },
97
+ ],
98
+ details: result ?? { answers: [], cancelled: true },
99
+ };
100
+ }
97
101
 
98
- const text = buildResponseText(result.answers, typed.questions);
99
- return { content: [{ type: "text", text }], details: result };
100
- },
102
+ const text = buildResponseText(result.answers, typed.questions);
103
+ return { content: [{ type: "text", text }], details: result };
104
+ },
101
105
 
102
- renderCall(args, theme) {
103
- const questions = Array.isArray(args.questions) ? args.questions : [];
104
- const count = questions.length;
105
- const firstQ = (questions[0]?.question ?? "") as string;
106
- let text = theme.fg("toolTitle", theme.bold(`ask (${count}) `));
107
- text += theme.fg("muted", firstQ);
108
- if (count > 1) text += theme.fg("dim", ` +${count - 1} more`);
109
- return new Text(text, 0, 0);
110
- },
106
+ renderCall(args, theme) {
107
+ const questions = Array.isArray(args.questions) ? args.questions : [];
108
+ const count = questions.length;
109
+ const firstQ = (questions[0]?.question ?? "") as string;
110
+ let text = theme.fg("toolTitle", theme.bold(`ask (${count}) `));
111
+ text += theme.fg("muted", firstQ);
112
+ if (count > 1) text += theme.fg("dim", ` +${count - 1} more`);
113
+ return new Text(text, 0, 0);
114
+ },
111
115
 
112
- renderResult(result, options, theme) {
113
- const details = result.details as
114
- | { answers?: QuestionAnswer[]; cancelled?: boolean }
115
- | undefined;
116
- if (options.isPartial) {
117
- return new Text(theme.fg("muted", "Waiting for user input..."), 0, 0);
118
- }
119
- if (!details || details.cancelled || !details.answers?.length) {
120
- return new Text(theme.fg("warning", "Cancelled"), 0, 0);
121
- }
122
- const texts = details.answers.map((a) => {
123
- const v =
124
- a.kind === "multi" ? (a.selected ?? []).join(", ") : (a.answer ?? "");
125
- return `${a.questionIndex + 1}: ${v}`;
126
- });
127
- return new Text(theme.fg("success", `✓ ${texts.join(" • ")}`), 0, 0);
128
- },
116
+ renderResult(result, options, theme) {
117
+ const details = result.details as
118
+ | { answers?: QuestionAnswer[]; cancelled?: boolean }
119
+ | undefined;
120
+ if (options.isPartial) {
121
+ return new Text(theme.fg("muted", "Waiting for user input..."), 0, 0);
122
+ }
123
+ if (!details || details.cancelled || !details.answers?.length) {
124
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
125
+ }
126
+ const texts = details.answers.map((a) => {
127
+ const v =
128
+ a.kind === "multi"
129
+ ? (a.selected ?? []).join(", ")
130
+ : (a.answer ?? "");
131
+ return `${a.questionIndex + 1}: ${v}`;
132
+ });
133
+ return new Text(theme.fg("success", `✓ ${texts.join(" • ")}`), 0, 0);
134
+ },
135
+ });
129
136
  });
130
137
  }
package/src/once.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Per-instance idempotency guard for extension activation.
3
+ *
4
+ * pix-core (the meta-package) invokes this package's factory, and a standalone
5
+ * install makes Pi invoke it again — sometimes against the SAME `pi`. We must
6
+ * dedupe that. But Pi rebuilds the extension runtime on /new, /resume, /fork,
7
+ * and /reload, handing the factory a BRAND-NEW `pi`; that must re-register.
8
+ *
9
+ * Keying the registry on the `pi` instance satisfies both: same instance =>
10
+ * skip, new instance => run. The registry lives on globalThis because jiti
11
+ * (`moduleCache: false`) re-evaluates this module on every load pass, so a
12
+ * module-scoped WeakMap would not be shared between the aggregator pass and the
13
+ * standalone pass within a single session.
14
+ */
15
+ export function once(pi: object, key: string, fn: () => void): void {
16
+ const g = globalThis as { __pixOnce?: WeakMap<object, Set<string>> };
17
+ const registry = (g.__pixOnce ??= new WeakMap<object, Set<string>>());
18
+ let loaded = registry.get(pi);
19
+ if (!loaded) {
20
+ loaded = new Set<string>();
21
+ registry.set(pi, loaded);
22
+ }
23
+ if (loaded.has(key)) return;
24
+ loaded.add(key);
25
+ fn();
26
+ }