@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.
Files changed (36) hide show
  1. package/README.md +83 -83
  2. package/package.json +62 -48
  3. package/plugins/devin-auth/extensions/index.ts +68 -7
  4. package/plugins/devin-auth/src/cloud-direct/auth.ts +246 -246
  5. package/plugins/devin-auth/src/cloud-direct/catalog.ts +246 -246
  6. package/plugins/devin-auth/src/cloud-direct/chat.ts +1096 -1091
  7. package/plugins/devin-auth/src/cloud-direct/index.ts +41 -41
  8. package/plugins/devin-auth/src/cloud-direct/metadata.ts +78 -78
  9. package/plugins/devin-auth/src/cloud-direct/wire.ts +202 -202
  10. package/plugins/devin-auth/src/models.ts +42 -196
  11. package/plugins/devin-auth/src/oauth/register-user.ts +174 -174
  12. package/plugins/devin-auth/src/oauth/types.ts +71 -71
  13. package/plugins/devin-auth/src/stream.ts +1 -1
  14. package/plugins/index.ts +29 -6
  15. package/plugins/pi-compact-tools/index.ts +35 -192
  16. package/plugins/pi-compact-tools/renderer.ts +589 -0
  17. package/plugins/pi-custom-agents/index.ts +727 -373
  18. package/plugins/pi-custom-agents/questionnaire-tool.ts +14 -5
  19. package/plugins/pi-custom-agents/subagent/agents/coder.md +1 -0
  20. package/plugins/pi-custom-agents/subagent/agents/scout.md +16 -37
  21. package/plugins/pi-custom-agents/subagent/extensions/agents.ts +18 -1
  22. package/plugins/pi-custom-agents/subagent/extensions/index.ts +118 -226
  23. package/plugins/pi-custom-agents/subagent/extensions/model.ts +96 -96
  24. package/plugins/pi-custom-agents/subagent/extensions/render.ts +205 -1
  25. package/plugins/pi-custom-agents/subagent/extensions/runner.ts +260 -170
  26. package/plugins/pi-custom-agents/subagent/extensions/test/render.test.ts +145 -0
  27. package/plugins/pi-ember-fff/index.ts +975 -0
  28. package/plugins/pi-ember-fff/query.ts +247 -0
  29. package/plugins/pi-ember-fff/test/query.test.ts +222 -0
  30. package/plugins/pi-ember-fff/test/renderer.test.ts +727 -0
  31. package/plugins/pi-ember-tps/index.ts +144 -0
  32. package/plugins/pi-ember-ui/ember.json +99 -0
  33. package/plugins/pi-ember-ui/index.ts +805 -0
  34. package/plugins/pi-ember-ui/mode-colors.ts +196 -0
  35. package/tsconfig.json +2 -1
  36. 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 };