@esso0428/pi-subagents 0.15.16 → 0.16.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/dist/agent-history-list.d.ts.map +1 -1
  4. package/dist/agent-history-list.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +47 -6
  7. package/dist/index.js.map +1 -1
  8. package/dist/output-file.js +3 -3
  9. package/dist/output-file.js.map +1 -1
  10. package/dist/ui/agent-widget.d.ts +11 -4
  11. package/dist/ui/agent-widget.d.ts.map +1 -1
  12. package/dist/ui/agent-widget.js +85 -19
  13. package/dist/ui/agent-widget.js.map +1 -1
  14. package/dist/ui/ccstyle/diff/ansi-utils.js +2 -2
  15. package/dist/ui/ccstyle/diff/ansi-utils.js.map +1 -1
  16. package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +1 -1
  17. package/dist/ui/ccstyle/diff/diff-presentation.js +0 -1
  18. package/dist/ui/ccstyle/diff/diff-presentation.js.map +1 -1
  19. package/dist/ui/ccstyle/diff/diff-renderer.d.ts +1 -1
  20. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
  21. package/dist/ui/ccstyle/diff/diff-renderer.js +15 -10
  22. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
  23. package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +1 -1
  24. package/dist/ui/ccstyle/diff/line-width-safety.js +1 -2
  25. package/dist/ui/ccstyle/diff/line-width-safety.js.map +1 -1
  26. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts +19 -0
  27. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts.map +1 -0
  28. package/dist/ui/ccstyle/diff/shiki-highlight.js +85 -0
  29. package/dist/ui/ccstyle/diff/shiki-highlight.js.map +1 -0
  30. package/dist/ui/ccstyle/tool-renderer.d.ts +1 -1
  31. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
  32. package/dist/ui/ccstyle/tool-renderer.js +1 -1
  33. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  34. package/dist/ui/ccstyle/tool-result.d.ts +9 -1
  35. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  36. package/dist/ui/ccstyle/tool-result.js +137 -5
  37. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  38. package/dist/ui/conversation-blocks.d.ts.map +1 -1
  39. package/dist/ui/conversation-blocks.js +14 -13
  40. package/dist/ui/conversation-blocks.js.map +1 -1
  41. package/dist/ui/conversation-role.d.ts +1 -1
  42. package/dist/ui/conversation-role.d.ts.map +1 -1
  43. package/dist/ui/conversation-search.js +4 -4
  44. package/dist/ui/conversation-search.js.map +1 -1
  45. package/dist/ui/conversation-timeline.d.ts +17 -1
  46. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  47. package/dist/ui/conversation-timeline.js +116 -43
  48. package/dist/ui/conversation-timeline.js.map +1 -1
  49. package/dist/ui/conversation-viewer.d.ts +5 -1
  50. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  51. package/dist/ui/conversation-viewer.js +67 -26
  52. package/dist/ui/conversation-viewer.js.map +1 -1
  53. package/dist/ui/deferred-ui-refresh.d.ts +15 -0
  54. package/dist/ui/deferred-ui-refresh.d.ts.map +1 -0
  55. package/dist/ui/deferred-ui-refresh.js +50 -0
  56. package/dist/ui/deferred-ui-refresh.js.map +1 -0
  57. package/dist/ui/fleet-list.d.ts +55 -14
  58. package/dist/ui/fleet-list.d.ts.map +1 -1
  59. package/dist/ui/fleet-list.js +178 -44
  60. package/dist/ui/fleet-list.js.map +1 -1
  61. package/dist/write-execution.d.ts.map +1 -1
  62. package/dist/write-execution.js +1 -1
  63. package/dist/write-execution.js.map +1 -1
  64. package/package.json +6 -5
  65. package/src/agent-history-list.ts +1 -1
  66. package/src/index.ts +49 -7
  67. package/src/output-file.ts +3 -3
  68. package/src/ui/agent-widget.ts +18 -10
  69. package/src/ui/ccstyle/diff/ansi-utils.ts +2 -2
  70. package/src/ui/ccstyle/diff/diff-presentation.ts +0 -1
  71. package/src/ui/ccstyle/diff/diff-renderer.ts +22 -21
  72. package/src/ui/ccstyle/diff/line-width-safety.ts +1 -2
  73. package/src/ui/ccstyle/diff/shiki-highlight.ts +97 -0
  74. package/src/ui/ccstyle/tool-renderer.ts +3 -3
  75. package/src/ui/ccstyle/tool-result.ts +175 -7
  76. package/src/ui/conversation-role.ts +1 -1
  77. package/src/ui/conversation-search.ts +4 -4
  78. package/src/ui/conversation-timeline.ts +7 -7
  79. package/src/ui/conversation-viewer.ts +16 -7
  80. package/src/ui/deferred-ui-refresh.ts +72 -0
  81. package/src/ui/fleet-list.ts +185 -50
  82. package/src/write-execution.ts +2 -2
  83. package/test/agent-history-list.test.ts +0 -121
  84. package/test/agent-manager-history.test.ts +0 -88
  85. package/test/agent-menu-history-navigation.test.ts +0 -86
  86. package/test/output-file-history.test.ts +0 -50
  87. package/test/ui/agent-widget.test.ts +0 -161
  88. package/test/ui/conversation-blocks.test.ts +0 -103
  89. package/test/ui/conversation-diff.test.ts +0 -91
  90. package/test/ui/conversation-search.test.ts +0 -55
  91. package/test/ui/conversation-viewer.test.ts +0 -438
  92. package/test/ui/fleet-list.test.ts +0 -135
  93. package/test/write-execution.test.ts +0 -83
  94. package/tsconfig.json +0 -16
