Skip to content

Commit 4a4bc89

Browse files
committed
Add a context-window panel for both terminal and chat agents
Shows what is actually occupying an agent's context window right now: system prompt + tools, CLAUDE.md, the carried compaction summary, harness attachments, messages, and tool output broken out per tool — plus the tools that are still discoverable but not yet loaded. Works for terminal (xterm) and chat (json-mode) tabs from one code path, because both leave the same artifact: a session jsonl under ~/.claude/projects. Terminal tabs refresh on the Stop hook, chat tabs on the jsonClaude busy->false boundary, and both backfill when a client first expands the panel. This is not the costs slice with different units. That one folds the whole transcript forward to answer "what did this session cost"; this one answers "what is in the window", which means following only the live era (everything after the last compaction), dropping subagent sidechains, and treating the several assistant records that share a requestId as the single API message they are. The numbers are anchored rather than estimated. Each turn's usage record gives the exact prompt size, so the delta between consecutive turns is exactly what the content in between added; that measured delta is what gets split across the tool results and messages by char proportion. Estimation error stays local to a turn instead of compounding. The system+tools baseline then falls out as the residual. Validated against real transcripts: the categories sum exactly to the headline number across a dozen sessions, and analysis runs in 18-140ms on 9-50MB files. Two things that only showed up against real data: per-turn attribution drifts unbounded without a normalisation pass (prior thinking blocks leave the window, and rewinds make deltas negative — one session read 597k against a 200k window), and the model table cannot be trusted for the window size, because the 1M beta is per-session and never appears in the model id.
1 parent c9f2639 commit 4a4bc89

17 files changed

Lines changed: 1662 additions & 0 deletions

