@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/view.ts CHANGED
@@ -9,43 +9,71 @@
9
9
  * adjacent spinning lines. Streamed assistant text is the single growing
10
10
  * element: it renders as the run's last line and freezes at message_end.
11
11
  *
12
+ * The focused run has two full-width pages, toggled with `d`:
13
+ * - activity (default): the live feed, scrollable with ↑/↓/PgUp/PgDn. The
14
+ * view pins to the end and auto-follows new entries; scrolling up unpins
15
+ * (a "⋮ N earlier" marker appears), reaching the bottom again (or End)
16
+ * re-pins.
17
+ * - brief: the run's inputs and vitals — task and context verbatim (wrapped;
18
+ * head+tail elided when huge), the reference file list annotated with ✓/·
19
+ * for whether the child's tool calls touched each file, usage and time
20
+ * stats, the fallback trace, and a stderr tail on failures.
21
+ *
22
+ * Steer input is modal so keys never conflict with the editor: browse mode
23
+ * owns navigation; `s` opens the editor (Enter sends and returns to browse,
24
+ * Esc cancels and clears). Esc in browse closes the overlay; Tab cycles
25
+ * the focused run and resets scrolls (activity re-pins, brief returns to
26
+ * the top).
27
+ *
12
28
  * Layout: a centered screen overlay (overlay:true) occupying most of the
13
29
  * terminal, framed with a thin border. An embedded Editor accepts steering
14
- * input for the focused run (Tab cycles targets); Enter queues the message
15
- * through the run's RPC stdin channel — delivered after the child's current
16
- * tool batch, before its next LLM call.
30
+ * input for the focused run; Enter queues the message through the run's RPC
31
+ * stdin channel — delivered after the child's current tool batch, before its
32
+ * next LLM call.
17
33
  */
18
34
 
19
35
  import type { Theme } from "@earendil-works/pi-coding-agent";
20
36
  import {
37
+ decodeKittyPrintable,
21
38
  Editor,
22
39
  type Component,
23
40
  type EditorTheme,
24
41
  type Focusable,
25
42
  Key,
26
43
  matchesKey,
44
+ stripTerminalSequences,
27
45
  truncateToWidth,
28
46
  visibleWidth,
47
+ wrapTextWithAnsi,
29
48
  } from "@earendil-works/pi-tui";
30
49
  import type { RunHandle } from "./run.ts";
31
50
  import type { ActivityEntry } from "./types.ts";
32
51
  import {
52
+ briefFilesUsed,
53
+ formatFallback,
33
54
  formatThinking,
34
55
  formatTimePart,
35
56
  formatToolCall,
57
+ formatTokens,
36
58
  formatUsageStats,
37
59
  runIcon,
60
+ shortenPath,
38
61
  statusStyle,
39
62
  taskPreview,
40
63
  } from "./utils.ts";
41
64
 
42
- /** Max body lines kept visible; older entries roll off the top. */
65
+ /** Max body lines kept visible; older lines scroll off behind a marker. */
43
66
  const VIEWPORT_LINES = 26;
44
67
  /** Animation tick for the running-entry ellipsis. */
45
68
  const ANIMATION_INTERVAL_MS = 150;
69
+ /** Brief-page text (task/context) is elided to head+tail beyond this many
70
+ * chars, so huge contexts stay cheap to re-wrap on every animation tick. */
71
+ const BRIEF_TEXT_CAP = 20_000;
46
72
 
47
73
  type TuiLike = { requestRender(): void };
48
74
  type Fg = (color: string, text: string) => string;
75
+ type Page = "activity" | "brief";
76
+ type Mode = "browse" | "steer";
49
77
 
50
78
  /** Pad a string with trailing spaces to a visible width (left-justified). */
