@esso0428/pi-subagents 0.15.6 → 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 (217) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/agent-history-list.d.ts +1 -0
  3. package/dist/agent-history-list.d.ts.map +1 -0
  4. package/dist/agent-history-list.js +1 -0
  5. package/dist/agent-history-list.js.map +1 -0
  6. package/dist/agent-history.d.ts +1 -0
  7. package/dist/agent-history.d.ts.map +1 -0
  8. package/dist/agent-history.js +1 -0
  9. package/dist/agent-history.js.map +1 -0
  10. package/dist/agent-manager.d.ts +2 -0
  11. package/dist/agent-manager.d.ts.map +1 -0
  12. package/dist/agent-manager.js +71 -0
  13. package/dist/agent-manager.js.map +1 -0
  14. package/dist/agent-runner.d.ts +1 -0
  15. package/dist/agent-runner.d.ts.map +1 -0
  16. package/dist/agent-runner.js +8 -0
  17. package/dist/agent-runner.js.map +1 -0
  18. package/dist/agent-types.d.ts +1 -0
  19. package/dist/agent-types.d.ts.map +1 -0
  20. package/dist/agent-types.js +1 -0
  21. package/dist/agent-types.js.map +1 -0
  22. package/dist/context.d.ts +1 -0
  23. package/dist/context.d.ts.map +1 -0
  24. package/dist/context.js +1 -0
  25. package/dist/context.js.map +1 -0
  26. package/dist/cross-extension-rpc.d.ts +1 -0
  27. package/dist/cross-extension-rpc.d.ts.map +1 -0
  28. package/dist/cross-extension-rpc.js +1 -0
  29. package/dist/cross-extension-rpc.js.map +1 -0
  30. package/dist/custom-agents.d.ts +1 -0
  31. package/dist/custom-agents.d.ts.map +1 -0
  32. package/dist/custom-agents.js +1 -0
  33. package/dist/custom-agents.js.map +1 -0
  34. package/dist/default-agents.d.ts +1 -0
  35. package/dist/default-agents.d.ts.map +1 -0
  36. package/dist/default-agents.js +1 -0
  37. package/dist/default-agents.js.map +1 -0
  38. package/dist/enabled-models.d.ts +1 -0
  39. package/dist/enabled-models.d.ts.map +1 -0
  40. package/dist/enabled-models.js +1 -0
  41. package/dist/enabled-models.js.map +1 -0
  42. package/dist/env.d.ts +1 -0
  43. package/dist/env.d.ts.map +1 -0
  44. package/dist/env.js +1 -0
  45. package/dist/env.js.map +1 -0
  46. package/dist/group-join.d.ts +1 -0
  47. package/dist/group-join.d.ts.map +1 -0
  48. package/dist/group-join.js +1 -0
  49. package/dist/group-join.js.map +1 -0
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +37 -10
  53. package/dist/index.js.map +1 -0
  54. package/dist/invocation-config.d.ts +1 -0
  55. package/dist/invocation-config.d.ts.map +1 -0
  56. package/dist/invocation-config.js +1 -0
  57. package/dist/invocation-config.js.map +1 -0
  58. package/dist/memory.d.ts +1 -0
  59. package/dist/memory.d.ts.map +1 -0
  60. package/dist/memory.js +1 -0
  61. package/dist/memory.js.map +1 -0
  62. package/dist/model-resolver.d.ts +1 -0
  63. package/dist/model-resolver.d.ts.map +1 -0
  64. package/dist/model-resolver.js +1 -0
  65. package/dist/model-resolver.js.map +1 -0
  66. package/dist/nico-overrides.d.ts +1 -0
  67. package/dist/nico-overrides.d.ts.map +1 -0
  68. package/dist/nico-overrides.js +1 -0
  69. package/dist/nico-overrides.js.map +1 -0
  70. package/dist/output-file.d.ts +1 -0
  71. package/dist/output-file.d.ts.map +1 -0
  72. package/dist/output-file.js +1 -0
  73. package/dist/output-file.js.map +1 -0
  74. package/dist/prompts.d.ts +1 -0
  75. package/dist/prompts.d.ts.map +1 -0
  76. package/dist/prompts.js +1 -0
  77. package/dist/prompts.js.map +1 -0
  78. package/dist/schedule-store.d.ts +1 -0
  79. package/dist/schedule-store.d.ts.map +1 -0
  80. package/dist/schedule-store.js +1 -0
  81. package/dist/schedule-store.js.map +1 -0
  82. package/dist/schedule.d.ts +1 -0
  83. package/dist/schedule.d.ts.map +1 -0
  84. package/dist/schedule.js +1 -0
  85. package/dist/schedule.js.map +1 -0
  86. package/dist/settings.d.ts +1 -0
  87. package/dist/settings.d.ts.map +1 -0
  88. package/dist/settings.js +1 -0
  89. package/dist/settings.js.map +1 -0
  90. package/dist/skill-loader.d.ts +1 -0
  91. package/dist/skill-loader.d.ts.map +1 -0
  92. package/dist/skill-loader.js +1 -0
  93. package/dist/skill-loader.js.map +1 -0
  94. package/dist/status-note.d.ts +1 -0
  95. package/dist/status-note.d.ts.map +1 -0
  96. package/dist/status-note.js +1 -0
  97. package/dist/status-note.js.map +1 -0
  98. package/dist/types.d.ts +5 -0
  99. package/dist/types.d.ts.map +1 -0
  100. package/dist/types.js +1 -0
  101. package/dist/types.js.map +1 -0
  102. package/dist/ui/agent-widget.d.ts +1 -0
  103. package/dist/ui/agent-widget.d.ts.map +1 -0
  104. package/dist/ui/agent-widget.js +4 -2
  105. package/dist/ui/agent-widget.js.map +1 -0
  106. package/dist/ui/ccstyle/diff/ansi-utils.d.ts +11 -0
  107. package/dist/ui/ccstyle/diff/ansi-utils.d.ts.map +1 -0
  108. package/dist/ui/ccstyle/diff/ansi-utils.js +145 -0
  109. package/dist/ui/ccstyle/diff/ansi-utils.js.map +1 -0
  110. package/dist/ui/ccstyle/diff/diff-presentation.d.ts +12 -0
  111. package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +1 -0
  112. package/dist/ui/ccstyle/diff/diff-presentation.js +49 -0
  113. package/dist/ui/ccstyle/diff/diff-presentation.js.map +1 -0
  114. package/dist/ui/ccstyle/diff/diff-renderer.d.ts +46 -0
  115. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -0
  116. package/dist/ui/ccstyle/diff/diff-renderer.js +2044 -0
  117. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -0
  118. package/dist/ui/ccstyle/diff/line-width-safety.d.ts +12 -0
  119. package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +1 -0
  120. package/dist/ui/ccstyle/diff/line-width-safety.js +59 -0
  121. package/dist/ui/ccstyle/diff/line-width-safety.js.map +1 -0
  122. package/dist/ui/ccstyle/diff/render-utils.d.ts +6 -0
  123. package/dist/ui/ccstyle/diff/render-utils.d.ts.map +1 -0
  124. package/dist/ui/ccstyle/diff/render-utils.js +25 -0
  125. package/dist/ui/ccstyle/diff/render-utils.js.map +1 -0
  126. package/dist/ui/ccstyle/diff/types.d.ts +22 -0
  127. package/dist/ui/ccstyle/diff/types.d.ts.map +1 -0
  128. package/dist/ui/ccstyle/diff/types.js +10 -0
  129. package/dist/ui/ccstyle/diff/types.js.map +1 -0
  130. package/dist/ui/ccstyle/diff/write-display-utils.d.ts +2 -0
  131. package/dist/ui/ccstyle/diff/write-display-utils.d.ts.map +1 -0
  132. package/dist/ui/ccstyle/diff/write-display-utils.js +12 -0
  133. package/dist/ui/ccstyle/diff/write-display-utils.js.map +1 -0
  134. package/dist/ui/ccstyle/tool-renderer.d.ts +17 -0
  135. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -0
  136. package/dist/ui/ccstyle/tool-renderer.js +58 -0
  137. package/dist/ui/ccstyle/tool-renderer.js.map +1 -0
  138. package/dist/ui/ccstyle/tool-result.d.ts +24 -0
  139. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -0
  140. package/dist/ui/ccstyle/tool-result.js +193 -0
  141. package/dist/ui/ccstyle/tool-result.js.map +1 -0
  142. package/dist/ui/conversation-blocks.d.ts +28 -0
  143. package/dist/ui/conversation-blocks.d.ts.map +1 -0
  144. package/dist/ui/conversation-blocks.js +119 -27
  145. package/dist/ui/conversation-blocks.js.map +1 -0
  146. package/dist/ui/conversation-nvim.d.ts +1 -0
  147. package/dist/ui/conversation-nvim.d.ts.map +1 -0
  148. package/dist/ui/conversation-nvim.js +1 -0
  149. package/dist/ui/conversation-nvim.js.map +1 -0
  150. package/dist/ui/conversation-role.d.ts +13 -0
  151. package/dist/ui/conversation-role.d.ts.map +1 -0
  152. package/dist/ui/conversation-role.js +39 -0
  153. package/dist/ui/conversation-role.js.map +1 -0
  154. package/dist/ui/conversation-search.d.ts +1 -0
  155. package/dist/ui/conversation-search.d.ts.map +1 -0
  156. package/dist/ui/conversation-search.js +1 -0
  157. package/dist/ui/conversation-search.js.map +1 -0
  158. package/dist/ui/conversation-timeline.d.ts +62 -0
  159. package/dist/ui/conversation-timeline.d.ts.map +1 -0
  160. package/dist/ui/conversation-timeline.js +263 -0
  161. package/dist/ui/conversation-timeline.js.map +1 -0
  162. package/dist/ui/conversation-viewer.d.ts +7 -1
  163. package/dist/ui/conversation-viewer.d.ts.map +1 -0
  164. package/dist/ui/conversation-viewer.js +96 -54
  165. package/dist/ui/conversation-viewer.js.map +1 -0
  166. package/dist/ui/fleet-list.d.ts +1 -0
  167. package/dist/ui/fleet-list.d.ts.map +1 -0
  168. package/dist/ui/fleet-list.js +1 -0
  169. package/dist/ui/fleet-list.js.map +1 -0
  170. package/dist/ui/schedule-menu.d.ts +1 -0
  171. package/dist/ui/schedule-menu.d.ts.map +1 -0
  172. package/dist/ui/schedule-menu.js +1 -0
  173. package/dist/ui/schedule-menu.js.map +1 -0
  174. package/dist/ui/viewer-keys.d.ts +1 -0
  175. package/dist/ui/viewer-keys.d.ts.map +1 -0
  176. package/dist/ui/viewer-keys.js +1 -0
  177. package/dist/ui/viewer-keys.js.map +1 -0
  178. package/dist/usage.d.ts +1 -0
  179. package/dist/usage.d.ts.map +1 -0
  180. package/dist/usage.js +1 -0
  181. package/dist/usage.js.map +1 -0
  182. package/dist/worktree.d.ts +1 -0
  183. package/dist/worktree.d.ts.map +1 -0
  184. package/dist/worktree.js +1 -0
  185. package/dist/worktree.js.map +1 -0
  186. package/dist/write-execution.d.ts +42 -0
  187. package/dist/write-execution.d.ts.map +1 -0
  188. package/dist/write-execution.js +138 -0
  189. package/dist/write-execution.js.map +1 -0
  190. package/package.json +1 -1
  191. package/src/agent-manager.ts +61 -0
  192. package/src/agent-runner.ts +7 -0
  193. package/src/index.ts +35 -11
  194. package/src/types.ts +4 -0
  195. package/src/ui/agent-widget.ts +2 -1
  196. package/src/ui/ccstyle/ATTRIBUTION.md +37 -0
  197. package/src/ui/ccstyle/diff/ansi-utils.ts +168 -0
  198. package/src/ui/ccstyle/diff/diff-presentation.ts +70 -0
  199. package/src/ui/ccstyle/diff/diff-renderer.ts +3148 -0
  200. package/src/ui/ccstyle/diff/line-width-safety.ts +92 -0
  201. package/src/ui/ccstyle/diff/render-utils.ts +32 -0
  202. package/src/ui/ccstyle/diff/types.ts +33 -0
  203. package/src/ui/ccstyle/diff/write-display-utils.ts +12 -0
  204. package/src/ui/ccstyle/tool-renderer.ts +71 -0
  205. package/src/ui/ccstyle/tool-result.ts +238 -0
  206. package/src/ui/conversation-blocks.ts +160 -26
  207. package/src/ui/conversation-role.ts +45 -0
  208. package/src/ui/conversation-timeline.ts +315 -0
  209. package/src/ui/conversation-viewer.ts +91 -54
  210. package/src/write-execution.ts +180 -0
  211. package/test/output-file-history.test.ts +50 -0
  212. package/test/ui/conversation-blocks.test.ts +31 -0
  213. package/test/ui/conversation-diff.test.ts +91 -0
  214. package/test/ui/conversation-viewer.test.ts +50 -0
  215. package/test/write-execution.test.ts +83 -0
  216. package/tsconfig.json +16 -0
  217. package/vitest.temp.config.mts +6 -0
