@mariozechner/pi-coding-agent 0.12.5 → 0.12.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.
@@ -1,30 +1,27 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { basename } from "path";
4
- import { VERSION } from "./config.js";
5
- /**
6
- * TUI Color scheme (matching exact RGB values from TUI components)
7
- */
4
+ import { APP_NAME, VERSION } from "./config.js";
5
+ // ============================================================================
6
+ // Color scheme (matching TUI)
7
+ // ============================================================================
8
8
  const COLORS = {
9
- // Backgrounds
10
- userMessageBg: "rgb(52, 53, 65)", // Dark slate
11
- toolPendingBg: "rgb(40, 40, 50)", // Dark blue-gray
12
- toolSuccessBg: "rgb(40, 50, 40)", // Dark green
13
- toolErrorBg: "rgb(60, 40, 40)", // Dark red
14
- bodyBg: "rgb(24, 24, 30)", // Very dark background
15
- containerBg: "rgb(30, 30, 36)", // Slightly lighter container
16
- // Text colors (matching chalk colors)
17
- text: "rgb(229, 229, 231)", // Light gray (close to white)
18
- textDim: "rgb(161, 161, 170)", // Dimmed gray
19
- cyan: "rgb(103, 232, 249)", // Cyan for paths
20
- green: "rgb(34, 197, 94)", // Green for success
21
- red: "rgb(239, 68, 68)", // Red for errors
22
- yellow: "rgb(234, 179, 8)", // Yellow for warnings
23
- italic: "rgb(161, 161, 170)", // Gray italic for thinking
9
+ userMessageBg: "rgb(52, 53, 65)",
10
+ toolPendingBg: "rgb(40, 40, 50)",
11
+ toolSuccessBg: "rgb(40, 50, 40)",
12
+ toolErrorBg: "rgb(60, 40, 40)",
13
+ bodyBg: "rgb(24, 24, 30)",
14
+ containerBg: "rgb(30, 30, 36)",
15
+ text: "rgb(229, 229, 231)",
16
+ textDim: "rgb(161, 161, 170)",
17
+ cyan: "rgb(103, 232, 249)",
18
+ green: "rgb(34, 197, 94)",
19
+ red: "rgb(239, 68, 68)",
20
+ yellow: "rgb(234, 179, 8)",
24
21
  };