51
79
  function padRight(s: string, width: number): string {
@@ -58,6 +86,28 @@ function dots(): string {
58
86
  return ".".repeat(1 + (Math.floor(Date.now() / 300) % 3));
59
87
  }
60
88
 
89
+ /** Normalize one keypress to its printable character (Kitty CSI-u aware). */
90
+ function printableChar(data: string): string | undefined {
91
+ const kitty = decodeKittyPrintable(data);
92
+ if (kitty !== undefined) return kitty;
93
+ return data.length === 1 && data >= " " && data <= "~" ? data : undefined;
94
+ }
95
+
96
+ /** Center a string within `width` visible columns (pad-right handles the tail). */
97
+ function centerText(s: string, width: number): string {
98
+ const pad = Math.max(0, Math.floor((width - visibleWidth(s)) / 2));
99
+ return " ".repeat(pad) + s;
100
+ }
101
+
102
+ /** Elide oversized brief text to head + tail around an elision marker. */
103
+ function capBriefText(text: string): string {
104
+ if (text.length <= BRIEF_TEXT_CAP) return text;
105
+ const head = text.slice(0, Math.floor(BRIEF_TEXT_CAP * 0.8));
106
+ const tail = text.slice(-Math.floor(BRIEF_TEXT_CAP * 0.2));
107
+ const elided = text.length - head.length - tail.length;
108
+ return `${head}\n… [${formatTokens(elided)} chars elided] …\n${tail}`;
109
+ }
110
+
61
111
  /**
62
112
  * Build the display list of runs for the panel: running/queued first, then
63
113
  * finished, each group ordered by registry id.
@@ -75,8 +125,19 @@ export class SubagentViewPanel implements Component, Focusable {
75
125
  private tui: TuiLike;
76
126
  private close: () => void;
77
127
  private editor: Editor;
78
- /** Focused run (Tab cycles); the whole viewport belongs to it. */
79
- private focusIndex = 0;
128
+ /** Focused run id (Tab cycles); the whole viewport belongs to it. */
129
+ private focusId: string | null = null;
130
+ /** browse = navigation keys; steer = the editor owns input. */
131
+ private mode: Mode = "browse";
132
+ /** Focused run's visible page. */
133
+ private page: Page = "activity";
134
+ /** Activity scroll: null = pinned to the end (auto-follow), else the
135
+ * index of the first visible entry line. */
136
+ private activityTop: number | null = null;
137
+ /** Brief scroll: index of the first visible line (clamped in render). */
138
+ private briefTop = 0;
139
+ /** Brief scroll ceiling, recomputed each render (content is static). */
140
+ private briefMax = 0;
80
141
  /** Transient feedback line ("steer sent to sub-N"), auto-clears. */
81
142
  private flash = "";
82
143
  private flashUntil = 0;
@@ -116,11 +177,38 @@ export class SubagentViewPanel implements Component, Focusable {
116
177
  }, ANIMATION_INTERVAL_MS);
117
178
  }
118
179
 
180
+ /** Resolve the focused run by id; stable across sort-order reshuffles
181
+ * (e.g. a run finishing re-ranks the list). Scroll state resets only when
182
+ * the focused run actually changes. */
119
183
  private focusedRun(): RunHandle | undefined {
120
184
  const runs = sortViewRuns(this.runsProvider());
121
- if (runs.length === 0) return undefined;
122
- if (this.focusIndex >= runs.length) this.focusIndex = 0;
123
- return runs[this.focusIndex];
185
+ if (runs.length === 0) {
186
+ this.focusId = null;
187
+ return undefined;
188
+ }
189
+ let run = runs.find((r) => r.id === this.focusId);
190
+ if (!run) {
191
+ run = runs[0];
192
+ this.focusId = run.id;
193
+ this.resetScrolls();
194
+ }
195
+ return run;
196
+ }
197
+
198
+ private resetScrolls(): void {
199
+ this.activityTop = null;
200
+ this.briefTop = 0;
201
+ }
202
+
203
+ private cycleRun(): void {
204
+ const runs = sortViewRuns(this.runsProvider());
205
+ if (runs.length < 2) return;
206
+ const idx = runs.findIndex((r) => r.id === this.focusId);
207
+ const next = runs[((idx >= 0 ? idx : 0) + 1) % runs.length];
208
+ if (next.id === this.focusId) return;
209
+ this.focusId = next.id;
210
+ this.resetScrolls();
211
+ this.tui.requestRender();
124
212
  }
