@esso0428/pi-subagents 0.15.5 → 0.15.7

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 (234) hide show
  1. package/CHANGELOG.md +10 -19
  2. package/README.md +5 -5
  3. package/dist/agent-history-list.d.ts +19 -0
  4. package/dist/agent-history-list.d.ts.map +1 -0
  5. package/dist/agent-history-list.js +69 -0
  6. package/dist/agent-history-list.js.map +1 -0
  7. package/dist/agent-history.d.ts +2 -1
  8. package/dist/agent-history.d.ts.map +1 -0
  9. package/dist/agent-history.js +32 -34
  10. package/dist/agent-history.js.map +1 -0
  11. package/dist/agent-manager.d.ts +24 -8
  12. package/dist/agent-manager.d.ts.map +1 -0
  13. package/dist/agent-manager.js +171 -93
  14. package/dist/agent-manager.js.map +1 -0
  15. package/dist/agent-runner.d.ts +1 -0
  16. package/dist/agent-runner.d.ts.map +1 -0
  17. package/dist/agent-runner.js +75 -86
  18. package/dist/agent-runner.js.map +1 -0
  19. package/dist/agent-types.d.ts +1 -0
  20. package/dist/agent-types.d.ts.map +1 -0
  21. package/dist/agent-types.js +28 -45
  22. package/dist/agent-types.js.map +1 -0
  23. package/dist/context.d.ts +1 -0
  24. package/dist/context.d.ts.map +1 -0
  25. package/dist/context.js +3 -6
  26. package/dist/context.js.map +1 -0
  27. package/dist/cross-extension-rpc.d.ts +1 -0
  28. package/dist/cross-extension-rpc.d.ts.map +1 -0
  29. package/dist/cross-extension-rpc.js +6 -9
  30. package/dist/cross-extension-rpc.js.map +1 -0
  31. package/dist/custom-agents.d.ts +1 -0
  32. package/dist/custom-agents.d.ts.map +1 -0
  33. package/dist/custom-agents.js +16 -18
  34. package/dist/custom-agents.js.map +1 -0
  35. package/dist/default-agents.d.ts +1 -0
  36. package/dist/default-agents.d.ts.map +1 -0
  37. package/dist/default-agents.js +2 -4
  38. package/dist/default-agents.js.map +1 -0
  39. package/dist/enabled-models.d.ts +1 -0
  40. package/dist/enabled-models.d.ts.map +1 -0
  41. package/dist/enabled-models.js +12 -16
  42. package/dist/enabled-models.js.map +1 -0
  43. package/dist/env.d.ts +1 -0
  44. package/dist/env.d.ts.map +1 -0
  45. package/dist/env.js +2 -4
  46. package/dist/env.js.map +1 -0
  47. package/dist/group-join.d.ts +1 -0
  48. package/dist/group-join.d.ts.map +1 -0
  49. package/dist/group-join.js +2 -5
  50. package/dist/group-join.js.map +1 -0
  51. package/dist/index.d.ts +1 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +306 -292
  54. package/dist/index.js.map +1 -0
  55. package/dist/invocation-config.d.ts +1 -0
  56. package/dist/invocation-config.d.ts.map +1 -0
  57. package/dist/invocation-config.js +3 -6
  58. package/dist/invocation-config.js.map +1 -0
  59. package/dist/memory.d.ts +1 -0
  60. package/dist/memory.d.ts.map +1 -0
  61. package/dist/memory.js +25 -34
  62. package/dist/memory.js.map +1 -0
  63. package/dist/model-resolver.d.ts +1 -0
  64. package/dist/model-resolver.d.ts.map +1 -0
  65. package/dist/model-resolver.js +2 -4
  66. package/dist/model-resolver.js.map +1 -0
  67. package/dist/nico-overrides.d.ts +1 -0
  68. package/dist/nico-overrides.d.ts.map +1 -0
  69. package/dist/nico-overrides.js +15 -20
  70. package/dist/nico-overrides.js.map +1 -0
  71. package/dist/output-file.d.ts +1 -0
  72. package/dist/output-file.d.ts.map +1 -0
  73. package/dist/output-file.js +17 -22
  74. package/dist/output-file.js.map +1 -0
  75. package/dist/prompts.d.ts +1 -0
  76. package/dist/prompts.d.ts.map +1 -0
  77. package/dist/prompts.js +2 -4
  78. package/dist/prompts.js.map +1 -0
  79. package/dist/schedule-store.d.ts +1 -0
  80. package/dist/schedule-store.d.ts.map +1 -0
  81. package/dist/schedule-store.js +17 -21
  82. package/dist/schedule-store.js.map +1 -0
  83. package/dist/schedule.d.ts +1 -0
  84. package/dist/schedule.d.ts.map +1 -0
  85. package/dist/schedule.js +9 -12
  86. package/dist/schedule.js.map +1 -0
  87. package/dist/settings.d.ts +1 -0
  88. package/dist/settings.d.ts.map +1 -0
  89. package/dist/settings.js +16 -23
  90. package/dist/settings.js.map +1 -0
  91. package/dist/skill-loader.d.ts +1 -0
  92. package/dist/skill-loader.d.ts.map +1 -0
  93. package/dist/skill-loader.js +21 -23
  94. package/dist/skill-loader.js.map +1 -0
  95. package/dist/status-note.d.ts +1 -0
  96. package/dist/status-note.d.ts.map +1 -0
  97. package/dist/status-note.js +2 -4
  98. package/dist/status-note.js.map +1 -0
  99. package/dist/types.d.ts +5 -1
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +2 -4
  102. package/dist/types.js.map +1 -0
  103. package/dist/ui/agent-widget.d.ts +2 -5
  104. package/dist/ui/agent-widget.d.ts.map +1 -0
  105. package/dist/ui/agent-widget.js +29 -51
  106. package/dist/ui/agent-widget.js.map +1 -0
  107. package/dist/ui/ccstyle/diff/ansi-utils.d.ts +11 -0
  108. package/dist/ui/ccstyle/diff/ansi-utils.d.ts.map +1 -0
  109. package/dist/ui/ccstyle/diff/ansi-utils.js +145 -0
  110. package/dist/ui/ccstyle/diff/ansi-utils.js.map +1 -0
  111. package/dist/ui/ccstyle/diff/diff-presentation.d.ts +12 -0
  112. package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +1 -0
  113. package/dist/ui/ccstyle/diff/diff-presentation.js +49 -0
  114. package/dist/ui/ccstyle/diff/diff-presentation.js.map +1 -0
  115. package/dist/ui/ccstyle/diff/diff-renderer.d.ts +46 -0
  116. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -0
  117. package/dist/ui/ccstyle/diff/diff-renderer.js +2044 -0
  118. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -0
  119. package/dist/ui/ccstyle/diff/line-width-safety.d.ts +12 -0
  120. package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +1 -0
  121. package/dist/ui/ccstyle/diff/line-width-safety.js +59 -0
  122. package/dist/ui/ccstyle/diff/line-width-safety.js.map +1 -0
  123. package/dist/ui/ccstyle/diff/render-utils.d.ts +6 -0
  124. package/dist/ui/ccstyle/diff/render-utils.d.ts.map +1 -0
  125. package/dist/ui/ccstyle/diff/render-utils.js +25 -0
  126. package/dist/ui/ccstyle/diff/render-utils.js.map +1 -0
  127. package/dist/ui/ccstyle/diff/types.d.ts +22 -0
  128. package/dist/ui/ccstyle/diff/types.d.ts.map +1 -0
  129. package/dist/ui/ccstyle/diff/types.js +10 -0
  130. package/dist/ui/ccstyle/diff/types.js.map +1 -0
  131. package/dist/ui/ccstyle/diff/write-display-utils.d.ts +2 -0
  132. package/dist/ui/ccstyle/diff/write-display-utils.d.ts.map +1 -0
  133. package/dist/ui/ccstyle/diff/write-display-utils.js +12 -0
  134. package/dist/ui/ccstyle/diff/write-display-utils.js.map +1 -0
  135. package/dist/ui/ccstyle/tool-renderer.d.ts +17 -0
  136. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -0
  137. package/dist/ui/ccstyle/tool-renderer.js +58 -0
  138. package/dist/ui/ccstyle/tool-renderer.js.map +1 -0
  139. package/dist/ui/ccstyle/tool-result.d.ts +24 -0
  140. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -0
  141. package/dist/ui/ccstyle/tool-result.js +193 -0
  142. package/dist/ui/ccstyle/tool-result.js.map +1 -0
  143. package/dist/ui/conversation-blocks.d.ts +46 -0
  144. package/dist/ui/conversation-blocks.d.ts.map +1 -0
  145. package/dist/ui/conversation-blocks.js +306 -0
  146. package/dist/ui/conversation-blocks.js.map +1 -0
  147. package/dist/ui/conversation-nvim.d.ts +8 -0
  148. package/dist/ui/conversation-nvim.d.ts.map +1 -0
  149. package/dist/ui/conversation-nvim.js +117 -0
  150. package/dist/ui/conversation-nvim.js.map +1 -0
  151. package/dist/ui/conversation-role.d.ts +13 -0
  152. package/dist/ui/conversation-role.d.ts.map +1 -0
  153. package/dist/ui/conversation-role.js +39 -0
  154. package/dist/ui/conversation-role.js.map +1 -0
  155. package/dist/ui/conversation-search.d.ts +39 -0
  156. package/dist/ui/conversation-search.d.ts.map +1 -0
  157. package/dist/ui/conversation-search.js +124 -0
  158. package/dist/ui/conversation-search.js.map +1 -0
  159. package/dist/ui/conversation-timeline.d.ts +62 -0
  160. package/dist/ui/conversation-timeline.d.ts.map +1 -0
  161. package/dist/ui/conversation-timeline.js +263 -0
  162. package/dist/ui/conversation-timeline.js.map +1 -0
  163. package/dist/ui/conversation-viewer.d.ts +62 -20
  164. package/dist/ui/conversation-viewer.d.ts.map +1 -0
  165. package/dist/ui/conversation-viewer.js +449 -224
  166. package/dist/ui/conversation-viewer.js.map +1 -0
  167. package/dist/ui/fleet-list.d.ts +24 -11
  168. package/dist/ui/fleet-list.d.ts.map +1 -0
  169. package/dist/ui/fleet-list.js +86 -78
  170. package/dist/ui/fleet-list.js.map +1 -0
  171. package/dist/ui/schedule-menu.d.ts +1 -0
  172. package/dist/ui/schedule-menu.d.ts.map +1 -0
  173. package/dist/ui/schedule-menu.js +2 -4
  174. package/dist/ui/schedule-menu.js.map +1 -0
  175. package/dist/ui/viewer-keys.d.ts +1 -0
  176. package/dist/ui/viewer-keys.d.ts.map +1 -0
  177. package/dist/ui/viewer-keys.js +8 -10
  178. package/dist/ui/viewer-keys.js.map +1 -0
  179. package/dist/usage.d.ts +1 -0
  180. package/dist/usage.d.ts.map +1 -0
  181. package/dist/usage.js +5 -10
  182. package/dist/usage.js.map +1 -0
  183. package/dist/worktree.d.ts +1 -0
  184. package/dist/worktree.d.ts.map +1 -0
  185. package/dist/worktree.js +27 -31
  186. package/dist/worktree.js.map +1 -0
  187. package/dist/write-execution.d.ts +42 -0
  188. package/dist/write-execution.d.ts.map +1 -0
  189. package/dist/write-execution.js +138 -0
  190. package/dist/write-execution.js.map +1 -0
  191. package/package.json +1 -1
  192. package/src/agent-history-list.ts +91 -0
  193. package/src/agent-history.ts +12 -17
  194. package/src/agent-manager.ts +169 -81
  195. package/src/agent-runner.ts +12 -13
  196. package/src/index.ts +117 -98
  197. package/src/output-file.ts +1 -1
  198. package/src/types.ts +4 -1
  199. package/src/ui/agent-widget.ts +3 -9
  200. package/src/ui/ccstyle/ATTRIBUTION.md +37 -0
  201. package/src/ui/ccstyle/diff/ansi-utils.ts +168 -0
  202. package/src/ui/ccstyle/diff/diff-presentation.ts +70 -0
  203. package/src/ui/ccstyle/diff/diff-renderer.ts +3148 -0
  204. package/src/ui/ccstyle/diff/line-width-safety.ts +92 -0
  205. package/src/ui/ccstyle/diff/render-utils.ts +32 -0
  206. package/src/ui/ccstyle/diff/types.ts +33 -0
  207. package/src/ui/ccstyle/diff/write-display-utils.ts +12 -0
  208. package/src/ui/ccstyle/tool-renderer.ts +71 -0
  209. package/src/ui/ccstyle/tool-result.ts +238 -0
  210. package/src/ui/conversation-blocks.ts +404 -0
  211. package/src/ui/conversation-nvim.ts +127 -0
  212. package/src/ui/conversation-role.ts +45 -0
  213. package/src/ui/conversation-search.ts +161 -0
  214. package/src/ui/conversation-timeline.ts +315 -0
  215. package/src/ui/conversation-viewer.ts +418 -210
  216. package/src/ui/fleet-list.ts +73 -52
  217. package/src/write-execution.ts +180 -0
  218. package/test/agent-history-list.test.ts +121 -0
  219. package/test/agent-manager-history.test.ts +59 -58
  220. package/test/agent-menu-history-navigation.test.ts +86 -0
  221. package/test/output-file-history.test.ts +50 -0
  222. package/test/ui/conversation-blocks.test.ts +103 -0
  223. package/test/ui/conversation-diff.test.ts +91 -0
  224. package/test/ui/conversation-search.test.ts +55 -0
  225. package/test/ui/conversation-viewer.test.ts +182 -0
  226. package/test/ui/fleet-list.test.ts +131 -0
  227. package/test/write-execution.test.ts +83 -0
  228. package/tsconfig.json +16 -0
  229. package/vitest.temp.config.mts +6 -0
  230. package/dist/ui/markdown-result.d.ts +0 -3
  231. package/dist/ui/markdown-result.js +0 -53
  232. package/src/ui/markdown-result.ts +0 -56
  233. package/test/agent-history.test.ts +0 -63
  234. package/test/ui/markdown-result.test.ts +0 -45
