pi-jar 0.1.3 → 0.1.4

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.
@@ -1,11 +1,12 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
- import type { DelegateRegistry, DelegateRun, SubagentRecord } from "./delegate.ts";
3
+ import { duration, leafTodos, type DelegateRegistry, type DelegateRun, type SubagentRecord, type TranscriptEntry } from "./delegate.ts";
4
4
  import { safeLine } from "./diff-view.ts";
5
5
  import { icon, withIcon } from "./icons.ts";
6
6
  import { describe, elapsed, type ShellJob, type ShellManager } from "./shells.ts";
7
7
  import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
8
8
  import { ACTIVE_STATES, cleanText } from "./status.ts";
9
+ import type { Todo } from "./tasks.ts";
9
10
 
10
11
  /** What to select when the view opens. */
11
12
  export type ActivityTarget = { kind: "subagent" | "shell"; id: string };
@@ -25,11 +26,18 @@ type Entry =
25
26
  | { kind: "shell"; id: string; status: Status; label: string; job: Omit<ShellJob, "lines"> }
26
27
  | { kind: "role"; id: string; status: Status; label: string; role: ActivityRole };
27
28
  type Item = Entry | { kind: "header"; label: string };
29
+ type Focus = "list" | "transcript" | "input";
28
30
 
29
31
  const COLOR: Record<Status, string> = { running: "accent", pending: "dim", success: "success", error: "error", stopped: "dim" };
30
32
  const ACTIONS = [{ key: "x" }, { key: "f" }] as const;
31
33
  const SHELL_TAIL = 400;
32
34
  const MAX_TASK_CHARS = 8000;
35
+ /** Expanded tool output shows at most this many wrapped rows; the rest is summarized. */
36
+ const MAX_OUTPUT_ROWS = 80;
37
+ const MAX_ARGS_ROWS = 12;
38
+ const LIVE_ROWS = 4;
39
+ const MAX_DRAFT = 2000;
40
+ const SPINNER = ["◐", "◓", "◑", "◒"];
33
41
 
34
42
  /** Left-pane rows: a header per non-empty section, then its entries. */