125
213
 
126
214
  private submitSteer(value: string): void {
@@ -134,6 +222,7 @@ export class SubagentViewPanel implements Component, Focusable {
134
222
  target.steer(text);
135
223
  this.editor.setText("");
136
224
  this.showFlash(`steer queued for ${target.id} (${target.role})`);
225
+ this.mode = "browse";
137
226
  }
138
227
 
139
228
  private showFlash(message: string): void {
@@ -142,19 +231,66 @@ export class SubagentViewPanel implements Component, Focusable {
142
231
  }
143
232
 
144
233
  handleInput(data: string): void {
234
+ if (this.mode === "steer") {
235
+ // Everything types into the editor; Esc cancels (never closes the panel).
236
+ if (matchesKey(data, Key.escape)) {
237
+ this.editor.setText("");
238
+ this.mode = "browse";
239
+ this.tui.requestRender();
240
+ return;
241
+ }
242
+ this.editor.handleInput(data);
243
+ this.tui.requestRender();
244
+ return;
245
+ }
246
+
145
247
  if (matchesKey(data, Key.escape)) {
146
248
  this.closePanel();
147
249
  return;
148
250
  }
149
251
  if (matchesKey(data, Key.tab)) {
150
- const n = sortViewRuns(this.runsProvider()).length;
151
- if (n > 1) {
152
- this.focusIndex = (this.focusIndex + 1) % n;
153
- this.tui.requestRender();
154
- }
252
+ this.cycleRun();
253
+ return;
254
+ }
255
+ const ch = printableChar(data);
256
+ if (ch === "d") {
257
+ this.page = this.page === "activity" ? "brief" : "activity";
258
+ this.tui.requestRender();
155
259
  return;
156
260
  }
157
- this.editor.handleInput(data);
261
+ if (ch === "s") {
262
+ this.mode = "steer";
263
+ this.tui.requestRender();
264
+ return;
265
+ }
266
+ const page = Math.max(3, this.browseBudget() - 1);
267
+ if (matchesKey(data, Key.up)) this.scrollBy(-1);
268
+ else if (matchesKey(data, Key.down)) this.scrollBy(1);
269
+ else if (matchesKey(data, Key.pageUp)) this.scrollBy(-page);
270
+ else if (matchesKey(data, Key.pageDown)) this.scrollBy(page);
271
+ else if (matchesKey(data, Key.home)) {
272
+ if (this.page === "activity") this.activityTop = 0;
273
+ else this.briefTop = 0;
274
+ this.tui.requestRender();
275
+ } else if (matchesKey(data, Key.end)) {
276
+ if (this.page === "activity") this.activityTop = null;
277
+ else this.briefTop = this.briefMax;
278
+ this.tui.requestRender();
279
+ }
280
+ }
281
+
282
+ /** Scroll the visible page by `delta` lines. Activity pins back to the end
283
+ * once the bottom is reached (auto-follow resumes). */
284
+ private scrollBy(delta: number): void {
285
+ if (this.page === "activity") {
286
+ const n = this.focusedRun()?.snapshot.activityLog.length ?? 0;
287
+ const maxTop = Math.max(0, n - this.browseBudget());
288
+ const cur = this.activityTop ?? maxTop;
289
+ const next = Math.max(0, Math.min(maxTop, cur + delta));
290
+ this.activityTop = next >= maxTop ? null : next;
291
+ } else {
292
+ this.briefTop = Math.max(0, Math.min(this.briefMax, this.briefTop + delta));
293
+ }
158
294
  this.tui.requestRender();
159
295
  }
160
296
 
@@ -166,6 +302,11 @@ export class SubagentViewPanel implements Component, Focusable {
166
302
  this.close();
167
303
  }
168
304
 
305
+ /** Content line budget in browse mode (tab row + header + steer hint + key hint). */
306
+ private browseBudget(): number {
307
+ return Math.max(3, VIEWPORT_LINES - 4);
308
+ }
309
+
169
310
  /** Render one activity entry as a static line; running entries get the
170
311
  * animated ellipsis suffix. */
171
312
  private renderEntry(e: ActivityEntry, width: number, fg: Fg): string {
@@ -197,6 +338,88 @@ export class SubagentViewPanel implements Component, Focusable {
197
338
  return truncateToWidth(indent + line, width);
198
339
  }
199
340
 
341
+ /** Render the brief page's full content (pre-scroll): task/context verbatim,
342
+ * annotated file list, stats, fallback trace, failure stderr tail. */
343
+ private renderBriefLines(run: RunHandle, width: number, fg: Fg): string[] {
344
+ const snap = run.snapshot;
345
+ const lines: string[] = [];
346
+ const section = (title: string) => {
347
+ const label = `── ${title} `;
348
+ lines.push(fg("dim", label + "─".repeat(Math.max(0, width - visibleWidth(label)))));
349
+ };
350
+ const body = (text: string, color?: string) => {
351
+ for (const ln of wrapTextWithAnsi(capBriefText(text), width - 2)) {
352
+ lines.push(color ? ` ${fg(color, ln)}` : ` ${ln}`);
353
+ }
354
+ };
355
+
356
+ section("task");
357
+ body(run.task);
358
+
359
+ if (run.context) {
360
+ section(`context · ${formatTokens(run.context.length)} chars`);
361
+ body(run.context);
362
+ }
363
+
364
+ if (run.files && run.files.length > 0) {
365
+ section(`files · ${run.files.length}`);
366
+ const used = briefFilesUsed(run.files, snap.activityLog);
367
+ for (const f of run.files) {
368
+ lines.push(
369
+ used.get(f)
370
+ ? fg("success", ` ✓ ${shortenPath(f)}`)
371
+ : fg("muted", ` · ${shortenPath(f)}`),
372
+ );
373
+ }
374
+ }
375
+
376
+ section("stats");
377
+ const usage = formatUsageStats(snap.usage, snap.model);
378
+ lines.push(fg("dim", ` ${usage || "no usage yet"}`));
379
+ const time = formatTimePart({ ...snap, exitCode: run.state === "queued" ? -1 : snap.exitCode });
380
+ if (time) lines.push(fg("dim", ` ${time}`));
381
+ if (run.state === "finished" || run.state === "failed") {
382
+ lines.push(fg("dim", ` exit ${snap.exitCode}${snap.stopReason ? ` · ${snap.stopReason}` : ""}`));
383
+ }
384
+ if (snap.fallbackFrom) {
385
+ lines.push(fg("warning", ` ⚠ ${formatFallback(snap.fallbackFrom)}`));
386
+ }
387
+ if (run.state === "failed") {
388
+ if (snap.errorMessage) body(snap.errorMessage, "error");
389
+ const tail = stripTerminalSequences(snap.stderr)
390
+ .split("\n")
391
+ .map((l) => l.trim())
392
+ .filter(Boolean)
393
+ .slice(-6);
394
+ if (tail.length > 0) {
395
+ lines.push(fg("dim", " stderr (tail):"));
396
+ for (const l of tail) lines.push(truncateToWidth(fg("muted", ` ${l}`), width));
397
+ }
398
+ }
399
+ return lines;
400
+ }
401
+
402
+ /** Push the focused run's current page (activity or brief) as framed rows. */
403
+ private renderPage(run: RunHandle, budget: number, innerW: number, fg: Fg, row: (s: string) => string): string[] {
404
+ const lines: string[] = [];
405
+ if (this.page === "activity") {
406
+ const entries = run.snapshot.activityLog.map((e) => this.renderEntry(e, innerW, fg));
407
+ const maxTop = Math.max(0, entries.length - budget);
408
+ const top = this.activityTop === null ? maxTop : Math.min(this.activityTop, maxTop);
409
+ if (top > 0) lines.push(row(fg("muted", `⋮ ${top} earlier`)));
410
+ for (const ln of entries.slice(top, top + budget - (top > 0 ? 1 : 0))) lines.push(row(ln));
411
+ return lines;
412
+ }
413
+ const all = this.renderBriefLines(run, innerW, fg);
414
+ this.briefMax = Math.max(0, all.length - budget);
415
+ this.briefTop = Math.min(this.briefTop, this.briefMax);
416
+ if (this.briefTop > 0) lines.push(row(fg("muted", `⋮ ${this.briefTop} earlier`)));
417
+ for (const ln of all.slice(this.briefTop, this.briefTop + budget - (this.briefTop > 0 ? 1 : 0))) {
418
+ lines.push(row(ln));
419
+ }
420
+ return lines;
421
+ }
422
+
200
423
  render(width: number): string[] {
201
424
  const th = this.theme;
202
425
  // Same adaptation render.ts uses: utils formatters take a loose Fg.
@@ -210,16 +433,18 @@ export class SubagentViewPanel implements Component, Focusable {
210
433
 
211
434
  const runs = sortViewRuns(this.runsProvider());
212
435
  const lines: string[] = [];
213
-
214
436
  const runningCount = runs.filter((r) => r.state === "running").length;
437
+ const focused = this.focusedRun();
215
438
 
216
439
  // ── Tab row: one cell per run; the focused one is highlighted. ──
217
440
  if (runs.length > 0) {
218
- const cells = runs.map((r, i) => {
219
- const focused = r === this.focusedRun();
441
+ // Brackets stay on every cell, focused included — the selectedBg +
442
+ // accent highlight is the indicator, so Tab doesn't shift text.
443
+ const cells = runs.map((r) => {
444
+ const isFocused = r === focused;
220
445
  const label = `${runIcon(r.snapshot, fg)} ${r.id} ${r.role}`;
221
- const styled = focused ? th.bg("selectedBg", fg("accent", label)) : fg("dim", label);
222
- return focused ? styled : `[${styled}]`;
446
+ const styled = isFocused ? th.bg("selectedBg", fg("accent", label)) : fg("dim", label);
447
+ return `[${styled}]`;
223
448
  });
224
449
  lines.push(
225
450
  row(
@@ -228,47 +453,75 @@ export class SubagentViewPanel implements Component, Focusable {
228
453
  ),
229
454
  );
230
455
  } else {
231
- lines.push(row(fg("muted", "subagents: no runs.")));
456
+ // Empty registry — every run left the view (a run disappears once its
457
+ // result is in the conversation). Give the state real presence — a
458
+ // full-size panel with a centered message and the close hint — and
459
+ // fold steer mode back to browse so Esc closes immediately.
460
+ if (this.mode === "steer") {
461
+ this.editor.setText("");
462
+ this.mode = "browse";
463
+ }
464
+ lines.push(row(""));
465
+ lines.push(row(fg("muted", centerText("no subagent runs", innerW))));
466
+ lines.push(
467
+ row(
468
+ fg(
469
+ "dim",
470
+ centerText("a run leaves the view once its result is in the conversation", innerW),
471
+ ),
472
+ ),
473
+ );
474
+ lines.push(row(""));
475
+ lines.push(row(""));
476
+ lines.push(row(fg("dim", "Esc close")));
232
477
  }
233
478
 
234
- // ── Focused run: full viewport, tail-capped entries. ──
235
- // Reserve room for tab row (1) + steer bar (2) + hint (1).
236
- const budget = Math.max(3, VIEWPORT_LINES - 4);
237
- const run = this.focusedRun();
238
- if (run) {
239
- const snap = run.snapshot;
479
+ // ── Focused run: header line, then the current page. ──
480
+ if (focused) {
481
+ const snap = focused.snapshot;
240
482
  const icon = runIcon(snap, fg);
241
- const time = formatTimePart({ ...snap, exitCode: run.state === "queued" ? -1 : snap.exitCode });
483
+ const time = formatTimePart({ ...snap, exitCode: focused.state === "queued" ? -1 : snap.exitCode });
484
+ const inputBits: string[] = [];
485
+ if (focused.files?.length) {
486
+ inputBits.push(`${focused.files.length} file${focused.files.length === 1 ? "" : "s"}`);
487
+ }
488
+ if (focused.context) inputBits.push(`context ${formatTokens(focused.context.length)}`);
242
489
  const parts = [
243
- `${icon} ${fg("accent", th.bold(run.id))}`,
244
- fg("text", run.role),
245
- fg("dim", taskPreview(run.task)),
490
+ `${icon} ${fg("accent", th.bold(focused.id))}`,
491
+ fg("text", focused.role),
492
+ fg("dim", taskPreview(focused.task)),
493
+ ...(inputBits.length > 0 ? [fg("dim", inputBits.join(" · "))] : []),
246
494
  time ? fg("dim", time) : "",
247
495
  fg("dim", formatUsageStats(snap.usage, snap.model)),
248
496
  ].filter(Boolean);
249
497
  lines.push(row(parts.join(th.fg("dim", " · "))));
250
- const entries = snap.activityLog.map((entry) => this.renderEntry(entry, innerW, fg));
251
- if (entries.length > budget) {
252
- entries.splice(0, entries.length - budget);
253
- lines.push(row(fg("muted", "⋮ earlier activity")));
254
- }
255
- for (const ln of entries.slice(0, budget)) lines.push(row(ln));
256
- }
257
498
 
258
- // ── Steer bar ──
259
- if (Date.now() < this.flashUntil) {
260
- lines.push(row(fg("success", this.flash)));
261
- } else {
262
- const target = run && run.state === "running" ? `${run.id} (${run.role})` : null;
263
- const label = target
264
- ? fg("accent", target)
265
- : fg("dim", run ? `${run.id} not running` : "no runs");
266
- lines.push(row(fg("dim", `steer → ${label}`)));
267
- }
268
- for (const el of this.editor.render(innerW)) {
269
- lines.push(row(el));
499
+ if (this.mode === "steer") {
500
+ const edLines = this.editor.render(innerW);
501
+ const budget = Math.max(3, VIEWPORT_LINES - 3 - edLines.length);
502
+ lines.push(...this.renderPage(focused, budget, innerW, fg, row));
503
+ const label =
504
+ focused.state === "running"
505
+ ? fg("accent", `${focused.id} (${focused.role})`)
506
+ : fg("dim", focused.state === "queued" ? `${focused.id} still queued` : `${focused.id} not running`);
507
+ lines.push(row(fg("dim", `steer → ${label}`)));
508
+ for (const el of edLines) lines.push(row(el));
509
+ lines.push(row(fg("dim", "Enter send · Esc cancel")));
510
+ } else {
511
+ lines.push(...this.renderPage(focused, this.browseBudget(), innerW, fg, row));
512
+ if (Date.now() < this.flashUntil) {
513
+ lines.push(row(fg("success", this.flash)));
514
+ } else {
515
+ const label =
516
+ focused.state === "running"
517
+ ? fg("accent", `${focused.id} (${focused.role})`)
518
+ : fg("dim", focused.state === "queued" ? `${focused.id} still queued` : `${focused.id} not running`);
519
+ lines.push(row(fg("dim", `steer → ${label} · press s`)));
520
+ }
521
+ const pageKey = this.page === "activity" ? "d brief" : "d activity";
522
+ lines.push(row(fg("dim", `↑↓ scroll · ${pageKey} · Tab run · s steer · Esc close`)));
523
+ }
270
524
  }
271
- lines.push(row(fg("dim", "Enter steer · Tab switch run · Esc close")));
272
525
 
273
526
  // Frame.
274
527
  return [