diff --git a/dash/src/App.tsx b/dash/src/App.tsx index f5658d4..be8f46e 100644 --- a/dash/src/App.tsx +++ b/dash/src/App.tsx @@ -551,6 +551,8 @@ export function App() { + {page === 'usage' && ( + <>

Devices

{multi && ( @@ -582,6 +584,8 @@ export function App() { Search local devices + + )}

Share

@@ -669,7 +673,7 @@ export function App() { )} - {isError && ( + {page === 'usage' && isError && (
Failed to load: {String((error as Error)?.message)}
)} diff --git a/dash/src/components/ContextExplorer.tsx b/dash/src/components/ContextExplorer.tsx index c5592d7..2fa67af 100644 --- a/dash/src/components/ContextExplorer.tsx +++ b/dash/src/components/ContextExplorer.tsx @@ -5,8 +5,8 @@ import { fetchContextSessions, fetchContextTree, type ContextProvider, + type ContextRow, type ContextSessionInfo, - type ContextSnapshot, } from '@/lib/api' import { cn, fmtNum, fmtTokens, label } from '@/lib/utils' import { Card } from '@/components/ui/card' @@ -24,47 +24,26 @@ function ago(mtimeMs: number): string { return `${Math.round(mins / (60 * 24))}d ago` } -type TreeRow = { depth: number; name: string; count: number; tokens: number; section?: boolean } - -function treeRows(view: ContextSnapshot): TreeRow[] { - const rows: TreeRow[] = [] - rows.push({ depth: 0, name: 'assistant', count: view.assistant.count, tokens: view.assistant.tokens, section: true }) - rows.push({ depth: 1, name: 'text', count: view.assistant.text.count, tokens: view.assistant.text.tokens }) - if (view.assistant.reasoning.count > 0) rows.push({ depth: 1, name: 'reasoning', count: view.assistant.reasoning.count, tokens: view.assistant.reasoning.tokens }) - rows.push({ depth: 1, name: 'tool-call', count: view.assistant.toolCall.count, tokens: view.assistant.toolCall.tokens }) - for (const t of view.assistant.byTool) rows.push({ depth: 2, name: t.tool, count: t.count, tokens: t.tokens }) - rows.push({ depth: 0, name: 'user', count: view.user.count, tokens: view.user.tokens, section: true }) - rows.push({ depth: 1, name: 'text', count: view.user.text.count, tokens: view.user.text.tokens }) - if (view.user.image.count > 0) rows.push({ depth: 1, name: 'image', count: view.user.image.count, tokens: view.user.image.tokens }) - if (view.user.compactSummary.count > 0) rows.push({ depth: 1, name: 'compact-summary', count: view.user.compactSummary.count, tokens: view.user.compactSummary.tokens }) - if (view.user.meta.count > 0) rows.push({ depth: 1, name: 'meta', count: view.user.meta.count, tokens: view.user.meta.tokens }) - rows.push({ depth: 0, name: 'tool', count: view.toolResult.count, tokens: view.toolResult.tokens, section: true }) - rows.push({ depth: 1, name: 'tool-result', count: view.toolResult.count, tokens: view.toolResult.tokens }) - if (view.system.count > 0) rows.push({ depth: 0, name: 'system', count: view.system.count, tokens: view.system.tokens, section: true }) - return rows -} - -function TreeTable({ view }: { view: ContextSnapshot }) { - const rows = treeRows(view) - const max = Math.max(1, ...rows.filter((r) => !r.section).map((r) => r.tokens)) +function TreeTable({ rows }: { rows: ContextRow[] }) { + const max = Math.max(1, ...rows.filter((r) => !r.bold).map((r) => r.tokens)) return (
{rows.map((r, i) => ( -
0 && 'mt-2')}> - {!r.section && ( +
0 && 'mt-2')}> + {!r.bold && ( )} - {r.name} + {r.label} {fmtNum(r.count)}x - + {fmtTokens(r.tokens)}
@@ -104,7 +83,9 @@ function SessionDetails({ provider, id }: { provider: ContextProvider; id: strin } const view = scope === 'full' ? data.full : data.effective - const pct = data.reported ? Math.min(100, Math.round((data.reported.context / data.reported.window) * 100)) : null + const rows = scope === 'full' ? data.fullRows : data.effectiveRows + const window = data.reported?.window ?? null + const pct = data.reported && window ? Math.min(100, Math.round((data.reported.context / window) * 100)) : null return (
@@ -113,7 +94,7 @@ function SessionDetails({ provider, id }: { provider: ContextProvider; id: strin
@@ -149,7 +130,7 @@ function SessionDetails({ provider, id }: { provider: ContextProvider; id: strin token counts are estimates; “Context (exact)” comes from API usage
- +
) } diff --git a/dash/src/lib/api.ts b/dash/src/lib/api.ts index cd08db4..7e40ea3 100644 --- a/dash/src/lib/api.ts +++ b/dash/src/lib/api.ts @@ -202,13 +202,17 @@ export type ContextSnapshot = { system: BlockStat } +export type ContextRow = { depth: number; label: string; count: number; tokens: number; bold?: boolean } + export type ContextTree = { session: { sessionId: string; project: string; mtimeMs: number; sizeBytes: number } model: string compactions: number - reported: { context: number; window: number } | null + reported: { context: number; window: number | null } | null effective: ContextSnapshot full: ContextSnapshot + effectiveRows: ContextRow[] + fullRows: ContextRow[] } export async function fetchContextSessions(provider: ContextProvider): Promise { diff --git a/src/context-tree-codex.ts b/src/context-tree-codex.ts index 22de5ae..e9f9939 100644 --- a/src/context-tree-codex.ts +++ b/src/context-tree-codex.ts @@ -1,4 +1,4 @@ -import { open, readdir, stat } from 'fs/promises' +import { readdir, stat } from 'fs/promises' import { existsSync } from 'fs' import { basename, join } from 'path' import { homedir } from 'os' @@ -8,11 +8,14 @@ import { add, estimateTokens, IMAGE_TOKEN_FALLBACK, + lineToText, newAcc, + readChunk, snapshot, type Acc, type ContextTreeResult, type SessionRef, + type TitledSessionRef, } from './context-tree.js' // Codex rollout counterpart of the Claude Code context tree. Rollouts carry @@ -97,6 +100,22 @@ function addCodexItem(accs: Acc[], item: CodexItem): void { } } } + } else if (item.type === 'message' && item.role === 'developer') { + // Injected per-turn instructions (permissions, harness rules), not user text. + if (!Array.isArray(item.content)) return + for (const block of item.content) { + if (block == null || typeof block !== 'object') continue + const b = block as { type?: string; text?: unknown } + if ((b.type === 'input_text' || b.type === 'text') && typeof b.text === 'string') { + for (const acc of accs) add(acc.userMeta, estimateTokens(b.text)) + } + } + } else if (item.type === 'compaction') { + // The compaction summary ships encrypted; base64 is ~4/3 of the plaintext, + // so estimate from the decoded size. + const encrypted = (item as { encrypted_content?: unknown }).encrypted_content + const chars = typeof encrypted === 'string' ? encrypted.length * 0.75 : 0 + for (const acc of accs) add(acc.userCompactSummary, Math.ceil(chars / 4)) } else if (item.type === 'reasoning') { // Tokens are patched from cumulative usage after the walk. for (const acc of accs) acc.assistantReasoning.count += 1 @@ -152,8 +171,8 @@ export async function buildCodexContextTree(session: SessionRef): Promise 0) { - reported = { context, window: contextWindow ?? (context > 220_000 ? 1_000_000 : 200_000) } + // No guessing for OpenAI windows: without model_context_window the + // percentage is omitted rather than computed against a wrong constant. + reported = { context, window: contextWindow } } } @@ -223,8 +244,15 @@ export async function buildCodexContextTree(session: SessionRef): Promise { - const root = join(homedir(), '.codex', 'sessions') +// Mirrors the CODEX_HOME handling of providers/codex.ts. +function codexSessionsRoot(): string { + return join(process.env['CODEX_HOME'] ?? join(homedir(), '.codex'), 'sessions') +} + +type RolloutFile = { filePath: string; sessionId: string } + +async function listRolloutFiles(): Promise { + const root = codexSessionsRoot() if (!existsSync(root)) return [] let files: string[] try { @@ -232,28 +260,37 @@ export async function listCodexSessionRefs(): Promise { } catch { return [] } - const refs: SessionRef[] = [] + const rollouts: RolloutFile[] = [] for (const rel of files) { - const base = basename(rel) - const match = ROLLOUT_RE.exec(base) - if (!match) continue - const filePath = join(root, rel) - try { - const info = await stat(filePath) - if (!info.isFile() || info.size === 0) continue - refs.push({ - filePath, - sessionId: match[1], - project: '', - mtimeMs: info.mtimeMs, - sizeBytes: info.size, - }) - } catch { - continue - } + const match = ROLLOUT_RE.exec(basename(rel)) + if (match) rollouts.push({ filePath: join(root, rel), sessionId: match[1] }) } - refs.sort((a, b) => b.mtimeMs - a.mtimeMs) - return refs + return rollouts +} + +async function statRef(file: RolloutFile): Promise { + try { + const info = await stat(file.filePath) + if (!info.isFile() || info.size === 0) return null + return { ...file, project: '', mtimeMs: info.mtimeMs, sizeBytes: info.size } + } catch { + return null + } +} + +function newestFirst(refs: Array): SessionRef[] { + return refs.filter((r): r is SessionRef => r !== null).sort((a, b) => b.mtimeMs - a.mtimeMs) +} + +export async function listCodexSessionRefs(): Promise { + const files = await listRolloutFiles() + return newestFirst(await Promise.all(files.map(statRef))) +} + +// Id lookups match filenames directly so only the matching files get stated. +export async function findCodexSession(idPrefix: string): Promise { + const matches = (await listRolloutFiles()).filter((f) => f.sessionId.startsWith(idPrefix)) + return newestFirst(await Promise.all(matches.map(statRef)))[0] ?? null } // Codex stores no session name; use the head chunk for the cwd (project) and @@ -261,14 +298,7 @@ export async function listCodexSessionRefs(): Promise { async function readCodexHeadInfo(ref: SessionRef): Promise<{ project: string; title: string }> { let chunk: string try { - const fd = await open(ref.filePath, 'r') - try { - const buf = Buffer.alloc(262_144) - const { bytesRead } = await fd.read(buf, 0, buf.length, 0) - chunk = buf.subarray(0, bytesRead).toString('utf-8') - } finally { - await fd.close() - } + chunk = await readChunk(ref.filePath, 0, 262_144) } catch { return { project: '', title: '' } } @@ -300,7 +330,7 @@ async function readCodexHeadInfo(ref: SessionRef): Promise<{ project: string; ti return { project, title } } -export async function listRecentCodexSessions(limit = 15): Promise> { +export async function listRecentCodexSessions(limit = 15): Promise { const refs = (await listCodexSessionRefs()).slice(0, limit) return Promise.all( refs.map(async (ref) => { diff --git a/src/context-tree.ts b/src/context-tree.ts index caff7e5..9da3af8 100644 --- a/src/context-tree.ts +++ b/src/context-tree.ts @@ -1,14 +1,12 @@ import { open, readdir, stat } from 'fs/promises' import { existsSync } from 'fs' -import { join } from 'path' +import { delimiter, join } from 'path' import { homedir } from 'os' import chalk from 'chalk' -import { readSessionLines } from './fs-utils.js' +import { readSessionLines, type SessionLine } from './fs-utils.js' import { formatTokens } from './format.js' -// zaly-style context breakdown for a single Claude Code session: what is in -// the model's context window right now, split by role, block type, and tool. // Block token counts are chars/4 estimates; the "context (exact)" line comes // from the last assistant message's API usage. Transcripts store thinking // blocks with their text stripped, so reasoning is derived per message as @@ -54,11 +52,25 @@ export type ContextTreeResult = { session: SessionRef model: string compactions: number - reported: { context: number; window: number } | null + reported: { context: number; window: number | null } | null effective: ContextSnapshot full: ContextSnapshot } +// A single line above this decodes to a string near V8's limit; skip it +// instead of letting toString abort the whole walk. +const MAX_LINE_BYTES = 256 * 1024 * 1024 + +export function lineToText(line: SessionLine): string | null { + if (typeof line === 'string') return line + if (line.length > MAX_LINE_BYTES) return null + try { + return line.toString('utf-8') + } catch { + return null + } +} + export type Acc = { messages: number assistantCount: number @@ -369,45 +381,44 @@ const skipFileSnapshots = (head: string): boolean => head.includes('"type":"file // segment's head (messages Claude Code carried across the compaction), not at // the boundary itself. async function findLastBoundary(filePath: string): Promise<{ - boundaryIndex: number headUuid: string | null compactions: number maxPreTokens: number }> { - let boundaryIndex = -1 let headUuid: string | null = null let compactions = 0 let maxPreTokens = 0 - let index = -1 - for await (const line of readSessionLines(filePath, skipFileSnapshots)) { - index += 1 - const text = line as string - if (!text.includes('"subtype":"compact_boundary"')) continue + for await (const line of readSessionLines(filePath, skipFileSnapshots, { largeLineAsBuffer: true })) { + if (typeof line !== 'string') continue + if (!line.includes('"subtype":"compact_boundary"')) continue let entry: RawEntry try { - entry = JSON.parse(text) as RawEntry + entry = JSON.parse(line) as RawEntry } catch { continue } if (entry.type !== 'system' || entry.subtype !== 'compact_boundary') continue compactions += 1 - boundaryIndex = index headUuid = entry.compactMetadata?.preservedSegment?.headUuid ?? null maxPreTokens = Math.max(maxPreTokens, entry.compactMetadata?.preTokens ?? 0) } - return { boundaryIndex, headUuid, compactions, maxPreTokens } + return { headUuid, compactions, maxPreTokens } } +// Claude models with a 1M window: opus-4-8 (auto-compactions on disk show +// ~1.0M preTokens) and the "[1m]" long-context variants. Others default to +// 200K unless the session itself proves bigger. +const MILLION_WINDOW_RE = /opus-4-8|\[1m\]/ + export async function buildContextTree(session: SessionRef): Promise { const boundary = await findLastBoundary(session.filePath) const builder = new TreeBuilder() builder.maxSeenTokens = boundary.maxPreTokens - let index = -1 + let boundariesSeen = 0 let inPreservedSegment = false - for await (const line of readSessionLines(session.filePath, skipFileSnapshots)) { - index += 1 - const text = line as string + for await (const line of readSessionLines(session.filePath, skipFileSnapshots, { largeLineAsBuffer: true })) { + const text = lineToText(line) if (!text || text.charCodeAt(0) !== 123) continue let entry: RawEntry try { @@ -416,9 +427,12 @@ export async function buildContextTree(session: SessionRef): Promise boundary.boundaryIndex || inPreservedSegment + const effective = boundariesSeen >= boundary.compactions || inPreservedSegment builder.addEntry(entry, effective) } builder.flushReasoning() @@ -431,7 +445,8 @@ export async function buildContextTree(session: SessionRef): Promise 220_000 ? 1_000_000 : 200_000 } + const million = MILLION_WINDOW_RE.test(builder.model) || builder.maxSeenTokens > 220_000 + reported = { context, window: million ? 1_000_000 : 200_000 } } return { @@ -444,44 +459,69 @@ export async function buildContextTree(session: SessionRef): Promise { - const root = join(homedir(), '.claude', 'projects') - if (!existsSync(root)) return [] - const refs: SessionRef[] = [] - let projectDirs: string[] - try { - projectDirs = await readdir(root) - } catch { - return [] - } - for (const dir of projectDirs) { - const projectPath = join(root, dir) - let files: string[] +// Mirrors the env handling of providers/claude.ts so the context views cover +// the same session roots as usage tracking. +function claudeProjectRoots(): string[] { + const dirsEnv = process.env['CLAUDE_CONFIG_DIRS'] + const dirs = dirsEnv ? dirsEnv.split(delimiter).filter(Boolean) : [process.env['CLAUDE_CONFIG_DIR'] ?? join(homedir(), '.claude')] + return dirs.map((d) => join(d, 'projects')) +} + +type SessionFile = { filePath: string; sessionId: string; project: string } + +async function listSessionFiles(): Promise { + const files: SessionFile[] = [] + for (const root of claudeProjectRoots()) { + if (!existsSync(root)) continue + let projectDirs: string[] try { - files = await readdir(projectPath) + projectDirs = await readdir(root) } catch { continue } - for (const file of files) { - if (!file.endsWith('.jsonl')) continue - const filePath = join(projectPath, file) + for (const dir of projectDirs) { + let names: string[] try { - const info = await stat(filePath) - if (!info.isFile() || info.size === 0) continue - refs.push({ - filePath, - sessionId: file.slice(0, -'.jsonl'.length), - project: dir.split('-').filter(Boolean).pop() ?? dir, - mtimeMs: info.mtimeMs, - sizeBytes: info.size, - }) + names = await readdir(join(root, dir)) } catch { continue } + for (const name of names) { + if (!name.endsWith('.jsonl')) continue + files.push({ + filePath: join(root, dir, name), + sessionId: name.slice(0, -'.jsonl'.length), + project: dir.split('-').filter(Boolean).pop() ?? dir, + }) + } } } - refs.sort((a, b) => b.mtimeMs - a.mtimeMs) - return refs.slice(0, limit) + return files +} + +async function statRef(file: SessionFile): Promise { + try { + const info = await stat(file.filePath) + if (!info.isFile() || info.size === 0) return null + return { ...file, mtimeMs: info.mtimeMs, sizeBytes: info.size } + } catch { + return null + } +} + +function newestFirst(refs: Array): SessionRef[] { + return refs.filter((r): r is SessionRef => r !== null).sort((a, b) => b.mtimeMs - a.mtimeMs) +} + +export async function listRecentSessions(limit = 15): Promise { + const files = await listSessionFiles() + return newestFirst(await Promise.all(files.map(statRef))).slice(0, limit) +} + +// Id lookups match filenames directly so only the matching files get stated. +export async function findClaudeSession(idPrefix: string): Promise { + const matches = (await listSessionFiles()).filter((f) => f.sessionId.startsWith(idPrefix)) + return newestFirst(await Promise.all(matches.map(statRef)))[0] ?? null } // Claude Code stores an AI-generated session name as "ai-title" entries (the @@ -513,7 +553,7 @@ function titleFromChunk(chunk: string): string { return title || summary } -async function readChunk(filePath: string, start: number, length: number): Promise { +export async function readChunk(filePath: string, start: number, length: number): Promise { const fd = await open(filePath, 'r') try { const buf = Buffer.alloc(length) @@ -535,7 +575,7 @@ export async function readSessionTitle(ref: SessionRef): Promise { } } -async function resolveSession(arg: string | undefined): Promise { +async function resolveSession(arg: string | undefined, provider: 'claude' | 'codex'): Promise { if (arg && (arg.endsWith('.jsonl') || arg.includes('/'))) { if (!existsSync(arg)) return null const info = await stat(arg) @@ -548,16 +588,20 @@ async function resolveSession(arg: string | undefined): Promise r.sessionId.startsWith(arg)) ?? null + if (provider === 'codex') { + const codex = await import('./context-tree-codex.js') + if (!arg) return (await codex.listRecentCodexSessions(1))[0] ?? null + return codex.findCodexSession(arg) + } + if (!arg) return (await listRecentSessions(1))[0] ?? null + return findClaudeSession(arg) } function num(n: number): string { return n.toLocaleString('en-US') } -function relativeAge(mtimeMs: number): string { +export function relativeAge(mtimeMs: number): string { const mins = Math.max(0, Math.round((Date.now() - mtimeMs) / 60_000)) if (mins < 60) return `${mins}m ago` if (mins < 60 * 24) return `${Math.round(mins / 60)}h ago` @@ -566,7 +610,6 @@ function relativeAge(mtimeMs: number): string { export type ContextRow = { depth: number; label: string; count: number; tokens: number; bold?: boolean } -// The tree flattened to rows, shared by the static CLI renderer and the TUI. export function snapshotRows(view: ContextSnapshot): ContextRow[] { const rows: ContextRow[] = [] rows.push({ depth: 0, label: 'assistant', count: view.assistant.count, tokens: view.assistant.tokens, bold: true }) @@ -622,8 +665,9 @@ export function renderContextTree(result: ContextTreeResult, opts: { full?: bool lines.push(` ${chalk.dim('◦')} ${formatTokens(result.effective.tokens)} ${chalk.dim(`effective (${pct}%)`)}`) } if (result.reported) { - const pct = Math.round((result.reported.context / result.reported.window) * 100) - lines.push(` context (exact, last turn): ${chalk.bold(formatTokens(result.reported.context))} ${chalk.dim(`of ${formatTokens(result.reported.window)} window (${pct}%)`)}`) + const { context, window } = result.reported + const windowPart = window ? ` ${chalk.dim(`of ${formatTokens(window)} window (${Math.round((context / window) * 100)}%)`)}` : '' + lines.push(` context (exact, last turn): ${chalk.bold(formatTokens(context))}${windowPart}`) const overhead = result.reported.context - result.effective.tokens if (overhead >= 0) { lines.push(` ${chalk.dim('◦')} ${formatTokens(overhead)} ${chalk.dim('system prompt, tools & memory (derived)')}`) @@ -641,42 +685,57 @@ export function renderContextTree(result: ContextTreeResult, opts: { full?: bool return lines.join('\n') } -function renderSessionList(refs: SessionRef[], titles: string[]): string { - const lines = ['', ` ${chalk.bold('Recent Claude Code sessions')}`, ''] +export type TitledSessionRef = SessionRef & { title: string } + +function renderSessionList(refs: TitledSessionRef[], provider: 'claude' | 'codex'): string { + const heading = provider === 'codex' ? 'Recent Codex sessions' : 'Recent Claude Code sessions' + const hint = provider === 'codex' ? 'codeburn context --provider codex to inspect one' : 'codeburn context to inspect one' + const lines = ['', ` ${chalk.bold(heading)}`, ''] const projectWidth = Math.max(...refs.map((r) => r.project.length)) - for (const [i, ref] of refs.entries()) { + for (const ref of refs) { const sizeMb = (ref.sizeBytes / 1024 / 1024).toFixed(1).padStart(6) - const title = titles[i] ?? '' - const shortTitle = title.length > 48 ? `${title.slice(0, 47)}…` : title + const shortTitle = ref.title.length > 48 ? `${ref.title.slice(0, 47)}…` : ref.title lines.push(` ${chalk.cyan(ref.sessionId.slice(0, 8))} ${chalk.dim(`${sizeMb}MB`)} ${relativeAge(ref.mtimeMs).padStart(7)} ${chalk.dim(ref.project.padEnd(projectWidth))} ${shortTitle}`) } lines.push('') - lines.push(chalk.dim(' codeburn context to inspect one')) + lines.push(chalk.dim(` ${hint}`)) lines.push('') return lines.join('\n') } +export async function listRecentTitledSessions(limit = 15): Promise { + const refs = await listRecentSessions(limit) + const titles = await Promise.all(refs.map(readSessionTitle)) + return refs.map((r, i) => ({ ...r, title: titles[i] ?? '' })) +} + export async function runContextCommand( sessionArg: string | undefined, - opts: { list?: boolean; full?: boolean; json?: boolean }, + opts: { list?: boolean; full?: boolean; json?: boolean; provider?: string }, ): Promise { + const provider: 'claude' | 'codex' = opts.provider === 'codex' ? 'codex' : 'claude' if (opts.list) { - const refs = await listRecentSessions(15) + const refs = + provider === 'codex' ? await (await import('./context-tree-codex.js')).listRecentCodexSessions(15) : await listRecentTitledSessions(15) if (refs.length === 0) { - console.log('No Claude Code sessions found under ~/.claude/projects.') + console.log(provider === 'codex' ? 'No Codex sessions found.' : 'No Claude Code sessions found.') return } - const titles = await Promise.all(refs.map(readSessionTitle)) - console.log(renderSessionList(refs, titles)) + if (opts.json) { + console.log(JSON.stringify({ sessions: refs }, null, 2)) + return + } + console.log(renderSessionList(refs, provider)) return } - const session = await resolveSession(sessionArg) + const session = await resolveSession(sessionArg, provider) if (!session) { - console.error(sessionArg ? `No session matching "${sessionArg}".` : 'No Claude Code sessions found under ~/.claude/projects.') + console.error(sessionArg ? `No ${provider} session matching "${sessionArg}".` : `No ${provider} sessions found.`) process.exitCode = 1 return } - const result = await buildContextTree(session) + const result = + provider === 'codex' ? await (await import('./context-tree-codex.js')).buildCodexContextTree(session) : await buildContextTree(session) if (opts.json) { console.log(JSON.stringify(result, null, 2)) return diff --git a/src/context-tui.tsx b/src/context-tui.tsx index c7a229d..4291af2 100644 --- a/src/context-tui.tsx +++ b/src/context-tui.tsx @@ -1,20 +1,20 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useState } from 'react' import { render, Box, Text, useApp, useInput } from 'ink' import { formatTokens } from './format.js' import { patchStdoutForWindows } from './ink-win.js' import { buildContextTree, - listRecentSessions, - readSessionTitle, + listRecentTitledSessions, + relativeAge, snapshotRows, type ContextTreeResult, - type SessionRef, + type TitledSessionRef, } from './context-tree.js' import { buildCodexContextTree, listRecentCodexSessions } from './context-tree-codex.js' type Provider = 'claude' | 'codex' -type SessionRow = SessionRef & { title: string } +type Scope = 'effective' | 'full' const ORANGE = '#FF8C42' const DIM = '#555555' @@ -24,25 +24,15 @@ const PROVIDERS: Array<{ key: Provider; label: string }> = [ { key: 'codex', label: 'Codex' }, ] -function ago(mtimeMs: number): string { - const mins = Math.max(0, Math.round((Date.now() - mtimeMs) / 60_000)) - if (mins < 60) return `${mins}m` - if (mins < 60 * 24) return `${Math.round(mins / 60)}h` - return `${Math.round(mins / (60 * 24))}d` -} - function truncate(text: string, max: number): string { return text.length > max ? `${text.slice(0, max - 1)}…` : text } -async function loadSessions(provider: Provider): Promise { - if (provider === 'codex') return listRecentCodexSessions(15) - const refs = await listRecentSessions(15) - const titles = await Promise.all(refs.map(readSessionTitle)) - return refs.map((r, i) => ({ ...r, title: titles[i] ?? '' })) +async function loadSessions(provider: Provider): Promise { + return provider === 'codex' ? listRecentCodexSessions(15) : listRecentTitledSessions(15) } -function TreeDetails({ tree, scope }: { tree: ContextTreeResult; scope: 'effective' | 'full' }) { +function TreeDetails({ tree, scope }: { tree: ContextTreeResult; scope: Scope }) { const view = scope === 'full' ? tree.full : tree.effective const rows = snapshotRows(view) const labelWidth = Math.max(...rows.map((r) => r.depth * 2 + r.label.length)) + 2 @@ -51,8 +41,8 @@ function TreeDetails({ tree, scope }: { tree: ContextTreeResult; scope: 'effecti const headline: string[] = [`model ${tree.model}`, `messages ${view.messages.toLocaleString('en-US')}`, `est ${formatTokens(view.tokens)}`] if (tree.reported) { - const pct = Math.round((tree.reported.context / tree.reported.window) * 100) - headline.push(`context ${formatTokens(tree.reported.context)} / ${formatTokens(tree.reported.window)} (${pct}%)`) + const { context, window } = tree.reported + headline.push(window ? `context ${formatTokens(context)} / ${formatTokens(window)} (${Math.round((context / window) * 100)}%)` : `context ${formatTokens(context)} (exact)`) } if (tree.compactions > 0) headline.push(`${tree.compactions} compaction${tree.compactions === 1 ? '' : 's'}`) @@ -81,17 +71,17 @@ function TreeDetails({ tree, scope }: { tree: ContextTreeResult; scope: 'effecti ) } -function ContextTuiApp() { +function ContextTuiApp({ initialScope }: { initialScope: Scope }) { const { exit } = useApp() const [provider, setProvider] = useState('claude') - const [sessions, setSessions] = useState(null) + const [sessions, setSessions] = useState(null) const [cursor, setCursor] = useState(0) const [expandedId, setExpandedId] = useState(null) - const [scope, setScope] = useState<'effective' | 'full'>('effective') + const [scope, setScope] = useState(initialScope) const [building, setBuilding] = useState(false) const [frame, setFrame] = useState(0) - const [, forceRender] = useState(0) - const trees = useRef(new Map()) + const [trees, setTrees] = useState>({}) + const [errors, setErrors] = useState>({}) useEffect(() => { let alive = true @@ -112,22 +102,20 @@ function ContextTuiApp() { return () => clearInterval(t) }, [building]) - const toggleExpand = (session: SessionRow) => { + const toggleExpand = (session: TitledSessionRef) => { if (expandedId === session.sessionId) { setExpandedId(null) return } setExpandedId(session.sessionId) const key = `${provider}:${session.sessionId}:${session.mtimeMs}` - if (trees.current.has(key)) return + if (trees[key]) return setBuilding(true) + setErrors((e) => ({ ...e, [key]: '' })) const build = provider === 'claude' ? buildContextTree(session) : buildCodexContextTree(session) void build - .then((tree) => { - trees.current.set(key, tree) - forceRender((n) => n + 1) - }) - .catch(() => {}) + .then((tree) => setTrees((t) => ({ ...t, [key]: tree }))) + .catch((err: unknown) => setErrors((e) => ({ ...e, [key]: err instanceof Error ? err.message : String(err) }))) .finally(() => setBuilding(false)) } @@ -170,7 +158,8 @@ function ContextTuiApp() { const selected = i === cursor const expanded = expandedId === s.sessionId const key = `${provider}:${s.sessionId}:${s.mtimeMs}` - const tree = trees.current.get(key) + const tree = trees[key] + const error = errors[key] return ( @@ -182,10 +171,15 @@ function ContextTuiApp() { {' '} - {truncate(s.project, 12).padEnd(12)} {ago(s.mtimeMs).padStart(4)} {`${(s.sizeBytes / 1024 / 1024).toFixed(1)}MB`.padStart(8)} + {truncate(s.project, 12).padEnd(12)} {relativeAge(s.mtimeMs).padStart(8)} {`${(s.sizeBytes / 1024 / 1024).toFixed(1)}MB`.padStart(8)} - {expanded && !tree && ( + {expanded && error && ( + + could not read this session: {error} + + )} + {expanded && !tree && !error && ( {SPINNER[frame % SPINNER.length]} reading transcript ({(s.sizeBytes / 1024 / 1024).toFixed(0)}MB)… @@ -202,8 +196,8 @@ function ContextTuiApp() { ) } -export async function runContextTui(): Promise { +export async function runContextTui(opts: { initialScope?: Scope } = {}): Promise { patchStdoutForWindows() - const instance = render() + const instance = render() await instance.waitUntilExit() } diff --git a/src/main.ts b/src/main.ts index f5b0673..10b4f63 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1338,14 +1338,20 @@ program program .command('context [session]') - .description('Context token breakdown per session: what fills the window, by role, block type, and tool (experimental). No arguments opens an interactive browser.') + .description('Context token breakdown per session: what fills the window, by role, block type, and tool (experimental). No session argument opens an interactive browser.') .option('--list', 'List recent sessions to pick from') .option('--full', 'Cover the whole session history instead of the live (post-compaction) window') .option('--json', 'JSON output') - .action(async (session: string | undefined, opts: { list?: boolean; full?: boolean; json?: boolean }) => { - if (!session && !opts.list && !opts.json && !opts.full && process.stdout.isTTY && process.stdin.isTTY) { + .option('--provider ', 'Session source: claude or codex', 'claude') + .action(async (session: string | undefined, opts: { list?: boolean; full?: boolean; json?: boolean; provider?: string }) => { + if (opts.provider !== 'claude' && opts.provider !== 'codex') { + console.error('context: --provider must be claude or codex') + process.exitCode = 1 + return + } + if (!session && !opts.list && !opts.json && process.stdout.isTTY && process.stdin.isTTY) { const { runContextTui } = await import('./context-tui.js') - await runContextTui() + await runContextTui({ initialScope: opts.full ? 'full' : 'effective' }) return } await runContextCommand(session, opts) diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts index eff8e6b..83d4290 100644 --- a/src/web-dashboard.ts +++ b/src/web-dashboard.ts @@ -19,8 +19,8 @@ import { pairingCode } from './sharing/pairing.js' import { getSharingDir, loadRemotes, loadShareAlways, saveShareAlways } from './sharing/store.js' import { ShareController } from './sharing/share-controller.js' import { sanitizeForSharing } from './sharing/sanitize.js' -import { buildContextTree, listRecentSessions, readSessionTitle, type ContextTreeResult, type SessionRef } from './context-tree.js' -import { buildCodexContextTree, listCodexSessionRefs, listRecentCodexSessions } from './context-tree-codex.js' +import { buildContextTree, findClaudeSession, listRecentTitledSessions, snapshotRows, type ContextTreeResult, type SessionRef } from './context-tree.js' +import { buildCodexContextTree, findCodexSession, listRecentCodexSessions } from './context-tree-codex.js' function readBody(req: import('http').IncomingMessage): Promise { return new Promise((resolve, reject) => { @@ -135,7 +135,12 @@ export async function runWebDashboard(opts: { const getContextTree = (provider: 'claude' | 'codex', ref: SessionRef): Promise => { const key = `${provider}:${ref.sessionId}:${ref.mtimeMs}` const hit = contextTreeCache.get(key) - if (hit) return hit + if (hit) { + // Re-insert so eviction is LRU rather than insertion order. + contextTreeCache.delete(key) + contextTreeCache.set(key, hit) + return hit + } const tree = provider === 'claude' ? buildContextTree(ref) : buildCodexContextTree(ref) contextTreeCache.set(key, tree) void tree.catch(() => contextTreeCache.delete(key)) @@ -295,23 +300,17 @@ export async function runWebDashboard(opts: { res.end(JSON.stringify(await share.status())) return } - // Context explorer: recent sessions per provider, and the per-session - // token tree. Session ids are resolved against the discovered session - // files only, never joined into a path from user input. + // Session ids are resolved against the discovered session files only, + // never joined into a path from user input, and responses never carry + // local file paths. if (url.pathname === '/api/context/sessions') { const provider = url.searchParams.get('provider') ?? 'claude' if (provider !== 'claude' && provider !== 'codex') { writeJsonError(res, 400, 'provider must be claude or codex') return } - let sessions - if (provider === 'claude') { - const refs = await listRecentSessions(15) - const titles = await Promise.all(refs.map(readSessionTitle)) - sessions = refs.map((r, i) => ({ provider, sessionId: r.sessionId, project: r.project, title: titles[i] ?? '', mtimeMs: r.mtimeMs, sizeBytes: r.sizeBytes })) - } else { - sessions = (await listRecentCodexSessions(15)).map((r) => ({ provider, sessionId: r.sessionId, project: r.project, title: r.title, mtimeMs: r.mtimeMs, sizeBytes: r.sizeBytes })) - } + const refs = provider === 'claude' ? await listRecentTitledSessions(15) : await listRecentCodexSessions(15) + const sessions = refs.map((r) => ({ provider, sessionId: r.sessionId, project: r.project, title: r.title, mtimeMs: r.mtimeMs, sizeBytes: r.sizeBytes })) res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) res.end(JSON.stringify({ sessions })) return @@ -323,15 +322,20 @@ export async function runWebDashboard(opts: { writeJsonError(res, 400, 'provider (claude|codex) and id are required') return } - const refs = provider === 'claude' ? await listRecentSessions(5000) : await listCodexSessionRefs() - const ref = refs.find((r) => r.sessionId === id) - if (!ref) { + const ref = provider === 'claude' ? await findClaudeSession(id) : await findCodexSession(id) + if (!ref || ref.sessionId !== id) { writeJsonError(res, 404, `no ${provider} session ${id}`) return } const tree = await getContextTree(provider, ref) + const payload = { + ...tree, + session: { sessionId: tree.session.sessionId, project: tree.session.project, mtimeMs: tree.session.mtimeMs, sizeBytes: tree.session.sizeBytes }, + effectiveRows: snapshotRows(tree.effective), + fullRows: snapshotRows(tree.full), + } res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }) - res.end(JSON.stringify(tree)) + res.end(JSON.stringify(payload)) return }