A band above the prompt showing the git branch, uncommitted changes and ahead/behind counts

自己写的 Claude Code Mod(函数钩子插件),整个仓库是一个插件市场。终端和桌面 App 的 Code 标签页都能用。
| Mod | 作用 |
|---|---|
| git-band | 在输入框上方显示当前 git 分支、未提交文件数和 ↑↓ 提交数,每 3 秒刷新 |
| wt-panel | /wt 打开当前仓库的 worktree 总览面板:状态、改动、复制路径、VS Code 打开、带风险提醒的删除 |
| feishu-notify | 长任务结束或出错、权限弹窗或提问等你超过一定时间时,用 lark-cli 的机器人给你发飞书私信;/feishu-test 发测试消息。需要先 lark-cli auth login |
在 Claude Code 的终端会话里,每个 Mod 一行(<mod> 换成上表里的名字),第一次会问是否添加这个插件市场,回答 y,再选安装范围:
/plugin install <mod> --marketplace Jenwein/claude-mods
或者在命令行里:
claude plugin install <mod> --marketplace Jenwein/claude-mods
按用户范围安装后,之后的每个会话都会自动加载。
想边改边用,就把本地克隆加成插件市场再安装,插件会直接从这个文件夹读取:
claude plugin marketplace add <克隆所在的路径>
claude plugin install <mod>@claude-mods
改完文件后,在会话里运行 /reload-plugins 就会生效,不用重装。
检查和测试:
claude plugin validate <mod>
claude plugin test <mod>hooks/register.tsx 82 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { parseStatus } from './git'
5
6const info = atom({ plugin: 'git-band', key: 'info' } as const, null)
7
8// Often enough to catch a branch switched in another terminal.
9const POLL_MS = 3000
10
11const STATUS = ['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch']
12
13let isRefreshing = false
14
15async function refresh($: EngineInterface) {
16 if (isRefreshing) {
17 return
18 }
19
20 isRefreshing = true
21
22 try {
23 const { exitCode, stdout } = await $.process.run(STATUS, { timeoutMs: 10_000 })
24 const found = exitCode === 0 ? parseStatus(stdout) : null
25 const shown = await read($, info)
26
27 // Every write redraws the band, so skip the polls that changed nothing.
28 if (JSON.stringify(found) !== JSON.stringify(shown)) {
29 await update($, info, () => found)
30 }
31 } catch {
32 // git is missing or too slow: keep what the band shows.
33 } finally {
34 isRefreshing = false
35 }
36}
37
38export const register: Register = on => {
39 on('session.start', async ($, e, next) => {
40 const started = await next(e)
41 await refresh($)
42 $.clock.every(POLL_MS, () => void refresh($))
43
44 return started
45 })
46
47 // Claude may have committed or switched branches during the turn.
48 on('turn.complete', async ($, e, next) => {
49 const completed = await next(e)
50 await refresh($)
51
52 return completed
53 })
54
55 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
56 const git = await read($, info)
57
58 if (e.props.hasSurvey || git === null) {
59 return next(e)
60 }
61
62 const { Box, Text } = $.ui.resolve(e)
63 const sync = [git.ahead > 0 ? `↑${git.ahead}` : '', git.behind > 0 ? `↓${git.behind}` : '']
64 .filter(Boolean)
65 .join(' ')
66
67 return (
68 <Box gap={2}>
69 <Text color="claude" bold wrap="truncate">
70 {git.isDetached ? `⎇ 分离 HEAD @ ${git.branch}` : `⎇ ${git.branch}`}
71 </Text>
72 {git.changed > 0 ? (
73 <Text color="warning">{`● ${git.changed} 个文件未提交`}</Text>
74 ) : (
75 <Text color="success">✓ 工作区干净</Text>
76 )}
77 {sync === '' ? null : <Text color="suggestion">{sync}</Text>}
78 </Box>
79 )
80 })
81}
82hooks/git.ts 30 lines1import type { GitInfo } from '../types'
2
3// Reads `git status --porcelain=v2 --branch`: `# branch.*` headers, then one
4// line per changed, untracked or unmerged path.
5export const parseStatus = (stdout: string): GitInfo => {
6 let oid = ''
7 let head = ''
8 let ahead = 0
9 let behind = 0
10 let changed = 0
11
12 for (const line of stdout.split(/\r?\n/)) {
13 if (line.startsWith('# branch.oid ')) {
14 oid = line.slice('# branch.oid '.length)
15 } else if (line.startsWith('# branch.head ')) {
16 head = line.slice('# branch.head '.length)
17 } else if (line.startsWith('# branch.ab ')) {
18 const [plus = '+0', minus = '-0'] = line.slice('# branch.ab '.length).split(' ')
19 ahead = Number(plus.slice(1)) || 0
20 behind = Number(minus.slice(1)) || 0
21 } else if (line !== '' && !line.startsWith('#')) {
22 changed += 1
23 }
24 }
25
26 const isDetached = head === '(detached)'
27
28 return { branch: isDetached ? oid.slice(0, 7) : head, isDetached, changed, ahead, behind }
29}
30types/index.d.ts 17 lines1export type GitInfo = {
2 /** The branch, or the commit's short hash while HEAD is detached. */
3 branch: string
4 isDetached: boolean
5 /** Paths staged, modified, untracked or unmerged. */
6 changed: number
7 /** Commits not yet pushed to, and not yet pulled from, the upstream. */
8 ahead: number
9 behind: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'git-band': { info: GitInfo | null }
15 }
16}
17