pi-jar 0.1.3 → 0.1.5

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[] {
@@ -38,7 +46,8 @@ function collect(sources: ActivitySources): Item[] {
38
46
  if (records.length) items.push({ kind: "header", label: withIcon("subagents", "SUBAGENTS") });
39
47
  for (const record of records) {
40
48
  const { run } = record;
41
- const status: Status = run.state === "working" ? "running" : run.state === "queued" ? "pending" : run.state === "done" ? "success" : run.error === "stopped" ? "stopped" : "error";
49
+ const status: Status = run.state === "working" ? "running" : run.state === "queued" || run.state === "idle" ? "pending"
50
+ : run.state === "paused" || run.state === "stopped" ? "stopped" : run.state === "done" ? "success" : "error";
42
51
  items.push({ kind: "subagent", id: record.key, status, label: run.name, record });
43
52
  }
44
53
  const jobs = sources.shells?.summaries() ?? [];
@@ -60,39 +69,165 @@ function collect(sources: ActivitySources): Item[] {
60
69
  }
61
70
 
62
71
  /** `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[] }
72
+ interface Details {
73
+ head: string[];
74
+ body: readonly string[];
75
+ /** Styles one body line; applied to the visible window only, so a 400-line shell tail costs a screenful. */
76
+ format?: (line: string) => string;
77
+ }
78
+
79
+ /**
80
+ * One collapsible piece of a subagent's details. `key` is stable across renders (the transcript entry
81
+ * itself, or a per-run object), so expansion state and cached rows survive live updates.
82
+ */
83
+ interface Block {
84
+ key: object;
85
+ /** Changes whenever the block's content does. */
86
+ rev: string;
87
+ expandable: boolean;
88
+ /** Expanded unless the user toggled it. */
89
+ open: boolean;
90
+ /** Rows for the given state, without the two-column gutter. */
91
+ rows(expanded: boolean, width: number): string[];
92
+ }
93
+
94
+ const wrap = (text: string, width: number) => {
95
+ const rows: string[] = [];
96
+ for (const raw of text.split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), Math.max(1, width))) rows.push(line);
97
+ return rows;
98
+ };
99
+ const firstLine = (text: string) => { const end = text.indexOf("\n"); return end < 0 ? text : text.slice(0, end); };
100
+ const lineCount = (text: string) => { let count = 1; for (let index = text.indexOf("\n"); index >= 0; index = text.indexOf("\n", index + 1)) count++; return count; };
101
+ const clipped = (rows: string[], limit: number, fg: Fg) => rows.length <= limit ? rows : [...rows.slice(0, limit), fg("dim", `… ${rows.length - limit} more lines`)];
102
+
103
+ function todoRows(todos: readonly Todo[], width: number, fg: Fg): string[] {
104
+ const counts = new Map<string, { done: number; total: number }>();
105
+ for (const todo of todos) if (todo.parentId) {
106
+ const count = counts.get(todo.parentId) ?? { done: 0, total: 0 };
107
+ count.total++;
108
+ if (todo.done) count.done++;
109
+ counts.set(todo.parentId, count);
110
+ }
111
+ return todos.map((todo) => {
112
+ const indent = todo.parentId ? " " : " ";
113
+ const count = counts.get(todo.id);
114
+ const suffix = count ? fg("dim", ` (${count.done}/${count.total})`) : "";
115
+ const room = Math.max(1, width - indent.length - 2 - (count ? `${count.done}/${count.total}`.length + 3 : 0));
116
+ const title = truncateToWidth(safeLine(todo.title), room);
117
+ if (todo.status === "completed") return fg("success", `${indent}✔ `) + fg("dim", "\x1b[9m" + title + "\x1b[29m") + suffix;
118
+ if (todo.status === "in_progress") return fg("accent", `${indent}◼ ${title}`) + suffix;
119
+ return fg("muted", `${indent}☐ ${title}`) + suffix;
120
+ });
121
+ }
122
+
123
+ /** Per-run keys for the blocks that are not transcript entries. */
124
+ const runKeys = new WeakMap<DelegateRun, { task: object; todos: object; live: object; report: object }>();
125
+ const keysOf = (run: DelegateRun) => {
126
+ let keys = runKeys.get(run);
127
+ if (!keys) { keys = { task: {}, todos: {}, live: {}, report: {} }; runKeys.set(run, keys); }
128
+ return keys;
129
+ };
130
+
131
+ function entryBlock(entry: TranscriptEntry, fg: Fg): Block {
132
+ if (entry.kind === "tool") {
133
+ const glyph = entry.status === "running" ? fg("accent", "●") : entry.status === "error" ? fg("error", "✖") : fg("success", "✔");
134
+ const took = entry.endedAt !== undefined ? fg("dim", ` · ${duration(entry.endedAt - entry.startedAt)}`) : fg("dim", " · running");
135
+ return {
136
+ key: entry, rev: `${entry.rev}`, expandable: true, open: false,
137
+ rows(expanded, width) {
138
+ const head = fg("dim", expanded ? "▾ " : "▸ ") + glyph + " " + fg("accent", entry.name) + (entry.hint ? " " + fg("muted", safeLine(entry.hint)) : "") + took;
139
+ if (!expanded) return [truncateToWidth(head, width)];
140
+ const rows = [truncateToWidth(head, width)];
141
+ 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));
142
+ if (entry.output) rows.push(...clipped(wrap(entry.output, width - 4).map((line) => fg(entry.status === "error" ? "error" : "muted", " " + line)), MAX_OUTPUT_ROWS, fg));
143
+ else rows.push(fg("dim", entry.status === "running" ? " (running…)" : " (no output)"));
144
+ return rows;
145
+ }
146
+ };
147
+ }
148
+ const multi = entry.text.length > 60 || entry.text.includes("\n");
149
+ const [mark, tone] = entry.kind === "steer" ? ["› you: ", "accent"] : entry.kind === "note" ? ["! ", "warning"] : ["◆ ", "text"];
150
+ return {
151
+ key: entry, rev: `${entry.rev}`, expandable: multi, open: false,
152
+ rows(expanded, width) {
153
+ const chevron = multi ? fg("dim", expanded ? "▾ " : "▸ ") : " ";
154
+ if (!expanded || !multi) {
155
+ const more = lineCount(entry.text) > 1 ? fg("dim", ` +${lineCount(entry.text) - 1} lines`) : "";
156
+ return [truncateToWidth(chevron + fg(tone, mark + safeLine(firstLine(entry.text))), Math.max(1, width - visibleWidth(more))) + more];
157
+ }
158
+ return [chevron + fg(tone, mark.trimEnd()), ...wrap(entry.text, width - 4).map((line) => fg(tone === "text" ? "text" : tone, " " + line))];
159
+ }
160
+ };
161
+ }
162
+
163
+ /** Everything under a subagent's pinned head: its task, checklist, transcript, live text and report. */
164
+ function subagentBlocks(run: DelegateRun, fg: Fg): Block[] {
165
+ const keys = keysOf(run);
166
+ const blocks: Block[] = [{
167
+ key: keys.task, rev: "task", expandable: true, open: false,
168
+ rows(expanded, width) {
169
+ if (!expanded) return [truncateToWidth(fg("dim", "▸ TASK ") + fg("text", safeLine(firstLine(run.task))), width)];
170
+ return [fg("dim", "▾ TASK"), ...wrap(run.task.slice(0, MAX_TASK_CHARS), width - 4).map((line) => fg("text", " " + line))];
171
+ }
172
+ }];
173
+ if (run.todos.length) {
174
+ const leaves = leafTodos(run.todos);
175
+ const done = leaves.filter((todo) => todo.done).length;
176
+ blocks.push({
177
+ key: keys.todos, rev: run.todos.map((todo) => todo.id + todo.status).join(), expandable: true, open: true,
178
+ rows(expanded, width) {
179
+ const head = fg("dim", (expanded ? "▾ " : "▸ ") + "TASKS ") + fg(done === leaves.length ? "success" : "accent", `${done}/${leaves.length} done`);
180
+ return expanded ? [head, ...todoRows(run.todos, width - 2, fg).map((row) => " " + row)] : [head];
181
+ }
182
+ });
183
+ }
184
+ if (!run.transcript.length && !run.live) blocks.push({ key: keys.live, rev: "empty", expandable: false, open: true, rows: () => [fg("dim", " (nothing yet)")] });
185
+ // Once finished, the last message is the report shown below: list it once.
186
+ const reported = !!run.output && run.state !== "working" && run.state !== "queued";
187
+ let last = -1;
188
+ if (reported) for (let index = run.transcript.length - 1; index >= 0 && last < 0; index--) if (run.transcript[index]!.kind === "text") last = index;
189
+ for (let index = 0; index < run.transcript.length; index++) if (index !== last) blocks.push(entryBlock(run.transcript[index]!, fg));
190
+ if (run.live) {
191
+ const live = run.live;
192
+ blocks.push({
193
+ // The text itself is the revision (it is capped, so its length stops changing); only a tail is wrapped.
194
+ key: keys.live, rev: live, expandable: false, open: true,
195
+ rows: (_expanded, width) => [fg("accent", " ✎ writing…"),
196
+ ...wrap(live.slice(-Math.max(200, width * (LIVE_ROWS + 1))), width - 4).slice(-LIVE_ROWS).map((line) => fg("muted", " " + line))]
197
+ });
198
+ }
199
+ if (reported) {
200
+ blocks.push({
201
+ key: keys.report, rev: run.output, expandable: true, open: true,
202
+ rows(expanded, width) {
203
+ const head = fg("dim", (expanded ? "▾ " : "▸ ") + "REPORT");
204
+ return expanded ? [head, ...wrap(run.output, width - 4).map((line) => fg("text", " " + line))] : [head];
205
+ }
206
+ });
207
+ }
208
+ return blocks;
209
+ }
64
210
 