@@ -2,7 +2,7 @@
2
2
  * fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor.
3
3
  *
4
4
  * Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or
5
- * ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker),
5
+ * `j`) at an empty prompt activates the list; ↑/↓ move the selection (filled ● marker),
6
6
  * Enter opens the selected agent's live conversation overlay, Esc returns to the prompt.
7
7
  * A viewer stays open when its agent finishes so the final output remains readable.
8
8
  *
@@ -11,10 +11,10 @@
11
11
  * can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched.
12
12
  */
13
13
 
14
- import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
14
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
- import type { AgentManager } from "../agent-manager.js";
15
+ import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
17
16
  import { canOpenActiveAgent } from "../agent-history-list.js";
17
+ import type { AgentManager } from "../agent-manager.js";
18
18
  import type { AgentRecord } from "../types.js";
19
19
  import { getLifetimeTotal } from "../usage.js";
20
20
  import { type AgentActivity, getDisplayName, type Theme } from "./agent-widget.js";
@@ -28,11 +28,23 @@ const FLEET_BASE_ROWS = 2; // hint + main
28
28
  const FLEET_BODY_ROWS = FLEET_MAX_RENDER_ROWS - FLEET_BASE_ROWS;
29
29
  /** Re-render cadence so elapsed/token stats tick while agents run. */
30
30
  const TICK_MS = 200;
31
+ /** Normalized mouse input accepted by FleetView's widget surface. */
32
+ export type FleetMouseEvent = { type: string; wheelDelta?: number };
33
+
34
+ type FleetMouseResult = { handled: true; render: true };
35
+
36
+ type FleetWidgetContent = {
37
+ render(width: number): string[];
38
+ invalidate(): void;
39
+ dispose?(): void;
40
+ handleMouse?(event: FleetMouseEvent): FleetMouseResult | undefined;
41
+ };
42
+
31
43
  /** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */
