SLOPSHOPPER

git-band

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

newbandprocesstimer
★ 1v0.1.0no licenseupdated 2026-10-08Jenwein/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 ⎇ ✓ 工作区干净 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⎇ ✓ 工作区干净
README

claude-mods

自己写的 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>
Source 3 files
hooks/register.tsx 82 lines
1import { 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}
82
hooks/git.ts 30 lines
1import 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}
30
types/index.d.ts 17 lines
1export 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