@@ -4,7 +4,7 @@
4
4
  * Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or
5
5
  * ←) 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
- * A viewer stays open when its agent finishes; finished agents linger briefly in the list.
7
+ * A viewer stays open when its agent finishes so the final output remains readable.
8
8
  *
9
9
  * Mechanics (see plan): the list is a `belowEditor` widget (render-only), and ALL key
10
10
  * handling goes through `onTerminalInput` — which fires before the focused editor and
@@ -12,22 +12,22 @@
12
12
  */
13
13
 
14
14
  import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
16
  import type { AgentManager } from "../agent-manager.js";
16
- import { hasAgentHistory, readAgentHistory } from "../agent-history.js";
17
+ import { canOpenActiveAgent } from "../agent-history-list.js";
17
18
  import type { AgentRecord } from "../types.js";
18
19
  import { getLifetimeTotal } from "../usage.js";
19
20
  import { type AgentActivity, getDisplayName, type Theme } from "./agent-widget.js";
20
- import { ConversationViewer, createStaticConversationSource, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
21
+ import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
21
22
 
22
23
  /** Widget key for the below-editor fleet list. */
23
24
  const FLEET_KEY = "fleet";
24
- /** Max agent rows shown at once; extras collapse into a "↓ N more" indicator. */
25
- const MAX_AGENT_ROWS = 5;
25
+ /** Maximum total rows contributed by FleetView, including hint and indicators. */
26
+ export const FLEET_MAX_RENDER_ROWS = 6;
27
+ const FLEET_BASE_ROWS = 2; // hint + main
28
+ const FLEET_BODY_ROWS = FLEET_MAX_RENDER_ROWS - FLEET_BASE_ROWS;
26
29
  /** Re-render cadence so elapsed/token stats tick while agents run. */
27
30
  const TICK_MS = 200;
28
- /** How long a finished agent lingers in the list before it drops out. */
29
- const FINISHED_LINGER_MS = 4000;
30
-
31
31
  /** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */
32
32
  export type FleetUICtx = {
33
33
  setWidget(
@@ -62,6 +62,32 @@ export function formatFleetTokens(count: number): string {
62
62
  return `↓ ${compact} tokens`;
63
63
  }
64
64
 
65
+ /**
66
+ * Calculate a bounded agent slice for the complete logical roster.
67
+ * `selectedRosterIndex` includes main at index 0; `end` is exclusive.
68
+ */
69
+ export function calculateFleetAgentWindow(
70
+ agentCount: number,
71
+ selectedRosterIndex: number,
72
+ ): { start: number; end: number; hiddenAbove: number; hiddenBelow: number } {
73
+ const count = Math.max(0, Math.floor(agentCount));
74
+ if (count === 0) return { start: 0, end: 0, hiddenAbove: 0, hiddenBelow: 0 };
75
+
76
+ const selectedAgent = Math.min(
77
+ count - 1,
78
+ Math.max(0, Math.floor(selectedRosterIndex) - 1),
79
+ );
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 };
89
+ }
90
+
65
91
  /**
66
92
  * Place `right` flush to `width`, truncating `left` first so the stats survive.
67
93
  * The final clamp guarantees the line never exceeds `width` (which would wrap and
@@ -80,6 +106,7 @@ export class FleetList {
80
106
  private tui: any | undefined;
81
107
  private inputUnsub: (() => void) | undefined;
82
108
  private widgetRegistered = false;
109
+ private lastRosterKey: string | undefined;
83
110
  private timer: ReturnType<typeof setInterval> | undefined;
84
111
 
85
112
  private enabled = true;
@@ -95,6 +122,8 @@ export class FleetList {
95
122
  private manager: AgentManager,
96
123
  private agentActivity: Map<string, AgentActivity>,
97
124
  private getCwd: () => string | undefined = () => undefined,
125
+ private pi?: ExtensionAPI,
126
+ private getCtx: () => ExtensionContext | undefined = () => undefined,
98
127
  ) {}
99
128
 
100
129
  // ---- Lifecycle ----
@@ -112,6 +141,7 @@ export class FleetList {
112
141
  this.inputUnsub?.();
113
142
  this.ui = ui;
114
143
  this.widgetRegistered = false;
144
+ this.lastRosterKey = undefined;
115
145
  this.tui = undefined;
116
146
  this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data));
117
147
  }
@@ -123,7 +153,7 @@ export class FleetList {
123
153
 
124
154
  /**
125
155
  * Called when an agent finishes. The viewer (if open on it) stays open so the
126
- * final output remains readable, and the row lingers in the list — just refresh.
156
+ * final output remains readable; refresh the active-only roster.
127
157
  */
128
158
  onAgentFinished(_id: string): void {
129
159
  this.update();
@@ -137,6 +167,7 @@ export class FleetList {
137
167
  this.viewingAgentId = undefined;
138
168
  if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined);
139
169
  this.widgetRegistered = false;
170
+ this.lastRosterKey = undefined;
140
171
  this.tui = undefined;
141
172
  this.active = false;
142
173
  // Null last so a `viewerClose()` microtask above can't re-register the widget.
@@ -146,7 +177,11 @@ export class FleetList {
146
177
  /** Re-register/refresh the below-editor widget; clears it when no agents remain. */
147
178
  update(): void {
148
179
  if (!this.ui) return;
149
- const hasAgents = this.enabled && this.agentRecords().length > 0;
180
+ const records = this.agentRecords();
181
+ const hasAgents = this.enabled && records.length > 0;
182
+ const rosterKey = records.map(record => record.id).join("\0");
183
+ const rosterChanged = rosterKey !== this.lastRosterKey;
184
+ this.lastRosterKey = rosterKey;
150
185
 
151
186
  if (!hasAgents) {
152
187
  if (this.widgetRegistered) {
@@ -157,6 +192,7 @@ export class FleetList {
157
192
  if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
158
193
  this.active = false;
159
194
  this.selectedIndex = 0;
195
+ this.lastRosterKey = undefined;
160
196
  return;
161
197
  }
162
198
 
@@ -173,35 +209,21 @@ export class FleetList {
173
209
  }, { placement: "belowEditor" });
174
210
  this.widgetRegistered = true;
175
211
  } else {
176
- this.tui?.requestRender();
212
+ this.tui?.requestRender(rosterChanged);
177
213
  }
178
214
  }
179
215
 
180
216
  // ---- Roster ----
181
217
 
182
218
  /**
183
- * Agents shown in the list, ordered earliest-launched first so the ones you
184
- * started sooner sit at the top. Live rows are openable through their session;
185
- * terminal rows are included only when their durable transcript exists. This
186
- * prevents Enter from dead-ending on stale rehydrated metadata.
187
- * Included: running/queued, plus the agent currently being viewed, plus
188
- * recently-finished ones (they linger briefly before dropping out).
189
- * Pending agents with no session yet are hidden until they start.
190
- * (`listAgents()` is newest-first, so we re-sort.)
219
+ * Active agents shown in the list, ordered earliest-launched first so the
220
+ * ones you started sooner sit at the top. Pending agents with no session yet
221
+ * are hidden until they start. (`listAgents()` is newest-first, so we
222
+ * re-sort.)
191
223
  */
192
224
  private agentRecords(): AgentRecord[] {
193
- const now = Date.now();
194
225
  return this.manager.listAgents()
195
- .filter(a => {
196
- const live = a.session && (
197
- a.status === "running" || a.status === "queued"
198
- || a.id === this.viewingAgentId
199
- || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
200
- );
201
- const cwd = this.getCwd();
202
- const history = cwd !== undefined && hasAgentHistory(cwd, a.transcriptPath);
203
- return !!live || history;
204
- })
226
+ .filter(record => canOpenActiveAgent(record))
205
227
  .sort((a, b) => a.startedAt - b.startedAt);
206
228
  }
207
229
 
@@ -296,18 +318,18 @@ export class FleetList {
296
318
  }
297
319
  const record = entry.record;
298
320
  if (!this.ui) return;
299
- const session = record.session ?? (record.transcriptPath && this.getCwd()
300
- ? (() => {
301
- const messages = readAgentHistory(this.getCwd()!, record.transcriptPath!);
302
- return messages ? createStaticConversationSource(messages) : undefined;
303
- })()
304
- : undefined);
305
- if (!session) {
306
- this.ui.notify(`Agent is ${record.status} — no history available.`, "info");
321
+ if (!canOpenActiveAgent(record) || !record.session) {
322
+ this.ui.notify(`Agent is ${record.status} — no live session available.`, "info");
323
+ return;
324
+ }
325
+ const session = record.session;
326
+ const ctx = this.getCtx();
327
+ const pi = this.pi;
328
+ if (!ctx || !pi) {
329
+ this.ui.notify("Agent conversation is unavailable — no active session.", "info");
307
330
  return;
308
331
  }
309
332
  const activity = this.agentActivity.get(record.id);
310
- const isLive = record.session !== undefined;
311
333
  this.viewingAgentId = record.id;
312
334
 
313
335
  void this.ui.custom<undefined>(
@@ -320,11 +342,12 @@ export class FleetList {
320
342
  activity,
321
343
  theme,
322
344
  done,
323
- isLive ? () => {
345
+ () => {
324
346
  if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info");
325
- } : undefined,
347
+ },
326
348
  keybindings,
327
- isLive ? (message: string) => this.manager.steer(record.id, message) : undefined,
349
+ (message: string) => this.manager.steer(record.id, message),
350
+ { pi, ctx, readOnly: false },
328
351
  );
329
352
  },
330
353
  {
@@ -363,20 +386,18 @@ export class FleetList {
363
386
  : "esc to interrupt · ← for agents · ↓ to manage";
364
387
  const lines: string[] = [];
365
388
  lines.push(truncateToWidth(" " + theme.fg("dim", hint), width));
366
- lines.push("");
367
389
  lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width));
368
390
 
369
- // Window the agent rows so the selected one stays visible.
370
- const visible = Math.min(MAX_AGENT_ROWS, agents.length);
371
- const selAgent = Math.max(0, sel - 1);
372
- const start = selAgent < visible ? 0 : selAgent - visible + 1;
373
- const hiddenBelow = agents.length - (start + visible);
374
-
375
- if (start > 0) lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more`), width));
376
- for (let a = start; a < start + visible; a++) {
391
+ const window = calculateFleetAgentWindow(agents.length, sel);
392
+ if (window.hiddenAbove > 0) {
393
+ lines.push(rightAlign("", theme.fg("dim", `↑ ${window.hiddenAbove} more`), width));
394
+ }
395
+ for (let a = window.start; a < window.end; a++) {
377
396
  lines.push(this.renderAgentRow(a + 1, sel, agents[a].record, width, theme));
378
397
  }
379
- if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width));
398
+ if (window.hiddenBelow > 0) {
399
+ lines.push(rightAlign("", theme.fg("dim", `↓ ${window.hiddenBelow} more`), width));
400
+ }
380
401
 
381
402
  return lines;
382
403
  }
@@ -0,0 +1,180 @@
1
+ import { createWriteToolDefinition, generateDiffString, generateUnifiedPatch, type ExtensionContext, type ToolDefinition, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
2
+ import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, resolve } from "node:path";
4
+
5
+ /** Maximum UTF-8 size of the durable display diff plus unified patch. */
6
+ export const MAX_DURABLE_DIFF_BYTES = 4 * 1024 * 1024;
7
+ /** Avoid retaining arbitrarily large previous files in the live-only store. */
8
+ export const MAX_COMPARABLE_WRITE_BYTES = 512_000;
9
+ export const MAX_WRITE_METADATA_ENTRIES = 100;
10
+
11
+ export interface DurableWriteDetails {
12
+ diff?: string;
13
+ patch?: string;
14
+ firstChangedLine?: number;
15
+ diffUnavailableReason?: string;
16
+ }
17
+
18
+ /** Ephemeral execution metadata used by the live renderer. Never persisted as a before/after pair. */
19
+ export interface WriteExecutionMeta extends DurableWriteDetails {
20
+ fileExistedBeforeWrite: boolean;
21
+ previousContent?: string;
22
+ }
23
+
24
+ export class WriteExecutionMetadataStore {
25
+ readonly entries = new Map<string, WriteExecutionMeta>();
26
+
27
+ set(toolCallId: string, metadata: WriteExecutionMeta): void {
28
+ this.entries.delete(toolCallId);
29
+ this.entries.set(toolCallId, metadata);
30
+ while (this.entries.size > MAX_WRITE_METADATA_ENTRIES) {
31
+ const oldest = this.entries.keys().next().value;
32
+ if (oldest === undefined) break;
33
+ this.entries.delete(oldest);
34
+ }
35
+ }
36
+
37
+ get(toolCallId: unknown): WriteExecutionMeta | undefined {
38
+ return typeof toolCallId === "string" ? this.entries.get(toolCallId) : undefined;
39
+ }
40
+
41
+ delete(toolCallId: string): void {
42
+ this.entries.delete(toolCallId);
43
+ }
44
+
45
+ clear(): void {
46
+ this.entries.clear();
47
+ }
48
+ }
49
+
50
+ type WriteParams = { path: string; content: string };
51
+
52
+ function resolveWritePath(path: string, cwd: string): string {
53
+ if (path === "~") return process.env.HOME ?? cwd;
54
+ if (path.startsWith("~/") || path.startsWith("~\\")) {
55
+ return resolve(process.env.HOME ?? cwd, path.slice(2));
56
+ }
57
+ return isAbsolute(path) ? path : resolve(cwd, path);
58
+ }
59
+
60
+ async function capturePreviousContent(absolutePath: string): Promise<WriteExecutionMeta> {
61
+ let info;
62
+ try {
63
+ info = await lstat(absolutePath);
64
+ } catch (error: any) {
65
+ if (error?.code === "ENOENT") return { fileExistedBeforeWrite: false, previousContent: "" };
66
+ return {
67
+ fileExistedBeforeWrite: true,
68
+ diffUnavailableReason: "unable to inspect the previous file",
69
+ };
70
+ }
71
+
72
+ if (!info.isFile()) {
73
+ return {
74
+ fileExistedBeforeWrite: true,
75
+ diffUnavailableReason: "previous path is not a regular file",
76
+ };
77
+ }
78
+ if (info.size > MAX_COMPARABLE_WRITE_BYTES) {
79
+ return {
80
+ fileExistedBeforeWrite: true,
81
+ diffUnavailableReason: `previous file exceeds ${MAX_COMPARABLE_WRITE_BYTES} bytes`,
82
+ };
83
+ }
84
+
85
+ try {
86
+ const bytes = await readFile(absolutePath);
87
+ if (bytes.byteLength > MAX_COMPARABLE_WRITE_BYTES) {
88
+ return {
89
+ fileExistedBeforeWrite: true,
90
+ diffUnavailableReason: `previous file exceeds ${MAX_COMPARABLE_WRITE_BYTES} bytes`,
91
+ };
92
+ }
93
+ const previousContent = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
94
+ return { fileExistedBeforeWrite: true, previousContent };
95
+ } catch {
96
+ return {
97
+ fileExistedBeforeWrite: true,
98
+ diffUnavailableReason: "previous file is not comparable UTF-8 text",
99
+ };
100
+ }
101
+ }
102
+
103
+ function durableDetails(
104
+ metadata: WriteExecutionMeta,
105
+ path: string,
106
+ content: string,
107
+ ): DurableWriteDetails {
108
+ if (metadata.diffUnavailableReason) {
109
+ return { diffUnavailableReason: metadata.diffUnavailableReason };
110
+ }
111
+ const previousContent = metadata.previousContent ?? "";
112
+ const diffResult = generateDiffString(previousContent, content);
113
+ const patch = generateUnifiedPatch(path, previousContent, content);
114
+ const totalBytes = Buffer.byteLength(diffResult.diff, "utf8") + Buffer.byteLength(patch, "utf8");
115
+ if (totalBytes > MAX_DURABLE_DIFF_BYTES) {
116
+ return { diffUnavailableReason: `durable diff exceeds ${MAX_DURABLE_DIFF_BYTES} bytes` };
117
+ }
118
+ return {
119
+ diff: diffResult.diff,
120
+ patch,
121
+ firstChangedLine: diffResult.firstChangedLine,
122
+ };
123
+ }
124
+
125
+ export async function executeWriteWithMetadata(
126
+ store: WriteExecutionMetadataStore,
127
+ toolCallId: string,
128
+ params: WriteParams,
129
+ signal: AbortSignal | undefined,
130
+ cwd: string,
131
+ ): Promise<{ content: Array<{ type: "text"; text: string }>; details?: DurableWriteDetails }> {
132
+ const absolutePath = resolveWritePath(params.path, cwd);
133
+ store.delete(toolCallId);
134
+
135
+ return withFileMutationQueue(absolutePath, async () => {
136
+ const throwIfAborted = () => {
137
+ if (signal?.aborted) throw new Error("Operation aborted");
138
+ };
139
+ throwIfAborted();
140
+ const metadata = await capturePreviousContent(absolutePath);
141
+ throwIfAborted();
142
+ await mkdir(dirname(absolutePath), { recursive: true });
143
+ throwIfAborted();
144
+ await writeFile(absolutePath, params.content, "utf8");
145
+ throwIfAborted();
146
+
147
+ const details = durableDetails(metadata, params.path, params.content);
148
+ store.set(toolCallId, { ...metadata, ...details });
149
+ return {
150
+ content: [{ type: "text" as const, text: `Successfully wrote to ${params.path}` }],
151
+ details,
152
+ };
153
+ }).catch((error) => {
154
+ store.delete(toolCallId);
155
+ throw error;
156
+ });
157
+ }
158
+
159
+ /**
160
+ * A session-local write definition. It retains Pi's public schema/description
161
+ * and replaces only execution so the parent registry is never mutated.
162
+ */
163
+ export function createTrackedWriteTool(
164
+ cwd: string,
165
+ store = new WriteExecutionMetadataStore(),
166
+ ): ToolDefinition<any, DurableWriteDetails> {
167
+ const nativeWrite = createWriteToolDefinition(cwd);
168
+ return {
169
+ ...nativeWrite,
170
+ async execute(
171
+ toolCallId: string,
172
+ params: WriteParams,
173
+ signal: AbortSignal | undefined,
174
+ _onUpdate: unknown,
175
+ ctx: ExtensionContext,
176
+ ) {
177
+ return executeWriteWithMetadata(store, toolCallId, params, signal, ctx.cwd || cwd);
178
+ },
179
+ } as ToolDefinition<any, DurableWriteDetails>;
180
+ }
@@ -0,0 +1,121 @@
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
+ });