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

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.
| Mod | O que faz |
|---|---|
model-effort | Mostra 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-band | Uma 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-hiroshi | Fork 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. |
hooks/register.tsx 242 lines1import { 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}
242types/index.d.ts 34 lines1export 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