diff --git a/v2/src/lib/components/Workspace/ContextTab.svelte b/v2/src/lib/components/Workspace/ContextTab.svelte index 1a4e859..3b6828d 100644 --- a/v2/src/lib/components/Workspace/ContextTab.svelte +++ b/v2/src/lib/components/Workspace/ContextTab.svelte @@ -269,6 +269,304 @@ else next.add(idx); expandedTurns = next; } + + // --- Sub-tab navigation --- + type ContextSubTab = 'overview' | 'ast' | 'graph'; + let activeSubTab = $state('overview'); + + // --- AST Tree --- + interface AstNode { + id: string; + label: string; + type: 'turn' | 'thinking' | 'text' | 'tool_call' | 'tool_result' | 'file' | 'error'; + color: string; + tokens: number; + children: AstNode[]; + detail?: string; + } + + let astTree = $derived.by((): AstNode[] => { + const turnNodes: AstNode[] = []; + let turnIdx = 0; + let currentTurn: AstNode | null = null; + let currentToolCall: AstNode | null = null; + + for (const msg of messages) { + if (msg.type === 'init' || msg.type === 'status') continue; + + if (msg.type === 'cost') { + // End of turn + if (currentTurn) { + turnNodes.push(currentTurn); + currentTurn = null; + currentToolCall = null; + } + continue; + } + + // Start new turn if needed + if (!currentTurn) { + currentTurn = { + id: `turn-${turnIdx}`, + label: `Turn ${++turnIdx}`, + type: 'turn', + color: 'var(--ctp-lavender)', + tokens: 0, + children: [], + }; + } + + const est = estimateTokens(msg); + currentTurn.tokens += est; + + if (msg.type === 'thinking') { + currentTurn.children.push({ + id: `think-${msg.id}`, + label: 'Thinking', + type: 'thinking', + color: 'var(--ctp-mauve)', + tokens: est, + children: [], + detail: truncateText(extractText(msg), 60), + }); + currentToolCall = null; + } else if (msg.type === 'text') { + currentTurn.children.push({ + id: `text-${msg.id}`, + label: 'Response', + type: 'text', + color: 'var(--ctp-green)', + tokens: est, + children: [], + detail: truncateText(extractText(msg), 60), + }); + currentToolCall = null; + } else if (msg.type === 'tool_call') { + const tc = msg.content as ToolCallContent; + const tcNode: AstNode = { + id: `tc-${tc.toolUseId}`, + label: tc.name, + type: 'tool_call', + color: 'var(--ctp-peach)', + tokens: est, + children: [], + }; + + // Add file references as children of tool call + const files = extractFilePaths(tc); + for (const f of files) { + tcNode.children.push({ + id: `file-${tc.toolUseId}-${f.path}`, + label: f.path.split('/').pop() ?? f.path, + type: 'file', + color: opColor(f.op), + tokens: 0, + children: [], + detail: f.path, + }); + } + + currentTurn.children.push(tcNode); + currentToolCall = tcNode; + } else if (msg.type === 'tool_result') { + // Attach result size to the matching tool call + if (currentToolCall) { + currentToolCall.tokens += est; + currentToolCall.detail = `${formatTokens(currentToolCall.tokens)} tokens`; + } + } else if (msg.type === 'error') { + currentTurn.children.push({ + id: `err-${msg.id}`, + label: 'Error', + type: 'error', + color: 'var(--ctp-red)', + tokens: est, + children: [], + detail: extractText(msg), + }); + } + } + + // Push final turn if not ended by cost + if (currentTurn && currentTurn.children.length > 0) { + turnNodes.push(currentTurn); + } + + return turnNodes; + }); + + // --- Tool Graph --- + interface GraphNode { + id: string; + label: string; + type: 'file' | 'tool'; + x: number; + y: number; + color: string; + count: number; + } + + interface GraphEdge { + from: string; + to: string; + op: string; + color: string; + } + + let toolGraph = $derived.by((): { nodes: GraphNode[]; edges: GraphEdge[] } => { + const fileNodes = new Map }>(); + const toolNodes = new Map(); + const edges: GraphEdge[] = []; + + for (const msg of messages) { + if (msg.type !== 'tool_call') continue; + const tc = msg.content as ToolCallContent; + const toolName = tc.name; + + // Track tool node + const existing = toolNodes.get(toolName); + if (existing) existing.count++; + else toolNodes.set(toolName, { count: 1 }); + + // Track file nodes and edges + const files = extractFilePaths(tc); + for (const f of files) { + const fNode = fileNodes.get(f.path); + if (fNode) { + fNode.count++; + fNode.ops.add(f.op); + } else { + fileNodes.set(f.path, { + label: f.path.split('/').pop() ?? f.path, + count: 1, + ops: new Set([f.op]), + }); + } + + edges.push({ + from: `tool-${toolName}`, + to: `file-${f.path}`, + op: f.op, + color: opColor(f.op), + }); + } + } + + // Layout: tools on left, files on right + const nodes: GraphNode[] = []; + const toolList = Array.from(toolNodes.entries()).sort((a, b) => b[1].count - a[1].count); + const fileList = Array.from(fileNodes.entries()).sort((a, b) => b[1].count - a[1].count); + + const NODE_SPACING = 36; + const LEFT_X = 20; + const RIGHT_X = 220; + + toolList.forEach(([name, data], i) => { + nodes.push({ + id: `tool-${name}`, + label: name, + type: 'tool', + x: LEFT_X, + y: 20 + i * NODE_SPACING, + color: toolColor(name), + count: data.count, + }); + }); + + fileList.forEach(([path, data], i) => { + nodes.push({ + id: `file-${path}`, + label: data.label, + type: 'file', + x: RIGHT_X, + y: 20 + i * NODE_SPACING, + color: 'var(--ctp-text)', + count: data.count, + }); + }); + + // Deduplicate edges (same from→to, aggregate) + const edgeMap = new Map(); + for (const e of edges) { + const key = `${e.from}→${e.to}`; + if (!edgeMap.has(key)) edgeMap.set(key, e); + } + + return { nodes, edges: Array.from(edgeMap.values()) }; + }); + + // AST layout helpers + const AST_NODE_W = 110; + const AST_NODE_H = 28; + const AST_H_GAP = 16; + const AST_V_GAP = 6; + + interface AstLayout { + node: AstNode; + x: number; + y: number; + children: AstLayout[]; + } + + function layoutAst(node: AstNode, x: number, y: number): { layout: AstLayout; height: number } { + if (node.children.length === 0) { + return { layout: { node, x, y, children: [] }, height: AST_NODE_H }; + } + + const childLayouts: AstLayout[] = []; + let childY = y; + let totalHeight = 0; + + for (const child of node.children) { + const result = layoutAst(child, x + AST_NODE_W + AST_H_GAP, childY); + childLayouts.push(result.layout); + childY += result.height + AST_V_GAP; + totalHeight += result.height + AST_V_GAP; + } + totalHeight -= AST_V_GAP; + + const parentY = childLayouts.length > 0 + ? (childLayouts[0].y + childLayouts[childLayouts.length - 1].y) / 2 + : y; + + return { + layout: { node, x, y: parentY, children: childLayouts }, + height: Math.max(AST_NODE_H, totalHeight), + }; + } + + function astSvgWidth(layout: AstLayout): number { + let maxX = layout.x + AST_NODE_W; + for (const child of layout.children) maxX = Math.max(maxX, astSvgWidth(child)); + return maxX + 12; + } + + // Helpers + function extractText(msg: AgentMessage): string { + const c = msg.content; + if (typeof c === 'string') return c; + if (c && typeof c === 'object' && 'text' in c) return String((c as Record).text ?? ''); + if (c && typeof c === 'object' && 'message' in c) return String((c as Record).message ?? ''); + return ''; + } + + function truncateText(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen - 1) + '…'; + } + + function toolColor(name: string): string { + switch (name) { + case 'Read': case 'read': return 'var(--ctp-blue)'; + case 'Write': case 'write': return 'var(--ctp-peach)'; + case 'Edit': case 'edit': return 'var(--ctp-peach)'; + case 'Grep': case 'grep': return 'var(--ctp-mauve)'; + case 'Glob': case 'glob': return 'var(--ctp-teal)'; + case 'Bash': case 'bash': return 'var(--ctp-yellow)'; + case 'Agent': case 'Task': return 'var(--ctp-flamingo)'; + default: return 'var(--ctp-sapphire)'; + } + }
@@ -284,6 +582,15 @@