25
- /**
26
- * Escape HTML special characters
27
- */
22
+ // ============================================================================
23
+ // Utility functions
24
+ // ============================================================================
28
25
  function escapeHtml(text) {
29
26
  return text
30
27
  .replace(/&/g, "&")
@@ -33,216 +30,316 @@ function escapeHtml(text) {
33
30
  .replace(/"/g, """)
34
31
  .replace(/'/g, "'");
35
32
  }
36
- /**
37
- * Shorten path with tilde notation
38
- */
39
33
  function shortenPath(path) {
40
34
  const home = homedir();
41
- if (path.startsWith(home)) {
42
- return "~" + path.slice(home.length);
43
- }
44
- return path;
35
+ return path.startsWith(home) ? "~" + path.slice(home.length) : path;
45
36
  }
46
- /**
47
- * Replace tabs with 3 spaces
48
- */
49
37
  function replaceTabs(text) {
50
38
  return text.replace(/\t/g, " ");
51
39
  }
52
- /**
53
- * Format tool execution matching TUI ToolExecutionComponent
54
- */
55
- function formatToolExecution(toolName, args, result) {
56
- let html = "";
57
- const isError = result?.isError || false;
58
- const bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg;
59
- // Get text output from result
60
- const getTextOutput = () => {
61
- if (!result)
62
- return "";
63
- const textBlocks = result.content.filter((c) => c.type === "text");
64
- return textBlocks.map((c) => c.text).join("\n");
40
+ function formatTimestamp(timestamp) {
41
+ if (!timestamp)
42
+ return "";
43
+ const date = new Date(typeof timestamp === "string" ? timestamp : timestamp);
44
+ return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
45
+ }
46
+ // ============================================================================
47
+ // Parsing functions
48
+ // ============================================================================
49
+ function parseSessionManagerFormat(lines) {
50
+ const data = {
51
+ sessionId: "unknown",
52
+ timestamp: new Date().toISOString(),
53
+ modelsUsed: new Set(),
54
+ messages: [],
55
+ toolResultsMap: new Map(),
56
+ sessionEvents: [],
57
+ tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
58
+ costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
65
59
  };
66
- // Format based on tool type (matching TUI logic exactly)
67
- if (toolName === "bash") {
68
- const command = args?.command || "";
69
- html = `<div class="tool-command">$ ${escapeHtml(command || "...")}</div>`;
70
- if (result) {
71
- const output = getTextOutput().trim();
72
- if (output) {
73
- const lines = output.split("\n");
74
- const maxLines = 5;
75
- const displayLines = lines.slice(0, maxLines);
76
- const remaining = lines.length - maxLines;
77
- if (remaining > 0) {
78
- // Truncated output - make it expandable
79
- html += '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
80
- html += '<div class="output-preview">';
81
- for (const line of displayLines) {
82
- html += `<div>${escapeHtml(line)}</div>`;
83
- }
84
- html += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
85
- html += "</div>";
86
- html += '<div class="output-full">';
87
- for (const line of lines) {
88
- html += `<div>${escapeHtml(line)}</div>`;
89
- }
90
- html += "</div>";
91
- html += "</div>";
60
+ for (const line of lines) {
61
+ let entry;
62
+ try {
63
+ entry = JSON.parse(line);
64
+ }
65
+ catch {
66
+ continue;
67
+ }
68
+ switch (entry.type) {
69
+ case "session":
70
+ data.sessionId = entry.id || "unknown";
71
+ data.timestamp = entry.timestamp || data.timestamp;
72
+ data.systemPrompt = entry.systemPrompt;
73
+ if (entry.modelId) {
74
+ const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;
75
+ data.modelsUsed.add(modelInfo);
76
+ }
77
+ break;
78
+ case "message": {
79
+ const message = entry.message;
80
+ data.messages.push(message);
81
+ data.sessionEvents.push({
82
+ type: "message",
83
+ message,
84
+ timestamp: entry.timestamp,
85
+ });
86
+ if (message.role === "toolResult") {
87
+ const toolResult = message;
88
+ data.toolResultsMap.set(toolResult.toolCallId, toolResult);
92
89
  }
93
- else {
94
- // Short output - show all
95
- html += '<div class="tool-output">';
96
- for (const line of displayLines) {
97
- html += `<div>${escapeHtml(line)}</div>`;
90
+ else if (message.role === "assistant") {
91
+ const assistantMsg = message;
92
+ if (assistantMsg.usage) {
93
+ data.tokenStats.input += assistantMsg.usage.input || 0;
94
+ data.tokenStats.output += assistantMsg.usage.output || 0;
95
+ data.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0;
96
+ data.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0;
97
+ if (assistantMsg.usage.cost) {
98
+ data.costStats.input += assistantMsg.usage.cost.input || 0;
99
+ data.costStats.output += assistantMsg.usage.cost.output || 0;
100
+ data.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0;
101
+ data.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0;
102
+ }
98
103
  }
99
- html += "</div>";
100
104
  }
105
+ break;
101
106
  }
107
+ case "model_change":
108
+ data.sessionEvents.push({
109
+ type: "model_change",
110
+ provider: entry.provider,
111
+ modelId: entry.modelId,
112
+ timestamp: entry.timestamp,
113
+ });
114
+ if (entry.modelId) {
115
+ const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;
116
+ data.modelsUsed.add(modelInfo);
117
+ }
118
+ break;
119
+ case "compaction":
120
+ data.sessionEvents.push({
121
+ type: "compaction",
122
+ timestamp: entry.timestamp,
123
+ summary: entry.summary,
124
+ tokensBefore: entry.tokensBefore,
125
+ });
126
+ break;
102
127
  }
103
128
  }
104
- else if (toolName === "read") {
105
- const path = shortenPath(args?.file_path || args?.path || "");
106
- html = `<div class="tool-header"><span class="tool-name">read</span> <span class="tool-path">${escapeHtml(path || "...")}</span></div>`;
107
- if (result) {
108
- const output = getTextOutput();
109
- const lines = output.split("\n");
110
- const maxLines = 10;
111
- const displayLines = lines.slice(0, maxLines);
112
- const remaining = lines.length - maxLines;
113
- if (remaining > 0) {
114
- // Truncated output - make it expandable
115
- html += '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
116
- html += '<div class="output-preview">';
117
- for (const line of displayLines) {
118
- html += `<div>${escapeHtml(replaceTabs(line))}</div>`;
129
+ return data;
130
+ }
131
+ function parseStreamingEventFormat(lines) {
132
+ const data = {
133
+ sessionId: "unknown",
134
+ timestamp: new Date().toISOString(),
135
+ modelsUsed: new Set(),
136
+ messages: [],
137
+ toolResultsMap: new Map(),
138
+ sessionEvents: [],
139
+ tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
140
+ costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
141
+ isStreamingFormat: true,
142
+ };
143
+ let timestampSet = false;
144
+ for (const line of lines) {
145
+ let entry;
146
+ try {
147
+ entry = JSON.parse(line);
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ if (entry.type === "message_end" && entry.message) {
153
+ const msg = entry.message;
154
+ data.messages.push(msg);
155
+ data.sessionEvents.push({
156
+ type: "message",
157
+ message: msg,
158
+ timestamp: msg.timestamp,
159
+ });
160
+ if (msg.role === "toolResult") {
161
+ const toolResult = msg;
162
+ data.toolResultsMap.set(toolResult.toolCallId, toolResult);
163
+ }
164
+ else if (msg.role === "assistant") {
165
+ const assistantMsg = msg;
166
+ if (assistantMsg.model) {
167
+ const modelInfo = assistantMsg.provider
168
+ ? `${assistantMsg.provider}/${assistantMsg.model}`
169
+ : assistantMsg.model;
170
+ data.modelsUsed.add(modelInfo);
119
171
  }
120
- html += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
121
- html += "</div>";
122
- html += '<div class="output-full">';
123
- for (const line of lines) {
124
- html += `<div>${escapeHtml(replaceTabs(line))}</div>`;
172
+ if (assistantMsg.usage) {
173
+ data.tokenStats.input += assistantMsg.usage.input || 0;
174
+ data.tokenStats.output += assistantMsg.usage.output || 0;
175
+ data.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0;
176
+ data.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0;
177
+ if (assistantMsg.usage.cost) {
178
+ data.costStats.input += assistantMsg.usage.cost.input || 0;
179
+ data.costStats.output += assistantMsg.usage.cost.output || 0;
180
+ data.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0;
181
+ data.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0;
182
+ }
125
183
  }
126
- html += "</div>";
127
- html += "</div>";
128
184
  }
129
- else {
130
- // Short output - show all
131
- html += '<div class="tool-output">';
132
- for (const line of displayLines) {
133
- html += `<div>${escapeHtml(replaceTabs(line))}</div>`;
134
- }
135
- html += "</div>";
185
+ if (!timestampSet && msg.timestamp) {
186
+ data.timestamp = new Date(msg.timestamp).toISOString();
187
+ timestampSet = true;
136
188
  }
137
189
  }
138
190
  }
139
- else if (toolName === "write") {
140
- const path = shortenPath(args?.file_path || args?.path || "");
141
- const fileContent = args?.content || "";
142
- const lines = fileContent ? fileContent.split("\n") : [];
143
- const totalLines = lines.length;
144
- html = `<div class="tool-header"><span class="tool-name">write</span> <span class="tool-path">${escapeHtml(path || "...")}</span>`;
145
- if (totalLines > 10) {
146
- html += ` <span class="line-count">(${totalLines} lines)</span>`;
191
+ data.sessionId = `stream-${data.timestamp.replace(/[:.]/g, "-")}`;
192
+ return data;
193
+ }
194
+ function detectFormat(lines) {
195
+ for (const line of lines) {
196
+ try {
197
+ const entry = JSON.parse(line);
198
+ if (entry.type === "session")
199
+ return "session-manager";
200
+ if (entry.type === "agent_start" || entry.type === "message_start" || entry.type === "turn_start") {
201
+ return "streaming-events";
202
+ }
147
203
  }
148
- html += "</div>";
149
- if (fileContent) {
150
- const maxLines = 10;
151
- const displayLines = lines.slice(0, maxLines);
152
- const remaining = lines.length - maxLines;
153
- if (remaining > 0) {
154
- // Truncated output - make it expandable
155
- html += '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
156
- html += '<div class="output-preview">';
157
- for (const line of displayLines) {
158
- html += `<div>${escapeHtml(replaceTabs(line))}</div>`;
159
- }
160
- html += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
161
- html += "</div>";
162
- html += '<div class="output-full">';
163
- for (const line of lines) {
164
- html += `<div>${escapeHtml(replaceTabs(line))}</div>`;
165
- }
166
- html += "</div>";
167
- html += "</div>";
204
+ catch { }
205
+ }
206
+ return "unknown";
207
+ }
208
+ function parseSessionFile(content) {
209
+ const lines = content
210
+ .trim()
211
+ .split("\n")
212
+ .filter((l) => l.trim());
213
+ if (lines.length === 0) {
214
+ throw new Error("Empty session file");
215
+ }
216
+ const format = detectFormat(lines);
217
+ if (format === "unknown") {
218
+ throw new Error("Unknown session file format");
219
+ }
220
+ return format === "session-manager" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines);
221
+ }
222
+ // ============================================================================
223
+ // HTML formatting functions
224
+ // ============================================================================
225
+ function formatToolExecution(toolName, args, result) {
226
+ let html = "";
227
+ const isError = result?.isError || false;
228
+ const bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg;
229
+ const getTextOutput = () => {
230
+ if (!result)
231
+ return "";
232
+ const textBlocks = result.content.filter((c) => c.type === "text");
233
+ return textBlocks.map((c) => c.text).join("\n");
234
+ };
235
+ const formatExpandableOutput = (lines, maxLines) => {
236
+ const displayLines = lines.slice(0, maxLines);
237
+ const remaining = lines.length - maxLines;
238
+ if (remaining > 0) {
239
+ let out = '<div class="tool-output expandable" onclick="this.classList.toggle(\'expanded\')">';
240
+ out += '<div class="output-preview">';
241
+ for (const line of displayLines) {
242
+ out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
168
243
  }
169
- else {
170
- // Short output - show all
171
- html += '<div class="tool-output">';
172
- for (const line of displayLines) {
173
- html += `<div>${escapeHtml(replaceTabs(line))}</div>`;
174
- }
175
- html += "</div>";
244
+ out += `<div class="expand-hint">... (${remaining} more lines) - click to expand</div>`;
245
+ out += "</div>";
246
+ out += '<div class="output-full">';
247
+ for (const line of lines) {
248
+ out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
176
249
  }
250
+ out += "</div></div>";
251
+ return out;
177
252
  }
178
- if (result) {
179
- const output = getTextOutput().trim();
180
- if (output) {
181
- html += `<div class="tool-output"><div>${escapeHtml(output)}</div></div>`;
182
- }
253
+ let out = '<div class="tool-output">';
254
+ for (const line of displayLines) {
255
+ out += `<div>${escapeHtml(replaceTabs(line))}</div>`;
183
256
  }
184
- }
185
- else if (toolName === "edit") {
186
- const path = shortenPath(args?.file_path || args?.path || "");
187
- html = `<div class="tool-header"><span class="tool-name">edit</span> <span class="tool-path">${escapeHtml(path || "...")}</span></div>`;
188
- // Show diff if available from result.details.diff
189
- if (result?.details?.diff) {
190
- const diffLines = result.details.diff.split("\n");
191
- html += '<div class="tool-diff">';
192
- for (const line of diffLines) {
193
- if (line.startsWith("+")) {
194
- html += `<div class="diff-line-new">${escapeHtml(line)}</div>`;
195
- }
196
- else if (line.startsWith("-")) {
197
- html += `<div class="diff-line-old">${escapeHtml(line)}</div>`;
257
+ out += "</div>";
258
+ return out;
259
+ };
260
+ switch (toolName) {
261
+ case "bash": {
262
+ const command = args?.command || "";
263
+ html = `<div class="tool-command">$ ${escapeHtml(command || "...")}</div>`;
264
+ if (result) {
265
+ const output = getTextOutput().trim();
266
+ if (output) {
267
+ html += formatExpandableOutput(output.split("\n"), 5);
198
268
  }
199
- else {
200
- html += `<div class="diff-line-context">${escapeHtml(line)}</div>`;
269
+ }
270
+ break;
271
+ }
272
+ case "read": {
273
+ const path = shortenPath(args?.file_path || args?.path || "");
274
+ html = `<div class="tool-header"><span class="tool-name">read</span> <span class="tool-path">${escapeHtml(path || "...")}</span></div>`;
275
+ if (result) {
276
+ const output = getTextOutput();
277
+ if (output) {
278
+ html += formatExpandableOutput(output.split("\n"), 10);
201
279
  }
202
280
  }
281
+ break;
282
+ }
283
+ case "write": {
284
+ const path = shortenPath(args?.file_path || args?.path || "");
285
+ const fileContent = args?.content || "";
286
+ const lines = fileContent ? fileContent.split("\n") : [];
287
+ html = `<div class="tool-header"><span class="tool-name">write</span> <span class="tool-path">${escapeHtml(path || "...")}</span>`;
288
+ if (lines.length > 10) {
289
+ html += ` <span class="line-count">(${lines.length} lines)</span>`;
290
+ }
203
291
  html += "</div>";
204
- }
205
- if (result) {
206
- const output = getTextOutput().trim();
207
- if (output) {
208
- html += `<div class="tool-output"><div>${escapeHtml(output)}</div></div>`;
292
+ if (fileContent) {
293
+ html += formatExpandableOutput(lines, 10);
209
294
  }
210
- }
211
- }
212
- else {
213
- // Generic tool
214
- html = `<div class="tool-header"><span class="tool-name">${escapeHtml(toolName)}</span></div>`;
215
- html += `<div class="tool-output"><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></div>`;
216
- if (result) {
217
- const output = getTextOutput();
218
- if (output) {
219
- html += `<div class="tool-output"><div>${escapeHtml(output)}</div></div>`;
295
+ if (result) {
296
+ const output = getTextOutput().trim();
297
+ if (output) {
298
+ html += `<div class="tool-output"><div>${escapeHtml(output)}</div></div>`;
299
+ }
300
+ }
301
+ break;
302
+ }
303
+ case "edit": {
304
+ const path = shortenPath(args?.file_path || args?.path || "");
305
+ html = `<div class="tool-header"><span class="tool-name">edit</span> <span class="tool-path">${escapeHtml(path || "...")}</span></div>`;
306
+ if (result?.details?.diff) {
307
+ const diffLines = result.details.diff.split("\n");
308
+ html += '<div class="tool-diff">';
309
+ for (const line of diffLines) {
310
+ if (line.startsWith("+")) {
311
+ html += `<div class="diff-line-new">${escapeHtml(line)}</div>`;
312
+ }
313
+ else if (line.startsWith("-")) {
314
+ html += `<div class="diff-line-old">${escapeHtml(line)}</div>`;
315
+ }
316
+ else {
317
+ html += `<div class="diff-line-context">${escapeHtml(line)}</div>`;
318
+ }
319
+ }
320
+ html += "</div>";
321
+ }
322
+ if (result) {
323
+ const output = getTextOutput().trim();
324
+ if (output) {
325
+ html += `<div class="tool-output"><div>${escapeHtml(output)}</div></div>`;
326
+ }
327
+ }
328
+ break;
329
+ }
330
+ default: {
331
+ html = `<div class="tool-header"><span class="tool-name">${escapeHtml(toolName)}</span></div>`;
332
+ html += `<div class="tool-output"><pre>${escapeHtml(JSON.stringify(args, null, 2))}</pre></div>`;
333
+ if (result) {
334
+ const output = getTextOutput();
335
+ if (output) {
336
+ html += `<div class="tool-output"><div>${escapeHtml(output)}</div></div>`;
337
+ }
220
338
  }
221
339
  }
222
340
  }
223
341
  return { html, bgColor };
224
342
  }
225
- /**
226
- * Format timestamp for display
227
- */
228
- function formatTimestamp(timestamp) {
229
- if (!timestamp)
230
- return "";
231
- const date = new Date(typeof timestamp === "string" ? timestamp : timestamp);
232
- return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
233
- }
234
- /**
235
- * Format model change event
236
- */
237
- function formatModelChange(event) {
238
- const timestamp = formatTimestamp(event.timestamp);
239
- const timestampHtml = timestamp ? `<div class="message-timestamp">${timestamp}</div>` : "";
240
- const modelInfo = `${event.provider}/${event.modelId}`;
241
- return `<div class="model-change">${timestampHtml}<div class="model-change-text">Switched to model: <span class="model-name">${escapeHtml(modelInfo)}</span></div></div>`;
242
- }
243
- /**
244
- * Format a message as HTML (matching TUI component styling)
245
- */
246
343
  function formatMessage(message, toolResultsMap) {
247
344
  let html = "";
248
345
  const timestamp = message.timestamp;
@@ -264,7 +361,6 @@ function formatMessage(message, toolResultsMap) {
264
361
  else if (message.role === "assistant") {
265
362
  const assistantMsg = message;
266
363
  html += timestampHtml ? `<div class="assistant-message">${timestampHtml}` : "";
267
- // Render text and thinking content
268
364
  for (const content of assistantMsg.content) {
269
365
  if (content.type === "text" && content.text.trim()) {
270
366
  html += `<div class="assistant-text">${escapeHtml(content.text.trim()).replace(/\n/g, "<br>")}</div>`;
@@ -273,7 +369,6 @@ function formatMessage(message, toolResultsMap) {
273
369
  html += `<div class="thinking-text">${escapeHtml(content.thinking.trim()).replace(/\n/g, "<br>")}</div>`;
274
370
  }
275
371
  }
276
- // Render tool calls with their results
277
372
  for (const content of assistantMsg.content) {
278
373
  if (content.type === "toolCall") {
279
374
  const toolResult = toolResultsMap.get(content.id);
@@ -281,158 +376,121 @@ function formatMessage(message, toolResultsMap) {
281
376
  html += `<div class="tool-execution" style="background-color: ${bgColor}">${toolHtml}</div>`;
282
377
  }
283
378
  }
284
- // Show error/abort status if no tool calls
285
379
  const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall");
286
380
  if (!hasToolCalls) {
287
381
  if (assistantMsg.stopReason === "aborted") {
288
382
  html += '<div class="error-text">Aborted</div>';
289
383
  }
290
384
  else if (assistantMsg.stopReason === "error") {
291
- const errorMsg = assistantMsg.errorMessage || "Unknown error";
292
- html += `<div class="error-text">Error: ${escapeHtml(errorMsg)}</div>`;
385
+ html += `<div class="error-text">Error: ${escapeHtml(assistantMsg.errorMessage || "Unknown error")}</div>`;
293
386
  }
294
387
  }
295
- // Close the assistant message wrapper if we opened one
296
388
  if (timestampHtml) {
297
389
  html += "</div>";
298
390
  }
299
391
  }
300
392
  return html;
301
393
  }
302
- /**
303
- * Export session to a self-contained HTML file matching TUI visual style
304
- */
305
- export function exportSessionToHtml(sessionManager, state, outputPath) {
306
- const sessionFile = sessionManager.getSessionFile();
307
- const timestamp = new Date().toISOString();
308
- // Use pi-session- prefix + session filename + .html if no output path provided
309
- if (!outputPath) {
310
- const sessionBasename = basename(sessionFile, ".jsonl");
311
- outputPath = `pi-session-${sessionBasename}.html`;
312
- }
313
- // Read and parse session data
314
- const sessionContent = readFileSync(sessionFile, "utf8");
315
- const lines = sessionContent.trim().split("\n");
316
- let sessionHeader = null;
317
- const messages = [];
318
- const toolResultsMap = new Map();
319
- const sessionEvents = []; // Track all events including model changes
320
- const modelsUsed = new Set(); // Track unique models used
321
- // Cumulative token and cost stats
322
- const tokenStats = {
323
- input: 0,
324
- output: 0,
325
- cacheRead: 0,
326
- cacheWrite: 0,
327
- };
328
- const costStats = {
329
- input: 0,
330
- output: 0,
331
- cacheRead: 0,
332
- cacheWrite: 0,
333
- };
334
- for (const line of lines) {
335
- try {
336
- const entry = JSON.parse(line);
337
- if (entry.type === "session") {
338
- sessionHeader = entry;
339
- // Track initial model from session header
340
- if (entry.modelId) {
341
- const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;
342
- modelsUsed.add(modelInfo);
343
- }
344
- }
345
- else if (entry.type === "message") {
346
- messages.push(entry.message);
347
- sessionEvents.push(entry);
348
- // Build map of tool call ID to result
349
- if (entry.message.role === "toolResult") {
350
- toolResultsMap.set(entry.message.toolCallId, entry.message);
351
- }
352
- // Accumulate token and cost stats from assistant messages
353
- if (entry.message.role === "assistant" && entry.message.usage) {
354
- const usage = entry.message.usage;
355
- tokenStats.input += usage.input || 0;
356
- tokenStats.output += usage.output || 0;
357
- tokenStats.cacheRead += usage.cacheRead || 0;
358
- tokenStats.cacheWrite += usage.cacheWrite || 0;
359
- if (usage.cost) {
360
- costStats.input += usage.cost.input || 0;
361
- costStats.output += usage.cost.output || 0;
362
- costStats.cacheRead += usage.cost.cacheRead || 0;
363
- costStats.cacheWrite += usage.cost.cacheWrite || 0;
364
- }
365
- }
366
- }
367
- else if (entry.type === "model_change") {
368
- sessionEvents.push(entry);
369
- // Track model from model change event
370
- if (entry.modelId) {
371
- const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;
372
- modelsUsed.add(modelInfo);
373
- }
374
- }
375
- }
376
- catch {
377
- // Skip malformed lines
378
- }
379
- }
380
- // Calculate message stats (matching session command)
381
- const userMessages = messages.filter((m) => m.role === "user").length;
382
- const assistantMessages = messages.filter((m) => m.role === "assistant").length;
383
- const toolResultMessages = messages.filter((m) => m.role === "toolResult").length;
384
- const totalMessages = messages.length;
385
- // Count tool calls from assistant messages
394
+ function formatModelChange(event) {
395
+ const timestamp = formatTimestamp(event.timestamp);
396
+ const timestampHtml = timestamp ? `<div class="message-timestamp">${timestamp}</div>` : "";
397
+ const modelInfo = `${event.provider}/${event.modelId}`;
398
+ return `<div class="model-change">${timestampHtml}<div class="model-change-text">Switched to model: <span class="model-name">${escapeHtml(modelInfo)}</span></div></div>`;
399
+ }
400
+ function formatCompaction(event) {
401
+ const timestamp = formatTimestamp(event.timestamp);
402
+ const timestampHtml = timestamp ? `<div class="message-timestamp">${timestamp}</div>` : "";
403
+ const summaryHtml = escapeHtml(event.summary).replace(/\n/g, "<br>");
404
+ return `<div class="compaction-container">
405
+ <div class="compaction-header" onclick="this.parentElement.classList.toggle('expanded')">
406
+ ${timestampHtml}
407
+ <div class="compaction-header-row">
408
+ <span class="compaction-toggle">▶</span>
409
+ <span class="compaction-title">Context compacted from ${event.tokensBefore.toLocaleString()} tokens</span>
410
+ <span class="compaction-hint">(click to expand summary)</span>
411
+ </div>
412
+ </div>
413
+ <div class="compaction-content">
414
+ <div class="compaction-summary">
415
+ <div class="compaction-summary-header">Summary sent to model</div>
416
+ <div class="compaction-summary-content">${summaryHtml}</div>
417
+ </div>
418
+ </div>
419
+ </div>`;
420
+ }
421
+ // ============================================================================
422
+ // HTML generation
423
+ // ============================================================================
424
+ function generateHtml(data, filename) {
425
+ const userMessages = data.messages.filter((m) => m.role === "user").length;
426
+ const assistantMessages = data.messages.filter((m) => m.role === "assistant").length;
386
427
  let toolCallsCount = 0;
387
- for (const message of messages) {
428
+ for (const message of data.messages) {
388
429
  if (message.role === "assistant") {
389
- const assistantMsg = message;
390
- toolCallsCount += assistantMsg.content.filter((c) => c.type === "toolCall").length;
430
+ toolCallsCount += message.content.filter((c) => c.type === "toolCall").length;
391
431
  }
392
432
  }
393
- // Get last assistant message for context percentage calculation (skip aborted messages)
394
- const lastAssistantMessage = messages
433
+ const lastAssistantMessage = data.messages
395
434
  .slice()
396
435
  .reverse()
397
436
  .find((m) => m.role === "assistant" && m.stopReason !== "aborted");
398
- // Calculate context percentage from last message (input + output + cacheRead + cacheWrite)
399
437
  const contextTokens = lastAssistantMessage
400
438
  ? lastAssistantMessage.usage.input +
401
439
  lastAssistantMessage.usage.output +
402
440
  lastAssistantMessage.usage.cacheRead +
403
441
  lastAssistantMessage.usage.cacheWrite
404
442
  : 0;
405
- // Get the model info from the last assistant message
406
- const lastModel = lastAssistantMessage?.model || state.model?.id || "unknown";
443
+ const lastModel = lastAssistantMessage?.model || "unknown";
407
444
  const lastProvider = lastAssistantMessage?.provider || "";
408
445
  const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;
409
- const contextWindow = state.model?.contextWindow || 0;
410
- const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : "0.0";
411
- // Generate messages HTML (including model changes in chronological order)
446
+ const contextWindow = data.contextWindow || 0;
447
+ const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : null;
412
448
  let messagesHtml = "";
413
- for (const event of sessionEvents) {
414
- if (event.type === "message" && event.message.role !== "toolResult") {
415
- // Skip toolResult messages as they're rendered with their tool calls
416
- messagesHtml += formatMessage(event.message, toolResultsMap);
417
- }
418
- else if (event.type === "model_change") {
419
- messagesHtml += formatModelChange(event);
449
+ for (const event of data.sessionEvents) {
450
+ switch (event.type) {
451
+ case "message":
452
+ if (event.message.role !== "toolResult") {
453
+ messagesHtml += formatMessage(event.message, data.toolResultsMap);
454
+ }
455
+ break;
456
+ case "model_change":
457
+ messagesHtml += formatModelChange(event);
458
+ break;
459
+ case "compaction":
460
+ messagesHtml += formatCompaction(event);
461
+ break;
420
462
  }
421
463
  }
422
- // Generate HTML (matching TUI aesthetic)
423
- const html = `<!DOCTYPE html>
464
+ const systemPromptHtml = data.systemPrompt
465
+ ? `<div class="system-prompt">
466
+ <div class="system-prompt-header">System Prompt</div>
467
+ <div class="system-prompt-content">${escapeHtml(data.systemPrompt)}</div>
468
+ </div>`
469
+ : "";
470
+ const toolsHtml = data.tools
471
+ ? `<div class="tools-list">
472
+ <div class="tools-header">Available Tools</div>
473
+ <div class="tools-content">
474
+ ${data.tools.map((tool) => `<div class="tool-item"><span class="tool-item-name">${escapeHtml(tool.name)}</span> - ${escapeHtml(tool.description)}</div>`).join("")}
475
+ </div>
476
+ </div>`
477
+ : "";
478
+ const streamingNotice = data.isStreamingFormat
479
+ ? `<div class="streaming-notice">
480
+ <em>Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.</em>
481
+ </div>`
482
+ : "";
483
+ const contextUsageText = contextPercent
484
+ ? `${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}`
485
+ : `${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}`;
486
+ return `<!DOCTYPE html>
424
487
  <html lang="en">
425
488
  <head>
426
489
  <meta charset="UTF-8">
427
490
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
428
- <title>Session Export - ${basename(sessionFile)}</title>
491
+ <title>Session Export - ${escapeHtml(filename)}</title>
429
492
  <style>
430
- * {
431
- margin: 0;
432
- padding: 0;
433
- box-sizing: border-box;
434
- }
435
-
493
+ * { margin: 0; padding: 0; box-sizing: border-box; }
436
494
  body {
437
495
  font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
438
496
  font-size: 12px;
@@ -441,69 +499,26 @@ export function exportSessionToHtml(sessionManager, state, outputPath) {
441
499
  background: ${COLORS.bodyBg};
442
500
  padding: 24px;
443
501
  }
444
-
445
- .container {
446
- max-width: 700px;
447
- margin: 0 auto;
448
- }
449
-
502
+ .container { max-width: 700px; margin: 0 auto; }
450
503
  .header {
451
504
  margin-bottom: 24px;
452
505
  padding: 16px;
453
506
  background: ${COLORS.containerBg};
454
507
  border-radius: 4px;
455
508
  }
456
-
457
509
  .header h1 {
458
510
  font-size: 14px;
459
511
  font-weight: bold;
460
512
  margin-bottom: 12px;
461
513
  color: ${COLORS.cyan};
462
514
  }
463
-
464
- .header-info {
465
- display: flex;
466
- flex-direction: column;
467
- gap: 3px;
468
- font-size: 11px;
469
- }
470
-
471
- .info-item {
472
- color: ${COLORS.textDim};
473
- display: flex;
474
- align-items: baseline;
475
- }
476
-
477
- .info-label {
478
- font-weight: 600;
479
- margin-right: 8px;
480
- min-width: 100px;
481
- }
482
-
483
- .info-value {
484
- color: ${COLORS.text};
485
- flex: 1;
486
- }
487
-
488
- .info-value.cost {
489
- font-family: 'SF Mono', monospace;
490
- }
491
-
492
- .messages {
493
- display: flex;
494
- flex-direction: column;
495
- gap: 16px;
496
- }
497
-
498
- /* Message timestamp */
499
- .message-timestamp {
500
- font-size: 10px;
501
- color: ${COLORS.textDim};
502
- margin-bottom: 4px;
503
- opacity: 0.8;
504
- }
505
-
506
- /* User message - matching TUI UserMessageComponent */
515
+ .header-info { display: flex; flex-direction: column; gap: 3px; font-size: 11px; }
516
+ .info-item { color: ${COLORS.textDim}; display: flex; align-items: baseline; }
517
+ .info-label { font-weight: 600; margin-right: 8px; min-width: 100px; }
518
+ .info-value { color: ${COLORS.text}; flex: 1; }
519
+ .info-value.cost { font-family: 'SF Mono', monospace; }
520
+ .messages { display: flex; flex-direction: column; gap: 16px; }
521
+ .message-timestamp { font-size: 10px; color: ${COLORS.textDim}; margin-bottom: 4px; opacity: 0.8; }
507
522
  .user-message {
508
523
  background: ${COLORS.userMessageBg};
509
524
  padding: 12px 16px;
@@ -513,1011 +528,162 @@ export function exportSessionToHtml(sessionManager, state, outputPath) {
513
528
  overflow-wrap: break-word;
514
529
  word-break: break-word;
515
530
  }
516
-
517
- /* Assistant message wrapper */
518
- .assistant-message {
519
- padding: 0;
520
- }
521
-
522
- /* Assistant text - matching TUI AssistantMessageComponent */
523
- .assistant-text {
531
+ .assistant-message { padding: 0; }
532
+ .assistant-text, .thinking-text {
524
533
  padding: 12px 16px;
525
534
  white-space: pre-wrap;
526
535
  word-wrap: break-word;
527
536
  overflow-wrap: break-word;
528
537
  word-break: break-word;
529
538
  }
530
-
531
- /* Thinking text - gray italic */
532
- .thinking-text {
533
- padding: 12px 16px;
534
- color: ${COLORS.italic};
535
- font-style: italic;
539
+ .thinking-text { color: ${COLORS.textDim}; font-style: italic; }
540
+ .model-change { padding: 8px 16px; background: rgb(40, 40, 50); border-radius: 4px; }
541
+ .model-change-text { color: ${COLORS.textDim}; font-size: 11px; }
542
+ .model-name { color: ${COLORS.cyan}; font-weight: bold; }
543
+ .compaction-container { background: rgb(60, 55, 35); border-radius: 4px; overflow: hidden; }
544
+ .compaction-header { padding: 12px 16px; cursor: pointer; }
545
+ .compaction-header:hover { background: rgba(255, 255, 255, 0.05); }
546
+ .compaction-header-row { display: flex; align-items: center; gap: 8px; }
547
+ .compaction-toggle { color: ${COLORS.cyan}; font-size: 10px; transition: transform 0.2s; }
548
+ .compaction-container.expanded .compaction-toggle { transform: rotate(90deg); }
549
+ .compaction-title { color: ${COLORS.text}; font-weight: bold; }
550
+ .compaction-hint { color: ${COLORS.textDim}; font-size: 11px; }
551
+ .compaction-content { display: none; padding: 0 16px 16px 16px; }
552
+ .compaction-container.expanded .compaction-content { display: block; }
553
+ .compaction-summary { background: rgba(0, 0, 0, 0.2); border-radius: 4px; padding: 12px; }
554
+ .compaction-summary-header { font-weight: bold; color: ${COLORS.cyan}; margin-bottom: 8px; font-size: 11px; }
555
+ .compaction-summary-content { color: ${COLORS.text}; white-space: pre-wrap; word-wrap: break-word; }
556
+ .tool-execution { padding: 12px 16px; border-radius: 4px; margin-top: 8px; }
557
+ .tool-header, .tool-name { font-weight: bold; }
558
+ .tool-path { color: ${COLORS.cyan}; word-break: break-all; }
559
+ .line-count { color: ${COLORS.textDim}; }
560
+ .tool-command { font-weight: bold; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
561
+ .tool-output {
562
+ margin-top: 12px;
563
+ color: ${COLORS.textDim};
536
564
  white-space: pre-wrap;
537
565
  word-wrap: break-word;
538
566
  overflow-wrap: break-word;
539
567
  word-break: break-word;
540
- }
541
-
542
- /* Model change */
543
- .model-change {
544
- padding: 8px 16px;
545
- background: rgb(40, 40, 50);
546
- border-radius: 4px;
547
- }
548
-
549
- .model-change-text {
550
- color: ${COLORS.textDim};
551
- font-size: 11px;
552
- }
553
-
554
- .model-name {
555
- color: ${COLORS.cyan};
556
- font-weight: bold;
557
- }
558
-
559
- /* Tool execution - matching TUI ToolExecutionComponent */
560
- .tool-execution {
561
- padding: 12px 16px;
562
- border-radius: 4px;
563
- margin-top: 8px;
564
- }
565
-
566
- .tool-header {
567
- font-weight: bold;
568
- }
569
-
570
- .tool-name {
571
- font-weight: bold;
572
- }
573
-
574
- .tool-path {
575
- color: ${COLORS.cyan};
576
- word-break: break-all;
577
- }
578
-
579
- .line-count {
580
- color: ${COLORS.textDim};
581
- }
582
-
583
- .tool-command {
584
- font-weight: bold;
585
- white-space: pre-wrap;
586
- word-wrap: break-word;
587
- overflow-wrap: break-word;
588
- word-break: break-word;
589
- }
590
-
591
- .tool-output {
592
- margin-top: 12px;
593
- color: ${COLORS.textDim};
594
- white-space: pre-wrap;
595
- word-wrap: break-word;
596
- overflow-wrap: break-word;
597
- word-break: break-word;
598
- font-family: inherit;
599
- overflow-x: auto;
600
- }
601
-
602
- .tool-output > div {
603
- line-height: 1.4;
604
- }
605
-
606
- .tool-output pre {
607
- margin: 0;
608
- font-family: inherit;
609
- color: inherit;
610
- white-space: pre-wrap;
611
- word-wrap: break-word;
612
- overflow-wrap: break-word;
613
- }
614
-
615
- /* Expandable tool output */
616
- .tool-output.expandable {
617
- cursor: pointer;
618
- }
619
-
620
- .tool-output.expandable:hover {
621
- opacity: 0.9;
622
- }
623
-
624
- .tool-output.expandable .output-full {
625
- display: none;
626
- }
627
-
628
- .tool-output.expandable.expanded .output-preview {
629
- display: none;
630
- }
631
-
632
- .tool-output.expandable.expanded .output-full {
633
- display: block;
634
- }
635
-
636
- .expand-hint {
637
- color: ${COLORS.cyan};
638
- font-style: italic;
639
- margin-top: 4px;
640
- }
641
-
642
- /* System prompt section */
643
- .system-prompt {
644
- background: rgb(60, 55, 40);
645
- padding: 12px 16px;
646
- border-radius: 4px;
647
- margin-bottom: 16px;
648
- }
649
-
650
- .system-prompt-header {
651
- font-weight: bold;
652
- color: ${COLORS.yellow};
653
- margin-bottom: 8px;
654
- }
655
-
656
- .system-prompt-content {
657
- color: ${COLORS.textDim};
658
- white-space: pre-wrap;
659
- word-wrap: break-word;
660
- overflow-wrap: break-word;
661
- word-break: break-word;
662
- font-size: 11px;
663
- }
664
-
665
- .tools-list {
666
- background: rgb(60, 55, 40);
667
- padding: 12px 16px;
668
- border-radius: 4px;
669
- margin-bottom: 16px;
670
- }
671
-
672
- .tools-header {
673
- font-weight: bold;
674
- color: ${COLORS.yellow};
675
- margin-bottom: 8px;
676
- }
677
-
678
- .tools-content {
679
- color: ${COLORS.textDim};
680
- font-size: 11px;
681
- }
682
-
683
- .tool-item {
684
- margin: 4px 0;
685
- }
686
-
687
- .tool-item-name {
688
- font-weight: bold;
689
- color: ${COLORS.text};
690
- }
691
-
692
- /* Diff styling */
693
- .tool-diff {
694
- margin-top: 12px;
695
- font-size: 11px;
696
- font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
697
- overflow-x: auto;
698
- max-width: 100%;
699
- }
700
-
701
- .diff-line-old {
702
- color: ${COLORS.red};
703
- white-space: pre-wrap;
704
- word-wrap: break-word;
705
- overflow-wrap: break-word;
706
- }
707
-
708
- .diff-line-new {
709
- color: ${COLORS.green};
710
- white-space: pre-wrap;
711
- word-wrap: break-word;
712
- overflow-wrap: break-word;
713
- }
714
-
715
- .diff-line-context {
716
- color: ${COLORS.textDim};
717
- white-space: pre-wrap;
718
- word-wrap: break-word;
719
- overflow-wrap: break-word;
720
- }
721
-
722
- /* Error text */
723
- .error-text {
724
- color: ${COLORS.red};
725
- padding: 12px 16px;
726
- }
727
-
728
- .footer {
729
- margin-top: 48px;
730
- padding: 20px;
731
- text-align: center;
732
- color: ${COLORS.textDim};
733
- font-size: 10px;
734
- }
735
-
736
- @media print {
737
- body {
738
- background: white;
739
- color: black;
740
- }
741
- .tool-execution {
742
- border: 1px solid #ddd;
743
- }
744
- }
745
- </style>
746
- </head>
747
- <body>
748
- <div class="container">
749
- <div class="header">
750
- <h1>pi v${VERSION}</h1>
751
- <div class="header-info">
752
- <div class="info-item">
753
- <span class="info-label">Session:</span>
754
- <span class="info-value">${escapeHtml(sessionHeader?.id || "unknown")}</span>
755
- </div>
756
- <div class="info-item">
757
- <span class="info-label">Date:</span>
758
- <span class="info-value">${sessionHeader?.timestamp ? new Date(sessionHeader.timestamp).toLocaleString() : timestamp}</span>
759
- </div>
760
- <div class="info-item">
761
- <span class="info-label">Models:</span>
762
- <span class="info-value">${Array.from(modelsUsed)
763
- .map((m) => escapeHtml(m))
764
- .join(", ") || escapeHtml(sessionHeader?.model || state.model.id)}</span>
765
- </div>
766
- </div>
767
- </div>
768
-
769
- <div class="header">
770
- <h1>Messages</h1>
771
- <div class="header-info">
772
- <div class="info-item">
773
- <span class="info-label">User:</span>
774
- <span class="info-value">${userMessages}</span>
775
- </div>
776
- <div class="info-item">
777
- <span class="info-label">Assistant:</span>
778
- <span class="info-value">${assistantMessages}</span>
779
- </div>
780
- <div class="info-item">
781
- <span class="info-label">Tool Calls:</span>
782
- <span class="info-value">${toolCallsCount}</span>
783
- </div>
784
- </div>
785
- </div>
786
-
787
- <div class="header">
788
- <h1>Tokens & Cost</h1>
789
- <div class="header-info">
790
- <div class="info-item">
791
- <span class="info-label">Input:</span>
792
- <span class="info-value">${tokenStats.input.toLocaleString()} tokens</span>
793
- </div>
794
- <div class="info-item">
795
- <span class="info-label">Output:</span>
796
- <span class="info-value">${tokenStats.output.toLocaleString()} tokens</span>
797
- </div>
798
- <div class="info-item">
799
- <span class="info-label">Cache Read:</span>
800
- <span class="info-value">${tokenStats.cacheRead.toLocaleString()} tokens</span>
801
- </div>
802
- <div class="info-item">
803
- <span class="info-label">Cache Write:</span>
804
- <span class="info-value">${tokenStats.cacheWrite.toLocaleString()} tokens</span>
805
- </div>
806
- <div class="info-item">
807
- <span class="info-label">Total:</span>
808
- <span class="info-value">${(tokenStats.input + tokenStats.output + tokenStats.cacheRead + tokenStats.cacheWrite).toLocaleString()} tokens</span>
809
- </div>
810
- <div class="info-item">
811
- <span class="info-label">Input Cost:</span>
812
- <span class="info-value cost">$${costStats.input.toFixed(4)}</span>
813
- </div>
814
- <div class="info-item">
815
- <span class="info-label">Output Cost:</span>
816
- <span class="info-value cost">$${costStats.output.toFixed(4)}</span>
817
- </div>
818
- <div class="info-item">
819
- <span class="info-label">Cache Read Cost:</span>
820
- <span class="info-value cost">$${costStats.cacheRead.toFixed(4)}</span>
821
- </div>
822
- <div class="info-item">
823
- <span class="info-label">Cache Write Cost:</span>
824
- <span class="info-value cost">$${costStats.cacheWrite.toFixed(4)}</span>
825
- </div>
826
- <div class="info-item">
827
- <span class="info-label">Total Cost:</span>
828
- <span class="info-value cost"><strong>$${(costStats.input + costStats.output + costStats.cacheRead + costStats.cacheWrite).toFixed(4)}</strong></span>
829
- </div>
830
- <div class="info-item">
831
- <span class="info-label">Context Usage:</span>
832
- <span class="info-value">${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}</span>
833
- </div>
834
- </div>
835
- </div>
836
-
837
- <div class="system-prompt">
838
- <div class="system-prompt-header">System Prompt</div>
839
- <div class="system-prompt-content">${escapeHtml(sessionHeader?.systemPrompt || state.systemPrompt)}</div>
840
- </div>
841
-
842
- <div class="tools-list">
843
- <div class="tools-header">Available Tools</div>
844
- <div class="tools-content">
845
- ${state.tools
846
- .map((tool) => `<div class="tool-item"><span class="tool-item-name">${escapeHtml(tool.name)}</span> - ${escapeHtml(tool.description)}</div>`)
847
- .join("")}
848
- </div>
849
- </div>
850
-
851
- <div class="messages">
852
- ${messagesHtml}
853
- </div>
854
-
855
- <div class="footer">
856
- Generated by pi coding-agent on ${new Date().toLocaleString()}
857
- </div>
858
- </div>
859
- </body>
860
- </html>`;
861
- // Write HTML file
862
- writeFileSync(outputPath, html, "utf8");
863
- return outputPath;
864
- }
865
- /**
866
- * Parse session manager format (type: "session", "message", "model_change")
867
- */
868
- function parseSessionManagerFormat(lines) {
869
- const data = {
870
- sessionId: "unknown",
871
- timestamp: new Date().toISOString(),
872
- modelsUsed: new Set(),
873
- messages: [],
874
- toolResultsMap: new Map(),
875
- sessionEvents: [],
876
- tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
877
- costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
878
- };
879
- for (const line of lines) {
880
- try {
881
- const entry = JSON.parse(line);
882
- if (entry.type === "session") {
883
- data.sessionId = entry.id || "unknown";
884
- data.timestamp = entry.timestamp || data.timestamp;
885
- data.cwd = entry.cwd;
886
- data.systemPrompt = entry.systemPrompt;
887
- if (entry.modelId) {
888
- const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;
889
- data.modelsUsed.add(modelInfo);
890
- }
891
- }
892
- else if (entry.type === "message") {
893
- data.messages.push(entry.message);
894
- data.sessionEvents.push(entry);
895
- if (entry.message.role === "toolResult") {
896
- data.toolResultsMap.set(entry.message.toolCallId, entry.message);
897
- }
898
- if (entry.message.role === "assistant" && entry.message.usage) {
899
- const usage = entry.message.usage;
900
- data.tokenStats.input += usage.input || 0;
901
- data.tokenStats.output += usage.output || 0;
902
- data.tokenStats.cacheRead += usage.cacheRead || 0;
903
- data.tokenStats.cacheWrite += usage.cacheWrite || 0;
904
- if (usage.cost) {
905
- data.costStats.input += usage.cost.input || 0;
906
- data.costStats.output += usage.cost.output || 0;
907
- data.costStats.cacheRead += usage.cost.cacheRead || 0;
908
- data.costStats.cacheWrite += usage.cost.cacheWrite || 0;
909
- }
910
- }
911
- }
912
- else if (entry.type === "model_change") {
913
- data.sessionEvents.push(entry);
914
- if (entry.modelId) {
915
- const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId;
916
- data.modelsUsed.add(modelInfo);
917
- }
918
- }
919
- }
920
- catch {
921
- // Skip malformed lines
922
- }
923
- }
924
- return data;
925
- }
926
- /**
927
- * Parse streaming event format (type: "agent_start", "message_start", "message_end", etc.)
928
- */
929
- function parseStreamingEventFormat(lines) {
930
- const data = {
931
- sessionId: "unknown",
932
- timestamp: new Date().toISOString(),
933
- modelsUsed: new Set(),
934
- messages: [],
935
- toolResultsMap: new Map(),
936
- sessionEvents: [],
937
- tokenStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
938
- costStats: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
939
- isStreamingFormat: true,
940
- };
941
- let timestampSet = false;
942
- // Track messages by collecting message_end events (which have the final state)
943
- for (const line of lines) {
944
- try {
945
- const entry = JSON.parse(line);
946
- if (entry.type === "message_end" && entry.message) {
947
- const msg = entry.message;
948
- data.messages.push(msg);
949
- data.sessionEvents.push({ type: "message", message: msg, timestamp: msg.timestamp });
950
- // Build tool results map
951
- if (msg.role === "toolResult") {
952
- data.toolResultsMap.set(msg.toolCallId, msg);
953
- }
954
- // Track models and accumulate stats from assistant messages
955
- if (msg.role === "assistant") {
956
- if (msg.model) {
957
- const modelInfo = msg.provider ? `${msg.provider}/${msg.model}` : msg.model;
958
- data.modelsUsed.add(modelInfo);
959
- }
960
- if (msg.usage) {
961
- data.tokenStats.input += msg.usage.input || 0;
962
- data.tokenStats.output += msg.usage.output || 0;
963
- data.tokenStats.cacheRead += msg.usage.cacheRead || 0;
964
- data.tokenStats.cacheWrite += msg.usage.cacheWrite || 0;
965
- if (msg.usage.cost) {
966
- data.costStats.input += msg.usage.cost.input || 0;
967
- data.costStats.output += msg.usage.cost.output || 0;
968
- data.costStats.cacheRead += msg.usage.cost.cacheRead || 0;
969
- data.costStats.cacheWrite += msg.usage.cost.cacheWrite || 0;
970
- }
971
- }
972
- }
973
- // Use first message timestamp as session timestamp
974
- if (!timestampSet && msg.timestamp) {
975
- data.timestamp = new Date(msg.timestamp).toISOString();
976
- timestampSet = true;
977
- }
978
- }
979
- }
980
- catch {
981
- // Skip malformed lines
982
- }
983
- }
984
- // Generate a session ID from the timestamp
985
- data.sessionId = `stream-${data.timestamp.replace(/[:.]/g, "-")}`;
986
- return data;
987
- }
988
- /**
989
- * Detect the format of a session file by examining the first valid JSON line
990
- */
991
- function detectFormat(lines) {
992
- for (const line of lines) {
993
- try {
994
- const entry = JSON.parse(line);
995
- if (entry.type === "session")
996
- return "session-manager";
997
- if (entry.type === "agent_start" || entry.type === "message_start" || entry.type === "turn_start") {
998
- return "streaming-events";
999
- }
1000
- }
1001
- catch {
1002
- // Skip malformed lines
1003
- }
1004
- }
1005
- return "unknown";
1006
- }
1007
- /**
1008
- * Generate HTML from parsed session data
1009
- */
1010
- function generateHtml(data, inputFilename) {
1011
- // Calculate message stats
1012
- const userMessages = data.messages.filter((m) => m.role === "user").length;
1013
- const assistantMessages = data.messages.filter((m) => m.role === "assistant").length;
1014
- // Count tool calls from assistant messages
1015
- let toolCallsCount = 0;
1016
- for (const message of data.messages) {
1017
- if (message.role === "assistant") {
1018
- const assistantMsg = message;
1019
- toolCallsCount += assistantMsg.content.filter((c) => c.type === "toolCall").length;
1020
- }
1021
- }
1022
- // Get last assistant message for context info
1023
- const lastAssistantMessage = data.messages
1024
- .slice()
1025
- .reverse()
1026
- .find((m) => m.role === "assistant" && m.stopReason !== "aborted");
1027
- const contextTokens = lastAssistantMessage
1028
- ? lastAssistantMessage.usage.input +
1029
- lastAssistantMessage.usage.output +
1030
- lastAssistantMessage.usage.cacheRead +
1031
- lastAssistantMessage.usage.cacheWrite
1032
- : 0;
1033
- const lastModel = lastAssistantMessage?.model || "unknown";
1034
- const lastProvider = lastAssistantMessage?.provider || "";
1035
- const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel;
1036
- // Generate messages HTML
1037
- let messagesHtml = "";
1038
- for (const event of data.sessionEvents) {
1039
- if (event.type === "message" && event.message.role !== "toolResult") {
1040
- messagesHtml += formatMessage(event.message, data.toolResultsMap);
1041
- }
1042
- else if (event.type === "model_change") {
1043
- messagesHtml += formatModelChange(event);
1044
- }
1045
- }
1046
- // Tools section (only if tools info available)
1047
- const toolsHtml = data.tools
1048
- ? `
1049
- <div class="tools-list">
1050
- <div class="tools-header">Available Tools</div>
1051
- <div class="tools-content">
1052
- ${data.tools.map((tool) => `<div class="tool-item"><span class="tool-item-name">${escapeHtml(tool.name)}</span> - ${escapeHtml(tool.description)}</div>`).join("")}
1053
- </div>
1054
- </div>`
1055
- : "";
1056
- // System prompt section (only if available)
1057
- const systemPromptHtml = data.systemPrompt
1058
- ? `
1059
- <div class="system-prompt">
1060
- <div class="system-prompt-header">System Prompt</div>
1061
- <div class="system-prompt-content">${escapeHtml(data.systemPrompt)}</div>
1062
- </div>`
1063
- : "";
1064
- return `<!DOCTYPE html>
1065
- <html lang="en">
1066
- <head>
1067
- <meta charset="UTF-8">
1068
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1069
- <title>Session Export - ${escapeHtml(inputFilename)}</title>
1070
- <style>
1071
- * {
1072
- margin: 0;
1073
- padding: 0;
1074
- box-sizing: border-box;
1075
- }
1076
-
1077
- body {
1078
- font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
1079
- font-size: 12px;
1080
- line-height: 1.6;
1081
- color: ${COLORS.text};
1082
- background: ${COLORS.bodyBg};
1083
- padding: 24px;
1084
- }
1085
-
1086
- .container {
1087
- max-width: 700px;
1088
- margin: 0 auto;
1089
- }
1090
-
1091
- .header {
1092
- margin-bottom: 24px;
1093
- padding: 16px;
1094
- background: ${COLORS.containerBg};
1095
- border-radius: 4px;
1096
- }
1097
-
1098
- .header h1 {
1099
- font-size: 14px;
1100
- font-weight: bold;
1101
- margin-bottom: 12px;
1102
- color: ${COLORS.cyan};
1103
- }
1104
-
1105
- .header-info {
1106
- display: flex;
1107
- flex-direction: column;
1108
- gap: 3px;
1109
- font-size: 11px;
1110
- }
1111
-
1112
- .info-item {
1113
- color: ${COLORS.textDim};
1114
- display: flex;
1115
- align-items: baseline;
1116
- }
1117
-
1118
- .info-label {
1119
- font-weight: 600;
1120
- margin-right: 8px;
1121
- min-width: 100px;
1122
- }
1123
-
1124
- .info-value {
1125
- color: ${COLORS.text};
1126
- flex: 1;
1127
- }
1128
-
1129
- .info-value.cost {
1130
- font-family: 'SF Mono', monospace;
1131
- }
1132
-
1133
- .messages {
1134
- display: flex;
1135
- flex-direction: column;
1136
- gap: 16px;
1137
- }
1138
-
1139
- .message-timestamp {
1140
- font-size: 10px;
1141
- color: ${COLORS.textDim};
1142
- margin-bottom: 4px;
1143
- opacity: 0.8;
1144
- }
1145
-
1146
- .user-message {
1147
- background: ${COLORS.userMessageBg};
1148
- padding: 12px 16px;
1149
- border-radius: 4px;
1150
- white-space: pre-wrap;
1151
- word-wrap: break-word;
1152
- overflow-wrap: break-word;
1153
- word-break: break-word;
1154
- }
1155
-
1156
- .assistant-message {
1157
- padding: 0;
1158
- }
1159
-
1160
- .assistant-text {
1161
- padding: 12px 16px;
1162
- white-space: pre-wrap;
1163
- word-wrap: break-word;
1164
- overflow-wrap: break-word;
1165
- word-break: break-word;
1166
- }
1167
-
1168
- .thinking-text {
1169
- padding: 12px 16px;
1170
- color: ${COLORS.italic};
1171
- font-style: italic;
1172
- white-space: pre-wrap;
1173
- word-wrap: break-word;
1174
- overflow-wrap: break-word;
1175
- word-break: break-word;
1176
- }
1177
-
1178
- .model-change {
1179
- padding: 8px 16px;
1180
- background: rgb(40, 40, 50);
1181
- border-radius: 4px;
1182
- }
1183
-
1184
- .model-change-text {
1185
- color: ${COLORS.textDim};
1186
- font-size: 11px;
1187
- }
1188
-
1189
- .model-name {
1190
- color: ${COLORS.cyan};
1191
- font-weight: bold;
1192
- }
1193
-
1194
- .tool-execution {
1195
- padding: 12px 16px;
1196
- border-radius: 4px;
1197
- margin-top: 8px;
1198
- }
1199
-
1200
- .tool-header {
1201
- font-weight: bold;
1202
- }
1203
-
1204
- .tool-name {
1205
- font-weight: bold;
1206
- }
1207
-
1208
- .tool-path {
1209
- color: ${COLORS.cyan};
1210
- word-break: break-all;
1211
- }
1212
-
1213
- .line-count {
1214
- color: ${COLORS.textDim};
1215
- }
1216
-
1217
- .tool-command {
1218
- font-weight: bold;
1219
- white-space: pre-wrap;
1220
- word-wrap: break-word;
1221
- overflow-wrap: break-word;
1222
- word-break: break-word;
1223
- }
1224
-
1225
- .tool-output {
1226
- margin-top: 12px;
1227
- color: ${COLORS.textDim};
1228
- white-space: pre-wrap;
1229
- word-wrap: break-word;
1230
- overflow-wrap: break-word;
1231
- word-break: break-word;
1232
- font-family: inherit;
1233
- overflow-x: auto;
1234
- }
1235
-
1236
- .tool-output > div {
1237
- line-height: 1.4;
1238
- }
1239
-
1240
- .tool-output pre {
1241
- margin: 0;
1242
568
  font-family: inherit;
1243
- color: inherit;
1244
- white-space: pre-wrap;
1245
- word-wrap: break-word;
1246
- overflow-wrap: break-word;
1247
- }
1248
-
1249
- .tool-output.expandable {
1250
- cursor: pointer;
1251
- }
1252
-
1253
- .tool-output.expandable:hover {
1254
- opacity: 0.9;
1255
- }
1256
-
1257
- .tool-output.expandable .output-full {
1258
- display: none;
1259
- }
1260
-
1261
- .tool-output.expandable.expanded .output-preview {
1262
- display: none;
1263
- }
1264
-
1265
- .tool-output.expandable.expanded .output-full {
1266
- display: block;
1267
- }
1268
-
1269
- .expand-hint {
1270
- color: ${COLORS.cyan};
1271
- font-style: italic;
1272
- margin-top: 4px;
1273
- }
1274
-
1275
- .system-prompt {
1276
- background: rgb(60, 55, 40);
1277
- padding: 12px 16px;
1278
- border-radius: 4px;
1279
- margin-bottom: 16px;
1280
- }
1281
-
1282
- .system-prompt-header {
1283
- font-weight: bold;
1284
- color: ${COLORS.yellow};
1285
- margin-bottom: 8px;
1286
- }
1287
-
1288
- .system-prompt-content {
1289
- color: ${COLORS.textDim};
1290
- white-space: pre-wrap;
1291
- word-wrap: break-word;
1292
- overflow-wrap: break-word;
1293
- word-break: break-word;
1294
- font-size: 11px;
1295
- }
1296
-
1297
- .tools-list {
1298
- background: rgb(60, 55, 40);
1299
- padding: 12px 16px;
1300
- border-radius: 4px;
1301
- margin-bottom: 16px;
1302
- }
1303
-
1304
- .tools-header {
1305
- font-weight: bold;
1306
- color: ${COLORS.yellow};
1307
- margin-bottom: 8px;
1308
- }
1309
-
1310
- .tools-content {
1311
- color: ${COLORS.textDim};
1312
- font-size: 11px;
1313
- }
1314
-
1315
- .tool-item {
1316
- margin: 4px 0;
1317
- }
1318
-
1319
- .tool-item-name {
1320
- font-weight: bold;
1321
- color: ${COLORS.text};
1322
- }
1323
-
1324
- .tool-diff {
1325
- margin-top: 12px;
1326
- font-size: 11px;
1327
- font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
1328
569
  overflow-x: auto;
1329
- max-width: 100%;
1330
- }
1331
-
1332
- .diff-line-old {
1333
- color: ${COLORS.red};
1334
- white-space: pre-wrap;
1335
- word-wrap: break-word;
1336
- overflow-wrap: break-word;
1337
- }
1338
-
1339
- .diff-line-new {
1340
- color: ${COLORS.green};
1341
- white-space: pre-wrap;
1342
- word-wrap: break-word;
1343
- overflow-wrap: break-word;
1344
- }
1345
-
1346
- .diff-line-context {
1347
- color: ${COLORS.textDim};
1348
- white-space: pre-wrap;
1349
- word-wrap: break-word;
1350
- overflow-wrap: break-word;
1351
- }
1352
-
1353
- .error-text {
1354
- color: ${COLORS.red};
1355
- padding: 12px 16px;
1356
- }
1357
-
1358
- .footer {
1359
- margin-top: 48px;
1360
- padding: 20px;
1361
- text-align: center;
1362
- color: ${COLORS.textDim};
1363
- font-size: 10px;
1364
- }
1365
-
1366
- .streaming-notice {
1367
- background: rgb(50, 45, 35);
1368
- padding: 12px 16px;
1369
- border-radius: 4px;
1370
- margin-bottom: 16px;
1371
- color: ${COLORS.textDim};
1372
- font-size: 11px;
1373
- }
1374
-
1375
- @media print {
1376
- body {
1377
- background: white;
1378
- color: black;
1379
- }
1380
- .tool-execution {
1381
- border: 1px solid #ddd;
1382
- }
1383
570
  }
571
+ .tool-output > div { line-height: 1.4; }
572
+ .tool-output pre { margin: 0; font-family: inherit; color: inherit; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }
573
+ .tool-output.expandable { cursor: pointer; }
574
+ .tool-output.expandable:hover { opacity: 0.9; }
575
+ .tool-output.expandable .output-full { display: none; }
576
+ .tool-output.expandable.expanded .output-preview { display: none; }
577
+ .tool-output.expandable.expanded .output-full { display: block; }
578
+ .expand-hint { color: ${COLORS.cyan}; font-style: italic; margin-top: 4px; }
579
+ .system-prompt, .tools-list { background: rgb(60, 55, 40); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; }
580
+ .system-prompt-header, .tools-header { font-weight: bold; color: ${COLORS.yellow}; margin-bottom: 8px; }
581
+ .system-prompt-content, .tools-content { color: ${COLORS.textDim}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-size: 11px; }
582
+ .tool-item { margin: 4px 0; }
583
+ .tool-item-name { font-weight: bold; color: ${COLORS.text}; }
584
+ .tool-diff { margin-top: 12px; font-size: 11px; font-family: inherit; overflow-x: auto; max-width: 100%; }
585
+ .diff-line-old { color: ${COLORS.red}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }
586
+ .diff-line-new { color: ${COLORS.green}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }
587
+ .diff-line-context { color: ${COLORS.textDim}; white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; }
588
+ .error-text { color: ${COLORS.red}; padding: 12px 16px; }
589
+ .footer { margin-top: 48px; padding: 20px; text-align: center; color: ${COLORS.textDim}; font-size: 10px; }
590
+ .streaming-notice { background: rgb(50, 45, 35); padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; color: ${COLORS.textDim}; font-size: 11px; }
591
+ @media print { body { background: white; color: black; } .tool-execution { border: 1px solid #ddd; } }
1384
592
  </style>
1385
593
  </head>
1386
594
  <body>
1387
595
  <div class="container">
1388
596
  <div class="header">
1389
- <h1>pi v${VERSION}</h1>
597
+ <h1>${APP_NAME} v${VERSION}</h1>
1390
598
  <div class="header-info">
1391
- <div class="info-item">
1392
- <span class="info-label">Session:</span>
1393
- <span class="info-value">${escapeHtml(data.sessionId)}</span>
1394
- </div>
1395
- <div class="info-item">
1396
- <span class="info-label">Date:</span>
1397
- <span class="info-value">${new Date(data.timestamp).toLocaleString()}</span>
1398
- </div>
1399
- <div class="info-item">
1400
- <span class="info-label">Models:</span>
1401
- <span class="info-value">${Array.from(data.modelsUsed)
599
+ <div class="info-item"><span class="info-label">Session:</span><span class="info-value">${escapeHtml(data.sessionId)}</span></div>
600
+ <div class="info-item"><span class="info-label">Date:</span><span class="info-value">${new Date(data.timestamp).toLocaleString()}</span></div>
601
+ <div class="info-item"><span class="info-label">Models:</span><span class="info-value">${Array.from(data.modelsUsed)
1402
602
  .map((m) => escapeHtml(m))
1403
- .join(", ") || "unknown"}</span>
1404
- </div>
603
+ .join(", ") || "unknown"}</span></div>
1405
604
  </div>
1406
605
  </div>
1407
606
 
1408
607
  <div class="header">
1409
608
  <h1>Messages</h1>
1410
609
  <div class="header-info">
1411
- <div class="info-item">
1412
- <span class="info-label">User:</span>
1413
- <span class="info-value">${userMessages}</span>
1414
- </div>
1415
- <div class="info-item">
1416
- <span class="info-label">Assistant:</span>
1417
- <span class="info-value">${assistantMessages}</span>
1418
- </div>
1419
- <div class="info-item">
1420
- <span class="info-label">Tool Calls:</span>
1421
- <span class="info-value">${toolCallsCount}</span>
1422
- </div>
610
+ <div class="info-item"><span class="info-label">User:</span><span class="info-value">${userMessages}</span></div>
611
+ <div class="info-item"><span class="info-label">Assistant:</span><span class="info-value">${assistantMessages}</span></div>
612
+ <div class="info-item"><span class="info-label">Tool Calls:</span><span class="info-value">${toolCallsCount}</span></div>
1423
613
  </div>
1424
614
  </div>
1425
615
 
1426
616
  <div class="header">
1427
617
  <h1>Tokens & Cost</h1>
1428
618
  <div class="header-info">
1429
- <div class="info-item">
1430
- <span class="info-label">Input:</span>
1431
- <span class="info-value">${data.tokenStats.input.toLocaleString()} tokens</span>
1432
- </div>
1433
- <div class="info-item">
1434
- <span class="info-label">Output:</span>
1435
- <span class="info-value">${data.tokenStats.output.toLocaleString()} tokens</span>
1436
- </div>
1437
- <div class="info-item">
1438
- <span class="info-label">Cache Read:</span>
1439
- <span class="info-value">${data.tokenStats.cacheRead.toLocaleString()} tokens</span>
1440
- </div>
1441
- <div class="info-item">
1442
- <span class="info-label">Cache Write:</span>
1443
- <span class="info-value">${data.tokenStats.cacheWrite.toLocaleString()} tokens</span>
1444
- </div>
1445
- <div class="info-item">
1446
- <span class="info-label">Total:</span>
1447
- <span class="info-value">${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens</span>
1448
- </div>
1449
- <div class="info-item">
1450
- <span class="info-label">Input Cost:</span>
1451
- <span class="info-value cost">$${data.costStats.input.toFixed(4)}</span>
1452
- </div>
1453
- <div class="info-item">
1454
- <span class="info-label">Output Cost:</span>
1455
- <span class="info-value cost">$${data.costStats.output.toFixed(4)}</span>
1456
- </div>
1457
- <div class="info-item">
1458
- <span class="info-label">Cache Read Cost:</span>
1459
- <span class="info-value cost">$${data.costStats.cacheRead.toFixed(4)}</span>
1460
- </div>
1461
- <div class="info-item">
1462
- <span class="info-label">Cache Write Cost:</span>
1463
- <span class="info-value cost">$${data.costStats.cacheWrite.toFixed(4)}</span>
1464
- </div>
1465
- <div class="info-item">
1466
- <span class="info-label">Total Cost:</span>
1467
- <span class="info-value cost"><strong>$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}</strong></span>
1468
- </div>
1469
- <div class="info-item">
1470
- <span class="info-label">Context Usage:</span>
1471
- <span class="info-value">${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}</span>
1472
- </div>
619
+ <div class="info-item"><span class="info-label">Input:</span><span class="info-value">${data.tokenStats.input.toLocaleString()} tokens</span></div>
620
+ <div class="info-item"><span class="info-label">Output:</span><span class="info-value">${data.tokenStats.output.toLocaleString()} tokens</span></div>
621
+ <div class="info-item"><span class="info-label">Cache Read:</span><span class="info-value">${data.tokenStats.cacheRead.toLocaleString()} tokens</span></div>
622
+ <div class="info-item"><span class="info-label">Cache Write:</span><span class="info-value">${data.tokenStats.cacheWrite.toLocaleString()} tokens</span></div>
623
+ <div class="info-item"><span class="info-label">Total:</span><span class="info-value">${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens</span></div>
624
+ <div class="info-item"><span class="info-label">Input Cost:</span><span class="info-value cost">$${data.costStats.input.toFixed(4)}</span></div>
625
+ <div class="info-item"><span class="info-label">Output Cost:</span><span class="info-value cost">$${data.costStats.output.toFixed(4)}</span></div>
626
+ <div class="info-item"><span class="info-label">Cache Read Cost:</span><span class="info-value cost">$${data.costStats.cacheRead.toFixed(4)}</span></div>
627
+ <div class="info-item"><span class="info-label">Cache Write Cost:</span><span class="info-value cost">$${data.costStats.cacheWrite.toFixed(4)}</span></div>
628
+ <div class="info-item"><span class="info-label">Total Cost:</span><span class="info-value cost"><strong>$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}</strong></span></div>
629
+ <div class="info-item"><span class="info-label">Context Usage:</span><span class="info-value">${contextUsageText}</span></div>
1473
630
  </div>
1474
631
  </div>
1475
632
 
1476
633
  ${systemPromptHtml}
1477
634
  ${toolsHtml}
1478
-
1479
- ${data.isStreamingFormat
1480
- ? `<div class="streaming-notice">
1481
- <em>Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.</em>
1482
- </div>`
1483
- : ""}
635
+ ${streamingNotice}
1484
636
 
1485
637
  <div class="messages">
1486
638
  ${messagesHtml}
1487
639
  </div>
1488
640
 
1489
641
  <div class="footer">
1490
- Generated by pi coding-agent on ${new Date().toLocaleString()}
642
+ Generated by ${APP_NAME} coding-agent on ${new Date().toLocaleString()}
1491
643
  </div>
1492
644
  </div>
1493
645
  </body>
1494
646
  </html>`;
1495
647
  }
648
+ // ============================================================================
649
+ // Public API
650
+ // ============================================================================
651
+ /**
652
+ * Export session to HTML using SessionManager and AgentState.
653
+ * Used by TUI's /export command.
654
+ */
655
+ export function exportSessionToHtml(sessionManager, state, outputPath) {
656
+ const sessionFile = sessionManager.getSessionFile();
657
+ const content = readFileSync(sessionFile, "utf8");
658
+ const data = parseSessionFile(content);
659
+ // Enrich with data from AgentState (tools, context window)
660
+ data.tools = state.tools.map((t) => ({ name: t.name, description: t.description }));
661
+ data.contextWindow = state.model?.contextWindow;
662
+ if (!data.systemPrompt) {
663
+ data.systemPrompt = state.systemPrompt;
664
+ }
665
+ if (!outputPath) {
666
+ const sessionBasename = basename(sessionFile, ".jsonl");
667
+ outputPath = `${APP_NAME}-session-${sessionBasename}.html`;
668
+ }
669
+ const html = generateHtml(data, basename(sessionFile));
670
+ writeFileSync(outputPath, html, "utf8");
671
+ return outputPath;
672
+ }
1496
673
  /**
1497
- * Export a session file to HTML (standalone, without AgentState or SessionManager)
1498
- * Auto-detects format: session manager format or streaming event format
674
+ * Export session file to HTML (standalone, without AgentState).
675
+ * Auto-detects format: session manager format or streaming event format.
676
+ * Used by CLI for exporting arbitrary session files.
1499
677
  */
1500
678
  export function exportFromFile(inputPath, outputPath) {
1501
679
  if (!existsSync(inputPath)) {
1502
680
  throw new Error(`File not found: ${inputPath}`);
1503
681
  }
1504
682
  const content = readFileSync(inputPath, "utf8");
1505
- const lines = content
1506
- .trim()
1507
- .split("\n")
1508
- .filter((l) => l.trim());
1509
- if (lines.length === 0) {
1510
- throw new Error(`Empty file: ${inputPath}`);
1511
- }
1512
- const format = detectFormat(lines);
1513
- if (format === "unknown") {
1514
- throw new Error(`Unknown session file format: ${inputPath}`);
1515
- }
1516
- const data = format === "session-manager" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines);
1517
- // Generate output path if not provided
683
+ const data = parseSessionFile(content);
1518
684
  if (!outputPath) {
1519
685
  const inputBasename = basename(inputPath, ".jsonl");
1520
- outputPath = `pi-session-${inputBasename}.html`;
686
+ outputPath = `${APP_NAME}-session-${inputBasename}.html`;
1521
687
  }
1522
688
  const html = generateHtml(data, basename(inputPath));
1523
689
  writeFileSync(outputPath, html, "utf8");