From c89b1ec3c2e711625cfb9100e74b804400345be7 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 4 Dec 2025 02:39:54 +0100 Subject: [PATCH] feat(coding-agent): context compaction with /compact, /autocompact, and auto-trigger - Add /compact command for manual context compaction with optional custom instructions - Add /autocompact command to toggle automatic compaction - Auto-trigger compaction when context usage exceeds threshold (contextWindow - reserveTokens) - Add CompactionComponent for TUI display with collapsed/expanded states - Add compaction events to HTML export with collapsible summary - Refactor export-html.ts to eliminate duplication between session and streaming formats - Use setTimeout to break out of agent event handler for safe async compaction - Show compaction summary in TUI after compaction completes fixes #92 --- packages/coding-agent/src/compaction.ts | 32 +- packages/coding-agent/src/export-html.ts | 1929 +++++------------ packages/coding-agent/src/session-manager.ts | 20 +- packages/coding-agent/src/tui/compaction.ts | 54 + packages/coding-agent/src/tui/tui-renderer.ts | 239 +- packages/tui/src/tui.ts | 2 +- 6 files changed, 803 insertions(+), 1473 deletions(-) create mode 100644 packages/coding-agent/src/tui/compaction.ts diff --git a/packages/coding-agent/src/compaction.ts b/packages/coding-agent/src/compaction.ts index eed31169..3756718d 100644 --- a/packages/coding-agent/src/compaction.ts +++ b/packages/coding-agent/src/compaction.ts @@ -8,7 +8,7 @@ import type { AppMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; -import { type CompactionEntry, loadSessionFromEntries, type SessionEntry } from "./session-manager.js"; +import type { CompactionEntry, SessionEntry } from "./session-manager.js"; // ============================================================================ // Types @@ -225,8 +225,10 @@ export async function compact( signal?: AbortSignal, customInstructions?: string, ): Promise { - // Reconstruct current messages from entries - const { messages: currentMessages } = loadSessionFromEntries(entries); + // Don't compact if the last entry is already a compaction + if (entries.length > 0 && entries[entries.length - 1].type === "compaction") { + throw new Error("Already compacted"); + } // Find previous compaction boundary let prevCompactionIndex = -1; @@ -246,9 +248,29 @@ export async function compact( // Find cut point (entry index) within the valid range const firstKeptEntryIndex = findCutPoint(entries, boundaryStart, boundaryEnd, settings.keepRecentTokens); - // Generate summary from the full current context + // Extract messages to summarize (before the cut point) + const messagesToSummarize: AppMessage[] = []; + for (let i = boundaryStart; i < firstKeptEntryIndex; i++) { + const entry = entries[i]; + if (entry.type === "message") { + messagesToSummarize.push(entry.message); + } + } + + // Also include the previous summary if there was a compaction + if (prevCompactionIndex >= 0) { + const prevCompaction = entries[prevCompactionIndex] as CompactionEntry; + // Prepend the previous summary as context + messagesToSummarize.unshift({ + role: "user", + content: `Previous session summary:\n${prevCompaction.summary}`, + timestamp: Date.now(), + }); + } + + // Generate summary from messages before the cut point const summary = await generateSummary( - currentMessages, + messagesToSummarize, model, settings.reserveTokens, apiKey, diff --git a/packages/coding-agent/src/export-html.ts b/packages/coding-agent/src/export-html.ts index e256df77..632e4732 100644 --- a/packages/coding-agent/src/export-html.ts +++ b/packages/coding-agent/src/export-html.ts @@ -6,31 +6,70 @@ import { basename } from "path"; import { APP_NAME, VERSION } from "./config.js"; import type { SessionManager } from "./session-manager.js"; -/** - * TUI Color scheme (matching exact RGB values from TUI components) - */ -const COLORS = { - // Backgrounds - userMessageBg: "rgb(52, 53, 65)", // Dark slate - toolPendingBg: "rgb(40, 40, 50)", // Dark blue-gray - toolSuccessBg: "rgb(40, 50, 40)", // Dark green - toolErrorBg: "rgb(60, 40, 40)", // Dark red - bodyBg: "rgb(24, 24, 30)", // Very dark background - containerBg: "rgb(30, 30, 36)", // Slightly lighter container +// ============================================================================ +// Types +// ============================================================================ - // Text colors (matching chalk colors) - text: "rgb(229, 229, 231)", // Light gray (close to white) - textDim: "rgb(161, 161, 170)", // Dimmed gray - cyan: "rgb(103, 232, 249)", // Cyan for paths - green: "rgb(34, 197, 94)", // Green for success - red: "rgb(239, 68, 68)", // Red for errors - yellow: "rgb(234, 179, 8)", // Yellow for warnings - italic: "rgb(161, 161, 170)", // Gray italic for thinking +interface MessageEvent { + type: "message"; + message: Message; + timestamp?: number; +} + +interface ModelChangeEvent { + type: "model_change"; + provider: string; + modelId: string; + timestamp?: number; +} + +interface CompactionEvent { + type: "compaction"; + timestamp: string; + summary: string; + tokensBefore: number; +} + +type SessionEvent = MessageEvent | ModelChangeEvent | CompactionEvent; + +interface ParsedSessionData { + sessionId: string; + timestamp: string; + systemPrompt?: string; + modelsUsed: Set; + messages: Message[]; + toolResultsMap: Map; + sessionEvents: SessionEvent[]; + tokenStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; + costStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; + tools?: { name: string; description: string }[]; + contextWindow?: number; + isStreamingFormat?: boolean; +} + +// ============================================================================ +// Color scheme (matching TUI) +// ============================================================================ + +const COLORS = { + userMessageBg: "rgb(52, 53, 65)", + toolPendingBg: "rgb(40, 40, 50)", + toolSuccessBg: "rgb(40, 50, 40)", + toolErrorBg: "rgb(60, 40, 40)", + bodyBg: "rgb(24, 24, 30)", + containerBg: "rgb(30, 30, 36)", + text: "rgb(229, 229, 231)", + textDim: "rgb(161, 161, 170)", + cyan: "rgb(103, 232, 249)", + green: "rgb(34, 197, 94)", + red: "rgb(239, 68, 68)", + yellow: "rgb(234, 179, 8)", }; -/** - * Escape HTML special characters - */ +// ============================================================================ +// Utility functions +// ============================================================================ + function escapeHtml(text: string): string { return text .replace(/&/g, "&") @@ -40,893 +79,25 @@ function escapeHtml(text: string): string { .replace(/'/g, "'"); } -/** - * Shorten path with tilde notation - */ function shortenPath(path: string): string { const home = homedir(); - if (path.startsWith(home)) { - return "~" + path.slice(home.length); - } - return path; + return path.startsWith(home) ? "~" + path.slice(home.length) : path; } -/** - * Replace tabs with 3 spaces - */ function replaceTabs(text: string): string { return text.replace(/\t/g, " "); } -/** - * Format tool execution matching TUI ToolExecutionComponent - */ -function formatToolExecution( - toolName: string, - args: any, - result?: ToolResultMessage, -): { html: string; bgColor: string } { - let html = ""; - const isError = result?.isError || false; - const bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg; - - // Get text output from result - const getTextOutput = (): string => { - if (!result) return ""; - const textBlocks = result.content.filter((c) => c.type === "text"); - return textBlocks.map((c: any) => c.text).join("\n"); - }; - - // Format based on tool type (matching TUI logic exactly) - if (toolName === "bash") { - const command = args?.command || ""; - html = `
$ ${escapeHtml(command || "...")}
`; - - if (result) { - const output = getTextOutput().trim(); - if (output) { - const lines = output.split("\n"); - const maxLines = 5; - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - - if (remaining > 0) { - // Truncated output - make it expandable - html += '"; - } else { - // Short output - show all - html += '
'; - for (const line of displayLines) { - html += `
${escapeHtml(line)}
`; - } - html += "
"; - } - } - } - } else if (toolName === "read") { - const path = shortenPath(args?.file_path || args?.path || ""); - html = `
read ${escapeHtml(path || "...")}
`; - - if (result) { - const output = getTextOutput(); - const lines = output.split("\n"); - const maxLines = 10; - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - - if (remaining > 0) { - // Truncated output - make it expandable - html += '"; - } else { - // Short output - show all - html += '
'; - for (const line of displayLines) { - html += `
${escapeHtml(replaceTabs(line))}
`; - } - html += "
"; - } - } - } else if (toolName === "write") { - const path = shortenPath(args?.file_path || args?.path || ""); - const fileContent = args?.content || ""; - const lines = fileContent ? fileContent.split("\n") : []; - const totalLines = lines.length; - - html = `
write ${escapeHtml(path || "...")}`; - if (totalLines > 10) { - html += ` (${totalLines} lines)`; - } - html += "
"; - - if (fileContent) { - const maxLines = 10; - const displayLines = lines.slice(0, maxLines); - const remaining = lines.length - maxLines; - - if (remaining > 0) { - // Truncated output - make it expandable - html += '"; - } else { - // Short output - show all - html += '
'; - for (const line of displayLines) { - html += `
${escapeHtml(replaceTabs(line))}
`; - } - html += "
"; - } - } - - if (result) { - const output = getTextOutput().trim(); - if (output) { - html += `
${escapeHtml(output)}
`; - } - } - } else if (toolName === "edit") { - const path = shortenPath(args?.file_path || args?.path || ""); - html = `
edit ${escapeHtml(path || "...")}
`; - - // Show diff if available from result.details.diff - if (result?.details?.diff) { - const diffLines = result.details.diff.split("\n"); - html += '
'; - for (const line of diffLines) { - if (line.startsWith("+")) { - html += `
${escapeHtml(line)}
`; - } else if (line.startsWith("-")) { - html += `
${escapeHtml(line)}
`; - } else { - html += `
${escapeHtml(line)}
`; - } - } - html += "
"; - } - - if (result) { - const output = getTextOutput().trim(); - if (output) { - html += `
${escapeHtml(output)}
`; - } - } - } else { - // Generic tool - html = `
${escapeHtml(toolName)}
`; - html += `
${escapeHtml(JSON.stringify(args, null, 2))}
`; - - if (result) { - const output = getTextOutput(); - if (output) { - html += `
${escapeHtml(output)}
`; - } - } - } - - return { html, bgColor }; -} - -/** - * Format timestamp for display - */ function formatTimestamp(timestamp: number | string | undefined): string { if (!timestamp) return ""; const date = new Date(typeof timestamp === "string" ? timestamp : timestamp); return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } -/** - * Format model change event - */ -function formatModelChange(event: any): string { - const timestamp = formatTimestamp(event.timestamp); - const timestampHtml = timestamp ? `
${timestamp}
` : ""; - const modelInfo = `${event.provider}/${event.modelId}`; - return `
${timestampHtml}
Switched to model: ${escapeHtml(modelInfo)}
`; -} +// ============================================================================ +// Parsing functions +// ============================================================================ -/** - * Format a message as HTML (matching TUI component styling) - */ -function formatMessage(message: Message, toolResultsMap: Map): string { - let html = ""; - const timestamp = (message as any).timestamp; - const timestampHtml = timestamp ? `
${formatTimestamp(timestamp)}
` : ""; - - if (message.role === "user") { - const userMsg = message as UserMessage; - let textContent = ""; - - if (typeof userMsg.content === "string") { - textContent = userMsg.content; - } else { - const textBlocks = userMsg.content.filter((c) => c.type === "text"); - textContent = textBlocks.map((c: any) => c.text).join(""); - } - - if (textContent.trim()) { - html += `
${timestampHtml}${escapeHtml(textContent).replace(/\n/g, "
")}
`; - } - } else if (message.role === "assistant") { - const assistantMsg = message as AssistantMessage; - html += timestampHtml ? `
${timestampHtml}` : ""; - - // Render text and thinking content - for (const content of assistantMsg.content) { - if (content.type === "text" && content.text.trim()) { - html += `
${escapeHtml(content.text.trim()).replace(/\n/g, "
")}
`; - } else if (content.type === "thinking" && content.thinking.trim()) { - html += `
${escapeHtml(content.thinking.trim()).replace(/\n/g, "
")}
`; - } - } - - // Render tool calls with their results - for (const content of assistantMsg.content) { - if (content.type === "toolCall") { - const toolResult = toolResultsMap.get(content.id); - const { html: toolHtml, bgColor } = formatToolExecution(content.name, content.arguments, toolResult); - html += `
${toolHtml}
`; - } - } - - // Show error/abort status if no tool calls - const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall"); - if (!hasToolCalls) { - if (assistantMsg.stopReason === "aborted") { - html += '
Aborted
'; - } else if (assistantMsg.stopReason === "error") { - const errorMsg = assistantMsg.errorMessage || "Unknown error"; - html += `
Error: ${escapeHtml(errorMsg)}
`; - } - } - - // Close the assistant message wrapper if we opened one - if (timestampHtml) { - html += "
"; - } - } - - return html; -} - -/** - * Export session to a self-contained HTML file matching TUI visual style - */ -export function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string { - const sessionFile = sessionManager.getSessionFile(); - const timestamp = new Date().toISOString(); - - // Use pi-session- prefix + session filename + .html if no output path provided - if (!outputPath) { - const sessionBasename = basename(sessionFile, ".jsonl"); - outputPath = `${APP_NAME}-session-${sessionBasename}.html`; - } - - // Read and parse session data - const sessionContent = readFileSync(sessionFile, "utf8"); - const lines = sessionContent.trim().split("\n"); - - let sessionHeader: any = null; - const messages: Message[] = []; - const toolResultsMap = new Map(); - const sessionEvents: any[] = []; // Track all events including model changes - const modelsUsed = new Set(); // Track unique models used - - // Cumulative token and cost stats - const tokenStats = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }; - const costStats = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }; - - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (entry.type === "session") { - sessionHeader = entry; - // Track initial model from session header - if (entry.modelId) { - const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId; - modelsUsed.add(modelInfo); - } - } else if (entry.type === "message") { - messages.push(entry.message); - sessionEvents.push(entry); - // Build map of tool call ID to result - if (entry.message.role === "toolResult") { - toolResultsMap.set(entry.message.toolCallId, entry.message); - } - // Accumulate token and cost stats from assistant messages - if (entry.message.role === "assistant" && entry.message.usage) { - const usage = entry.message.usage; - tokenStats.input += usage.input || 0; - tokenStats.output += usage.output || 0; - tokenStats.cacheRead += usage.cacheRead || 0; - tokenStats.cacheWrite += usage.cacheWrite || 0; - - if (usage.cost) { - costStats.input += usage.cost.input || 0; - costStats.output += usage.cost.output || 0; - costStats.cacheRead += usage.cost.cacheRead || 0; - costStats.cacheWrite += usage.cost.cacheWrite || 0; - } - } - } else if (entry.type === "model_change") { - sessionEvents.push(entry); - // Track model from model change event - if (entry.modelId) { - const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId; - modelsUsed.add(modelInfo); - } - } - } catch { - // Skip malformed lines - } - } - - // Calculate message stats (matching session command) - const userMessages = messages.filter((m) => m.role === "user").length; - const assistantMessages = messages.filter((m) => m.role === "assistant").length; - const toolResultMessages = messages.filter((m) => m.role === "toolResult").length; - const totalMessages = messages.length; - - // Count tool calls from assistant messages - let toolCallsCount = 0; - for (const message of messages) { - if (message.role === "assistant") { - const assistantMsg = message as AssistantMessage; - toolCallsCount += assistantMsg.content.filter((c) => c.type === "toolCall").length; - } - } - - // Get last assistant message for context percentage calculation (skip aborted messages) - const lastAssistantMessage = messages - .slice() - .reverse() - .find((m) => m.role === "assistant" && (m as AssistantMessage).stopReason !== "aborted") as - | AssistantMessage - | undefined; - - // Calculate context percentage from last message (input + output + cacheRead + cacheWrite) - const contextTokens = lastAssistantMessage - ? lastAssistantMessage.usage.input + - lastAssistantMessage.usage.output + - lastAssistantMessage.usage.cacheRead + - lastAssistantMessage.usage.cacheWrite - : 0; - - // Get the model info from the last assistant message - const lastModel = lastAssistantMessage?.model || state.model?.id || "unknown"; - const lastProvider = lastAssistantMessage?.provider || ""; - const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel; - - const contextWindow = state.model?.contextWindow || 0; - const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : "0.0"; - - // Generate messages HTML (including model changes in chronological order) - let messagesHtml = ""; - for (const event of sessionEvents) { - if (event.type === "message" && event.message.role !== "toolResult") { - // Skip toolResult messages as they're rendered with their tool calls - messagesHtml += formatMessage(event.message, toolResultsMap); - } else if (event.type === "model_change") { - messagesHtml += formatModelChange(event); - } - } - - // Generate HTML (matching TUI aesthetic) - const html = ` - - - - - Session Export - ${basename(sessionFile)} - - - -
-
-

${APP_NAME} v${VERSION}

-
-
- Session: - ${escapeHtml(sessionHeader?.id || "unknown")} -
-
- Date: - ${sessionHeader?.timestamp ? new Date(sessionHeader.timestamp).toLocaleString() : timestamp} -
-
- Models: - ${ - Array.from(modelsUsed) - .map((m) => escapeHtml(m)) - .join(", ") || escapeHtml(sessionHeader?.model || state.model.id) - } -
-
-
- -
-

Messages

-
-
- User: - ${userMessages} -
-
- Assistant: - ${assistantMessages} -
-
- Tool Calls: - ${toolCallsCount} -
-
-
- -
-

Tokens & Cost

-
-
- Input: - ${tokenStats.input.toLocaleString()} tokens -
-
- Output: - ${tokenStats.output.toLocaleString()} tokens -
-
- Cache Read: - ${tokenStats.cacheRead.toLocaleString()} tokens -
-
- Cache Write: - ${tokenStats.cacheWrite.toLocaleString()} tokens -
-
- Total: - ${(tokenStats.input + tokenStats.output + tokenStats.cacheRead + tokenStats.cacheWrite).toLocaleString()} tokens -
-
- Input Cost: - $${costStats.input.toFixed(4)} -
-
- Output Cost: - $${costStats.output.toFixed(4)} -
-
- Cache Read Cost: - $${costStats.cacheRead.toFixed(4)} -
-
- Cache Write Cost: - $${costStats.cacheWrite.toFixed(4)} -
-
- Total Cost: - $${(costStats.input + costStats.output + costStats.cacheRead + costStats.cacheWrite).toFixed(4)} -
-
- Context Usage: - ${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)} -
-
-
- -
-
System Prompt
-
${escapeHtml(sessionHeader?.systemPrompt || state.systemPrompt)}
-
- -
-
Available Tools
-
- ${state.tools - .map( - (tool) => - `
${escapeHtml(tool.name)} - ${escapeHtml(tool.description)}
`, - ) - .join("")} -
-
- -
- ${messagesHtml} -
- - -
- -`; - - // Write HTML file - writeFileSync(outputPath, html, "utf8"); - - return outputPath; -} - -/** - * Parsed session data structure for HTML generation - */ -interface ParsedSessionData { - sessionId: string; - timestamp: string; - cwd?: string; - systemPrompt?: string; - modelsUsed: Set; - messages: Message[]; - toolResultsMap: Map; - sessionEvents: any[]; - tokenStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; - costStats: { input: number; output: number; cacheRead: number; cacheWrite: number }; - tools?: { name: string; description: string }[]; - isStreamingFormat?: boolean; -} - -/** - * Parse session manager format (type: "session", "message", "model_change") - */ function parseSessionManagerFormat(lines: string[]): ParsedSessionData { const data: ParsedSessionData = { sessionId: "unknown", @@ -940,54 +111,81 @@ function parseSessionManagerFormat(lines: string[]): ParsedSessionData { }; for (const line of lines) { + let entry: { type: string; [key: string]: unknown }; try { - const entry = JSON.parse(line); - if (entry.type === "session") { - data.sessionId = entry.id || "unknown"; - data.timestamp = entry.timestamp || data.timestamp; - data.cwd = entry.cwd; - data.systemPrompt = entry.systemPrompt; + entry = JSON.parse(line) as { type: string; [key: string]: unknown }; + } catch { + continue; + } + + switch (entry.type) { + case "session": + data.sessionId = (entry.id as string) || "unknown"; + data.timestamp = (entry.timestamp as string) || data.timestamp; + data.systemPrompt = entry.systemPrompt as string | undefined; if (entry.modelId) { - const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId; + const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string); data.modelsUsed.add(modelInfo); } - } else if (entry.type === "message") { - data.messages.push(entry.message); - data.sessionEvents.push(entry); - if (entry.message.role === "toolResult") { - data.toolResultsMap.set(entry.message.toolCallId, entry.message); - } - if (entry.message.role === "assistant" && entry.message.usage) { - const usage = entry.message.usage; - data.tokenStats.input += usage.input || 0; - data.tokenStats.output += usage.output || 0; - data.tokenStats.cacheRead += usage.cacheRead || 0; - data.tokenStats.cacheWrite += usage.cacheWrite || 0; - if (usage.cost) { - data.costStats.input += usage.cost.input || 0; - data.costStats.output += usage.cost.output || 0; - data.costStats.cacheRead += usage.cost.cacheRead || 0; - data.costStats.cacheWrite += usage.cost.cacheWrite || 0; + break; + + case "message": { + const message = entry.message as Message; + data.messages.push(message); + data.sessionEvents.push({ + type: "message", + message, + timestamp: entry.timestamp as number | undefined, + }); + + if (message.role === "toolResult") { + const toolResult = message as ToolResultMessage; + data.toolResultsMap.set(toolResult.toolCallId, toolResult); + } else if (message.role === "assistant") { + const assistantMsg = message as AssistantMessage; + if (assistantMsg.usage) { + data.tokenStats.input += assistantMsg.usage.input || 0; + data.tokenStats.output += assistantMsg.usage.output || 0; + data.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0; + data.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0; + if (assistantMsg.usage.cost) { + data.costStats.input += assistantMsg.usage.cost.input || 0; + data.costStats.output += assistantMsg.usage.cost.output || 0; + data.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0; + data.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0; + } } } - } else if (entry.type === "model_change") { - data.sessionEvents.push(entry); + break; + } + + case "model_change": + data.sessionEvents.push({ + type: "model_change", + provider: entry.provider as string, + modelId: entry.modelId as string, + timestamp: entry.timestamp as number | undefined, + }); if (entry.modelId) { - const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : entry.modelId; + const modelInfo = entry.provider ? `${entry.provider}/${entry.modelId}` : (entry.modelId as string); data.modelsUsed.add(modelInfo); } - } - } catch { - // Skip malformed lines + break; + + case "compaction": + data.sessionEvents.push({ + type: "compaction", + timestamp: entry.timestamp as string, + summary: entry.summary as string, + tokensBefore: entry.tokensBefore as number, + }); + break; } } return data; } -/** - * Parse streaming event format (type: "agent_start", "message_start", "message_end", etc.) - */ function parseStreamingEventFormat(lines: string[]): ParsedSessionData { const data: ParsedSessionData = { sessionId: "unknown", @@ -1003,94 +201,332 @@ function parseStreamingEventFormat(lines: string[]): ParsedSessionData { let timestampSet = false; - // Track messages by collecting message_end events (which have the final state) for (const line of lines) { + let entry: { type: string; message?: Message }; try { - const entry = JSON.parse(line); + entry = JSON.parse(line) as { type: string; message?: Message }; + } catch { + continue; + } - if (entry.type === "message_end" && entry.message) { - const msg = entry.message; - data.messages.push(msg); - data.sessionEvents.push({ type: "message", message: msg, timestamp: msg.timestamp }); + if (entry.type === "message_end" && entry.message) { + const msg = entry.message; + data.messages.push(msg); + data.sessionEvents.push({ + type: "message", + message: msg, + timestamp: (msg as { timestamp?: number }).timestamp, + }); - // Build tool results map - if (msg.role === "toolResult") { - data.toolResultsMap.set(msg.toolCallId, msg); + if (msg.role === "toolResult") { + const toolResult = msg as ToolResultMessage; + data.toolResultsMap.set(toolResult.toolCallId, toolResult); + } else if (msg.role === "assistant") { + const assistantMsg = msg as AssistantMessage; + if (assistantMsg.model) { + const modelInfo = assistantMsg.provider + ? `${assistantMsg.provider}/${assistantMsg.model}` + : assistantMsg.model; + data.modelsUsed.add(modelInfo); } - - // Track models and accumulate stats from assistant messages - if (msg.role === "assistant") { - if (msg.model) { - const modelInfo = msg.provider ? `${msg.provider}/${msg.model}` : msg.model; - data.modelsUsed.add(modelInfo); + if (assistantMsg.usage) { + data.tokenStats.input += assistantMsg.usage.input || 0; + data.tokenStats.output += assistantMsg.usage.output || 0; + data.tokenStats.cacheRead += assistantMsg.usage.cacheRead || 0; + data.tokenStats.cacheWrite += assistantMsg.usage.cacheWrite || 0; + if (assistantMsg.usage.cost) { + data.costStats.input += assistantMsg.usage.cost.input || 0; + data.costStats.output += assistantMsg.usage.cost.output || 0; + data.costStats.cacheRead += assistantMsg.usage.cost.cacheRead || 0; + data.costStats.cacheWrite += assistantMsg.usage.cost.cacheWrite || 0; } - if (msg.usage) { - data.tokenStats.input += msg.usage.input || 0; - data.tokenStats.output += msg.usage.output || 0; - data.tokenStats.cacheRead += msg.usage.cacheRead || 0; - data.tokenStats.cacheWrite += msg.usage.cacheWrite || 0; - if (msg.usage.cost) { - data.costStats.input += msg.usage.cost.input || 0; - data.costStats.output += msg.usage.cost.output || 0; - data.costStats.cacheRead += msg.usage.cost.cacheRead || 0; - data.costStats.cacheWrite += msg.usage.cost.cacheWrite || 0; - } - } - } - - // Use first message timestamp as session timestamp - if (!timestampSet && msg.timestamp) { - data.timestamp = new Date(msg.timestamp).toISOString(); - timestampSet = true; } } - } catch { - // Skip malformed lines + + if (!timestampSet && (msg as { timestamp?: number }).timestamp) { + data.timestamp = new Date((msg as { timestamp: number }).timestamp).toISOString(); + timestampSet = true; + } } } - // Generate a session ID from the timestamp data.sessionId = `stream-${data.timestamp.replace(/[:.]/g, "-")}`; - return data; } -/** - * Detect the format of a session file by examining the first valid JSON line - */ function detectFormat(lines: string[]): "session-manager" | "streaming-events" | "unknown" { for (const line of lines) { try { - const entry = JSON.parse(line); + const entry = JSON.parse(line) as { type: string }; if (entry.type === "session") return "session-manager"; if (entry.type === "agent_start" || entry.type === "message_start" || entry.type === "turn_start") { return "streaming-events"; } - } catch { - // Skip malformed lines - } + } catch {} } return "unknown"; } -/** - * Generate HTML from parsed session data - */ -function generateHtml(data: ParsedSessionData, inputFilename: string): string { - // Calculate message stats - const userMessages = data.messages.filter((m) => m.role === "user").length; - const assistantMessages = data.messages.filter((m) => m.role === "assistant").length; +function parseSessionFile(content: string): ParsedSessionData { + const lines = content + .trim() + .split("\n") + .filter((l) => l.trim()); - // Count tool calls from assistant messages - let toolCallsCount = 0; - for (const message of data.messages) { - if (message.role === "assistant") { - const assistantMsg = message as AssistantMessage; - toolCallsCount += assistantMsg.content.filter((c) => c.type === "toolCall").length; + if (lines.length === 0) { + throw new Error("Empty session file"); + } + + const format = detectFormat(lines); + if (format === "unknown") { + throw new Error("Unknown session file format"); + } + + return format === "session-manager" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines); +} + +// ============================================================================ +// HTML formatting functions +// ============================================================================ + +function formatToolExecution( + toolName: string, + args: Record, + result?: ToolResultMessage, +): { html: string; bgColor: string } { + let html = ""; + const isError = result?.isError || false; + const bgColor = result ? (isError ? COLORS.toolErrorBg : COLORS.toolSuccessBg) : COLORS.toolPendingBg; + + const getTextOutput = (): string => { + if (!result) return ""; + const textBlocks = result.content.filter((c) => c.type === "text"); + return textBlocks.map((c) => (c as { type: "text"; text: string }).text).join("\n"); + }; + + const formatExpandableOutput = (lines: string[], maxLines: number): string => { + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + + if (remaining > 0) { + let out = '"; + return out; + } + + let out = '
'; + for (const line of displayLines) { + out += `
${escapeHtml(replaceTabs(line))}
`; + } + out += "
"; + return out; + }; + + switch (toolName) { + case "bash": { + const command = (args?.command as string) || ""; + html = `
$ ${escapeHtml(command || "...")}
`; + if (result) { + const output = getTextOutput().trim(); + if (output) { + html += formatExpandableOutput(output.split("\n"), 5); + } + } + break; + } + + case "read": { + const path = shortenPath((args?.file_path as string) || (args?.path as string) || ""); + html = `
read ${escapeHtml(path || "...")}
`; + if (result) { + const output = getTextOutput(); + if (output) { + html += formatExpandableOutput(output.split("\n"), 10); + } + } + break; + } + + case "write": { + const path = shortenPath((args?.file_path as string) || (args?.path as string) || ""); + const fileContent = (args?.content as string) || ""; + const lines = fileContent ? fileContent.split("\n") : []; + + html = `
write ${escapeHtml(path || "...")}`; + if (lines.length > 10) { + html += ` (${lines.length} lines)`; + } + html += "
"; + + if (fileContent) { + html += formatExpandableOutput(lines, 10); + } + if (result) { + const output = getTextOutput().trim(); + if (output) { + html += `
${escapeHtml(output)}
`; + } + } + break; + } + + case "edit": { + const path = shortenPath((args?.file_path as string) || (args?.path as string) || ""); + html = `
edit ${escapeHtml(path || "...")}
`; + + if (result?.details?.diff) { + const diffLines = result.details.diff.split("\n"); + html += '
'; + for (const line of diffLines) { + if (line.startsWith("+")) { + html += `
${escapeHtml(line)}
`; + } else if (line.startsWith("-")) { + html += `
${escapeHtml(line)}
`; + } else { + html += `
${escapeHtml(line)}
`; + } + } + html += "
"; + } + if (result) { + const output = getTextOutput().trim(); + if (output) { + html += `
${escapeHtml(output)}
`; + } + } + break; + } + + default: { + html = `
${escapeHtml(toolName)}
`; + html += `
${escapeHtml(JSON.stringify(args, null, 2))}
`; + if (result) { + const output = getTextOutput(); + if (output) { + html += `
${escapeHtml(output)}
`; + } + } + } + } + + return { html, bgColor }; +} + +function formatMessage(message: Message, toolResultsMap: Map): string { + let html = ""; + const timestamp = (message as { timestamp?: number }).timestamp; + const timestampHtml = timestamp ? `
${formatTimestamp(timestamp)}
` : ""; + + if (message.role === "user") { + const userMsg = message as UserMessage; + let textContent = ""; + + if (typeof userMsg.content === "string") { + textContent = userMsg.content; + } else { + const textBlocks = userMsg.content.filter((c) => c.type === "text"); + textContent = textBlocks.map((c) => (c as { type: "text"; text: string }).text).join(""); + } + + if (textContent.trim()) { + html += `
${timestampHtml}${escapeHtml(textContent).replace(/\n/g, "
")}
`; + } + } else if (message.role === "assistant") { + const assistantMsg = message as AssistantMessage; + html += timestampHtml ? `
${timestampHtml}` : ""; + + for (const content of assistantMsg.content) { + if (content.type === "text" && content.text.trim()) { + html += `
${escapeHtml(content.text.trim()).replace(/\n/g, "
")}
`; + } else if (content.type === "thinking" && content.thinking.trim()) { + html += `
${escapeHtml(content.thinking.trim()).replace(/\n/g, "
")}
`; + } + } + + for (const content of assistantMsg.content) { + if (content.type === "toolCall") { + const toolResult = toolResultsMap.get(content.id); + const { html: toolHtml, bgColor } = formatToolExecution( + content.name, + content.arguments as Record, + toolResult, + ); + html += `
${toolHtml}
`; + } + } + + const hasToolCalls = assistantMsg.content.some((c) => c.type === "toolCall"); + if (!hasToolCalls) { + if (assistantMsg.stopReason === "aborted") { + html += '
Aborted
'; + } else if (assistantMsg.stopReason === "error") { + html += `
Error: ${escapeHtml(assistantMsg.errorMessage || "Unknown error")}
`; + } + } + + if (timestampHtml) { + html += "
"; + } + } + + return html; +} + +function formatModelChange(event: ModelChangeEvent): string { + const timestamp = formatTimestamp(event.timestamp); + const timestampHtml = timestamp ? `
${timestamp}
` : ""; + const modelInfo = `${event.provider}/${event.modelId}`; + return `
${timestampHtml}
Switched to model: ${escapeHtml(modelInfo)}
`; +} + +function formatCompaction(event: CompactionEvent): string { + const timestamp = formatTimestamp(event.timestamp); + const timestampHtml = timestamp ? `
${timestamp}
` : ""; + const summaryHtml = escapeHtml(event.summary).replace(/\n/g, "
"); + + return `
+
+ ${timestampHtml} +
+ + Context compacted from ${event.tokensBefore.toLocaleString()} tokens + (click to expand summary) +
+
+
+
+
Summary sent to model
+
${summaryHtml}
+
+
+
`; +} + +// ============================================================================ +// HTML generation +// ============================================================================ + +function generateHtml(data: ParsedSessionData, filename: string): string { + const userMessages = data.messages.filter((m) => m.role === "user").length; + const assistantMessages = data.messages.filter((m) => m.role === "assistant").length; + + let toolCallsCount = 0; + for (const message of data.messages) { + if (message.role === "assistant") { + toolCallsCount += (message as AssistantMessage).content.filter((c) => c.type === "toolCall").length; } } - // Get last assistant message for context info const lastAssistantMessage = data.messages .slice() .reverse() @@ -1109,20 +545,35 @@ function generateHtml(data: ParsedSessionData, inputFilename: string): string { const lastProvider = lastAssistantMessage?.provider || ""; const lastModelInfo = lastProvider ? `${lastProvider}/${lastModel}` : lastModel; - // Generate messages HTML + const contextWindow = data.contextWindow || 0; + const contextPercent = contextWindow > 0 ? ((contextTokens / contextWindow) * 100).toFixed(1) : null; + let messagesHtml = ""; for (const event of data.sessionEvents) { - if (event.type === "message" && event.message.role !== "toolResult") { - messagesHtml += formatMessage(event.message, data.toolResultsMap); - } else if (event.type === "model_change") { - messagesHtml += formatModelChange(event); + switch (event.type) { + case "message": + if (event.message.role !== "toolResult") { + messagesHtml += formatMessage(event.message, data.toolResultsMap); + } + break; + case "model_change": + messagesHtml += formatModelChange(event); + break; + case "compaction": + messagesHtml += formatCompaction(event); + break; } } - // Tools section (only if tools info available) + const systemPromptHtml = data.systemPrompt + ? `
+
System Prompt
+
${escapeHtml(data.systemPrompt)}
+
` + : ""; + const toolsHtml = data.tools - ? ` -
+ ? `
Available Tools
${data.tools.map((tool) => `
${escapeHtml(tool.name)} - ${escapeHtml(tool.description)}
`).join("")} @@ -1130,28 +581,24 @@ function generateHtml(data: ParsedSessionData, inputFilename: string): string {
` : ""; - // System prompt section (only if available) - const systemPromptHtml = data.systemPrompt - ? ` -
-
System Prompt
-
${escapeHtml(data.systemPrompt)}
+ const streamingNotice = data.isStreamingFormat + ? `
+ Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions.
` : ""; + const contextUsageText = contextPercent + ? `${contextTokens.toLocaleString()} / ${contextWindow.toLocaleString()} tokens (${contextPercent}%) - ${escapeHtml(lastModelInfo)}` + : `${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)}`; + return ` - Session Export - ${escapeHtml(inputFilename)} + Session Export - ${escapeHtml(filename)} @@ -1466,103 +704,45 @@ function generateHtml(data: ParsedSessionData, inputFilename: string): string {

${APP_NAME} v${VERSION}

-
- Session: - ${escapeHtml(data.sessionId)} -
-
- Date: - ${new Date(data.timestamp).toLocaleString()} -
-
- Models: - ${ - Array.from(data.modelsUsed) - .map((m) => escapeHtml(m)) - .join(", ") || "unknown" - } -
+
Session:${escapeHtml(data.sessionId)}
+
Date:${new Date(data.timestamp).toLocaleString()}
+
Models:${ + Array.from(data.modelsUsed) + .map((m) => escapeHtml(m)) + .join(", ") || "unknown" + }

Messages

-
- User: - ${userMessages} -
-
- Assistant: - ${assistantMessages} -
-
- Tool Calls: - ${toolCallsCount} -
+
User:${userMessages}
+
Assistant:${assistantMessages}
+
Tool Calls:${toolCallsCount}

Tokens & Cost

-
- Input: - ${data.tokenStats.input.toLocaleString()} tokens -
-
- Output: - ${data.tokenStats.output.toLocaleString()} tokens -
-
- Cache Read: - ${data.tokenStats.cacheRead.toLocaleString()} tokens -
-
- Cache Write: - ${data.tokenStats.cacheWrite.toLocaleString()} tokens -
-
- Total: - ${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens -
-
- Input Cost: - $${data.costStats.input.toFixed(4)} -
-
- Output Cost: - $${data.costStats.output.toFixed(4)} -
-
- Cache Read Cost: - $${data.costStats.cacheRead.toFixed(4)} -
-
- Cache Write Cost: - $${data.costStats.cacheWrite.toFixed(4)} -
-
- Total Cost: - $${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)} -
-
- Context Usage: - ${contextTokens.toLocaleString()} tokens (last turn) - ${escapeHtml(lastModelInfo)} -
+
Input:${data.tokenStats.input.toLocaleString()} tokens
+
Output:${data.tokenStats.output.toLocaleString()} tokens
+
Cache Read:${data.tokenStats.cacheRead.toLocaleString()} tokens
+
Cache Write:${data.tokenStats.cacheWrite.toLocaleString()} tokens
+
Total:${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens
+
Input Cost:$${data.costStats.input.toFixed(4)}
+
Output Cost:$${data.costStats.output.toFixed(4)}
+
Cache Read Cost:$${data.costStats.cacheRead.toFixed(4)}
+
Cache Write Cost:$${data.costStats.cacheWrite.toFixed(4)}
+
Total Cost:$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}
+
Context Usage:${contextUsageText}
${systemPromptHtml} ${toolsHtml} - - ${ - data.isStreamingFormat - ? `
- Note: This session was reconstructed from raw agent event logs, which do not contain system prompt or tool definitions. -
` - : "" - } + ${streamingNotice}
${messagesHtml} @@ -1576,9 +756,40 @@ function generateHtml(data: ParsedSessionData, inputFilename: string): string { `; } +// ============================================================================ +// Public API +// ============================================================================ + /** - * Export a session file to HTML (standalone, without AgentState or SessionManager) - * Auto-detects format: session manager format or streaming event format + * Export session to HTML using SessionManager and AgentState. + * Used by TUI's /export command. + */ +export function exportSessionToHtml(sessionManager: SessionManager, state: AgentState, outputPath?: string): string { + const sessionFile = sessionManager.getSessionFile(); + const content = readFileSync(sessionFile, "utf8"); + const data = parseSessionFile(content); + + // Enrich with data from AgentState (tools, context window) + data.tools = state.tools.map((t) => ({ name: t.name, description: t.description })); + data.contextWindow = state.model?.contextWindow; + if (!data.systemPrompt) { + data.systemPrompt = state.systemPrompt; + } + + if (!outputPath) { + const sessionBasename = basename(sessionFile, ".jsonl"); + outputPath = `${APP_NAME}-session-${sessionBasename}.html`; + } + + const html = generateHtml(data, basename(sessionFile)); + writeFileSync(outputPath, html, "utf8"); + return outputPath; +} + +/** + * Export session file to HTML (standalone, without AgentState). + * Auto-detects format: session manager format or streaming event format. + * Used by CLI for exporting arbitrary session files. */ export function exportFromFile(inputPath: string, outputPath?: string): string { if (!existsSync(inputPath)) { @@ -1586,23 +797,8 @@ export function exportFromFile(inputPath: string, outputPath?: string): string { } const content = readFileSync(inputPath, "utf8"); - const lines = content - .trim() - .split("\n") - .filter((l) => l.trim()); + const data = parseSessionFile(content); - if (lines.length === 0) { - throw new Error(`Empty file: ${inputPath}`); - } - - const format = detectFormat(lines); - if (format === "unknown") { - throw new Error(`Unknown session file format: ${inputPath}`); - } - - const data = format === "session-manager" ? parseSessionManagerFormat(lines) : parseStreamingEventFormat(lines); - - // Generate output path if not provided if (!outputPath) { const inputBasename = basename(inputPath, ".jsonl"); outputPath = `${APP_NAME}-session-${inputBasename}.html`; @@ -1610,6 +806,5 @@ export function exportFromFile(inputPath: string, outputPath?: string): string { const html = generateHtml(data, basename(inputPath)); writeFileSync(outputPath, html, "utf8"); - return outputPath; } diff --git a/packages/coding-agent/src/session-manager.ts b/packages/coding-agent/src/session-manager.ts index 6758cdeb..e40374fb 100644 --- a/packages/coding-agent/src/session-manager.ts +++ b/packages/coding-agent/src/session-manager.ts @@ -72,17 +72,21 @@ export interface LoadedSession { model: { provider: string; modelId: string } | null; } -const SUMMARY_PREFIX = `Another language model worked on this task and produced a summary. Use this to continue the work without duplicating effort: +export const SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary: + `; +export const SUMMARY_SUFFIX = ` +`; + /** * Create a user message containing the summary with the standard prefix. */ export function createSummaryMessage(summary: string): AppMessage { return { role: "user", - content: SUMMARY_PREFIX + summary, + content: SUMMARY_PREFIX + summary + SUMMARY_SUFFIX, timestamp: Date.now(), }; } @@ -115,6 +119,18 @@ export function parseSessionEntries(content: string): SessionEntry[] { * 2. Keep all entries from firstKeptEntryIndex onwards (extracting messages) * 3. Prepend summary as user message */ +/** + * Get the latest compaction entry from session entries, if any. + */ +export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null { + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].type === "compaction") { + return entries[i] as CompactionEntry; + } + } + return null; +} + export function loadSessionFromEntries(entries: SessionEntry[]): LoadedSession { // Find model and thinking level (always scan all entries) let thinkingLevel = "off"; diff --git a/packages/coding-agent/src/tui/compaction.ts b/packages/coding-agent/src/tui/compaction.ts new file mode 100644 index 00000000..ab73ffa5 --- /dev/null +++ b/packages/coding-agent/src/tui/compaction.ts @@ -0,0 +1,54 @@ +import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { getMarkdownTheme, theme } from "../theme/theme.js"; + +/** + * Component that renders a compaction indicator with collapsed/expanded state. + * Collapsed: shows "Context compacted from X tokens" + * Expanded: shows the full summary rendered as markdown (like a user message) + */ +export class CompactionComponent extends Container { + private expanded = false; + private tokensBefore: number; + private summary: string; + + constructor(tokensBefore: number, summary: string) { + super(); + this.tokensBefore = tokensBefore; + this.summary = summary; + this.updateDisplay(); + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + this.updateDisplay(); + } + + private updateDisplay(): void { + this.clear(); + this.addChild(new Spacer(1)); + + if (this.expanded) { + // Show header + summary as markdown (like user message) + const header = `**Context compacted from ${this.tokensBefore.toLocaleString()} tokens**\n\n`; + this.addChild( + new Markdown(header + this.summary, 1, 1, getMarkdownTheme(), { + bgColor: (text: string) => theme.bg("userMessageBg", text), + color: (text: string) => theme.fg("userMessageText", text), + }), + ); + } else { + // Collapsed: just show the header line with user message styling + const isMac = process.platform === "darwin"; + const shortcut = isMac ? "CMD+O" : "CTRL+O"; + this.addChild( + new Text( + theme.fg("userMessageText", `--- Earlier messages compacted (${shortcut} to expand) ---`), + 1, + 1, + (text: string) => theme.bg("userMessageBg", text), + ), + ); + } + this.addChild(new Spacer(1)); + } +} diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index efe0189d..582bc23c 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -22,12 +22,19 @@ import { calculateContextTokens, compact, getLastAssistantUsage, shouldCompact } import { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from "../config.js"; import { exportSessionToHtml } from "../export-html.js"; import { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from "../model-config.js"; -import { listOAuthProviders, login, logout } from "../oauth/index.js"; -import { loadSessionFromEntries, type SessionManager } from "../session-manager.js"; +import { listOAuthProviders, login, logout, type SupportedOAuthProvider } from "../oauth/index.js"; +import { + getLatestCompactionEntry, + loadSessionFromEntries, + type SessionManager, + SUMMARY_PREFIX, + SUMMARY_SUFFIX, +} from "../session-manager.js"; import type { SettingsManager } from "../settings-manager.js"; import { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from "../slash-commands.js"; import { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "../theme/theme.js"; import { AssistantMessageComponent } from "./assistant-message.js"; +import { CompactionComponent } from "./compaction.js"; import { CustomEditor } from "./custom-editor.js"; import { DynamicBorder } from "./dynamic-border.js"; import { FooterComponent } from "./footer.js"; @@ -564,58 +571,7 @@ export class TuiRenderer { if (!shouldCompact(contextTokens, contextWindow, settings)) return; // Trigger auto-compaction - await this.handleAutoCompaction(); - } - - private async handleAutoCompaction(): Promise { - // Unsubscribe to stop processing events - this.unsubscribe?.(); - - // Abort current agent run and wait for completion - this.agent.abort(); - await this.agent.waitForIdle(); - - // Stop loading animation - if (this.loadingAnimation) { - this.loadingAnimation.stop(); - this.loadingAnimation = null; - } - this.statusContainer.clear(); - - // Show compacting status - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(theme.fg("muted", "Auto-compacting context..."), 1, 1)); - this.ui.requestRender(); - - try { - const apiKey = await getApiKeyForModel(this.agent.state.model); - if (!apiKey) { - throw new Error(`No API key for ${this.agent.state.model.provider}`); - } - - const entries = this.sessionManager.loadEntries(); - const settings = this.settingsManager.getCompactionSettings(); - const compactionEntry = await compact(entries, this.agent.state.model, settings, apiKey); - - // Save and reload - this.sessionManager.saveCompaction(compactionEntry); - const loaded = loadSessionFromEntries(this.sessionManager.loadEntries()); - this.agent.replaceMessages(loaded.messages); - - // Rebuild UI - this.chatContainer.clear(); - this.rebuildChatFromMessages(); - - this.showSuccess( - "✓ Context auto-compacted", - `Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`, - ); - } catch (error) { - this.showError(`Auto-compaction failed: ${error instanceof Error ? error.message : String(error)}`); - } - - // Resubscribe - this.subscribeToAgent(); + await this.executeCompaction(undefined, true); } private async handleEvent(event: AgentEvent, state: AgentState): Promise { @@ -648,9 +604,12 @@ export class TuiRenderer { case "message_start": if (event.message.role === "user") { // Check if this is a queued message - const userMsg = event.message as any; - const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); - const messageText = textBlocks.map((c: any) => c.text).join(""); + const userMsg = event.message; + const textBlocks = + typeof userMsg.content === "string" + ? [{ type: "text", text: userMsg.content }] + : userMsg.content.filter((c) => c.type === "text"); + const messageText = textBlocks.map((c) => c.text).join(""); const queuedIndex = this.queuedMessages.indexOf(messageText); if (queuedIndex !== -1) { @@ -789,17 +748,20 @@ export class TuiRenderer { private addMessageToChat(message: Message): void { if (message.role === "user") { - const userMsg = message as any; + const userMsg = message; // Extract text content from content blocks - const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); - const textContent = textBlocks.map((c: any) => c.text).join(""); + const textBlocks = + typeof userMsg.content === "string" + ? [{ type: "text", text: userMsg.content }] + : userMsg.content.filter((c) => c.type === "text"); + const textContent = textBlocks.map((c) => c.text).join(""); if (textContent) { const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); this.chatContainer.addChild(userComponent); this.isFirstUserMessage = false; } } else if (message.role === "assistant") { - const assistantMsg = message as AssistantMessage; + const assistantMsg = message; // Add assistant message component const assistantComponent = new AssistantMessageComponent(assistantMsg); @@ -819,18 +781,32 @@ export class TuiRenderer { // Update editor border color based on current thinking level this.updateEditorBorderColor(); + // Get compaction info if any + const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries()); + // Render messages for (let i = 0; i < state.messages.length; i++) { const message = state.messages[i]; if (message.role === "user") { - const userMsg = message as any; - const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); - const textContent = textBlocks.map((c: any) => c.text).join(""); + const userMsg = message; + const textBlocks = + typeof userMsg.content === "string" + ? [{ type: "text", text: userMsg.content }] + : userMsg.content.filter((c) => c.type === "text"); + const textContent = textBlocks.map((c) => c.text).join(""); if (textContent) { - const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); - this.chatContainer.addChild(userComponent); - this.isFirstUserMessage = false; + // Check if this is a compaction summary message + if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) { + const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length); + const component = new CompactionComponent(compactionEntry.tokensBefore, summary); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + } else { + const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); + this.chatContainer.addChild(userComponent); + this.isFirstUserMessage = false; + } } } else if (message.role === "assistant") { const assistantMsg = message as AssistantMessage; @@ -892,18 +868,32 @@ export class TuiRenderer { this.isFirstUserMessage = true; this.pendingTools.clear(); + // Get compaction info if any + const compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries()); + for (const message of this.agent.state.messages) { if (message.role === "user") { - const userMsg = message as any; - const textBlocks = userMsg.content.filter((c: any) => c.type === "text"); - const textContent = textBlocks.map((c: any) => c.text).join(""); + const userMsg = message; + const textBlocks = + typeof userMsg.content === "string" + ? [{ type: "text", text: userMsg.content }] + : userMsg.content.filter((c) => c.type === "text"); + const textContent = textBlocks.map((c) => c.text).join(""); if (textContent) { - const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); - this.chatContainer.addChild(userComponent); - this.isFirstUserMessage = false; + // Check if this is a compaction summary message + if (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) { + const summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length); + const component = new CompactionComponent(compactionEntry.tokensBefore, summary); + component.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(component); + } else { + const userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage); + this.chatContainer.addChild(userComponent); + this.isFirstUserMessage = false; + } } } else if (message.role === "assistant") { - const assistantMsg = message as AssistantMessage; + const assistantMsg = message; const assistantComponent = new AssistantMessageComponent(assistantMsg); this.chatContainer.addChild(assistantComponent); @@ -1095,10 +1085,12 @@ export class TuiRenderer { private toggleToolOutputExpansion(): void { this.toolOutputExpanded = !this.toolOutputExpanded; - // Update all tool execution components + // Update all tool execution and compaction components for (const child of this.chatContainer.children) { if (child instanceof ToolExecutionComponent) { child.setExpanded(this.toolOutputExpanded); + } else if (child instanceof CompactionComponent) { + child.setExpanded(this.toolOutputExpanded); } } @@ -1445,7 +1437,7 @@ export class TuiRenderer { // Create OAuth selector this.oauthSelector = new OAuthSelectorComponent( mode, - async (providerId: any) => { + async (providerId: string) => { // Hide selector first this.hideOAuthSelector(); @@ -1457,7 +1449,7 @@ export class TuiRenderer { try { await login( - providerId, + providerId as SupportedOAuthProvider, (url: string) => { // Show auth URL to user this.chatContainer.addChild(new Spacer(1)); @@ -1509,7 +1501,7 @@ export class TuiRenderer { } else { // Handle logout try { - await logout(providerId); + await logout(providerId as SupportedOAuthProvider); // Invalidate OAuth cache so footer updates invalidateOAuthCache(); @@ -1707,7 +1699,7 @@ export class TuiRenderer { private handleDebugCommand(): void { // Force a render and capture all lines with their widths - const width = (this.ui as any).terminal.columns; + const width = this.ui.terminal.columns; const allLines = this.ui.render(width); const debugLogPath = getDebugLogPath(); @@ -1737,16 +1729,13 @@ export class TuiRenderer { this.ui.requestRender(); } - private async handleCompactCommand(customInstructions?: string): Promise { - // Check if there are any messages to compact - const entries = this.sessionManager.loadEntries(); - const messageCount = entries.filter((e) => e.type === "message").length; - - if (messageCount < 2) { - this.showWarning("Nothing to compact (no messages yet)"); - return; - } + private compactionAbortController: AbortController | null = null; + /** + * Shared logic to execute context compaction. + * Handles aborting agent, showing loader, performing compaction, updating session/UI. + */ + private async executeCompaction(customInstructions?: string, isAuto = false): Promise { // Unsubscribe first to prevent processing events during compaction this.unsubscribe?.(); @@ -1761,9 +1750,27 @@ export class TuiRenderer { } this.statusContainer.clear(); - // Show compacting status + // Create abort controller for compaction + this.compactionAbortController = new AbortController(); + + // Set up escape handler during compaction + const originalOnEscape = this.editor.onEscape; + this.editor.onEscape = () => { + if (this.compactionAbortController) { + this.compactionAbortController.abort(); + } + }; + + // Show compacting status with loader this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(theme.fg("muted", "Compacting context..."), 1, 1)); + const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)"; + const compactingLoader = new Loader( + this.ui, + (spinner) => theme.fg("accent", spinner), + (text) => theme.fg("muted", text), + label, + ); + this.statusContainer.addChild(compactingLoader); this.ui.requestRender(); try { @@ -1773,17 +1780,23 @@ export class TuiRenderer { throw new Error(`No API key for ${this.agent.state.model.provider}`); } - // Perform compaction + // Perform compaction with abort signal + const entries = this.sessionManager.loadEntries(); const settings = this.settingsManager.getCompactionSettings(); const compactionEntry = await compact( entries, this.agent.state.model, settings, apiKey, - undefined, + this.compactionAbortController.signal, customInstructions, ); + // Check if aborted after compact returned + if (this.compactionAbortController.signal.aborted) { + throw new Error("Compaction cancelled"); + } + // Save compaction to session this.sessionManager.saveCompaction(compactionEntry); @@ -1795,19 +1808,49 @@ export class TuiRenderer { this.chatContainer.clear(); this.rebuildChatFromMessages(); - // Show success - this.showSuccess( - "✓ Context compacted", - `Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`, - ); + // Add compaction component at current position so user can see/expand the summary + const compactionComponent = new CompactionComponent(compactionEntry.tokensBefore, compactionEntry.summary); + compactionComponent.setExpanded(this.toolOutputExpanded); + this.chatContainer.addChild(compactionComponent); + + // Update footer with new state (fixes context % display) + this.footer.updateState(this.agent.state); + + // Show success message + const successTitle = isAuto ? "✓ Context auto-compacted" : "✓ Context compacted"; + this.showSuccess(successTitle, `Reduced from ${compactionEntry.tokensBefore.toLocaleString()} tokens`); } catch (error) { - this.showError(`Compaction failed: ${error instanceof Error ? error.message : String(error)}`); + const message = error instanceof Error ? error.message : String(error); + if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) { + this.showError("Compaction cancelled"); + } else { + this.showError(`Compaction failed: ${message}`); + } + } finally { + // Clean up + compactingLoader.stop(); + this.statusContainer.clear(); + this.compactionAbortController = null; + this.editor.onEscape = originalOnEscape; } // Resubscribe to agent this.subscribeToAgent(); } + private async handleCompactCommand(customInstructions?: string): Promise { + // Check if there are any messages to compact + const entries = this.sessionManager.loadEntries(); + const messageCount = entries.filter((e) => e.type === "message").length; + + if (messageCount < 2) { + this.showWarning("Nothing to compact (no messages yet)"); + return; + } + + await this.executeCompaction(customInstructions, false); + } + private handleAutocompactCommand(): void { const currentEnabled = this.settingsManager.getCompactionEnabled(); const newState = !currentEnabled; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 051c703d..ed89cfaf 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -73,7 +73,7 @@ export class Container implements Component { * TUI - Main class for managing terminal UI with differential rendering */ export class TUI extends Container { - private terminal: Terminal; + public terminal: Terminal; private previousLines: string[] = []; private previousWidth = 0; private focusedComponent: Component | null = null;