@@ -6,13 +6,15 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI, ExtensionContext, AgentSession } from "@earendil-works/pi-coding-agent";
9
- import { copyToClipboard, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
10
- import { type Component, Input, Key, Markdown, matchesKey, Text, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
+ import { copyToClipboard } from "@earendil-works/pi-coding-agent";
10
+ import { type Component, Input, Key, matchesKey, type TUI, type TuiMouseEvent, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import type { AgentRecord } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
13
  import type { Theme } from "./agent-widget.js";
14
14
  import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
15
  import { formatConversationMessages, type ConversationBlock } from "./conversation-blocks.js";
16
+ import { ConversationTimeline, type TimelineRenderLine } from "./conversation-timeline.js";
17
+ import { renderConversationRoleHeader } from "./conversation-role.js";
16
18
  import { editLiveAssistantBlockInNvim, viewConversationBlockInNvim } from "./conversation-nvim.js";
17
19
  import { findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi, type ConversationMatch } from "./conversation-search.js";
18
20
  import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
@@ -35,12 +37,7 @@ export function createStaticConversationSource(
35
37
  return { messages, subscribe: () => () => {} };
36
38
  }
37
39
 
38
- type RenderLine = {
39
- text: string;
40
- plain: string;
41
- blockIndex: number;
42
- railable?: boolean;
43
- };
40
+ type RenderLine = TimelineRenderLine;
44
41
 
45
42
  /** Optional operations are appended so all existing constructor call sites stay valid. */
46
43
  export interface ConversationViewerOperations {
@@ -71,6 +68,7 @@ export class ConversationViewer implements Component {
71
68
  private searchQuery = "";
72
69
  private searchMatches: ConversationMatch[] = [];
73
70
  private currentMatchIdx = -1;
71
+ private timeline: ConversationTimeline;
74
72
 
75
73
  constructor(
76
74
  private tui: TUI,
@@ -90,9 +88,20 @@ export class ConversationViewer implements Component {
90
88
  ) {
91
89
  this.keys = createViewerKeys(keybindings);
92
90
  this.blocks = formatConversationMessages(session.messages);
91
+ this.timeline = new ConversationTimeline(tui, theme, {
92
+ cwd: operations?.ctx.cwd,
93
+ record,
94
+ onChange: () => {
95
+ this.cacheVersion++;
96
+ this.invalidate();
97
+ this.tui.requestRender();
98
+ },
99
+ });
100
+ this.timeline.setBlocks(this.blocks);
93
101
  this.unsubscribe = session.subscribe(() => {
94
102
  if (this.closed) return;
95
103
  this.blocks = formatConversationMessages(this.session.messages);
104
+ this.timeline.setBlocks(this.blocks);
96
105
  this.cacheVersion++;
97
106
  this.invalidate();
98
107
  this.tui.requestRender();
@@ -114,10 +123,17 @@ export class ConversationViewer implements Component {
114
123
  this.done(undefined);
115
124
  return;
116
125
  }
117
- if (matchesKey(data, "enter") && this.canSteer()) {
118
- this.stopArmed = false;
119
- this.openComposer();
120
- return;
126
+ if (matchesKey(data, "enter")) {
127
+ this.buildContentLines(this.lastInnerW || 80);
128
+ if (this.timeline.isToolFocused()) {
129
+ this.timeline.toggleFocusedTool();
130
+ return;
131
+ }
132
+ if (this.canSteer()) {
133
+ this.stopArmed = false;
134
+ this.openComposer();
135
+ return;
136
+ }
121
137
  }
122
138
  if (matchesKey(data, "x")) {
123
139
  if (this.isStoppable()) {
@@ -140,6 +156,15 @@ export class ConversationViewer implements Component {
140
156
 
141
157
  if (data === "t") {
142
158
  this.showTools = !this.showTools;
159
+ this.timeline.setShowTools(this.showTools);
160
+ this.invalidate();
161
+ this.tui.requestRender();
162
+ } else if (data === "\t") {
163
+ this.timeline.moveFocus(1);
164
+ this.invalidate();
165
+ this.tui.requestRender();
166
+ } else if (matchesKey(data, Key.shift("tab"))) {
167
+ this.timeline.moveFocus(-1);
143
168
  this.invalidate();
144
169
  this.tui.requestRender();
145
170
  } else if (data === "J" || matchesKey(data, Key.shift("j"))) {
@@ -183,6 +208,25 @@ export class ConversationViewer implements Component {
183
208
  void maxScroll;
184
209
  }
185
210
 
211
+ handleMouse(event: TuiMouseEvent): { handled?: boolean; render?: boolean } | undefined {
212
+ if (this.composer || this.searchMode) return undefined;
213
+ const innerW = Math.max(1, this.lastInnerW || event.width - 4);
214
+ const contentLines = this.buildContentLines(innerW);
215
+ const viewportHeight = this.viewportHeight();
216
+ const maxScroll = Math.max(0, contentLines.length - viewportHeight);
217
+ const visibleStart = Math.min(this.scrollOffset, maxScroll);
218
+ const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
219
+ const contentY = event.y - contentOrigin + visibleStart;
220
+ if (contentY < 0 || contentY >= contentLines.length) return undefined;
221
+ const result = this.timeline.handleMouse({ ...event, y: contentY });
222
+ if (result?.handled) {
223
+ this.cacheVersion++;
224
+ this.invalidate();
225
+ this.tui.requestRender();
226
+ }
227
+ return result;
228
+ }
229
+
186
230
  render(width: number): string[] {
187
231
  if (width < 6) return [];
188
232
  const th = this.theme;
@@ -226,7 +270,10 @@ export class ConversationViewer implements Component {
226
270
  const displayed = visible.slice();
227
271
  if (showSticky) {
228
272
  const block = this.cachedMessageBlocks[this.currentMessageIndex()];
229
- if (block) displayed[0] = { text: ` ${block.header}`, plain: block.header, blockIndex: currentBlockIdx, railable: true };
273
+ if (block) {
274
+ const header = renderConversationRoleHeader(block, this.theme);
275
+ displayed[0] = { text: ` ${header}`, plain: header, blockIndex: currentBlockIdx, railable: true };
276
+ }
230
277
  }
231
278
  for (let i = 0; i < displayed.length; i++) {
232
279
  const line = displayed[i];
@@ -248,6 +295,13 @@ export class ConversationViewer implements Component {
248
295
  const hint = th.fg("dim", "Enter send · Esc cancel");
249
296
  const label = th.fg("accent", "✎ steer");
250
297
  lines.push(row(label + " ".repeat(Math.max(1, innerW - visibleWidth(label) - visibleWidth(hint))) + hint));
298
+ } else if (this.searchMode) {
299
+ const query = `/${this.searchQuery}`;
300
+ const matchCount = this.searchMatches.length > 0
301
+ ? ` · ${this.searchMatches.length} match${this.searchMatches.length === 1 ? "" : "es"}`
302
+ : "";
303
+ const hints = th.fg("dim", " · Enter apply · Esc cancel");
304
+ lines.push(row(truncateToWidth(th.fg("accent", query) + th.fg("dim", matchCount) + hints, innerW, "...", true)));
251
305
  } else {
252
306
  const actions: string[] = [];
253
307
  if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
@@ -306,52 +360,35 @@ export class ConversationViewer implements Component {
306
360
  }
307
361
 
308
362
  private invocationLine(): string | undefined {
309
- const { modelName, tags } = buildInvocationTags(this.record.invocation);
310
- const parts = modelName ? [modelName, ...tags] : tags;
311
- return parts.length === 0 ? undefined : this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
363
+ const liveModel = this.isLive() ? this.record.session?.model : undefined;
364
+ const invocation = this.record.invocation
365
+ ? {
366
+ ...this.record.invocation,
367
+ ...(liveModel && { effectiveModelName: liveModel.name ?? liveModel.id }),
368
+ ...(this.isLive() && { effectiveThinking: this.record.session?.thinkingLevel }),
369
+ }
370
+ : liveModel || (this.isLive() && this.record.session?.thinkingLevel)
371
+ ? {
372
+ effectiveModelName: liveModel?.name ?? liveModel?.id,
373
+ effectiveThinking: this.record.session?.thinkingLevel,
374
+ }
375
+ : undefined;
376
+ if (!invocation) return this.theme.fg("dim", " ↳ model: unknown · thinking: unknown");
377
+ const { modelName, tags } = buildInvocationTags(invocation);
378
+ const effectiveModelName = invocation.effectiveModelName ?? modelName;
379
+ const parts = effectiveModelName ? [`model: ${effectiveModelName}`, ...tags] : ["model: inherit", ...tags];
380
+ return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
312
381
  }
313
382
 
314
383
  private buildContentLines(width: number): RenderLine[] {
315
384
  if (width <= 0) return [];
316
385
  if (this.cachedVersion === this.cacheVersion && this.cachedWidth === width) return this.cachedLines;
317
- const visibleBlocks = this.blocks.filter((block) => this.showTools || block.kind !== "tool");
318
- const lines: RenderLine[] = [];
319
- this.cachedMessageStarts = [];
320
- this.cachedMessageBlocks = [];
321
- if (visibleBlocks.length === 0) {
322
- const empty = this.blocks.length === 0 ? "(waiting for first message...)" : "(tools hidden)";
323
- lines.push({ text: this.theme.fg("dim", empty), plain: empty, blockIndex: -1 });
324
- }
325
- for (const block of visibleBlocks) {
326
- const blockIndex = this.blocks.indexOf(block);
327
- if (block.kind === "tool") {
328
- const raw = (block.toolLine ?? block.copyText).replace(/^·\s*/, "");
329
- const line = this.theme.fg("dim", ` └─ ${raw}`);
330
- lines.push({ text: truncateToWidth(line, width), plain: stripAnsi(line), blockIndex });
331
- continue;
332
- }
333
- if (lines.length > 0) {
334
- const separator = this.theme.fg("border", ` ${"─ ".repeat(Math.max(1, Math.floor((width - 6) / 2)))}`.trimEnd());
335
- lines.push({ text: truncateToWidth(separator, width), plain: stripAnsi(separator), blockIndex });
336
- }
337
- this.cachedMessageStarts.push(lines.length);
338
- this.cachedMessageBlocks.push(block);
339
- const header = ` ${block.header}`;
340
- lines.push({ text: truncateToWidth(header, width), plain: block.header, blockIndex, railable: true });
341
- try {
342
- const markdown = new Markdown(block.markdown || "∅", 2, 0, getMarkdownTheme(), {
343
- color: (text) => this.theme.fg(block.role === "meta" ? "muted" : "text", text),
344
- });
345
- for (const text of markdown.render(Math.max(1, width))) lines.push({ text: truncateToWidth(text, width), plain: stripAnsi(text), blockIndex, railable: true });
346
- } catch {
347
- try {
348
- for (const text of new Text(block.markdown || "∅").render(Math.max(1, width))) lines.push({ text: truncateToWidth(text, width), plain: stripAnsi(text), blockIndex, railable: true });
349
- } catch {
350
- const text = block.markdown || "∅";
351
- for (const line of text.split("\n")) lines.push({ text: truncateToWidth(line, width), plain: line, blockIndex, railable: true });
352
- }
353
- }
354
- }
386
+ this.timeline.setShowTools(this.showTools);
387
+ this.timeline.render(width);
388
+ const timelineSnapshot = this.timeline.getSnapshot();
389
+ const lines: RenderLine[] = timelineSnapshot.lines.map((line) => ({ ...line }));
390
+ this.cachedMessageStarts = [...timelineSnapshot.messageStarts];
391
+ this.cachedMessageBlocks = [...timelineSnapshot.messageBlocks];
355
392
  if (this.record.status === "running" && this.activity) {
356
393
  const act = describeActivity(this.activity.activeTools, this.activity.responseText);
357
394
  const text = `▍ ${act}`;
@@ -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,50 @@
1
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { streamToOutputFile, writeInitialEntry } from "../src/output-file.js";
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ afterEach(async () => {
10
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
11
+ });
12
+
13
+ describe("streamToOutputFile durable message snapshots", () => {
14
+ it("writes tool result details to both output and project history files", async () => {
15
+ const dir = await mkdtemp(join(tmpdir(), "pi-subagents-output-test-"));
16
+ tempDirs.push(dir);
17
+ const outputPath = join(dir, "agent.output");
18
+ const historyPath = join(dir, "history.jsonl");
19
+ writeInitialEntry(outputPath, "agent-1", "run", dir);
20
+ writeInitialEntry(historyPath, "agent-1", "run", dir);
21
+
22
+ let listener: ((event: any) => void) | undefined;
23
+ const session = {
24
+ messages: [
25
+ { role: "user", content: "run" },
26
+ {
27
+ role: "toolResult",
28
+ toolCallId: "call-1",
29
+ toolName: "write",
30
+ content: [{ type: "text", text: "Successfully wrote to file.txt" }],
31
+ details: { diff: "+1 new", patch: "+new" },
32
+ },
33
+ ],
34
+ subscribe(next: (event: any) => void) {
35
+ listener = next;
36
+ return () => { listener = undefined; };
37
+ },
38
+ } as any;
39
+
40
+ const cleanup = streamToOutputFile(session, outputPath, "agent-1", dir, historyPath);
41
+ listener?.({ type: "turn_end" });
42
+ cleanup();
43
+
44
+ for (const path of [outputPath, historyPath]) {
45
+ const entries = (await readFile(path, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
46
+ expect(entries).toHaveLength(2);
47
+ expect(entries[1].message.details).toEqual({ diff: "+1 new", patch: "+new" });
48
+ }
49
+ });
50
+ });
@@ -31,6 +31,37 @@ describe("formatConversationMessages", () => {
31
31
  expect(result?.fullText.length).toBeLessThanOrEqual(40);
32
32
  });
33
33
 
34
+ it("pairs tool results by id, then same-name source order, and preserves orphans", () => {
35
+ const blocks = formatConversationMessages([
36
+ { role: "assistant", content: [
37
+ { type: "toolCall", id: "call-read", name: "read", arguments: { path: "a.ts" } },
38
+ { type: "toolCall", id: "call-write", name: "write", arguments: { path: "b.ts", content: "new" } },
39
+ ] },
40
+ { role: "toolResult", toolCallId: "call-read", toolName: "read", isError: false, details: { source: "id" }, content: [{ type: "text", text: "read output" }] },
41
+ { role: "toolResult", toolName: "write", isError: true, details: { source: "name" }, content: [{ type: "text", text: "write failed" }] },
42
+ { role: "toolResult", toolCallId: "missing", toolName: "bash", isError: true, content: [{ type: "text", text: "orphan" }] },
43
+ ] as any);
44
+
45
+ const tools = blocks.filter((block) => block.kind === "tool");
46
+ expect(tools).toHaveLength(3);
47
+ expect(tools[0]?.toolCallId).toBe("call-read");
48
+ expect(tools[0]?.toolResult?.details).toEqual({ source: "id" });
49
+ expect(tools[1]?.toolCallId).toBe("call-write");
50
+ expect(tools[1]?.toolResult?.isError).toBe(true);
51
+ expect(tools[2]?.toolResult?.content).toEqual([{ type: "text", text: "orphan" }]);
52
+ expect(tools[2]?.fullText).toContain("orphan");
53
+ });
54
+
55
+ it("uses a stable synthetic id when a tool call omits its id", () => {
56
+ const blocks = formatConversationMessages([
57
+ { role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "a.ts" } }] },
58
+ { role: "toolResult", toolName: "read", content: [{ type: "text", text: "ok" }] },
59
+ ] as any);
60
+
61
+ expect(blocks[0]?.toolCallId).toBe("message-0-tool-0");
62
+ expect(blocks[0]?.toolResult?.content).toEqual([{ type: "text", text: "ok" }]);
63
+ });
64
+
34
65
  it("normalizes CRLF and bounds text with a deterministic truncation marker", () => {
35
66
  const blocks = formatConversationMessages([
36
67
  { role: "user", content: "one\r\ntwo\rthree\nfour" },
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import {
4
+ renderEditDiffResult,
5
+ renderWriteDiffResult,
6
+ } from "../../src/ui/ccstyle/diff/diff-renderer.js";
7
+ import { renderRichToolResult } from "../../src/ui/ccstyle/tool-result.js";
8
+ import { DEFAULT_TOOL_DISPLAY_CONFIG } from "../../src/ui/ccstyle/diff/types.js";
9
+
10
+ const theme = {
11
+ fg(color: string, text: string) {
12
+ return `<${color}>${text}</${color}>`;
13
+ },
14
+ bg(color: string, text: string) {
15
+ return `<bg:${color}>${text}</bg:${color}>`;
16
+ },
17
+ bold(text: string) {
18
+ return `<bold>${text}</bold>`;
19
+ },
20
+ getBgAnsi() {
21
+ return "";
22
+ },
23
+ getFgAnsi() {
24
+ return "";
25
+ },
26
+ };
27
+
28
+ const config = {
29
+ ...DEFAULT_TOOL_DISPLAY_CONFIG,
30
+ diffViewMode: "unified" as const,
31
+ };
32
+
33
+ describe("viewer-local ccstyle diff renderer", () => {
34
+ it("renders persisted write diff after the underlying file changes", () => {
35
+ const persisted = {
36
+ diff: "-1 old\n+1 new",
37
+ patch: "--- file.txt\n+++ file.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n",
38
+ };
39
+
40
+ const first = renderWriteDiffResult(undefined, {
41
+ expanded: true,
42
+ filePath: "file.txt",
43
+ persistedDiff: persisted,
44
+ }, config, theme, "");
45
+ const second = renderWriteDiffResult(undefined, {
46
+ expanded: true,
47
+ filePath: "file.txt",
48
+ persistedDiff: persisted,
49
+ }, config, theme, "");
50
+
51
+ expect(second.render(100).join("\n")).toContain("new");
52
+ expect(second.render(100).join("\n")).toBe(first.render(100).join("\n"));
53
+ });
54
+
55
+ it("prefers persisted write diff over the current content fallback", () => {
56
+ const component = renderWriteDiffResult("later\n", {
57
+ expanded: true,
58
+ filePath: "file.txt",
59
+ previousContent: "old\n",
60
+ fileExistedBeforeWrite: true,
61
+ persistedDiff: { diff: "-1 old\n+1 new", patch: "--- file.txt\n+++ file.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n" },
62
+ }, config, theme, "");
63
+
64
+ const rendered = component.render(100).join("\n");
65
+ expect(rendered).toContain("new");
66
+ expect(rendered).not.toContain("later");
67
+ });
68
+
69
+ it("returns an explicit unavailable row instead of an empty success card", () => {
70
+ const component = renderRichToolResult("write", {
71
+ content: [{ type: "text", text: "Successfully wrote" }],
72
+ details: { diffUnavailableReason: "durable diff exceeds limit" },
73
+ }, { expanded: true }, theme, { args: { path: "large.txt" } });
74
+
75
+ expect(component?.render(100).join("\n")).toContain("diff unavailable");
76
+ });
77
+
78
+ it("keeps edit diff and all output rows within the requested width", () => {
79
+ const component = renderEditDiffResult(
80
+ { diff: "-1 a very long removed line\n+1 a very long added line" },
81
+ { expanded: true, filePath: "file.ts" },
82
+ config,
83
+ theme,
84
+ "",
85
+ );
86
+
87
+ for (const width of [20, 40, 100]) {
88
+ expect(component.render(width).every((line) => visibleWidth(line) <= width)).toBe(true);
89
+ }
90
+ });
91
+ });
@@ -103,6 +103,56 @@ describe("ConversationViewer", () => {
103
103
  expect(historyFooter).not.toContain("M o O");
104
104
  });
105
105
 
106
+ it("shows semantic role headers, effective invocation metadata, and the search row", () => {
107
+ const semanticTheme = {
108
+ fg: (name: string, text: string) => `<${name}>${text}</${name}>`,
109
+ bold: (text: string) => `<bold>${text}</bold>`,
110
+ };
111
+ const viewer = new ConversationViewer(
112
+ tui(),
113
+ createStaticConversationSource([
114
+ { role: "user", content: "find model" },
115
+ { role: "assistant", content: [{ type: "text", text: "model found" }] },
116
+ ] as any),
117
+ record({ invocation: { effectiveModelName: "claude-sonnet", effectiveThinking: "high" } }),
118
+ undefined,
119
+ semanticTheme,
120
+ vi.fn(),
121
+ );
122
+
123
+ const initial = viewer.render(120).join("\n");
124
+ expect(initial).toContain("<userMessageText>USER</userMessageText>");
125
+ expect(initial).toContain("<accent>ASSISTANT</accent>");
126
+ expect(initial).toContain("model: claude-sonnet");
127
+ expect(initial).toContain("thinking: high");
128
+
129
+ viewer.handleInput("/");
130
+ const searchRow = viewer.render(120).join("\n");
131
+ expect(searchRow).toContain("<accent>/</accent>");
132
+ expect(searchRow).toContain("Enter apply · Esc cancel");
133
+ for (const character of "model") viewer.handleInput(character);
134
+ expect(viewer.render(120).join("\n")).toContain("model");
135
+ });
136
+
137
+ it("focuses and expands a bounded fallback tool card", () => {
138
+ const viewer = new ConversationViewer(
139
+ tui(),
140
+ createStaticConversationSource([
141
+ { role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
142
+ { role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
143
+ ] as any),
144
+ record(),
145
+ undefined,
146
+ theme,
147
+ vi.fn(),
148
+ );
149
+
150
+ expect(viewer.render(120).join("\n")).not.toContain("six");
151
+ viewer.handleInput("\t");
152
+ viewer.handleInput("\r");
153
+ expect(viewer.render(120).join("\n")).toContain("six");
154
+ });
155
+
106
156
  it("keeps historical viewers read-only and closes on Escape", () => {
107
157
  const ui = tui();
108
158
  const steer = vi.fn();