@pi-archimedes/ask 2.8.0 → 2.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/ask",
3
- "version": "2.8.0",
3
+ "version": "2.9.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "main": "./src/index.ts",
17
17
  "dependencies": {
18
- "@pi-archimedes/core": "2.8.0"
18
+ "@pi-archimedes/core": "2.9.1"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "@earendil-works/pi-coding-agent": ">=0.1.0",
@@ -23,8 +23,8 @@
23
23
  "typebox": ">=1.1.0"
24
24
  },
25
25
  "devDependencies": {
26
- "@earendil-works/pi-coding-agent": "^0.85.1",
27
- "@earendil-works/pi-tui": "^0.85.1",
26
+ "@earendil-works/pi-coding-agent": "^0.87.0",
27
+ "@earendil-works/pi-tui": "^0.87.0",
28
28
  "typebox": "^1.1.38",
29
29
  "typescript": "^6.0.0"
30
30
  },
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { emitBlocked, blockedLabel } from "./herdr.js";
4
+
5
+ // ── blockedLabel ────────────────────────────────────────────────────────────
6
+
7
+ describe("blockedLabel", () => {
8
+ it("uses the first question, collapsed to a single line", () => {
9
+ expect(blockedLabel([{ question: " Which\n\tframework? " }])).toBe("Which framework?");
10
+ });
11
+
12
+ it("truncates long questions at 80 chars with an ellipsis", () => {
13
+ const label = blockedLabel([{ question: "a".repeat(120) }]);
14
+ expect(label).toHaveLength(80);
15
+ expect(label.endsWith("…")).toBe(true);
16
+ });
17
+
18
+ it("keeps 80-char questions intact", () => {
19
+ const q = "a".repeat(80);
20
+ expect(blockedLabel([{ question: q }])).toBe(q);
21
+ });
22
+
23
+ it("falls back to 'question' for a missing or blank first question", () => {
24
+ expect(blockedLabel([])).toBe("question");
25
+ expect(blockedLabel([{ question: " " }])).toBe("question");
26
+ });
27
+ });
28
+
29
+ // ── emitBlocked ─────────────────────────────────────────────────────────────
30
+
31
+ describe("emitBlocked", () => {
32
+ it("emits 'herdr:blocked' on pi.events with the label only when active", () => {
33
+ const emit = vi.fn();
34
+ const pi = { events: { emit } } as unknown as ExtensionAPI;
35
+
36
+ emitBlocked(pi, true, "Which?");
37
+ emitBlocked(pi, false);
38
+
39
+ expect(emit).toHaveBeenCalledTimes(2);
40
+ expect(emit).toHaveBeenNthCalledWith(1, "herdr:blocked", { active: true, label: "Which?" });
41
+ expect(emit).toHaveBeenNthCalledWith(2, "herdr:blocked", { active: false, label: undefined });
42
+ });
43
+ });
package/src/herdr.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * Report the ask dialog to herdr's managed extension (herdr-agent-state.ts).
5
+ *
6
+ * That extension listens for "herdr:blocked" on pi's SHARED event bus
7
+ * (`pi.events` — every extension in the process sees it) and refcounts the
8
+ * blocked state: `active: true` → +1 (message = label), `active: false` → −1.
9
+ * It is TUI-only and inert unless herdr spawned pi with `HERDR_ENV=1` +
10
+ * `HERDR_SOCKET_PATH` + `HERDR_PANE_ID`, so when herdr is not active the
11
+ * event has no live listeners and the emit is a no-op — the same pattern as
12
+ * the `ASK_REQUEST` bus emit (a notify hook). No env gate is needed here.
13
+ *
14
+ * The caller MUST pair every `active: true` with exactly one `active: false`
15
+ * (try/finally) so the refcount never leaks — an unpaired increment would
16
+ * leave the pane stuck at "blocked".
17
+ */
18
+ export function emitBlocked(pi: ExtensionAPI, active: boolean, label?: string): void {
19
+ pi.events.emit("herdr:blocked", { active, label });
20
+ }
21
+
22
+ /**
23
+ * The label herdr shows while the agent is blocked: the first question,
24
+ * collapsed to one line and truncated (herdr renders it in the pane's
25
+ * status line). One pair per dialog, not per question — the dialog is one
26
+ * UI, and the refcount is per pending prompt.
27
+ */
28
+ export function blockedLabel(questions: Array<{ question: string }>): string {
29
+ const question = questions[0]?.question;
30
+ if (!question) return "question";
31
+ const label = question.replace(/\s+/g, " ").trim();
32
+ if (label.length === 0) return "question";
33
+ return label.length > 80 ? `${label.slice(0, 79)}…` : label;
34
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { registerIpcRelay } from "./ipc-relay.js";
3
3
  import { registerAskTool } from "./tool.js";
4
+ export { renderAskCall, renderAskResult } from "./renderer.js";
4
5
 
5
6
  export function registerAsk(pi: ExtensionAPI) {
6
7
  const unsubscribes: Array<() => void> = [];
package/src/ipc-relay.ts CHANGED
@@ -4,6 +4,7 @@ import { getBridge } from "@pi-archimedes/core/bridge";
4
4
  import { type AskQuestion, type AskSelection } from "./selection.js";
5
5
  import { askSingleQuestionWithInlineNote } from "./picker.js";
6
6
  import { askQuestionsWithTabs } from "./dialog.js";
7
+ import { emitBlocked, blockedLabel } from "./herdr.js";
7
8
 
8
9
  export function registerIpcRelay(
9
10
  pi: ExtensionAPI,
@@ -31,7 +32,7 @@ export function registerIpcRelay(
31
32
 
32
33
  // Defer to next tick so TUI can process current state
33
34
  await new Promise((resolve) => setImmediate(resolve));
34
- await handleAskRequest(data, getCtx);
35
+ await handleAskRequest(data, getCtx, (active, label) => emitBlocked(pi, active, label));
35
36
  });
36
37
  unsubscribes.push(unsubAskRequest);
37
38
  }
@@ -42,6 +43,7 @@ async function handleAskRequest(
42
43
  questions: AskQuestion[];
43
44
  },
44
45
  getCtx: () => ExtensionContext | undefined,
46
+ reportBlocked: (active: boolean, label?: string) => void,
45
47
  ): Promise<void> {
46
48
  const ctx = getCtx();
47
49
  if (!ctx?.ui) return;
@@ -50,6 +52,12 @@ async function handleAskRequest(
50
52
  let cancelled = true;
51
53
  let selections: AskSelection[] = [];
52
54
 
55
+ // Report to herdr's managed extension: the PARENT's TUI is the surface this
56
+ // subagent's dialog appears on (the child process is headless and its own
57
+ // herdr extension is inert). Paired in the finally — the existing catch
58
+ // converts a picker error into a cancelled result, so the dialog always
59
+ // settles.
60
+ reportBlocked(true, blockedLabel(questions));
53
61
  try {
54
62
  if (questions.length === 1) {
55
63
  const q = questions[0]!;
@@ -77,6 +85,8 @@ async function handleAskRequest(
77
85
  } catch {
78
86
  cancelled = true;
79
87
  selections = questions.map(() => ({ selectedOptions: [] }));
88
+ } finally {
89
+ reportBlocked(false);
80
90
  }
81
91
 
82
92
  // Build results matching the request's questions
@@ -0,0 +1,225 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { renderAskCall, renderAskResult } from "./renderer.js";
4
+
5
+ // Mocking dependencies if necessary
6
+ vi.mock("@pi-archimedes/core/tool-render", () => ({
7
+ renderToolHeader: (name: string) => `Header:${name}`,
8
+ renderStatusLabel: (status: string, message: string) => `[${status}] ${message}`,
9
+ }));
10
+
11
+ function createMockText() {
12
+ const text = new Text("", 0, 0) as any;
13
+ text._text = "";
14
+ text.setText = (t: string) => { text._text = t; };
15
+ text.getText = () => text._text;
16
+ return text;
17
+ }
18
+
19
+ const mockTheme = {
20
+ fg: (color: string, text: string) => `${color}:${text}`,
21
+ } as any;
22
+
23
+ describe("renderer", () => {
24
+ it("renderAskCall renders header", () => {
25
+ const text = createMockText();
26
+ renderAskCall({}, mockTheme, { lastComponent: text });
27
+ expect(text.getText()).toBe("Header:ask");
28
+ });
29
+
30
+ it("renderAskResult with isPartial: true renders waiting", () => {
31
+ const text = createMockText();
32
+ renderAskResult({}, { isPartial: true }, mockTheme, { lastComponent: text });
33
+ expect(text.getText()).toBe("[running] waiting for input...");
34
+ });
35
+
36
+ it("renderAskResult completed single question renders correct selection", () => {
37
+ const text = createMockText();
38
+ const result = {
39
+ details: {
40
+ results: [{
41
+ id: "test",
42
+ question: "q",
43
+ options: ["A", "B"],
44
+ multi: false,
45
+ selectedOptions: ["A"],
46
+ }]
47
+ }
48
+ };
49
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
50
+ expect(text.getText()).toBe("[success] A");
51
+ });
52
+
53
+ it("renderAskResult completed multi-select renders [A, B]", () => {
54
+ const text = createMockText();
55
+ const result = {
56
+ details: {
57
+ results: [{
58
+ id: "test",
59
+ question: "q",
60
+ options: ["A", "B"],
61
+ multi: true,
62
+ selectedOptions: ["A", "B"],
63
+ }]
64
+ }
65
+ };
66
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
67
+ expect(text.getText()).toBe("[success] [A, B]");
68
+ });
69
+
70
+ it("renderAskResult completed custom input renders custom note", () => {
71
+ const text = createMockText();
72
+ const result = {
73
+ details: {
74
+ results: [{
75
+ id: "test",
76
+ question: "q",
77
+ options: ["A"],
78
+ multi: false,
79
+ selectedOptions: [],
80
+ customInput: "custom note"
81
+ }]
82
+ }
83
+ };
84
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
85
+ expect(text.getText()).toBe("[success] \"custom note\"");
86
+ });
87
+
88
+ it("renderAskResult completed combo choice + custom input renders A + Other", () => {
89
+ const text = createMockText();
90
+ const result = {
91
+ details: {
92
+ results: [{
93
+ id: "test",
94
+ question: "q",
95
+ options: ["A"],
96
+ multi: false,
97
+ selectedOptions: ["A"],
98
+ customInput: "custom note"
99
+ }]
100
+ }
101
+ };
102
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
103
+ expect(text.getText()).toBe("[success] A + Other: \"custom note\"");
104
+ });
105
+
106
+ it("renderAskResult completed multiple questions renders comma separated", () => {
107
+ const text = createMockText();
108
+ const result = {
109
+ details: {
110
+ results: [
111
+ { id: "auth", question: "q", options: ["A"], multi: false, selectedOptions: ["OAuth"] },
112
+ { id: "cache", question: "q", options: ["B"], multi: false, selectedOptions: ["Redis"] }
113
+ ]
114
+ }
115
+ };
116
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
117
+ expect(text.getText()).toBe("[success] auth: OAuth, cache: Redis");
118
+ });
119
+
120
+ it("renderAskResult sanitizes multiline and control characters in collapsed status row", () => {
121
+ const text = createMockText();
122
+ const result = {
123
+ details: {
124
+ results: [{
125
+ id: "note\n1",
126
+ question: "q",
127
+ options: ["Option\r\nLine"],
128
+ multi: false,
129
+ selectedOptions: ["Option\r\nLine\x00"],
130
+ customInput: "Line 1\nLine 2\t\x07extra"
131
+ }]
132
+ }
133
+ };
134
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
135
+ expect(text.getText()).toBe("[success] Option Line + Other: \"Line 1 Line 2 extra\"");
136
+ });
137
+
138
+ it("renderAskResult cancelled renders (cancelled)", () => {
139
+ const text = createMockText();
140
+ const result = {
141
+ content: [{ type: "text", text: "User cancelled the question." }],
142
+ details: { results: [] }
143
+ };
144
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
145
+ expect(text.getText()).toBe("[error] (cancelled)");
146
+ });
147
+
148
+ it("renderAskResult error renders failed", () => {
149
+ const text = createMockText();
150
+ renderAskResult({}, {}, mockTheme, { lastComponent: text, isError: true });
151
+ expect(text.getText()).toBe("[error] failed");
152
+ });
153
+
154
+ it("renderAskResult expanded renders clean Q&A breakdown", () => {
155
+ const text = createMockText();
156
+ const result = {
157
+ details: {
158
+ results: [{
159
+ id: "test",
160
+ question: "Question prompt?",
161
+ options: ["A"],
162
+ multi: false,
163
+ selectedOptions: ["A"],
164
+ }]
165
+ }
166
+ };
167
+ renderAskResult(result, { expanded: true }, mockTheme, { lastComponent: text });
168
+ expect(text.getText()).toContain("dim:Question prompt?");
169
+ expect(text.getText()).toContain("success:✓");
170
+ expect(text.getText()).toContain("toolOutput:A");
171
+ });
172
+
173
+ it("renderAskResult expanded renders cancelled state", () => {
174
+ const text = createMockText();
175
+ const result = {
176
+ content: [{ type: "text", text: "User cancelled the question." }],
177
+ details: { results: [] }
178
+ };
179
+ renderAskResult(result, { expanded: true }, mockTheme, { lastComponent: text });
180
+ expect(text.getText()).toBe("error:✗ (cancelled)");
181
+ });
182
+
183
+ it("renderAskResult expanded renders error state", () => {
184
+ const text = createMockText();
185
+ renderAskResult({}, { expanded: true }, mockTheme, { lastComponent: text, isError: true });
186
+ expect(text.getText()).toBe("error:failed");
187
+ });
188
+
189
+ it("renderAskResult expanded renders empty results", () => {
190
+ const text = createMockText();
191
+ const result = { details: { results: [] } };
192
+ renderAskResult(result, { expanded: true }, mockTheme, { lastComponent: text });
193
+ expect(text.getText()).toBe("error:failed");
194
+ });
195
+
196
+ it("renderAskResult legacy details format", () => {
197
+ const text = createMockText();
198
+ const result = {
199
+ details: {
200
+ id: "test",
201
+ question: "q",
202
+ }
203
+ };
204
+ renderAskResult(result, {}, mockTheme, { lastComponent: text });
205
+ expect(text.getText()).toBe("[error] (cancelled)");
206
+ });
207
+
208
+ it("renderAskCall catch fallback", () => {
209
+ const text = createMockText();
210
+ const theme = { fg: () => { throw new Error(); } } as any;
211
+ renderAskCall({}, theme, { lastComponent: text });
212
+ expect(text.getText()).toBe("Header:ask");
213
+ });
214
+
215
+ it("renderAskResult catch fallback", () => {
216
+ const text = createMockText();
217
+ renderAskResult(null as any, {}, { fg: () => { throw new Error(); } } as any, { lastComponent: text });
218
+ expect(text.getText()).toBe("");
219
+ });
220
+
221
+ it("renderAskResult reuseText fallback", () => {
222
+ const text = renderAskResult({}, {}, mockTheme, { lastComponent: { something: "else" } });
223
+ expect(text).toBeInstanceOf(Text);
224
+ });
225
+ });
@@ -0,0 +1,114 @@
1
+ import { Text } from "@earendil-works/pi-tui";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import { renderToolHeader, renderStatusLabel } from "@pi-archimedes/core/tool-render";
5
+ import { formatSelectionForSummary, type QuestionResult } from "./tool.js";
6
+
7
+ export type RenderContext = {
8
+ lastComponent?: Component;
9
+ isError?: boolean;
10
+ expanded?: boolean;
11
+ isPartial?: boolean;
12
+ };
13
+
14
+ export type RenderOptions = {
15
+ expanded?: boolean;
16
+ isPartial?: boolean;
17
+ };
18
+
19
+ function reuseText(context?: RenderContext): Text {
20
+ return (context?.lastComponent instanceof Text
21
+ ? context.lastComponent
22
+ : new Text("", 0, 0)) as Text;
23
+ }
24
+
25
+ function extractQuestionResults(result: unknown, context?: RenderContext): { results: QuestionResult[]; isCancelled: boolean; isError: boolean; errorMessage: string | undefined } {
26
+ const r = result as { content?: Array<{ type: string; text?: string }>; details?: { results?: QuestionResult[]; id?: string; question?: string; description?: string; options?: string[]; multi?: boolean; selectedOptions?: string[]; customInput?: string } };
27
+
28
+ let results: QuestionResult[] = [];
29
+ if (r.details?.results && r.details.results.length > 0) {
30
+ results = r.details.results;
31
+ } else if (r.details?.id && r.details?.question) {
32
+ results = [{
33
+ id: r.details.id,
34
+ question: r.details.question,
35
+ description: r.details.description,
36
+ options: r.details.options ?? [],
37
+ multi: Boolean(r.details.multi),
38
+ selectedOptions: r.details.selectedOptions ?? [],
39
+ customInput: r.details.customInput,
40
+ }];
41
+ }
42
+
43
+ const isCancelled = (results.length > 0 && results.every(res => res.selectedOptions.length === 0 && !res.customInput)) ||
44
+ (r.content?.some(c => c.text?.match(/user cancelled the question/i)) ?? false);
45
+
46
+ const isError = context?.isError === true || results.length === 0;
47
+ const errorMessage = isError ? (r.content?.[0]?.text ?? "failed") : undefined;
48
+
49
+ return { results, isCancelled, isError, errorMessage };
50
+ }
51
+
52
+ export function formatExpandedBreakdown(results: QuestionResult[], isCancelled: boolean, theme: Theme): string {
53
+ if (isCancelled) return theme.fg("error", "✗ (cancelled)");
54
+ if (results.length === 0) return theme.fg("muted", "(no details)");
55
+
56
+ return results.map(q =>
57
+ `${theme.fg("dim", q.question)}\n ${theme.fg("success", "✓")} ${theme.fg("toolOutput", formatSelectionForSummary(q))}`
58
+ ).join("\n\n");
59
+ }
60
+
61
+ function sanitizeForStatusRow(value: string): string {
62
+ return value
63
+ .replace(/[\r\n\t]/g, " ")
64
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
65
+ .replace(/\s{2,}/g, " ")
66
+ .trim();
67
+ }
68
+
69
+ export function renderAskCall(args: unknown, theme: Theme, context?: unknown): Text {
70
+ const text = reuseText(context as RenderContext | undefined);
71
+ try {
72
+ text.setText(renderToolHeader("ask", undefined, theme));
73
+ } catch {
74
+ text.setText("ask");
75
+ }
76
+ return text;
77
+ }
78
+
79
+ export function renderAskResult(result: unknown, options: RenderOptions = {}, theme: Theme, context?: unknown): Text {
80
+ const text = reuseText(context as RenderContext | undefined);
81
+ const ctx = context as RenderContext | undefined;
82
+
83
+ try {
84
+ const expanded = options?.expanded ?? ctx?.expanded ?? false;
85
+ const isPartial = options?.isPartial ?? ctx?.isPartial ?? false;
86
+ const { results, isCancelled, isError, errorMessage } = extractQuestionResults(result, ctx);
87
+ if (expanded) {
88
+ if (isCancelled) {
89
+ text.setText(formatExpandedBreakdown(results, true, theme));
90
+ } else if (isError) {
91
+ text.setText(theme.fg("error", errorMessage ?? "failed"));
92
+ } else {
93
+ text.setText(formatExpandedBreakdown(results, false, theme));
94
+ }
95
+ } else {
96
+ if (isPartial) text.setText(renderStatusLabel("running", "waiting for input...", theme));
97
+ else if (isCancelled) text.setText(renderStatusLabel("error", "(cancelled)", theme));
98
+ else if (isError) text.setText(renderStatusLabel("error", sanitizeForStatusRow(errorMessage ?? "failed"), theme));
99
+ else if (results.length === 1) {
100
+ const first = results[0];
101
+ const label = sanitizeForStatusRow(first ? formatSelectionForSummary(first) : "(unknown)");
102
+ text.setText(renderStatusLabel("success", label, theme));
103
+ } else if (results.length > 1) {
104
+ const summary = sanitizeForStatusRow(results.map((r) => `${r.id}: ${formatSelectionForSummary(r)}`).join(", "));
105
+ text.setText(renderStatusLabel("success", summary, theme));
106
+ } else {
107
+ text.setText(renderStatusLabel("error", "failed", theme));
108
+ }
109
+ }
110
+ } catch {
111
+ text.setText("");
112
+ }
113
+ return text;
114
+ }
package/src/tool.test.ts CHANGED
@@ -8,6 +8,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
8
8
  import { Events } from "@pi-archimedes/core/bus";
9
9
  import { registerAskTool } from "./tool.js";
10
10
  import { registerIpcRelay } from "./ipc-relay.js";
11
+ import { renderAskCall, renderAskResult } from "./renderer.js";
11
12
 
12
13
  // ── Mocks ────────────────────────────────────────────────────────────────────
13
14
  //
@@ -17,7 +18,7 @@ import { registerIpcRelay } from "./ipc-relay.js";
17
18
  // Bridge: a mutable state object — `active` flips per test, `ask` is a vi.fn.
18
19
  // Picker/dialog: mocked so the TUI branch never touches the real TUI.
19
20
 
20
- const { busState, bridgeState } = vi.hoisted(() => {
21
+ const { busState, bridgeState, eventsState } = vi.hoisted(() => {
21
22
  const listeners: Record<string, Array<(p: unknown) => void>> = {};
22
23
  const emit = vi.fn((event: string, payload: unknown) => {
23
24
  for (const fn of listeners[event] ?? []) {
@@ -44,6 +45,7 @@ const { busState, bridgeState } = vi.hoisted(() => {
44
45
  password: vi.fn(),
45
46
  state: vi.fn(() => "idle"),
46
47
  },
48
+ eventsState: { emit: vi.fn() }, // pi.events — the shared extension event bus
47
49
  };
48
50
  });
49
51
 
@@ -77,9 +79,14 @@ function captureTool(): {
77
79
  onUpdate: unknown,
78
80
  ctx: unknown,
79
81
  ) => Promise<{ content: Array<{ type: string; text: string }>; details: unknown }>;
82
+ renderCall?: unknown;
83
+ renderResult?: unknown;
80
84
  } {
81
85
  const registered: Array<Record<string, unknown>> = [];
82
- registerAskTool({ registerTool: (t: Record<string, unknown>) => registered.push(t) } as unknown as ExtensionAPI);
86
+ registerAskTool({
87
+ registerTool: (t: Record<string, unknown>) => registered.push(t),
88
+ events: eventsState,
89
+ } as unknown as ExtensionAPI);
83
90
  return registered[0] as ReturnType<typeof captureTool>;
84
91
  }
85
92
 
@@ -103,6 +110,13 @@ function emitted(event: string): Record<string, unknown>[] {
103
110
  .map((c) => c[1]!) as Record<string, unknown>[];
104
111
  }
105
112
 
113
+ /** The recorded `pi.events` "herdr:blocked" payloads, in order. */
114
+ function herdrEmits(): Array<{ active: boolean; label?: string }> {
115
+ return eventsState.emit.mock.calls
116
+ .filter((c) => c[0] === "herdr:blocked")
117
+ .map((c) => c[1]!) as Array<{ active: boolean; label?: string }>;
118
+ }
119
+
106
120
  // Relay subscriptions are tracked file-wide and torn down in afterEach (not at
107
121
  // the end of each test body) so a failing assertion cannot leak a live relay
108
122
  // handler into the next test (double registration → double ASK_RESPONSE).
@@ -114,6 +128,7 @@ afterEach(() => {
114
128
 
115
129
  beforeEach(() => {
116
130
  busState.emit.mockClear();
131
+ eventsState.emit.mockClear();
117
132
  bridgeState.active = false;
118
133
  bridgeState.ask.mockReset();
119
134
  vi.mocked(askSingleQuestionWithInlineNote).mockReset();
@@ -122,6 +137,14 @@ beforeEach(() => {
122
137
 
123
138
  // ── Bridge branch (checked first) ────────────────────────────────────────────
124
139
 
140
+ describe("tool registration", () => {
141
+ it("registers the correct render functions", () => {
142
+ const tool = captureTool();
143
+ expect(tool.renderCall).toBe(renderAskCall);
144
+ expect(tool.renderResult).toBe(renderAskResult);
145
+ });
146
+ });
147
+
125
148
  describe("bridge branch", () => {
126
149
  it("emits ASK_REQUEST (main) with toolCallId, then a paired ASK_RESPONSE, and returns the built results", async () => {
127
150
  bridgeState.active = true;
@@ -343,6 +366,104 @@ describe("TUI branch (bridge inactive, has UI)", () => {
343
366
 
344
367
  expect(result.content[0]!.text).toBe("Error: questions must not be empty");
345
368
  expect(busState.emit).not.toHaveBeenCalled();
369
+ // No dialog shown → no herdr pairing either
370
+ expect(herdrEmits()).toEqual([]);
371
+ });
372
+ });
373
+
374
+ // ── herdr pairing (pi.events 'herdr:blocked') ────────────────────────────
375
+ //
376
+ // herdr's managed extension refcounts the blocked state off this event
377
+ // (+1 on active:true, −1 on active:false; TUI-only). The tool must pair
378
+ // every active:true with exactly one active:false — on answer, cancel,
379
+ // AND error — or the pane is stuck at "blocked".
380
+
381
+ describe("herdr pairing (TUI)", () => {
382
+ it("single question, answered: active:true (label = the question) then active:false, in order", async () => {
383
+ bridgeState.active = false;
384
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: ["A"] });
385
+
386
+ const tool = captureTool();
387
+ await tool.execute("tool-7", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
388
+
389
+ expect(herdrEmits()).toEqual([
390
+ { active: true, label: "Which option?" },
391
+ { active: false, label: undefined },
392
+ ]);
393
+ });
394
+
395
+ it("single question, cancelled (empty selection): still paired", async () => {
396
+ bridgeState.active = false;
397
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: [] });
398
+
399
+ const tool = captureTool();
400
+ const result = await tool.execute("tool-8", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
401
+
402
+ expect(result.content[0]!.text).toContain("(cancelled)");
403
+ expect(herdrEmits()).toEqual([
404
+ { active: true, label: "Which option?" },
405
+ { active: false, label: undefined },
406
+ ]);
407
+ });
408
+
409
+ it("picker error: active:false still emitted (finally) and the error propagates", async () => {
410
+ bridgeState.active = false;
411
+ vi.mocked(askSingleQuestionWithInlineNote).mockRejectedValue(new Error("ui crashed"));
412
+
413
+ const tool = captureTool();
414
+ await expect(tool.execute("tool-9", makeParams(), undefined, undefined, { hasUI: true, ui: {} })).rejects.toThrow("ui crashed");
415
+
416
+ expect(herdrEmits()).toEqual([
417
+ { active: true, label: "Which option?" },
418
+ { active: false, label: undefined },
419
+ ]);
420
+ });
421
+
422
+ it("multi question: ONE pair, label = the first question", async () => {
423
+ bridgeState.active = false;
424
+ vi.mocked(askQuestionsWithTabs).mockResolvedValue({
425
+ cancelled: false,
426
+ selections: [{ selectedOptions: ["A"] }, { selectedOptions: ["B"] }],
427
+ });
428
+
429
+ const tool = captureTool();
430
+ await tool.execute(
431
+ "tool-10",
432
+ {
433
+ questions: [
434
+ { id: "q1", question: "First?", options: [{ label: "A" }] },
435
+ { id: "q2", question: "Second?", options: [{ label: "B" }] },
436
+ ],
437
+ },
438
+ undefined,
439
+ undefined,
440
+ { hasUI: true, ui: {} },
441
+ );
442
+
443
+ expect(herdrEmits()).toEqual([
444
+ { active: true, label: "First?" },
445
+ { active: false, label: undefined },
446
+ ]);
447
+ });
448
+
449
+ it("bridge path: NO herdr emit (non-TUI — herdr's handler is a no-op there)", async () => {
450
+ bridgeState.active = true;
451
+ bridgeState.ask.mockResolvedValue({ cancelled: false, results: [{ id: "q1", selectedOptions: ["A"] }] });
452
+
453
+ const tool = captureTool();
454
+ await tool.execute("tool-11", makeParams(), undefined, undefined, { hasUI: true, ui: {} });
455
+
456
+ expect(herdrEmits()).toEqual([]);
457
+ });
458
+
459
+ it("headless path: NO herdr emit (the child is non-TUI; the parent's relay reports it)", async () => {
460
+ bridgeState.active = false;
461
+ delete process.env.PI_SUBAGENT_SOCKET;
462
+
463
+ const tool = captureTool();
464
+ await tool.execute("tool-12", makeParams(), undefined, undefined, { hasUI: false });
465
+
466
+ expect(herdrEmits()).toEqual([]);
346
467
  });
347
468
  });
348
469
 
@@ -352,7 +473,7 @@ describe("ipc-relay", () => {
352
473
  it("bridge active: a subagent ASK_REQUEST is NOT consumed (no picker, no ASK_RESPONSE)", async () => {
353
474
  bridgeState.active = true;
354
475
  const unsubscribes: Array<() => void> = [];
355
- registerIpcRelay({} as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
476
+ registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
356
477
  relayUnsubs.push(...unsubscribes);
357
478
 
358
479
  busState.emit(Events.ASK_REQUEST, {
@@ -366,13 +487,15 @@ describe("ipc-relay", () => {
366
487
  expect(vi.mocked(askSingleQuestionWithInlineNote)).not.toHaveBeenCalled();
367
488
  expect(vi.mocked(askQuestionsWithTabs)).not.toHaveBeenCalled();
368
489
  expect(emitted(Events.ASK_RESPONSE).length).toBe(0);
490
+ // Not consumed → no dialog shown → no herdr pairing
491
+ expect(herdrEmits()).toEqual([]);
369
492
  });
370
493
 
371
494
  it("bridge inactive: a subagent ASK_REQUEST is consumed and answered via ASK_RESPONSE (existing behavior)", async () => {
372
495
  bridgeState.active = false;
373
496
  vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: ["A"] });
374
497
  const unsubscribes: Array<() => void> = [];
375
- registerIpcRelay({} as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
498
+ registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
376
499
  relayUnsubs.push(...unsubscribes);
377
500
 
378
501
  busState.emit(Events.ASK_REQUEST, {
@@ -389,4 +512,53 @@ describe("ipc-relay", () => {
389
512
  expect(resp.cancelled).toBe(false);
390
513
  expect(resp.results).toEqual([{ id: "q1", selectedOptions: ["A"], customInput: undefined }]);
391
514
  });
515
+
516
+ it("bridge inactive: the parent's dialog pairs a herdr blocked/unblocked (label = the subagent's question)", async () => {
517
+ bridgeState.active = false;
518
+ vi.mocked(askSingleQuestionWithInlineNote).mockResolvedValue({ selectedOptions: ["A"] });
519
+ const unsubscribes: Array<() => void> = [];
520
+ registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
521
+ relayUnsubs.push(...unsubscribes);
522
+
523
+ busState.emit(Events.ASK_REQUEST, {
524
+ source: "subagent:x",
525
+ requestId: "relay-3",
526
+ questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
527
+ });
528
+ await vi.waitFor(() => {
529
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
530
+ });
531
+
532
+ // The PARENT's TUI is the surface the subagent's dialog appears on — the
533
+ // child process is headless and its own herdr extension is inert, so the
534
+ // pairing happens here, in the parent.
535
+ expect(herdrEmits()).toEqual([
536
+ { active: true, label: "Which?" },
537
+ { active: false, label: undefined },
538
+ ]);
539
+ });
540
+
541
+ it("bridge inactive, picker error: the herdr pairing still settles (the catch converts it to a cancelled response)", async () => {
542
+ bridgeState.active = false;
543
+ vi.mocked(askSingleQuestionWithInlineNote).mockRejectedValue(new Error("ui crashed"));
544
+ const unsubscribes: Array<() => void> = [];
545
+ registerIpcRelay({ events: eventsState } as unknown as ExtensionAPI, () => ({ ui: {} } as unknown as ExtensionContext), unsubscribes);
546
+ relayUnsubs.push(...unsubscribes);
547
+
548
+ busState.emit(Events.ASK_REQUEST, {
549
+ source: "subagent:x",
550
+ requestId: "relay-4",
551
+ questions: [{ id: "q1", question: "Which?", options: [{ label: "A" }] }],
552
+ });
553
+ await vi.waitFor(() => {
554
+ expect(emitted(Events.ASK_RESPONSE).length).toBe(1);
555
+ });
556
+
557
+ const resp = emitted(Events.ASK_RESPONSE)[0]!;
558
+ expect(resp.cancelled).toBe(true);
559
+ expect(herdrEmits()).toEqual([
560
+ { active: true, label: "Which?" },
561
+ { active: false, label: undefined },
562
+ ]);
563
+ });
392
564
  });
package/src/tool.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type, type Static } from "typebox";
3
3
  import { getBus, Events } from "@pi-archimedes/core/bus";
4
4
  import { getBridge } from "@pi-archimedes/core/bridge";
@@ -7,6 +7,8 @@ import { randomUUID } from "node:crypto";
7
7
  import { OTHER_OPTION, type AskQuestion } from "./selection.js";
8
8
  import { askSingleQuestionWithInlineNote } from "./picker.js";
9
9
  import { askQuestionsWithTabs } from "./dialog.js";
10
+ import { renderAskCall, renderAskResult } from "./renderer.js";
11
+ import { emitBlocked, blockedLabel } from "./herdr.js";
10
12
 
11
13
  // ── Schemas ──────────────────────────────────────────────────────────────────
12
14
 
@@ -108,7 +110,7 @@ function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
108
110
  };
109
111
  }
110
112
 
111
- function formatSelectionForSummary(result: QuestionResult): string {
113
+ export function formatSelectionForSummary(result: QuestionResult): string {
112
114
  const hasSelectedOptions = result.selectedOptions.length > 0;
113
115
  const hasCustomInput = Boolean(result.customInput);
114
116
 
@@ -228,6 +230,8 @@ export function registerAskTool(pi: ExtensionAPI): void {
228
230
  label: "Ask",
229
231
  description: ASK_TOOL_DESCRIPTION,
230
232
  parameters: AskParamsSchema,
233
+ renderCall: renderAskCall,
234
+ renderResult: renderAskResult,
231
235
 
232
236
  async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
233
237
  // Bridge mode (checked first): the root's Client request is sent by
@@ -361,62 +365,84 @@ export function registerAskTool(pi: ExtensionAPI): void {
361
365
  // Emit bus event so notify (if installed) can schedule a desktop notification
362
366
  getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: randomUUID(), questions: params.questions, toolCallId: _toolCallId });
363
367
 
364
- if (params.questions.length === 1) {
365
- const q = params.questions[0]!;
366
- const selection = q.multi
367
- ? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
368
- : await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
369
- const optionLabels = q.options.map((option) => option.label);
370
- const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
371
-
372
- const result: QuestionResult = {
373
- id: q.id,
374
- question: q.question,
375
- description: desc,
376
- options: optionLabels,
377
- multi: q.multi ?? false,
378
- selectedOptions: selection.selectedOptions,
379
- customInput: selection.customInput,
380
- };
368
+ // Report to herdr's managed extension (inert unless herdr spawned pi — the
369
+ // event is a no-op otherwise). One pair per dialog; the finally pairs it on
370
+ // answer, cancel, AND error so the refcount never leaks. (The bridge and
371
+ // headless paths above are non-TUI — herdr's handler is TUI-only, so they
372
+ // are intentionally not reported.)
373
+ emitBlocked(pi, true, blockedLabel(params.questions));
374
+ try {
375
+ return await executeTuiAsk(params, ctx);
376
+ } finally {
377
+ emitBlocked(pi, false);
378
+ }
379
+ },
380
+ });
381
+ }
381
382
 
382
- const details: AskToolDetails = {
383
- id: q.id,
384
- question: q.question,
385
- description: desc,
386
- options: optionLabels,
387
- multi: q.multi ?? false,
388
- selectedOptions: selection.selectedOptions,
389
- customInput: selection.customInput,
390
- results: [result],
391
- };
383
+ /**
384
+ * Show the questions in the TUI (picker for a single non-multi question, tabs
385
+ * dialog otherwise) and build the tool result. The caller wraps this in the
386
+ * herdr blocked pairing (emitBlocked true before / false in finally).
387
+ */
388
+ async function executeTuiAsk(
389
+ params: AskParams,
390
+ ctx: ExtensionContext,
391
+ ): Promise<AgentToolResult<AskToolDetails>> {
392
+ if (params.questions.length === 1) {
393
+ const q = params.questions[0]!;
394
+ const selection = q.multi
395
+ ? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
396
+ : await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
397
+ const optionLabels = q.options.map((option) => option.label);
398
+ const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
399
+
400
+ const result: QuestionResult = {
401
+ id: q.id,
402
+ question: q.question,
403
+ description: desc,
404
+ options: optionLabels,
405
+ multi: q.multi ?? false,
406
+ selectedOptions: selection.selectedOptions,
407
+ customInput: selection.customInput,
408
+ };
392
409
 
393
- return {
394
- content: [{ type: "text", text: buildAskSessionContent([result]) }],
395
- details,
396
- };
397
- }
410
+ const details: AskToolDetails = {
411
+ id: q.id,
412
+ question: q.question,
413
+ description: desc,
414
+ options: optionLabels,
415
+ multi: q.multi ?? false,
416
+ selectedOptions: selection.selectedOptions,
417
+ customInput: selection.customInput,
418
+ results: [result],
419
+ };
398
420
 
399
- const results: QuestionResult[] = [];
400
- const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
401
- for (let i = 0; i < params.questions.length; i++) {
402
- const q = params.questions[i]!;
403
- const selection = tabResult.selections[i] ?? { selectedOptions: [] };
404
- const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
405
- results.push({
406
- id: q.id,
407
- question: q.question,
408
- description: desc,
409
- options: q.options.map((option) => option.label),
410
- multi: q.multi ?? false,
411
- selectedOptions: selection.selectedOptions,
412
- customInput: selection.customInput,
413
- });
414
- }
421
+ return {
422
+ content: [{ type: "text", text: buildAskSessionContent([result]) }],
423
+ details,
424
+ };
425
+ }
415
426
 
416
- return {
417
- content: [{ type: "text", text: buildAskSessionContent(results) }],
418
- details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
419
- };
420
- },
421
- });
427
+ const results: QuestionResult[] = [];
428
+ const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
429
+ for (let i = 0; i < params.questions.length; i++) {
430
+ const q = params.questions[i]!;
431
+ const selection = tabResult.selections[i] ?? { selectedOptions: [] };
432
+ const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
433
+ results.push({
434
+ id: q.id,
435
+ question: q.question,
436
+ description: desc,
437
+ options: q.options.map((option) => option.label),
438
+ multi: q.multi ?? false,
439
+ selectedOptions: selection.selectedOptions,
440
+ customInput: selection.customInput,
441
+ });
442
+ }
443
+
444
+ return {
445
+ content: [{ type: "text", text: buildAskSessionContent(results) }],
446
+ details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
447
+ };
422
448
  }