codeburn/dash/src/components/ContextExplorer.tsx
Resham Joshi 9b20dfd7dc
feat(context): per-session context token tree across CLI, TUI, and dashboard (#592)
* feat(context): add experimental context command with per-session token tree

Reconstructs context breakdown for a Claude Code session
from its transcript: messages and tokens by role, block type, and tool,
split into full-session history vs the live window since the last
compaction (following preservedSegment.headUuid).

Block tokens are chars/4 estimates, images estimated from sniffed PNG or
JPEG dimensions, reasoning derived per message from output_tokens minus
visible output since transcripts strip thinking text. The exact context
size comes from the last assistant message's API usage, and the gap to
the estimate is shown as derived system prompt and tool overhead.

* feat(context): show session titles in context --list

Reads the latest ai-title entry (summary entry as fallback for older
sessions) from one tail and one head chunk of each transcript, so the
list stays fast on 100MB files.

* feat(dash): context explorer page with Claude Code and Codex session trees

Adds a Context page to the web dashboard: a navbar toggle, a provider
picker (Claude Code / Codex), the 15 most recent sessions with titles,
and per-session expandable details showing the context token tree, the
exact live context vs window, and a live/full-history scope toggle.

Server side adds /api/context/sessions and /api/context/tree with an
mtime-keyed tree cache, plus a Codex rollout builder: response items
feed the tree, compacted entries (with replacement_history) split live
window from full history, reasoning comes exact from cumulative
token_count totals, and model_context_window gives the real window.

* feat(context): interactive TUI for the context command

codeburn context with no arguments now opens an ink TUI: arrow keys to
move, enter to expand a session's token tree inline (with a spinner
while the transcript reads), tab to switch between Claude Code and
Codex, f to toggle live window vs full history, q to quit. Flag and id
forms keep the plain output for scripts, and non-TTY runs fall back to
the static list. Tree rows are shared with the static renderer via
snapshotRows.

* fix(context): review fixes across window detection, codex parsing, and surfaces

Window sizes are no longer guessed from token counts alone: opus-4-8 and
[1m] model ids map to the 1M window (a 190K opus-4-8 session used to
render as 95% of a 200K window), and Codex omits the percentage instead
of borrowing Anthropic constants when the rollout lacks
model_context_window.

Codex compaction accounting now reads the encrypted compaction item and
developer messages from replacement_history, so post-compaction windows
stop undercounting. Both builders stream with largeLineAsBuffer so one
oversized line cannot silently truncate the walk.

The tree API stops sending session.filePath, serves the flattened rows
so the dash renders the same tree as the CLI and TUI, resolves ids
directly instead of re-scanning every session file per request, and the
tree cache evicts LRU. Session discovery honors CLAUDE_CONFIG_DIRS,
CLAUDE_CONFIG_DIR, and CODEX_HOME, stats files in parallel, and id
lookups stat only matching files.

CLI gains --provider codex and machine-readable --list --json; --full
now opens the TUI in full scope instead of bypassing it. The TUI shows
build errors instead of a frozen spinner and drops the ref-plus-counter
repaint for plain state. The dashboard scopes the usage error banner and
device sidebar to the Usage page.
2026-07-02 05:00:03 +02:00

