@arhen/pi-core-subagent 1.3.43 → 1.3.45

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/peek.ts +118 -32
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.43",
3
+ "version": "1.3.45",
4
4
  "type": "module",
5
5
  "description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
6
6
  "license": "MIT",
package/src/peek.ts CHANGED
@@ -38,51 +38,119 @@ function readTail(path: string): string {
38
38
  }
39
39
  }
40
40
 
41
+ /** Reasoning markup is the model talking to itself, and empty `<think></think>`
42
+ * pairs are the commonest single line in a transcript — they filled the pane with
43
+ * rows carrying no information at all. */
44
+ function stripThinking(text: string): string {
45
+ return text
46
+ .replace(/<think>[\s\S]*?<\/think>/g, " ")
47
+ .replace(/<\/?(?:think|thinking|reasoning)>/g, " ")
48
+ .trim();
49
+ }
50
+
51
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping real terminal escapes is the point
52
+ const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
53
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: same
54
+ const CONTROL = /[\x00-\x08\x0b-\x1f\x7f]/g;
55
+
56
+ /** Tool output is terminal output: it carries colour escapes and carriage returns
57
+ * that corrupt the pane's own styling (stray `[0m` mid-row, cursor jumps). */
41
58
  function clip(text: string, max: number): string {
42
- const flat = text.replace(/\s+/g, " ").trim();
59
+ const flat = text.replace(ANSI, "").replace(CONTROL, " ").replace(/\s+/g, " ").trim();
43
60
  return flat.length > max ? `${flat.slice(0, max)}…` : flat;
44
61
  }
45
62
 
