From bc5fc016acec842ca70ede8d44ddf5b3e1847e1a Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Thu, 2 Jul 2026 01:20:17 +0200 Subject: [PATCH] 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. --- src/context-tree.ts | 41 +++++---- src/context-tui.tsx | 209 ++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 7 +- 3 files changed, 238 insertions(+), 19 deletions(-) create mode 100644 src/context-tui.tsx diff --git a/src/context-tree.ts b/src/context-tree.ts index 642dc5e..caff7e5 100644 --- a/src/context-tree.ts +++ b/src/context-tree.ts @@ -564,10 +564,29 @@ function relativeAge(mtimeMs: number): string { return `${Math.round(mins / (60 * 24))}d ago` } -type Row = { depth: number; label: string; count: number; tokens: number; bold?: boolean } +export type ContextRow = { depth: number; label: string; count: number; tokens: number; bold?: boolean } -function renderRows(rows: Row[]): string[] { - const leftLen = (r: Row): number => r.depth * 2 + (r.depth > 0 ? 2 : 0) + r.label.length +// 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 }) + rows.push({ depth: 1, label: 'text', count: view.assistant.text.count, tokens: view.assistant.text.tokens }) + if (view.assistant.reasoning.count > 0) rows.push({ depth: 1, label: 'reasoning', count: view.assistant.reasoning.count, tokens: view.assistant.reasoning.tokens }) + rows.push({ depth: 1, label: 'tool-call', count: view.assistant.toolCall.count, tokens: view.assistant.toolCall.tokens }) + for (const t of view.assistant.byTool) rows.push({ depth: 2, label: t.tool, count: t.count, tokens: t.tokens }) + rows.push({ depth: 0, label: 'user', count: view.user.count, tokens: view.user.tokens, bold: true }) + rows.push({ depth: 1, label: 'text', count: view.user.text.count, tokens: view.user.text.tokens }) + if (view.user.image.count > 0) rows.push({ depth: 1, label: 'image', count: view.user.image.count, tokens: view.user.image.tokens }) + if (view.user.compactSummary.count > 0) rows.push({ depth: 1, label: 'compact-summary', count: view.user.compactSummary.count, tokens: view.user.compactSummary.tokens }) + if (view.user.meta.count > 0) rows.push({ depth: 1, label: 'meta', count: view.user.meta.count, tokens: view.user.meta.tokens }) + rows.push({ depth: 0, label: 'tool', count: view.toolResult.count, tokens: view.toolResult.tokens, bold: true }) + rows.push({ depth: 1, label: 'tool-result', count: view.toolResult.count, tokens: view.toolResult.tokens }) + if (view.system.count > 0) rows.push({ depth: 0, label: 'system', count: view.system.count, tokens: view.system.tokens, bold: true }) + return rows +} + +function renderRows(rows: ContextRow[]): string[] { + const leftLen = (r: ContextRow): number => r.depth * 2 + (r.depth > 0 ? 2 : 0) + r.label.length const labelWidth = Math.max(...rows.map(leftLen)) + 2 const countWidth = Math.max(...rows.map((r) => num(r.count).length)) + 1 const tokenWidth = Math.max(...rows.map((r) => num(r.tokens).length)) @@ -612,21 +631,7 @@ export function renderContextTree(result: ContextTreeResult, opts: { full?: bool } lines.push('') - const rows: Row[] = [] - rows.push({ depth: 0, label: 'assistant', count: view.assistant.count, tokens: view.assistant.tokens, bold: true }) - rows.push({ depth: 1, label: 'text', count: view.assistant.text.count, tokens: view.assistant.text.tokens }) - if (view.assistant.reasoning.count > 0) rows.push({ depth: 1, label: 'reasoning', count: view.assistant.reasoning.count, tokens: view.assistant.reasoning.tokens }) - rows.push({ depth: 1, label: 'tool-call', count: view.assistant.toolCall.count, tokens: view.assistant.toolCall.tokens }) - for (const t of view.assistant.byTool) rows.push({ depth: 2, label: t.tool, count: t.count, tokens: t.tokens }) - rows.push({ depth: 0, label: 'user', count: view.user.count, tokens: view.user.tokens, bold: true }) - rows.push({ depth: 1, label: 'text', count: view.user.text.count, tokens: view.user.text.tokens }) - if (view.user.image.count > 0) rows.push({ depth: 1, label: 'image', count: view.user.image.count, tokens: view.user.image.tokens }) - if (view.user.compactSummary.count > 0) rows.push({ depth: 1, label: 'compact-summary', count: view.user.compactSummary.count, tokens: view.user.compactSummary.tokens }) - if (view.user.meta.count > 0) rows.push({ depth: 1, label: 'meta', count: view.user.meta.count, tokens: view.user.meta.tokens }) - rows.push({ depth: 0, label: 'tool', count: view.toolResult.count, tokens: view.toolResult.tokens, bold: true }) - rows.push({ depth: 1, label: 'tool-result', count: view.toolResult.count, tokens: view.toolResult.tokens }) - if (view.system.count > 0) rows.push({ depth: 0, label: 'system', count: view.system.count, tokens: view.system.tokens, bold: true }) - lines.push(...renderRows(rows)) + lines.push(...renderRows(snapshotRows(view))) lines.push('') lines.push(chalk.dim(' block tokens are estimated (chars/4, images by pixel count, reasoning from per-message usage);')) diff --git a/src/context-tui.tsx b/src/context-tui.tsx new file mode 100644 index 0000000..c7a229d --- /dev/null +++ b/src/context-tui.tsx @@ -0,0 +1,209 @@ +import React, { useEffect, useRef, 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, + snapshotRows, + type ContextTreeResult, + type SessionRef, +} from './context-tree.js' +import { buildCodexContextTree, listRecentCodexSessions } from './context-tree-codex.js' + +type Provider = 'claude' | 'codex' +type SessionRow = SessionRef & { title: string } + +const ORANGE = '#FF8C42' +const DIM = '#555555' +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const PROVIDERS: Array<{ key: Provider; 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` + 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] ?? '' })) +} + +function TreeDetails({ tree, scope }: { tree: ContextTreeResult; scope: 'effective' | 'full' }) { + 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 + const countWidth = Math.max(...rows.map((r) => `${r.count}x`.length)) + 1 + const tokenWidth = Math.max(...rows.map((r) => formatTokens(r.tokens).length)) + 2 + + 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}%)`) + } + if (tree.compactions > 0) headline.push(`${tree.compactions} compaction${tree.compactions === 1 ? '' : 's'}`) + + return ( + + + {headline.join(' · ')} + + + showing {scope === 'effective' ? 'live window' : 'full history'} · press f to switch + + + {rows.map((r, i) => ( + + {' '.repeat(r.depth * 2)} + + {(r.label + ' ').padEnd(labelWidth - r.depth * 2, r.bold ? ' ' : '·')} + + {`${r.count.toLocaleString('en-US')}x`.padStart(countWidth)} + + {formatTokens(r.tokens).padStart(tokenWidth)} + + + ))} + + ) +} + +function ContextTuiApp() { + const { exit } = useApp() + const [provider, setProvider] = useState('claude') + const [sessions, setSessions] = useState(null) + const [cursor, setCursor] = useState(0) + const [expandedId, setExpandedId] = useState(null) + const [scope, setScope] = useState<'effective' | 'full'>('effective') + const [building, setBuilding] = useState(false) + const [frame, setFrame] = useState(0) + const [, forceRender] = useState(0) + const trees = useRef(new Map()) + + useEffect(() => { + let alive = true + setSessions(null) + setCursor(0) + setExpandedId(null) + void loadSessions(provider).then((rows) => { + if (alive) setSessions(rows) + }) + return () => { + alive = false + } + }, [provider]) + + useEffect(() => { + if (!building) return + const t = setInterval(() => setFrame((f) => f + 1), 100) + return () => clearInterval(t) + }, [building]) + + const toggleExpand = (session: SessionRow) => { + if (expandedId === session.sessionId) { + setExpandedId(null) + return + } + setExpandedId(session.sessionId) + const key = `${provider}:${session.sessionId}:${session.mtimeMs}` + if (trees.current.has(key)) return + setBuilding(true) + const build = provider === 'claude' ? buildContextTree(session) : buildCodexContextTree(session) + void build + .then((tree) => { + trees.current.set(key, tree) + forceRender((n) => n + 1) + }) + .catch(() => {}) + .finally(() => setBuilding(false)) + } + + useInput((input, key) => { + if (input === 'q' || key.escape) { + exit() + return + } + if (key.upArrow || input === 'k') setCursor((c) => Math.max(0, c - 1)) + if (key.downArrow || input === 'j') setCursor((c) => Math.min((sessions?.length ?? 1) - 1, c + 1)) + if (key.tab || key.leftArrow || key.rightArrow) setProvider((p) => (p === 'claude' ? 'codex' : 'claude')) + if (input === 'f') setScope((s) => (s === 'effective' ? 'full' : 'effective')) + if ((key.return || input === ' ') && sessions && sessions[cursor]) toggleExpand(sessions[cursor]) + }) + + const titleWidth = 46 + + return ( + + + + Context{' '} + + {PROVIDERS.map((p) => ( + + {' '} + + {` ${p.label} `} + + + ))} + {' ↑↓ move · enter expand · tab provider · f scope · q quit'} + + + + {!sessions && Loading sessions…} + {sessions && sessions.length === 0 && No sessions found for this provider.} + + {sessions?.map((s, i) => { + const selected = i === cursor + const expanded = expandedId === s.sessionId + const key = `${provider}:${s.sessionId}:${s.mtimeMs}` + const tree = trees.current.get(key) + return ( + + + {selected ? '❯ ' : ' '} + {s.sessionId.slice(0, 8)} + + {' '} + {truncate(s.title || 'untitled session', titleWidth).padEnd(titleWidth)} + + + {' '} + {truncate(s.project, 12).padEnd(12)} {ago(s.mtimeMs).padStart(4)} {`${(s.sizeBytes / 1024 / 1024).toFixed(1)}MB`.padStart(8)} + + + {expanded && !tree && ( + + {SPINNER[frame % SPINNER.length]} + reading transcript ({(s.sizeBytes / 1024 / 1024).toFixed(0)}MB)… + + )} + {expanded && tree && } + + ) + })} + + + block tokens are estimates; context (exact) comes from API usage + + ) +} + +export async function runContextTui(): Promise { + patchStdoutForWindows() + const instance = render() + await instance.waitUntilExit() +} diff --git a/src/main.ts b/src/main.ts index 044c1b2..f5b0673 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1338,11 +1338,16 @@ program program .command('context [session]') - .description('Context token breakdown for a Claude Code session: what fills the window, by role, block type, and tool (experimental)') + .description('Context token breakdown per session: what fills the window, by role, block type, and tool (experimental). No arguments 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) { + const { runContextTui } = await import('./context-tui.js') + await runContextTui() + return + } await runContextCommand(session, opts) })