在 Claude Code 面板顯示目前 repo 的 git 分支線圖,執行 git 指令後自動更新

兩個 Claude Code mod(function hooks 外掛),在 Claude Code 自己的介面裡運作。
| mod | 功能 | 指令 | ||
|---|---|---|---|---|
| flowmap | 長回應結束後由 Haiku 判斷是否值得畫圖,值得就由主模型產生 Mermaid 圖,顯示在 cmux 瀏覽器窗格;預設不顯示,提示框上方(VS Code 擴充套件改用通知)會問要不要看,按「看圖解」才打開;圖解頁面可以編輯 Mermaid 語法重畫,並匯出 SVG、PNG、Mermaid 原始碼 | /flow、/flow hide、`/flow on\ | off、/flow web on\ | off` |
| gitgraph | 仿 GitHub Desktop 的提交列表,可同時勾選多條分支比對 | /branches、/branches 分支 分支、/branches all、/branches hide |
~/.claude/mods: git clone https://github.com/TCcodemaster/claude-mods ~/.claude/mods
~/.claude/settings.json 加上(一定要放在家目錄的設定檔,專案的設定檔不會生效): "env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/flowmap:~/.claude/mods/gitgraph",
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
第二行是強制開啟 mod 功能。mod 目前是逐步開放的早期功能,開關狀態快取在本機,偶爾會被存成關閉,導致新對話完全不載入 mod。
code --install-extension ~/.claude/mods/vscode-viewer/flowmap-viewer.vsix
每次開 Claude Code 對話時,flowmap 會在背景對這個 repo 跑 git pull --ff-only,有更新會跳提示。本機有未推送的提交或衝突時會跳過,不會動任何檔案。VS Code 檢視器(.vsix)不會自動重裝,更新後要再跑一次安裝步驟 3。
| 執行環境 | 顯示位置 |
|---|---|
| cmux | cmux 右邊的瀏覽器窗格 |
| VS Code,有裝檢視器 | VS Code 旁邊一欄 |
| VS Code,沒裝檢視器 | 用 mermaid-cli 與 Chrome 渲染成 PNG,在 VS Code 開啟 |
| 其他 | 不另外開視窗,輸入 /flow 在面板裡看 |
claude plugin validate flowmap && claude plugin test flowmap
claude plugin validate gitgraph && claude plugin test gitgraphhooks/register.tsx 177 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitView } from '../types'
5import { continuation, DATE_FORMAT, FORMAT, laneSegments, laneText, LIMIT, logArgs, parseLog, parseStatus, pill } from './parse'
6
7const PANE = 'gitgraph'
8const TITLE = 'Git 分支'
9const view = atom({ plugin: 'gitgraph', key: 'view' } as const, null)
10// 勾選要看的分支;空陣列代表全部分支,可同時勾選多條比對。
11const selected = atom({ plugin: 'gitgraph', key: 'selected' } as const, [])
12
13// 每 15 秒更新一次,接住在終端機其他地方執行的 git 操作。
14const REFRESH_MS = 15000
15const MAX_BRANCH_BUTTONS = 6
16const MAX_PILLS = 3
17// 用 Bash 輸出區的底色:深色主題下就是終端機的黑底,淺色主題會跟著換。
18const PANEL_BG = 'bashMessageBackgroundColor'
19
20async function refresh($: EngineInterface): Promise<void> {
21 const status = await $.process.run(['git', 'status', '--porcelain=v1', '-b'])
22 if (status.exitCode !== 0) {
23 const next: GitView = { branch: '', tracking: '', dirty: 0, branches: [], commits: [], error: '目前目錄不是 git repo。' }
24 await update($, view, () => next)
25 return
26 }
27 const refs = await $.process.run(['git', 'for-each-ref', '--sort=-committerdate', '--format=%(refname:short)', 'refs/heads'])
28 const branches = refs.stdout.split('\n').filter(Boolean)
29 // 已刪除的分支從勾選中移除。
30 const picked = (await read($, selected)).filter(name => branches.includes(name))
31 await update($, selected, () => picked)
32 const log = await $.process.run([
33 'git', '-c', 'color.ui=never', 'log', '--graph', '--shortstat', `-n${LIMIT}`,
34 `--date=format:${DATE_FORMAT}`, `--format=${FORMAT}`, ...logArgs(picked),
35 ])
36 const next: GitView = {
37 ...parseStatus(status.stdout),
38 branches,
39 commits: log.exitCode === 0 ? parseLog(log.stdout) : [],
40 error: log.exitCode === 0 ? '' : '這個 repo 還沒有任何提交。',
41 }
42 await update($, view, () => next)
43}
44
45async function toggle($: EngineInterface, name: string): Promise<void> {
46 await update($, selected, list =>
47 name === '' ? [] : list.includes(name) ? list.filter(one => one !== name) : [...list, name],
48 )
49 await refresh($)
50}
51
52export const register: Register = on => {
53 on('session.start', async ($, e, next) => {
54 await $.command.register({ name: 'branches', description: '開啟 Git 分支面板;/branches 名稱 切換分支;/branches hide 收起' })
55 // 不自動開面板;輸入 /branches 才打開。計時器只在面板開著時更新。
56 $.clock.every(REFRESH_MS, () => {
57 void (async () => {
58 if ((await $.ui.panes()).some(pane => pane.id === PANE)) await refresh($)
59 })()
60 })
61
62 return next(e)
63 })
64
65 on('command.run', { command: 'branches' }, async ($, e) => {
66 const names = e.args.trim().split(/\s+/).filter(Boolean)
67 if (names[0] === 'hide') {
68 await $.ui.close({ id: PANE })
69 return { text: '已收起 Git 分支面板,輸入 /branches 可再打開。' }
70 }
71 await $.ui.open({ id: PANE, title: TITLE })
72 if (names.length > 0) {
73 await update($, selected, () => (names.includes('all') ? [] : names))
74 }
75 await refresh($)
76 const picked = await read($, selected)
77
78 return { text: picked.length === 0 ? '顯示全部分支。' : `顯示分支:${picked.join('、')}` }
79 })
80
81 // 我執行任何 git 指令後立刻更新,不必等計時器。
82 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
83 const ran = await next(e)
84 if (/\bgit\b/.test(e.command) && (await $.ui.panes()).some(pane => pane.id === PANE)) void refresh($)
85
86 return ran
87 })
88
89 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
90 const { Box, Button, Text } = $.ui.resolve(e)
91 const current = await read($, view)
92 if (!current) return <Text dimColor>讀取中…</Text>
93 if (current.error !== '' && current.commits.length === 0) return <Text dimColor>{current.error}</Text>
94 const picked = await read($, selected)
95
96 // 面板自己鋪上終端機主畫面的底色,文字對比才夠。
97 const rows = Math.max(1, (e.viewport?.rows ?? 30) - 2)
98 return (
99 <Box flexDirection="column" backgroundColor={PANEL_BG} paddingX={1} width="100%" minHeight={rows}>
100 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
101 <Button key="all" label="全部" variant={picked.length === 0 ? 'primary' : undefined} onPress={() => toggle($, '')} />
102 <Button key="hide" label="收起" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
103 {current.branches.slice(0, MAX_BRANCH_BUTTONS).map(name => (
104 <Button
105 key={`b:${name}`}
106 label={picked.includes(name) ? `✓ ${name}` : name}
107 variant={picked.includes(name) ? 'primary' : undefined}
108 onPress={() => toggle($, name)}
109 />
110 ))}
111 </Box>
112 <Text> </Text>
113
114 <Text wrap="truncate-end">
115 <Text color="warning">◌ </Text>
116 <Text bold color={current.dirty === 0 ? 'text' : 'warning'}>
117 {current.dirty === 0 ? '沒有變更' : `${current.dirty} 個未提交變更`}
118 </Text>
119 </Text>
120 <Text wrap="truncate-end">
121 <Text color="subtle">{'│ 目前分支 '}</Text>
122 <Text bold color="success">{current.branch}</Text>
123 {current.tracking !== '' && <Text color="subtle">{` ${current.tracking}`}</Text>}
124 </Text>
125
126 {current.commits.map(commit => {
127 if (commit.hash === '') {
128 return (
129 <Text>{laneSegments(laneText(commit.graph)).map(seg => <Text color={seg.color ?? 'text'}>{seg.text}</Text>)}</Text>
130 )
131 }
132 const lane = laneSegments(laneText(commit.graph))
133 const below = laneSegments(laneText(continuation(commit.graph)))
134 const draw = (segs: typeof lane) => segs.map(seg => <Text color={seg.color ?? 'text'}>{seg.text}</Text>)
135 const pills = commit.refs.filter(ref => !ref.endsWith('/HEAD')).slice(0, MAX_PILLS)
136 return (
137 <Box flexDirection="column" marginTop={1}>
138 <Box flexDirection="row" justifyContent="space-between">
139 <Box flexShrink={1}>
140 <Text wrap="truncate-end">
141 {draw(lane)}
142 <Text bold color="text">{commit.subject}</Text>
143 </Text>
144 </Box>
145 {commit.files > 0 && <Text backgroundColor="subtle" color="inverseText">{` ${commit.files} `}</Text>}
146 </Box>
147 <Box flexDirection="row" justifyContent="space-between">
148 <Box flexShrink={1}>
149 <Text wrap="truncate-end">
150 {draw(below)}
151 <Text color="subtle" italic>{commit.author}</Text>
152 </Text>
153 </Box>
154 {pills.length > 0 ? (
155 <Text>
156 {pills.map(ref => {
157 const p = pill(ref)
158 return (
159 <Text>
160 <Text backgroundColor={p.background} color="inverseText" bold>{` ${p.text} `}</Text>
161 {' '}
162 </Text>
163 )
164 })}
165 </Text>
166 ) : (
167 <Text color="subtle" italic>{commit.date}</Text>
168 )}
169 </Box>
170 </Box>
171 )
172 })}
173 </Box>
174 )
175 })
176}
177hooks/parse.ts 83 lines1import type { Commit } from '../types'
2
3export const FORMAT = '%x01%h%x02%D%x02%s%x02%an%x02%ad'
4export const DATE_FORMAT = '%m/%d %a %H:%M'
5export const LIMIT = 40
6
7const STAT = /(\d+) files? changed/
8
9// 解析 git log --graph --shortstat:\x01 前是線圖;統計行歸給上一個提交。
10export function parseLog(stdout: string): Commit[] {
11 const out: Commit[] = []
12 for (const line of stdout.split('\n')) {
13 const at = line.indexOf('\x01')
14 if (at >= 0) {
15 const [hash = '', refs = '', subject = '', author = '', date = ''] = line.slice(at + 1).split('\x02')
16 out.push({ graph: line.slice(0, at), hash, refs: refs === '' ? [] : refs.split(', '), subject, author, date, files: 0 })
17 continue
18 }
19 const stat = STAT.exec(line)
20 if (stat) {
21 const last = [...out].reverse().find(commit => commit.hash !== '')
22 if (last) last.files = Number(stat[1])
23 continue
24 }
25 // 只保留有分岔或合併的線圖行;純直線行由每個提交的第二行接續。
26 if (/[\\/_]/.test(line)) {
27 out.push({ graph: line.trimEnd(), hash: '', refs: [], subject: '', author: '', date: '', files: 0 })
28 }
29 }
30 return out
31}
32
33// 解析 git status --porcelain=v1 -b:第一行是分支與追蹤狀態,其餘每行一個變更檔案。
34export function parseStatus(stdout: string): { branch: string; tracking: string; dirty: number } {
35 const [head = '', ...rest] = stdout.split('\n').filter(line => line !== '')
36 const m = /^## (?:No commits yet on )?([^.\s]+)(?:\.\.\.(\S+))?(?: \[(.+)\])?/.exec(head)
37 const ahead = /ahead (\d+)/.exec(m?.[3] ?? '')?.[1]
38 const behind = /behind (\d+)/.exec(m?.[3] ?? '')?.[1]
39 const tracking = [m?.[2] ?? '', ahead ? `領先 ${ahead}` : '', behind ? `落後 ${behind}` : '']
40 .filter(Boolean)
41 .join(' ')
42 return { branch: m?.[1] ?? '', tracking, dirty: rest.length }
43}
44
45// 線圖字元換成框線字元,提交點用實心圓。
46export function laneText(graph: string): string {
47 return graph.replace(/\*/g, '●').replace(/\|/g, '│').replace(/\//g, '╱').replace(/\\/g, '╲').replace(/_/g, '─')
48}
49
50// 提交第二行的線圖:提交點那欄接成直線,斜線不延續。
51export function continuation(graph: string): string {
52 return graph.replace(/[*|]/g, '│').replace(/[\\/_]/g, ' ')
53}
54
55// 每兩欄是一條分支線,依欄位輪流上色。
56export const LANE_COLORS = ['suggestion', 'success', 'warning', 'claude', 'error'] as const
57
58export function laneSegments(text: string): { text: string; color: string | undefined }[] {
59 const out: { text: string; color: string | undefined }[] = []
60 for (let i = 0; i < text.length; i += 1) {
61 const ch = text[i] ?? ' '
62 const color = ch === ' ' ? undefined : LANE_COLORS[Math.floor(i / 2) % LANE_COLORS.length]
63 const last = out[out.length - 1]
64 if (last && last.color === color) last.text += ch
65 else out.push({ text: ch, color })
66 }
67 return out
68}
69
70// 標籤膠囊:HEAD 指向的分支、本地分支、tag、遠端分支各用一種底色。
71export function pill(ref: string): { text: string; background: string } {
72 if (ref.startsWith('HEAD -> ')) return { text: ref.slice(8), background: 'suggestion' }
73 if (ref === 'HEAD') return { text: 'HEAD', background: 'suggestion' }
74 if (ref.startsWith('tag: ')) return { text: ref.slice(5), background: 'warning' }
75 if (ref.includes('/')) return { text: ref, background: 'subtle' }
76 return { text: ref, background: 'success' }
77}
78
79// 要看的分支:空陣列代表全部分支。
80export function logArgs(selected: readonly string[]): string[] {
81 return selected.length === 0 ? ['--all'] : [...selected]
82}
83types/index.d.ts 24 lines1export type Commit = {
2 graph: string
3 hash: string
4 refs: string[]
5 subject: string
6 author: string
7 date: string
8 files: number
9}
10export type GitView = {
11 branch: string
12 tracking: string
13 dirty: number
14 branches: string[]
15 commits: Commit[]
16 error: string
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 gitgraph: { view: GitView | null; selected: string[] }
22 }
23}
24