Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 74 additions & 0 deletions src/__tests__/fixtures/jsonl-samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,80 @@ export const MINIMAL_SESSION = [
}),
].join('\n');

export const SESSION_WITH_IDE_TAGS = [
JSON.stringify({
type: 'user',
uuid: 'u1',
timestamp: '2025-01-15T10:00:00Z',
cwd: '/home/user/project',
message: {
content:
'<ide_opened_file>The user opened the file /home/user/project/package.json in the IDE.</ide_opened_file>' +
'<ide_selection>The user selected lines 1 to 3</ide_selection>Fix the build script',
},
}),
JSON.stringify({
type: 'assistant',
uuid: 'a1',
timestamp: '2025-01-15T10:01:00Z',
message: {
model: 'claude-sonnet-4',
content: [{ type: 'text', text: 'On it.' }],
usage: { input_tokens: 10, output_tokens: 5 },
stop_reason: 'end_turn',
},
}),
].join('\n');

export const SESSION_WITH_ATTACHMENTS = [
JSON.stringify({
type: 'user',
uuid: 'u1',
timestamp: '2025-01-15T10:00:00Z',
cwd: '/home/user/project',
message: { content: 'Review @docs/plan.md and @src/index.ts please' },
}),
JSON.stringify({
type: 'attachment',
uuid: 'att1',
timestamp: '2025-01-15T10:00:00Z',
attachment: {
type: 'file',
filename: '/home/user/project/docs/plan.md',
displayPath: 'docs/plan.md',
content: { type: 'text', file: { filePath: '/home/user/project/docs/plan.md', content: '# Plan' } },
},
}),
JSON.stringify({
type: 'attachment',
uuid: 'att2',
timestamp: '2025-01-15T10:00:00Z',
attachment: {
type: 'file',
filename: '/home/user/project/src/index.ts',
displayPath: 'src/index.ts',
content: { type: 'text', file: { filePath: '/home/user/project/src/index.ts', content: 'export {};' } },
},
}),
JSON.stringify({
type: 'attachment',
uuid: 'att3',
timestamp: '2025-01-15T10:00:00Z',
attachment: { type: 'skill_listing', skills: [] },
}),
JSON.stringify({
type: 'assistant',
uuid: 'a1',
timestamp: '2025-01-15T10:01:00Z',
message: {
model: 'claude-sonnet-4',
content: [{ type: 'text', text: 'Reviewed.' }],
usage: { input_tokens: 100, output_tokens: 50 },
stop_reason: 'end_turn',
},
}),
].join('\n');

