@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/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).