32
44
  export type FleetUICtx = {
33
45
  setWidget(
34
46
  key: string,
35
- content: undefined | ((tui: any, theme: Theme) => { render(width: number): string[]; invalidate(): void; dispose?(): void }),
47
+ content: undefined | ((tui: any, theme: Theme) => FleetWidgetContent),
36
48
  options?: { placement?: "aboveEditor" | "belowEditor" },
37
49
  ): void;
38
50
  onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void;
@@ -62,30 +74,70 @@ export function formatFleetTokens(count: number): string {
62
74
  return `↓ ${compact} tokens`;
63
75
  }
64
76
 
77
+ /** A bounded slice of the agent-only roster rendered by FleetView. */
78
+ export type FleetAgentWindow = {
79
+ start: number;
80
+ end: number;
81
+ hiddenAbove: number;
82
+ hiddenBelow: number;
83
+ };
84
+
65
85
  /**
66
- * Calculate a bounded agent slice for the complete logical roster.
67
- * `selectedRosterIndex` includes main at index 0; `end` is exclusive.
86
+ * Calculate a bounded agent slice from an agent-only viewport start.
87
+ * `end` is exclusive; `main` is rendered separately and is not part of this
88
+ * window. Marker rows are reserved only when the corresponding side is hidden.
68
89
  */
69
90
  export function calculateFleetAgentWindow(
70
91
  agentCount: number,
71
- selectedRosterIndex: number,
72
- ): { start: number; end: number; hiddenAbove: number; hiddenBelow: number } {
92
+ viewportStart: number,
93
+ ): FleetAgentWindow {
73
94
  const count = Math.max(0, Math.floor(agentCount));
74
95
  if (count === 0) return { start: 0, end: 0, hiddenAbove: 0, hiddenBelow: 0 };
75
96
 
76
- const selectedAgent = Math.min(
97
+ const maxStart = Math.max(0, count - (FLEET_BODY_ROWS - 1));
98
+ const start = Math.min(maxStart, Math.max(0, Math.floor(viewportStart)));
99
+ const hasAbove = start > 0;
100
+ const agentBudget = Math.max(1, FLEET_BODY_ROWS - (hasAbove ? 1 : 0));
101
+ let end = Math.min(count, start + agentBudget);
102
+ if (end < count) end -= 1; // reserve the ↓ marker row
103
+
104
+ return {
105
+ start,
106
+ end,
107
+ hiddenAbove: start,
108
+ hiddenBelow: count - end,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Return the smallest viewport adjustment that makes an agent visible. The
114
+ * selected index is 0-based within the agent-only roster; `main` is handled by
115
+ * FleetList rather than this pure helper.
116
+ */
117
+ export function ensureFleetSelectionVisible(
118
+ agentCount: number,
119
+ selectedAgentIndex: number,
120
+ viewportStart: number,
121
+ ): number {
122
+ const count = Math.max(0, Math.floor(agentCount));
123
+ if (count === 0) return 0;
124
+
125
+ const selected = Math.min(
77
126
  count - 1,
78
- Math.max(0, Math.floor(selectedRosterIndex) - 1),
127
+ Math.max(0, Math.floor(selectedAgentIndex)),
79
128
  );
80
- const reserveAbove = selectedAgent > 0 ? 1 : 0;
81
- const reserveBelow = selectedAgent < count - 1 ? 1 : 0;
82
- const visibleCount = Math.max(1, FLEET_BODY_ROWS - reserveAbove - reserveBelow);
83
- const start = Math.min(
84
- Math.max(0, selectedAgent - visibleCount + 1),
85
- Math.max(0, count - visibleCount),
86
- );
87
- const end = Math.min(count, start + visibleCount);
88
- return { start, end, hiddenAbove: start, hiddenBelow: count - end };
129
+ const maxStart = Math.max(0, count - (FLEET_BODY_ROWS - 1));
130
+ let start = Math.min(maxStart, Math.max(0, Math.floor(viewportStart)));
131
+ const current = calculateFleetAgentWindow(count, start);
132
+
133
+ if (selected < current.start) {
134
+ return Math.min(maxStart, selected);
135
+ }
136
+ if (selected < current.end) return start;
137
+
138
+ // With a possible ↓ marker, selected - 1 is the least forward shift that
139
+ // exposes the selected row while preserving the marker row budget.
140
+ return Math.min(maxStart, Math.max(start, selected - 1));
89
141
  }
90
142
 
91
143
  /**
@@ -115,14 +167,15 @@ export class FleetList {
115
167
  private active = false;
116
168
  /** 0 = `main`, 1..N = subagents. */
117
169
  private selectedIndex = 0;
170
+ /** 0-based start in the agent-only roster; main is rendered separately. */
171
+ private viewportStart = 0;
118
172
  /** Set while a conversation overlay is open; calling it closes the overlay. */
119
173
  private viewerClose: (() => void) | undefined;
120
174
  private viewingAgentId: string | undefined;
121
175
 
122
176
  constructor(
123
177
  private manager: AgentManager,
124
- private agentActivity: Map<string, AgentActivity>,
125
- private getCwd: () => string | undefined = () => undefined,
178
+ private agentActivity: Map<string, AgentActivity>,_getCwd: () => string | undefined = () => undefined,
126
179
  private pi?: ExtensionAPI,
127
180
  private getCtx: () => ExtensionContext | undefined = () => undefined,
128
181
  ) {}
@@ -137,8 +190,8 @@ export class FleetList {
137
190
  }
138
191
 
139
192
  /** Capture the UI context and (re)register the global input handler. */
140
- setUICtx(ui: FleetUICtx): void {
141
- if (ui === this.ui) return;
193
+ setUICtx(ui: FleetUICtx): boolean {
194
+ if (ui === this.ui) return false;
142
195
  this.inputUnsub?.();
143
196
  this.ui = ui;
144
197
  this.widgetRegistered = false;
@@ -146,6 +199,14 @@ export class FleetList {
146
199
  this.lastRenderKey = undefined;
147
200
  this.tui = undefined;
148
201
  this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data));
202
+ return true;
203
+ }
204
+
205
+ /** Request a render on the currently registered TUI without touching input. */
206
+ requestUiRefresh(force = true): boolean {
207
+ if (!this.tui) return false;
208
+ this.tui.requestRender(force);
209
+ return true;
149
210
  }
150
211
 
151
212
  /** Ensure the re-render timer is running (called when an agent spawns). */
@@ -173,6 +234,8 @@ export class FleetList {
173
234
  this.lastRenderKey = undefined;
174
235
  this.tui = undefined;
175
236
  this.active = false;
237
+ this.selectedIndex = 0;
238
+ this.viewportStart = 0;
176
239
  // Null last so a `viewerClose()` microtask above can't re-register the widget.
177
240
  this.ui = undefined;
178
241
  }
@@ -195,12 +258,14 @@ export class FleetList {
195
258
  if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
196
259
  this.active = false;
197
260
  this.selectedIndex = 0;
261
+ this.viewportStart = 0;
198
262
  this.lastRosterKey = undefined;
199
263
  this.lastRenderKey = undefined;
200
264
  return;
201
265
  }
202
266
 
203
267
  this.clampSelection();
268
+ this.ensureSelectionVisible();
204
269
  this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable)
205
270
  const renderKey = this.renderKey(records);
206
271
 
@@ -210,6 +275,7 @@ export class FleetList {
210
275
  return {
211
276
  render: (w: number) => this.renderBar(w, theme),
212
277
  invalidate: () => { this.widgetRegistered = false; this.tui = undefined; },
278
+ handleMouse: (event: FleetMouseEvent) => this.handleMouse(event),
213
279
  };
214
280
  }, { placement: "belowEditor" });
215
281
  this.widgetRegistered = true;
@@ -243,7 +309,13 @@ export class FleetList {
243
309
  Math.round(((record.completedAt ?? now) - record.startedAt) / 1000),
244
310
  ].join("\u001f");
245
311
  });
