@nmzpy/pi-ember-stack 0.1.6 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -83
- package/package.json +62 -48
- package/plugins/devin-auth/extensions/index.ts +68 -7
- package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
- package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
- package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
- package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
- package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
- package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
- package/plugins/devin-auth/src/models.ts +42 -196
- package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
- package/plugins/devin-auth/src/oauth/types.ts +71 -71
- package/plugins/devin-auth/src/stream.ts +1 -1
- package/plugins/index.ts +29 -6
- package/plugins/pi-compact-tools/index.ts +35 -192
- package/plugins/pi-compact-tools/renderer.ts +589 -0
- package/plugins/pi-custom-agents/index.ts +727 -373
- package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
- package/plugins/pi-custom-agents/subagent/agents/coder.md +1 -0
- package/plugins/pi-custom-agents/subagent/agents/scout.md +16 -37
- package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
- package/plugins/pi-custom-agents/subagent/extensions/index.ts +118 -226
- package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
- package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
- package/plugins/pi-custom-agents/subagent/extensions/runner.ts +260 -170
- package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
- package/plugins/pi-ember-fff/index.ts +975 -0
- package/plugins/pi-ember-fff/query.ts +247 -0
- package/plugins/pi-ember-fff/test/query.test.ts +222 -0
- package/plugins/pi-ember-fff/test/renderer.test.ts +727 -0
- package/plugins/pi-ember-tps/index.ts +144 -0
- package/plugins/pi-ember-ui/ember.json +99 -0
- package/plugins/pi-ember-ui/index.ts +805 -0
- package/plugins/pi-ember-ui/mode-colors.ts +196 -0
- package/tsconfig.json +2 -1
- package/plugins/pi-custom-agents/subagent/agents/architect.md +0 -56
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
const BULLET = "• ";
|
|
4
|
+
const DISCOVERY_TOOLS = new Set(["read", "grep", "find", "ls"]);
|
|
5
|
+
const GROUPABLE_TOOLS = new Set([...DISCOVERY_TOOLS, "bash"]);
|
|
6
|
+
|
|
7
|
+
export type ToolRenderContext = {
|
|
8
|
+
args: any;
|
|
9
|
+
toolCallId: string;
|
|
10
|
+
invalidate: () => void;
|
|
11
|
+
state: Record<string, any>;
|
|
12
|
+
expanded?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ToolRenderResultOptions = {
|
|
16
|
+
isPartial: boolean;
|
|
17
|
+
expanded?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type CompactCall = {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
args: any;
|
|
24
|
+
group?: DiscoveryGroup;
|
|
25
|
+
invalidate?: () => void;
|
|
26
|
+
isError: boolean;
|
|
27
|
+
_completed?: boolean;
|
|
28
|
+
result?: any;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type DiscoveryGroup = {
|
|
32
|
+
records: CompactCall[];
|
|
33
|
+
/**
|
|
34
|
+
* The record whose component renders the group header. Set once at
|
|
35
|
+
* group creation to the first member and never changed.
|
|
36
|
+
*/
|
|
37
|
+
renderOwner?: CompactCall;
|
|
38
|
+
hasNonDiscovery?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Shared visual handle for the group block. The owner re-binds this
|
|
41
|
+
* to its live `Text` on every `renderCall`; members write into it
|
|
42
|
+
* directly via `setText` in `renderResultInner` so the group stays
|
|
43
|
+
* visible across Pi rebuilds (thinking-toggle, compaction, settings)
|
|
44
|
+
* without relying on owner invalidation.
|
|
45
|
+
*/
|
|
46
|
+
callText?: Text;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function textValue(value: unknown, fallback = ""): string {
|
|
50
|
+
if (value === undefined || value === null) return fallback;
|
|
51
|
+
return String(value).replace(/[\r\n]+/g, " ");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function toolPath(args: any): string {
|
|
55
|
+
return textValue(args?.file_path ?? args?.path, ".");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bashCdDir(command: string): string | undefined {
|
|
59
|
+
const match = /^\s*cd\s+([^\s&]+)\s*&&\s*/.exec(command);
|
|
60
|
+
return match?.[1];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Detect bash commands that are grep invocations (optionally preceded by
|
|
65
|
+
* `cd <dir> &&`). Returns the extracted pattern and path so the call
|
|
66
|
+
* can render as "Search" and join the discovery group.
|
|
67
|
+
*/
|
|
68
|
+
function bashGrepInfo(command: string): { pattern: string; path: string } | undefined {
|
|
69
|
+
const stripped = command.replace(/^\s*cd\s+([^\s&]+)\s*&&\s*/, "");
|
|
70
|
+
if (!/^\s*grep\b/.test(stripped)) return undefined;
|
|
71
|
+
const cdDir = bashCdDir(command);
|
|
72
|
+
const path = cdDir ?? ".";
|
|
73
|
+
const afterGrep = stripped.replace(/^\s*grep\s+/, "");
|
|
74
|
+
const cmdBeforePipe = afterGrep.split(/\s+[|>]/)[0];
|
|
75
|
+
const parts = cmdBeforePipe.trim().split(/\s+/);
|
|
76
|
+
let pattern: string | undefined;
|
|
77
|
+
for (const part of parts) {
|
|
78
|
+
if (!part.startsWith("-")) {
|
|
79
|
+
pattern = part;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!pattern) return undefined;
|
|
84
|
+
pattern = pattern.replace(/^["']|["']$/g, "");
|
|
85
|
+
return { pattern, path };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function groupKey(name: string, args: any): string | undefined {
|
|
89
|
+
if (DISCOVERY_TOOLS.has(name)) return "__discovery__";
|
|
90
|
+
if (name === "bash") {
|
|
91
|
+
const command = textValue(args?.command);
|
|
92
|
+
if (bashGrepInfo(command)) return "__discovery__";
|
|
93
|
+
const dir = bashCdDir(command);
|
|
94
|
+
return dir ? `bash:${dir}` : undefined;
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function errorText(result: any, isError: boolean): string | undefined {
|
|
100
|
+
const content = result?.content?.find((item: any) => item.type === "text");
|
|
101
|
+
if (!isError && !content?.text?.startsWith("Error")) return undefined;
|
|
102
|
+
return textValue(content?.text, "Tool failed").split("\n")[0];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fullOutputText(result: any): string {
|
|
106
|
+
const content = result?.content?.find((item: any) => item.type === "text");
|
|
107
|
+
const text = content?.text;
|
|
108
|
+
if (typeof text !== "string") return "";
|
|
109
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatExpandedOutput(result: any, theme: any): string {
|
|
113
|
+
const text = fullOutputText(result).trimEnd();
|
|
114
|
+
if (!text) return "";
|
|
115
|
+
return "\n" + text.split("\n").map((line) => theme.fg("text", line)).join("\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function bashLastLine(result: any): string | undefined {
|
|
119
|
+
const content = result?.content?.find((item: any) => item.type === "text");
|
|
120
|
+
const text = content?.text;
|
|
121
|
+
if (typeof text !== "string") return undefined;
|
|
122
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
|
123
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
124
|
+
const line = lines[i].trim();
|
|
125
|
+
if (line.length > 0) return line;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatBashResultLine(result: any, theme: any): string {
|
|
131
|
+
const lastLine = bashLastLine(result);
|
|
132
|
+
if (lastLine === undefined) return "";
|
|
133
|
+
return "\n" + theme.fg("dim", " ") + theme.fg("text", lastLine);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function diffStats(result: any): { additions: number; removals: number } {
|
|
137
|
+
const diff = typeof result?.details?.diff === "string" ? result.details.diff : "";
|
|
138
|
+
let additions = 0;
|
|
139
|
+
let removals = 0;
|
|
140
|
+
for (const line of diff.split("\n")) {
|
|
141
|
+
if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
142
|
+
if (line.startsWith("-") && !line.startsWith("---")) removals++;
|
|
143
|
+
}
|
|
144
|
+
return { additions, removals };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function matchCount(result: any): number | undefined {
|
|
148
|
+
const total = result?.details?.totalMatched;
|
|
149
|
+
if (typeof total === "number") return total;
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function matchLabel(result: any, theme: any): string {
|
|
154
|
+
const total = matchCount(result);
|
|
155
|
+
if (total === undefined) return "";
|
|
156
|
+
const label = total === 1 ? "1 match" : `${total} matches`;
|
|
157
|
+
const color = total > 0 ? "success" : "muted";
|
|
158
|
+
return theme.fg("dim", " ") + theme.fg(color, label);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const PULSE_INTERVAL_MS = 600;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Canonical status-bullet color: error→red, completed→green, else a
|
|
165
|
+
* flashing muted/dim bullet driven by PULSE_INTERVAL_MS. Shared by the
|
|
166
|
+
* compact renderer and the subagent renderer so the pulse stays in sync.
|
|
167
|
+
*/
|
|
168
|
+
export function statusBulletColor(isError: boolean, isCompleted: boolean, theme: any): string {
|
|
169
|
+
if (isError) return theme.fg("error", BULLET);
|
|
170
|
+
if (isCompleted) return theme.fg("success", BULLET);
|
|
171
|
+
const pulse = Math.floor(Date.now() / PULSE_INTERVAL_MS) % 2 === 0;
|
|
172
|
+
return pulse ? theme.fg("muted", BULLET) : theme.fg("dim", BULLET);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Canonical group-bullet color: any error→red, all completed→green,
|
|
177
|
+
* else flashing. Derived from statusBulletColor's pulse logic.
|
|
178
|
+
*/
|
|
179
|
+
export function groupBulletColorFromFlags(hasError: boolean, allCompleted: boolean, theme: any): string {
|
|
180
|
+
return statusBulletColor(hasError, allCompleted, theme);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Single shared pulse timer. Holds a set of invalidate callbacks and
|
|
185
|
+
* fires them all on one PULSE_INTERVAL_MS interval, starting on first
|
|
186
|
+
* add and stopping when the last callback is removed. One timer drives
|
|
187
|
+
* every flashing bullet in the session.
|
|
188
|
+
*/
|
|
189
|
+
export class PulseManager {
|
|
190
|
+
private readonly callbacks = new Set<() => void>();
|
|
191
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
192
|
+
|
|
193
|
+
add(cb: () => void): void {
|
|
194
|
+
this.callbacks.add(cb);
|
|
195
|
+
if (this.timer) return;
|
|
196
|
+
this.timer = setInterval(() => {
|
|
197
|
+
for (const cb of this.callbacks) {
|
|
198
|
+
try { cb(); } catch { /* best effort */ }
|
|
199
|
+
}
|
|
200
|
+
}, PULSE_INTERVAL_MS);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
remove(cb: () => void): void {
|
|
204
|
+
this.callbacks.delete(cb);
|
|
205
|
+
if (this.callbacks.size === 0 && this.timer) {
|
|
206
|
+
clearInterval(this.timer);
|
|
207
|
+
this.timer = undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
clear(): void {
|
|
212
|
+
this.callbacks.clear();
|
|
213
|
+
if (this.timer) {
|
|
214
|
+
clearInterval(this.timer);
|
|
215
|
+
this.timer = undefined;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function bulletColor(record: CompactCall, theme: any): string {
|
|
221
|
+
return statusBulletColor(record.isError, record._completed === true, theme);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function formatEditStats(result: any, theme: any): string {
|
|
225
|
+
const { additions, removals } = diffStats(result);
|
|
226
|
+
return theme.fg("success", `+${additions}`) +
|
|
227
|
+
theme.fg("dim", " / ") +
|
|
228
|
+
theme.fg("error", `-${removals}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function formatCallBody(name: string, args: any, theme: any, inGroup = false): string {
|
|
232
|
+
const pathName = toolPath(args);
|
|
233
|
+
switch (name) {
|
|
234
|
+
case "read":
|
|
235
|
+
return theme.fg("dim", theme.bold("Read")) +
|
|
236
|
+
theme.fg("dim", ` ${pathName}`);
|
|
237
|
+
case "grep":
|
|
238
|
+
return theme.fg("dim", theme.bold("Search")) +
|
|
239
|
+
theme.fg("dim", ` ${textValue(args?.pattern)}`) +
|
|
240
|
+
theme.fg("dim", ` in ${pathName}`);
|
|
241
|
+
case "find":
|
|
242
|
+
return theme.fg("dim", theme.bold("Find")) +
|
|
243
|
+
theme.fg("dim", ` ${textValue(args?.pattern)}`) +
|
|
244
|
+
theme.fg("dim", ` in ${pathName}`);
|
|
245
|
+
case "ls":
|
|
246
|
+
return theme.fg("dim", theme.bold("List")) +
|
|
247
|
+
theme.fg("dim", ` ${pathName}`);
|
|
248
|
+
case "bash": {
|
|
249
|
+
const cmd = textValue(args?.command);
|
|
250
|
+
const grepInfo = bashGrepInfo(cmd);
|
|
251
|
+
if (grepInfo) {
|
|
252
|
+
return theme.fg("dim", theme.bold("Search")) +
|
|
253
|
+
theme.fg("dim", ` ${grepInfo.pattern}`) +
|
|
254
|
+
theme.fg("dim", ` in ${grepInfo.path}`);
|
|
255
|
+
}
|
|
256
|
+
const stripped = inGroup ? (cmd.replace(/^\s*cd\s+[^\s&]+\s*&&\s*/, "") || cmd) : cmd;
|
|
257
|
+
return theme.fg("dim", theme.bold("Run")) +
|
|
258
|
+
theme.fg("dim", ` $ ${stripped}`);
|
|
259
|
+
}
|
|
260
|
+
case "edit":
|
|
261
|
+
return theme.fg("dim", theme.bold("edit")) +
|
|
262
|
+
theme.fg("dim", ` ${pathName}`);
|
|
263
|
+
case "write":
|
|
264
|
+
return theme.fg("dim", theme.bold("write")) +
|
|
265
|
+
theme.fg("dim", ` ${pathName}`);
|
|
266
|
+
default:
|
|
267
|
+
return theme.fg("dim", theme.bold(name));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function groupBulletColor(group: DiscoveryGroup, theme: any): string {
|
|
272
|
+
const hasError = group.records.some((r) => r.isError);
|
|
273
|
+
const allCompleted = group.records.every((r) => r._completed);
|
|
274
|
+
return groupBulletColorFromFlags(hasError, allCompleted, theme);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function groupHeaderLabel(group: DiscoveryGroup): string {
|
|
278
|
+
const allDone = group.records.every((r) => r._completed);
|
|
279
|
+
const verb = allDone && group.hasNonDiscovery ? "Explored" : "Exploring";
|
|
280
|
+
const first = group.records[0];
|
|
281
|
+
if (!first) return verb;
|
|
282
|
+
const key = groupKey(first.name, first.args);
|
|
283
|
+
if (key?.startsWith("bash:")) {
|
|
284
|
+
const dir = key.slice(5);
|
|
285
|
+
return `${verb} ${dir}`;
|
|
286
|
+
}
|
|
287
|
+
return verb;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function formatGroup(group: DiscoveryGroup, theme: any): string {
|
|
291
|
+
const lines = [
|
|
292
|
+
groupBulletColor(group, theme) + theme.fg("text", theme.bold(groupHeaderLabel(group))),
|
|
293
|
+
];
|
|
294
|
+
for (const [index, record] of group.records.entries()) {
|
|
295
|
+
const prefix = index === 0 ? " └ " : " ";
|
|
296
|
+
const suffix = record._completed && (record.name === "grep" || record.name === "find")
|
|
297
|
+
? matchLabel(record.result, theme)
|
|
298
|
+
: "";
|
|
299
|
+
lines.push(
|
|
300
|
+
theme.fg("dim", prefix) +
|
|
301
|
+
formatCallBody(record.name, record.args, theme, true) +
|
|
302
|
+
suffix,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
return lines.join("\n");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export class CompactRenderer {
|
|
309
|
+
private readonly calls = new Map<string, CompactCall>();
|
|
310
|
+
private lastCall: CompactCall | undefined;
|
|
311
|
+
private lastGroupKey: string | undefined;
|
|
312
|
+
private currentGroup: DiscoveryGroup | undefined;
|
|
313
|
+
/** The single discovery group persists until session replacement. */
|
|
314
|
+
private discoveryGroup: DiscoveryGroup | undefined;
|
|
315
|
+
private readonly pulses = new PulseManager();
|
|
316
|
+
|
|
317
|
+
/** Whether the current turn has produced visible text output. When
|
|
318
|
+
* true, grouping state is reset so the next discovery call starts a
|
|
319
|
+
* fresh group. When false (thinking-only turns), discovery calls
|
|
320
|
+
* continue appending to the previous turn's group so exploration
|
|
321
|
+
* stays coherent with nothing visible between turns. */
|
|
322
|
+
private turnHasText = false;
|
|
323
|
+
|
|
324
|
+
beginTurn(): void {
|
|
325
|
+
// Do NOT reset grouping state here. A turn that only streams thinking
|
|
326
|
+
// tokens (no visible text) and then does discovery calls should
|
|
327
|
+
// append to the previous turn's group — there is nothing visible
|
|
328
|
+
// between them. Grouping is reset lazily in registerCall() once we
|
|
329
|
+
// know the current turn produced visible text (see noteVisibleText()).
|
|
330
|
+
this.turnHasText = false;
|
|
331
|
+
this.pulses.clear();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Called by the plugin when the current turn produces visible text
|
|
335
|
+
* output (text_start/text_delta). Marks the turn as having visible
|
|
336
|
+
* content so the next discovery call starts a fresh group instead
|
|
337
|
+
* of appending to the previous turn's group. */
|
|
338
|
+
noteVisibleText(): void {
|
|
339
|
+
this.turnHasText = true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Clear all accumulated call state. Called on session replacement
|
|
343
|
+
* (/resume, /new, /fork) so stale rows from the previous session do
|
|
344
|
+
* not leak into the new one. */
|
|
345
|
+
resetForSession(): void {
|
|
346
|
+
this.calls.clear();
|
|
347
|
+
this.lastCall = undefined;
|
|
348
|
+
this.lastGroupKey = undefined;
|
|
349
|
+
this.currentGroup = undefined;
|
|
350
|
+
this.discoveryGroup = undefined;
|
|
351
|
+
this.pulses.clear();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private markNonDiscoveryGroups(): void {
|
|
355
|
+
const groups = new Set<DiscoveryGroup>();
|
|
356
|
+
if (this.currentGroup) groups.add(this.currentGroup);
|
|
357
|
+
if (this.discoveryGroup) groups.add(this.discoveryGroup);
|
|
358
|
+
for (const group of groups) {
|
|
359
|
+
group.hasNonDiscovery = true;
|
|
360
|
+
group.renderOwner?.invalidate?.();
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private appendToGroup(group: DiscoveryGroup, record: CompactCall): void {
|
|
365
|
+
// Attach every member only once the group has a second member. This
|
|
366
|
+
// keeps a lone discovery call rendered as a normal standalone row.
|
|
367
|
+
for (const member of group.records) member.group = group;
|
|
368
|
+
group.records.push(record);
|
|
369
|
+
record.group = group;
|
|
370
|
+
this.currentGroup = group;
|
|
371
|
+
group.renderOwner?.invalidate?.();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
registerCall(
|
|
375
|
+
name: string,
|
|
376
|
+
id: string,
|
|
377
|
+
args: any,
|
|
378
|
+
invalidate?: () => void,
|
|
379
|
+
): CompactCall {
|
|
380
|
+
const existing = this.calls.get(id);
|
|
381
|
+
if (existing) {
|
|
382
|
+
existing.args = args;
|
|
383
|
+
// On Pi rebuilds (thinking-toggle, compaction, settings) the
|
|
384
|
+
// ToolExecutionComponent is destroyed and recreated with a fresh
|
|
385
|
+
// invalidate callback. Swap the old callback out of the
|
|
386
|
+
// PulseManager and insert the live one so the pulse timer only
|
|
387
|
+
// fires live components. Completed records are not re-pulsed.
|
|
388
|
+
if (existing.invalidate && invalidate && existing.invalidate !== invalidate) {
|
|
389
|
+
this.pulses.remove(existing.invalidate);
|
|
390
|
+
}
|
|
391
|
+
existing.invalidate = invalidate;
|
|
392
|
+
if (invalidate && !existing._completed) this.pulses.add(invalidate);
|
|
393
|
+
return existing;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const record: CompactCall = { id, name, args, isError: false };
|
|
397
|
+
this.calls.set(id, record);
|
|
398
|
+
const key = groupKey(name, args);
|
|
399
|
+
|
|
400
|
+
// If the current turn produced visible text, reset grouping so this
|
|
401
|
+
// call starts a fresh group. Thinking-only turns do NOT reset —
|
|
402
|
+
// discovery calls append to the previous turn's group so exploration
|
|
403
|
+
// stays coherent when nothing visible separates the turns.
|
|
404
|
+
if (this.turnHasText && key !== undefined) {
|
|
405
|
+
this.lastCall = undefined;
|
|
406
|
+
this.lastGroupKey = undefined;
|
|
407
|
+
this.currentGroup = undefined;
|
|
408
|
+
this.discoveryGroup = undefined;
|
|
409
|
+
this.turnHasText = false;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (key === undefined) {
|
|
413
|
+
this.markNonDiscoveryGroups();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (key === "__discovery__") {
|
|
417
|
+
// Discovery is one persistent group. It intentionally survives
|
|
418
|
+
// turn boundaries and intervening non-discovery calls; the latter
|
|
419
|
+
// only makes the group label monotonic via hasNonDiscovery.
|
|
420
|
+
if (!this.discoveryGroup) {
|
|
421
|
+
this.discoveryGroup = {
|
|
422
|
+
records: [record],
|
|
423
|
+
renderOwner: record,
|
|
424
|
+
};
|
|
425
|
+
this.currentGroup = this.discoveryGroup;
|
|
426
|
+
} else {
|
|
427
|
+
this.appendToGroup(this.discoveryGroup, record);
|
|
428
|
+
}
|
|
429
|
+
} else if (key && this.lastCall && key === this.lastGroupKey) {
|
|
430
|
+
const group = this.lastCall.group ?? { records: [this.lastCall] };
|
|
431
|
+
this.lastCall.group = group;
|
|
432
|
+
if (!group.renderOwner) group.renderOwner = this.lastCall;
|
|
433
|
+
this.appendToGroup(group, record);
|
|
434
|
+
}
|
|
435
|
+
this.lastCall = record;
|
|
436
|
+
this.lastGroupKey = key;
|
|
437
|
+
record.invalidate = invalidate;
|
|
438
|
+
if (invalidate) this.pulses.add(invalidate);
|
|
439
|
+
return record;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
setResult(record: CompactCall, result: any, isError: boolean): void {
|
|
443
|
+
if (record._completed && record.result === result && record.isError === isError) return;
|
|
444
|
+
record.isError = isError;
|
|
445
|
+
record._completed = true;
|
|
446
|
+
record.result = result;
|
|
447
|
+
if (record.invalidate) this.pulses.remove(record.invalidate);
|
|
448
|
+
// Do NOT invalidate the owner here. The group visual is updated
|
|
449
|
+
// directly via group.callText.setText() in renderResultInner so the
|
|
450
|
+
// owner's next render picks up the change. Invalidating the owner
|
|
451
|
+
// synchronously triggers updateDisplay -> renderResult -> setResult,
|
|
452
|
+
// which races during Pi rebuilds (thinking-toggle, compaction) when
|
|
453
|
+
// the owner component has been destroyed and recreated.
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
renderCall(
|
|
457
|
+
name: string,
|
|
458
|
+
args: any,
|
|
459
|
+
theme: any,
|
|
460
|
+
context: ToolRenderContext,
|
|
461
|
+
): Component {
|
|
462
|
+
try {
|
|
463
|
+
return this.renderCallInner(name, args, theme, context);
|
|
464
|
+
} catch {
|
|
465
|
+
// Never throw: Pi's fallback would dump raw content. Return a
|
|
466
|
+
// compact call row instead.
|
|
467
|
+
return new Text(
|
|
468
|
+
theme.fg("muted", BULLET) + formatCallBody(name, args, theme),
|
|
469
|
+
0, 0,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private renderCallInner(
|
|
475
|
+
name: string,
|
|
476
|
+
args: any,
|
|
477
|
+
theme: any,
|
|
478
|
+
context: ToolRenderContext,
|
|
479
|
+
): Component {
|
|
480
|
+
const record = this.registerCall(name, context.toolCallId, args, context.invalidate);
|
|
481
|
+
if (record.group && record.group.records.length > 1) {
|
|
482
|
+
if (record.group.renderOwner !== record) return new Text("", 0, 0);
|
|
483
|
+
const callText = context.state.callText instanceof Text
|
|
484
|
+
? context.state.callText
|
|
485
|
+
: new Text("", 0, 0);
|
|
486
|
+
context.state.callText = callText;
|
|
487
|
+
// Re-bind the group's shared visual handle to the owner's live
|
|
488
|
+
// Text on every render. On Pi rebuilds (thinking-toggle,
|
|
489
|
+
// compaction, settings) context.state is fresh, so a new Text is
|
|
490
|
+
// created and the group handle is repointed to the live owner.
|
|
491
|
+
record.group.callText = callText;
|
|
492
|
+
callText.setText(formatGroup(record.group, theme));
|
|
493
|
+
return callText;
|
|
494
|
+
}
|
|
495
|
+
const callText = context.state.callText instanceof Text
|
|
496
|
+
? context.state.callText
|
|
497
|
+
: new Text("", 0, 0);
|
|
498
|
+
context.state.callText = callText;
|
|
499
|
+
callText.setText(
|
|
500
|
+
bulletColor(record, theme) + formatCallBody(name, args, theme),
|
|
501
|
+
);
|
|
502
|
+
return callText;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
renderResult(
|
|
506
|
+
name: string,
|
|
507
|
+
args: any,
|
|
508
|
+
result: any,
|
|
509
|
+
options: ToolRenderResultOptions,
|
|
510
|
+
theme: any,
|
|
511
|
+
context: ToolRenderContext & { isError: boolean },
|
|
512
|
+
): Component {
|
|
513
|
+
try {
|
|
514
|
+
return this.renderResultInner(name, args, result, options, theme, context);
|
|
515
|
+
} catch {
|
|
516
|
+
// Never throw: Pi's fallback would dump the full tool output
|
|
517
|
+
// (e.g. entire file contents for read). Return an empty result
|
|
518
|
+
// row — the call row already shows the compact summary.
|
|
519
|
+
return new Text("", 0, 0);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private renderResultInner(
|
|
524
|
+
name: string,
|
|
525
|
+
args: any,
|
|
526
|
+
result: any,
|
|
527
|
+
options: ToolRenderResultOptions,
|
|
528
|
+
theme: any,
|
|
529
|
+
context: ToolRenderContext & { isError: boolean },
|
|
530
|
+
): Component {
|
|
531
|
+
const record = this.registerCall(name, context.toolCallId, args, context.invalidate);
|
|
532
|
+
this.setResult(record, result, context.isError);
|
|
533
|
+
const expanded = options.expanded === true;
|
|
534
|
+
|
|
535
|
+
if (record.group && record.group.records.length > 1) {
|
|
536
|
+
// Update the shared group visual directly so the owner's row
|
|
537
|
+
// reflects this member's completion (bullet color, match count,
|
|
538
|
+
// Explored label) without invalidating the owner. setText clears
|
|
539
|
+
// the Text cache; Pi's next requestRender re-renders the owner's
|
|
540
|
+
// selfRenderContainer with the updated Text.
|
|
541
|
+
record.group.callText?.setText(formatGroup(record.group, theme));
|
|
542
|
+
if (record.group.renderOwner !== record) return new Text("", 0, 0);
|
|
543
|
+
if (expanded && !options.isPartial) {
|
|
544
|
+
const output = formatExpandedOutput(result, theme);
|
|
545
|
+
if (output) return new Text(output, 0, 0);
|
|
546
|
+
}
|
|
547
|
+
const error = errorText(result, context.isError);
|
|
548
|
+
return error ? new Text(theme.fg("error", error), 0, 0) : new Text("", 0, 0);
|
|
549
|
+
}
|
|
550
|
+
if (options.isPartial) return new Text("", 0, 0);
|
|
551
|
+
|
|
552
|
+
const error = errorText(result, context.isError);
|
|
553
|
+
const callText = context.state.callText;
|
|
554
|
+
if (callText instanceof Text) {
|
|
555
|
+
if (name === "edit") {
|
|
556
|
+
callText.setText(
|
|
557
|
+
bulletColor(record, theme) +
|
|
558
|
+
formatCallBody(name, args, theme) +
|
|
559
|
+
theme.fg("dim", " ") +
|
|
560
|
+
formatEditStats(result, theme),
|
|
561
|
+
);
|
|
562
|
+
} else if (name === "grep" || name === "find") {
|
|
563
|
+
callText.setText(
|
|
564
|
+
bulletColor(record, theme) +
|
|
565
|
+
formatCallBody(name, args, theme) +
|
|
566
|
+
matchLabel(result, theme),
|
|
567
|
+
);
|
|
568
|
+
} else if (name === "bash") {
|
|
569
|
+
callText.setText(
|
|
570
|
+
bulletColor(record, theme) +
|
|
571
|
+
formatCallBody(name, args, theme) +
|
|
572
|
+
formatBashResultLine(result, theme),
|
|
573
|
+
);
|
|
574
|
+
} else {
|
|
575
|
+
callText.setText(
|
|
576
|
+
bulletColor(record, theme) + formatCallBody(name, args, theme),
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (error) return new Text(theme.fg("error", error), 0, 0);
|
|
581
|
+
if (expanded) {
|
|
582
|
+
const output = formatExpandedOutput(result, theme);
|
|
583
|
+
if (output) return new Text(output, 0, 0);
|
|
584
|
+
}
|
|
585
|
+
return new Text("", 0, 0);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export { BULLET, DISCOVERY_TOOLS, GROUPABLE_TOOLS, bashGrepInfo };
|