35
43
  function collect(sources: ActivitySources): Item[] {
@@ -60,39 +68,165 @@ function collect(sources: ActivitySources): Item[] {
60
68
  }
61
69
 
62
70
  /** `head` stays pinned above the scrolling `body`, so following a transcript never hides who is running and how. */
63
- interface Details { head: string[]; body: string[] }
71
+ interface Details {
72
+ head: string[];
73
+ body: readonly string[];
74
+ /** Styles one body line; applied to the visible window only, so a 400-line shell tail costs a screenful. */
75
+ format?: (line: string) => string;
76
+ }
77
+
78
+ /**
79
+ * One collapsible piece of a subagent's details. `key` is stable across renders (the transcript entry
80
+ * itself, or a per-run object), so expansion state and cached rows survive live updates.
81
+ */
82
+ interface Block {
83
+ key: object;
84
+ /** Changes whenever the block's content does. */
85
+ rev: string;
86
+ expandable: boolean;
87
+ /** Expanded unless the user toggled it. */
88
+ open: boolean;
89
+ /** Rows for the given state, without the two-column gutter. */
90
+ rows(expanded: boolean, width: number): string[];
91
+ }
92
+
93
+ const wrap = (text: string, width: number) => {
94
+ const rows: string[] = [];
95
+ for (const raw of text.split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), Math.max(1, width))) rows.push(line);
96
+ return rows;
97
+ };
98
+ const firstLine = (text: string) => { const end = text.indexOf("\n"); return end < 0 ? text : text.slice(0, end); };
99
+ const lineCount = (text: string) => { let count = 1; for (let index = text.indexOf("\n"); index >= 0; index = text.indexOf("\n", index + 1)) count++; return count; };
100
+ const clipped = (rows: string[], limit: number, fg: Fg) => rows.length <= limit ? rows : [...rows.slice(0, limit), fg("dim", `… ${rows.length - limit} more lines`)];
101
+
102
+ function todoRows(todos: readonly Todo[], width: number, fg: Fg): string[] {
103
+ const counts = new Map<string, { done: number; total: number }>();
104
+ for (const todo of todos) if (todo.parentId) {
105
+ const count = counts.get(todo.parentId) ?? { done: 0, total: 0 };
106
+ count.total++;
107
+ if (todo.done) count.done++;
108
+ counts.set(todo.parentId, count);
109
+ }
110
+ return todos.map((todo) => {
111
+ const indent = todo.parentId ? " " : " ";
112
+ const count = counts.get(todo.id);
113
+ const suffix = count ? fg("dim", ` (${count.done}/${count.total})`) : "";
114
+ const room = Math.max(1, width - indent.length - 2 - (count ? `${count.done}/${count.total}`.length + 3 : 0));
115
+ const title = truncateToWidth(safeLine(todo.title), room);
116
+ if (todo.status === "completed") return fg("success", `${indent}✔ `) + fg("dim", "\x1b[9m" + title + "\x1b[29m") + suffix;
117
+ if (todo.status === "in_progress") return fg("accent", `${indent}◼ ${title}`) + suffix;
118
+ return fg("muted", `${indent}☐ ${title}`) + suffix;
119
+ });
120
+ }
121
+
122
+ /** Per-run keys for the blocks that are not transcript entries. */
123
+ const runKeys = new WeakMap<DelegateRun, { task: object; todos: object; live: object; report: object }>();
124
+ const keysOf = (run: DelegateRun) => {
125
+ let keys = runKeys.get(run);
126
+ if (!keys) { keys = { task: {}, todos: {}, live: {}, report: {} }; runKeys.set(run, keys); }
127
+ return keys;
128
+ };
129
+
130
+ function entryBlock(entry: TranscriptEntry, fg: Fg): Block {
131
+ if (entry.kind === "tool") {
132
+ const glyph = entry.status === "running" ? fg("accent", "●") : entry.status === "error" ? fg("error", "✖") : fg("success", "✔");
133
+ const took = entry.endedAt !== undefined ? fg("dim", ` · ${duration(entry.endedAt - entry.startedAt)}`) : fg("dim", " · running");
134
+ return {
135
+ key: entry, rev: `${entry.rev}`, expandable: true, open: false,
136
+ rows(expanded, width) {
137
+ const head = fg("dim", expanded ? "▾ " : "▸ ") + glyph + " " + fg("accent", entry.name) + (entry.hint ? " " + fg("muted", safeLine(entry.hint)) : "") + took;
138
+ if (!expanded) return [truncateToWidth(head, width)];
139
+ const rows = [truncateToWidth(head, width)];
140
+ if (entry.args && entry.args !== JSON.stringify(entry.hint)) rows.push(...clipped(wrap(entry.args, width - 4).map((line) => fg("dim", " " + line)), MAX_ARGS_ROWS, fg));
141
+ if (entry.output) rows.push(...clipped(wrap(entry.output, width - 4).map((line) => fg(entry.status === "error" ? "error" : "muted", " " + line)), MAX_OUTPUT_ROWS, fg));
142
+ else rows.push(fg("dim", entry.status === "running" ? " (running…)" : " (no output)"));
143
+ return rows;
144
+ }
145
+ };
146
+ }
147
+ const multi = entry.text.length > 60 || entry.text.includes("\n");
148
+ const [mark, tone] = entry.kind === "steer" ? ["› you: ", "accent"] : entry.kind === "note" ? ["! ", "warning"] : ["◆ ", "text"];
149
+ return {
150
+ key: entry, rev: `${entry.rev}`, expandable: multi, open: false,
151
+ rows(expanded, width) {
152
+ const chevron = multi ? fg("dim", expanded ? "▾ " : "▸ ") : " ";
153
+ if (!expanded || !multi) {
154
+ const more = lineCount(entry.text) > 1 ? fg("dim", ` +${lineCount(entry.text) - 1} lines`) : "";
155
+ return [truncateToWidth(chevron + fg(tone, mark + safeLine(firstLine(entry.text))), Math.max(1, width - visibleWidth(more))) + more];
156
+ }
157
+ return [chevron + fg(tone, mark.trimEnd()), ...wrap(entry.text, width - 4).map((line) => fg(tone === "text" ? "text" : tone, " " + line))];
158
+ }
159
+ };
160
+ }
161
+
162
+ /** Everything under a subagent's pinned head: its task, checklist, transcript, live text and report. */
163
+ function subagentBlocks(run: DelegateRun, fg: Fg): Block[] {
164
+ const keys = keysOf(run);
165
+ const blocks: Block[] = [{
166
+ key: keys.task, rev: "task", expandable: true, open: false,
167
+ rows(expanded, width) {
168
+ if (!expanded) return [truncateToWidth(fg("dim", "▸ TASK ") + fg("text", safeLine(firstLine(run.task))), width)];
169
+ return [fg("dim", "▾ TASK"), ...wrap(run.task.slice(0, MAX_TASK_CHARS), width - 4).map((line) => fg("text", " " + line))];
170
+ }
171
+ }];
172
+ if (run.todos.length) {
173
+ const leaves = leafTodos(run.todos);
174
+ const done = leaves.filter((todo) => todo.done).length;
175
+ blocks.push({
176
+ key: keys.todos, rev: run.todos.map((todo) => todo.id + todo.status).join(), expandable: true, open: true,
177
+ rows(expanded, width) {
178
+ const head = fg("dim", (expanded ? "▾ " : "▸ ") + "TASKS ") + fg(done === leaves.length ? "success" : "accent", `${done}/${leaves.length} done`);
179
+ return expanded ? [head, ...todoRows(run.todos, width - 2, fg).map((row) => " " + row)] : [head];
180
+ }
181
+ });
182
+ }
183
+ if (!run.transcript.length && !run.live) blocks.push({ key: keys.live, rev: "empty", expandable: false, open: true, rows: () => [fg("dim", " (nothing yet)")] });
184
+ // Once finished, the last message is the report shown below: list it once.
185
+ const reported = !!run.output && run.state !== "working" && run.state !== "queued";
186
+ let last = -1;
187
+ if (reported) for (let index = run.transcript.length - 1; index >= 0 && last < 0; index--) if (run.transcript[index]!.kind === "text") last = index;
188
+ for (let index = 0; index < run.transcript.length; index++) if (index !== last) blocks.push(entryBlock(run.transcript[index]!, fg));
189
+ if (run.live) {
190
+ const live = run.live;
191
+ blocks.push({
192
+ // The text itself is the revision (it is capped, so its length stops changing); only a tail is wrapped.
193
+ key: keys.live, rev: live, expandable: false, open: true,
194
+ rows: (_expanded, width) => [fg("accent", " ✎ writing…"),
195
+ ...wrap(live.slice(-Math.max(200, width * (LIVE_ROWS + 1))), width - 4).slice(-LIVE_ROWS).map((line) => fg("muted", " " + line))]
196
+ });
197
+ }
198
+ if (reported) {
199
+ blocks.push({
200
+ key: keys.report, rev: run.output, expandable: true, open: true,
201
+ rows(expanded, width) {
202
+ const head = fg("dim", (expanded ? "▾ " : "▸ ") + "REPORT");
203
+ return expanded ? [head, ...wrap(run.output, width - 4).map((line) => fg("text", " " + line))] : [head];
204
+ }
205
+ });
206
+ }
207
+ return blocks;
208
+ }
64
209
 
