SLOPSHOPPER

git-band

Faixa acima do prompt com a branch ou worktree atual e uma prévia do último commit

newbandguardpromptprocesstimer
v0.2.3no licenseupdated 2026-10-13MoriHiroshiDesu/hiroshi-claude-mods/git-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-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 ⟨Claude Code's own drawing⟩ ⎇ │ ✓ │ sem commits ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ⎇ │ ✓ │ sem commits
README

Instalar

claude plugin marketplace add MoriHiroshiDesu/hiroshi-claude-mods
claude plugin install <mod>@hiroshi-claude-mods --scope user

Depois, /reload-plugins ou reinicie o Claude Code. Para trazer versões novas: claude plugin marketplace update.

Mods

ModO que faz
model-effortMostra o modelo e o effort da sessão, alinhados à direita do rodapé do prompt (sonnet 5.5 · high). Verde até sonnet e até high; vermelho de opus e de xhigh para cima.
git-bandUma linha acima do prompt em 4 colunas: branch ou worktree (↑↓), alterações não commitadas por cor, último commit e as linhas +/- dele.
context-bar-hiroshiFork do context-bar do hamzafer (MIT): a janela de contexto como barra empilhada acima do prompt, agora atualizada também depois de um /compact. /context-bar mostra ou esconde.
Source 2 files
hooks/register.tsx 242 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Commit, GitInfo } from '../types'
5
6const info = atom({ plugin: 'git-band', key: 'info' } as const, null)
7
8const FIELD = '\x1f'
9
10// `git status --porcelain=v2 --branch`: "# branch.head <name|(detached)>",
11// "# branch.oid <hash|(initial)>", "# branch.ab +<ahead> -<behind>", then one line per
12// changed (1), renamed (2), unmerged (u) or untracked (?) path; "1"/"2"/"u" carry the
13// two-letter XY: X the index (staged) state, Y the working tree's, "." for none.
14export function parseStatus(
15  out: string,
16): Pick<GitInfo, 'head' | 'isDetached' | 'ahead' | 'behind' | 'staged' | 'modified' | 'untracked' | 'deleted'> {
17  let head = ''
18  let oid = ''
19  let ahead = 0
20  let behind = 0
21  let staged = 0
22  let modified = 0
23  let untracked = 0
24  let deleted = 0
25  for (const line of out.split('\n')) {
26    if (line.startsWith('# branch.head ')) head = line.slice(14)
27    else if (line.startsWith('# branch.oid ')) oid = line.slice(13)
28    else if (line.startsWith('# branch.ab ')) {
29      const match = /^\+(\d+) -(\d+)$/.exec(line.slice(12))
30      if (match !== null) [ahead, behind] = [Number(match[1]), Number(match[2])]
31    } else if (line.startsWith('? ')) untracked += 1
32    else if (/^[12u] /.test(line)) {
33      const [x = '.', y = '.'] = [line[2], line[3]]
34      if (x === 'D' || y === 'D') deleted += 1
35      else if (y !== '.' || line[0] === 'u') modified += 1
36      else staged += 1
37    }
38  }
39  const isDetached = head === '(detached)'
40  return { head: isDetached ? oid.slice(0, 7) : head, isDetached, ahead, behind, staged, modified, untracked, deleted }
41}
42
43// `git log -1 --format=%h<FS>%s<FS>%an<FS>%ar --numstat`: the header line, then one
44// "<added>\t<removed>\t<path>" per file ("-" for a binary one).
45export function parseCommit(out: string): Commit | undefined {
46  const [header = '', ...rest] = out.split('\n')
47  const [hash = '', subject = '', author = '', when = ''] = header.split(FIELD)
48  if (hash === '') return undefined
49  let added = 0
50  let removed = 0
51  for (const line of rest) {
52    const match = /^(\d+|-)\t(\d+|-)\t/.exec(line)
53    if (match === null) continue
54    added += match[1] === '-' ? 0 : Number(match[1])
55    removed += match[2] === '-' ? 0 : Number(match[2])
56  }
57  return { hash, subject, author, when, added, removed }
58}
59
60export function clip(text: string, max: number): string {
61  return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
62}
63
64// The row is a flat list of runs of text, so its width is the sum of their lengths.
65export type Seg = { text: string; color?: string; bold?: boolean; dim?: boolean }
66
67const width = (segs: Seg[]) => segs.reduce((sum, seg) => sum + seg.text.length, 0)
68const SEP: Seg = { text: ' │ ', dim: true }
69
70// A worktree folder usually repeats its branch ("dark-mode" for "refactor/dark-mode").
71const repeatsBranch = (git: GitInfo) =>
72  git.worktree !== undefined && (git.head === git.worktree || git.head.endsWith(`/${git.worktree}`))
73
74// Column 1. The main checkout shows its branch; a linked worktree shows its folder first,
75// in another color and glyph, then the branch it has checked out. When the folder only
76// repeats the branch, just the glyph marks the worktree.
77export function placeColumn(git: GitInfo): Seg[] {
78  const branch: Seg = {
79    text: `${git.isDetached ? '◆' : '⎇'} ${git.head}`,
80    color: git.isDetached ? 'yellow' : 'cyan',
81    bold: git.worktree === undefined,
82  }
83  const folder = git.worktree === undefined ? '' : repeatsBranch(git) ? '' : `${git.worktree} `
84  const segs: Seg[] =
85    git.worktree === undefined ? [branch] : [{ text: `❖ ${folder}`, color: 'magenta', bold: true }, branch]
86  if (git.ahead > 0) segs.push({ text: ` ↑${git.ahead}`, color: 'green' })
87  if (git.behind > 0) segs.push({ text: ` ↓${git.behind}`, color: 'red' })
88  return segs
89}
90
91// Column 2: what is not committed yet, one colored count per kind; ✓ when there is none.
92export function treeColumn(git: GitInfo): Seg[] {
93  const kinds = [
94    { text: `●${git.staged}`, color: 'green', n: git.staged },
95    { text: `~${git.modified}`, color: 'yellow', n: git.modified },
96    { text: `?${git.untracked}`, color: 'cyan', n: git.untracked },
97    { text: `✖${git.deleted}`, color: 'red', n: git.deleted },
98  ].filter(kind => kind.n > 0)
99  if (kinds.length === 0) return [{ text: '✓', dim: true }]
100  return kinds.flatMap((kind, i) => [...(i > 0 ? [{ text: ' ' }] : []), { text: kind.text, color: kind.color }])
101}
102
103// Column 4: the last commit's lines, added then removed.
104export function linesColumn(commit: Commit): Seg[] {
105  return [
106    { text: `+${commit.added}`, color: 'green' },
107    { text: ' ' },
108    { text: `-${commit.removed}`, color: 'red' },
109  ]
110}
111
112// The whole row for `columns` cells: the commit's subject takes what the rest leaves.
113export function rowSegments(git: GitInfo, columns: number): Seg[] {
114  const place = placeColumn(git)
115  const tree = treeColumn(git)
116  const commit = git.commit
117  if (commit === undefined) return [...place, SEP, ...tree, SEP, { text: 'sem commits', dim: true }]
118  const lines = linesColumn(commit)
119  const hash: Seg = { text: `${commit.hash} `, color: 'yellow' }
120  const when: Seg = { text: ` · ${commit.when}`, dim: true }
121  const others = width([...place, SEP, ...tree, SEP, hash, when, SEP, ...lines])
122  const subject: Seg = { text: clip(commit.subject, Math.max(8, columns - others - 1)) }
123  return [...place, SEP, ...tree, SEP, hash, subject, when, SEP, ...lines]
124}
125
126type Run = (argv: string[]) => Promise<{ exitCode: number; stdout: string }>
127
128// Read-only: `--no-optional-locks` keeps `git status` from refreshing the index, which
129// could clash with a `git add` or `git commit` the person runs in another terminal.
130const gitArgv = (...args: string[]) => ['git', '--no-optional-locks', ...args]
131
132export async function load(run: Run): Promise<GitInfo | null> {
133  const status = await run(gitArgv('status', '--porcelain=v2', '--branch'))
134  if (status.exitCode !== 0) return null
135  const [dirs, log] = await Promise.all([
136    run(gitArgv('rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel')),
137    run(gitArgv('log', '-1', `--format=%h${FIELD}%s${FIELD}%an${FIELD}%ar`, '--numstat')),
138  ])
139  const [gitDir, commonDir, top = ''] = dirs.stdout.split('\n')
140  const isLinked = dirs.exitCode === 0 && gitDir !== commonDir
141  const worktree = isLinked ? top.split('/').filter(Boolean).pop() : undefined
142  const commit = log.exitCode === 0 ? parseCommit(log.stdout) : undefined
143  return { ...parseStatus(status.stdout), ...(worktree === undefined ? {} : { worktree }), ...(commit === undefined ? {} : { commit }) }
144}
145
146// A failed read must not hold anything back: it only leaves the band as it was.
147async function refresh($: EngineInterface) {
148  const next = await load(async argv => {
149    const { exitCode, stdout } = await $.process.run(argv, { timeoutMs: 5000 })
150    return { exitCode, stdout }
151  }).catch(() => undefined)
152  if (next === undefined) return
153  // Writing redraws the band: skip it when nothing changed.
154  const current = await read($, info).catch(() => undefined)
155  if (JSON.stringify(current) !== JSON.stringify(next)) await update($, info, () => next).catch(() => {})
156}
157
158// Changes made outside this session (another terminal's `git add`, an editor) fire no
159// event here, so the row is also read on a timer.
160const POLL_MS = 4000
161
162export const register: Register = on => {
163  // While the model works its turn ends in `turn.complete` and each Bash call already
164  // refreshes, so the timer stays idle then; it also skips a tick while a read is running.
165  let isWorking = false
166  let isReading = false
167
168  on('session.start', async ($, e, next) => {
169    await refresh($).catch(() => {})
170    $.clock.every(POLL_MS, () => {
171      if (isWorking || isReading) return
172      isReading = true
173      refresh($)
174        .catch(() => {})
175        .finally(() => {
176          isReading = false
177        })
178    })
179    return next(e)
180  })
181
182  on('turn.start', ($, e, next) => {
183    isWorking = true
184    return next(e)
185  })
186
187  on('prompt.submit', async ($, e, next) => {
188    await refresh($).catch(() => {})
189    return next(e)
190  })
191
192  // The turn may have edited files, committed, switched branch or moved worktree.
193  on('turn.complete', async ($, e, next) => {
194    if (e.agentId === undefined) isWorking = false
195    await refresh($).catch(() => {})
196    return next(e)
197  })
198
199  // A command the user or the model ran (git checkout, commit, ...) shows at once.
200  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
201    const result = await next(e)
202    await refresh($).catch(() => {})
203    return result
204  })
205
206  // So does a file the model edited or created, which no Bash call would report.
207  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
208    const result = await next(e)
209    await refresh($).catch(() => {})
210    return result
211  })
212
213  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
214    const result = await next(e)
215    await refresh($).catch(() => {})
216    return result
217  })
218
219  // Mods stack here by composition: what the ones beneath draw stays, and this one's row
220  // goes under it, so when this plugin is outermost its row is the nearest to the prompt.
221  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
222    const rest = await next(e)
223    const git = await read($, info)
224    if (git === null || e.props.hasSurvey) return rest
225
226    const { Box, Text } = $.ui.resolve(e)
227
228    return (
229      <Box flexDirection="column">
230        {rest}
231        <Text wrap="truncate-end">
232          {rowSegments(git, e.props.bodyColumns).map(seg => (
233            <Text color={seg.color} bold={seg.bold} dimColor={seg.dim}>
234              {seg.text}
235            </Text>
236          ))}
237        </Text>
238      </Box>
239    )
240  })
241}
242
types/index.d.ts 34 lines
1export type Commit = {
2  hash: string
3  subject: string
4  author: string
5  when: string
6  // Lines the commit added and removed, summed over its files (binary files count 0).
7  added: number
8  removed: number
9}
10
11export type GitInfo = {
12  // Branch name, or the short hash when HEAD is detached.
13  head: string
14  isDetached: boolean
15  // Name of the linked worktree's folder; undefined in the main checkout.
16  worktree?: string
17  ahead: number
18  behind: number
19  // Each changed path is counted once, by this priority: untracked, deleted (staged or
20  // not), modified in the working tree (staged too or not), staged only.
21  staged: number
22  modified: number
23  untracked: number
24  deleted: number
25  // Undefined in a repository with no commit yet.
26  commit?: Commit
27}
28
29declare module 'claude-code' {
30  interface PluginState {
31    'git-band': { info: GitInfo | null }
32  }
33}
34