diff --git a/.gitignore b/.gitignore index c9d3262..47fda45 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ webview-ui/coverage/ # Claude Code — local-only settings (personal overrides, not for team) .claude/settings.local.json +.claude/.headroom_wrap_marker.json +.tokensave \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 2ea81c6..9c8cd0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,8 @@ Data flows one way: `JSONL files → FileWatcher → DashboardStore → Panel.bu | Component | Used in | |---|---| -| `SessionDetail.tsx` (+ `modelLabel`, `modelBadgeColor`) | ProjectDetail → Sessions tab; includes the copy-resume-command button | +| `SessionDetail.tsx` (+ `modelLabel`, `modelBadgeColor`) | ProjectDetail → Sessions tab; session meta header + copy-resume-command button; renders turns via `conversation/` | +| `conversation/` (`ConversationTurn`, `ResponseGroup`, `UserMessageCard`, `ThinkingRow`, `ToolCallRow`, `AgentCallBlock`, `SkillContextRow`, `SystemEventRow`, `shared.tsx`, `systemEvents.ts`) | Claude-Code-extension-style conversation timeline: user prompt cards ("You" header + accent border), tool-colored dots joined by connector lines (everything between two user prompts merges into one `ResponseGroup`), tool "OUT" output boxes, project-relative file hints, show-less-by-default messages | | `SessionsBrowser.tsx` | Dashboard → Sessions tab (cross-project rows + backend-served prompt search) | | `WeeklyStatsTab.tsx` | ProjectDetail → Activity (usage trend) | | `MarkdownView.tsx` (+ `CommandBlock`) | ProjectDetail → Setup (CLAUDE.md, memory, commands) | @@ -156,7 +157,7 @@ Session turns are **lazy-loaded**: initial state ships sessions with `turns: []` - **Project** — `{ id, name, path, lastActive, isActive, sessionCount, totalTokens, totalCostUsd, techStack[] }` - **Session** — `{ id, projectId, parentSessionId, startTime, endTime, durationMs, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, totalTokens, costUsd, promptCount, toolCallCount, filesModified[], filesCreated[], turns[], sessionSummary, hasThinking, thinkingTokens, cacheHitRate, subagentCostUsd, idleTimeMs, activeTimeMs, activityRatio, model }` -- **Turn** — `{ id, role, content, inputTokens, outputTokens, toolCalls[], timestamp }` +- **Turn** — `{ id, role, content, inputTokens, outputTokens, toolCalls[], timestamp, thinking? }` - **ToolCall** — `{ id, name, input, output?, mcpServer? }` - **ProjectConfig** — `{ claudeMd, mcpServers, projectSettings, commands[], memory, hooks[] }` - **ProjectStats** — `{ usageOverTime[], toolUsage[], promptPatterns[], efficiency, recentToolCalls[], weeklyStats }` diff --git a/src/__tests__/fixtures/jsonl-samples.ts b/src/__tests__/fixtures/jsonl-samples.ts index 347191e..f759599 100644 --- a/src/__tests__/fixtures/jsonl-samples.ts +++ b/src/__tests__/fixtures/jsonl-samples.ts @@ -280,3 +280,52 @@ export const SESSION_ARRAY_CONTENT = [ }, }), ].join('\n'); + +export const SESSION_WITH_TOOL_RESULTS = [ + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2025-01-15T10:00:00Z', + cwd: '/home/user/project', + message: { content: 'Search the web' }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'a1', + timestamp: '2025-01-15T10:00:30Z', + message: { + model: 'claude-sonnet-4', + content: [ + { type: 'thinking', thinking: 'Let me plan the search first.', thinking_tokens: 400 }, + { type: 'text', text: 'Searching now.' }, + { type: 'tool_use', id: 'ws1', name: 'WebSearch', input: { query: 'persistent memory' } }, + { type: 'tool_use', id: 'ws2', name: 'Bash', input: { command: 'ls' } }, + ], + usage: { input_tokens: 200, output_tokens: 100 }, + stop_reason: 'tool_use', + }, + }), + JSON.stringify({ + type: 'user', + uuid: 'tr1', + timestamp: '2025-01-15T10:00:40Z', + message: { + content: [ + { type: 'tool_result', tool_use_id: 'ws1', content: [{ type: 'text', text: ' Result A\nResult B ' }] }, + { type: 'tool_result', tool_use_id: 'ws2', content: 'file1.ts\nfile2.ts' }, + { type: 'tool_result', tool_use_id: 'unknown-id', content: 'orphan' }, + ], + }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'a2', + timestamp: '2025-01-15T10:01:00Z', + message: { + model: 'claude-sonnet-4', + content: [{ type: 'text', text: 'Found it.' }], + usage: { input_tokens: 300, output_tokens: 80 }, + stop_reason: 'end_turn', + }, + }), +].join('\n'); diff --git a/src/parsers/SessionParser.ts b/src/parsers/SessionParser.ts index 4219778..344894a 100644 --- a/src/parsers/SessionParser.ts +++ b/src/parsers/SessionParser.ts @@ -34,6 +34,11 @@ export interface ParsedSession extends Session { isActiveSession: boolean; } +// Turns ship to the webview over postMessage — cap the bulky per-turn extras +// so a session with huge tool outputs or long thinking stays lightweight. +const TOOL_OUTPUT_CAP = 2500; +const THINKING_TEXT_CAP = 10000; + export class SessionParser { /** Read just the cwd from the first entry in a JSONL file */ readCwd(filePath: string): string | null { @@ -75,6 +80,8 @@ export class SessionParser { // File attachments appear as separate entries right after the user // message that @-tagged them; buffer any that arrive before their turn. let pendingAttachments: TurnAttachment[] = []; + // Tool results echo back as later user entries referencing tool_use_id. + const toolCallById = new Map(); for (const line of lines) { try { @@ -90,6 +97,24 @@ export class SessionParser { if (!startTime && ts) { startTime = ts; } const rawContent = entry.message.content; + + // Attach tool results to the originating tool call — they are + // echoes of assistant activity, not user prompts. + const toolResults = Array.isArray(rawContent) + ? rawContent.filter((c: any) => c.type === 'tool_result') + : []; + for (const tr of toolResults) { + const target = toolCallById.get(tr.tool_use_id); + if (!target || target.output !== undefined) { continue; } + const raw = typeof tr.content === 'string' + ? tr.content + : Array.isArray(tr.content) + ? tr.content.filter((c: any) => c.type === 'text').map((c: any) => c.text || '').join('\n') + : ''; + const trimmed = raw.trim(); + if (trimmed) { target.output = trimmed.slice(0, TOOL_OUTPUT_CAP); } + } + const text = Array.isArray(rawContent) ? rawContent.filter((c: any) => c.type === 'text').map((c: any) => c.text || '').join('') : typeof rawContent === 'string' ? rawContent : ''; @@ -107,17 +132,20 @@ export class SessionParser { sessionSummary = displayText.slice(0, 120) + (displayText.length > 120 ? '…' : ''); } - turns.push({ - id: entry.uuid || String(ts), - role: 'user', - content: displayText, - inputTokens: 0, - outputTokens: 0, - toolCalls: [], - timestamp: ts, - ...(pendingAttachments.length > 0 ? { attachments: pendingAttachments } : {}), - }); - pendingAttachments = []; + // Pure tool-result entries are not prompts — skip the turn entirely. + if (toolResults.length === 0 || displayText.length > 0) { + turns.push({ + id: entry.uuid || String(ts), + role: 'user', + content: displayText, + inputTokens: 0, + outputTokens: 0, + toolCalls: [], + timestamp: ts, + ...(pendingAttachments.length > 0 ? { attachments: pendingAttachments } : {}), + }); + pendingAttachments = []; + } } if (entry.type === 'attachment' && entry.attachment?.type === 'file') { @@ -154,11 +182,13 @@ export class SessionParser { const toolCalls: ToolCall[] = []; const contentBlocks = Array.isArray(entry.message.content) ? entry.message.content : []; let textContent = ''; + let turnThinking = ''; for (const block of contentBlocks) { if (block.type === 'text') { textContent += block.text; } if (block.type === 'thinking') { hasThinking = true; + if (typeof block.thinking === 'string') { turnThinking += block.thinking; } // thinking blocks may report their own token count if (typeof block.thinking_tokens === 'number') { thinkingTokens += block.thinking_tokens as number; @@ -169,7 +199,9 @@ export class SessionParser { const mcpServer = typeof block.name === 'string' && block.name.startsWith('mcp__') ? (block.name.split('__')[1] ?? undefined) : undefined; - toolCalls.push({ id: block.id, name: block.name, input: block.input ?? {}, mcpServer }); + const call: ToolCall = { id: block.id, name: block.name, input: block.input ?? {}, mcpServer }; + toolCalls.push(call); + if (block.id) { toolCallById.set(block.id, call); } if (block.name === 'Write') { const fp = block.input?.file_path as string | undefined; @@ -189,6 +221,7 @@ export class SessionParser { outputTokens: outTok, toolCalls, timestamp: ts, + ...(turnThinking.trim() ? { thinking: turnThinking.trim().slice(0, THINKING_TEXT_CAP) } : {}), }); } } catch { /* skip malformed lines */ } diff --git a/src/parsers/__tests__/SessionParser.test.ts b/src/parsers/__tests__/SessionParser.test.ts index 75d1da7..eb9670b 100644 --- a/src/parsers/__tests__/SessionParser.test.ts +++ b/src/parsers/__tests__/SessionParser.test.ts @@ -14,6 +14,7 @@ import { SESSION_WITH_MCP, SESSION_WITH_THINKING, SESSION_WITH_TOOLS, + SESSION_WITH_TOOL_RESULTS, } from '../../__tests__/fixtures/jsonl-samples'; vi.mock('fs'); @@ -186,6 +187,24 @@ describe('SessionParser', () => { expect(thinkingResult?.model).toBe('claude-opus-4'); }); + it('attaches tool outputs, captures thinking text, and skips tool-result turns', () => { + vi.mocked(fs.readFileSync).mockReturnValue(asReadResult(SESSION_WITH_TOOL_RESULTS)); + const result = parser.parseFile('/sessions/results.jsonl', 'proj-1'); + + // The tool-result user entry must not become a turn or count as a prompt + expect(result?.turns.map(t => t.role)).toEqual(['user', 'assistant', 'assistant']); + expect(result?.promptCount).toBe(1); + + const [webSearch, bash] = result!.turns[1].toolCalls; + expect(webSearch.output).toBe('Result A\nResult B'); + expect(bash.output).toBe('file1.ts\nfile2.ts'); + + expect(result?.turns[1].thinking).toBe('Let me plan the search first.'); + expect(result?.turns[2].thinking).toBeUndefined(); + expect(result?.hasThinking).toBe(true); + expect(result?.thinkingTokens).toBe(400); + }); + it('computes idle, active, and duration metrics', () => { const idleSession = [ JSON.stringify({ type: 'user', uuid: 'u1', timestamp: '2025-01-15T10:00:00Z', cwd: '/test', message: { content: 'Q1' } }), diff --git a/src/store/DashboardStore.ts b/src/store/DashboardStore.ts index a0896a6..cab5c86 100644 --- a/src/store/DashboardStore.ts +++ b/src/store/DashboardStore.ts @@ -122,6 +122,7 @@ export interface Turn { toolCalls: ToolCall[]; timestamp: number; attachments?: TurnAttachment[]; // files the user @-tagged in this prompt + thinking?: string; // extended-thinking text emitted before this turn's content } export interface TurnAttachment { diff --git a/webview-ui/src/components/SessionDetail.tsx b/webview-ui/src/components/SessionDetail.tsx index 2721534..ba71c94 100644 --- a/webview-ui/src/components/SessionDetail.tsx +++ b/webview-ui/src/components/SessionDetail.tsx @@ -1,139 +1,29 @@ import React from 'react'; -import { Session, Turn, ToolCall, TurnAttachment } from '../types'; +import { Session, Turn } from '../types'; import { formatTokens, formatCost, formatDuration } from '../utils/format'; -import { toolColor } from '../utils/toolColor'; -import { MarkdownView } from './MarkdownView'; +import { isPromptTurn } from './conversation/ConversationTurn'; +import { UserMessageCard } from './conversation/UserMessageCard'; +import { ResponseGroup } from './conversation/ResponseGroup'; -// ── System event parsing ─────────────────────────────────────────────────────── +// Re-exported for existing importers (SessionsBrowser, ProjectDetail, tests). +export { parseSystemContent, stripAnsi } from './conversation/systemEvents'; -type SystemEvent = - | { kind: 'command'; name: string; args?: string } - | { kind: 'stdout'; text: string } - | { kind: 'skill'; name: string; body: string }; - -// Loading a skill injects its full instructions as a "user" message that begins -// with this marker. These can be hundreds of KB — surface them as collapsed -// context rather than a user turn. -const SKILL_INJECTION_PREFIX = 'Base directory for this skill:'; - -// eslint-disable-next-line no-control-regex -const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; -export function stripAnsi(s: string): string { return s.replace(ANSI_RE, ''); } - -export function parseSystemContent(content: string): SystemEvent | 'skip' | null { - const t = content.trim(); - if (//i.test(t) && !t.replace(/[\s\S]*?<\/local-command-caveat>/gi, '').trim()) { - return 'skip'; - } - if (t.startsWith(SKILL_INJECTION_PREFIX)) { - const nl = t.indexOf('\n'); - const firstLine = nl === -1 ? t : t.slice(0, nl); - const dir = firstLine.slice(SKILL_INJECTION_PREFIX.length).trim(); - const name = dir.split('/').filter(Boolean).pop() || 'skill'; - return { kind: 'skill', name, body: t.slice(firstLine.length).trim() }; - } - const cmdMatch = t.match(/([^<]+)<\/command-name>/); - if (cmdMatch) { - const argsMatch = t.match(/([\s\S]*?)<\/command-args>/); - const args = argsMatch?.[1]?.trim() || undefined; - return { kind: 'command', name: cmdMatch[1].trim(), args }; - } - const stdoutMatch = t.match(/<(?:local-command-stdout|command-stdout)>([\s\S]*?)<\/(?:local-command-stdout|command-stdout)>/); - if (stdoutMatch) { - const text = stripAnsi(stdoutMatch[1].trim()); - return text ? { kind: 'stdout', text } : 'skip'; - } - return null; -} - -function SystemEventRow({ event }: { event: SystemEvent }) { - if (event.kind === 'command') { - return ( -
- - - - - {event.name} - {event.args && {event.args}} -
- ); - } - if (event.kind === 'stdout') { - return ( -
- - - - {event.text} -
- ); - } +export function modelLabel(model: string | null): string | null { + if (!model) return null; + if (model.includes('fable')) return 'Fable'; + if (model.includes('mythos')) return 'Mythos'; + if (model.includes('opus')) return 'Opus'; + if (model.includes('haiku')) return 'Haiku'; + if (model.includes('sonnet')) return 'Sonnet'; return null; } -// Skill instructions injected as a "user" turn — rendered as collapsed context. -function SkillContextRow({ event }: { event: Extract }) { - const [expanded, setExpanded] = React.useState(false); - const body = event.body; - const shown = body.length > CONTENT_RENDER_CAP ? body.slice(0, CONTENT_RENDER_CAP) : body; - const hidden = body.length - shown.length; - return ( -
- - {expanded && ( -
- - {hidden > 0 && ( -
- {hidden.toLocaleString()} more chars hidden — open the session file to see the full skill. -
- )} -
- )} -
- ); -} - -// ── Turn rendering ───────────────────────────────────────────────────────────── - -function TurnToolBadge({ tc }: { tc: ToolCall }) { - const displayName = tc.name.startsWith('mcp__') - ? tc.name.slice(5).replace('__', '/') - : tc.name; - const hint = (tc.input?.file_path as string | undefined) - ?? (tc.input?.command as string | undefined) - ?? (tc.input?.pattern as string | undefined) - ?? (tc.input?.query as string | undefined) - ?? ''; - return ( -
- - {displayName} - - {hint && ( - - {hint.length > 80 ? hint.slice(0, 80) + '…' : hint} - - )} -
- ); +export function modelBadgeColor(model: string | null): string { + if (!model) return ''; + if (model.includes('fable') || model.includes('mythos')) return 'text-pink-400 bg-pink-500/15'; + if (model.includes('opus')) return 'text-purple-400 bg-purple-500/15'; + if (model.includes('haiku')) return 'text-orange-400 bg-orange-500/15'; + return 'text-blue-400 bg-blue-500/15'; } function truncateSessionId(id: string): string { @@ -156,251 +46,6 @@ function SessionIdGlyph({ copied }: { copied: boolean }) { ); } -function CopyButton({ text }: { text: string }) { - const [copied, setCopied] = React.useState(false); - const copy = () => { - navigator.clipboard.writeText(text).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }); - }; - return ( - - ); -} - -function CollapseButton({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) { - return ( - - ); -} - -function AgentCallBlock({ tc, tokenInfo }: { tc: ToolCall; tokenInfo?: { input: number; output: number } }) { - const [collapsed, setCollapsed] = React.useState(false); - const prompt = (tc.input?.prompt as string | undefined)?.trim() ?? ''; - return ( -
-
- Agent - subagent -
- {prompt && } - setCollapsed(c => !c)} /> -
-
- {!collapsed && prompt && ( -
- -
- )} - {collapsed && prompt && ( -
{prompt.slice(0, 120)}{prompt.length > 120 ? '…' : ''}
- )} - {tokenInfo && tokenInfo.output > 0 && ( -
{formatTokens(tokenInfo.input)}↑ {formatTokens(tokenInfo.output)}↓
- )} -
- ); -} - -function AttachmentChips({ attachments }: { attachments: TurnAttachment[] }) { - return ( -
- {attachments.map(a => ( - - - {a.displayPath || a.path} - - ))} -
- ); -} - -// Injected context (skill listings, pasted files) can make a single turn -// hundreds of KB long, which explodes into thousands of DOM nodes and freezes -// the webview. Render a bounded preview by default, expandable up to a hard cap. -const CONTENT_PREVIEW_LIMIT = 4000; -const CONTENT_RENDER_CAP = 40000; - -function TurnBlock({ turn }: { turn: Turn }) { - const [collapsed, setCollapsed] = React.useState(false); - const [showFull, setShowFull] = React.useState(false); - const content = turn.content?.trim() ?? ''; - const hasContent = content.length > 0; - const isLongContent = content.length > CONTENT_PREVIEW_LIMIT; - const displayContent = !isLongContent - ? content - : showFull - ? content.slice(0, CONTENT_RENDER_CAP) - : content.slice(0, CONTENT_PREVIEW_LIMIT); - const cappedHidden = isLongContent && showFull ? content.length - CONTENT_RENDER_CAP : 0; - const hasTools = turn.toolCalls.length > 0; - const attachments = turn.attachments ?? []; - - if (!hasContent && !hasTools && attachments.length === 0) return null; - - if (turn.role === 'user' && hasContent) { - const sys = parseSystemContent(content); - if (sys === 'skip') return null; - if (sys && sys.kind === 'skill') return ; - if (sys) return ; - } - - // Split tool calls: Agent gets full blocks, others get compact badges - const agentCalls = turn.toolCalls.filter(tc => tc.name === 'Agent'); - const regularCalls = turn.toolCalls.filter(tc => tc.name !== 'Agent'); - - // Tool-only assistant turn - if (turn.role === 'assistant' && !hasContent && hasTools) { - const tokenInfo = { input: turn.inputTokens, output: turn.outputTokens }; - return ( -
- {agentCalls.map(tc => ( - - ))} - {regularCalls.length > 0 && ( -
- {regularCalls.map(tc => ( - - ))} - {turn.outputTokens > 0 && agentCalls.length === 0 && ( -
{formatTokens(turn.inputTokens)}↑ {formatTokens(turn.outputTokens)}↓
- )} -
- )} -
- ); - } - - const isUser = turn.role === 'user'; - - return ( -
-
-
- {isUser ? ( - - - You - - ) : ( - - - Claude - - )} -
- {hasContent && } - setCollapsed(c => !c)} /> -
-
- {!collapsed && ( - <> - {attachments.length > 0 && } - {hasContent && ( -
- - {isLongContent && ( -
- - {cappedHidden > 0 && ( - - {cappedHidden.toLocaleString()} more chars hidden — use copy to get the full text - - )} -
- )} -
- )} - {regularCalls.length > 0 && ( -
- {regularCalls.map(tc => ( - - ))} -
- )} - - )} - {collapsed && hasContent && ( -
{content.slice(0, 120)}{content.length > 120 ? '…' : ''}
- )} - {turn.outputTokens > 0 && ( -
{formatTokens(turn.inputTokens)}↑ {formatTokens(turn.outputTokens)}↓
- )} -
- {agentCalls.map(tc => ( - - ))} -
- ); -} - -// ── SessionDetail ────────────────────────────────────────────────────────────── - -export function modelLabel(model: string | null): string | null { - if (!model) return null; - if (model.includes('fable')) return 'Fable'; - if (model.includes('mythos')) return 'Mythos'; - if (model.includes('opus')) return 'Opus'; - if (model.includes('haiku')) return 'Haiku'; - if (model.includes('sonnet')) return 'Sonnet'; - return null; -} - -export function modelBadgeColor(model: string | null): string { - if (!model) return ''; - if (model.includes('fable') || model.includes('mythos')) return 'text-pink-400 bg-pink-500/15'; - if (model.includes('opus')) return 'text-purple-400 bg-purple-500/15'; - if (model.includes('haiku')) return 'text-orange-400 bg-orange-500/15'; - return 'text-blue-400 bg-blue-500/15'; -} - function ResumeButton({ sessionId }: { sessionId: string }) { const [copied, setCopied] = React.useState(false); const command = `claude --resume ${sessionId}`; @@ -426,7 +71,7 @@ function ResumeButton({ sessionId }: { sessionId: string }) { ); } -export default function SessionDetail({ session, turns, loading }: { session: Session; turns: Turn[]; loading: boolean }) { +function SessionMetaRow({ session }: { session: Session }) { const totalCost = session.costUsd + (session.subagentCostUsd ?? 0); const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'failed'>('idle'); @@ -442,116 +87,147 @@ export default function SessionDetail({ session, turns, loading }: { session: Se }, [session.id]); return ( -
-
- -
-
- - · - {new Date(session.startTime).toLocaleString([], { hour12: true })} - {modelLabel(session.model) && ( - - {modelLabel(session.model)} - - )} - · - {formatDuration(session.durationMs)} - · - - {formatTokens(session.totalTokens)} tokens - - · +
+ + · + {new Date(session.startTime).toLocaleString([], { hour12: true })} + {modelLabel(session.model) && ( - est.{session.pricingConfidence === 'fallback' ? '*' : ''} {formatCost(totalCost)} + {modelLabel(session.model)} - {(session.cacheReadTokens ?? 0) > 0 && ( - - +{formatTokens(session.cacheReadTokens)} cached - - )} - {(session.cacheHitRate ?? 0) > 0 && ( - - {Math.round(session.cacheHitRate)}% cache - - )} - {session.hasThinking && ( - - - thinking{(session.thinkingTokens ?? 0) > 0 ? ` (${formatTokens(session.thinkingTokens)})` : ''} - - )} - {(session.subagentCostUsd ?? 0) > 0 && ( - - +{formatCost(session.subagentCostUsd)} subagents - - )} + )} + · + {formatDuration(session.durationMs)} + · + + {formatTokens(session.totalTokens)} tokens + + · + + est.{session.pricingConfidence === 'fallback' ? '*' : ''} {formatCost(totalCost)} + + {(session.cacheReadTokens ?? 0) > 0 && ( + + +{formatTokens(session.cacheReadTokens)} cached + + )} + {(session.cacheHitRate ?? 0) > 0 && ( + + {Math.round(session.cacheHitRate)}% cache + + )} + {session.hasThinking && ( + + + thinking{(session.thinkingTokens ?? 0) > 0 ? ` (${formatTokens(session.thinkingTokens)})` : ''} + + )} + {(session.subagentCostUsd ?? 0) > 0 && ( + + +{formatCost(session.subagentCostUsd)} subagents + + )} +
+ ); +} + +function FilesTouched({ session }: { session: Session }) { + return ( +
+
Files touched
+
+ {session.filesModified.map(f => { + const created = session.filesCreated?.includes(f); + return ( + + + {f.split('/').pop()} + + ); + })}
+
+ ); +} - {session.filesModified.length > 0 && ( -
-
Files touched
-
- {session.filesModified.map(f => { - const created = session.filesCreated?.includes(f); - return ( - - - {f.split('/').pop()} - - ); - })} -
-
- )} +// Everything between two user prompts renders as one ResponseGroup so the +// timeline connector line runs unbroken across the whole response — tool +// calls, thoughts, text, and system rows alike. +function conversationBlocks(turns: Turn[], projectRoot: string | null): React.ReactNode[] { + const blocks: React.ReactNode[] = []; + let group: Turn[] = []; + const flush = () => { + if (group.length > 0) { + blocks.push(); + group = []; + } + }; + for (const turn of turns) { + if (isPromptTurn(turn)) { + flush(); + blocks.push(); + } else { + group.push(turn); + } + } + flush(); + return blocks; +} + +export default function SessionDetail({ session, turns, loading }: { session: Session; turns: Turn[]; loading: boolean }) { + return ( +
+
+ +
+ + {session.filesModified.length > 0 && } {loading ? (
Loading turns...
) : turns.length === 0 ? (
No turns recorded for this session.
) : ( -
- {turns.map(turn => ( - - ))} +
+ {conversationBlocks(turns, session.cwd)}
)}
diff --git a/webview-ui/src/components/__tests__/SessionDetail.test.tsx b/webview-ui/src/components/__tests__/SessionDetail.test.tsx index c448334..68f1e33 100644 --- a/webview-ui/src/components/__tests__/SessionDetail.test.tsx +++ b/webview-ui/src/components/__tests__/SessionDetail.test.tsx @@ -90,7 +90,7 @@ describe('SessionDetail', () => { expect(chip.closest('span[title]')).toHaveAttribute('title', '/home/user/project/docs/plan.md'); }); - it('truncates very long turn content behind a show-full toggle', () => { + it('defaults long messages to a short preview with a show-more toggle', () => { const longContent = 'word '.repeat(2000).trim(); // ~10k chars const session = makeSession({ turns: [makeTurn({ role: 'user', content: longContent, timestamp: 1 })], @@ -98,7 +98,7 @@ describe('SessionDetail', () => { render(); - const toggle = screen.getByText(`Show full message (${longContent.length.toLocaleString()} chars)`); + const toggle = screen.getByText(`Show more (${(longContent.length - 300).toLocaleString()} more chars)`); expect(toggle).toBeInTheDocument(); fireEvent.click(toggle); @@ -131,8 +131,7 @@ describe('SessionDetail', () => { expect(screen.getByText('Data Viz')).toBeInTheDocument(); }); - it('renders cache, thinking, subagent cost, collapsed previews, and tool-only assistant turns', () => { - const longContent = 'A'.repeat(140); + it('renders cache, thinking, subagent cost, and tool call timeline rows', () => { const session = makeSession({ model: 'claude-opus-4', cacheReadTokens: 300, @@ -152,10 +151,10 @@ describe('SessionDetail', () => { }), makeTurn({ role: 'assistant', - content: longContent, + content: 'Reading the file now.', inputTokens: 5, outputTokens: 0, - toolCalls: [makeToolCall({ name: 'Read', input: { file_path: '/tmp/file.ts' } })], + toolCalls: [makeToolCall({ name: 'WebSearch', input: { query: 'react hooks' } })], timestamp: 2, }), ], @@ -171,9 +170,101 @@ describe('SessionDetail', () => { expect(screen.getByText('Copy resume command')).toBeInTheDocument(); expect(screen.getByText('github/search')).toBeInTheDocument(); expect(screen.getByText('repo:foo bug')).toBeInTheDocument(); - expect(screen.getByText('10↑ 20↓')).toBeInTheDocument(); + // consecutive assistant turns aggregate into one group token footer + expect(screen.getByText('15↑ 20↓')).toBeInTheDocument(); + // camelCase tool names get the extension-style spaced label + expect(screen.getByText('Web Search')).toBeInTheDocument(); + expect(screen.getByText('Reading the file now.')).toBeInTheDocument(); + }); + + it('shows tool file paths relative to the project root', () => { + const session = makeSession({ + cwd: '/home/user/project', + turns: [ + makeTurn({ + role: 'assistant', + content: '', + toolCalls: [ + makeToolCall({ name: 'Read', input: { file_path: '/home/user/project/src/index.ts' } }), + makeToolCall({ name: 'Edit', input: { file_path: '/etc/hosts' } }), + ], + timestamp: 1, + }), + ], + }); + + render(); + + expect(screen.getByText('src/index.ts')).toBeInTheDocument(); + // files outside the project keep the full path + expect(screen.getByText('/etc/hosts')).toBeInTheDocument(); + }); + + it('copies the session ID and shows the failed state when the clipboard rejects', async () => { + const session = makeSession({ pricingConfidence: 'fallback', turns: [] }); + render(); + + // fallback pricing is flagged with an asterisk + expect(screen.getByText(/est\.\*/)).toBeInTheDocument(); + + const chip = screen.getByLabelText('Copy session ID'); + await act(async () => { + fireEvent.click(chip); + }); + await waitFor(() => expect(screen.getByText('copied')).toBeInTheDocument()); + + vi.mocked(navigator.clipboard.writeText).mockRejectedValueOnce(new Error('denied')); + await act(async () => { + fireEvent.click(chip); + }); + await waitFor(() => expect(screen.getByText('failed')).toBeInTheDocument()); + }); + + it('renders a collapsed Thought row that expands to the thinking text', () => { + const session = makeSession({ + turns: [ + makeTurn({ + role: 'assistant', + content: 'Answer.', + thinking: 'Let me reason about this problem first.', + timestamp: 1, + }), + ], + }); + + render(); + + expect(screen.getByText('Thought')).toBeInTheDocument(); + expect(screen.queryByText('Let me reason about this problem first.')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Thought')); + expect(screen.getByText('Let me reason about this problem first.')).toBeInTheDocument(); + }); + + it('renders tool outputs in an OUT box with expand for long output', () => { + const longOutput = 'line '.repeat(100).trim(); // ~600 chars + const session = makeSession({ + turns: [ + makeTurn({ + role: 'assistant', + content: '', + toolCalls: [ + makeToolCall({ name: 'Bash', input: { command: 'ls' }, output: 'file1.ts\nfile2.ts' }), + makeToolCall({ name: 'Grep', input: { pattern: 'foo' }, output: longOutput }), + ], + timestamp: 1, + }), + ], + }); + + render(); + + expect(screen.getAllByText('OUT')).toHaveLength(2); + expect(screen.getByText(/file1\.ts/)).toBeInTheDocument(); - fireEvent.click(screen.getAllByTitle('Collapse')[0]); - expect(screen.getByText(`${longContent.slice(0, 120)}…`)).toBeInTheDocument(); + // long output is truncated until clicked + expect(screen.queryByText(longOutput)).not.toBeInTheDocument(); + fireEvent.click(screen.getByTitle('Show full output')); + expect(screen.getByText(longOutput)).toBeInTheDocument(); }); }); diff --git a/webview-ui/src/components/__tests__/conversation.test.tsx b/webview-ui/src/components/__tests__/conversation.test.tsx new file mode 100644 index 0000000..03ce3fe --- /dev/null +++ b/webview-ui/src/components/__tests__/conversation.test.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { fireEvent, render, screen } from '../../__tests__/helpers/render-helpers'; +import { makeToolCall, makeTurn } from '../../__tests__/fixtures/test-data'; +import { ConversationTurn } from '../conversation/ConversationTurn'; +import { SystemEventRow } from '../conversation/SystemEventRow'; +import { AgentCallBlock } from '../conversation/AgentCallBlock'; +import { ResponseGroup } from '../conversation/ResponseGroup'; +import { ToolCallRow, relativizeHint, toolDisplayName, toolHint } from '../conversation/ToolCallRow'; + +describe('conversation components', () => { + it('renders nothing for empty turns and caveat-only user turns', () => { + const { container: empty } = render( + , + ); + expect(empty).toBeEmptyDOMElement(); + + const { container: caveat } = render( + note', timestamp: 1 })} />, + ); + expect(caveat).toBeEmptyDOMElement(); + }); + + it('renders an attachments-only user turn as a prompt card', () => { + render( + , + ); + expect(screen.getByText('a.md')).toBeInTheDocument(); + }); + + it('renders command rows without args and nothing for unknown event kinds', () => { + render(clear', timestamp: 1 })} />); + expect(screen.getByText('clear')).toBeInTheDocument(); + + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('collapses agent blocks to a preview and handles missing prompts', () => { + const prompt = 'P'.repeat(140); + render(); + fireEvent.click(screen.getByTitle('Collapse')); + expect(screen.getByText(`${prompt.slice(0, 120)}…`)).toBeInTheDocument(); + fireEvent.click(screen.getByTitle('Expand')); + expect(screen.getByText(prompt)).toBeInTheDocument(); + + render(); + expect(screen.getAllByText('subagent').length).toBeGreaterThan(0); + }); + + it('derives tool labels, input hints, and relative paths', () => { + expect(toolDisplayName('WebSearch')).toBe('Web Search'); + expect(toolDisplayName('Bash')).toBe('Bash'); + expect(toolDisplayName('mcp__github__search')).toBe('github/search'); + expect(toolHint(makeToolCall({ input: { url: 'https://example.com' } }))).toBe('https://example.com'); + expect(toolHint(makeToolCall({ input: {} }))).toBe(''); + + expect(relativizeHint('/repo/src/a.ts', '/repo')).toBe('src/a.ts'); + expect(relativizeHint('/elsewhere/b.ts', '/repo')).toBe('/elsewhere/b.ts'); + expect(relativizeHint('/repo/src/a.ts', null)).toBe('/repo/src/a.ts'); + + render(); + expect(screen.getByText('Web Fetch')).toBeInTheDocument(); + expect(screen.getByText('https://example.com')).toBeInTheDocument(); + }); + + it('renders nothing for a response group with no visible rows', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows short tool output without an expand affordance', () => { + render(); + expect(screen.getByText('OUT')).toBeInTheDocument(); + expect(screen.getByText('ok')).toBeInTheDocument(); + expect(screen.queryByTitle('Show full output')).not.toBeInTheDocument(); + }); +}); diff --git a/webview-ui/src/components/conversation/AgentCallBlock.tsx b/webview-ui/src/components/conversation/AgentCallBlock.tsx new file mode 100644 index 0000000..2762247 --- /dev/null +++ b/webview-ui/src/components/conversation/AgentCallBlock.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { ToolCall } from '../../types'; +import { MarkdownView } from '../MarkdownView'; +import { CopyButton } from './shared'; + +export function AgentCallBlock({ tc }: { tc: ToolCall }) { + const [collapsed, setCollapsed] = React.useState(false); + const prompt = (tc.input?.prompt as string | undefined)?.trim() ?? ''; + return ( +
+
+ Agent + subagent +
+ {prompt && } + +
+
+ {!collapsed && prompt && ( +
+ +
+ )} + {collapsed && prompt && ( +
{prompt.slice(0, 120)}{prompt.length > 120 ? '…' : ''}
+ )} +
+ ); +} diff --git a/webview-ui/src/components/conversation/ConversationTurn.tsx b/webview-ui/src/components/conversation/ConversationTurn.tsx new file mode 100644 index 0000000..02f7182 --- /dev/null +++ b/webview-ui/src/components/conversation/ConversationTurn.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Turn } from '../../types'; +import { parseSystemContent } from './systemEvents'; +import { UserMessageCard } from './UserMessageCard'; +import { ResponseGroup } from './ResponseGroup'; + +// A real user prompt (typed or attachments-only) — everything else, including +// system-injected "user" turns, belongs on the response timeline. +export function isPromptTurn(turn: Turn): boolean { + if (turn.role !== 'user') { return false; } + const content = turn.content?.trim() ?? ''; + if (!content) { return (turn.attachments?.length ?? 0) > 0; } + return parseSystemContent(content) === null; +} + +// Renders one turn standalone: the user prompt card, or a timeline group of +// one. Grouping upstream (SessionDetail) merges consecutive non-prompt turns +// into a single ResponseGroup so the connector line runs unbroken. +export function ConversationTurn({ turn, projectRoot }: { turn: Turn; projectRoot?: string | null }) { + if (isPromptTurn(turn)) { return ; } + return ; +} diff --git a/webview-ui/src/components/conversation/ResponseGroup.tsx b/webview-ui/src/components/conversation/ResponseGroup.tsx new file mode 100644 index 0000000..188c44f --- /dev/null +++ b/webview-ui/src/components/conversation/ResponseGroup.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Turn } from '../../types'; +import { formatTokens } from '../../utils/format'; +import { toolColor } from '../../utils/toolColor'; +import { CopyButton, ExpandableMarkdown, TimelineRow } from './shared'; +import { parseSystemContent } from './systemEvents'; +import { SystemEventRow } from './SystemEventRow'; +import { SkillContextRow } from './SkillContextRow'; +import { ThinkingRow } from './ThinkingRow'; +import { ToolCallRow } from './ToolCallRow'; +import { AgentCallBlock } from './AgentCallBlock'; + +function AssistantMessageBody({ content }: { content: string }) { + return ( +
+
+ +
+ +
+ ); +} + +// Everything between two user prompts rendered as one continuous timeline — +// thought, text, tool, and system-event rows all joined by a single connector +// line, with one aggregate token footer at the end. +export function ResponseGroup({ turns, projectRoot }: { turns: Turn[]; projectRoot?: string | null }) { + const rows: { key: string; color?: string; node: React.ReactNode }[] = []; + let totalIn = 0; + let totalOut = 0; + + for (const turn of turns) { + const content = turn.content?.trim() ?? ''; + + // System-injected "user" turns (slash commands, stdout, skill payloads) + // ride the same timeline as muted rows. + if (turn.role === 'user') { + const sys = content ? parseSystemContent(content) : null; + if (!sys || sys === 'skip') { continue; } + rows.push({ + key: turn.id, + node: sys.kind === 'skill' ? : , + }); + continue; + } + + totalIn += turn.inputTokens; + totalOut += turn.outputTokens; + if (turn.thinking) { + rows.push({ key: `${turn.id}-thinking`, node: }); + } + if (content) { + rows.push({ key: `${turn.id}-text`, node: }); + } + for (const tc of turn.toolCalls) { + rows.push({ + key: tc.id, + color: toolColor(tc.name), + node: tc.name === 'Agent' + ? + : , + }); + } + } + + if (rows.length === 0) { return null; } + + return ( +
+ {rows.map((row, i) => ( + + {row.node} + + ))} + {totalOut > 0 && ( +
{formatTokens(totalIn)}↑ {formatTokens(totalOut)}↓
+ )} +
+ ); +} diff --git a/webview-ui/src/components/conversation/SkillContextRow.tsx b/webview-ui/src/components/conversation/SkillContextRow.tsx new file mode 100644 index 0000000..ee924c2 --- /dev/null +++ b/webview-ui/src/components/conversation/SkillContextRow.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { MarkdownView } from '../MarkdownView'; +import { SystemEvent } from './systemEvents'; +import { CONTENT_RENDER_CAP } from './shared'; + +// Skill instructions injected as a "user" turn — rendered as collapsed context. +export function SkillContextRow({ event }: { event: Extract }) { + const [expanded, setExpanded] = React.useState(false); + const body = event.body; + const shown = body.length > CONTENT_RENDER_CAP ? body.slice(0, CONTENT_RENDER_CAP) : body; + const hidden = body.length - shown.length; + return ( +
+ + {expanded && ( +
+ + {hidden > 0 && ( +
+ {hidden.toLocaleString()} more chars hidden — open the session file to see the full skill. +
+ )} +
+ )} +
+ ); +} diff --git a/webview-ui/src/components/conversation/SystemEventRow.tsx b/webview-ui/src/components/conversation/SystemEventRow.tsx new file mode 100644 index 0000000..609f94e --- /dev/null +++ b/webview-ui/src/components/conversation/SystemEventRow.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { SystemEvent } from './systemEvents'; + +export function SystemEventRow({ event }: { event: SystemEvent }) { + if (event.kind === 'command') { + return ( +
+ + + + + {event.name} + {event.args && {event.args}} +
+ ); + } + if (event.kind === 'stdout') { + return ( +
+ + + + {event.text} +
+ ); + } + return null; +} diff --git a/webview-ui/src/components/conversation/ThinkingRow.tsx b/webview-ui/src/components/conversation/ThinkingRow.tsx new file mode 100644 index 0000000..7ec1f50 --- /dev/null +++ b/webview-ui/src/components/conversation/ThinkingRow.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { MarkdownView } from '../MarkdownView'; + +// Collapsed "Thought" row, like the extension's "Thought for Ns" — the JSONL +// doesn't record thinking duration, so the label stays plain. +export function ThinkingRow({ thinking }: { thinking: string }) { + const [expanded, setExpanded] = React.useState(false); + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ); +} diff --git a/webview-ui/src/components/conversation/ToolCallRow.tsx b/webview-ui/src/components/conversation/ToolCallRow.tsx new file mode 100644 index 0000000..ddf8edd --- /dev/null +++ b/webview-ui/src/components/conversation/ToolCallRow.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { ToolCall } from '../../types'; +import { toolColor } from '../../utils/toolColor'; + +export function toolDisplayName(name: string): string { + if (name.startsWith('mcp__')) { return name.slice(5).replace('__', '/'); } + // "WebSearch" → "Web Search", matching the extension's labels + return name.replace(/([a-z])([A-Z])/g, '$1 $2'); +} + +export function toolHint(tc: ToolCall): string { + return (tc.input?.file_path as string | undefined) + ?? (tc.input?.command as string | undefined) + ?? (tc.input?.pattern as string | undefined) + ?? (tc.input?.query as string | undefined) + ?? (tc.input?.url as string | undefined) + ?? ''; +} + +// Paths inside the project read better relative to its root; anything outside +// the project keeps the full path. +export function relativizeHint(hint: string, projectRoot?: string | null): string { + if (projectRoot && hint.startsWith(projectRoot + '/')) { + return hint.slice(projectRoot.length + 1); + } + return hint; +} + +const OUTPUT_PREVIEW_LIMIT = 240; + +function ToolOutputBox({ output }: { output: string }) { + const [expanded, setExpanded] = React.useState(false); + const isLong = output.length > OUTPUT_PREVIEW_LIMIT; + const shown = expanded || !isLong ? output : output.slice(0, OUTPUT_PREVIEW_LIMIT) + '…'; + return ( + + ); +} + +export function ToolCallRow({ tc, projectRoot }: { tc: ToolCall; projectRoot?: string | null }) { + const hint = relativizeHint(toolHint(tc), projectRoot); + const output = tc.output?.trim() ?? ''; + return ( +
+
+ + {toolDisplayName(tc.name)} + + {hint && {hint}} +
+ {output && } +
+ ); +} diff --git a/webview-ui/src/components/conversation/UserMessageCard.tsx b/webview-ui/src/components/conversation/UserMessageCard.tsx new file mode 100644 index 0000000..28d93b6 --- /dev/null +++ b/webview-ui/src/components/conversation/UserMessageCard.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Turn, TurnAttachment } from '../../types'; +import { CopyButton, ExpandableMarkdown } from './shared'; + +function AttachmentChips({ attachments }: { attachments: TurnAttachment[] }) { + return ( +
+ {attachments.map(a => ( + + + {a.displayPath || a.path} + + ))} +
+ ); +} + +// The user's prompt: right-indented card with the accent left border and +// "You" header. +export function UserMessageCard({ turn }: { turn: Turn }) { + const content = turn.content?.trim() ?? ''; + const attachments = turn.attachments ?? []; + return ( +
+
+
+ + + You + + {content && ( +
+ +
+ )} +
+ {attachments.length > 0 && } + {content && ( +
+ +
+ )} +
+
+ ); +} diff --git a/webview-ui/src/components/conversation/shared.tsx b/webview-ui/src/components/conversation/shared.tsx new file mode 100644 index 0000000..18be0db --- /dev/null +++ b/webview-ui/src/components/conversation/shared.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { MarkdownView } from '../MarkdownView'; + +// Messages default to a short "show less" preview; expanding renders up to the +// hard cap — injected context can be hundreds of KB, which explodes into +// thousands of DOM nodes and freezes the webview. +export const CONTENT_PREVIEW_LIMIT = 300; +export const CONTENT_LONG_THRESHOLD = 400; +export const CONTENT_RENDER_CAP = 40000; + +export function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = React.useState(false); + const copy = () => { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + return ( + + ); +} + +// One event in the response timeline: a gutter dot + row content, with a +// vertical connector line running down to the next row. `dotColor` marks tool +// rows with their tool color; without it the dot renders muted (thought/text). +export function TimelineRow({ dotColor, last = false, children }: { + dotColor?: string; + last?: boolean; + children: React.ReactNode; +}) { + return ( +
+
+
+
{children}
+
+ ); +} + +export function ExpandableMarkdown({ content, highlightMentions = false }: { content: string; highlightMentions?: boolean }) { + const [showFull, setShowFull] = React.useState(false); + const isLong = content.length > CONTENT_LONG_THRESHOLD; + const shown = !isLong + ? content + : showFull + ? content.slice(0, CONTENT_RENDER_CAP) + : content.slice(0, CONTENT_PREVIEW_LIMIT); + const cappedHidden = isLong && showFull ? content.length - CONTENT_RENDER_CAP : 0; + return ( + <> + + {isLong && ( +
+ + {cappedHidden > 0 && ( + + {cappedHidden.toLocaleString()} more chars hidden — use copy to get the full text + + )} +
+ )} + + ); +} diff --git a/webview-ui/src/components/conversation/systemEvents.ts b/webview-ui/src/components/conversation/systemEvents.ts new file mode 100644 index 0000000..41d45bf --- /dev/null +++ b/webview-ui/src/components/conversation/systemEvents.ts @@ -0,0 +1,42 @@ +// Parsing of system-injected "user" messages (slash commands, stdout echoes, +// skill instruction payloads) so they render as context rows, not prompts. + +export type SystemEvent = + | { kind: 'command'; name: string; args?: string } + | { kind: 'stdout'; text: string } + | { kind: 'skill'; name: string; body: string }; + +// Loading a skill injects its full instructions as a "user" message that begins +// with this marker. These can be hundreds of KB — surface them as collapsed +// context rather than a user turn. +const SKILL_INJECTION_PREFIX = 'Base directory for this skill:'; + +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; +export function stripAnsi(s: string): string { return s.replace(ANSI_RE, ''); } + +export function parseSystemContent(content: string): SystemEvent | 'skip' | null { + const t = content.trim(); + if (//i.test(t) && !t.replace(/[\s\S]*?<\/local-command-caveat>/gi, '').trim()) { + return 'skip'; + } + if (t.startsWith(SKILL_INJECTION_PREFIX)) { + const nl = t.indexOf('\n'); + const firstLine = nl === -1 ? t : t.slice(0, nl); + const dir = firstLine.slice(SKILL_INJECTION_PREFIX.length).trim(); + const name = dir.split('/').filter(Boolean).pop() || 'skill'; + return { kind: 'skill', name, body: t.slice(firstLine.length).trim() }; + } + const cmdMatch = t.match(/([^<]+)<\/command-name>/); + if (cmdMatch) { + const argsMatch = t.match(/([\s\S]*?)<\/command-args>/); + const args = argsMatch?.[1]?.trim() || undefined; + return { kind: 'command', name: cmdMatch[1].trim(), args }; + } + const stdoutMatch = t.match(/<(?:local-command-stdout|command-stdout)>([\s\S]*?)<\/(?:local-command-stdout|command-stdout)>/); + if (stdoutMatch) { + const text = stripAnsi(stdoutMatch[1].trim()); + return text ? { kind: 'stdout', text } : 'skip'; + } + return null; +} diff --git a/webview-ui/src/types.ts b/webview-ui/src/types.ts index 266d01b..d7ac4f4 100644 --- a/webview-ui/src/types.ts +++ b/webview-ui/src/types.ts @@ -51,6 +51,7 @@ export interface Turn { toolCalls: ToolCall[]; timestamp: number; attachments?: TurnAttachment[]; // files the user @-tagged in this prompt + thinking?: string; // extended-thinking text emitted before this turn's content } export interface TurnAttachment {