65
- function subagentDetails(run: DelegateRun, status: Status, width: number, fg: Fg): Details {
210
+ function subagentHead(run: DelegateRun, status: Status, width: number, fg: Fg): string[] {
66
211
  const facts = [
67
212
  run.startedAt !== undefined ? elapsed({ startedAt: run.startedAt, endedAt: run.endedAt }) : "",
68
- `${run.tools} tool${run.tools === 1 ? "" : "s"}`, `${run.turns} turn${run.turns === 1 ? "" : "s"}`, `$${run.cost.toFixed(3)}`
213
+ `${run.tools} tool${run.tools === 1 ? "" : "s"}`, `${run.turns} turn${run.turns === 1 ? "" : "s"}`, `$${run.cost.toFixed(3)}`,
214
+ run.filesEdited.length ? `${run.filesEdited.length} edited` : "", run.steered ? `steered ${run.steered}×` : ""
69
215
  ].filter(Boolean);
70
216
  const head = [
71
217
  fg("accent", [run.name, run.role, run.model].filter(Boolean).join(" · ")),
72
218
  fg(COLOR[status], withIcon(status, status === "stopped" ? "stopped" : run.state)) + fg("dim", " · " + facts.join(" · "))
73
219
  ];
74
- if (run.activity) head.push(fg("muted", run.activity));
220
+ // A turning glyph on the 1 s tick: a long tool call or a slow model still looks alive.
221
+ if (run.activity) head.push(fg("muted", (run.state === "working" ? SPINNER[Math.floor(Date.now() / 1000) % SPINNER.length] + " " : "") + run.activity));
75
222
  if (run.error && status !== "stopped") for (const line of wrapTextWithAnsi(safeLine(run.error), width).slice(0, 2)) head.push(fg("error", line));
76
- const body = ["", fg("dim", "TASK")];
77
- for (const raw of run.task.slice(0, MAX_TASK_CHARS).split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
78
- body.push("", fg("dim", "── transcript ──"));
79
- if (!run.log.length) body.push(fg("dim", "(nothing yet)"));
80
- for (const entry of run.log) {
81
- const tone = entry.startsWith("▸ ") ? "accent" : "muted";
82
- for (const line of wrapTextWithAnsi(entry, width)) body.push(fg(tone, line));
83
- }
84
- if (run.output) {
85
- body.push("", fg("dim", "── report ──"));
86
- for (const raw of run.output.split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
87
- }
88
- return { head, body };
223
+ return head;
89
224
  }
90
225
 
91
226
  function shellDetails(job: Omit<ShellJob, "lines">, output: readonly string[], width: number, fg: Fg): Details {
92
227
  const head = [fg("accent", safeLine(describe(job))), fg("muted", "$ " + cleanText(job.command, 400)), fg("dim", withIcon("folder", safeLine(job.cwd)))];
93
- const body = ["", ...output.map((line) => fg("muted", truncateToWidth(safeLine(line), width)))];
94
- if (!output.length) body.push(fg("dim", "(no output yet)"));
95
- return { head, body };
228
+ if (!output.length) return { head, body: ["", fg("dim", "(no output yet)")] };
229
+ return { head, body: ["", ...output], format: (line) => line && fg("muted", truncateToWidth(safeLine(line), width)) };
96
230
  }
97
231
 
98
232
  function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg): Details {
@@ -103,7 +237,8 @@ function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg):
103
237
 
104
238
  /**
105
239
  * Overlay: subagents, shells and other extensions' teammates on the left; the selected one's live
106
- * details on the right, following new output until scrolled up. x stops a subagent or kills a shell.
240
+ * details on the right, following new output until scrolled up. For subagents the details are
241
+ * collapsible blocks (task, checklist, each tool call, text, report) and `s` steers the run.
107
242
  */
108
243
  export async function openActivityView(ctx: ExtensionContext, sources: ActivitySources, initial?: ActivityTarget): Promise<void> {
109
244
  if (!ctx.hasUI || ctx.mode !== "tui") return;
@@ -124,8 +259,18 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
124
259
  let listTop = 0;
125
260
  let width = 80;
126
261
  let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
262
+ let pinnedRows = 0;
127
263
  let timer: NodeJS.Timeout | undefined;
128
264
  let closed = false;
265
+ let focus: Focus = "list";
266
+ let draft = "";
267
+ let inputRow = -1;
268
+ // Subagent body layout of the last render: which block starts at which body row.
269
+ let blocks: Block[] = [];
270
+ let offsets: number[] = [];
271
+ const cursors = new Map<string, object>();
272
+ const toggled = new WeakMap<object, boolean>();
273
+ const rowCache = new WeakMap<object, { sig: string; rows: string[] }>();
129
274
  const fg: Fg = (color, text) => theme.fg(color as never, text);
130
275
  const shells = sources.shells;
131
276
  const previous = shells?.onChange;
@@ -140,6 +285,19 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
140
285
  if (shells) shells.onChange = previous;
141
286
  };
142
287
  const close = () => { cleanup(); done(); };
288
+ const selected = (): Entry | undefined => { const item = items[at]; return item && item.kind !== "header" ? item : undefined; };
289
+ const steerable = () => { const item = selected(); return item?.kind === "subagent" && item.record.run.state === "working"; };
290
+ const isOpen = (block: Block) => block.expandable ? toggled.get(block.key) ?? block.open : true;
291
+ /** Rows of one block, rebuilt only when its content, state or width changed. */
292
+ const rowsOf = (block: Block, bodyWidth: number) => {
293
+ const expanded = isOpen(block);
294
+ const sig = `${block.rev}|${expanded}|${bodyWidth}`;
295
+ const cached = rowCache.get(block.key);
296
+ if (cached?.sig === sig) return cached.rows;
297
+ const rows = block.rows(expanded, bodyWidth);
298
+ rowCache.set(block.key, { sig, rows });
299
+ return rows;
300
+ };
143
301
  // Rows reorder live (finished subagents sink, old ones drop off): keep the selection by identity.
144
302
  const refresh = () => {
145
303
  const current = items[at];
@@ -155,6 +313,7 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
155
313
  for (let index = at + delta; index >= 0 && index < items.length; index += delta) {
156
314
  if (items[index]!.kind === "header") continue;
157
315
  at = index; follow = true; scroll = 0;
316
+ if (focus === "input") focus = "list";
158
317
  return;
159
318
  }
160
319
  };
@@ -164,25 +323,83 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
164
323
  // Scrolling up pauses follow; scrolling back down to the end resumes it.
165
324
  follow = delta > 0 && scroll >= maxScroll;
166
325
  };
326
+ const cursorIndex = () => {
327
+ const item = selected();
328
+ const key = item?.kind === "subagent" ? cursors.get(item.id) : undefined;
329
+ const index = key ? blocks.findIndex((block) => block.key === key) : -1;
330
+ return index >= 0 ? index : blocks.length - 1;
331
+ };
332
+ const setCursor = (index: number) => {
333
+ const item = selected();
334
+ if (item?.kind !== "subagent" || !blocks.length) return;
335
+ const target = Math.max(0, Math.min(blocks.length - 1, index));
336
+ cursors.set(item.id, blocks[target]!.key);
337
+ follow = false;
338
+ // Keep the block's first row in view.
339
+ const row = offsets[target] ?? 0;
340
+ if (row < scroll) scroll = row;
341
+ else if (row >= scroll + bodyRows) scroll = Math.min(maxScroll, row - bodyRows + 1);
342
+ };
343
+ const toggle = (index: number) => {
344
+ const block = blocks[index];
345
+ if (!block?.expandable) return;
346
+ toggled.set(block.key, !isOpen(block));
347
+ setCursor(index);
348
+ };
167
349
  const stopSelected = () => {
168
- const item = items[at];
169
- if (!item || item.kind === "header" || item.kind === "role" || (item.status !== "running" && item.status !== "pending")) return;
350
+ const item = selected();
351
+ if (!item || item.kind === "role" || (item.status !== "running" && item.status !== "pending")) return;
170
352
  try {
171
353
  if (item.kind === "subagent") sources.subagents.stop(item.id);
172
354
  else shells?.kill(item.id);
173
355
  } catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
174
356
  };
357
+ const send = () => {
358
+ const item = selected();
359
+ const text = draft.trim();
360
+ if (!text || item?.kind !== "subagent") return;
361
+ if (sources.subagents.steer(item.id, text)) { draft = ""; focus = "transcript"; follow = true; cursors.delete(item.id); }
362
+ else ctx.ui.notify("pi-jar: that subagent is not taking messages (it is not working)", "warning");
363
+ };
364
+ const input = (data: string) => {
365
+ if (matchesKey(data, Key.escape)) { focus = "transcript"; return; }
366
+ if (matchesKey(data, Key.enter)) { send(); return; }
367
+ if (matchesKey(data, Key.backspace) || data === "\x7f" || data === "\b") { draft = draft.slice(0, -1); return; }
368
+ // Typed or pasted text: drop terminal sequences and control characters; line breaks become spaces.
369
+ const text = stripTerminalSequences(data).replace(/[\r\n\t]+/g, " ").replace(/[\x00-\x1f\x7f]/g, "");
370
+ if (text) draft = (draft + text).slice(0, MAX_DRAFT);
371
+ };
175
372
  const component = {
176
373
  invalidate() {},
177
374
  dispose: cleanup,
178
375
  handleInput(data: string) {
376
+ if (focus === "input") { input(data); tui.requestRender(); return; }
377
+ const item = selected();
378
+ const subagent = item?.kind === "subagent";
379
+ if (focus === "transcript") {
380
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.tab) || matchesKey(data, Key.left) || data === "h") focus = "list";
381
+ else if (data === "q") return close();
382
+ else if (matchesKey(data, Key.up) || data === "k") setCursor(cursorIndex() - 1);
383
+ else if (matchesKey(data, Key.down) || data === "j") setCursor(cursorIndex() + 1);
384
+ else if (matchesKey(data, Key.enter) || data === " ") toggle(cursorIndex());
385
+ else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
386
+ else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
387
+ else if (data === "f" || data === "G") follow = true;
388
+ else if (data === "s" && steerable()) focus = "input";
389
+ else if (data === "x") stopSelected();
390
+ else return;
391
+ tui.requestRender();
392
+ return;
393
+ }
179
394
  if (matchesKey(data, Key.escape) || data === "q") return close();
180
395
  if (matchesKey(data, Key.up) || data === "k") move(-1);
181
396
  else if (matchesKey(data, Key.down) || data === "j") move(1);
397
+ else if (subagent && (matchesKey(data, Key.tab) || matchesKey(data, Key.right) || data === "l" || matchesKey(data, Key.enter))) focus = "transcript";
182
398
  else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
183
399
  else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
184
400
  else if (data === "f" || data === "G") follow = true;
185
401
  else if (matchesKey(data, Key.enter)) follow = !follow;
402
+ else if (data === "s" && steerable()) focus = "input";
186
403
  else if (data === "x") stopSelected();
187
404
  else return;
188
405
  tui.requestRender();
@@ -196,31 +413,92 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
196
413
  const index = listTop + row;
197
414
  if (!items[index] || items[index].kind === "header") return;
198
415
  if (index !== at) { at = index; follow = true; scroll = 0; }
416
+ focus = "list";
417
+ tui.requestRender();
418
+ return { handled: true, focus: true };
419
+ }
420
+ if (row >= pinnedRows && row < layout.rows && blocks.length && selected()?.kind === "subagent") {
421
+ // A click on a block's first row expands or collapses it; any other row just points the cursor at it.
422
+ const line = scroll + row - pinnedRows;
423
+ let index = -1;
424
+ for (let block = 0; block < offsets.length && offsets[block]! <= line; block++) index = block;
425
+ if (index < 0) return;
426
+ focus = "transcript";
427
+ if (offsets[index] === line) toggle(index); else setCursor(index);
199
428
  tui.requestRender();
200
429
  return { handled: true, focus: true };
201
430
  }
