@esso0428/pi-subagents 0.15.6 → 0.15.8

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 +14 -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 +266 -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 +318 -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 +70 -0
  215. package/test/write-execution.test.ts +83 -0
  216. package/tsconfig.json +16 -0
  217. package/vitest.temp.config.mts +6 -0
@@ -3,6 +3,15 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
3
3
  export type ConversationBlockKind = "text" | "tool" | "meta";
4
4
  export type ConversationBlockRole = "user" | "assistant" | "tool" | "custom" | "meta";
5
5
 
6
+ /** The durable result snapshot attached to a normalized tool block. */
7
+ export interface ConversationToolResultSnapshot {
8
+ toolCallId?: string;
9
+ toolName?: string;
10
+ content?: unknown;
11
+ details?: unknown;
12
+ isError?: boolean;
13
+ }
14
+
6
15
  export interface ConversationBlock {
7
16
  id: string;
8
17
  kind: ConversationBlockKind;
@@ -12,6 +21,18 @@ export interface ConversationBlock {
12
21
  copyText: string;
13
22
  fullText: string;
14
23
  toolLine?: string;
24
+ /** Stable call id, including a synthetic id when the provider omitted one. */
25
+ toolCallId?: string;
26
+ toolName?: string;
27
+ /** Original tool arguments are retained for rich input rendering. */
28
+ toolArguments?: unknown;
29
+ /** Compatibility-facing short alias used by the timeline renderer. */
30
+ args?: unknown;
31
+ /** Result details/content are retained for rich output and durable diff rendering. */
32
+ toolResult?: ConversationToolResultSnapshot;
33
+ /** Compatibility-facing short alias used by the timeline renderer. */
34
+ result?: ConversationToolResultSnapshot;
35
+ toolStatus?: "pending" | "success" | "error";
15
36
  }
16
37
 
17
38
  export interface ConversationFormatOptions {
@@ -29,11 +50,17 @@ type UnknownMessage = {
29
50
  command?: unknown;
30
51
  output?: unknown;
31
52
  toolName?: unknown;
53
+ toolCallId?: unknown;
54
+ id?: unknown;
55
+ name?: unknown;
56
+ details?: unknown;
32
57
  isError?: unknown;
33
58
  customType?: unknown;
34
59
  display?: unknown;
35
60
  };
36
61
 
62
+ type UnknownToolCall = Record<string, unknown>;
63
+
37
64
  function charLimit(value: number | undefined, fallback: number): number {
38
65
  if (value === undefined || !Number.isFinite(value)) return fallback;
39
66
  return Math.max(0, Math.floor(value));
@@ -49,7 +76,7 @@ function bound(value: string, limit: number): string {
49
76
  }
50
77
 
51
78
  function asString(value: unknown): string | undefined {
52
- return typeof value === "string" ? value : undefined;
79
+ return typeof value === "string" && value.length > 0 ? value : undefined;
53
80
  }
54
81
 
55
82
  /** Extract displayable text without leaking provider-specific or unknown parts. */
@@ -76,7 +103,7 @@ function firstNonEmptyLine(text: string): string {
76
103
  return line.trim().replace(/\s+/g, " ");
77
104
  }
78
105
 
79
- function toolSummary(name: string, call: Record<string, unknown>): string {
106
+ function toolSummary(name: string, call: UnknownToolCall): string {
80
107
  const args = call.arguments && typeof call.arguments === "object"
81
108
  ? call.arguments as Record<string, unknown>
82
109
  : call.input && typeof call.input === "object"
@@ -100,6 +127,18 @@ function blockId(messageIndex: number, emitted: number, toolIndex?: number): str
100
127
  return `message-${messageIndex}-tool-${toolIndex ?? emitted}`;
101
128
  }
102
129
 
130
+ function uniqueBlockId(preferred: string, used: Set<string>): string {
131
+ if (!used.has(preferred)) {
132
+ used.add(preferred);
133
+ return preferred;
134
+ }
135
+ let suffix = 2;
136
+ while (used.has(`${preferred}-${suffix}`)) suffix++;
137
+ const id = `${preferred}-${suffix}`;
138
+ used.add(id);
139
+ return id;
140
+ }
141
+
103
142
  function textBlock(
104
143
  id: string,
105
144
  role: "user" | "assistant",
@@ -118,37 +157,73 @@ function textBlock(
118
157
 
119
158
  function toolCallBlock(
120
159
  id: string,
160
+ callId: string,
121
161
  name: string,
162
+ call: UnknownToolCall,
122
163
  summary: string,
123
164
  ): ConversationBlock {
124
165
  const toolLine = `· ${name} → ${summary || "(no details)"}`;
125
166
  return {
126
167
  id,
127
168
  kind: "tool",
128
- role: "assistant",
129
- header: "Assistant tool call",
169
+ role: "tool",
170
+ header: name,
130
171
  markdown: "",
131
172
  copyText: toolLine,
132
173
  fullText: toolLine,
133
174
  toolLine,
175
+ toolCallId: callId,
176
+ toolName: name,
177
+ toolArguments: call.arguments ?? call.input,
178
+ args: call.arguments ?? call.input,
179
+ toolStatus: "pending",
134
180
  };
135
181
  }
136
182
 
137
- function toolResultBlock(
138
- id: string,
139
- message: UnknownMessage,
183
+ function resultSnapshot(message: UnknownMessage): ConversationToolResultSnapshot {
184
+ return {
185
+ toolCallId: asString(message.toolCallId) ?? asString(message.id),
186
+ toolName: asString(message.toolName) ?? asString(message.name),
187
+ content: message.content,
188
+ details: message.details,
189
+ isError: message.isError === true,
190
+ };
191
+ }
192
+
193
+ function applyToolResult(
194
+ block: ConversationBlock,
195
+ result: ConversationToolResultSnapshot,
140
196
  maxChars: number,
141
- ): ConversationBlock | undefined {
142
- const name = asString(message.toolName) ?? "tool";
143
- const output = contentText(message.content);
144
- const normalized = output.replace(/\r\n?/g, "\n");
145
- if (!normalized.trim() && maxChars === 0) return undefined;
146
- const success = message.isError === true ? "✗" : "✓";
197
+ ): void {
198
+ const name = result.toolName ?? block.toolName ?? "tool";
199
+ const normalized = contentText(result.content).replace(/\r\n?/g, "\n");
200
+ const success = result.isError ? "✗" : "✓";
147
201
  const lineCount = normalized.length === 0 ? 0 : normalized.split("\n").length;
148
202
  const charCount = normalized.length;
149
203
  const summary = firstNonEmptyLine(normalized);
204
+ block.toolResult = result;
205
+ block.result = result;
206
+ block.toolStatus = result.isError ? "error" : "success";
207
+ block.toolName = name;
208
+ block.header = name;
209
+ block.copyText = bound(`${name} ${success}\n${normalized}`, maxChars);
210
+ block.fullText = block.copyText;
211
+ block.toolLine = `· ${name} ${success} ${summary} ${lineCount}L/${charCount}c`;
212
+ }
213
+
214
+ function orphanToolResultBlock(
215
+ id: string,
216
+ result: ConversationToolResultSnapshot,
217
+ maxChars: number,
218
+ ): ConversationBlock {
219
+ const name = result.toolName ?? "tool";
220
+ const output = contentText(result.content).replace(/\r\n?/g, "\n");
221
+ const success = result.isError ? "✗" : "✓";
222
+ const lineCount = output.length === 0 ? 0 : output.split("\n").length;
223
+ const charCount = output.length;
224
+ const summary = firstNonEmptyLine(output);
150
225
  const toolLine = `· ${name} ${success} ${summary} ${lineCount}L/${charCount}c`;
151
- const fullText = bound(`${name} ${success}\n${normalized}`, maxChars);
226
+ const fullText = bound(`${name} ${success}\n${output}`, maxChars);
152
227
  return {
153
228
  id,
154
229
  kind: "tool",
@@ -158,6 +233,11 @@ function toolResultBlock(
158
233
  copyText: fullText,
159
234
  fullText,
160
235
  toolLine,
236
+ toolCallId: result.toolCallId,
237
+ toolName: name,
238
+ toolResult: result,
239
+ result,
240
+ toolStatus: result.isError ? "error" : "success",
161
241
  };
162
242
  }
163
243
 
@@ -165,11 +245,10 @@ function bashBlock(
165
245
  id: string,
166
246
  message: UnknownMessage,
167
247
  maxChars: number,
168
- ): ConversationBlock | undefined {
248
+ ): ConversationBlock {
169
249
  const command = asString(message.command) ?? "";
170
250
  const output = asString(message.output) ?? contentText(message.content);
171
251
  const body = command ? `$ ${command}${output ? `\n${output}` : ""}` : output;
172
- if (!body.trim() && maxChars === 0) return undefined;
173
252
  const toolLine = `· bash → ${command || "(no command)"}`;
174
253
  const fullText = bound(body, maxChars);
175
254
  return {
@@ -181,6 +260,7 @@ function bashBlock(
181
260
  copyText: fullText,
182
261
  fullText,
183
262
  toolLine,
263
+ toolName: "bash",
184
264
  };
185
265
  }
186
266
 
@@ -204,6 +284,13 @@ function metaBlock(
204
284
  };
205
285
  }
206
286
 
287
+ /**
288
+ * Convert provider messages into stable, render-oriented blocks.
289
+ *
290
+ * Tool results are attached to the original call when possible. This keeps the
291
+ * block in call/source order while preserving the complete result snapshot for
292
+ * rich rendering. Results that cannot be matched remain visible as orphan cards.
293
+ */
207
294
  export function formatConversationMessages(
208
295
  messages: AgentSession["messages"],
209
296
  options?: ConversationFormatOptions,
@@ -211,6 +298,26 @@ export function formatConversationMessages(
211
298
  const maxEntryChars = charLimit(options?.maxEntryChars, DEFAULT_MAX_CHARS);
212
299
  const maxToolResultChars = charLimit(options?.maxToolResultChars, DEFAULT_MAX_CHARS);
213
300
  const blocks: ConversationBlock[] = [];
301
+ const usedBlockIds = new Set<string>();
302
+ const callsById = new Map<string, ConversationBlock>();
303
+ const callsByName = new Map<string, ConversationBlock[]>();
304
+
305
+ const registerCall = (name: string, block: ConversationBlock) => {
306
+ callsById.set(block.toolCallId!, block);
307
+ const nameKey = name.toLowerCase();
308
+ const candidates = callsByName.get(nameKey) ?? [];
309
+ candidates.push(block);
310
+ callsByName.set(nameKey, candidates);
311
+ };
312
+ const unregisterCall = (block: ConversationBlock) => {
313
+ if (block.toolCallId) callsById.delete(block.toolCallId);
314
+ const nameKey = (block.toolName ?? "tool").toLowerCase();
315
+ const candidates = callsByName.get(nameKey);
316
+ if (!candidates) return;
317
+ const index = candidates.indexOf(block);
318
+ if (index >= 0) candidates.splice(index, 1);
319
+ if (candidates.length === 0) callsByName.delete(nameKey);
320
+ };
214
321
 
215
322
  for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
216
323
  const message = messages[messageIndex] as unknown as UnknownMessage;
@@ -219,49 +326,76 @@ export function formatConversationMessages(
219
326
 
220
327
  if (role === "user") {
221
328
  const text = bound(contentText(message.content), maxEntryChars);
222
- if (text.trim()) blocks.push(textBlock(blockId(messageIndex, emitted++), "user", text));
329
+ if (text.trim()) {
330
+ blocks.push(textBlock(uniqueBlockId(blockId(messageIndex, emitted), usedBlockIds), "user", text));
331
+ }
223
332
  continue;
224
333
  }
225
334
 
226
335
  if (role === "assistant") {
227
336
  const parts = Array.isArray(message.content) ? message.content : [];
228
337
  const text = bound(contentText(parts), maxEntryChars);
229
- if (text.trim()) blocks.push(textBlock(blockId(messageIndex, emitted++), "assistant", text));
338
+ if (text.trim()) {
339
+ blocks.push(textBlock(uniqueBlockId(blockId(messageIndex, emitted++), usedBlockIds), "assistant", text));
340
+ }
230
341
 
231
342
  let toolIndex = 0;
232
343
  for (const part of parts) {
233
344
  if (!part || typeof part !== "object" || (part as any).type !== "toolCall") continue;
234
- const call = part as Record<string, unknown>;
345
+ const call = part as UnknownToolCall;
235
346
  const name = asString(call.name) ?? asString(call.toolName) ?? "unknown";
236
- blocks.push(toolCallBlock(blockId(messageIndex, emitted++, toolIndex), name, toolSummary(name, call)));
347
+ const preferredId = asString(call.id) ?? asString(call.toolCallId)
348
+ ?? `message-${messageIndex}-tool-${toolIndex}`;
349
+ const callId = callsById.has(preferredId)
350
+ ? uniqueBlockId(preferredId, new Set(callsById.keys()))
351
+ : preferredId;
352
+ const block = toolCallBlock(
353
+ uniqueBlockId(blockId(messageIndex, emitted++, toolIndex), usedBlockIds),
354
+ callId,
355
+ name,
356
+ call,
357
+ toolSummary(name, call),
358
+ );
359
+ blocks.push(block);
360
+ registerCall(name, block);
237
361
  toolIndex++;
238
362
  }
239
363
  continue;
240
364
  }
241
365
 
242
366
  if (role === "toolResult") {
243
- const block = toolResultBlock(blockId(messageIndex, emitted), message, maxToolResultChars);
244
- if (block) blocks.push(block);
367
+ const result = resultSnapshot(message);
368
+ const direct = result.toolCallId ? callsById.get(result.toolCallId) : undefined;
369
+ let match = direct;
370
+ if (!match && result.toolName) {
371
+ match = callsByName.get(result.toolName.toLowerCase())?.[0];
372
+ }
373
+ if (match) {
374
+ applyToolResult(match, result, maxToolResultChars);
375
+ unregisterCall(match);
376
+ } else {
377
+ const preferred = blockId(messageIndex, emitted);
378
+ blocks.push(orphanToolResultBlock(uniqueBlockId(preferred, usedBlockIds), result, maxToolResultChars));
379
+ }
245
380
  continue;
246
381
  }
247
382
 
248
383
  if (role === "bashExecution") {
249
- const block = bashBlock(blockId(messageIndex, emitted), message, maxToolResultChars);
250
- if (block) blocks.push(block);
384
+ blocks.push(bashBlock(uniqueBlockId(blockId(messageIndex, emitted), usedBlockIds), message, maxToolResultChars));
251
385
  continue;
252
386
  }
253
387
 
254
388
  if (role === "custom") {
255
389
  if (message.display === false) continue;
256
390
  const header = asString(message.customType) || "Custom";
257
- const block = metaBlock(blockId(messageIndex, emitted), header, message.content, maxEntryChars);
391
+ const block = metaBlock(uniqueBlockId(blockId(messageIndex, emitted), usedBlockIds), header, message.content, maxEntryChars);
258
392
  if (block) blocks.push({ ...block, role: "custom" });
259
393
  continue;
260
394
  }
261
395
 
262
396
  if (role === "compactionSummary" || role === "branchSummary" || role === "summary") {
263
397
  const header = role === "summary" ? "Summary" : role === "compactionSummary" ? "Compaction summary" : "Branch summary";
264
- const block = metaBlock(blockId(messageIndex, emitted), header, message.summary, maxEntryChars);
398
+ const block = metaBlock(uniqueBlockId(blockId(messageIndex, emitted), usedBlockIds), header, message.summary, maxEntryChars);
265
399
  if (block) blocks.push(block);
266
400
  }
267
401
  }
@@ -0,0 +1,45 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import type { ConversationBlock } from "./conversation-blocks.js";
3
+ import type { Theme } from "./agent-widget.js";
4
+
5
+ export type ConversationRoleTheme = Pick<Theme, "fg">;
6
+
7
+ /**
8
+ * Render a semantic role label without coloring the whole message body.
9
+ * These names intentionally mirror the labels used by peek-style viewers.
10
+ */
11
+ export function conversationRoleLabel(block: ConversationBlock): string {
12
+ if (block.kind === "tool") return (block.toolName || "TOOL").toUpperCase();
13
+ if (block.role === "user") return "USER";
14
+ if (block.role === "assistant") return "ASSISTANT";
15
+ if (block.role === "custom") return block.header || "CUSTOM";
16
+ return block.header || "META";
17
+ }
18
+
19
+ export function renderConversationRoleHeader(
20
+ block: ConversationBlock,
21
+ theme: ConversationRoleTheme,
22
+ ): string {
23
+ const label = conversationRoleLabel(block);
24
+ const color = block.kind === "tool"
25
+ ? "toolTitle"
26
+ : block.role === "user"
27
+ ? "userMessageText"
28
+ : block.role === "assistant"
29
+ ? "accent"
30
+ : block.role === "custom"
31
+ ? "customMessageLabel"
32
+ : "muted";
33
+ const detail = block.kind === "tool" && block.toolCallId
34
+ ? theme.fg("dim", ` · ${block.toolCallId}`)
35
+ : "";
36
+ return `${theme.fg(color, label)}${detail}`;
37
+ }
38
+
39
+ /** Adapter useful to callers that need a Component-shaped role row. */
40
+ export function conversationRoleComponent(block: ConversationBlock, theme: ConversationRoleTheme): Component {
41
+ return {
42
+ render: () => [` ${renderConversationRoleHeader(block, theme)}`],
43
+ invalidate() {},
44
+ };
45
+ }
@@ -0,0 +1,318 @@
1
+ import * as CodingAgent from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Markdown,
4
+ Text,
5
+ type Component,
6
+ type TUI,
7
+ type TuiMouseEvent,
8
+ truncateToWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
11
+ import type { AgentRecord } from "../types.js";
12
+ import type { Theme } from "./agent-widget.js";
13
+ import {
14
+ type ConversationBlock,
15
+ type ConversationToolResultSnapshot,
16
+ } from "./conversation-blocks.js";
17
+ import { renderConversationRoleHeader } from "./conversation-role.js";
18
+ import { stripAnsi } from "./conversation-search.js";
19
+ import {
20
+ createViewerCcstyleTool,
21
+ type ViewerToolRenderers,
22
+ } from "./ccstyle/tool-renderer.js";
23
+
24
+ export interface TimelineRenderLine {
25
+ text: string;
26
+ plain: string;
27
+ blockIndex: number;
28
+ railable?: boolean;
29
+ }
30
+
31
+ export interface TimelineToolSpan {
32
+ toolCallId: string;
33
+ startLine: number;
34
+ height: number;
35
+ }
36
+
37
+ export interface ConversationTimelineSnapshot {
38
+ lines: TimelineRenderLine[];
39
+ messageStarts: number[];
40
+ messageBlocks: ConversationBlock[];
41
+ interactiveToolIds: string[];
42
+ toolSpans: TimelineToolSpan[];
43
+ }
44
+
45
+ export interface ConversationTimelineOptions {
46
+ cwd?: string;
47
+ record?: AgentRecord;
48
+ onChange?: () => void;
49
+ }
50
+
51
+ function safeWidth(width: number): number {
52
+ return Math.max(1, Math.floor(Number.isFinite(width) ? width : 1));
53
+ }
54
+
55
+ function resultContent(result: ConversationToolResultSnapshot): Array<Record<string, unknown>> {
56
+ if (Array.isArray(result.content)) return result.content as Array<Record<string, unknown>>;
57
+ if (typeof result.content === "string") return [{ type: "text", text: result.content }];
58
+ return [];
59
+ }
60
+
61
+ function fallbackToolLines(block: ConversationBlock, width: number, expanded = false): string[] {
62
+ const summary = block.toolLine || block.fullText || block.toolName || "tool";
63
+ const output = block.toolResult
64
+ ? block.toolResult.content
65
+ : undefined;
66
+ const outputText = typeof output === "string"
67
+ ? output
68
+ : Array.isArray(output)
69
+ ? output.filter((item: any) => item?.type === "text").map((item: any) => String(item.text ?? "")).join("\n")
70
+ : "";
71
+ const outputLines = outputText.split(/\r?\n/);
72
+ const displayOutput = expanded || outputLines.length <= 4
73
+ ? outputLines
74
+ : [...outputLines.slice(0, 4), `… (${outputLines.length - 4} more lines · Enter to expand)`];
75
+ const body = outputText ? [summary, ...displayOutput].join("\n") : summary;
76
+ return body.split(/\r?\n/).map((line) => truncateToWidth(line, width, ""));
77
+ }
78
+
79
+ function applyToolComponentState(component: any, block: ConversationBlock, expanded: boolean): void {
80
+ component.updateArgs?.(block.args ?? block.toolArguments ?? {});
81
+ component.setArgsComplete?.();
82
+ if (block.result ?? block.toolResult) {
83
+ const result = (block.result ?? block.toolResult)!;
84
+ component.updateResult?.({
85
+ content: resultContent(result),
86
+ details: result.details,
87
+ isError: result.isError === true,
88
+ }, false);
89
+ }
90
+ component.setExpanded?.(expanded);
91
+ }
92
+
93
+ function createToolComponent(
94
+ block: ConversationBlock,
95
+ tui: TUI,
96
+ cwd: string,
97
+ expanded: boolean,
98
+ ): Component | undefined {
99
+ const Constructor = (CodingAgent as any).ToolExecutionComponent as (new (...args: any[]) => Component) | undefined;
100
+ if (typeof Constructor !== "function" || !block.toolName || !block.toolCallId) return undefined;
101
+
102
+ try {
103
+ const renderer = createViewerCcstyleTool(block.toolName) as ViewerToolRenderers;
104
+ const component = new Constructor(
105
+ block.toolName,
106
+ block.toolCallId,
107
+ block.args ?? block.toolArguments ?? {},
108
+ { showImages: false },
109
+ renderer,
110
+ tui,
111
+ cwd,
112
+ ) as any;
113
+ component.markExecutionStarted?.();
114
+ applyToolComponentState(component, block, expanded);
115
+ return component;
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Component-aware, snapshot-driven conversation timeline.
123
+ * ToolExecutionComponent state is rebuilt from normalized blocks when a call id
124
+ * first appears and then reused; history never needs a live session, spinner,
125
+ * stream, or process handle.
126
+ */
127
+ export class ConversationTimeline implements Component {
128
+ private blocks: ConversationBlock[] = [];
129
+ private showTools = true;
130
+ private focusedToolCallId: string | undefined;
131
+ private expandedTools = new Set<string>();
132
+ private toolComponents = new Map<string, Component & { handleMouse?: (event: TuiMouseEvent) => unknown }>();
133
+ private snapshot: ConversationTimelineSnapshot = {
134
+ lines: [],
135
+ messageStarts: [],
136
+ messageBlocks: [],
137
+ interactiveToolIds: [],
138
+ toolSpans: [],
139
+ };
140
+ private lastWidth = 0;
141
+
142
+ constructor(
143
+ private readonly tui: TUI,
144
+ private readonly theme: Theme,
145
+ private readonly options: ConversationTimelineOptions = {},
146
+ ) {}
147
+
148
+ setBlocks(blocks: readonly ConversationBlock[]): void {
149
+ this.blocks = [...blocks];
150
+ const ids = new Set(this.blocks.filter((block) => block.kind === "tool" && block.toolCallId).map((block) => block.toolCallId!));
151
+ if (this.focusedToolCallId && !ids.has(this.focusedToolCallId)) this.focusedToolCallId = undefined;
152
+ for (const id of this.expandedTools) if (!ids.has(id)) this.expandedTools.delete(id);
153
+ for (const id of this.toolComponents.keys()) if (!ids.has(id)) this.toolComponents.delete(id);
154
+ this.invalidate();
155
+ }
156
+
157
+ setShowTools(show: boolean): void {
158
+ if (this.showTools === show) return;
159
+ this.showTools = show;
160
+ if (!show) this.focusedToolCallId = undefined;
161
+ this.invalidate();
162
+ }
163
+
164
+ getShowTools(): boolean {
165
+ return this.showTools;
166
+ }
167
+
168
+ setFocusedToolCallId(id: string | undefined): void {
169
+ this.focusedToolCallId = id;
170
+ this.invalidate();
171
+ }
172
+
173
+ getFocusedToolCallId(): string | undefined {
174
+ return this.focusedToolCallId;
175
+ }
176
+
177
+ getSnapshot(): ConversationTimelineSnapshot {
178
+ if (this.lastWidth <= 0) this.render(80);
179
+ return this.snapshot;
180
+ }
181
+
182
+ moveFocus(direction: 1 | -1): boolean {
183
+ const ids = this.getSnapshot().interactiveToolIds;
184
+ if (ids.length === 0) return false;
185
+ const current = this.focusedToolCallId ? ids.indexOf(this.focusedToolCallId) : -1;
186
+ const next = current < 0
187
+ ? direction > 0 ? 0 : ids.length - 1
188
+ : (current + direction + ids.length) % ids.length;
189
+ this.focusedToolCallId = ids[next];
190
+ this.invalidate();
191
+ this.options.onChange?.();
192
+ return true;
193
+ }
194
+
195
+ toggleFocusedTool(): boolean {
196
+ const id = this.focusedToolCallId;
197
+ if (!id) return false;
198
+ if (this.expandedTools.has(id)) this.expandedTools.delete(id);
199
+ else this.expandedTools.add(id);
200
+ this.invalidate();
201
+ this.options.onChange?.();
202
+ return true;
203
+ }
204
+
205
+ isToolFocused(): boolean {
206
+ return this.focusedToolCallId !== undefined;
207
+ }
208
+
209
+ render(width: number): string[] {
210
+ const requestedWidth = safeWidth(width);
211
+ const lines: TimelineRenderLine[] = [];
212
+ const messageStarts: number[] = [];
213
+ const messageBlocks: ConversationBlock[] = [];
214
+ const interactiveToolIds: string[] = [];
215
+ const toolSpans: TimelineToolSpan[] = [];
216
+ const visibleBlocks = this.blocks.filter((block) => this.showTools || block.kind !== "tool");
217
+
218
+ if (visibleBlocks.length === 0) {
219
+ const text = this.blocks.length === 0 ? "(waiting for first message...)" : "(tools hidden)";
220
+ lines.push({ text: this.theme.fg("dim", text), plain: text, blockIndex: -1 });
221
+ }
222
+
223
+ for (const block of visibleBlocks) {
224
+ const blockIndex = this.blocks.indexOf(block);
225
+ if (lines.length > 0) {
226
+ const separator = this.theme.fg("border", " " + "─ ".repeat(Math.max(1, Math.floor((requestedWidth - 6) / 2))).trimEnd());
227
+ lines.push({ text: truncateToWidth(separator, requestedWidth), plain: stripAnsi(separator), blockIndex });
228
+ }
229
+
230
+ const headerLine = ` ${renderConversationRoleHeader(block, this.theme)}`;
231
+ messageStarts.push(lines.length);
232
+ messageBlocks.push(block);
233
+ lines.push({ text: truncateToWidth(headerLine, requestedWidth), plain: stripAnsi(headerLine), blockIndex, railable: true });
234
+
235
+ if (block.kind === "tool") {
236
+ const id = block.toolCallId;
237
+ if (id) interactiveToolIds.push(id);
238
+ let component: (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
239
+ if (id) {
240
+ const existing = this.toolComponents.get(id);
241
+ if (existing) {
242
+ applyToolComponentState(existing, block, this.expandedTools.has(id));
243
+ component = existing;
244
+ } else {
245
+ component = createToolComponent(block, this.tui, this.options.cwd ?? process.cwd(), this.expandedTools.has(id)) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
246
+ if (component) this.toolComponents.set(id, component);
247
+ }
248
+ }
249
+ let rendered: string[];
250
+ try {
251
+ rendered = component?.render(requestedWidth) ?? fallbackToolLines(block, requestedWidth, id ? this.expandedTools.has(id) : false);
252
+ } catch {
253
+ // Pi's interactive theme is a capability, not a viewer dependency;
254
+ // test hosts and older Pi versions can lack its initialized theme.
255
+ rendered = fallbackToolLines(block, requestedWidth, id ? this.expandedTools.has(id) : false);
256
+ }
257
+ for (const line of rendered) {
258
+ lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
259
+ }
260
+ if (id) toolSpans.push({ toolCallId: id, startLine: messageStarts[messageStarts.length - 1], height: lines.length - messageStarts[messageStarts.length - 1] });
261
+ continue;
262
+ }
263
+
264
+ try {
265
+ const markdown = new Markdown(block.markdown || "∅", 2, 0, getMarkdownTheme(), {
266
+ color: (text) => this.theme.fg(block.role === "meta" ? "muted" : "text", text),
267
+ });
268
+ for (const line of markdown.render(requestedWidth)) {
269
+ lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
270
+ }
271
+ } catch {
272
+ try {
273
+ for (const line of new Text(block.markdown || "∅").render(requestedWidth)) {
274
+ lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
275
+ }
276
+ } catch {
277
+ for (const line of (block.markdown || "∅").split("\n")) {
278
+ lines.push({ text: truncateToWidth(line, requestedWidth), plain: line, blockIndex, railable: true });
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ this.lastWidth = requestedWidth;
285
+ this.snapshot = { lines, messageStarts, messageBlocks, interactiveToolIds, toolSpans };
286
+ return lines.map((line) => line.text);
287
+ }
288
+
289
+ handleMouse(event: TuiMouseEvent): { handled?: boolean; render?: boolean } | undefined {
290
+ // A normalized mouse interaction emits press/release/click. Toggle only
291
+ // on the terminal click; handling press as well causes one user click to
292
+ // expand and immediately collapse the same card.
293
+ if (event.type !== "click" || event.button !== "left") return undefined;
294
+ const span = this.snapshot.toolSpans.find((candidate) => event.y >= candidate.startLine && event.y < candidate.startLine + candidate.height);
295
+ if (!span) return undefined;
296
+ this.focusedToolCallId = span.toolCallId;
297
+ const component = this.toolComponents.get(span.toolCallId);
298
+ try {
299
+ // Keep Pi's normalized mouse contract in the component tree. The local
300
+ // role header occupies the first row; ToolExecutionComponent receives
301
+ // the remaining card-relative coordinate.
302
+ component?.handleMouse?.({ ...event, y: event.y - span.startLine, height: span.height });
303
+ } catch {
304
+ // A missing/older Pi mouse implementation must not break the viewer.
305
+ }
306
+ this.expandedTools.has(span.toolCallId)
307
+ ? this.expandedTools.delete(span.toolCallId)
308
+ : this.expandedTools.add(span.toolCallId);
309
+ this.invalidate();
310
+ this.options.onChange?.();
311
+ return { handled: true, render: true };
312
+ }
313
+
314
+ invalidate(): void {
315
+ this.lastWidth = 0;
316
+ this.snapshot = { lines: [], messageStarts: [], messageBlocks: [], interactiveToolIds: [], toolSpans: [] };
317
+ }
318
+ }