diff --git a/src/core/parser-claude.test.ts b/src/core/parser-claude.test.ts index 588b33bf..3c891219 100644 --- a/src/core/parser-claude.test.ts +++ b/src/core/parser-claude.test.ts @@ -13,7 +13,7 @@ import * as path from 'path'; import { execSync } from 'child_process'; import { describe, it, expect } from 'vitest'; import { EditLocIndex } from './edit-loc-diff'; -import { parseClaudeSessions } from './parser-claude'; +import { parseClaudeSessions, extractClaudeImagesFromJsonl } from './parser-claude'; /** os.tmpdir() on Windows often returns 8.3 short names (e.g. TAMASB~1) * that don't match readdirSync output. Resolve to the long form so @@ -594,3 +594,46 @@ describe('parseClaudeSessions', () => { fs.rmSync(cwd, { recursive: true, force: true }); }); }); + +describe('extractClaudeImagesFromJsonl', () => { + it('extracts base64 images from a Claude user turn by request id', () => { + const png = 'iVBORw0KGgoAAAANSUhEUg=='; + const line = JSON.stringify({ + type: 'user', + timestamp: '2025-06-15T10:00:00Z', + sessionId: 'sess-1', + uuid: 'req-with-image', + message: { + role: 'user', + content: [ + { type: 'text', text: 'what is wrong here?' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: png } }, + ], + }, + }); + const other = JSON.stringify({ type: 'user', uuid: 'other-req', message: { role: 'user', content: [] } }); + + const images = extractClaudeImagesFromJsonl([other, line].join('\n'), 'req-with-image'); + expect(images).toEqual([`data:image/png;base64,${png}`]); + }); + + it('returns no images for a request id that has none', () => { + const line = JSON.stringify({ + type: 'user', + uuid: 'text-only', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + }); + expect(extractClaudeImagesFromJsonl(line, 'text-only')).toEqual([]); + expect(extractClaudeImagesFromJsonl(line, 'missing-id')).toEqual([]); + }); + + it('caps extracted images at four per request', () => { + const img = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }; + const line = JSON.stringify({ + type: 'user', + uuid: 'many-images', + message: { role: 'user', content: [img, img, img, img, img, img] }, + }); + expect(extractClaudeImagesFromJsonl(line, 'many-images')).toHaveLength(4); + }); +}); diff --git a/src/core/parser-claude.ts b/src/core/parser-claude.ts index 73e0ab1f..e4372d16 100644 --- a/src/core/parser-claude.ts +++ b/src/core/parser-claude.ts @@ -230,6 +230,39 @@ function countClaudeImages(line: ClaudeLine): number { .filter(block => block.type === 'image').length; } +/** + * Extract inline screenshots for one Claude request. Claude Code stores images + * as base64 inside the user turn, and the request id is the entry uuid, so the + * matching line can be found directly. Capped at 4 images to bound memory, the + * same limit the VS Code extractor uses. + */ +export function extractClaudeImagesFromJsonl(raw: string, requestId: string): string[] { + const images: string[] = []; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + // Quick reject before the parse: most lines are not the one we want. + if (!trimmed || !trimmed.includes(requestId)) continue; + + let entry: ClaudeLine; + try { + entry = JSON.parse(trimmed) as ClaudeLine; + } catch { + continue; + } + if (entry.uuid !== requestId) continue; + + for (const block of toContentArray(entry.message?.content)) { + if (block.type !== 'image') continue; + const source = (block as { source?: { type?: string; media_type?: string; data?: string } }).source; + if (source?.type !== 'base64' || !source.data) continue; + images.push(`data:${source.media_type || 'image/png'};base64,${source.data}`); + if (images.length >= 4) return images; + } + return images; + } + return images; +} + function applyClaudeToolBlock( block: ClaudeContentBlock, data: Pick, @@ -467,11 +500,20 @@ function projectNameFromEncoded(encoded: string, _projectsDir: string): string { return path.basename(resolved); } +/** One Claude project directory's sessions, plus the file each came from. */ +export interface ClaudeProjectResult { + sessions: Session[]; + workspaceId: string; + workspaceName: string; + /** sessionId -> absolute path of the .jsonl the session was parsed from. */ + sessionFiles: Map; +} + function parseClaudeProjectSessions( projectsDir: string, dirName: string, editLocIndex?: EditLocIndex, -): { sessions: Session[]; workspaceId: string; workspaceName: string } | null { +): ClaudeProjectResult | null { const projPath = path.join(projectsDir, dirName); const workspaceId = `claude-${dirName}`; const workspaceName = projectNameFromEncoded(dirName, projectsDir); @@ -488,12 +530,17 @@ function parseClaudeProjectSessions( // can be merged into the right parent in pass 2. const sessionsById = new Map(); const sessions: Session[] = []; + // sessionId -> originating file, so the image gallery can read screenshots + // back out of the raw log. + const sessionFiles = new Map(); for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; - const session = parseClaudeSessionFile(path.join(projPath, entry.name), workspaceId, workspaceName, editLocIndex); + const filePath = path.join(projPath, entry.name); + const session = parseClaudeSessionFile(filePath, workspaceId, workspaceName, editLocIndex); if (!session) continue; sessions.push(session); sessionsById.set(session.sessionId, session); + sessionFiles.set(session.sessionId, filePath); } // Pass 2: walk `/subagents/agent-*.jsonl` directories. Each @@ -580,14 +627,14 @@ function parseClaudeProjectSessions( session.requestCount = session.requests.length; } - return sessions.length > 0 ? { sessions, workspaceId, workspaceName } : null; + return sessions.length > 0 ? { sessions, workspaceId, workspaceName, sessionFiles } : null; } export function parseClaudeSessions( projectsDir: string, editLocIndex?: EditLocIndex, -): { sessions: Session[]; workspaceId: string; workspaceName: string }[] { - const results: { sessions: Session[]; workspaceId: string; workspaceName: string }[] = []; +): ClaudeProjectResult[] { + const results: ClaudeProjectResult[] = []; let projectDirs: fs.Dirent[]; try { @@ -608,8 +655,8 @@ export async function parseClaudeSessionsAsync( projectsDir: string, onProject?: (idx: number, total: number, name: string) => void, editLocIndex?: EditLocIndex, -): Promise<{ sessions: Session[]; workspaceId: string; workspaceName: string }[]> { - const results: { sessions: Session[]; workspaceId: string; workspaceName: string }[] = []; +): Promise { + const results: ClaudeProjectResult[] = []; let projectDirs: string[]; try { diff --git a/src/core/parser-harnesses.ts b/src/core/parser-harnesses.ts index 17714319..b30cbee7 100644 --- a/src/core/parser-harnesses.ts +++ b/src/core/parser-harnesses.ts @@ -11,6 +11,7 @@ import { findClaudeDirs, parseClaudeSessions, parseClaudeSessionsAsync } from '. import { findCodexDirs, parseCodexSessions } from './parser-codex'; import { findOpenCodeDirs, parseOpenCodeSessions } from './parser-opencode'; import { EditLocIndex } from './edit-loc-diff'; +import { SessionSource } from './cache'; type WorkspaceMap = Map; @@ -18,6 +19,28 @@ interface HarnessCollectionContext { workspaces: WorkspaceMap; sessions: Session[]; editLocIndex: EditLocIndex; + /** Lets the image gallery read raw screenshots back out of a session file. */ + sessionSourceIndex?: Map; +} + +/** Register where each session was parsed from, when the caller tracks it. */ +function recordSources( + ctx: HarnessCollectionContext, + result: { sessions: Session[]; workspaceId: string; workspaceName: string; sessionFiles: Map }, + harness: string, +): void { + if (!ctx.sessionSourceIndex) return; + for (const session of result.sessions) { + const filePath = result.sessionFiles.get(session.sessionId); + if (!filePath) continue; + ctx.sessionSourceIndex.set(session.sessionId, { + kind: 'cli-events', + filePath, + workspaceId: result.workspaceId, + workspaceName: result.workspaceName, + harness, + }); + } } interface ExternalHarnessCollector { @@ -39,8 +62,9 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [ name: 'Claude Code', collectSync(ctx) { for (const claudeDir of findClaudeDirs()) { - for (const { sessions } of parseClaudeSessions(claudeDir, ctx.editLocIndex)) { - for (const session of sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir); + for (const result of parseClaudeSessions(claudeDir, ctx.editLocIndex)) { + for (const session of result.sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir); + recordSources(ctx, result, 'Claude'); } } }, @@ -49,8 +73,9 @@ const EXTERNAL_HARNESSES: ExternalHarnessCollector[] = [ const results = await parseClaudeSessionsAsync(claudeDir, (idx, total, name) => { reportDetail?.(`${idx}/${total}: ${name}`); }, ctx.editLocIndex); - for (const { sessions } of results) { - for (const session of sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir); + for (const result of results) { + for (const session of result.sessions) addSession(ctx.workspaces, ctx.sessions, session, claudeDir); + recordSources(ctx, result, 'Claude'); } } }, @@ -97,8 +122,9 @@ export function collectExternalHarnessesSync( workspaces: WorkspaceMap, sessions: Session[], editLocIndex: EditLocIndex, + sessionSourceIndex?: Map, ): void { - const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex }; + const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex, sessionSourceIndex }; for (const harness of EXTERNAL_HARNESSES) { harness.collectSync(ctx); } @@ -119,8 +145,9 @@ export async function collectExternalHarnessesAsync( sessions: Session[], editLocIndex: EditLocIndex, handlers: ExternalHarnessProgressHandlers = {}, + sessionSourceIndex?: Map, ): Promise { - const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex }; + const ctx: HarnessCollectionContext = { workspaces, sessions, editLocIndex, sessionSourceIndex }; const total = EXTERNAL_HARNESSES.length; for (let index = 0; index < EXTERNAL_HARNESSES.length; index++) { diff --git a/src/core/parser-main.test.ts b/src/core/parser-main.test.ts index 9cb1cad5..e57f8ba8 100644 --- a/src/core/parser-main.test.ts +++ b/src/core/parser-main.test.ts @@ -235,6 +235,7 @@ describe('parseAllLogs', () => { result.workspaces, result.sessions, result.editLocIndex, + result.sessionSourceIndex, ); }); @@ -293,6 +294,7 @@ describe('parseAllLogsAsyncDetailed', () => { cachedResult.sessions, cachedResult.editLocIndex, expect.any(Object), + cachedResult.sessionSourceIndex, ); }); @@ -435,6 +437,7 @@ describe('parseAllLogsAsyncDetailed', () => { parsed.result.sessions, parsed.result.editLocIndex, expect.any(Object), + parsed.result.sessionSourceIndex, ); }); diff --git a/src/core/parser-vscode-files.ts b/src/core/parser-vscode-files.ts index 06a2e578..08aaaf46 100644 --- a/src/core/parser-vscode-files.ts +++ b/src/core/parser-vscode-files.ts @@ -10,6 +10,7 @@ import { StringDecoder } from 'string_decoder'; import { assertTrustedPath, prefetchCache, readFileSafe, recordFailedFile, recordSkippedLines } from './parser-shared'; import { fileUriToPath } from './helpers'; import { debugCore, warnCore } from './log'; +import { extractClaudeImagesFromJsonl } from './parser-claude'; export function readFile(fpath: string): string { assertTrustedPath(fpath); @@ -480,7 +481,10 @@ export function extractSessionImages(filePath: string, requestId: string): strin if (raw === null) return []; if (filePath.endsWith('.jsonl')) { - return extractImagesFromJsonl(raw, requestId); + const images = extractImagesFromJsonl(raw, requestId); + // Claude Code also uses .jsonl but stores images as base64 in the user + // turn rather than in VS Code's variableData shape. + return images.length > 0 ? images : extractClaudeImagesFromJsonl(raw, requestId); } return extractImagesFromJson(raw, requestId); } catch (e) { diff --git a/src/core/parser.ts b/src/core/parser.ts index ab23988f..3bf9107f 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -348,7 +348,7 @@ async function tryMemoryCache( removeRefreshableExternalEditLocs(mem.result.sessions, mem.result.editLocIndex); mem.result.sessions = mem.result.sessions.filter(s => !EXTERNAL_HARNESS_SET.has(s.harness)); await collectVolatileHarnesses(logsDirs, mem.result, onProgress); - await collectExternalHarnesses(mem.result.workspaces, mem.result.sessions, mem.result.editLocIndex, onProgress); + await collectExternalHarnesses(mem.result.workspaces, mem.result.sessions, mem.result.editLocIndex, onProgress, mem.result.sessionSourceIndex); report({ phase: 1, detail: 'Loaded from memory', pct: pct(1, 1), sessions: mem.result.sessions.length, @@ -373,7 +373,7 @@ async function tryDiskCache( removeRefreshableExternalEditLocs(cached.result.sessions, cached.result.editLocIndex); cached.result.sessions = cached.result.sessions.filter(s => !EXTERNAL_HARNESS_SET.has(s.harness)); await collectVolatileHarnesses(logsDirs, cached.result, onProgress); - await collectExternalHarnesses(cached.result.workspaces, cached.result.sessions, cached.result.editLocIndex, onProgress); + await collectExternalHarnesses(cached.result.workspaces, cached.result.sessions, cached.result.editLocIndex, onProgress, cached.result.sessionSourceIndex); setMemoryCache(cached.result, currentMetas); report({ phase: 1, detail: 'Loaded from cache', pct: pct(1, 1), @@ -580,6 +580,7 @@ async function collectExternalHarnesses( sessions: import('./types').Session[], editLocIndex: EditLocIndex, onProgress?: ProgressCallback, + sessionSourceIndex?: Map, ): Promise { await collectExternalHarnessesAsync(workspaces, sessions, editLocIndex, { onHarnessStart: (name, index, total, sessionCount) => { @@ -592,7 +593,7 @@ async function collectExternalHarnesses( warnCore('parser', `${name} scan failed`, error); }, yieldToLoop, - }); + }, sessionSourceIndex); } export function parseAllLogs(logsDirs: string[]): ParseResult { @@ -619,7 +620,7 @@ export function parseAllLogs(logsDirs: string[]): ParseResult { } } - collectExternalHarnessesSync(workspaces, sessions, editLocIndex); + collectExternalHarnessesSync(workspaces, sessions, editLocIndex, sessionSourceIndex); stripSessionsForMemory(sessions); return { workspaces, sessions, editLocIndex, sessionSourceIndex }; @@ -737,7 +738,7 @@ export async function parseAllLogsAsyncDetailed( editLocIndex, sessionSourceIndex: freshSessionSourceIndex, }, onProgress); - await collectExternalHarnesses(workspaces, freshSessions, editLocIndex, onProgress); + await collectExternalHarnesses(workspaces, freshSessions, editLocIndex, onProgress, freshSessionSourceIndex); const result: ParseResult = { workspaces, sessions: freshSessions, editLocIndex, sessionSourceIndex: freshSessionSourceIndex }; stripSessionsForMemory(result.sessions); @@ -760,7 +761,7 @@ export async function parseAllLogsAsyncDetailed( await processWorkspaces(entries, totalDirs, ctx, onProgress); await collectXcode(xcodeDirs, workspaces, sessions, editLocIndex, onProgress); - await collectExternalHarnesses(workspaces, sessions, editLocIndex, onProgress); + await collectExternalHarnesses(workspaces, sessions, editLocIndex, onProgress, sessionSourceIndex); const result: ParseResult = { workspaces, sessions, editLocIndex, sessionSourceIndex }; stripSessionsForMemory(result.sessions);