431
+ if (inputRow >= 0 && event.y === inputRow && steerable()) { focus = "input"; tui.requestRender(); return { handled: true, focus: true }; }
202
432
  const action = ACTIONS[event.y - layout.footerTop];
203
- if (action) { component.handleInput(action.key); return { handled: true }; }
433
+ if (action) { const previousFocus = focus; focus = "list"; component.handleInput(action.key); focus = previousFocus; return { handled: true }; }
204
434
  },
205
435
  render(available: number): string[] {
206
436
  width = Math.max(24, available);
207
- const rows = contentRows(6 + ACTIONS.length, 6);
208
437
  refresh();
209
- const picked = items[at];
210
- const item = picked && picked.kind !== "header" ? picked : undefined;
438
+ const item = selected();
439
+ const subagent = item?.kind === "subagent" ? item : undefined;
440
+ if (!subagent && focus !== "list") focus = "list";
441
+ if (focus === "input" && !steerable()) focus = "transcript";
211
442
  const listWidth = sidebarWidth(width, 20, 34);
212
443
  const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
213
444
  const entries = items.filter((entry): entry is Entry => entry.kind !== "header");
214
- const details = !item ? { head: [fg("dim", "Nothing to show.")], body: [] }
215
- : item.kind === "subagent" ? subagentDetails(item.record.run, item.status, bodyWidth, fg)
445
+ // Footer: the two actions, the steering input for subagents, then key hints.
446
+ const actions = optionList(theme, [item?.kind === "shell" ? "Kill the selected shell" : item?.kind === "role" ? "Stop (teammates from other extensions are read-only)" : "Stop the selected subagent",
447
+ "Follow the latest output"], -1, ACTIONS.map((action) => action.key));
448
+ const stoppable = item?.kind === "subagent" ? item.status === "running" || item.status === "pending" : item?.kind === "shell" && item.status === "running";
449
+ if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
450
+ const footer = [...actions];
451
+ if (subagent) {
452
+ const room = Math.max(4, width - 16);
453
+ footer.push(focus === "input"
454
+ ? fg("accent", "› steer: ") + fg("text", truncateToWidth(draft.length > room ? "…" + draft.slice(-room + 1) : draft, room)) + fg("accent", "█")
455
+ : fg("dim", steerable() ? "› s steer this subagent (type a message, Enter sends)" : "› steering works while a subagent is running"));
456
+ }
457
+ footer.push(fg("dim", focus === "input" ? `type a message · ${withIcon("enter", "send")} · ${withIcon("esc", "cancel")}`
458
+ : focus === "transcript" ? `↑↓ step · ${withIcon("enter", "expand")} · tab list · s steer · ${withIcon("esc", "back")}`
459
+ : `↑↓ select · ${subagent ? "tab transcript" : "PgUp/PgDn scroll"} · ${withIcon("enter", subagent ? "open" : "follow")} · ${withIcon("esc", "close")}`));
460
+ const rows = contentRows(3 + footer.length, 6);
461
+ const details: Details | undefined = !item || item.kind === "subagent" ? undefined
216
462
  : item.kind === "shell" ? shellDetails(item.job, shells?.output(item.id, SHELL_TAIL) ?? [], bodyWidth, fg)
217
463
  : roleDetails(item.role, item.status, bodyWidth, fg);
464
+ const head = !item ? [fg("dim", "Nothing to show.")] : item.kind === "subagent" ? subagentHead(item.record.run, item.status, bodyWidth, fg) : details!.head;
218
465
  // Narrow terminals drop the list: a pager row above the pinned facts says what is selected.
219
466
  const pinned = [...(!listWidth && item ? [fg("accent", `‹ ${entries.indexOf(item) + 1}/${entries.length} ${withIcon(item.status, item.label)} ›`)] : []),
220
- ...details.head].slice(0, rows - 1);
467
+ ...head].slice(0, rows - 1);
468
+ pinnedRows = pinned.length;
221
469
  bodyRows = rows - pinned.length;
