@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 +4 -4
- package/src/herdr.test.ts +43 -0
- package/src/herdr.ts +34 -0
- package/src/index.ts +1 -0
- package/src/ipc-relay.ts +11 -1
- package/src/renderer.test.ts +225 -0
- package/src/renderer.ts +114 -0
- package/src/tool.test.ts +176 -4
- package/src/tool.ts +82 -56
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/ask",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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.
|
|
27
|
-
"@earendil-works/pi-tui": "^0.
|
|
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
|
+
});
|
package/src/renderer.ts
ADDED
|
@@ -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({
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
}
|