SLOPSHOPPER

git-sync-band

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…

newbandguardtoastpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-sync-band
› 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 ──────────────────────────────────────────────────────────────────────────────────────────────────── git · not on remote · clean ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
──────────────────────────────────────────────────────────────────────────────────────────────────── git · not on remote · clean ⟨Claude Code's own drawing⟩
README

Claude Code mods

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.

ModWhat it doesWhat the audit found
Cache BandShows how warm the prompt cache is, keeps it warm while you're away, and compacts long conversations at 400K tokensMy cache went cold 91 times
Git Sync BandBranch, ahead/behind and changed files above the prompt, with Pull, Files and Commit & push buttons1,660 git checks done by hand
Scout RouterHas a Haiku judge decide whether each subagent needs Opus, and runs the rest on Sonnet243 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.

Install

  1. Copy a mod's folder into ~/.claude/skills/, for example ~/.claude/skills/cache-band.
  2. New sessions load it. In a session that was already open, run /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.

Cache Band

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:

  • Every message re-reads the whole conversation, so at 830K each message costs about twice what it does at 400K.
  • The longer the conversation, the more Claude misses (Chroma's context rot study).
  • Compacting too early summarizes a long task halfway through, and details get lost. Anthropic's API compaction defaults to 150K (docs). Claude Code's own auto-compact waits until about 83% of the window, around 830K on a 1M window.
  • 400K is twice the old 200K window, so a long task fits in one go, at under half the per-message cost of 830K.

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.

Git Sync Band

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.

Scout Router

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.

License

MIT

Source 2 files
hooks/register.tsx 699 lines
1import { 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}
699
types/index.d.ts 29 lines
1export 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