246
- return [this.enabled ? "1" : "0", this.active ? "1" : "0", String(this.selectedIndex), ...agentState].join("\u001e");
312
+ return [
313
+ this.enabled ? "1" : "0",
314
+ this.active ? "1" : "0",
315
+ String(this.selectedIndex),
316
+ String(this.viewportStart),
317
+ ...agentState,
318
+ ].join("\u001e");
247
319
  }
248
320
 
249
321
  // ---- Roster ----
@@ -270,8 +342,84 @@ export class FleetList {
270
342
  if (this.selectedIndex < 0) this.selectedIndex = 0;
271
343
  }
272
344
 
345
+ /** Keep the selected agent visible without recentering an already-valid window. */
346
+ private ensureSelectionVisible(): void {
347
+ const agents = this.agentRecords();
348
+ if (agents.length === 0) {
349
+ this.viewportStart = 0;
350
+ return;
351
+ }
352
+ if (this.selectedIndex > 0) {
353
+ this.viewportStart = ensureFleetSelectionVisible(
354
+ agents.length,
355
+ this.selectedIndex - 1,
356
+ this.viewportStart,
357
+ );
358
+ return;
359
+ }
360
+ // `main` does not constrain the agent window, but a roster shrink can make
361
+ // its previous start invalid. Use the same pure clamp as renderBar.
362
+ this.viewportStart = calculateFleetAgentWindow(agents.length, this.viewportStart).start;
363
+ }
364
+
273
365
  // ---- Key handling ----
274
366
 