222 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
fetchContextSessions,
fetchContextTree,
type ContextProvider,
type ContextRow,
type ContextSessionInfo,
} from '@/lib/api'
import { cn, fmtNum, fmtTokens, label } from '@/lib/utils'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
const PROVIDERS: Array<{ key: ContextProvider; label: string }> = [
{ key: 'claude', label: 'Claude Code' },
{ 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 ago`
if (mins < 60 * 24) return `${Math.round(mins / 60)}h ago`
return `${Math.round(mins / (60 * 24))}d ago`
}
function TreeTable({ rows }: { rows: ContextRow[] }) {
const max = Math.max(1, ...rows.filter((r) => !r.bold).map((r) => r.tokens))
return (
<div className="flex flex-col">
{rows.map((r, i) => (
<div key={i} className={cn('relative flex items-center gap-3 rounded-sm px-2 py-[3px]', r.bold && i > 0 && 'mt-2')}>
{!r.bold && (
<span
className="absolute inset-y-[3px] left-0 rounded-sm bg-primary/[0.07]"
style={{ width: `${Math.max(1, (r.tokens / max) * 100)}%` }}
/>
)}
<span
className={cn('relative min-w-0 flex-1 truncate text-[13px]', r.bold ? 'font-semibold text-foreground' : 'text-muted-foreground')}
style={{ paddingLeft: r.depth * 16 }}
>
{r.label}
</span>
<span className="relative w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{fmtNum(r.count)}x</span>
<span className={cn('relative w-20 shrink-0 text-right text-[13px] tabular-nums', r.bold ? 'font-semibold text-foreground' : 'text-foreground')}>
{fmtTokens(r.tokens)}
</span>
</div>
))}
</div>
)
}
function Chip({ label: lbl, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border bg-interactive-secondary px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.1em] text-tertiary-foreground">{lbl}</div>
<div className="mt-0.5 text-sm font-medium tabular-nums text-foreground">{value}</div>
</div>
)
}
function SessionDetails({ provider, id }: { provider: ContextProvider; id: string }) {
const [scope, setScope] = useState<'effective' | 'full'>('effective')
const { data, isLoading, isError, error } = useQuery({
queryKey: ['context-tree', provider, id],
queryFn: () => fetchContextTree(provider, id),
staleTime: 60_000,
})
if (isLoading) {
return (
<div className="flex flex-col gap-2 px-4 py-4">
<Skeleton className="h-14" />
<Skeleton className="h-40" />
<p className="text-xs text-tertiary-foreground">Reading the whole transcript, large sessions take a few seconds</p>
</div>
)
}
if (isError || !data) {
return <p className="px-4 py-4 text-sm text-tertiary-foreground">Failed to load: {String((error as Error)?.message ?? 'unknown')}</p>
}
const view = scope === 'full' ? data.full : data.effective
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 (
<div className="flex flex-col gap-3 px-4 py-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Chip label="Messages" value={fmtNum(view.messages)} />
<Chip label="Est. tokens" value={fmtTokens(view.tokens)} />
<Chip
label="Context (exact)"
value={data.reported ? (window ? `${fmtTokens(data.reported.context)} / ${fmtTokens(window)}` : fmtTokens(data.reported.context)) : '—'}
/>
<Chip label="Compactions" value={fmtNum(data.compactions)} />
</div>
{pct !== null && (
<div>
<div className="mb-1 flex justify-between text-[11px] text-tertiary-foreground">
<span>{label(data.model)} · live context window</span>
<span className="tabular-nums">{pct}%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-interactive-secondary">
<div className={cn('h-full rounded-full', pct >= 80 ? 'bg-[#c8541f]' : 'bg-primary')} style={{ width: `${pct}%` }} />
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5">
{(['effective', 'full'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => setScope(s)}
className={cn(
'rounded-[5px] px-2.5 py-1 text-[11px] font-medium transition-colors',
scope === s ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
)}
>
{s === 'effective' ? 'Live window' : 'Full history'}
</button>
))}
</div>
<span className="text-[11px] text-tertiary-foreground">token counts are estimates; Context (exact) comes from API usage</span>
</div>
<TreeTable rows={rows} />
</div>
)
}
function SessionRow({ s, open, onToggle }: { s: ContextSessionInfo; open: boolean; onToggle: () => void }) {
return (
<div className={cn('border-t border-border first:border-t-0', open && 'bg-interactive-secondary/30')}>
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-interactive-secondary/50">
<svg
viewBox="0 0 16 16"
width="10"
height="10"
className={cn('shrink-0 text-tertiary-foreground transition-transform', open && 'rotate-90')}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 3l5 5-5 5" />
</svg>
<span className="shrink-0 font-mono text-xs text-primary">{s.sessionId.slice(0, 8)}</span>
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
{s.title || <span className="text-tertiary-foreground">untitled session</span>}
</span>
<span className="hidden shrink-0 text-xs text-tertiary-foreground sm:block">{s.project}</span>
<span className="w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{ago(s.mtimeMs)}</span>
<span className="w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{(s.sizeBytes / 1024 / 1024).toFixed(1)}MB</span>
</button>
{open && (
<div className="border-t border-border">
<SessionDetails provider={s.provider} id={s.sessionId} />
</div>
)}
</div>
)
}
export function ContextExplorer() {
const [provider, setProvider] = useState<ContextProvider>('claude')
const [openId, setOpenId] = useState<string | null>(null)
const { data, isLoading, isError, error } = useQuery({
queryKey: ['context-sessions', provider],
queryFn: () => fetchContextSessions(provider),
staleTime: 30_000,
})
return (
<>
<div className="mb-3 flex items-center gap-2">
{PROVIDERS.map((p) => (
<button
key={p.key}
type="button"
onClick={() => {
setProvider(p.key)
setOpenId(null)
}}
className={cn(
'rounded-md border px-3.5 py-1.5 text-xs font-medium transition-colors',
provider === p.key
? 'border-primary/40 bg-primary/10 text-foreground'
: 'border-border bg-card text-tertiary-foreground hover:text-foreground',
)}
>
{p.label}
</button>
))}
<span className="ml-auto text-xs text-tertiary-foreground">what fills each sessions context window, block by block</span>
</div>
<Card className="overflow-hidden">
{isLoading && (
<div className="flex flex-col gap-2 p-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-9" />
))}
</div>
)}
{isError && <p className="px-4 py-6 text-sm text-tertiary-foreground">Failed to load sessions: {String((error as Error)?.message)}</p>}
{data && data.length === 0 && <p className="px-4 py-6 text-sm text-tertiary-foreground">No sessions found for this provider.</p>}
{data?.map((s) => (
<SessionRow key={s.sessionId} s={s} open={openId === s.sessionId} onToggle={() => setOpenId(openId === s.sessionId ? null : s.sessionId)} />
))}
</Card>
</>
)
}