46
- /** One session-file line → one display line: [gutter, text]. Irrelevant → null. */
47
- function eventLine(raw: string): [string, string] | null {
63
+ /** Long absolute paths are almost entirely prefix. The tail is what identifies
64
+ * the file, so shorten from the left and keep the last segments. */
65
+ function shortenPath(text: string): string {
66
+ return text.replace(/(?:\/[\w.@+-]+){3,}/g, (path) => {
67
+ const parts = path.split("/").filter(Boolean);
68
+ return parts.length <= 3 ? path : `…/${parts.slice(-3).join("/")}`;
69
+ });
70
+ }
71
+
72
+ const PATH_ARGS = ["path", "file", "filePath", "command", "pattern", "query", "url", "task", "subject"];
73
+
74
+ /** The argument that says WHAT a call does. Object.values() order is insertion
75
+ * order, so the old "first string wins" picked `oldText` blobs over `path`. */
76
+ function callSummary(args: Record<string, unknown> | undefined): string {
77
+ for (const key of PATH_ARGS) {
78
+ const value = args?.[key];
79
+ if (typeof value === "string" && value.trim()) return value;
80
+ }
81
+ const first = Object.values(args ?? {}).find((v) => typeof v === "string" && v.trim() !== "");
82
+ return typeof first === "string" ? first : "";
83
+ }
84
+
85
+ export type PeekLine = { gutter: string; text: string; kind: "call" | "result" | "error" | "say" };
86
+
87
+ /** One session-file entry → display lines. A turn with 4 tool calls is 4 lines:
88
+ * keeping only the first hid the parallel calls that explain what the child did. */
89
+ function eventLines(raw: string): PeekLine[] {
48
90
  let entry: any;
49
91
  try {
50
92
  entry = JSON.parse(raw);
51
93
  } catch {
52
- return null;
94
+ return [];
53
95
  }
54
96
  const msg = entry?.message;
55
- if (!msg) return null;
56
- const out: [string, string][] = [];
57
- for (const block of msg.content ?? []) {
97
+ if (!msg) return [];
98
+ const out: PeekLine[] = [];
99
+ const isResult = msg.role === "toolResult";
100
+ for (const block of Array.isArray(msg.content) ? msg.content : []) {
58
101
  if (block.type === "toolCall") {
59
- const arg = Object.values(block.arguments ?? {}).find((v) => typeof v === "string" && v.trim() !== "") as
60
- | string
61
- | undefined;
62
- out.push(["→", `${block.name}${arg ? ` ${clip(arg, 120)}` : ""}`]);
63
- } else if (block.type === "text" && block.text?.trim()) {
64
- out.push([msg.role === "toolResult" ? "←" : "·", clip(block.text, 160)]);
102
+ const arg = callSummary(block.arguments);
103
+ out.push({ gutter: "→", kind: "call", text: `${block.name}${arg ? ` ${shortenPath(clip(arg, 110))}` : ""}` });
104
+ } else if (block.type === "text") {
105
+ const text = stripThinking(block.text ?? "");
106
+ if (!text) continue; // a turn that was pure reasoning has nothing to show
107
+ if (isResult) {
108
+ const kind = msg.isError ? "error" : "result";
109
+ const name = msg.toolName ? `${msg.toolName}: ` : "";
110
+ out.push({ gutter: msg.isError ? "✗" : "←", kind, text: `${name}${shortenPath(clip(text, 150))}` });
111
+ } else {
112
+ out.push({ gutter: "·", kind: "say", text: clip(text, 150) });
113
+ }
65
114
  }
66
115
  }
67
- return out[0] ?? null;
116
+ return out;
68
117
  }
69
118
 
70
- function tailLines(path: string, max: number): [string, string][] {
119
+ function tailLines(path: string, max: number): PeekLine[] {
71
120
  let text: string;
72
121
  try {
73
122
  text = readTail(path);
74
123
  } catch {
75
- return [["·", "(session file not readable yet)"]];
124
+ return [{ gutter: "·", kind: "say", text: "(session file not readable yet)" }];
76
125
  }
77
- const lines: [string, string][] = [];
126
+ const lines: PeekLine[] = [];
78
127
  // First line of a mid-file read is usually a fragment — drop it.
79
- for (const raw of text.split("\n").slice(1)) {
80
- const line = eventLine(raw);
81
- if (line) lines.push(line);
82
- }
128
+ for (const raw of text.split("\n").slice(1)) lines.push(...eventLines(raw));
83
129
  return lines.slice(-max);
84
130
  }
85
131
 
132
+ /** Gutter colour carries the line's role, so the eye can skip to failures without
133
+ * reading: → call, ← result, ✗ error, · the child speaking. */
134
+ function renderLine(line: PeekLine, theme: Theme): string {
135
+ if (line.kind === "error") return `${theme.fg("error", line.gutter)} ${theme.fg("error", line.text)}`;
136
+ if (line.kind === "call") {
137
+ const space = line.text.indexOf(" ");
138
+ const name = space === -1 ? line.text : line.text.slice(0, space);
139
+ const arg = space === -1 ? "" : line.text.slice(space + 1);
140
+ return `${theme.fg("accent", line.gutter)} ${theme.fg("toolTitle", name)}${arg ? ` ${theme.fg("muted", arg)}` : ""}`;
141
+ }
142
+ if (line.kind === "result") return `${theme.fg("dim", line.gutter)} ${theme.fg("dim", line.text)}`;
143
+ return `${theme.fg("dim", line.gutter)} ${line.text}`;
144
+ }
145
+
146
+ /** Status as a coloured word, the way pi marks tool state. */
147
+ function statusTag(status: string, theme: Theme): string {
148
+ if (status === "failed") return theme.fg("error", status);
149
+ if (status === "completed") return theme.fg("success", status);
150
+ if (status === "aborted") return theme.fg("warning", status);
151
+ return theme.fg("muted", status);
152
+ }
153
+
86
154
  export interface PeekPane {
87
155
  render(width: number): string[];
88
156
  handleInput(data: string): void;
@@ -109,27 +177,45 @@ export function createPeekPane(
109
177
  const clamp = (n: number, len: number) => (len === 0 ? 0 : Math.max(0, Math.min(len - 1, n)));
110
178
 
111
179
  /**
112
- * Every row is padded to the full pane width and background-filled, so the
113
- * transcript underneath never shows through the overlay.
180
+ * Every row is a full-width bordered line: │ edge, one space of padding, the
181
+ * text padded out, then the closing edge. Background-filled so the transcript
182
+ * underneath never shows through the overlay, and edged so the pane reads as
183
+ * one object instead of a floating block of colour.
114
184
  */
115
185
  const row = (content: string, width: number): string => {
116
- const inner = width - 4; // 2 cols padding each side
186
+ const inner = width - 4; // │ + space each side
117
187
  const text = truncateToWidth(content, Math.max(0, inner), "…");
118
188
  const pad = Math.max(0, inner - visibleWidth(text));
119
- return theme.bg("selectedBg", ` ${text}${" ".repeat(pad)} `);
189
+ const edge = theme.fg("border", "│");
190
+ return theme.bg("selectedBg", `${edge} ${text}${" ".repeat(pad)} ${edge}`);
120
191
  };
121
192
 
193
+ /** Top/bottom cap, and the ├───┤ divider between chrome and content. */
194
+ const edgeRow = (width: number, left: string, right: string): string =>
195
+ theme.bg("selectedBg", theme.fg("border", `${left}${"─".repeat(Math.max(0, width - 2))}${right}`));
196
+
122
197
  return {
123
198
  render(width: number): string[] {
124
199
  const tasks = getTasks();
125
200
  selected = clamp(selected, tasks.length);
126
- if (tasks.length === 0) return [row(theme.fg("dim", "No subagents in this session."), width)];
201
+ if (tasks.length === 0) {
202
+ return [
203
+ edgeRow(width, "╭", "╮"),
204
+ row(theme.fg("dim", "No subagents in this session."), width),
205
+ edgeRow(width, "╰", "╯"),
206
+ ];
207
+ }
127
208
  const task = tasks[selected]!;
128
209
  const hint = confirming
129
210
  ? theme.fg("error", `abort ${task.agent}? y / n`)
130
211
  : theme.fg("dim", tailing ? "esc back · x abort" : "shift+↑↓ / jk move · enter tail · x abort · esc close");
131
- const title = `${theme.fg("accent", theme.bold(tailing ? task.agent : "Subagents"))} ${theme.fg("muted", `${selected + 1}/${tasks.length}`)}`;
132
- const lines = [row("", width), row(title, width), row(hint, width), row("", width)];
212
+ // Breadcrumb, not a bare name: while tailing, WHERE you are is the thing you
213
+ // lose track of first, and the child's status belongs next to its name.
214
+ const crumb = tailing
215
+ ? `${theme.fg("muted", "Subagents")}${theme.fg("dim", " › ")}${theme.fg("accent", theme.bold(task.agent))} ${statusTag(task.status, theme)}`
216
+ : theme.fg("accent", theme.bold("Subagents"));
217
+ const title = `${crumb} ${theme.fg("muted", `${selected + 1}/${tasks.length}`)}`;
218
+ const lines = [edgeRow(width, "╭", "╮"), row(title, width), row(hint, width), edgeRow(width, "├", "┤")];
133
219
 
134
220
  if (!tailing) {
135
221
  for (const [i, t] of tasks.entries()) {
@@ -140,11 +226,11 @@ export function createPeekPane(
140
226
  lines.push(row(theme.fg("dim", "(no session file — agent has not started yet)"), width));
141
227
  } else {
142
228
  // ponytail: re-reads the tail each render (700ms poll). A watcher only pays off for files far bigger than a child session.
143
- for (const [gutter, text] of tailLines(task.sessionFile, TAIL_ROWS)) {
144
- lines.push(row(`${theme.fg("dim", gutter)} ${gutter === "←" ? theme.fg("dim", text) : text}`, width));
145
- }
229
+ const tail = tailLines(task.sessionFile, TAIL_ROWS);
230
+ if (tail.length === 0) lines.push(row(theme.fg("dim", "(no activity yet)"), width));
231
+ for (const line of tail) lines.push(row(renderLine(line, theme), width));
146
232
  }
147
- lines.push(row("", width));
233
+ lines.push(edgeRow(width, "╰", "╯"));
148
234
  return lines;
149
235
  },
150
236
  handleInput(data: string): void {