222
- maxScroll = Math.max(0, details.body.length - bodyRows);
223
- scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
470
+ let body: string[];
471
+ if (subagent) {
472
+ blocks = subagentBlocks(subagent.record.run, fg);
473
+ const blockRows = blocks.map((block) => rowsOf(block, bodyWidth - 2));
474
+ offsets = [];
475
+ let total = 0;
476
+ for (const block of blockRows) { offsets.push(total); total += block.length; }
477
+ // A blank row separates the pinned head from the blocks.
478
+ offsets = offsets.map((offset) => offset + 1);
479
+ total += 1;
480
+ maxScroll = Math.max(0, total - bodyRows);
481
+ scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
482
+ // Only the visible window gets its gutter: long transcripts cost nothing off-screen.
483
+ const cursor = focus === "transcript" ? cursorIndex() : -1;
484
+ body = scroll === 0 ? [""] : [];
485
+ for (let index = 0; index < blocks.length && body.length < bodyRows; index++) {
486
+ const start = offsets[index]!;
487
+ const rowsInBlock = blockRows[index]!;
488
+ if (start + rowsInBlock.length <= scroll) continue;
489
+ for (let line = Math.max(0, scroll - start); line < rowsInBlock.length && body.length < bodyRows; line++) {
490
+ body.push((line === 0 && index === cursor ? fg("accent", "❯ ") : " ") + rowsInBlock[line]!);
491
+ }
492
+ }
493
+ } else {
494
+ blocks = [];
495
+ offsets = [];
496
+ const lines = details?.body ?? [];
497
+ maxScroll = Math.max(0, lines.length - bodyRows);
498
+ scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
499
+ body = lines.slice(scroll, scroll + bodyRows);
500
+ if (details?.format) body = body.map(details.format);
501
+ }
224
502
  if (at >= 0 && at < listTop) listTop = items[at - 1]?.kind === "header" ? at - 1 : at;
