A band above the prompt with branch, ahead/behind, changed files and last push; pulls before your prompt when it is safe; Pull, Files and Commit & push buttons…

Three mods for Claude Code. I asked Claude to audit my last 30 Claude Code sessions and tell me where my time went. Each mod answers one thing it found.
| Mod | What it does | What the audit found |
|---|---|---|
| Cache Band | Shows how warm the prompt cache is, keeps it warm while you're away, and compacts long conversations at 400K tokens | My cache went cold 91 times |
| Git Sync Band | Branch, ahead/behind and changed files above the prompt, with Pull, Files and Commit & push buttons | 1,660 git checks done by hand |
| Scout Router | Has a Haiku judge decide whether each subagent needs Opus, and runs the rest on Sonnet | 243 of 309 subagents ran on Opus |
They run on Claude Code's function hooks. Those are in early access, so the API can change between releases. I built and tested these on Claude Code 2.1.286 and 2.1.287.
~/.claude/skills/, for example ~/.claude/skills/cache-band./reload-plugins.To check a mod before you load it, or to run its tests:
claude plugin validate ~/.claude/skills/cache-band
claude plugin test ~/.claude/skills/cache-band
When Claude Code loads a mod, it writes the claude-code type declarations into the mod's .claude-plugin/types folder, which tsconfig.json points at. That folder is git-ignored here.
Every message re-reads the whole conversation. The prompt cache keeps that cheap: a cache read costs about a tenth of the normal input price. On my plan a cache entry lives for an hour after it was last read. Once it goes cold, the next message writes the whole conversation into the cache again, and a one-hour cache write costs twice the input price. On a 400K conversation, that adds up.
The band sits above the prompt: a flame that cools into a snowflake as the hour runs out, the minutes left, two toggles and a Compact button.
Auto cache. Ten minutes before the cache would go cold, it pings it. The ping goes through $.model.fork, which sends the conversation's last request again from a background copy, with a one-line keep-alive at the end. The copy is word for word the same conversation, so the API serves it from the same cache, and that read keeps the cache for another hour. The reply is dropped and nothing is added to your chat. A "hi" typed into the chat would keep the cache warm too, but it stays in the conversation for good, and Claude might act on it.
It stops after 20 pings in a row. A ping reads the cache (0.1x) and a cold restart writes it (2x), so 20 pings cost about what the restart they prevent would, around 16 hours in. Your next message starts it again. If a ping finds the cache already gone, Auto cache pauses instead of paying full price every hour.
Auto compact. It compacts once the conversation passes 400K tokens, right after Claude's reply, while the cache is still warm, so reading the conversation for the summary is cheap. Why 400K:
Compact. The button runs /compact as if you typed it. The desktop app refuses a mod's direct compaction call, so this is the route that works there.
28 tests cover the gauge, the pings and Auto compact.
A band above the prompt with the branch, how far ahead or behind it is, the changed files and when you last pushed. It reads git status every minute and fetches every five, with no credential prompts and without taking git's index lock, so it never gets in the way of Claude's own git.
Before your prompt, it pulls with --ff-only when that can't lose anything, and tells Claude what came in, or why nothing did. Pull, Files and Commit & push run git straight from the band, without a turn in the chat. Commit & push writes the message with one small model call and shows it to you before anything runs. It won't commit files that look like secrets (.env files, keys, credentials).
6 tests cover the band's layouts, the divider and the commit flow.
Before a subagent starts on Opus, Scout Router asks a judge: one small Haiku call that reads the subagent's task and the start of its instructions, and answers opus or sonnet. Opus is for deep judgment where a mistake is costly: planning, design decisions, audits and reviews, grading other work, and subtle debugging that spans many parts. Everything else runs on Sonnet: writing code, tests or data to a clear brief, searching and summarizing, running checks, mechanical edits. When the judge is unsure, it picks Sonnet. It decides even when the main model asked for Opus by name.
Forks keep their model, since they share the main conversation's cache. Explore agents go straight to Sonnet. Agents already headed for Sonnet or Haiku aren't judged. If the judge can't answer (an error, a time-out, a usage limit), a word rule decides: planning, audit and review tasks stay on Opus, and the rest go to Sonnet. Each decision is logged.
The judge costs a few hundred Haiku tokens per subagent and adds about a second before it starts. 5 tests cover the judge, its fallback and what it skips.
MIT
hooks/register.tsx 699 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BandUi, GitFile, GitSnap } from '../types'
5
6const snapAtom = atom({ plugin: 'git-sync-band', key: 'snap' } as const, null)
7const uiAtom = atom({ plugin: 'git-sync-band', key: 'ui' } as const, {
8 isExpanded: false,
9 busy: null,
10 pending: null,
11})
12
13const HUMAN = new Set(['composer', 'bridge', 'sdk', 'unclassified'])
14// No credential prompts (there is no terminal to answer them), and no index
15// lock for a status check, so the band never collides with Claude's own git.
16const GIT_ENV = {
17 GIT_TERMINAL_PROMPT: '0',
18 GCM_INTERACTIVE: 'never',
19 GIT_OPTIONAL_LOCKS: '0',
20 GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
21}
22const STATUS_EVERY_MS = 60_000
23const FETCH_EVERY_MS = 5 * 60_000
24// Before a prompt, fetch again when the last fetch is older than this.
25const FETCH_STALE_MS = 2 * 60_000
26const SENSITIVE =
27 /(^|\/)(\.env(\.[\w.-]+)?|[^/]*\.(pem|key|pfx|p12|jks|keystore|ppk)|id_(rsa|ed25519|ecdsa)[^/]*|[^/]*credentials[^/]*\.json|[^/]*secrets?\.(json|ya?ml|env|txt)|\.npmrc|\.netrc)$/i
28const NOT_SENSITIVE = /\.env\.(example|sample|template|defaults)$/i
29const COMMIT_SYSTEM =
30 'You write git commit messages. Reply with the message only: a subject line under 72 characters ' +
31 'in the imperative mood, then optionally a blank line and up to four short "- " lines. ' +
32 'No quotes, no code fences, no trailers or attribution lines.'
33
34type Ran = { ok: boolean; out: string; err: string }
35
36let lastFetchAt = 0
37let inflight: Promise<GitSnap | null> | undefined
38let debounce: { cancel: () => void } | undefined
39
40const firstLine = (text: string) =>
41 text.split('\n').map(line => line.trim()).find(Boolean) ?? 'unknown error'
42
43const ago = (ms: number) => {
44 const m = Math.round(ms / 60_000)
45 if (m < 1) return 'just now'
46 if (m < 60) return `${m}m ago`
47 const h = Math.round(m / 60)
48
49 return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
50}
51
52const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
53
54// The band's pictures are drawn as images, which can't read the app's theme,
55// so their colors are mid-tones that hold on light and dark alike: the cache
56// band's grey and orange, with its amber and red for trouble.
57const INK = '#A3A095'
58const ACCENT = '#F28B54'
59const AMBER = '#DFA13A'
60const RED = '#E5534B'
61const iconSvg = (paths: string, ink: string) =>
62 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${ink}" ` +
63 `stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`
64const BRANCH =
65 '<circle cx="6" cy="6" r="2.4"/><circle cx="6" cy="18" r="2.4"/><circle cx="18" cy="6" r="2.4"/>' +
66 '<path d="M6 8.4v7.2M18 8.4c0 5-5 6.6-9.6 8.6"/>'
67const FILES =
68 '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/>'
69const PULL = '<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 20h14"/>'
70const PUSH = '<path d="M12 15V4M7.5 8.5 12 4l4.5 4.5M5 20h14"/>'
71const CHECK = '<path d="M5 12.5 10 17.5 19 7"/>'
72const CROSS = '<path d="M6 6l12 12M18 6 6 18"/>'
73
74// The branch icon's color says the state at a glance: red for a conflict or
75// an operation half done, amber when behind, orange with something to commit
76// or push, grey when clean and in step.
77const branchInk = (snap: GitSnap) =>
78 snap.op !== null || snap.conflicted > 0
79 ? RED
80 : snap.behind > 0
81 ? AMBER
82 : snap.files.length > 0 || snap.ahead > 0 || snap.upstream === null
83 ? ACCENT
84 : INK
85
86// A picture takes no click, so each one a person presses has a blank button
87// laid over it and centered on it, two blank Braille cells wide.
88const ICON_BLANK = '⠀⠀'
89
90// Widths in the band's cells, to pick the fullest layout that fits one row,
91// as the cache band does: its sans text runs about three quarters of a cell a
92// character, and a cell is about 7.8 pixels.
93const textCells = (text: string) => text.length * 0.75
94const pxCells = (px: number) => px / 7.8
95const PAD = pxCells(12)
96const ICON_BUTTON = pxCells(30)
97
98function parseStatus(raw: string) {
99 const items = raw.split('\0')
100 const files: GitFile[] = []
101 let branch = ''
102 let upstream: string | null = null
103 let ahead = 0
104 let behind = 0
105 let conflicted = 0
106
107 for (let i = 0; i < items.length; i++) {
108 const item = items[i] ?? ''
109 if (item.startsWith('# branch.head ')) branch = item.slice(14)
110 else if (item.startsWith('# branch.upstream ')) upstream = item.slice(18)
111 else if (item.startsWith('# branch.ab ')) {
112 const m = /\+(\d+) -(\d+)/.exec(item)
113 ahead = Number(m?.[1] ?? 0)
114 behind = Number(m?.[2] ?? 0)
115 } else if (item.startsWith('1 ') || item.startsWith('2 ')) {
116 const fields = item.split(' ')
117 const isRename = item.startsWith('2 ')
118 files.push({
119 code: (fields[1] ?? '..').replace(/\./g, ' '),
120 path: fields.slice(isRename ? 9 : 8).join(' '),
121 })
122 // With -z a rename's original path is the next item.
123 if (isRename) i++
124 } else if (item.startsWith('u ')) {
125 conflicted++
126 files.push({ code: 'UU', path: item.split(' ').slice(10).join(' ') })
127 } else if (item.startsWith('? ')) {
128 files.push({ code: '??', path: item.slice(2) })
129 }
130 }
131
132 return { branch, upstream, ahead, behind, conflicted, files }
133}
134
135async function git(
136 $: EngineInterface,
137 cwd: string,
138 args: string[],
139 timeoutMs = 30_000,
140 stdin?: string,
141): Promise<Ran> {
142 try {
143 const r = await $.process.run(['git', ...args], { cwd, env: GIT_ENV, timeoutMs, stdin })
144
145 return { ok: r.exitCode === 0, out: r.stdout, err: r.stderr }
146 } catch (error) {
147 return { ok: false, out: '', err: String(error) }
148 }
149}
150
151async function opOf($: EngineInterface, gitDir: string) {
152 const marks: [string, string][] = [
153 ['rebase-merge', 'rebase'],
154 ['rebase-apply', 'rebase'],
155 ['MERGE_HEAD', 'merge'],
156 ['CHERRY_PICK_HEAD', 'cherry-pick'],
157 ['REVERT_HEAD', 'revert'],
158 ]
159 for (const [file, op] of marks) {
160 if (await $.fs.exists(`${gitDir}/${file}`)) return op
161 }
162
163 return null
164}
165
166async function load($: EngineInterface, doFetch: boolean, fetchTimeoutMs: number) {
167 const cwd = await $.session.cwd()
168 const top = await git($, cwd, ['rev-parse', '--show-toplevel'])
169 if (!top.ok) {
170 await update($, snapAtom, () => null)
171
172 return null
173 }
174 const root = top.out.trim()
175
176 if (doFetch) {
177 lastFetchAt = await $.clock.now()
178 await git($, root, ['fetch', '--quiet', '--no-tags'], fetchTimeoutMs)
179 }
180
181 const status = await git($, root, [
182 'status',
183 '--porcelain=v2',
184 '--branch',
185 '-z',
186 '--untracked-files=normal',
187 ])
188 if (!status.ok) return read($, snapAtom)
189
190 const gitDir = await git($, root, ['rev-parse', '--absolute-git-dir'])
191 const snap: GitSnap = {
192 root,
193 ...parseStatus(status.out),
194 op: gitDir.ok ? await opOf($, gitDir.out.trim()) : null,
195 pushedAt: Number(await $.store.get(`pushedAt:${root}`)) || null,
196 checkedAt: await $.clock.now(),
197 }
198 await update($, snapAtom, () => snap)
199
200 return snap
201}
202
203async function refresh($: EngineInterface, doFetch = false, fetchTimeoutMs = 60_000) {
204 while (inflight) await inflight.catch(() => null)
205 inflight = load($, doFetch, fetchTimeoutMs)
206 try {
207 return await inflight
208 } finally {
209 inflight = undefined
210 }
211}
212
213function refreshSoon($: EngineInterface) {
214 debounce?.cancel()
215 debounce = $.clock.after(2_000, () => {
216 debounce = undefined
217 void refresh($)
218 })
219}
220
221async function markPushed($: EngineInterface, root: string) {
222 await $.store.set(`pushedAt:${root}`, await $.clock.now())
223}
224
225async function setUi($: EngineInterface, patch: Partial<BandUi>) {
226 await update($, uiAtom, ui => ({ ...ui, ...patch }))
227}
228
229// Before a person's prompt: fast-forward when that cannot lose anything, and
230// tell Claude what came in (or why nothing did).
231async function syncBefore($: EngineInterface, snap: GitSnap) {
232 if (!snap.upstream || snap.behind === 0) return undefined
233
234 const isDirty = snap.files.some(f => f.code !== '??')
235 const why = snap.op
236 ? `a ${snap.op} is in progress`
237 : isDirty
238 ? 'there are uncommitted changes'
239 : snap.ahead > 0
240 ? `it also has ${plural(snap.ahead, 'local commit')} origin does not have`
241 : null
242 if (why) {
243 $.ui.toast(`${snap.branch} is ${snap.behind} behind ${snap.upstream}; not pulled because ${why}.`, {
244 timeoutMs: 8_000,
245 })
246
247 return `[git-sync-band] ${snap.branch} is ${plural(snap.behind, 'commit')} behind ${snap.upstream} and was not pulled automatically because ${why}.`
248 }
249
250 const before = await git($, snap.root, ['rev-parse', 'HEAD'])
251 const pull = await git($, snap.root, ['pull', '--ff-only', '--quiet'], 120_000)
252 if (!pull.ok) {
253 $.ui.toast(`Pull failed: ${firstLine(pull.err)}`, { timeoutMs: 8_000 })
254
255 return `[git-sync-band] Tried to fast-forward ${snap.branch} to ${snap.upstream} before this prompt, and it failed: ${firstLine(pull.err)}`
256 }
257
258 const log = await git($, snap.root, [
259 'log',
260 '--oneline',
261 '--no-decorate',
262 '-n',
263 '15',
264 `${before.out.trim()}..HEAD`,
265 ])
266 await refresh($)
267 $.ui.toast(`Pulled ${plural(snap.behind, 'commit')} from ${snap.upstream}.`)
268
269 return `[git-sync-band] Before this prompt, ${snap.branch} was fast-forwarded to ${snap.upstream} (${plural(snap.behind, 'new commit')}):\n${log.out.trim()}`
270}
271
272async function pullNow($: EngineInterface) {
273 const snap = await read($, snapAtom)
274 if (!snap) return
275 await setUi($, { busy: 'Pulling…' })
276 const r = await git($, snap.root, ['pull', '--ff-only', '--quiet'], 120_000)
277 await setUi($, { busy: null })
278 $.ui.toast(r.ok ? `Pulled from ${snap.upstream}.` : `Pull failed: ${firstLine(r.err)}`, {
279 timeoutMs: 8_000,
280 })
281 await refresh($)
282}
283
284async function commitMessage($: EngineInterface, snap: GitSnap, newFiles: string[]) {
285 const [stat, diff, recent] = await Promise.all([
286 git($, snap.root, ['diff', 'HEAD', '--stat', '--no-color']),
287 git($, snap.root, ['diff', 'HEAD', '--no-color', '-U1']),
288 git($, snap.root, ['log', '--oneline', '--no-decorate', '-n', '8']),
289 ])
290 const prompt = [
291 `Recent commit subjects here, match their style:\n${recent.out.trim() || '(none)'}`,
292 `Changed files:\n${stat.out.trim() || '(none tracked)'}`,
293 newFiles.length > 0 ? `New files:\n${newFiles.slice(0, 40).join('\n')}` : '',
294 `Diff, possibly cut:\n${diff.out.slice(0, 12_000)}`,
295 ]
296 .filter(Boolean)
297 .join('\n\n')
298 const r = await $.model.complete({
299 model: 'haiku',
300 system: COMMIT_SYSTEM,
301 prompt,
302 maxTokens: 300,
303 timeoutMs: 20_000,
304 })
305 const text = r.isAnswered ? r.text.replace(/^```\w*\n?|\n?```$/g, '').trim() : ''
306 if (text) return text
307
308 const names = snap.files.slice(0, 3).map(f => f.path.split('/').pop())
309
310 return `Update ${plural(snap.files.length, 'file')}: ${names.join(', ')}${snap.files.length > 3 ? ', …' : ''}`
311}
312
313async function startCommit($: EngineInterface) {
314 const snap = await refresh($)
315 if (!snap) return
316 if (snap.op || snap.conflicted > 0) {
317 $.ui.toast(`Not committing: ${snap.op ? `a ${snap.op} is in progress` : 'there are conflicts'}.`)
318
319 return
320 }
321 if (snap.files.length === 0) {
322 await setUi($, { pending: { message: '', files: 0 } })
323
324 return
325 }
326
327 // Every untracked file, not just their folders, so a new .env in a new
328 // folder is caught too.
329 const all = await git($, snap.root, ['status', '--porcelain', '-z', '--untracked-files=all'])
330 const paths = all.out
331 .split('\0')
332 .filter(Boolean)
333 .map(entry => entry.slice(3))
334 const risky = paths.filter(p => SENSITIVE.test(p) && !NOT_SENSITIVE.test(p))
335 if (risky.length > 0) {
336 $.ui.toast(
337 `Not committing: ${risky.slice(0, 3).join(', ')} ${risky.length === 1 ? 'looks' : 'look'} like secrets. ` +
338 'Add to .gitignore, or commit by hand.',
339 { timeoutMs: 12_000 },
340 )
341
342 return
343 }
344
345 await setUi($, { busy: 'Writing a commit message…' })
346 const newFiles = all.out
347 .split('\0')
348 .filter(entry => entry.startsWith('??'))
349 .map(entry => entry.slice(3))
350 const message = await commitMessage($, snap, newFiles)
351 await setUi($, { busy: null, pending: { message, files: snap.files.length } })
352}
353
354async function confirmCommit($: EngineInterface) {
355 const ui = await read($, uiAtom)
356 const pending = ui.pending
357 if (!pending) return
358
359 const snap = await refresh($)
360 if (!snap) return
361 if (snap.files.length !== pending.files) {
362 $.ui.toast('Files changed since the message was written; writing it again.')
363 await setUi($, { pending: null })
364 await startCommit($)
365
366 return
367 }
368
369 await setUi($, { pending: null, busy: pending.files > 0 ? 'Committing…' : 'Pushing…' })
370 if (pending.files > 0) {
371 const add = await git($, snap.root, ['add', '-A'])
372 const commit = add.ok
373 ? await git($, snap.root, ['commit', '--quiet', '-F', '-'], 60_000, `${pending.message}\n`)
374 : add
375 if (!commit.ok) {
376 await setUi($, { busy: null })
377 $.ui.toast(`Commit failed: ${firstLine(commit.err || commit.out)}`, { timeoutMs: 12_000 })
378 await refresh($)
379
380 return
381 }
382 }
383
384 await setUi($, { busy: 'Pushing…' })
385 const args = snap.upstream ? ['push', '--quiet'] : ['push', '--quiet', '-u', 'origin', 'HEAD']
386 const push = await git($, snap.root, args, 120_000)
387 await setUi($, { busy: null, isExpanded: false })
388 if (!push.ok) {
389 $.ui.toast(
390 `${pending.files > 0 ? 'Committed, but the push' : 'Push'} failed: ${firstLine(push.err)}`,
391 { timeoutMs: 12_000 },
392 )
393 await refresh($)
394
395 return
396 }
397
398 await markPushed($, snap.root)
399 const head = await git($, snap.root, ['log', '-1', '--format=%h %s'])
400 const target = snap.upstream ?? `origin/${snap.branch}`
401 $.ui.toast(`Pushed to ${target}: ${head.out.trim()}`)
402 $.ui.log(`git-sync-band: pushed ${head.out.trim()} to ${target}`)
403 await refresh($)
404}
405
406export const register: Register = on => {
407 on('session.start', async ($, e, next) => {
408 void refresh($, true)
409 $.clock.every(STATUS_EVERY_MS, () => void refresh($))
410 $.clock.every(FETCH_EVERY_MS, () => void refresh($, true))
411
412 return next(e)
413 })
414
415 on('prompt.submit', async ($, e, next) => {
416 // Only a person's prompt, typed while Claude is idle: never pull under a running turn.
417 if (!HUMAN.has(e.origin.kind) || e.turnId) return next(e)
418
419 const now = await $.clock.now()
420 const snap = await refresh($, now - lastFetchAt > FETCH_STALE_MS, 20_000)
421 const note = snap ? await syncBefore($, snap) : undefined
422
423 return next(note ? { ...e, context: [...(e.context ?? []), note] } : e)
424 })
425
426 on('tool.call', async ($, e, next) => {
427 const ran = await next(e)
428 const command =
429 e.tool === 'Bash' || e.tool === 'PowerShell'
430 ? String((e as { command?: unknown }).command ?? '')
431 : ''
432 if (command && /\bgit\b[^\n|;&]*\bpush\b/.test(command) && ran.deny === undefined && ran.isError !== true) {
433 const snap = await read($, snapAtom)
434 if (snap) await markPushed($, snap.root)
435 }
436 if (command || e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
437 refreshSoon($)
438 }
439
440 return ran
441 })
442
443 on('turn.complete', async ($, e, next) => {
444 const done = await next(e)
445 if (!e.agentId) void refresh($)
446
447 return done
448 })
449
450 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
451 if (e.props.hasSurvey) return next(e)
452 const snap = await read($, snapAtom)
453 if (!snap) return next(e)
454
455 const ui = await read($, uiAtom)
456 const now = await $.clock.now()
457 const table = $.ui.resolve(e)
458 const { Box, Button, Text } = table
459 // The terminal draws no pictures: words there, and the keys it can press.
460 const Svg = e.surface !== 'terminal' && 'Svg' in table ? table.Svg : undefined
461 const isTerminal = Svg === undefined
462 const below = await next(e)
463
464 const changed = snap.files.length
465 const isIdle = !e.props.isWorking && !ui.busy && !ui.pending
466 const canCommit = !snap.op && snap.conflicted === 0 && (changed > 0 || snap.ahead > 0)
467 const listRows = Math.max(1, e.props.maxRows - (ui.pending ? 5 : 2))
468 const target = snap.upstream ?? `origin/${snap.branch}`
469
470 // What the row says, each piece with its color; the pushed time goes first
471 // when the band is short of room.
472 const pieces = [
473 snap.upstream === null ? { text: 'not on remote', color: undefined } : null,
474 snap.behind > 0 ? { text: `↓${snap.behind} behind`, color: 'warning' } : null,
475 snap.ahead > 0 ? { text: `↑${snap.ahead} to push`, color: 'permission' } : null,
476 snap.upstream !== null && snap.ahead === 0 && snap.behind === 0 ? { text: 'in sync', color: undefined } : null,
477 { text: changed > 0 ? `${changed} changed` : 'clean', color: undefined },
478 snap.conflicted > 0 ? { text: plural(snap.conflicted, 'conflict'), color: 'error' } : null,
479 snap.op !== null ? { text: `${snap.op} in progress`, color: 'error' } : null,
480 ui.busy !== null ? { text: ui.busy, color: 'warning' } : null,
481 ].filter(piece => piece !== null)
482 const pushed = snap.pushedAt !== null ? `pushed ${ago(now - snap.pushedAt)}` : null
483
484 const actions = [
485 {
486 key: 'files',
487 name: ui.isExpanded ? 'Hide files' : 'Files',
488 paths: FILES,
489 ink: INK,
490 hotkey: 'f',
491 shows: changed > 0,
492 onPress: () => void update($, uiAtom, u => ({ ...u, isExpanded: !u.isExpanded })),
493 },
494 {
495 key: 'pull',
496 name: 'Pull',
497 paths: PULL,
498 ink: INK,
499 hotkey: 'p',
500 shows: isIdle && snap.behind > 0,
501 onPress: () => void pullNow($),
502 },
503 {
504 key: 'commit',
505 name: changed > 0 ? 'Commit & push' : 'Push',
506 paths: PUSH,
507 ink: ACCENT,
508 hotkey: 'c',
509 shows: isIdle && canCommit,
510 onPress: () => void startCommit($),
511 },
512 ].filter(action => action.shows)
513
514 // The fullest layout that fits one row: every word; then without the
515 // pushed time and the buttons' names; then icons alone, the words in
516 // hover cards. The terminal keeps its words.
517 const columns = e.props.bodyColumns
518 const statusCells = pieces.reduce((sum, piece) => sum + 1 + textCells(`· ${piece.text}`), 0)
519 const leftCells = (Svg ? pxCells(16) + 1 : textCells('git ')) + textCells(snap.branch) + statusCells
520 // Files is its icon alone; the others are named where there's room.
521 const isNamed = (key: string) => key !== 'files'
522 const namedCells = actions.reduce(
523 (sum, action) =>
524 sum + (isNamed(action.key) ? (Svg ? ICON_BUTTON + 1 : 0) + textCells(action.name) + PAD : ICON_BUTTON) + 2,
525 0,
526 )
527 const iconCells = actions.length * (ICON_BUTTON + 1)
528 const fullCells = Math.ceil(leftCells + (pushed ? 1 + textCells(`· ${pushed}`) : 0) + 3 + namedCells)
529 const mediumCells = Math.ceil(leftCells + 3 + iconCells)
530 const layout = isTerminal || columns >= fullCells ? 'full' : columns >= mediumCells ? 'medium' : 'icons'
531 const isIcons = layout === 'icons'
532 const hasNames = layout === 'full'
533
534 // A card that opens while the pointer is over the keyed Box around it.
535 const card = (...lines: string[]) => (
536 <Box
537 position="absolute"
538 top={-4}
539 left={0}
540 display="none"
541 hover={{ display: 'flex' }}
542 flexDirection="column"
543 borderStyle="round"
544 borderColor="subtle"
545 backgroundColor="background"
546 paddingX={1}
547 >
548 {lines.map(line => (
549 <Text>{line}</Text>
550 ))}
551 </Box>
552 )
553
554 // An icon a person presses: a blank button laid over the picture takes the
555 // click, and its hover frames it.
556 const pressable = (key: string, paths: string, ink: string, onPress: () => void, tip?: string) => (
557 <Box key={`${key}-box`} position="relative" flexDirection="row" alignItems="center" flexShrink={0}>
558 {Svg && <Svg source={iconSvg(paths, ink)} alt={tip ?? key} width={16} height={16} />}
559 <Box
560 position="absolute"
561 top={0}
562 left={0}
563 right={0}
564 height="100%"
565 flexDirection="row"
566 alignItems="center"
567 justifyContent="center"
568 >
569 <Button key={key} label={ICON_BLANK} plain onPress={onPress} />
570 </Box>
571 {tip !== undefined && card(tip)}
572 </Box>
573 )
574
575 // An action: its icon and name where there's room, the icon alone (named
576 // in a hover card) where there isn't, a key and a name on the terminal.
577 const action = (a: (typeof actions)[number], withName: boolean) => {
578 if (isTerminal) {
579 return <Button key={a.key} label={a.name} hotkey={a.hotkey} plain onPress={a.onPress} />
580 }
581 if (!withName) return pressable(a.key, a.paths, a.ink, a.onPress, a.name)
582
583 return (
584 <Box key={`${a.key}-named`} flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
585 {pressable(`${a.key}-icon`, a.paths, a.ink, a.onPress)}
586 <Button key={a.key} label={a.name} plain onPress={a.onPress} />
587 </Box>
588 )
589 }
590
591 const summary = [snap.branch, ...pieces.map(piece => piece.text), ...(pushed ? [pushed] : [])].join(' · ')
592
593 const branch = (
594 <Box key="branch" flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
595 {Svg ? (
596 <Box flexShrink={0}>
597 <Svg source={iconSvg(BRANCH, branchInk(snap))} alt={`Branch ${snap.branch}`} width={16} height={16} />
598 </Box>
599 ) : (
600 <Text dimColor>git</Text>
601 )}
602 {!isIcons && (
603 <Text bold wrap="truncate">
604 {snap.branch}
605 </Text>
606 )}
607 {!isIcons &&
608 pieces.map(piece => (
609 <Text color={piece.color} dimColor={piece.color === undefined} wrap="truncate">
610 · {piece.text}
611 </Text>
612 ))}
613 {hasNames && pushed !== null && (
614 <Text dimColor wrap="truncate">
615 · {pushed}
616 </Text>
617 )}
618 {isIcons && card(summary)}
619 </Box>
620 )
621
622 const pending = ui.pending
623 const question =
624 pending === null
625 ? ''
626 : pending.files > 0
627 ? `Commit ${plural(pending.files, 'file')} on ${snap.branch} and push to ${target}:`
628 : `Push ${plural(snap.ahead, 'commit')} to ${target}?`
629 const confirm = {
630 key: 'confirm',
631 name: pending !== null && pending.files > 0 ? 'Commit & push' : 'Push',
632 paths: CHECK,
633 ink: ACCENT,
634 hotkey: 'y',
635 shows: true,
636 onPress: () => void confirmCommit($),
637 }
638 const cancel = {
639 key: 'cancel',
640 name: 'Cancel',
641 paths: CROSS,
642 ink: INK,
643 hotkey: 'n',
644 shows: true,
645 onPress: () => void update($, uiAtom, u => ({ ...u, pending: null })),
646 }
647
648 return (
649 <Box flexDirection="column">
650 {/* The line between the cache band's row and this one: box-drawing
651 cells, drawn in the code font a cell each, so the band's width in
652 cells spans it exactly. */}
653 <Text color="subtle" wrap="truncate">
654 {'─'.repeat(Math.max(1, columns))}
655 </Text>
656 <Box
657 flexDirection="row"
658 flexWrap="wrap"
659 alignItems="center"
660 justifyContent="space-between"
661 width="100%"
662 columnGap={isIcons ? 2 : 3}
663 >
664 {branch}
665 <Box key="git-actions" flexDirection="row" alignItems="center" columnGap={hasNames ? 2 : 1}>
666 {actions.map(a => action(a, hasNames && isNamed(a.key)))}
667 </Box>
668 </Box>
669 {pending !== null && (
670 <Box key="git-confirm" flexDirection="column">
671 <Text dimColor wrap="truncate-end">
672 {question}
673 </Text>
674 {pending.message !== '' && <Text bold>{pending.message}</Text>}
675 <Box flexDirection="row" alignItems="center" columnGap={2}>
676 {action(confirm, true)}
677 {action(cancel, true)}
678 </Box>
679 </Box>
680 )}
681 {ui.isExpanded && pending === null && (
682 <Box key="git-files" flexDirection="column">
683 {snap.files.slice(0, listRows).map(f => (
684 <Text key={f.path} wrap="truncate-middle">
685 <Text color={f.code === '??' ? 'green' : f.code.includes('D') ? 'red' : 'yellow'}>
686 {f.code.padEnd(2)}
687 </Text>{' '}
688 {f.path}
689 </Text>
690 ))}
691 {changed > listRows && <Text dimColor>+{changed - listRows} more</Text>}
692 </Box>
693 )}
694 {below}
695 </Box>
696 )
697 })
698}
699types/index.d.ts 29 lines1export type GitFile = { code: string; path: string }
2
3export type GitSnap = {
4 root: string
5 branch: string
6 upstream: string | null
7 ahead: number
8 behind: number
9 conflicted: number
10 files: GitFile[]
11 op: string | null
12 pushedAt: number | null
13 checkedAt: number
14}
15
16export type PendingCommit = { message: string; files: number }
17
18export type BandUi = {
19 isExpanded: boolean
20 busy: string | null
21 pending: PendingCommit | null
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'git-sync-band': { snap: GitSnap | null; ui: BandUi }
27 }
28}
29