367
+ /**
368
+ * Move the selection while keeping keyboard and wheel input on one path.
369
+ * Returns whether the direction was handled; inactive upward input and input
370
+ * that cannot activate the list are left for the editor/transcript.
371
+ */
372
+ private moveSelection(direction: -1 | 1): boolean {
373
+ const max = this.roster().length - 1;
374
+ if (max < 0) return false;
375
+
376
+ if (!this.active) {
377
+ // Match ↓ activation: only a downward direction at an empty prompt with
378
+ // the editor focused enters the list. Upward input remains a no-op.
379
+ if (direction !== 1 || !this.editorHasFocus() || this.agentRecords().length === 0 || this.ui?.getEditorText() !== "") {
380
+ return false;
381
+ }
382
+ this.active = true;
383
+ this.selectedIndex = 0;
384
+ this.viewportStart = 0;
385
+ this.update();
386
+ return true;
387
+ }
388
+
389
+ this.clampSelection();
390
+ if (direction === -1 && this.selectedIndex === 0) {
391
+ this.deactivate();
392
+ return true;
393
+ }
394
+ this.selectedIndex = direction === 1
395
+ ? Math.min(max, this.selectedIndex + 1)
396
+ : Math.max(0, this.selectedIndex - 1);
397
+ this.ensureSelectionVisible();
398
+ this.update();
399
+ return true;
400
+ }
401
+
402
+ /** Handle normalized wheel input from the registered widget. */
403
+ handleMouse(event: FleetMouseEvent): FleetMouseResult | undefined {
404
+ if (!this.enabled || !this.ui || this.viewerClose) return undefined;
405
+ if (event.type !== "wheel") return undefined;
406
+
407
+ const wheelDelta = event.wheelDelta;
408
+ const direction: -1 | 1 | undefined = wheelDelta == null
409
+ ? undefined
410
+ : wheelDelta < 0
411
+ ? -1
412
+ : wheelDelta > 0
413
+ ? 1
414
+ : undefined;
415
+ if (!direction || !this.moveSelection(direction)) return undefined;
416
+
417
+ // The widget handled the normalized wheel, including at selection
418
+ // boundaries; explicitly request a redraw so it never falls through to
419
+ // Pi's transcript fallback.
420
+ return { handled: true, render: true };
421
+ }
422
+
275
423
  /** Returns `{consume:true}` to swallow a key, or undefined to let it through. */
276
424
  handleKey(data: string): { consume?: boolean; data?: string } | undefined {
277
425
  if (!this.enabled || !this.ui) return undefined;
@@ -290,31 +438,17 @@ export class FleetList {
290
438
  return undefined;
291
439
  }
292
440
 
293
- if (!this.active) {
294
- // Activate: ↓ or ← at an empty prompt moves focus into the list.
295
- const isActivator = matchesKey(data, "down") || matchesKey(data, "left");
296
- if (isActivator && this.agentRecords().length > 0 && this.ui.getEditorText() === "") {
297
- this.active = true;
298
- this.selectedIndex = 0;
299
- this.update();
300
- return { consume: true };
301
- }
302
- return undefined;
303
- }
441
+ const direction: -1 | 1 | undefined = matchesKey(data, "down") || matchesKey(data, "j")
442
+ || (!this.active && matchesKey(data, "left"))
443
+ ? 1
444
+ : matchesKey(data, "up") || matchesKey(data, "k")
445
+ ? -1
446
+ : undefined;
447
+ if (direction && this.moveSelection(direction)) return { consume: true };
304
448
 
305
- // Active — arrows navigate, Enter opens, Esc / Up-past-top exits.
306
- if (matchesKey(data, "down")) {
307
- const max = this.roster().length - 1;
308
- this.selectedIndex = Math.min(max, this.selectedIndex + 1);
309
- this.update();
310
- return { consume: true };
311
- }
312
- if (matchesKey(data, "up")) {
313
- if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; }
314
- this.selectedIndex -= 1;
315
- this.update();
316
- return { consume: true };
317
- }
449
+ if (!this.active) return undefined;
450
+
451
+ // Active — Enter opens, Esc / Up-past-top exits.
318
452
  if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; }
319
453
  if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; }
320
454
 