225
503
  if (at >= listTop + rows) listTop = at - rows + 1;
226
504
  listTop = Math.max(0, Math.min(listTop, items.length - rows));
@@ -228,18 +506,13 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
228
506
  if (entry.kind === "header") return fg("dim", truncateToWidth(entry.label, listWidth));
229
507
  const active = listTop + offset === at;
230
508
  const glyph = icon(entry.status);
231
- return fg(active ? "accent" : "muted", active ? "▌" : " ") + fg(COLOR[entry.status], glyph + " ")
509
+ return fg(active ? "accent" : "muted", active ? (focus === "list" ? "▌" : "▏") : " ") + fg(COLOR[entry.status], glyph + " ")
232
510
  + fg(active ? "accent" : "muted", truncateToWidth(entry.label, Math.max(1, listWidth - 2 - visibleWidth(glyph))));
233
511
  }) : [];
234
512
  const running = entries.filter((entry) => entry.status === "running").length;
235
- const stoppable = item?.kind === "subagent" ? item.status === "running" || item.status === "pending" : item?.kind === "shell" && item.status === "running";
236
- const actions = optionList(theme, [item?.kind === "shell" ? "Kill the selected shell" : item?.kind === "role" ? "Stop (teammates from other extensions are read-only)" : "Stop the selected subagent",
237
- "Follow the latest output"], -1, ACTIONS.map((action) => action.key));
238
- if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
239
- const footer = [...actions, fg("dim", `↑↓ select · PgUp/PgDn scroll · ${withIcon("enter", "follow")} · ${withIcon("esc", "close")}`)];
240
- const split = splitFrame(theme, width, `ACTIVITY · ${running} running${follow ? "" : " · paused"}`, list,
241
- [...pinned, ...details.body.slice(scroll, scroll + bodyRows)], footer, rows, listWidth);
513
+ const split = splitFrame(theme, width, `ACTIVITY · ${running} running${follow ? "" : " · paused"}`, list, [...pinned, ...body], footer, rows, listWidth);
242
514
  layout = split.layout;