Start an agent session to see context window analysis

{:else} + +
+ + + +
+ + +
@@ -414,6 +721,195 @@ {/if}
+
+ + +
+ {#if astTree.length === 0} +
No conversation data yet
+ {:else} +
+ {#each astTree as turnNode (turnNode.id)} + {@const result = layoutAst(turnNode, 8, 8)} + {@const svgW = astSvgWidth(result.layout)} + {@const svgH = Math.max(50, result.height + 20)} +
+
+ {turnNode.label} + {formatTokens(turnNode.tokens)} +
+
+ + {#snippet renderAstNode(layout: AstLayout)} + + {#each layout.children as child} + + {/each} + + + + + + + {truncateText(layout.node.label, 12)} + + {#if layout.node.tokens > 0} + {formatTokens(layout.node.tokens)} + {/if} + + + {#if layout.node.detail} + {layout.node.detail} + {/if} + + {#each layout.children as child} + {@render renderAstNode(child)} + {/each} + {/snippet} + + {@render renderAstNode(result.layout)} + +
+
+ {/each} +
+ {/if} +
+ + +
+ {#if toolGraph.nodes.length === 0} +
No tool calls yet
+ {:else} + {@const maxY = Math.max(...toolGraph.nodes.map(n => n.y)) + 40} +
+ + + {#each toolGraph.edges as edge} + {@const fromNode = toolGraph.nodes.find(n => n.id === edge.from)} + {@const toNode = toolGraph.nodes.find(n => n.id === edge.to)} + {#if fromNode && toNode} + + {/if} + {/each} + + + {#each toolGraph.nodes as node (node.id)} + {#if node.type === 'tool'} + + + {node.label} + {node.count}× + {:else} + + + {truncateText(node.label, 16)} + {node.count}× + {/if} + {/each} + + + Tools + Files + +
+ {/if} +
{/if} @@ -796,4 +1292,112 @@ text-align: center; padding: 1rem; } + + /* Sub-tabs */ + .sub-tabs { + display: flex; + gap: 0; + background: var(--ctp-mantle); + border-bottom: 1px solid var(--ctp-surface0); + flex-shrink: 0; + } + + .sub-tab { + padding: 0.25rem 0.75rem; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--ctp-overlay1); + font-size: 0.65rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; + transition: color 0.12s, background 0.12s, border-color 0.12s; + } + + .sub-tab:hover { + color: var(--ctp-subtext1); + background: var(--ctp-surface0); + } + + .sub-tab.active { + color: var(--ctp-text); + font-weight: 600; + border-bottom-color: var(--ctp-blue); + background: var(--ctp-base); + } + + .sub-panel { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow-y: auto; + } + + /* AST */ + .ast-panel { + gap: 0.125rem; + } + + .ast-scroll { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.375rem; + overflow-y: auto; + flex: 1; + } + + .ast-turn-block { + border: 1px solid var(--ctp-surface0); + border-radius: 0.375rem; + overflow: hidden; + background: var(--ctp-base); + } + + .ast-turn-label { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.25rem 0.5rem; + background: var(--ctp-mantle); + border-bottom: 1px solid var(--ctp-surface0); + } + + .ast-turn-name { + font-size: 0.65rem; + font-weight: 600; + color: var(--ctp-lavender); + } + + .ast-turn-tokens { + font-size: 0.575rem; + font-family: var(--term-font-family, monospace); + color: var(--ctp-overlay1); + } + + .ast-svg-wrap { + overflow-x: auto; + padding: 0.25rem; + } + + .ast-svg-wrap svg { + display: block; + } + + /* Graph */ + .graph-panel { + padding: 0.375rem; + } + + .graph-scroll { + overflow: auto; + flex: 1; + } + + .graph-scroll svg { + display: block; + }