@d3ara1n/pi-subagent 1.7.1 → 2.1.0
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 +18 -9
- package/package.json +1 -1
- package/preview.png +0 -0
- package/src/index.ts +35 -10
- package/src/render-async.ts +57 -3
- package/src/roles.ts +4 -2
- package/src/run.ts +2 -0
- package/src/spawn.test.ts +58 -1
- package/src/spawn.ts +102 -53
- package/src/types.ts +25 -2
- package/src/utils.test.ts +104 -0
- package/src/utils.ts +85 -0
- package/src/view.ts +304 -51
package/src/utils.test.ts
CHANGED
|
@@ -41,7 +41,9 @@ import {
|
|
|
41
41
|
createThrottler,
|
|
42
42
|
terminalResultLine,
|
|
43
43
|
buildDisplayItems,
|
|
44
|
+
completionNoticeLines,
|
|
44
45
|
formatToolCall,
|
|
46
|
+
briefFilesUsed,
|
|
45
47
|
} from "./utils.ts";
|
|
46
48
|
import type { ActivityEntry, SubagentResult, SubagentRole } from "./types.ts";
|
|
47
49
|
|
|
@@ -436,6 +438,60 @@ describe("terminalResultLine", () => {
|
|
|
436
438
|
});
|
|
437
439
|
});
|
|
438
440
|
|
|
441
|
+
// ── completionNoticeLines: background-run completion notice card ──
|
|
442
|
+
describe("completionNoticeLines", () => {
|
|
443
|
+
// Marker fakes so assertions can see both text and color placement.
|
|
444
|
+
const fg = (color: string, text: string) => `<${color}>${text}</${color}>`;
|
|
445
|
+
const bold = (text: string) => `*${text}*`;
|
|
446
|
+
|
|
447
|
+
test("header: bracket label + id (role) + plain-text outcome", () => {
|
|
448
|
+
assert.deepEqual(
|
|
449
|
+
completionNoticeLines({ id: "sub-3", role: "worker", outcome: "finished" }, fg, bold),
|
|
450
|
+
[
|
|
451
|
+
"<customMessageLabel>*[subagent]*</customMessageLabel> " +
|
|
452
|
+
"<customMessageText>sub-3 (worker)</customMessageText> " +
|
|
453
|
+
"<customMessageText>finished</customMessageText>",
|
|
454
|
+
],
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("failed colors the outcome error-red, cancelled warning-yellow", () => {
|
|
459
|
+
const failed = completionNoticeLines({ id: "sub-1", role: "x", outcome: "failed" }, fg, bold)[0];
|
|
460
|
+
assert.ok(failed.includes("<error>failed</error>"));
|
|
461
|
+
const cancelled = completionNoticeLines(
|
|
462
|
+
{ id: "sub-1", role: "x", outcome: "cancelled" },
|
|
463
|
+
fg,
|
|
464
|
+
bold,
|
|
465
|
+
)[0];
|
|
466
|
+
assert.ok(cancelled.includes("<warning>cancelled</warning>"));
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("task preview on its own line without a prefix, newlines flattened", () => {
|
|
470
|
+
const lines = completionNoticeLines(
|
|
471
|
+
{
|
|
472
|
+
id: "sub-2",
|
|
473
|
+
role: "explorer",
|
|
474
|
+
outcome: "finished",
|
|
475
|
+
task: "Map the routing\nstructure",
|
|
476
|
+
},
|
|
477
|
+
fg,
|
|
478
|
+
bold,
|
|
479
|
+
);
|
|
480
|
+
assert.deepEqual(lines[1], "<dim>Map the routing structure</dim>");
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("whitespace-only task: header-only notice", () => {
|
|
484
|
+
assert.deepEqual(
|
|
485
|
+
completionNoticeLines(
|
|
486
|
+
{ id: "sub-4", role: "worker", outcome: "finished", task: " " },
|
|
487
|
+
fg,
|
|
488
|
+
bold,
|
|
489
|
+
).length,
|
|
490
|
+
1,
|
|
491
|
+
);
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
439
495
|
// ── formatToolCall: single-line guarantee for TUI rows ──
|
|
440
496
|
describe("formatToolCall newline sanitization", () => {
|
|
441
497
|
const id = (_color: string, text: string) => text;
|
|
@@ -749,3 +805,51 @@ describe("streamed-text activity entries", () => {
|
|
|
749
805
|
);
|
|
750
806
|
});
|
|
751
807
|
});
|
|
808
|
+
|
|
809
|
+
describe("briefFilesUsed", () => {
|
|
810
|
+
const call = (toolName: string, args: Record<string, any>): ActivityEntry => ({
|
|
811
|
+
kind: "toolCall",
|
|
812
|
+
id: `tc-${toolName}`,
|
|
813
|
+
status: "done",
|
|
814
|
+
toolName,
|
|
815
|
+
args,
|
|
816
|
+
});
|
|
817
|
+
const F1 = "/Users/x/proj/src/a.ts";
|
|
818
|
+
const F2 = "/Users/x/proj/docs/guide.md";
|
|
819
|
+
|
|
820
|
+
test("marks a file matched by an exact-path arg", () => {
|
|
821
|
+
const used = briefFilesUsed([F1, F2], [call("read", { file_path: F1 })]);
|
|
822
|
+
assert.equal(used.get(F1), true);
|
|
823
|
+
assert.equal(used.get(F2), false);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
test("marks a file referenced inside a bash command", () => {
|
|
827
|
+
const used = briefFilesUsed([F1], [call("bash", { command: `cat ${F1} | head -5` })]);
|
|
828
|
+
assert.equal(used.get(F1), true);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test("marks a file the child addressed by relative path", () => {
|
|
832
|
+
const used = briefFilesUsed([F1], [call("edit", { file_path: "src/a.ts" })]);
|
|
833
|
+
assert.equal(used.get(F1), true);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("ignores short separator-less values — no false positives", () => {
|
|
837
|
+
const used = briefFilesUsed([F1], [call("grep", { pattern: "a.ts" })]);
|
|
838
|
+
assert.equal(used.get(F1), false);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("collects strings from nested args one level deep", () => {
|
|
842
|
+
const used = briefFilesUsed([F1], [
|
|
843
|
+
call("edit", { edits: [{ oldText: "x", path: F1 }] }),
|
|
844
|
+
]);
|
|
845
|
+
assert.equal(used.get(F1), true);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
test("skips non-toolCall entries and empty file lists", () => {
|
|
849
|
+
assert.equal(briefFilesUsed(undefined, [call("read", { file_path: F1 })]).size, 0);
|
|
850
|
+
const used = briefFilesUsed([F1], [
|
|
851
|
+
{ kind: "text", id: "text-0", status: "done", text: "hi" },
|
|
852
|
+
]);
|
|
853
|
+
assert.equal(used.get(F1), false);
|
|
854
|
+
});
|
|
855
|
+
});
|
package/src/utils.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type { Component } from "@earendil-works/pi-tui";
|
|
|
9
9
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
10
|
import type {
|
|
11
11
|
ActivityEntry,
|
|
12
|
+
CompletionNoticeDetails,
|
|
12
13
|
FallbackFrom,
|
|
13
14
|
RunState,
|
|
14
15
|
SubagentDetails,
|
|
@@ -269,6 +270,56 @@ export function renderDisplayItems(
|
|
|
269
270
|
return text.trimEnd();
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
// ── Brief page (subagent:view detail view) ─────────────────────
|
|
274
|
+
|
|
275
|
+
/** Collect string values from a tool call's args, up to a small nesting depth. */
|
|
276
|
+
function argStrings(args: Record<string, any> | undefined): string[] {
|
|
277
|
+
const out: string[] = [];
|
|
278
|
+
const walk = (v: unknown, depth: number): void => {
|
|
279
|
+
if (typeof v === "string") {
|
|
280
|
+
out.push(v);
|
|
281
|
+
} else if (depth > 0 && Array.isArray(v)) {
|
|
282
|
+
for (const item of v) walk(item, depth - 1);
|
|
283
|
+
} else if (depth > 0 && v && typeof v === "object") {
|
|
284
|
+
for (const item of Object.values(v)) walk(item, depth - 1);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
walk(args, 3);
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Arg values shorter than this (and without a separator) are ignored as path candidates. */
|
|
292
|
+
const PATH_MATCH_MIN = 5;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Cross-annotation for the brief page's file list: for each delegate
|
|
296
|
+
* reference file, did any tool call in the activity log touch it? Heuristic
|
|
297
|
+
* string match — a hit is either an arg containing the full path (absolute
|
|
298
|
+
* use, bash references) or the file path ending with the arg (the child
|
|
299
|
+
* using a relative form). Short separator-less values never match, so plain
|
|
300
|
+
* query strings cannot false-positive.
|
|
301
|
+
*/
|
|
302
|
+
export function briefFilesUsed(
|
|
303
|
+
files: string[] | undefined,
|
|
304
|
+
activityLog: ActivityEntry[],
|
|
305
|
+
): Map<string, boolean> {
|
|
306
|
+
const used = new Map<string, boolean>((files ?? []).map((f) => [f, false]));
|
|
307
|
+
if (used.size === 0) return used;
|
|
308
|
+
for (const entry of activityLog) {
|
|
309
|
+
if (entry.kind !== "toolCall") continue;
|
|
310
|
+
for (const v of argStrings(entry.args)) {
|
|
311
|
+
if (v.length < PATH_MATCH_MIN) continue;
|
|
312
|
+
for (const [file, hit] of used) {
|
|
313
|
+
if (hit) continue;
|
|
314
|
+
if (v.includes(file) || (v.includes("/") && file.endsWith(v))) {
|
|
315
|
+
used.set(file, true);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return used;
|
|
321
|
+
}
|
|
322
|
+
|
|
272
323
|
// ── Shared result-view composition ─────────────────────────────
|
|
273
324
|
// Used by the foreground delegate row (./render.ts) and the background
|
|
274
325
|
// wait/check rows (./render-async.ts) so every view renders the same
|
|
@@ -286,6 +337,40 @@ export function taskPreview(task: string): string {
|
|
|
286
337
|
return firstLine.length > 70 ? `${firstLine.slice(0, 70)}...` : firstLine;
|
|
287
338
|
}
|
|
288
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Lines of the background-run completion notice. Bracket label + id/role +
|
|
342
|
+
* outcome on the header line, the bare task preview beneath (dim). Pure
|
|
343
|
+
* notification: the result itself never appears here — it surfaces through
|
|
344
|
+
* subagent_check (model) or /subagent:status (user). The renderer truncates
|
|
345
|
+
* each line to the terminal width independently, so the header never falls
|
|
346
|
+
* off the right edge.
|
|
347
|
+
*
|
|
348
|
+
* Visual family: the [compaction] system-notice card (bracket label, purple
|
|
349
|
+
* box), deliberately NOT the tool-row family — no tool-title prefix, no
|
|
350
|
+
* status icons. Signals "system event", not model behavior.
|
|
351
|
+
*/
|
|
352
|
+
export function completionNoticeLines(
|
|
353
|
+
details: CompletionNoticeDetails,
|
|
354
|
+
fg: (color: string, text: string) => string,
|
|
355
|
+
bold: (text: string) => string,
|
|
356
|
+
): string[] {
|
|
357
|
+
// Intentional stops keep the plain text color; only real failures go red
|
|
358
|
+
// (cancelled keeps warning yellow, mirroring result-line semantics).
|
|
359
|
+
const outcomeColor =
|
|
360
|
+
details.outcome === "failed" ? "error" : details.outcome === "cancelled" ? "warning" : "customMessageText";
|
|
361
|
+
const header =
|
|
362
|
+
fg("customMessageLabel", bold("[subagent]")) +
|
|
363
|
+
` ${fg("customMessageText", `${details.id} (${details.role})`)} ` +
|
|
364
|
+
fg(outcomeColor, details.outcome);
|
|
365
|
+
|
|
366
|
+
const lines = [header];
|
|
367
|
+
const task = oneLine(details.task ?? "").trim();
|
|
368
|
+
if (task) {
|
|
369
|
+
lines.push(fg("dim", task));
|
|
370
|
+
}
|
|
371
|
+
return lines;
|
|
372
|
+
}
|
|
373
|
+
|
|
289
374
|
/**
|
|
290
375
|
* Width-aware collapsed-view component: renders each line truncated with "…"
|
|
291
376
|
* to the actual viewport width (never wraps), padded full-width like Text(0,0).
|