65
- function subagentDetails(run: DelegateRun, status: Status, width: number, fg: Fg): Details {
211
+ function subagentHead(run: DelegateRun, status: Status, width: number, fg: Fg): string[] {
66
212
  const facts = [
67
213
  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)}`
214
+ `${run.tools} tool${run.tools === 1 ? "" : "s"}`, `${run.turns} turn${run.turns === 1 ? "" : "s"}`, `$${run.cost.toFixed(3)}`,
215
+ run.filesEdited.length ? `${run.filesEdited.length} edited` : "", run.steered ? `steered ${run.steered}×` : ""
69
216
  ].filter(Boolean);
70
217
  const head = [
71
218
  fg("accent", [run.name, run.role, run.model].filter(Boolean).join(" · ")),
72
- fg(COLOR[status], withIcon(status, status === "stopped" ? "stopped" : run.state)) + fg("dim", " · " + facts.join(" · "))
219
+ fg(COLOR[status], withIcon(status, run.state === "paused" ? "paused" : status === "stopped" ? "stopped" : run.state)) + fg("dim", " · " + facts.join(" · "))
73
220
  ];
74
- if (run.activity) head.push(fg("muted", run.activity));
221
+ // A turning glyph on the 1 s tick: a long tool call or a slow model still looks alive.
222
+ if (run.activity) head.push(fg("muted", (run.state === "working" ? SPINNER[Math.floor(Date.now() / 1000) % SPINNER.length] + " " : "") + run.activity));
75
223
  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 };
224
+ return head;
89
225
  }
90
226
 
91
227
  function shellDetails(job: Omit<ShellJob, "lines">, output: readonly string[], width: number, fg: Fg): Details {
92
228
  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 };
229
+ if (!output.length) return { head, body: ["", fg("dim", "(no output yet)")] };
230
+ return { head, body: ["", ...output], format: (line) => line && fg("muted", truncateToWidth(safeLine(line), width)) };
96
231
  }
97
232
 
98
233
  function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg): Details {
@@ -103,7 +238,8 @@ function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg):
103
238
 
104
239
  /**
105
240
  * 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.
241
+ * details on the right, following new output until scrolled up. For subagents the details are
242
+ * collapsible blocks (task, checklist, each tool call, text, report) and `s` steers the run.
107
243
  */
108
244
  export async function openActivityView(ctx: ExtensionContext, sources: ActivitySources, initial?: ActivityTarget): Promise<void> {
109
245
  if (!ctx.hasUI || ctx.mode !== "tui") return;
@@ -124,8 +260,18 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
124
260
  let listTop = 0;
125
261
  let width = 80;
126
262
  let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
263
+ let pinnedRows = 0;
127
264
  let timer: NodeJS.Timeout | undefined;
128
265
  let closed = false;
266
+ let focus: Focus = "list";
267
+ let draft = "";
268
+ let inputRow = -1;
269
+ // Subagent body layout of the last render: which block starts at which body row.
270
+ let blocks: Block[] = [];
271
+ let offsets: number[] = [];
272
+ const cursors = new Map<string, object>();
273
+ const toggled = new WeakMap<object, boolean>();
274
+ const rowCache = new WeakMap<object, { sig: string; rows: string[] }>();
129
275
  const fg: Fg = (color, text) => theme.fg(color as never, text);
130
276
  const shells = sources.shells;
131
277
  const previous = shells?.onChange;
@@ -140,6 +286,19 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
140
286
  if (shells) shells.onChange = previous;
141
287
  };
142
288
  const close = () => { cleanup(); done(); };
289
+ const selected = (): Entry | undefined => { const item = items[at]; return item && item.kind !== "header" ? item : undefined; };
290
+ const steerable = () => { const item = selected(); return item?.kind === "subagent" && item.record.run.state === "working"; };
291
+ const isOpen = (block: Block) => block.expandable ? toggled.get(block.key) ?? block.open : true;
292
+ /** Rows of one block, rebuilt only when its content, state or width changed. */
293
+ const rowsOf = (block: Block, bodyWidth: number) => {
294
+ const expanded = isOpen(block);
295
+ const sig = `${block.rev}|${expanded}|${bodyWidth}`;
296
+ const cached = rowCache.get(block.key);
297
+ if (cached?.sig === sig) return cached.rows;
298
+ const rows = block.rows(expanded, bodyWidth);
299
+ rowCache.set(block.key, { sig, rows });
300
+ return rows;
301
+ };
143
302
  // Rows reorder live (finished subagents sink, old ones drop off): keep the selection by identity.
144
303
  const refresh = () => {
145
304
  const current = items[at];
@@ -155,6 +314,7 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
155
314
  for (let index = at + delta; index >= 0 && index < items.length; index += delta) {
156
315
  if (items[index]!.kind === "header") continue;
157
316
  at = index; follow = true; scroll = 0;
317
+ if (focus === "input") focus = "list";
158
318
  return;
159
319
  }
160
320
  };
@@ -164,25 +324,88 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
164
324
  // Scrolling up pauses follow; scrolling back down to the end resumes it.
165
325
  follow = delta > 0 && scroll >= maxScroll;
166
326
  };
327
+ const cursorIndex = () => {
328
+ const item = selected();
329
+ const key = item?.kind === "subagent" ? cursors.get(item.id) : undefined;
330
+ const index = key ? blocks.findIndex((block) => block.key === key) : -1;
331
+ return index >= 0 ? index : blocks.length - 1;
332
+ };
333
+ const setCursor = (index: number) => {
334
+ const item = selected();
335
+ if (item?.kind !== "subagent" || !blocks.length) return;
336
+ const target = Math.max(0, Math.min(blocks.length - 1, index));
337
+ cursors.set(item.id, blocks[target]!.key);
338
+ follow = false;
339
+ // Keep the block's first row in view.
340
+ const row = offsets[target] ?? 0;
341
+ if (row < scroll) scroll = row;
342
+ else if (row >= scroll + bodyRows) scroll = Math.min(maxScroll, row - bodyRows + 1);
343
+ };
344
+ const toggle = (index: number) => {
345
+ const block = blocks[index];
346
+ if (!block?.expandable) return;
347
+ toggled.set(block.key, !isOpen(block));
348
+ setCursor(index);
349
+ };
167
350
  const stopSelected = () => {
168
- const item = items[at];
169
- if (!item || item.kind === "header" || item.kind === "role" || (item.status !== "running" && item.status !== "pending")) return;
351
+ const item = selected();
352
+ if (!item || item.kind === "role") return;
353
+ const subagentLive = item.kind === "subagent" && !["done", "failed", "stopped"].includes(item.record.run.state);
354
+ const shellLive = item.kind === "shell" && item.status === "running";
355
+ if (!subagentLive && !shellLive) return;
170
356
  try {
171
- if (item.kind === "subagent") sources.subagents.stop(item.id);
357
+ if (item.kind === "subagent") void sources.subagents.stop(item.id).catch((error: unknown) => {
358
+ ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error");
359
+ });
172
360
  else shells?.kill(item.id);
173
361
  } catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
174
362
  };
363
+ const send = () => {
364
+ const item = selected();
365
+ const text = draft.trim();
366
+ if (!text || item?.kind !== "subagent") return;
367
+ if (sources.subagents.steer(item.id, text)) { draft = ""; focus = "transcript"; follow = true; cursors.delete(item.id); }
368
+ else ctx.ui.notify("pi-jar: that subagent is not taking messages (it is not working)", "warning");
369
+ };
370
+ const input = (data: string) => {
371
+ if (matchesKey(data, Key.escape)) { focus = "transcript"; return; }
372
+ if (matchesKey(data, Key.enter)) { send(); return; }
373
+ if (matchesKey(data, Key.backspace) || data === "\x7f" || data === "\b") { draft = draft.slice(0, -1); return; }
374
+ // Typed or pasted text: drop terminal sequences and control characters; line breaks become spaces.
375
+ const text = stripTerminalSequences(data).replace(/[\r\n\t]+/g, " ").replace(/[\x00-\x1f\x7f]/g, "");
376
+ if (text) draft = (draft + text).slice(0, MAX_DRAFT);
377
+ };
175
378
  const component = {
176
379
  invalidate() {},
177
380
  dispose: cleanup,
178
381
  handleInput(data: string) {
382
+ if (focus === "input") { input(data); tui.requestRender(); return; }
383
+ const item = selected();
384
+ const subagent = item?.kind === "subagent";
385
+ if (focus === "transcript") {
386
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.tab) || matchesKey(data, Key.left) || data === "h") focus = "list";
387
+ else if (data === "q") return close();
388
+ else if (matchesKey(data, Key.up) || data === "k") setCursor(cursorIndex() - 1);
389
+ else if (matchesKey(data, Key.down) || data === "j") setCursor(cursorIndex() + 1);
390
+ else if (matchesKey(data, Key.enter) || data === " ") toggle(cursorIndex());
391
+ else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
392
+ else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
393
+ else if (data === "f" || data === "G") follow = true;
394
+ else if (data === "s" && steerable()) focus = "input";
395
+ else if (data === "x") stopSelected();
396
+ else return;
397
+ tui.requestRender();
398
+ return;
399
+ }
179
400
  if (matchesKey(data, Key.escape) || data === "q") return close();
180
401
  if (matchesKey(data, Key.up) || data === "k") move(-1);
181
402
  else if (matchesKey(data, Key.down) || data === "j") move(1);
403
+ else if (subagent && (matchesKey(data, Key.tab) || matchesKey(data, Key.right) || data === "l" || matchesKey(data, Key.enter))) focus = "transcript";
182
404
  else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
183
405
  else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
184
406
  else if (data === "f" || data === "G") follow = true;
185
407
  else if (matchesKey(data, Key.enter)) follow = !follow;
408
+ else if (data === "s" && steerable()) focus = "input";
186
409
  else if (data === "x") stopSelected();
187
410
  else return;
188
411
  tui.requestRender();
@@ -196,31 +419,93 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
196
419
  const index = listTop + row;
197
420
  if (!items[index] || items[index].kind === "header") return;
198
421
  if (index !== at) { at = index; follow = true; scroll = 0; }
422
+ focus = "list";
199
423
  tui.requestRender();
200
424
  return { handled: true, focus: true };
201
425
  }
426
+ if (row >= pinnedRows && row < layout.rows && blocks.length && selected()?.kind === "subagent") {
427
+ // A click on a block's first row expands or collapses it; any other row just points the cursor at it.
428
+ const line = scroll + row - pinnedRows;
429
+ let index = -1;
430
+ for (let block = 0; block < offsets.length && offsets[block]! <= line; block++) index = block;
431
+ if (index < 0) return;
432
+ focus = "transcript";
433
+ if (offsets[index] === line) toggle(index); else setCursor(index);
434
+ tui.requestRender();
435
+ return { handled: true, focus: true };
436
+ }
437
+ if (inputRow >= 0 && event.y === inputRow && steerable()) { focus = "input"; tui.requestRender(); return { handled: true, focus: true }; }
202
438
  const action = ACTIONS[event.y - layout.footerTop];
203
- if (action) { component.handleInput(action.key); return { handled: true }; }
439
+ if (action) { const previousFocus = focus; focus = "list"; component.handleInput(action.key); focus = previousFocus; return { handled: true }; }
204
440
  },
205
441
  render(available: number): string[] {
206
442
  width = Math.max(24, available);
207
- const rows = contentRows(6 + ACTIONS.length, 6);
208
443
  refresh();
209
- const picked = items[at];
210
- const item = picked && picked.kind !== "header" ? picked : undefined;
444
+ const item = selected();
445
+ const subagent = item?.kind === "subagent" ? item : undefined;
446
+ if (!subagent && focus !== "list") focus = "list";
447
+ if (focus === "input" && !steerable()) focus = "transcript";
211
448
  const listWidth = sidebarWidth(width, 20, 34);
212
449
  const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
213
450
  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)
451
+ // Footer: the two actions, the steering input for subagents, then key hints.
452
+ 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",
453
+ "Follow the latest output"], -1, ACTIONS.map((action) => action.key));
454
+ const stoppable = item?.kind === "subagent" ? !["done", "failed", "stopped"].includes(item.record.run.state)
455
+ : item?.kind === "shell" && item.status === "running";
456
+ if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
457
+ const footer = [...actions];
458
+ if (subagent) {
459
+ const room = Math.max(4, width - 16);
460
+ footer.push(focus === "input"
461
+ ? fg("accent", "› steer: ") + fg("text", truncateToWidth(draft.length > room ? "…" + draft.slice(-room + 1) : draft, room)) + fg("accent", "█")
462
+ : fg("dim", steerable() ? "› s steer this subagent (type a message, Enter sends)" : "› steering works while a subagent is running"));
463
+ }
464
+ footer.push(fg("dim", focus === "input" ? `type a message · ${withIcon("enter", "send")} · ${withIcon("esc", "cancel")}`
465
+ : focus === "transcript" ? `↑↓ step · ${withIcon("enter", "expand")} · tab list · s steer · ${withIcon("esc", "back")}`
466
+ : `↑↓ select · ${subagent ? "tab transcript" : "PgUp/PgDn scroll"} · ${withIcon("enter", subagent ? "open" : "follow")} · ${withIcon("esc", "close")}`));
467
+ const rows = contentRows(3 + footer.length, 6);
468
+ const details: Details | undefined = !item || item.kind === "subagent" ? undefined
216
469
  : item.kind === "shell" ? shellDetails(item.job, shells?.output(item.id, SHELL_TAIL) ?? [], bodyWidth, fg)
217
470
  : roleDetails(item.role, item.status, bodyWidth, fg);
471
+ const head = !item ? [fg("dim", "Nothing to show.")] : item.kind === "subagent" ? subagentHead(item.record.run, item.status, bodyWidth, fg) : details!.head;
218
472
  // Narrow terminals drop the list: a pager row above the pinned facts says what is selected.
219
473
  const pinned = [...(!listWidth && item ? [fg("accent", `‹ ${entries.indexOf(item) + 1}/${entries.length} ${withIcon(item.status, item.label)} ›`)] : []),
220
- ...details.head].slice(0, rows - 1);
474
+ ...head].slice(0, rows - 1);
475
+ pinnedRows = pinned.length;
221
476
  bodyRows = rows - pinned.length;
222
- maxScroll = Math.max(0, details.body.length - bodyRows);
223
- scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
477
+ let body: string[];
478
+ if (subagent) {
479
+ blocks = subagentBlocks(subagent.record.run, fg);
480
+ const blockRows = blocks.map((block) => rowsOf(block, bodyWidth - 2));
481
+ offsets = [];
482
+ let total = 0;
483
+ for (const block of blockRows) { offsets.push(total); total += block.length; }
484
+ // A blank row separates the pinned head from the blocks.
485
+ offsets = offsets.map((offset) => offset + 1);
486
+ total += 1;
487
+ maxScroll = Math.max(0, total - bodyRows);
488
+ scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
489
+ // Only the visible window gets its gutter: long transcripts cost nothing off-screen.
490
+ const cursor = focus === "transcript" ? cursorIndex() : -1;
491
+ body = scroll === 0 ? [""] : [];
492
+ for (let index = 0; index < blocks.length && body.length < bodyRows; index++) {
493
+ const start = offsets[index]!;
494
+ const rowsInBlock = blockRows[index]!;
495
+ if (start + rowsInBlock.length <= scroll) continue;
496
+ for (let line = Math.max(0, scroll - start); line < rowsInBlock.length && body.length < bodyRows; line++) {
497
+ body.push((line === 0 && index === cursor ? fg("accent", "❯ ") : " ") + rowsInBlock[line]!);
498
+ }
499
+ }
500
+ } else {
501
+ blocks = [];
502
+ offsets = [];
503
+ const lines = details?.body ?? [];
504
+ maxScroll = Math.max(0, lines.length - bodyRows);
505
+ scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
506
+ body = lines.slice(scroll, scroll + bodyRows);
507
+ if (details?.format) body = body.map(details.format);
508
+ }
224
509
  if (at >= 0 && at < listTop) listTop = items[at - 1]?.kind === "header" ? at - 1 : at;
225
510
  if (at >= listTop + rows) listTop = at - rows + 1;
226
511
  listTop = Math.max(0, Math.min(listTop, items.length - rows));
@@ -228,18 +513,13 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
228
513
  if (entry.kind === "header") return fg("dim", truncateToWidth(entry.label, listWidth));
229
514
  const active = listTop + offset === at;
230
515
  const glyph = icon(entry.status);
231
- return fg(active ? "accent" : "muted", active ? "▌" : " ") + fg(COLOR[entry.status], glyph + " ")
516
+ return fg(active ? "accent" : "muted", active ? (focus === "list" ? "▌" : "▏") : " ") + fg(COLOR[entry.status], glyph + " ")
232
517
  + fg(active ? "accent" : "muted", truncateToWidth(entry.label, Math.max(1, listWidth - 2 - visibleWidth(glyph))));
233
518
  }) : [];
234
519
  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);
520
+ const split = splitFrame(theme, width, `ACTIVITY · ${running} running${follow ? "" : " · paused"}`, list, [...pinned, ...body], footer, rows, listWidth);
242
521
  layout = split.layout;
522
+ inputRow = subagent ? layout.footerTop + ACTIONS.length : -1;
243
523
  // Subagent events and shell exits repaint through the listeners; elapsed times and shell output
244
524
  // advance on a 1s tick that exists only while something runs.
245
525
  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/ask-tool.ts CHANGED
@@ -26,13 +26,18 @@ type PickResult = { kind: "answer"; selected: string[] } | { kind: "custom" } |
26
26
 
27
27
  const fit = (line: string, width: number) => truncateToWidth(line, Math.max(0, width));
28
28
 
29
- async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index: number, total: number): Promise<PickResult> {
29
+ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index: number, total: number, signal?: AbortSignal): Promise<PickResult> {
30
30
  const options = (question.options ?? []).map((option) => ({
31
31
  label: cleanText(option.label, 120),
32
32
  description: option.description ? cleanText(option.description, 240) : undefined
33
33
  })).filter((option) => !!option.label);
34
- if (!ctx.hasUI || ctx.mode !== "tui") return { kind: "cancel" };
35
- return ctx.ui.custom<PickResult>((tui, theme, _keys, done) => {
34
+ if (!ctx.hasUI || ctx.mode !== "tui" || signal?.aborted) return { kind: "cancel" };
35
+ let abort: (() => void) | undefined;
36
+ try { return await ctx.ui.custom<PickResult>((tui, theme, _keys, complete) => {
37
+ const done = (result: PickResult) => { if (abort) signal?.removeEventListener("abort", abort); complete(result); };
38
+ abort = () => done({ kind: "cancel" });
39
+ signal?.addEventListener("abort", abort, { once: true });
40
+ if (signal?.aborted) queueMicrotask(abort);
36
41
  const multi = !!question.multi;
37
42
  const allowCustom = question.allowCustom !== false;
38
43
  const actions = [
@@ -131,13 +136,13 @@ async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index:
131
136
  return lines.map((line) => visibleWidth(line) <= width ? line : fit(line, width));
132
137
  }
133
138
  };
134
- });
139
+ }); } finally { if (abort) signal?.removeEventListener("abort", abort); }
135
140
  }
136
141
 
137
- async function askOne(ctx: ExtensionContext, question: AskQuestion, index: number, total: number): Promise<AskAnswer> {
142
+ export async function askOne(ctx: ExtensionContext, question: AskQuestion, index = 0, total = 1, signal?: AbortSignal): Promise<AskAnswer> {
138
143
  const id = cleanText(question.id ?? "q" + String(index + 1), 64) || "q" + String(index + 1);
139
- const picked = await pickQuestion(ctx, question, index, total);
140
- if (!picked || picked.kind === "cancel") return { id, cancelled: true };
144
+ const picked = await pickQuestion(ctx, question, index, total, signal);
145
+ if (signal?.aborted || !picked || picked.kind === "cancel") return { id, cancelled: true };
141
146
  if (picked.kind === "custom") {
142
147
  const text = await ctx.ui.editor("✎ Your answer · " + cleanText(question.header ?? "Question", 48), "");
143
148
  return text?.trim() ? { id, answer: text.trim() } : { id, cancelled: true };