src/main/build-initial-state.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { initialTerminals } from '../shared/state/terminals'
77
import { initialUpdater } from '../shared/state/updater'
88
import { initialRepoConfigs } from '../shared/state/repo-configs'
99
import { initialCosts } from '../shared/state/costs'
10+
import { initialContextWindow } from '../shared/state/context-window'
1011
import { initialBrowser } from '../shared/state/browser'
1112
import {
1213
initialJsonClaude,
@@ -82,6 +83,9 @@ export function buildInitialAppState(
8283
updater: initialUpdater,
8384
repoConfigs: initialRepoConfigs,
8485
costs: config.costs ? { ...initialCosts, ...config.costs } : initialCosts,
86+
// Not persisted — occupancy is recomputed from the transcript on the
87+
// first turn boundary after a client opens the panel.
88+
contextWindow: initialContextWindow,
8589
browser: initialBrowser,
8690
jsonClaude: initialJsonClaude,
8791
snooze: config.snooze ? { byPath: { ...config.snooze } } : initialSnooze,

src/main/context-tracker.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// Keeps the contextWindow slice fresh for both kinds of agent tab.
2+
//
3+
// The two tab types reach Claude by different routes but leave the same
4+
// artifact, so this tracker only needs one analyzer:
5+
// - terminal tabs (xterm-hosted `claude`) fire the Stop hook, which
6+
// hands us `transcript_path` directly.
7+
// - chat tabs (json-mode) have hooks scrubbed, so we key off the
8+
// `jsonClaude/busyChanged -> false` boundary and derive the path from
9+
// worktreePath + sessionId, the same encoding claude uses.
10+
//
11+
// Same interest-gating as CostTracker: the panel is collapsed by default,
12+
// and re-analyzing a multi-megabyte transcript on every turn for a panel
13+
// nobody has open is pure waste. While no client is interested this does
14+
// nothing but remember the last Stop per terminal, so opening the panel
15+
// can backfill without waiting for another turn.
16+
//
17+
// Unlike CostTracker this cannot parse incrementally. Context occupancy
18+
// is a property of the live era, and a compaction retroactively deletes
19+
// most of what came before it — there's no forward-only fold that
20+
// survives that. Full reparse is affordable because it's gated on the
21+
// panel being open and measures 18-140ms on 9-50MB transcripts.
22+
23+
import { existsSync, readFileSync, statSync } from 'fs'
24+
import { homedir } from 'os'
25+
import { join } from 'path'
26+
import type { Store } from './store'
27+
import type { StateEvent } from '../shared/state'
28+
import { onStopEvent, type StopEvent } from './hooks'
29+
import { findLeafByTabId, getLeaves } from '../shared/state/terminals'
30+
import type { ContextSnapshot } from '../shared/state/context-window'
31+
import { analyzeContext } from './context-window'
32+
import { latestSessionId } from './agents/claude'
33+
import { log } from './debug'
34+
35+
/** Same path encoding claude uses for its own session files. */
36+
function transcriptPathFor(worktreePath: string, sessionId: string): string {
37+
return join(
38+
homedir(),
39+
'.claude',
40+
'projects',
41+
worktreePath.replace(/[^a-zA-Z0-9]/g, '-'),
42+
`${sessionId}.jsonl`
43+
)
44+
}
45+
46+
/** Byte size of the CLAUDE.md files claude would have loaded for this
47+
* worktree. Only the size matters — the analyzer uses it to carve a
48+
* memory-file estimate out of the system-prompt residual, since the API
49+
* never reports that separately. */
50+
function memoryCharsFor(worktreePath: string): number {
51+
let total = 0
52+
for (const p of [
53+
join(worktreePath, 'CLAUDE.md'),
54+
join(worktreePath, '.claude', 'CLAUDE.md'),
55+
join(homedir(), '.claude', 'CLAUDE.md')
56+
]) {
57+
try {
58+
if (existsSync(p)) total += statSync(p).size
59+
} catch {
60+
/* unreadable — just don't count it */
61+
}
62+
}
63+
return total
64+
}
65+
66+
export class ContextTracker {
67+
private unsubscribeHook: (() => void) | null = null
68+
private unsubscribeStore: (() => void) | null = null
69+
private interestedClients = new Set<string>()
70+
private lastStops = new Map<string, StopEvent>()
71+
72+
constructor(private store: Store) {}
73+
74+
start(): void {
75+
this.unsubscribeHook = onStopEvent((ev) => this.handleStop(ev))
76+
this.unsubscribeStore = this.store.subscribe((event) => this.handleStoreEvent(event))
77+
}
78+
79+
stop(): void {
80+
this.unsubscribeHook?.()
81+
this.unsubscribeHook = null
82+
this.unsubscribeStore?.()
83+
this.unsubscribeStore = null
84+
}
85+
86+
setClientInterested(clientId: string, expanded: boolean): void {
87+
const wasZero = this.interestedClients.size === 0
88+
if (expanded) this.interestedClients.add(clientId)
89+
else this.interestedClients.delete(clientId)
90+
if (wasZero && this.interestedClients.size > 0) this.backfillAll()
91+
}
92+
93+
removeClient(clientId: string): void {
94+
this.interestedClients.delete(clientId)
95+
}
96+
97+
private handleStop(ev: StopEvent): void {
98+
this.lastStops.set(ev.terminalId, ev)
99+
if (this.interestedClients.size === 0) return
100+
this.analyzeAndDispatch(ev.terminalId, ev.sessionId, ev.transcriptPath, this.worktreeForTerminal(ev.terminalId))
101+
}
102+
103+
private handleStoreEvent(event: StateEvent): void {
104+
if (event.type === 'jsonClaude/busyChanged' && event.payload.busy === false) {
105+
this.refreshJsonMode(event.payload.sessionId)
106+
return
107+
}
108+
if (event.type === 'jsonClaude/sessionStarted') {
109+
this.refreshJsonMode(event.payload.sessionId)
110+
return
111+
}
112+
if (event.type === 'terminals/removed') {
113+
this.lastStops.delete(event.payload)
114+
this.store.dispatch({
115+
type: 'contextWindow/terminalCleared',
116+
payload: { terminalId: event.payload }
117+
})
118+
}
119+
}
120+
121+
private refreshJsonMode(sessionId: string): void {
122+
if (this.interestedClients.size === 0) return
123+
const session = this.store.getSnapshot().state.jsonClaude.sessions[sessionId]
124+
if (!session) return
125+
// Chat tabs pin the tab id as the claude session id, so terminalId
126+
// and sessionId are the same value here.
127+
this.analyzeAndDispatch(
128+
sessionId,
129+
sessionId,
130+
transcriptPathFor(session.worktreePath, sessionId),
131+
session.worktreePath
132+
)
133+
}
134+
135+
/** Worktree that owns a terminal tab, for locating its CLAUDE.md. */
136+
private worktreeForTerminal(terminalId: string): string | null {
137+
const panes = this.store.getSnapshot().state.terminals.panes
138+
for (const [worktreePath, tree] of Object.entries(panes)) {
139+
if (findLeafByTabId(tree, terminalId)) return worktreePath
140+
}
141+
return null
142+
}
143+
144+
private analyzeAndDispatch(
145+
terminalId: string,
146+
sessionId: string,
147+
transcriptPath: string,
148+
worktreePath: string | null
149+
): void {
150+
let raw: string
151+
try {
152+
raw = readFileSync(transcriptPath, 'utf-8')
153+
} catch {
154+
// Transcript not written yet (first turn) — nothing to report, and
155+
// the next turn boundary will pick it up.
156+
return
157+
}
158+
try {
159+
const analysis = analyzeContext(raw, worktreePath ? memoryCharsFor(worktreePath) : 0)
160+
const snapshot: ContextSnapshot = {
161+
sessionId,
162+
transcriptPath,
163+
model: analysis.model,
164+
limit: analysis.limit,
165+
usedTokens: analysis.usedTokens,
166+
categories: analysis.categories,
167+
autocompactAt: analysis.autocompactAt,
168+
compactions: analysis.compactions,
169+
discoverableTools: analysis.discoverableTools,
170+
measured: analysis.measured,
171+
updatedAt: Date.now()
172+
}
173+
this.store.dispatch({
174+
type: 'contextWindow/snapshotUpdated',
175+
payload: { terminalId, snapshot }
176+
})
177+
} catch (err) {
178+
log(
179+
'context-tracker',
180+
`failed to analyze ${transcriptPath}: ${err instanceof Error ? err.message : err}`
181+
)
182+
}
183+
}
184+
185+
/** Re-analyze everything we know about. Runs when the first client
186+
* opens the panel, so it populates without waiting for a turn. */
187+
private backfillAll(): void {
188+
for (const ev of this.lastStops.values()) {
189+
this.analyzeAndDispatch(
190+
ev.terminalId,
191+
ev.sessionId,
192+
ev.transcriptPath,
193+
this.worktreeForTerminal(ev.terminalId)
194+
)
195+
}
196+
const state = this.store.getSnapshot().state
197+
for (const sessionId of Object.keys(state.jsonClaude.sessions)) {
198+
this.refreshJsonMode(sessionId)
199+
}
200+
// Terminal tabs that haven't fired a Stop this run (app restarted
201+
// mid-session) still have a transcript on disk.
202+
//
203+
// A transcript belongs to exactly one tab, so claim them: the
204+
// latestSessionId fallback below would otherwise hand the same file to
205+
// every session-id-less tab in a worktree, and showing two tabs the
206+
// same wrong numbers is worse than showing one of them nothing. Tabs
207+
// that lose the race stay empty until their first Stop event, which
208+
// tells us their real transcript.
209+
const claimed = new Set<string>()
210+
for (const ev of this.lastStops.values()) claimed.add(ev.transcriptPath)
211+
212+
for (const [worktreePath, tree] of Object.entries(state.terminals.panes)) {
213+
for (const leaf of getLeaves(tree)) {
214+
for (const tab of leaf.tabs) {
215+
if (tab.type !== 'agent') continue
216+
if (this.lastStops.has(tab.id)) continue
217+
const cwd = tab.cwd || worktreePath
218+
// Prefer the tab's own session id, but fall back to the most
219+
// recent transcript in the worktree. Tabs created before session
220+
// ids were assigned — and tabs whose id changed under `/clear`
221+
// before any hook fired — have no sessionId, and without this
222+
// fallback the panel stays empty for them forever.
223+
const own =
224+
tab.sessionId && existsSync(transcriptPathFor(cwd, tab.sessionId))
225+
? tab.sessionId
226+
: null
227+
const sessionId = own ?? latestSessionId(cwd)
228+
if (!sessionId) continue
229+
const path = transcriptPathFor(cwd, sessionId)
230+
if (!existsSync(path) || claimed.has(path)) continue
231+
claimed.add(path)
232+
this.analyzeAndDispatch(tab.id, sessionId, path, cwd)
233+
}
234+
}
235+
}
236+
}
237+
}

0 commit comments

Comments
 (0)