515
+ inputRow = subagent ? layout.footerTop + ACTIONS.length : -1;
243
516
  // Subagent events and shell exits repaint through the listeners; elapsed times and shell output
244
517
  // advance on a 1s tick that exists only while something runs.
245
518
  if (running && !timer && !closed) { timer = setInterval(() => tui.requestRender(), 1000); timer.unref?.(); }
package/src/advisor.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "typebox";
3
4
  import type { ModelRoleManager } from "./model-roles.ts";
@@ -16,6 +17,8 @@ export const FAILURE_STREAK = 3;
16
17
  /** Automatic consultations allowed per user prompt. */
17
18
  export const GATES_PER_PROMPT = 2;
18
19
  const WINDOW = 8;
20
+ /** Serialized inputs up to this length stay verbatim in a loop key; longer ones keep a prefix and a digest. */
21
+ const KEY_INPUT_CHARS = 1024;
19
22
 
20
23
  export const ADVISOR_SYSTEM = [
21
24
  "You are the advisor: a senior engineer giving a second opinion to a coding agent (the executor) mid-task.",
@@ -85,8 +88,11 @@ export function advisorPrompt(request: AdvisorRequest, conversation: string, git
85
88
  ].filter(Boolean).join("\n\n");
86
89
  }
87
90
 
88
- /** Stable key for loop detection. */
89
- export const callKey = (tool: string, input: unknown) => tool + " " + JSON.stringify(input ?? {});
91
+ /** Stable key for loop detection. A `write` of a large file must not pin its whole content in the recent window. */
92
+ export function callKey(tool: string, input: unknown): string {
93
+ const json = String(JSON.stringify(input ?? {}));
94
+ return tool + " " + (json.length <= KEY_INPUT_CHARS ? json : json.slice(0, 200) + "…#" + createHash("sha1").update(json).digest("hex"));
95
+ }
90
96
 
91
97
  /** Tracks repeated calls and failure streaks within one user prompt. */
92
98
  export class StuckDetector {
@@ -181,7 +187,7 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
181
187
  const focus = args.trim();
182
188
  ctx.ui.notify("Consulting the advisor…", "info");
183
189
  try {
184
- const answer = await consult(ctx, focus ? { question: focus } : {});
190
+ const answer = await consult(ctx, focus ? { question: focus } : {}, ctx.signal);
185
191
  deliver(ctx, `◆ Advisor · ${answer.model}${focus ? " · " + focus : ""}`, answer.text);
186
192
  } catch (error) { ctx.ui.notify("Advisor failed: " + (error instanceof Error ? error.message : String(error)), "error"); }
187
193
  }