@@ -339,6 +473,7 @@ export class FleetList {
339
473
  private deactivate(): void {
340
474
  this.active = false;
341
475
  this.selectedIndex = 0;
476
+ this.viewportStart = 0;
342
477
  this.update();
343
478
  }
344
479
 
@@ -416,12 +551,12 @@ export class FleetList {
416
551
 
417
552
  const hint = this.active
418
553
  ? "↑↓ select · enter view · esc back"
419
- : "esc to interrupt · ← for agents · ↓ to manage";
554
+ : "esc to interrupt · ↓ to manage";
420
555
  const lines: string[] = [];
421
556
  lines.push(truncateToWidth(" " + theme.fg("dim", hint), width));
422
557
  lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
423
558
 
424
- const window = calculateFleetAgentWindow(agents.length, sel);
559
+ const window = calculateFleetAgentWindow(agents.length, this.viewportStart);
425
560
  if (window.hiddenAbove > 0) {
426
561
  lines.push(rightAlign("", theme.fg("dim", `↑ ${window.hiddenAbove} more`), width));
427
562
  }
@@ -1,6 +1,6 @@
1
- import { createWriteToolDefinition, generateDiffString, generateUnifiedPatch, type ExtensionContext, type ToolDefinition, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2
1
  import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
3
2
  import { dirname, isAbsolute, resolve } from "node:path";
3
+ import { createWriteToolDefinition, type ExtensionContext, generateDiffString, generateUnifiedPatch, type ToolDefinition, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  /** Maximum UTF-8 size of the durable display diff plus unified patch. */
6
6
  export const MAX_DURABLE_DIFF_BYTES = 4 * 1024 * 1024;
@@ -58,7 +58,7 @@ function resolveWritePath(path: string, cwd: string): string {
58
58
  }
59
59
 
60
60
  async function capturePreviousContent(absolutePath: string): Promise<WriteExecutionMeta> {
61
- let info;
61
+ let info: Awaited<ReturnType<typeof lstat>>;
62
62
  try {
63
63
  info = await lstat(absolutePath);
64
64
  } catch (error: any) {
@@ -1,121 +0,0 @@
1
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, describe, expect, it } from "vitest";
5
- import {
6
- buildAgentStatusMenuEntries,
7
- canOpenActiveAgent,
8
- canOpenAgentHistory,
9
- formatAgentHistoryOption,
10
- isTerminalAgentStatus,
11
- splitAgentRecords,
12
- } from "../src/agent-history-list.js";
13
-
14
- type TestRecord = {
15
- status: string;
16
- session?: object;
17
- transcriptPath?: string;
18
- type?: string;
19
- description?: string;
20
- startedAt?: number;
21
- completedAt?: number;
22
- };
23
-
24
- const tempDirectories: string[] = [];
25
-
26
- afterEach(() => {
27
- for (const directory of tempDirectories.splice(0)) {
28
- rmSync(directory, { recursive: true, force: true });
29
- }
30
- });
31
-
32
- function record(overrides: TestRecord): any {
33
- return {
34
- id: "agent-1",
35
- type: "Explore",
36
- description: "Inspect the repository",
37
- status: "completed",
38
- toolUses: 0,
39
- startedAt: 1_000,
40
- lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
41
- compactionCount: 0,
42
- ...overrides,
43
- };
44
- }
45
-
46
- describe("agent history list helpers", () => {
47
- it("recognizes exactly the five terminal statuses", () => {
48
- for (const status of ["completed", "steered", "stopped", "aborted", "error"]) {
49
- expect(isTerminalAgentStatus(status)).toBe(true);
50
- }
51
- for (const status of ["running", "queued", "unknown", ""]) {
52
- expect(isTerminalAgentStatus(status)).toBe(false);
53
- }
54
- });
55
-
56
- it("separates active and terminal records without mutating input", () => {
57
- const done = record({ status: "completed", session: {} });
58
- const running = record({ status: "running", session: {} });
59
- const stopped = record({ status: "stopped", session: {} });
60
- const queued = record({ status: "queued", session: {} });
61
- const input = [done, running, stopped, queued];
62
- const before = [...input];
63
-
64
- const result = splitAgentRecords(input, undefined);
65
-
66
- expect(result.active).toEqual([running, queued]);
67
- expect(result.history).toEqual([done, stopped]);
68
- expect(input).toEqual(before);
69
- expect(input).toEqual([done, running, stopped, queued]);
70
- });
71
-
72
- it("requires a session or existing transcript for history", () => {
73
- expect(canOpenAgentHistory(record({ session: undefined }), undefined)).toBe(false);
74
- expect(canOpenAgentHistory(record({ session: {} }), undefined)).toBe(true);
75
- expect(canOpenAgentHistory(record({ status: "running", session: {} }), undefined)).toBe(false);
76
- expect(canOpenActiveAgent(record({ status: "running", session: {} }))).toBe(true);
77
- expect(canOpenActiveAgent(record({ status: "queued", session: undefined }))).toBe(false);
78
- expect(canOpenActiveAgent(record({ status: "completed", session: {} }))).toBe(false);
79
- });
80
-
81
- it("opens history only for an existing project-local transcript", () => {
82
- const cwd = mkdtempSync(join(tmpdir(), "pi-subagents-history-list-"));
83
- tempDirectories.push(cwd);
84
- const transcriptPath = ".pi-subagents/agent-transcripts/agent.jsonl";
85
- mkdirSync(join(cwd, ".pi-subagents", "agent-transcripts"), { recursive: true });
86
- const historical = record({ session: undefined, transcriptPath });
87
-
88
- expect(canOpenAgentHistory(historical, cwd)).toBe(false);
89
- writeFileSync(join(cwd, transcriptPath), "{}\n");
90
- expect(canOpenAgentHistory(historical, cwd)).toBe(true);
91
- expect(canOpenAgentHistory(historical, undefined)).toBe(false);
92
- });
93
-
94
- it("builds separate menu entries with openable records only", () => {
95
- const entries = buildAgentStatusMenuEntries([
96
- record({ status: "running", session: {} }),
97
- record({ status: "queued", session: undefined }),
98
- record({ status: "completed", session: {} }),
99
- record({ status: "error", session: {} }),
100
- record({ status: "stopped", session: undefined }),
101
- ], undefined);
102
-
103
- expect(entries).toEqual(["Running agents (1)", "Agent history (2)"]);
104
- expect(buildAgentStatusMenuEntries([record({ status: "queued", session: undefined })], undefined)).toEqual([]);
105
- });
106
-
107
- it("formats a bounded history option using the display name and duration", () => {
108
- const option = formatAgentHistoryOption(record({
109
- status: "completed",
110
- type: "Explore",
111
- description: "A very long description ".repeat(20),
112
- startedAt: 1_000,
113
- completedAt: 3_000,
114
- }), 9_000);
115
-
116
- expect(option).toContain("[completed]");
117
- expect(option).toContain("Explore");
118
- expect(option).toContain("2.0s");
119
- expect(option.length).toBeLessThanOrEqual(140);
120
- });
121
- });
@@ -1,88 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest";
2
- import { AgentManager } from "../src/agent-manager.js";
3
-
4
- const managers: AgentManager[] = [];
5
-
6
- afterEach(() => {
7
- for (const manager of managers.splice(0)) manager.dispose();
8
- });
9
-
10
- function makeManager(): AgentManager {
11
- const manager = new AgentManager();
12
- managers.push(manager);
13
- return manager;
14
- }
15
-
16
- function record(overrides: Record<string, unknown> = {}) {
17
- return {
18
- id: "agent-1",
19
- type: "Explore",
20
- description: "Inspect the repository",
21
- status: "completed",
22
- result: "done",
23
- toolUses: 2,
24
- startedAt: 1_000,
25
- completedAt: 2_000,
26
- ...overrides,
27
- };
28
- }
29
-
30
- describe("AgentManager.restoreCompleted", () => {
31
- it("restores all five terminal statuses without sessions", () => {
32
- const manager = makeManager();
33
- manager.restoreCompleted(["completed", "steered", "stopped", "aborted", "error"].map((status, i) =>
34
- record({ id: `agent-${i}`, status }),
35
- ));
36
-
37
- expect(manager.listAgents()).toHaveLength(5);
38
- for (const restored of manager.listAgents()) {
39
- expect(["completed", "steered", "stopped", "aborted", "error"]).toContain(restored.status);
40
- expect(restored.session).toBeUndefined();
41
- expect(restored.abortController).toBeUndefined();
42
- expect(restored.promise).toBeUndefined();
43
- expect(restored.lifetimeUsage).toEqual({ input: 0, output: 0, cacheWrite: 0 });
44
- expect(restored.compactionCount).toBe(0);
45
- }
46
- });
47
-
48
- it("ignores running, queued, and invalid records", () => {
49
- const manager = makeManager();
50
- manager.restoreCompleted([
51
- record({ id: "running", status: "running" }),
52
- record({ id: "queued", status: "queued" }),
53
- { id: "invalid" },
54
- ]);
55
-
56
- expect(manager.listAgents()).toEqual([]);
57
- });
58
-
59
- it("keeps the last valid duplicate input", () => {
60
- const manager = makeManager();
61
- manager.restoreCompleted([
62
- record({ result: "first", startedAt: 1_000, completedAt: 1_100 }),
63
- record({ result: "last", startedAt: 2_000, completedAt: 2_100 }),
64
- ]);
65
-
66
- expect(manager.getRecord("agent-1")?.result).toBe("last");
67
- expect(manager.getRecord("agent-1")?.startedAt).toBe(2_000);
68
- });
69
-
70
- it("does not replace a live record and rejects unsafe persisted data", () => {
71
- const manager = makeManager();
72
- // Exercise the public map through a restored terminal, then make it live
73
- // without starting an LLM session.
74
- manager.restoreCompleted([record({ id: "live", result: "original" })]);
75
- const liveRecord = manager.getRecord("live")!;
76
- liveRecord.status = "running";
77
-
78
- manager.restoreCompleted([
79
- record({ id: "live", result: "replacement" }),
80
- record({ id: "bad-time", startedAt: Number.NaN }),
81
- record({ id: "bad-path", transcriptPath: "../../outside.jsonl" }),
82
- ]);
83
-
84
- expect(manager.getRecord("live")?.result).toBe("original");
85
- expect(manager.getRecord("bad-time")).toBeUndefined();
86
- expect(manager.getRecord("bad-path")).toBeUndefined();
87
- });
88
- });
@@ -1,86 +0,0 @@
1
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import { afterEach, describe, expect, it } from "vitest";
5
- import extension from "../src/index.js";
6
- import { agentHistoryLocator } from "../src/agent-history.js";
7
-
8
- const tempDirectories: string[] = [];
9
-
10
- afterEach(() => {
11
- for (const directory of tempDirectories.splice(0)) rmSync(directory, { recursive: true, force: true });
12
- });
13
-
14
- describe("/agents history navigation", () => {
15
- it("returns to the Agents menu after closing a history viewer", async () => {
16
- const cwd = mkdtempSync(join(tmpdir(), "pi-subagents-menu-test-"));
17
- tempDirectories.push(cwd);
18
- const transcript = ".pi-subagents/agent-transcripts/agent-1.jsonl";
19
- mkdirSync(join(cwd, ".pi-subagents/agent-transcripts"), { recursive: true });
20
- writeFileSync(join(cwd, transcript), JSON.stringify({ message: { role: "user", content: "hello" } }) + "\n");
21
-
22
- const eventHandlers = new Map<string, Set<(...args: any[]) => any>>();
23
- const events = {
24
- on: (name: string, handler: (...args: any[]) => any) => {
25
- const handlersForEvent = eventHandlers.get(name) ?? new Set<(...args: any[]) => any>();
26
- handlersForEvent.add(handler);
27
- eventHandlers.set(name, handlersForEvent);
28
- return () => handlersForEvent.delete(handler);
29
- },
30
- emit: (name: string, ...args: any[]) => {
31
- for (const handler of eventHandlers.get(name) ?? []) handler(...args);
32
- },
33
- };
34
- const handlers = new Map<string, (...args: any[]) => any>();
35
- let command: { handler: (_args: unknown, ctx: any) => Promise<void> } | undefined;
36
- const pi: any = {
37
- events,
38
- registerMessageRenderer: () => {},
39
- registerTool: () => {},
40
- registerCommand: (_name: string, definition: typeof command) => { command = definition; },
41
- on: (name: string, handler: (...args: any[]) => any) => { handlers.set(name, handler); },
42
- appendEntry: () => {},
43
- sendMessage: () => {},
44
- };
45
- extension(pi);
46
-
47
- const titles: string[] = [];
48
- let historySelections = 0;
49
- const ui = {
50
- notify: () => {},
51
- setWidget: () => {},
52
- setStatus: () => {},
53
- onTerminalInput: () => () => {},
54
- select: async (title: string, options: string[]) => {
55
- titles.push(title);
56
- if (title === "Agents" && titles.filter(item => item === "Agents").length === 1) {
57
- return options.find(option => option.startsWith("Agent history ("));
58
- }
59
- if (title === "Agent history" && historySelections++ === 0) return options[0];
60
- return undefined;
61
- },
62
- custom: async () => undefined,
63
- };
64
- const ctx = {
65
- cwd,
66
- ui,
67
- sessionManager: {
68
- getSessionId: () => undefined,
69
- getBranch: () => [{
70
- type: "custom",
71
- customType: "subagents:record",
72
- data: {
73
- id: "agent-1", type: "Explore", description: "Inspect", status: "completed",
74
- startedAt: 1, completedAt: 2, transcriptPath: agentHistoryLocator(cwd, join(cwd, transcript)),
75
- },
76
- }],
77
- },
78
- };
79
-
80
- await handlers.get("session_start")?.({}, ctx);
81
- await command?.handler({}, ctx);
82
- await handlers.get("session_shutdown")?.();
83
-
84
- expect(titles).toEqual(["Agents", "Agent history", "Agents"]);
85
- });
86
- });