export const SESSION_WITH_TOOLS = [
JSON.stringify({
type: 'user',
Expand Down
31 changes: 28 additions & 3 deletions src/parsers/SessionParser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as fs from 'fs';
import { Session, Turn, ToolCall } from '../store/DashboardStore';
import { Session, Turn, ToolCall, TurnAttachment } from '../store/DashboardStore';

const MODEL_COSTS: Record<string, { input: number; output: number; cacheWrite: number; cacheRead: number }> = {
'claude-opus-4': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 },
Expand Down Expand Up @@ -57,6 +57,9 @@ export class SessionParser {
let sessionSummary: string | null = null;
let hasThinking = false;
let thinkingTokens = 0;
// File attachments appear as separate entries right after the user
// message that @-tagged them; buffer any that arrive before their turn.
let pendingAttachments: TurnAttachment[] = [];

for (const line of lines) {
try {
Expand All @@ -76,8 +79,12 @@ export class SessionParser {
? rawContent.filter((c: any) => c.type === 'text').map((c: any) => c.text || '').join('')
: typeof rawContent === 'string' ? rawContent : '';

// Skip internal command messages for turn content
const displayText = text.replace(/<command-message>.*?<\/command-message>/gs, '').trim();
// Skip internal command messages and IDE-injected context for turn content
const displayText = text
.replace(/<command-message>.*?<\/command-message>/gs, '')
.replace(/<ide_opened_file>.*?<\/ide_opened_file>/gs, '')
.replace(/<ide_selection>.*?<\/ide_selection>/gs, '')
.trim();

// Capture first meaningful user prompt as session summary
if (!sessionSummary && displayText.length > 0) {
Expand All @@ -92,7 +99,25 @@ export class SessionParser {
outputTokens: 0,
toolCalls: [],
timestamp: ts,
...(pendingAttachments.length > 0 ? { attachments: pendingAttachments } : {}),
});
pendingAttachments = [];
}

if (entry.type === 'attachment' && entry.attachment?.type === 'file') {
const path = (entry.attachment.filename || entry.attachment.displayPath || '') as string;
const displayPath = (entry.attachment.displayPath || entry.attachment.filename || '') as string;
if (path) {
const lastTurn = turns[turns.length - 1];
if (lastTurn && lastTurn.role === 'user') {
lastTurn.attachments = lastTurn.attachments ?? [];
if (!lastTurn.attachments.some(a => a.path === path)) {
lastTurn.attachments.push({ path, displayPath });
}
} else if (!pendingAttachments.some(a => a.path === path)) {
pendingAttachments.push({ path, displayPath });
}
}
}

if (entry.type === 'assistant' && entry.message) {
Expand Down
74 changes: 74 additions & 0 deletions src/parsers/__tests__/SessionParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
MINIMAL_SESSION,
MULTI_TURN_SESSION,
SESSION_ARRAY_CONTENT,
SESSION_WITH_ATTACHMENTS,
SESSION_WITH_IDE_TAGS,
SESSION_WITH_CACHE,
SESSION_WITH_COMMAND_MESSAGE,
SESSION_WITH_MCP,
Expand Down Expand Up @@ -79,6 +81,78 @@ describe('SessionParser', () => {
expect(multiResult?.promptCount).toBe(2);
});

it('strips IDE-injected context tags from user turns and summaries', () => {
vi.mocked(fs.readFileSync).mockReturnValue(asReadResult(SESSION_WITH_IDE_TAGS));
const result = parser.parseFile('/sessions/ide.jsonl', 'proj-1');

expect(result?.turns[0].content).toBe('Fix the build script');
expect(result?.sessionSummary).toBe('Fix the build script');
});

it('attaches @-tagged files to the preceding user turn', () => {
vi.mocked(fs.readFileSync).mockReturnValue(asReadResult(SESSION_WITH_ATTACHMENTS));
const result = parser.parseFile('/sessions/attach.jsonl', 'proj-1');

expect(result?.turns).toHaveLength(2);
expect(result?.turns[0].attachments).toEqual([
{ path: '/home/user/project/docs/plan.md', displayPath: 'docs/plan.md' },
{ path: '/home/user/project/src/index.ts', displayPath: 'src/index.ts' },
]);
// non-file attachments (skill listings etc.) are ignored
expect(result?.turns[1].attachments).toBeUndefined();
});

it('buffers file attachments that arrive before their user turn', () => {
const session = [
JSON.stringify({
type: 'attachment',
uuid: 'att-early',
timestamp: '2025-01-15T09:59:59Z',
cwd: '/home/user/project',
attachment: {
type: 'file',
filename: '/home/user/project/notes.md',
displayPath: 'notes.md',
},
}),
JSON.stringify({
type: 'attachment',
uuid: 'att-early-dup',
timestamp: '2025-01-15T09:59:59Z',
attachment: {
type: 'file',
filename: '/home/user/project/notes.md',
displayPath: 'notes.md',
},
}),
JSON.stringify({
type: 'user',
uuid: 'u1',
timestamp: '2025-01-15T10:00:00Z',
message: { content: 'See @notes.md' },
}),
JSON.stringify({
type: 'assistant',
uuid: 'a1',
timestamp: '2025-01-15T10:00:30Z',
message: {
model: 'claude-sonnet-4',
content: [{ type: 'text', text: 'Ok' }],
usage: { input_tokens: 10, output_tokens: 5 },
stop_reason: 'end_turn',
},
}),
].join('\n');
vi.mocked(fs.readFileSync).mockReturnValue(asReadResult(session));

const result = parser.parseFile('/sessions/pending.jsonl', 'proj-1');

expect(result?.turns[0].role).toBe('user');
expect(result?.turns[0].attachments).toEqual([
{ path: '/home/user/project/notes.md', displayPath: 'notes.md' },
]);
});

it('tracks tool calls, modified files, created files, and MCP server names', () => {
vi.mocked(fs.readFileSync)
.mockReturnValueOnce(asReadResult(SESSION_WITH_TOOLS))
Expand Down
6 changes: 6 additions & 0 deletions src/store/DashboardStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ export interface Turn {
outputTokens: number;
toolCalls: ToolCall[];
timestamp: number;
attachments?: TurnAttachment[]; // files the user @-tagged in this prompt
}

export interface TurnAttachment {
path: string; // absolute file path
displayPath: string; // project-relative path as shown in the prompt
}

export interface ToolCall {
Expand Down
48 changes: 41 additions & 7 deletions webview-ui/src/components/MarkdownView.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,49 @@
import React from 'react';

// Matches @-tagged file paths in prompts (e.g. @src/index.ts); the lookbehind
// keeps emails (user@host) and mid-word @ from matching.
const MENTION_TOKEN_RE = /(?<![\w.@])@[\w./~-]*[\w/]/;
const INLINE_RE = /(`[^`]+`|\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|\*[^*]+\*)/;
const INLINE_WITH_MENTIONS_RE = /(`[^`]+`|\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|\*[^*]+\*|(?<![\w.@])@[\w./~-]*[\w/])/;

function MentionChip({ text }: { text: string }) {
return (
<span className="font-mono text-xs px-1 rounded bg-[var(--vscode-editor-inactiveSelectionBackground)] text-[var(--vscode-textLink-foreground)]">
{text}
</span>
);
}

/** Plain one-line text with @file mentions highlighted (no markdown parsing). */
export function MentionText({ text }: { text: string }) {
const parts = text.split(/((?<![\w.@])@[\w./~-]*[\w/])/);
return (
<>
{parts.map((part, i) =>
part.startsWith('@') && MENTION_TOKEN_RE.test(part)
? <MentionChip key={i} text={part} />
: part
)}
</>
);
}

function renderInline(
text: string,
onLinkClick?: (href: string) => void,
highlightMentions = false,
): React.ReactNode[] {
const parts = text.split(/(`[^`]+`|\[[^\]]+\]\([^)]+\)|\*\*[^*]+\*\*|\*[^*]+\*)/);
const parts = text.split(highlightMentions ? INLINE_WITH_MENTIONS_RE : INLINE_RE);
return parts.map((part, i) => {
if (!part) {
return null;
}
if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {
return <code key={i} className="font-mono text-xs bg-[var(--vscode-editor-inactiveSelectionBackground)] px-1 rounded">{part.slice(1, -1)}</code>;
}
if (highlightMentions && part.startsWith('@') && MENTION_TOKEN_RE.test(part)) {
return <MentionChip key={i} text={part} />;
}
const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
if (linkMatch) {
const [, label, href] = linkMatch;
Expand Down Expand Up @@ -51,10 +83,12 @@ export function MarkdownView({
content,
compact = false,
onLinkClick,
highlightMentions = false,
}: {
content: string;
compact?: boolean;
onLinkClick?: (href: string) => void;
highlightMentions?: boolean;
}) {
const lines = content.split('\n');
const elements: React.ReactNode[] = [];
Expand All @@ -81,15 +115,15 @@ export function MarkdownView({
const h3 = line.match(/^### (.+)/);
const h2 = line.match(/^## (.+)/);
const h1 = line.match(/^# (.+)/);
if (h1) { elements.push(<h1 key={key++} className="text-xl font-bold mt-5 mb-2 border-b border-[var(--vscode-panel-border)] pb-1">{renderInline(h1[1], onLinkClick)}</h1>); i++; continue; }
if (h2) { elements.push(<h2 key={key++} className="text-base font-bold mt-4 mb-1.5">{renderInline(h2[1], onLinkClick)}</h2>); i++; continue; }
if (h3) { elements.push(<h3 key={key++} className="text-sm font-semibold mt-3 mb-1 opacity-80">{renderInline(h3[1], onLinkClick)}</h3>); i++; continue; }
if (h1) { elements.push(<h1 key={key++} className="text-xl font-bold mt-5 mb-2 border-b border-[var(--vscode-panel-border)] pb-1">{renderInline(h1[1], onLinkClick, highlightMentions)}</h1>); i++; continue; }
if (h2) { elements.push(<h2 key={key++} className="text-base font-bold mt-4 mb-1.5">{renderInline(h2[1], onLinkClick, highlightMentions)}</h2>); i++; continue; }
if (h3) { elements.push(<h3 key={key++} className="text-sm font-semibold mt-3 mb-1 opacity-80">{renderInline(h3[1], onLinkClick, highlightMentions)}</h3>); i++; continue; }

// Bullet list
if (line.match(/^[\-\*] /)) {
const items: React.ReactNode[] = [];
while (i < lines.length && lines[i].match(/^[\-\*] /)) {
items.push(<li key={i} className="leading-relaxed">{renderInline(lines[i].slice(2), onLinkClick)}</li>);
items.push(<li key={i} className="leading-relaxed">{renderInline(lines[i].slice(2), onLinkClick, highlightMentions)}</li>);
i++;
}
elements.push(<ul key={key++} className="list-disc pl-5 my-2 space-y-0.5 text-sm">{items}</ul>);
Expand All @@ -100,7 +134,7 @@ export function MarkdownView({
if (line.match(/^\d+\. /)) {
const items: React.ReactNode[] = [];
while (i < lines.length && lines[i].match(/^\d+\. /)) {
items.push(<li key={i} className="leading-relaxed">{renderInline(lines[i].replace(/^\d+\. /, ''), onLinkClick)}</li>);
items.push(<li key={i} className="leading-relaxed">{renderInline(lines[i].replace(/^\d+\. /, ''), onLinkClick, highlightMentions)}</li>);
i++;
}
elements.push(<ol key={key++} className="list-decimal pl-5 my-2 space-y-0.5 text-sm">{items}</ol>);
Expand Down Expand Up @@ -128,7 +162,7 @@ export function MarkdownView({
!lines[i].match(/^---+$/)
) { paraLines.push(lines[i]); i++; }
if (paraLines.length) {
elements.push(<p key={key++} className="text-sm leading-relaxed my-1.5 opacity-90">{renderInline(paraLines.join(' '), onLinkClick)}</p>);
elements.push(<p key={key++} className="text-sm leading-relaxed my-1.5 opacity-90">{renderInline(paraLines.join(' '), onLinkClick, highlightMentions)}</p>);
}
}

Expand Down
Loading
Loading