mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
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.
This commit is contained in:
parent
0e0a3ad057
commit
bc5fc016ac
3 changed files with 238 additions and 19 deletions
|
|
@ -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);'))
|
||||
|
|
|
|||
209
src/context-tui.tsx
Normal file
209
src/context-tui.tsx
Normal file
|
|
@ -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<SessionRow[]> {
|
||||
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 (
|
||||
<Box flexDirection="column" marginLeft={4} marginBottom={1} paddingLeft={1} borderStyle="round" borderColor={DIM} width={72}>
|
||||
<Text color={DIM}>
|
||||
{headline.join(' · ')}
|
||||
</Text>
|
||||
<Text color={DIM}>
|
||||
showing <Text color={ORANGE}>{scope === 'effective' ? 'live window' : 'full history'}</Text> · press f to switch
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{rows.map((r, i) => (
|
||||
<Text key={i}>
|
||||
{' '.repeat(r.depth * 2)}
|
||||
<Text bold={r.bold} color={r.bold ? undefined : DIM}>
|
||||
{(r.label + ' ').padEnd(labelWidth - r.depth * 2, r.bold ? ' ' : '·')}
|
||||
</Text>
|
||||
<Text color={DIM}>{`${r.count.toLocaleString('en-US')}x`.padStart(countWidth)}</Text>
|
||||
<Text color={ORANGE} bold={r.bold}>
|
||||
{formatTokens(r.tokens).padStart(tokenWidth)}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextTuiApp() {
|
||||
const { exit } = useApp()
|
||||
const [provider, setProvider] = useState<Provider>('claude')
|
||||
const [sessions, setSessions] = useState<SessionRow[] | null>(null)
|
||||
const [cursor, setCursor] = useState(0)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(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<string, ContextTreeResult>())
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
<Box>
|
||||
<Text bold color={ORANGE}>
|
||||
Context{' '}
|
||||
</Text>
|
||||
{PROVIDERS.map((p) => (
|
||||
<Text key={p.key}>
|
||||
{' '}
|
||||
<Text bold={provider === p.key} color={provider === p.key ? undefined : DIM} inverse={provider === p.key}>
|
||||
{` ${p.label} `}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
<Text color={DIM}>{' ↑↓ move · enter expand · tab provider · f scope · q quit'}</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
|
||||
{!sessions && <Text color={DIM}>Loading sessions…</Text>}
|
||||
{sessions && sessions.length === 0 && <Text color={DIM}>No sessions found for this provider.</Text>}
|
||||
|
||||
{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 (
|
||||
<Box key={s.filePath} flexDirection="column">
|
||||
<Text>
|
||||
<Text color={ORANGE}>{selected ? '❯ ' : ' '}</Text>
|
||||
<Text color={selected ? ORANGE : undefined}>{s.sessionId.slice(0, 8)}</Text>
|
||||
<Text color={expanded || selected ? undefined : DIM}>
|
||||
{' '}
|
||||
{truncate(s.title || 'untitled session', titleWidth).padEnd(titleWidth)}
|
||||
</Text>
|
||||
<Text color={DIM}>
|
||||
{' '}
|
||||
{truncate(s.project, 12).padEnd(12)} {ago(s.mtimeMs).padStart(4)} {`${(s.sizeBytes / 1024 / 1024).toFixed(1)}MB`.padStart(8)}
|
||||
</Text>
|
||||
</Text>
|
||||
{expanded && !tree && (
|
||||
<Box marginLeft={4} marginBottom={1}>
|
||||
<Text color={ORANGE}>{SPINNER[frame % SPINNER.length]} </Text>
|
||||
<Text color={DIM}>reading transcript ({(s.sizeBytes / 1024 / 1024).toFixed(0)}MB)…</Text>
|
||||
</Box>
|
||||
)}
|
||||
{expanded && tree && <TreeDetails tree={tree} scope={scope} />}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
<Box height={1} />
|
||||
<Text color={DIM}>block tokens are estimates; context (exact) comes from API usage</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export async function runContextTui(): Promise<void> {
|
||||
patchStdoutForWindows()
|
||||
const instance = render(<ContextTuiApp />)
|
||||
await instance.waitUntilExit()
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue