A band above the prompt showing the model, context-window usage and version-control state

A Claude Code mod that shows the model, context usage and repository status in a line above the prompt.
Opus 5.5 · Effort: high · Context: 24k (18%) · [GitHub/my-project/main*] +12 -3
<!-- TODO: add a screenshot of the band, e.g.
-->
settings.json.In Claude Code:
/plugin install context-watch --marketplace zbshadow/ContextWatch
Answer y to add the marketplace, then choose a scope.
From a terminal:
claude plugin install context-watch --marketplace zbshadow/ContextWatch
In the Claude desktop app: click + next to the prompt box, choose Plugins → Add marketplace, enter zbshadow/ContextWatch, then click Add plugin.
claude plugin update context-watch@context-watch
Restart Claude Code to load the new version.
claude plugin uninstall context-watch@context-watch
To disable without uninstalling: claude plugin disable context-watch@context-watch.
| Segment | Example | Meaning |
|---|---|---|
| Model | Opus 5.5 | Active model |
| Effort | Effort: high | Reasoning effort; shown after the first prompt |
| Context | Context: 24k (18%) | Tokens used and percentage of the context window |
| Repository | [GitHub/my-project/main*] | Platform, repo and branch; * means uncommitted changes |
| Changes | +12 -3 | Lines added and removed since the last commit |
| Color | Default range |
|---|---|
| Green | under 70k tokens |
| Yellow | 70k – 100k tokens |
| Red | over 100k tokens |
In the red zone, click Compact or press c while the band is focused to run /compact. The button is hidden while Claude is working.
/context-watch toggles the band. The setting persists across sessions.
Set options in /config, or from a terminal:
claude plugin configure context-watch@context-watch
| Option | Default | Description |
|---|---|---|
yellowAt | 70000 | Tokens at which the meter turns yellow |
redAbove | 100000 | Tokens above which the meter turns red |
showVcs | true | Show the repository segment |
Thresholds are token counts, not percentages.
The band isn't showing. Run /context-watch to show it. If it still doesn't appear, check that the plugin is enabled with claude plugin list.
Effort isn't shown. It appears after the first prompt of a session, and is omitted for models without effort levels.
Updates are delayed under organization-managed settings. Some managed installs restrict which events plugins receive. The band still refreshes after each tool call and response, but model switches and commits made outside the session can take longer to appear.
Issues and pull requests are welcome.
git clone https://github.com/zbshadow/ContextWatch.git
cd ContextWatch
claude --plugin-dir . # run with live reload
claude plugin test . # run tests
claude plugin validate . # validate the plugin
Design notes: docs/DESIGN.md.
MIT © Brice Bayless
hooks/register.tsx 298 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage } from 'claude-code'
3
4import type { Snapshot, VcsState } from '../types'
5import { classify, displayModel, fitSegments, formatTokens, resolveThresholds } from './meter'
6import type { Thresholds } from './meter'
7import { detectVcs } from './vcs'
8import type { Exists, Run } from './vcs'
9
10const STORE_HIDDEN = 'isHidden'
11const RUN_TIMEOUT_MS = 3000
12// The columns the band keeps for the Compact button beside the line.
13const COMPACT_BUTTON_COLUMNS = 14
14const VCS_TOOLS = ['Bash', 'Edit', 'Write', 'NotebookEdit'] as const
15// Files under the git dir that move on checkout, commit, stage and reset.
16const GIT_WATCH_FILES = ['HEAD', 'index'] as const
17
18const snapshotAtom = atom({ plugin: 'context-watch', key: 'snapshot' } as const, null)
19const isHiddenAtom = atom({ plugin: 'context-watch', key: 'isHidden' } as const, false)
20// Whether the toast for this stay in the red zone has been shown; kept in $.state so a reload doesn't repeat it.
21const isRedNotifiedAtom = atom({ plugin: 'context-watch', key: 'isRedNotified' } as const, false)
22
23type Settings = { thresholds: Thresholds; showVcs: boolean }
24type Pass = 'snapshot' | 'vcs'
25
26// One pass of each kind at a time: a request while one runs asks for one more pass after it.
27const gates: Record<Pass, { running: Promise<void> | null; isStale: boolean }> = {
28 snapshot: { running: null, isStale: false },
29 vcs: { running: null, isStale: false },
30}
31// The git files the engine watches for this mod (classic.FileChanged).
32let watchedPaths: string[] = []
33
34function runner($: EngineInterface, cwd: string): Run {
35 return async argv => {
36 const { exitCode, stdout } = await $.process.run(argv, { cwd, timeoutMs: RUN_TIMEOUT_MS })
37 if (exitCode !== 0) throw new Error(`${argv.join(' ')} exited ${exitCode}`)
38 return stdout.trim()
39 }
40}
41
42async function readVcs($: EngineInterface, settings: Settings): Promise<VcsState> {
43 if (!settings.showVcs) return { type: 'none' }
44 const cwd = await $.session.cwd()
45 const exists: Exists = path => $.fs.exists(path)
46 return detectVcs(runner($, cwd), cwd, exists)
47}
48
49/** The whole snapshot: model, usage and VCS. Session start only; later passes move one part. */
50async function refresh($: EngineInterface, settings: Settings): Promise<void> {
51 try {
52 const [usage, model, vcs] = await Promise.all([
53 $.session.usage(),
54 $.session.model().catch(() => ''),
55 readVcs($, settings),
56 ])
57 const snapshot: Snapshot = {
58 model: displayModel(model),
59 // No engine getter for effort: it is learned from the next turn.step.
60 effort: (await read($, snapshotAtom))?.effort ?? null,
61 tokens: usage.context.tokens ?? 0,
62 percent: usage.context.percent ?? 0,
63 vcs,
64 }
65 await update($, snapshotAtom, () => snapshot)
66 await noteZone($, settings)
67 } catch {
68 // keep the previous snapshot
69 }
70}
71
72async function refreshVcs($: EngineInterface, settings: Settings): Promise<void> {
73 if (!settings.showVcs) return
74 try {
75 const vcs = await readVcs($, settings)
76 await update($, snapshotAtom, snapshot => (snapshot ? { ...snapshot, vcs } : snapshot))
77 } catch {
78 // keep the previous VCS state
79 }
80}
81
82/** Toasts once on entering the red zone; leaving it re-arms the toast. */
83async function noteZone($: EngineInterface, settings: Settings): Promise<void> {
84 const snapshot = await read($, snapshotAtom)
85 if (!snapshot) return
86 const isRed = classify(snapshot.tokens, settings.thresholds) === 'red'
87 const isNotified = await read($, isRedNotifiedAtom)
88 if (isRed === isNotified) return
89 await update($, isRedNotifiedAtom, () => isRed)
90 if (isRed) {
91 $.ui.toast(`Context is at ${formatTokens(snapshot.tokens)} (${Math.round(snapshot.percent)}%): consider /compact or /clear`)
92 }
93}
94
95async function applyUsage($: EngineInterface, settings: Settings, context: SessionContextUsage): Promise<void> {
96 // A window with no response yet has no fill: keep what is shown.
97 if (context.tokens === undefined) return
98 const tokens = context.tokens
99 const percent = context.percent ?? 0
100 await update($, snapshotAtom, snapshot => (snapshot ? { ...snapshot, tokens, percent } : snapshot))
101 await noteZone($, settings)
102}
103
104/** Usage alone, read from the engine: no processes, cheap enough to run after every tool. */
105async function refreshUsage($: EngineInterface, settings: Settings): Promise<void> {
106 try {
107 await applyUsage($, settings, (await $.session.usage()).context)
108 } catch {
109 // keep the previous usage
110 }
111}
112
113/** The band's Compact button: `/compact`'s own call. It skips this mod's session.compact hook, so usage is re-read here. */
114async function compactNow($: EngineInterface, settings: Settings): Promise<void> {
115 try {
116 const { skip } = await $.session.compact()
117 if (skip) return
118 } catch (error) {
119 // The button is hidden mid-turn, so a refusal here is the engine's own (a turn just started, nothing to compact).
120 $.ui.toast(`Couldn't compact${error instanceof Error ? `: ${error.message}` : ''}`)
121 return
122 }
123 await refreshUsage($, settings)
124}
125
126async function applyModel($: EngineInterface, model: string): Promise<void> {
127 const shown = displayModel(model)
128 await update($, snapshotAtom, snapshot => (snapshot ? { ...snapshot, model: shown } : snapshot))
129}
130
131async function applyEffort($: EngineInterface, effort: string | number | null): Promise<void> {
132 try {
133 await update($, snapshotAtom, snapshot =>
134 snapshot && snapshot.effort !== effort ? { ...snapshot, effort } : snapshot,
135 )
136 } catch {
137 // never hold up the model request: keep the previous effort
138 }
139}
140
141function requestRefresh($: EngineInterface, settings: Settings, pass: Pass): Promise<void> {
142 const gate = gates[pass]
143 if (gate.running) {
144 gate.isStale = true
145 return gate.running
146 }
147 gate.running = (async () => {
148 try {
149 do {
150 gate.isStale = false
151 if (pass === 'vcs') await refreshVcs($, settings)
152 else await refresh($, settings)
153 } while (gate.isStale)
154 } finally {
155 gate.running = null
156 }
157 })()
158 return gate.running
159}
160
161/** The git dir's files worth watching, absolute; none outside a git repo. */
162async function gitWatchPaths($: EngineInterface, cwd: string): Promise<string[]> {
163 try {
164 const gitDir = await runner($, cwd)(['git', 'rev-parse', '--absolute-git-dir'])
165 return gitDir ? GIT_WATCH_FILES.map(file => `${gitDir}/${file}`) : []
166 } catch {
167 return []
168 }
169}
170
171export const register: Register = (on, options) => {
172 const { thresholds, warning } = resolveThresholds(options)
173 const settings: Settings = { thresholds, showVcs: options.showVcs !== false }
174 let hasWarned = false
175
176 on('session.start', async ($, e, next) => {
177 const result = await next(e)
178 if (warning && !hasWarned) {
179 hasWarned = true
180 $.ui.log(warning)
181 }
182 await $.command.register({ name: 'context-watch', description: 'Show or hide the context meter band' })
183 const stored = await $.store.get(STORE_HIDDEN).catch(() => undefined)
184 await update($, isHiddenAtom, () => stored === true)
185 await requestRefresh($, settings, 'snapshot')
186 return result
187 })
188
189 // Usage as the engine measures it, pushed after each main-thread turn.
190 on('session.measure', async ($, e, next) => {
191 const result = await next(e)
192 if (e.changed.includes('context')) await applyUsage($, settings, e.context)
193 return result
194 })
195
196 // Mid-turn, each tool call follows a response, so usage has moved; git may have too.
197 // Usage is one engine read, so it is awaited; git is not, so the tool result isn't held back.
198 on('tool.call', async ($, e, next) => {
199 const result = await next(e)
200 await refreshUsage($, settings)
201 if ((VCS_TOOLS as readonly string[]).includes(e.tool)) void requestRefresh($, settings, 'vcs')
202 return result
203 }).catch(($, e, next) => next(e))
204
205 // Effort as each main-loop request sends it (after the model's downgrade): /effort, /model, a fallback.
206 on('turn.step', async function* ($, e, next) {
207 if (e.agentId === undefined) await applyEffort($, e.effort ?? null)
208 return yield* next(e)
209 })
210
211 // A safety net for git changes made outside the session where no watch is set.
212 on('turn.complete', async ($, e, next) => {
213 const result = await next(e)
214 await requestRefresh($, settings, 'vcs')
215 return result
216 })
217
218 on('session.compact', async ($, e, next) => {
219 const result = await next(e)
220 await refreshUsage($, settings)
221 return result
222 }).catch(($, e, next) => next(e))
223
224 on('session.end', async ($, e, next) => {
225 const result = await next(e)
226 if (e.reason === 'clear') {
227 await update($, snapshotAtom, snapshot => (snapshot ? { ...snapshot, tokens: 0, percent: 0 } : snapshot))
228 await noteZone($, settings)
229 }
230 return result
231 })
232
233 // /model, the picker, fast mode or a fallback: show the new model now, not after the next turn.
234 on('classic.PostModelSwitch', async ($, e, next) => {
235 const result = await next(e)
236 await applyModel($, e.to_model)
237 await refreshUsage($, settings)
238 return result
239 }).catch(($, e, next) => next(e))
240
241 // Have the engine watch git's HEAD and index, so a checkout or commit in another terminal shows.
242 on('classic.SessionStart', async ($, e, next) => {
243 const result = await next(e)
244 if (!settings.showVcs) return result
245 watchedPaths = await gitWatchPaths($, e.cwd)
246 if (watchedPaths.length === 0) return result
247 return { ...result, watchPaths: [...(result.watchPaths ?? []), ...watchedPaths] }
248 }).catch(($, e, next) => next(e))
249
250 on('classic.FileChanged', async ($, e, next) => {
251 const result = await next(e)
252 if (watchedPaths.includes(e.file_path)) await requestRefresh($, settings, 'vcs')
253 return result
254 }).catch(($, e, next) => next(e))
255
256 // Entering or leaving a worktree moves the session to another checkout.
257 on('classic.CwdChanged', async ($, e, next) => {
258 const result = await next(e)
259 await requestRefresh($, settings, 'vcs')
260 return result
261 }).catch(($, e, next) => next(e))
262
263 on('command.run', { command: 'context-watch' }, async $ => {
264 const isHidden = !(await read($, isHiddenAtom))
265 await update($, isHiddenAtom, () => isHidden)
266 await $.store.set(STORE_HIDDEN, isHidden)
267 return { text: isHidden ? 'ContextWatch hidden.' : 'ContextWatch shown.' }
268 })
269
270 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
271 if (e.props.hasSurvey) return next(e)
272 const snapshot = await read($, snapshotAtom)
273 const isHidden = await read($, isHiddenAtom)
274 if (snapshot === null || isHidden) return next(e)
275
276 const { Box, Button, Text } = $.ui.resolve(e)
277 // Compacting is refused mid-turn, so the button shows only between turns.
278 const hasCompact = classify(snapshot.tokens, settings.thresholds) === 'red' && !e.props.isWorking
279 const columns = e.props.bodyColumns - (hasCompact ? COMPACT_BUTTON_COLUMNS : 0)
280 const segments = fitSegments(snapshot, columns, settings)
281
282 return (
283 <Box flexDirection="row" gap={1}>
284 <Text wrap="truncate-end">
285 {segments.map(segment => (
286 <Text color={segment.color} dimColor={segment.dim} bold={segment.bold} inverse={segment.inverse}>
287 {segment.text}
288 </Text>
289 ))}
290 </Text>
291 {hasCompact && (
292 <Button key="compact" label="Compact" hotkey="c" variant="primary" onPress={() => compactNow($, settings)} />
293 )}
294 </Box>
295 )
296 })
297}
298hooks/meter.ts 169 lines1import type { Snapshot, VcsState, Zone } from '../types'
2
3export type Thresholds = { yellowAt: number; redAbove: number }
4
5export const DEFAULT_THRESHOLDS: Thresholds = { yellowAt: 70_000, redAbove: 100_000 }
6
7export type SegmentColor = 'success' | 'warning' | 'error' | 'cyan'
8
9export type Segment = {
10 text: string
11 color?: SegmentColor
12 dim?: boolean
13 bold?: boolean
14 /** Swaps foreground and background: a colored badge. */
15 inverse?: boolean
16 /** What the segment is, so the band can drop the hint first when short of room. */
17 part: 'model' | 'effort' | 'label' | 'usage' | 'hint' | 'vcs' | 'added' | 'removed' | 'separator'
18}
19
20export type BuildOptions = {
21 thresholds?: Thresholds
22 showVcs?: boolean
23 showHint?: boolean
24}
25
26const ZONE_COLOR: Record<Zone, SegmentColor> = {
27 green: 'success',
28 yellow: 'warning',
29 red: 'error',
30}
31
32const SEPARATOR: Segment = { text: ' · ', dim: true, part: 'separator' }
33/** The red-zone hint as a red badge, so it reads as a warning rather than a footnote. */
34export const HINT: Segment = { text: ' Consider /compact or /clear ', color: 'error', bold: true, inverse: true, part: 'hint' }
35
36export function formatTokens(count: number): string {
37 if (count >= 1_000_000) return `${Math.round(count / 1_000_000)}M`
38 if (count >= 1_000) return `${Math.round(count / 1_000)}k`
39 return String(count)
40}
41
42export function classify(count: number, thresholds: Thresholds = DEFAULT_THRESHOLDS): Zone {
43 if (count < thresholds.yellowAt) return 'green'
44 if (count <= thresholds.redAbove) return 'yellow'
45 return 'red'
46}
47
48/**
49 * Reads the thresholds from the mod's options; falls back to the defaults
50 * (with a warning to log) when they are missing, not numbers, or out of order.
51 */
52export function resolveThresholds(options: { readonly [key: string]: unknown }): {
53 thresholds: Thresholds
54 warning: string | null
55} {
56 const yellowAt = options.yellowAt ?? DEFAULT_THRESHOLDS.yellowAt
57 const redAbove = options.redAbove ?? DEFAULT_THRESHOLDS.redAbove
58 const isValid =
59 typeof yellowAt === 'number' &&
60 typeof redAbove === 'number' &&
61 Number.isFinite(yellowAt) &&
62 Number.isFinite(redAbove) &&
63 yellowAt >= 0 &&
64 yellowAt < redAbove
65 if (isValid) return { thresholds: { yellowAt, redAbove }, warning: null }
66 return {
67 thresholds: DEFAULT_THRESHOLDS,
68 warning: `context-watch: yellowAt (${String(yellowAt)}) must be a number below redAbove (${String(redAbove)}); using ${DEFAULT_THRESHOLDS.yellowAt} and ${DEFAULT_THRESHOLDS.redAbove}`,
69 }
70}
71
72const FAMILY_NAMES: Record<string, string> = {
73 opus: 'Opus',
74 sonnet: 'Sonnet',
75 haiku: 'Haiku',
76 fable: 'Fable',
77}
78
79/**
80 * Turns a model ID (`claude-sonnet-4-6`, `claude-opus-4-1-20250805`,
81 * `claude-opus-4-6[1m]`) into the status line's display style (`Sonnet 4.6`).
82 * Anything else, a display name included, comes back as given; empty is null.
83 */
84export function displayModel(model: string | null | undefined): string | null {
85 const raw = model?.trim()
86 if (!raw) return null
87 const match = raw.match(/^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i)
88 const family = match?.[1] ? FAMILY_NAMES[match[1].toLowerCase()] : undefined
89 if (!match || !family) return raw
90 const version = match[3] ? `${match[2]}.${match[3]}` : match[2]
91 return `${family} ${version}${match[4] ? ' (1M context)' : ''}`
92}
93
94/** A level (`high`) as given; a thinking budget in tokens as `32k`. */
95export function formatEffort(effort: string | number): string {
96 return typeof effort === 'number' ? formatTokens(effort) : effort
97}
98
99export function formatVcs(vcs: VcsState): Segment {
100 if (vcs.type === 'none') return { text: '[No version control]', dim: true, part: 'vcs' }
101
102 const star = vcs.dirty ? '*' : ''
103 const style = vcs.dirty ? { color: 'cyan' as const } : { dim: true }
104
105 if (vcs.type === 'detached') {
106 const bracketParts = [vcs.platform, vcs.repo].filter(Boolean)
107 const bracket = bracketParts.length ? `[${bracketParts.join('/')}] ` : ''
108 return { text: `${bracket}(HEAD detached at ${vcs.hash})${star}`, ...style, part: 'vcs' }
109 }
110
111 if (vcs.type === 'branch' || vcs.type === 'tfvc') {
112 const parts = [vcs.platform, vcs.repo, vcs.branch].filter(Boolean)
113 return { text: `[${parts.join('/')}${star}]`, ...style, part: 'vcs' }
114 }
115
116 return { text: '[Unknown VCS]', dim: true, part: 'vcs' }
117}
118
119/** The diff counts after the branch, git's colors: `+N` green, `-N` red. None without changes. */
120export function formatChanges(vcs: VcsState): Segment[] {
121 if ((vcs.type !== 'branch' && vcs.type !== 'detached') || !vcs.changes) return []
122 const { added, removed } = vcs.changes
123 return [
124 { text: ' ', part: 'separator' },
125 { text: `+${added}`, color: 'success', part: 'added' },
126 { text: ' ', part: 'separator' },
127 { text: `-${removed}`, color: 'error', part: 'removed' },
128 ]
129}
130
131export function buildSegments(snapshot: Snapshot, options: BuildOptions = {}): Segment[] {
132 const { thresholds = DEFAULT_THRESHOLDS, showVcs = true, showHint = true } = options
133 const zone = classify(snapshot.tokens, thresholds)
134 const segments: Segment[] = []
135
136 if (snapshot.model) segments.push({ text: snapshot.model, dim: true, part: 'model' }, SEPARATOR)
137 if (snapshot.effort !== null) {
138 segments.push({ text: `Effort: ${formatEffort(snapshot.effort)}`, dim: true, part: 'effort' }, SEPARATOR)
139 }
140 segments.push(
141 { text: 'Context: ', dim: true, part: 'label' },
142 {
143 text: `${formatTokens(snapshot.tokens)} (${Math.round(snapshot.percent)}%)`,
144 color: ZONE_COLOR[zone],
145 part: 'usage',
146 },
147 )
148 if (zone === 'red' && showHint) {
149 segments.push(SEPARATOR, HINT)
150 }
151 if (showVcs) segments.push(SEPARATOR, formatVcs(snapshot.vcs), ...formatChanges(snapshot.vcs))
152
153 return segments
154}
155
156export function segmentsWidth(segments: readonly Segment[]): number {
157 return segments.reduce((sum, segment) => sum + segment.text.length, 0)
158}
159
160/**
161 * The segments for a band `columns` wide: the whole line when it fits, else
162 * without the /compact hint; whatever still overflows is truncated by the band.
163 */
164export function fitSegments(snapshot: Snapshot, columns: number, options: BuildOptions = {}): Segment[] {
165 const full = buildSegments(snapshot, options)
166 if (segmentsWidth(full) <= columns) return full
167 return buildSegments(snapshot, { ...options, showHint: false })
168}
169hooks/vcs.ts 189 lines1import type { LineChanges, VcsState } from '../types'
2
3/** Runs a command by argv and resolves its trimmed stdout; rejects on failure (non-zero exit, timeout, not found). */
4export type Run = (argv: readonly string[]) => Promise<string>
5
6/** Resolves whether a path exists. */
7export type Exists = (path: string) => Promise<boolean>
8
9type Provider = {
10 key: string
11 marker: string
12 detect: (run: Run) => Promise<VcsState | null>
13}
14
15const KNOWN_PLATFORMS: Record<string, string> = {
16 'github.com': 'GitHub',
17 'bitbucket.org': 'Bitbucket',
18 'gitlab.com': 'GitLab',
19}
20
21export function parseRemoteUrl(url: string): { platform: string | null; repo: string | null } {
22 let host: string | null = null
23 let repo: string | null = null
24
25 const sshMatch = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/)
26 if (sshMatch?.[1] && sshMatch[2]) {
27 host = sshMatch[1]
28 repo = sshMatch[2].split('/').pop() || null
29 } else {
30 try {
31 const u = new URL(url)
32 host = u.hostname || null
33 const segments = u.pathname.replace(/^\//, '').replace(/\.git$/, '').split('/')
34 repo = segments.pop() || null
35 } catch {
36 // unparseable remote URL
37 }
38 }
39
40 const platform = host ? (KNOWN_PLATFORMS[host] ?? host) : null
41 return { platform, repo }
42}
43
44export function parseTfvcPlatform(url: string | null): string {
45 if (!url) return 'TFS'
46 try {
47 const u = new URL(url.trim())
48 const host = u.hostname.toLowerCase()
49 if (host === 'dev.azure.com') {
50 return u.pathname.split('/').filter(Boolean)[0] || 'Azure DevOps'
51 }
52 if (host.endsWith('.visualstudio.com')) {
53 return host.split('.')[0] || host
54 }
55 return host
56 } catch {
57 return 'TFS'
58 }
59}
60
61async function attempt(run: Run, argv: readonly string[]): Promise<string | null> {
62 try {
63 return await run(argv)
64 } catch {
65 return null
66 }
67}
68
69/**
70 * Sums `git diff --numstat` output; binary files (`-\t-`) count no lines.
71 * Null when nothing was added or removed, so the band shows no counts.
72 */
73export function parseNumstat(numstat: string): LineChanges | null {
74 let added = 0
75 let removed = 0
76 for (const row of numstat.split('\n')) {
77 const [plus, minus] = row.split('\t')
78 const a = Number(plus)
79 const r = Number(minus)
80 if (Number.isInteger(a)) added += a
81 if (Number.isInteger(r)) removed += r
82 }
83 return added || removed ? { added, removed } : null
84}
85
86export async function detectGit(run: Run): Promise<VcsState | null> {
87 const branch = await attempt(run, ['git', 'branch', '--show-current'])
88 if (branch === null) return null
89
90 const remote = await attempt(run, ['git', 'remote', 'get-url', 'origin'])
91 const { platform, repo } = remote === null ? { platform: null, repo: null } : parseRemoteUrl(remote)
92
93 const status = await attempt(run, ['git', 'status', '--porcelain'])
94 const dirty = status !== null && status.length > 0
95
96 // Staged and unstaged lines against HEAD; untracked files count none, as in `git diff`.
97 // A clean tree has none to count, and a repo with no commit yet has no HEAD to diff.
98 const numstat = dirty ? await attempt(run, ['git', 'diff', '--numstat', 'HEAD']) : null
99 const lines = numstat === null ? null : parseNumstat(numstat)
100 const changes = lines ? { changes: lines } : {}
101
102 if (!branch) {
103 const hash = (await attempt(run, ['git', 'rev-parse', '--short', 'HEAD'])) || 'HEAD'
104 return { type: 'detached', platform, repo, hash, dirty, ...changes }
105 }
106 return { type: 'branch', platform, repo, branch, dirty, ...changes }
107}
108
109export async function detectTfvc(run: Run): Promise<VcsState | null> {
110 const workfold = await attempt(run, ['tf', 'workfold', '/noprompt'])
111 if (workfold === null) return null
112
113 const workspaceName = workfold.match(/Workspace\s*:\s*(\S+)/i)?.[1] ?? null
114 const collectionUrl = workfold.match(/Collection\s*:\s*(\S+)/i)?.[1] ?? null
115 const serverPath = workfold.match(/\$\/\S+/)?.[0] ?? null
116
117 if (!workspaceName && !serverPath) return null
118
119 const platform = parseTfvcPlatform(collectionUrl)
120
121 let repo: string | null = null
122 let branch: string | null = null
123 if (serverPath) {
124 const parts = serverPath.replace(/^\$\//, '').split('/')
125 repo = parts[0] || null
126 branch = parts.slice(1).join('/') || null
127 }
128
129 const status = await attempt(run, ['tf', 'status', '/noprompt', '/recursive'])
130 const dirty = status !== null && status.length > 0 && !/no pending changes/i.test(status)
131
132 return { type: 'tfvc', platform, repo: repo || workspaceName, branch, dirty }
133}
134
135const VCS_PROVIDERS: readonly Provider[] = [
136 { key: 'git', marker: '.git', detect: detectGit },
137 { key: 'tfvc', marker: '$tf', detect: detectTfvc },
138]
139
140function separatorOf(path: string): string {
141 return path.includes('\\') && !path.includes('/') ? '\\' : '/'
142}
143
144function join(dir: string, name: string): string {
145 const sep = separatorOf(dir)
146 return dir.endsWith(sep) ? `${dir}${name}` : `${dir}${sep}${name}`
147}
148
149function dirname(path: string): string {
150 const sep = separatorOf(path)
151 const trimmed = path.length > 1 && path.endsWith(sep) ? path.slice(0, -1) : path
152 const cut = trimmed.lastIndexOf(sep)
153 if (cut < 0) return trimmed
154 if (cut === 0) return sep
155 const parent = trimmed.slice(0, cut)
156 // keep a drive root (`C:`) as `C:\`
157 return /^[A-Za-z]:$/.test(parent) ? `${parent}${sep}` : parent
158}
159
160async function walkUp(cwd: string, name: string, exists: Exists): Promise<boolean> {
161 let dir = cwd
162 while (true) {
163 if (await exists(join(dir, name)).catch(() => false)) return true
164 const parent = dirname(dir)
165 if (parent === dir) return false
166 dir = parent
167 }
168}
169
170/**
171 * Providers whose marker is found walking up from `cwd` are tried first, then
172 * the rest; the first non-null state wins, else `{ type: 'none' }`.
173 */
174export async function detectVcs(run: Run, cwd: string, exists: Exists): Promise<VcsState> {
175 const marked = await Promise.all(VCS_PROVIDERS.map(p => walkUp(cwd, p.marker, exists)))
176 const hasMarker = new Set(VCS_PROVIDERS.filter((_, i) => marked[i]).map(p => p.key))
177 const ordered = [
178 ...VCS_PROVIDERS.filter(p => hasMarker.has(p.key)),
179 ...VCS_PROVIDERS.filter(p => !hasMarker.has(p.key)),
180 ]
181
182 for (const provider of ordered) {
183 const state = await provider.detect(run)
184 if (state) return state
185 }
186
187 return { type: 'none' }
188}
189types/index.d.ts 26 lines1export type Zone = 'green' | 'yellow' | 'red'
2
3/** Lines added and removed in the working tree and index against HEAD (`git diff --numstat HEAD`). */
4export type LineChanges = { added: number; removed: number }
5
6export type VcsState =
7 | { type: 'none' }
8 | { type: 'branch'; platform: string | null; repo: string | null; branch: string; dirty: boolean; changes?: LineChanges }
9 | { type: 'detached'; platform: string | null; repo: string | null; hash: string; dirty: boolean; changes?: LineChanges }
10 | { type: 'tfvc'; platform: string; repo: string | null; branch: string | null; dirty: boolean }
11
12export type Snapshot = {
13 model: string | null
14 /** The main loop's reasoning effort as its last model request sent it; null for a model without effort. */
15 effort: string | number | null
16 tokens: number
17 percent: number
18 vcs: VcsState
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 'context-watch': { snapshot: Snapshot | null; isHidden: boolean; isRedNotified: boolean }
24 }
25}
26