package/src/changes.ts CHANGED
@@ -104,7 +104,8 @@ export class ChangeTracker {
104
104
  private readonly cwd: () => string;
105
105
  constructor(cwd: () => string) { this.cwd = cwd; }
106
106
 
107
- private absolute(path: string): string { return isAbsolute(path) ? resolve(path) : resolve(this.cwd(), path); }
107
+ /** Resolve like every other tracker method; baselines are keyed by this absolute path. */
108
+ absolute(path: string): string { return isAbsolute(path) ? resolve(path) : resolve(this.cwd(), path); }
108
109
  private bytes(content: string | null): number { return content === null ? 0 : Buffer.byteLength(content, "utf8"); }
109
110
  private drop(target: string): void {
110
111
  if (this.baselines.has(target)) this.baselineBytes = Math.max(0, this.baselineBytes - this.bytes(this.baselines.get(target)!));
@@ -112,23 +113,46 @@ export class ChangeTracker {
112
113
  this.dirty.delete(target);
113
114
  }
114
115
 
115
- /** Record the pre-change content once; returns false when the file cannot be tracked. */
116
- capture(path: string): boolean {
117
- const target = this.absolute(path);
118
- if (this.baselines.has(target)) return true;
119
- // Only project files: plan files and scratch space elsewhere are not part of the review.
116
+ /**
117
+ * Store a baseline when the file is inside the project (plan files and scratch space elsewhere are
118
+ * not reviewed) and within the file/byte caps. `content` is lazy so outside files are never read.
119
+ */
120
+ private admit(target: string, content: () => string | null | undefined): boolean {
120
121
  const rel = relative(this.cwd(), target);
121
122
  if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
122
123
  if (this.baselines.size >= MAX_TRACKED_FILES) return false;
123
- const content = readText(target);
124
- if (content === undefined) return false;
125
- const bytes = this.bytes(content);
126
- if (this.baselineBytes + bytes > MAX_TRACKED_TOTAL_BYTES) return false;
127
- this.baselines.set(target, content);
124
+ const before = content();
125
+ if (before === undefined) return false;
126
+ const bytes = this.bytes(before);
127
+ if (bytes > MAX_TRACKED_BYTES || this.baselineBytes + bytes > MAX_TRACKED_TOTAL_BYTES) return false;
128
+ this.baselines.set(target, before);
128
129
  this.baselineBytes += bytes;
129
130
  return true;
130
131
  }
131
132
 
133
+ /** Record the pre-change content once; returns false when the file cannot be tracked. */
134
+ capture(path: string): boolean {
135
+ const target = this.absolute(path);
136
+ return this.baselines.has(target) || this.admit(target, () => readText(target));
137
+ }
138
+
139
+ /** Captured pre-change content (`null` = file did not exist), or `undefined` when untracked. */
140
+ baseline(path: string): string | null | undefined {
141
+ const target = this.absolute(path);
142
+ return this.baselines.has(target) ? this.baselines.get(target)! : undefined;
143
+ }
144
+
145
+ /**
146
+ * Take over a baseline captured elsewhere (a subagent's first-edit content) and mark the file
147
+ * dirty. An existing baseline is older and wins, so each absolute path counts once.
148
+ */
149
+ adopt(path: string, baseline: string | null): boolean {
150
+ const target = this.absolute(path);
151
+ if (!this.baselines.has(target) && !this.admit(target, () => baseline)) return false;
152
+ this.dirty.add(target);
153
+ return true;
154
+ }
155
+
132
156
  /**
133
157
  * Mark one captured file after a successful edit/write. Do not synchronously re-read the file on
134
158
  * every tool result; the review UI reconciles content lazily when /diff is actually opened.
@@ -0,0 +1,42 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Directory a parent hands to a subagent so the child can report each file's content before its
7
+ * first edit/write. The parent then adopts those baselines into its own `/diff` review.
8
+ */
9
+ export const CHILD_BASELINE_ENV = "PI_JAR_BASELINE_DIR";
10
+
11
+ type BaselineRecord = { path: string; content: string | null };
12
+
13
+ /** Writer and reader must agree on the name: one JSON file per absolute path. */
14
+ const fileFor = (dir: string, absPath: string) => join(dir, createHash("sha1").update(absPath).digest("hex") + ".json");
15
+
16
+ /**
17
+ * Store the pre-change content (`null` = file did not exist) once per absolute path. The first
18
+ * write wins: the temp file is hard-linked into place, which fails instead of replacing an
19
+ * existing baseline, so readers never see a partial or newer record. Throws on I/O failure.
20
+ */
21
+ export function writeChildBaseline(dir: string, absPath: string, content: string | null): void {
22
+ const target = fileFor(dir, absPath);
23
+ const temp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
24
+ if (existsSync(target)) return;
25
+ mkdirSync(dir, { recursive: true });
26
+ try {
27
+ writeFileSync(temp, JSON.stringify({ path: absPath, content } satisfies BaselineRecord));
28
+ linkSync(temp, target);
29
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
30
+ finally { rmSync(temp, { force: true }); }
31
+ }
32
+
33
+ /** The recorded baseline, or `undefined` when absent, unreadable or malformed. */
34
+ export function readChildBaseline(dir: string, absPath: string): string | null | undefined {
35
+ let record: unknown;
36
+ try { record = JSON.parse(readFileSync(fileFor(dir, absPath), "utf8")); }
37
+ catch { return undefined; } // absent or corrupt: the parent simply does not adopt this file
38
+ if (!record || typeof record !== "object") return undefined;
39
+ const { path, content } = record as Partial<BaselineRecord>;
40
+ if (path !== absPath || (content !== null && typeof content !== "string")) return undefined;
41
+ return content;
42
+ }