SLOPSHOPPER

context-watch

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

newbandguardcommandtoastprocess
v0.1.0MITupdated 2026-10-15zbshadow/ContextWatch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-watch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /context-watch ⎿ context-watch: ContextWatch hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ContextWatch

Version License: MIT Claude Code

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. ContextWatch in action -->

Features

  • Context meter: tokens used and percentage of the context window, colored green, yellow or red.
  • Compact button: shown next to the meter when usage is in the red.
  • Red-zone notification: shown once when usage enters the red.
  • Model and effort: the active model and its reasoning effort.
  • Repository status: platform, repo, branch, uncommitted changes, and lines added and removed.
  • Supported version control: GitHub, GitLab, Bitbucket, Azure DevOps and TFVC.
  • No network access; does not modify settings.json.

Requirements

Installation

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.

Updating

claude plugin update context-watch@context-watch

Restart Claude Code to load the new version.

Uninstalling

claude plugin uninstall context-watch@context-watch

To disable without uninstalling: claude plugin disable context-watch@context-watch.

Usage

Reading the band

SegmentExampleMeaning
ModelOpus 5.5Active model
EffortEffort: highReasoning effort; shown after the first prompt
ContextContext: 24k (18%)Tokens used and percentage of the context window
Repository[GitHub/my-project/main*]Platform, repo and branch; * means uncommitted changes
Changes+12 -3Lines added and removed since the last commit
ColorDefault range
Greenunder 70k tokens
Yellow70k – 100k tokens
Redover 100k tokens

Compacting

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.

Showing and hiding

/context-watch toggles the band. The setting persists across sessions.

Configuration

Set options in /config, or from a terminal:

claude plugin configure context-watch@context-watch
OptionDefaultDescription
yellowAt70000Tokens at which the meter turns yellow
redAbove100000Tokens above which the meter turns red
showVcstrueShow the repository segment

Thresholds are token counts, not percentages.

Troubleshooting

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.

Contributing

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.

License

MIT © Brice Bayless

Source 4 files
hooks/register.tsx 298 lines
1import { 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}
298
hooks/meter.ts 169 lines
1import 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}
169
hooks/vcs.ts 189 lines
1import 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}
189
types/index.d.ts 26 lines
1export 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