Wukong Git Graph: read-only Git history for Claude Code Desktop

在 Codex 或 Claude Code 中查看当前项目的 Git 提交和分支。图谱只读,不会更改代码或 Git 历史,也不会上传项目内容。
复制下面的提示词,发给 Codex 或 Claude Code:
请在当前 Agent 工具中安装 Wukong Git Graph,并在当前项目打开面板。先确认当前工具是 Codex 还是 Claude Code,再按对应步骤操作:
- Codex:从 https://github.com/wukongnotnull/wukong-git-graph 获取仓库源码,在仓库目录运行
npm --prefix codex ci和npm --prefix codex run build,再运行codex plugin marketplace add "<仓库目录绝对路径>"和codex plugin add wukong-git-graph@wukong-git-graph-local。- Claude Code:运行
claude plugin marketplace add wukongnotnull/wukong-git-graph和claude plugin install wukong-git-graph@wukong-git-graph。安装后确认插件已启用,并在当前项目的新聊天或会话中打开 Git Graph。检查面板显示的是当前项目的提交历史,或该项目有效的空历史状态。不要修改项目文件或 Git 历史,也不要改用网页方式。如果当前工具不支持、缺少依赖或面板未能打开,请说明原因,不要声称安装成功。
/wukong-git-graph 并选择 Wukong Git Graph。菜单入口会优先使用项目 root;宿主未提供 roots 时,通过调用携带的聊天 ID 自动读取当前项目目录(需要 codex 命令可用)。仍无法确定项目时,会显示 Repository path 输入框;填入本地仓库绝对路径,点击 Open repository。路径无效时可在面板内修改并重试。/git-graph,或点击 Wukong Git Graph 按钮。面板显示当前项目的提交、分支和文件改动。
需要 Codex 桌面应用、Git、Node.js 22 或更高版本,以及 Codex CLI(终端里的 codex 命令)。在电脑终端中运行:
git clone https://github.com/wukongnotnull/wukong-git-graph.git
cd wukong-git-graph
npm --prefix codex ci
npm --prefix codex run build
codex plugin marketplace add "$PWD"
codex plugin add wukong-git-graph@wukong-git-graph-local
在 Codex 插件管理中启用 Wukong Git Graph,然后新开一个本地项目聊天。旧聊天可能还没有加载新插件。
在电脑终端中运行:
claude plugin marketplace add wukongnotnull/wukong-git-graph
claude plugin install wukong-git-graph@wukong-git-graph
在 Claude Code 插件管理中启用插件,然后新开一个本地项目会话。更新插件时运行:
claude plugin update wukong-git-graphhooks/register.tsx 147 lines1import type { ElementTable, EngineInterface, Register } from 'claude-code'
2
3import type { ViewState } from '../types'
4import { PANE_COLUMNS, PANE_ID } from './constants'
5import { openGraph } from './controller'
6import { describeError } from './errors'
7import { parseCurrentBranch } from './git/current-branch'
8import { runGit } from './git/run-git'
9import { snapshotOf } from './usage'
10import { initialView } from './state'
11import { makeActions } from './ui/actions'
12import { renderBand } from './ui/band'
13import { renderPane } from './ui/pane'
14
15const OPENED_TEXT = 'Wukong Git Graph opened.'
16const VIEW_REF = { plugin: 'wukong-git-graph', key: 'view' } as const
17const BRANCH_REF = { plugin: 'wukong-git-graph', key: 'branch' } as const
18const USAGE_REF = { plugin: 'wukong-git-graph', key: 'usage' } as const
19
20// The hooks loader follows `$` only into functions declared in this file, never across an
21// import, and refuses `$` being spread or returned. It also wants every state reference
22// spelled out as literals. The controller and the actions live in their own modules, so
23// they get an object that forwards just the calls they make, each spelled `$.noun.event(...)`
24// here. The plugin keeps a single state value (the view), so the state calls ignore the
25// reference they are given. The cast is the same one the test adapter makes.
26function engineOf($: EngineInterface): EngineInterface {
27 const nouns = {
28 process: {
29 run: (argv: readonly string[], init?: Parameters<EngineInterface['process']['run']>[1]) => $.process.run(argv, init),
30 },
31 state: {
32 get: () => $.state.get(VIEW_REF),
33 set: (_ref: unknown, value: ViewState, options?: { ifVersion?: number }) =>
34 $.state.set(VIEW_REF, value, options),
35 },
36 ui: {
37 toast: (message: string) => $.ui.toast(message),
38 scroll: (args: Parameters<EngineInterface['ui']['scroll']>[0]) => $.ui.scroll(args),
39 },
40 }
41
42 return nouns as unknown as EngineInterface
43}
44
45// The branch the session's directory is on, kept for the band above the prompt; empty outside a repository.
46// The agent can switch branches during a turn, so this runs again when a turn ends.
47async function refreshBranch($: EngineInterface): Promise<void> {
48 const result = await runGit(engineOf($), ['rev-parse', '--abbrev-ref', 'HEAD'])
49
50 await $.state.set(BRANCH_REF, result.ok ? parseCurrentBranch(result.stdout) : '')
51}
52
53// The usage figures are a nicety: where the engine has none (it never throws for that) or a host cannot answer,
54// the band simply shows no meters, and opening the pane must not depend on them.
55async function refreshUsage($: EngineInterface): Promise<void> {
56 try {
57 await $.state.set(USAGE_REF, snapshotOf(await $.session.usage()))
58 } catch {
59 // Left as it was: the meters keep the last figures, or stay hidden.
60 }
61}
62
63// Opens the pane and loads the graph. Both the command and the band's button come here;
64// `$` has to stay a plain parameter so the hooks loader can follow it.
65async function openBoard($: EngineInterface): Promise<string> {
66 await refreshBranch($)
67 await refreshUsage($)
68
69 const opened = await $.ui.open({ id: PANE_ID, title: 'Wukong Git Graph', columns: PANE_COLUMNS })
70
71 if (!opened.isPlaced) {
72 return 'Wukong Git Graph: this surface cannot show the panel.'
73 }
74
75 try {
76 await openGraph(engineOf($))
77 } catch (error) {
78 // openGraph records load failures itself; this catches a failure of the state calls.
79 return `Wukong Git Graph: ${describeError(error)}`
80 }
81
82 return OPENED_TEXT
83}
84
85export const register: Register = on => {
86 on('session.start', async ($, e, next) => {
87 await $.command.register({ name: 'git-graph', description: 'Open the Git graph board' })
88 await refreshBranch($)
89 await refreshUsage($)
90
91 return next(e)
92 })
93
94 // Pushed by the engine after each turn and whenever a rate-limit window moves a whole point.
95 on('session.measure', async ($, e, next) => {
96 await $.state.set(USAGE_REF, snapshotOf(e))
97
98 return next(e)
99 })
100
101 on('turn.complete', async ($, e, next) => {
102 await refreshBranch($)
103
104 return next(e)
105 })
106
107 on('command.run', { command: 'git-graph' }, async $ => {
108 return { text: await openBoard($) }
109 })
110
111 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
112 if (e.surface !== 'desktop' || e.props.hasSurvey) {
113 return next(e)
114 }
115
116 const ui = $.ui.resolve(e) as ElementTable<'desktop'>
117 const { value: branch = '' } = await $.state.get(BRANCH_REF)
118 const { value: usage } = await $.state.get(USAGE_REF)
119
120 // Only to tell a reset later today from one on another day, so the module's own Date is enough and nothing waits on the engine's clock.
121 return renderBand(ui, branch, usage, new Date().getTime(), () => {
122 openBoard($).then(text => {
123 if (text !== OPENED_TEXT) {
124 $.ui.toast(text)
125 }
126 })
127 })
128 })
129
130 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
131 if (e.surface !== 'desktop') {
132 const { Box, Text } = $.ui.resolve(e)
133
134 return (
135 <Box>
136 <Text dimColor>Wukong Git Graph works only in the desktop app.</Text>
137 </Box>
138 )
139 }
140
141 const ui = $.ui.resolve(e) as ElementTable<'desktop'>
142 const { value: view = initialView } = await $.state.get(VIEW_REF)
143
144 return renderPane(ui, view, makeActions(engineOf($)), e.props.bodyColumns)
145 })
146}
147hooks/constants.ts 59 lines1export const PLUGIN = 'wukong-git-graph'
2export const PANE_ID = 'git-graph'
3// The width the docked pane asks for when it opens: about what every column needs. A request, not a grant: a width
4// the person has dragged the pane to, or kept from an earlier open, wins.
5export const PANE_COLUMNS = 100
6export const PAGE_SIZE = 50
7// The engine draws a pane's tree only up to about 200,000 characters and drops the rest (measured: 100 rows came
8// to 199,692 and the 101st never showed). The pane draws a window of rows within this budget, which leaves room
9// for the toolbar, the header and an open commit's detail.
10export const ROW_WINDOW_CHARS = 140_000
11// What a row costs besides its SVGs and texts: its nodes, props and keys, measured at about 1,400.
12export const ROW_OVERHEAD_CHARS = 1_400
13// A git call that has not returned by then is reported as a failure instead of leaving the pane on Loading.
14export const GIT_TIMEOUT_MS = 10_000
15export const ALL_BRANCHES = '*'
16// The engine refuses a Select of more options than this, and with it the whole pane.
17export const MAX_SELECT_OPTIONS = 64
18
19export const LANE_WIDTH = 14
20export const ROW_HEIGHT = 18
21export const NODE_RADIUS = 4
22export const LINE_WIDTH = 2
23// Measured in the desktop app: a Date column of 18 cells is 189 px.
24export const CELL_WIDTH_PX = 10.5
25// Enough cells for the "Graph" header label.
26export const GRAPH_MIN_CELLS = 6
27
28// Measured against the pane: `13 Oct 2026 19:47` fills about 13.5 of these cells, an eight-character hash about 6.5,
29// a five-character CJK name about 8. Each column keeps one or two cells of air and no more.
30export const DATE_WIDTH = 15
31export const AUTHOR_WIDTH = 10
32export const HASH_WIDTH = 8
33// The description is never squeezed below this many cells; the other columns give way first. The pane opens about
34// 50 cells wide, and there only the graph and the description are drawn: the date needs about 57.
35export const DESCRIPTION_MIN = 36
36// A commit's detail puts its files beside its fields from this many cells on, and under them below.
37export const DETAIL_WIDE_MIN = 100
38export const DETAIL_LABEL_WIDTH = 11
39// A commit's detail lists at most this many files and this much of its message: the engine drops what comes after
40// a pane's text limit, and with it the rest of the list. Each file costs about three times its path (two keys and
41// its name), so a commit of thousands of files alone would pass that limit.
42export const MAX_DETAIL_FILES = 50
43export const MAX_MESSAGE_CHARS = 4_000
44// A row draws at most this many ref chips, each name cut to this many characters.
45export const MAX_CHIPS = 2
46export const CHIP_LABEL_MAX = 22
47
48// The Okabe-Ito palette, chosen to stay apart for red-green colour blindness: no red-green pair,
49// and the blue, vermillion, green and orange also differ in lightness.
50export const LANE_COLORS: readonly string[] = [
51 '#0072b2',
52 '#d55e00',
53 '#009e73',
54 '#e69f00',
55 '#56b4e9',
56 '#cc79a7',
57 '#6e7681',
58]
59hooks/controller.ts 426 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { Commit, ViewState } from '../types'
5import { visibleBranches } from './branches'
6import { ALL_BRANCHES, PAGE_SIZE, PANE_ID } from './constants'
7import { LOG_FORMAT, parseLog } from './git/parse-log'
8import { parseBranches } from './git/parse-branches'
9import { META_FORMAT, parseMeta, parseNumstat } from './git/parse-detail'
10import { parseFileDiff } from './git/parse-diff'
11import { isCommitHash, runGit } from './git/run-git'
12import type { GitFailure } from './git/run-git'
13import { describeError } from './errors'
14import { emptyLaneState, layoutCommits } from './layout/lanes'
15import { windowEnd } from './layout/window'
16import { initialView } from './state'
17
18// The hooks loader reads state references only when their plugin and key are string literals
19// written in the same file as the read or update, so the atom is declared here, not imported.
20export const viewAtom = atom({ plugin: 'wukong-git-graph', key: 'view' } as const, initialView)
21
22type Page = { commits: Commit[]; hasMore: boolean }
23type PageResult = { ok: true; page: Page } | GitFailure
24type BranchLists = { branches: string[]; remoteBranches: string[] }
25type BranchResult = ({ ok: true } & BranchLists) | GitFailure
26
27function buildRevs(view: ViewState): string[] | null {
28 if (view.branch === ALL_BRANCHES) {
29 return ['--branches', '--tags', ...(view.isRemoteShown ? ['--remotes'] : []), 'HEAD']
30 }
31
32 if (!visibleBranches(view).includes(view.branch)) {
33 return null
34 }
35
36 // The full refname, so a branch named like a tag or a path cannot resolve to something else.
37 return [view.remoteBranches.includes(view.branch) ? `refs/remotes/${view.branch}` : `refs/heads/${view.branch}`]
38}
39
40async function fetchPage($: EngineInterface, view: ViewState, offset: number): Promise<PageResult> {
41 const revs = buildRevs(view)
42
43 if (revs === null) {
44 return { ok: false, kind: 'failed', message: 'That branch no longer exists.' }
45 }
46
47 const result = await runGit($, [
48 'log',
49 '--topo-order',
50 `--skip=${offset}`,
51 '-n',
52 String(PAGE_SIZE + 1),
53 `--format=${LOG_FORMAT}`,
54 ...revs,
55 '--',
56 ])
57
58 if (!result.ok) {
59 return result.kind === 'no-commits' ? { ok: true, page: { commits: [], hasMore: false } } : result
60 }
61
62 const parsed = parseLog(result.stdout, new Set(view.remoteBranches))
63
64 return { ok: true, page: { commits: parsed.slice(0, PAGE_SIZE), hasMore: parsed.length > PAGE_SIZE } }
65}
66
67const isCurrent = (view: ViewState, generation: number): boolean => view.generation === generation
68
69// A page may only extend the list it was fetched for; a sibling loadMore may have extended it already.
70const isSamePage = (latest: ViewState, base: ViewState): boolean =>
71 isCurrent(latest, base.generation) && latest.commits.length === base.commits.length
72
73async function fail($: EngineInterface, failure: GitFailure, generation: number): Promise<void> {
74 await update($, viewAtom, (latest): ViewState => {
75 if (!isCurrent(latest, generation)) {
76 return latest
77 }
78
79 return {
80 ...latest,
81 status: failure.kind === 'not-a-repo' ? 'no-repo' : 'error',
82 errorMessage: failure.message,
83 }
84 })
85}
86
87// Records a thrown error as the view's error. With a generation, a failure of a superseded load is dropped.
88export async function recordFailure($: EngineInterface, error: unknown, generation?: number): Promise<void> {
89 await update($, viewAtom, (latest): ViewState => {
90 if (generation !== undefined && !isCurrent(latest, generation)) {
91 return latest
92 }
93
94 return { ...latest, status: 'error', errorMessage: describeError(error) }
95 })
96}
97
98export async function currentGeneration($: EngineInterface): Promise<number> {
99 return (await read($, viewAtom)).generation
100}
101
102async function fetchBranches($: EngineInterface): Promise<BranchResult> {
103 const inside = await runGit($, ['rev-parse', '--is-inside-work-tree'])
104
105 if (!inside.ok) {
106 return inside
107 }
108
109 const listed = await runGit($, ['for-each-ref', '--format=%(refname)', 'refs/heads', 'refs/remotes'])
110
111 if (!listed.ok) {
112 return listed
113 }
114
115 const { locals, remotes } = parseBranches(listed.stdout)
116 const remoteBranches = remotes.filter(name => !name.endsWith('/HEAD'))
117
118 return { ok: true, branches: [...locals, ...remoteBranches], remoteBranches }
119}
120
121async function loadGeneration($: EngineInterface, current: ViewState, generation: number): Promise<void> {
122 const lists = await fetchBranches($)
123
124 if (!lists.ok) {
125 return fail($, lists, generation)
126 }
127
128 const { branches, remoteBranches } = lists
129 const probe: ViewState = { ...current, branches, remoteBranches }
130 const branch =
131 current.branch === ALL_BRANCHES || visibleBranches(probe).includes(current.branch) ? current.branch : ALL_BRANCHES
132
133 const result = await fetchPage($, { ...probe, branch }, 0)
134
135 if (!result.ok) {
136 return fail($, result, generation)
137 }
138
139 const { rows, state } = layoutCommits(emptyLaneState(), result.page.commits)
140
141 await update($, viewAtom, (latest): ViewState => {
142 if (!isCurrent(latest, generation)) {
143 return latest
144 }
145
146 return {
147 ...latest,
148 status: 'ready',
149 errorMessage: '',
150 branches,
151 remoteBranches,
152 branch,
153 commits: result.page.commits,
154 rows,
155 laneState: state,
156 hasMore: result.page.hasMore,
157 expandedHash: null,
158 detail: null,
159 detailError: '',
160 fileDiff: null,
161 windowStart: 0,
162 windowStack: [],
163 }
164 })
165}
166
167export async function loadFirst($: EngineInterface): Promise<void> {
168 const current = await read($, viewAtom)
169 const started = await update($, viewAtom, (view): ViewState => ({
170 ...view,
171 status: 'loading',
172 errorMessage: '',
173 generation: view.generation + 1,
174 }))
175
176 try {
177 await loadGeneration($, current, started.generation)
178 } catch (error) {
179 await recordFailure($, error, started.generation)
180 }
181}
182
183export async function loadMore($: EngineInterface): Promise<void> {
184 const view = await read($, viewAtom)
185
186 if (!view.hasMore || view.status !== 'ready') {
187 return
188 }
189
190 const result = await fetchPage($, view, view.commits.length)
191
192 if (!result.ok) {
193 await update($, viewAtom, latest => (isSamePage(latest, view) ? { ...latest, errorMessage: result.message } : latest))
194
195 return
196 }
197
198 const known = new Set(view.commits.map(commit => commit.hash))
199 const fresh = result.page.commits.filter(commit => !known.has(commit.hash))
200 const { rows, state } = layoutCommits(view.laneState, fresh)
201
202 await update($, viewAtom, latest => {
203 if (!isSamePage(latest, view)) {
204 return latest
205 }
206
207 return {
208 ...latest,
209 errorMessage: '',
210 commits: [...latest.commits, ...fresh],
211 rows: [...latest.rows, ...rows],
212 laneState: state,
213 hasMore: fresh.length === 0 ? false : result.page.hasMore,
214 }
215 })
216}
217
218const clearedList = (view: ViewState): ViewState => ({
219 ...view,
220 status: 'loading',
221 generation: view.generation + 1,
222 commits: [],
223 rows: [],
224 laneState: emptyLaneState(),
225 hasMore: false,
226 expandedHash: null,
227 detail: null,
228 detailError: '',
229 fileDiff: null,
230 windowStart: 0,
231 windowStack: [],
232})
233
234export async function selectBranch($: EngineInterface, value: string): Promise<void> {
235 const view = await read($, viewAtom)
236
237 if (value !== ALL_BRANCHES && !visibleBranches(view).includes(value)) {
238 return
239 }
240
241 await update($, viewAtom, latest => ({ ...clearedList(latest), branch: value }))
242 await loadFirst($)
243}
244
245export async function toggleRemote($: EngineInterface): Promise<void> {
246 const view = await read($, viewAtom)
247 const isRemoteShown = !view.isRemoteShown
248 const isSelectionHidden = !isRemoteShown && view.remoteBranches.includes(view.branch)
249
250 await update($, viewAtom, latest => ({
251 ...clearedList(latest),
252 isRemoteShown,
253 branch: isSelectionHidden ? ALL_BRANCHES : latest.branch,
254 }))
255 await loadFirst($)
256}
257
258// Opening the board shows nothing from an earlier repository or session while it loads.
259export async function openGraph($: EngineInterface): Promise<void> {
260 await update($, viewAtom, latest => ({ ...clearedList(latest), errorMessage: '' }))
261 await loadFirst($)
262}
263
264export async function refresh($: EngineInterface): Promise<void> {
265 await loadFirst($)
266}
267
268type GitOutcome = { ok: true } | GitFailure
269
270function describeDetailError(meta: GitOutcome, isParsed: boolean, files: GitOutcome): string {
271 if (!meta.ok) {
272 return meta.message
273 }
274
275 if (!isParsed) {
276 return 'Could not read the commit details.'
277 }
278
279 return files.ok ? '' : `Could not read the file list: ${files.message}`
280}
281
282export async function toggleCommit($: EngineInterface, hash: string): Promise<void> {
283 const view = await read($, viewAtom)
284 const { generation } = view
285
286 if (view.expandedHash === hash) {
287 await update($, viewAtom, latest => ({ ...latest, expandedHash: null, detail: null, detailError: '', fileDiff: null }))
288
289 return
290 }
291
292 if (!isCommitHash(hash)) {
293 return
294 }
295
296 await update($, viewAtom, latest => ({ ...latest, expandedHash: hash, detail: null, detailError: '', fileDiff: null }))
297
298 const [meta, files] = await Promise.all([
299 runGit($, ['show', '-s', '--no-ext-diff', '--no-textconv', `--format=${META_FORMAT}`, hash]),
300 runGit($, ['show', '-m', '--first-parent', '--no-renames', '--numstat', '--no-ext-diff', '--no-textconv', '--format=', hash]),
301 ])
302 const parsed = meta.ok ? parseMeta(meta.stdout) : null
303 const detail = parsed === null ? null : { ...parsed, files: files.ok ? parseNumstat(files.stdout) : [] }
304 const detailError = describeDetailError(meta, parsed !== null, files)
305
306 await update($, viewAtom, latest =>
307 latest.expandedHash === hash && isCurrent(latest, generation) ? { ...latest, detail, detailError } : latest,
308 )
309}
310
311// Scrolling to a row can fail (the pane was closed meanwhile); the window has moved either way.
312async function scrollToRow($: EngineInterface, hash: string | undefined): Promise<void> {
313 if (hash === undefined) {
314 return
315 }
316
317 try {
318 await $.ui.scroll({ in: PANE_ID, to: { key: `row-${hash}` } })
319 } catch {
320 // Nothing to do: the new window is drawn, only the scroll position is left where it was.
321 }
322}
323
324// The button at the bottom of the list. While the window has room it grows by loading the next page; once the window
325// is full it moves on to the older rows after it, loading them first if they are not here yet.
326export async function showOlder($: EngineInterface): Promise<void> {
327 const before = await read($, viewAtom)
328
329 if (before.status !== 'ready') {
330 return
331 }
332
333 if (windowEnd(before, before.windowStart) >= before.commits.length) {
334 await loadMore($)
335
336 const loaded = await read($, viewAtom)
337
338 if (windowEnd(loaded, loaded.windowStart) > windowEnd(before, before.windowStart)) {
339 return
340 }
341 }
342
343 const latest = await read($, viewAtom)
344 const end = windowEnd(latest, latest.windowStart)
345
346 if (end >= latest.commits.length) {
347 return
348 }
349
350 await update($, viewAtom, (view): ViewState =>
351 view.generation === latest.generation && view.windowStart === latest.windowStart
352 ? { ...view, windowStart: end, windowStack: [...view.windowStack, view.windowStart], expandedHash: null, detail: null, detailError: '', fileDiff: null }
353 : view,
354 )
355 await fillWindow($)
356 await scrollToRow($, latest.commits[end]?.hash)
357}
358
359const MAX_FILL_PAGES = 3
360
361// A window that just moved on holds only the rows already loaded after it; load pages until it is full, so the next
362// press does not have to finish the job.
363async function fillWindow($: EngineInterface): Promise<void> {
364 for (let page = 0; page < MAX_FILL_PAGES; page += 1) {
365 const view = await read($, viewAtom)
366
367 if (!view.hasMore || windowEnd(view, view.windowStart) < view.commits.length) {
368 return
369 }
370
371 await loadMore($)
372 }
373}
374
375// The button at the top of the list: back to the window that was left.
376export async function showNewer($: EngineInterface): Promise<void> {
377 const view = await read($, viewAtom)
378 const previous = view.windowStack.at(-1)
379
380 if (previous === undefined) {
381 return
382 }
383
384 await update($, viewAtom, (latest): ViewState =>
385 latest.generation === view.generation && latest.windowStart === view.windowStart
386 ? { ...latest, windowStart: previous, windowStack: latest.windowStack.slice(0, -1), expandedHash: null, detail: null, detailError: '', fileDiff: null }
387 : latest,
388 )
389 await scrollToRow($, view.commits[previous]?.hash)
390}
391
392const NO_DIFF = { text: '', isCut: false, isTooLarge: false }
393
394// Opens the diff of one file of the open commit under the card, or closes it when it is already open. A rename is
395// listed as a delete and an add (`--no-renames`), so every listed path is a plain pathspec.
396export async function toggleFileDiff($: EngineInterface, path: string): Promise<void> {
397 const view = await read($, viewAtom)
398 const { detail, generation } = view
399
400 if (detail === null || !isCommitHash(detail.hash)) {
401 return
402 }
403
404 if (view.fileDiff?.path === path) {
405 await update($, viewAtom, latest => ({ ...latest, fileDiff: null }))
406
407 return
408 }
409
410 if (!detail.files.some(file => file.path === path)) {
411 return
412 }
413
414 const loading = { path, ...NO_DIFF, error: '', isLoading: true }
415 await update($, viewAtom, (latest): ViewState => ({ ...latest, fileDiff: loading }))
416
417 const result = await runGit($, ['show', '-m', '--first-parent', '--no-renames', '--no-ext-diff', '--no-textconv', '--format=', '--unified=3', detail.hash, '--', path])
418 const shown = { path, ...(result.ok ? parseFileDiff(result.stdout) : NO_DIFF), error: result.ok ? '' : result.message, isLoading: false }
419
420 await update($, viewAtom, (latest): ViewState =>
421 latest.detail?.hash === detail.hash && latest.fileDiff?.path === path && isCurrent(latest, generation)
422 ? { ...latest, fileDiff: shown }
423 : latest,
424 )
425}
426hooks/errors.ts 5 lines1// The text of a thrown value, for a toast or the pane: an Error's message, anything else as it prints.
2export function describeError(error: unknown): string {
3 return error instanceof Error ? error.message : String(error)
4}
5hooks/git/current-branch.ts 9 lines1export const DETACHED_LABEL = 'HEAD (detached)'
2
3// The output of `git rev-parse --abbrev-ref HEAD`: the branch name, or the word HEAD when it points at a commit.
4export function parseCurrentBranch(stdout: string): string {
5 const name = stdout.trim()
6
7 return name === 'HEAD' ? DETACHED_LABEL : name
8}
9hooks/git/run-git.ts 59 lines1import type { EngineInterface } from 'claude-code'
2
3import { GIT_TIMEOUT_MS } from '../constants'
4import { describeError } from '../errors'
5import { buildGitArgv } from './argv'
6export { buildGitArgv, isCommitHash } from './argv'
7
8// The failures below are told apart by git's messages, so git must not translate them.
9const ENGLISH_MESSAGES = { LC_ALL: 'C' }
10
11export type GitResult =
12 | { ok: true; stdout: string }
13 | {
14 ok: false
15 kind: 'not-a-repo' | 'no-commits' | 'truncated' | 'unavailable' | 'failed'
16 message: string
17 }
18
19export type GitFailure = Extract<GitResult, { ok: false }>
20
21// The engine rejects when git cannot start (most often it is not installed) or is still running at the timeout.
22function describeUnavailable(reason: string): string {
23 return /timed? ?out/i.test(reason)
24 ? `git did not answer within ${GIT_TIMEOUT_MS / 1000} seconds.`
25 : `Could not start git. Is it installed? (${reason})`
26}
27
28export async function runGit($: EngineInterface, args: readonly string[]): Promise<GitResult> {
29 const argv = buildGitArgv(args)
30
31 let result: Awaited<ReturnType<EngineInterface['process']['run']>>
32
33 try {
34 result = await $.process.run(argv, { env: ENGLISH_MESSAGES, timeoutMs: GIT_TIMEOUT_MS })
35 } catch (error) {
36 return { ok: false, kind: 'unavailable', message: describeUnavailable(describeError(error)) }
37 }
38
39 if (result.isStdoutTruncated) {
40 return { ok: false, kind: 'truncated', message: 'Git output was too large to read in one page.' }
41 }
42
43 if (result.exitCode === 0) {
44 return { ok: true, stdout: result.stdout }
45 }
46
47 if (/not a git repository/i.test(result.stderr)) {
48 return { ok: false, kind: 'not-a-repo', message: 'Not a git repository.' }
49 }
50
51 if (/does not have any commits yet|ambiguous argument 'HEAD'|unknown revision.*\bHEAD\b/i.test(result.stderr)) {
52 return { ok: false, kind: 'no-commits', message: 'The repository has no commits yet.' }
53 }
54
55 const firstLine = result.stderr.split('\n')[0]?.trim() ?? ''
56
57 return { ok: false, kind: 'failed', message: firstLine === '' ? `git exited with ${result.exitCode}` : firstLine }
58}
59hooks/usage.ts 87 lines1import type { UsageSnapshot } from '../types'
2
3// The parts of `$.session.usage()` and of the `session.measure` event that the band shows.
4export type UsageSource = {
5 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
6 context: { percent?: number }
7 cost?: { usd: number }
8}
9
10export type UsageItem = {
11 key: string
12 label: string
13 percent: number
14 // When the window resets, e.g. `Mon 06:00` or `14:30`; empty for the context window.
15 reset: string
16}
17
18const WINDOW_LABELS: Record<string, string> = {
19 five_hour: 'Session',
20 seven_day: 'Week',
21 spend_limit: 'Spend',
22}
23
24const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
25const WARN_PERCENT = 80
26const DANGER_PERCENT = 95
27// Okabe-Ito colours, apart for red-green colour blindness, and told apart by lightness too.
28const NORMAL_COLOR = '#56b4e9'
29const WARN_COLOR = '#e69f00'
30const DANGER_COLOR = '#d55e00'
31
32export function snapshotOf(source: UsageSource): UsageSnapshot {
33 return {
34 rateLimits: source.rateLimits.map(limit => ({ kind: limit.kind, percentUsed: limit.percentUsed, resetsAt: limit.resetsAt })),
35 contextPercent: source.context.percent,
36 costUsd: source.cost?.usd,
37 }
38}
39
40const pad = (value: number): string => String(value).padStart(2, '0')
41
42// `14:30` when the reset is today, `Mon 06:00` otherwise; empty for a time that cannot be read.
43export function formatReset(iso: string | undefined, nowMs: number): string {
44 if (iso === undefined) {
45 return ''
46 }
47
48 const at = new Date(iso)
49
50 if (Number.isNaN(at.getTime())) {
51 return ''
52 }
53
54 const now = new Date(nowMs)
55 const isToday = at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth() && at.getDate() === now.getDate()
56 const time = `${pad(at.getHours())}:${pad(at.getMinutes())}`
57
58 return isToday ? time : `${WEEKDAYS[at.getDay()] ?? ''} ${time}`
59}
60
61export function usageItems(usage: UsageSnapshot, nowMs: number): UsageItem[] {
62 const limits = usage.rateLimits.map(
63 (limit): UsageItem => ({
64 key: `limit-${limit.kind}`,
65 label: WINDOW_LABELS[limit.kind] ?? limit.kind,
66 percent: limit.percentUsed,
67 reset: formatReset(limit.resetsAt, nowMs),
68 }),
69 )
70 const order = (item: UsageItem): number => Object.keys(WINDOW_LABELS).findIndex(kind => `limit-${kind}` === item.key)
71 const sorted = [...limits].sort((a, b) => (order(a) === -1 ? 99 : order(a)) - (order(b) === -1 ? 99 : order(b)))
72
73 return usage.contextPercent === undefined
74 ? sorted
75 : [...sorted, { key: 'context', label: 'Context', percent: usage.contextPercent, reset: '' }]
76}
77
78export function levelColor(percent: number): string {
79 if (percent >= DANGER_PERCENT) {
80 return DANGER_COLOR
81 }
82
83 return percent >= WARN_PERCENT ? WARN_COLOR : NORMAL_COLOR
84}
85
86export const formatCost = (usd: number): string => `$${usd.toFixed(2)}`
87hooks/state.ts 24 lines1import type { ViewState } from '../types'
2import { ALL_BRANCHES } from './constants'
3import { emptyLaneState } from './layout/lanes'
4
5export const initialView: ViewState = {
6 status: 'idle',
7 errorMessage: '',
8 commits: [],
9 rows: [],
10 laneState: emptyLaneState(),
11 hasMore: false,
12 branches: [],
13 remoteBranches: [],
14 branch: ALL_BRANCHES,
15 isRemoteShown: true,
16 expandedHash: null,
17 detail: null,
18 detailError: '',
19 fileDiff: null,
20 generation: 0,
21 windowStart: 0,
22 windowStack: [],
23}
24hooks/ui/actions.ts 65 lines1import type { EngineInterface } from 'claude-code'
2
3import { PANE_ID } from '../constants'
4import {
5 currentGeneration,
6 loadMore,
7 recordFailure,
8 refresh,
9 selectBranch,
10 showNewer,
11 showOlder,
12 toggleCommit,
13 toggleFileDiff,
14 toggleRemote,
15} from '../controller'
16import { describeError } from '../errors'
17
18export type Actions = {
19 selectBranch(value: string): void
20 toggleRemote(): void
21 refresh(): void
22 loadMore(): void
23 showOlder(): void
24 showNewer(): void
25 toggleCommit(hash: string): void
26 toggleFileDiff(path: string): void
27 scrollToCommit(hash: string): void
28}
29
30export function makeActions($: EngineInterface): Actions {
31 // A throw is shown as a toast and recorded in the view, unless a newer load has taken over meanwhile.
32 const guard = (task: () => Promise<unknown>): void => {
33 const run = async (): Promise<void> => {
34 const generation = await currentGeneration($)
35
36 try {
37 await task()
38 } catch (error) {
39 $.ui.toast(`git-graph: ${describeError(error)}`)
40 await recordFailure($, error, generation)
41 }
42 }
43
44 run().catch(error => {
45 $.ui.toast(`git-graph: ${describeError(error)}`)
46 })
47 }
48
49 return {
50 selectBranch: value => guard(() => selectBranch($, value)),
51 toggleRemote: () => guard(() => toggleRemote($)),
52 refresh: () => guard(() => refresh($)),
53 loadMore: () => guard(() => loadMore($)),
54 showOlder: () => guard(() => showOlder($)),
55 showNewer: () => guard(() => showNewer($)),
56 toggleCommit: hash => guard(() => toggleCommit($, hash)),
57 toggleFileDiff: path => guard(() => toggleFileDiff($, path)),
58 scrollToCommit: hash => {
59 $.ui.scroll({ in: PANE_ID, to: { key: `row-${hash}` } }).catch(error => {
60 $.ui.toast(`git-graph: ${describeError(error)}`)
61 })
62 },
63 }
64}
65hooks/ui/band.tsx 114 lines1import type { ElementTable } from 'claude-code'
2
3import type { UsageSnapshot } from '../../types'
4import { ellipsize } from '../format'
5import { branchIconSvg } from '../layout/branch-icon-svg'
6import { usageRingSvg } from '../layout/usage-ring-svg'
7import { formatCost, levelColor, usageItems } from '../usage'
8import type { UsageItem } from '../usage'
9
10type DesktopUi = ElementTable<'desktop'>
11
12const BRANCH_LABEL_MAX = 40
13const RING_SIZE = 16
14const BRANCH_ICON_SIZE = 14
15const BRANCH_ICON_COLOR = '#6e7781'
16
17// Every usage cell is this wide and its parts have fixed widths, so the cells of one row line up with the cells of
18// the next when the band wraps. Widths are in cells.
19const CELL_WIDTH = 27
20const LABEL_WIDTH = 7
21const PERCENT_WIDTH = 4
22const COST_WIDTH = 10
23// Where the ring sits in a cell; the cost cell, which has no ring, leaves the same space.
24const RING_SLOT_WIDTH = 2
25
26// The branch the session is on: the git-branch glyph and the name, plain and dimmed, with no outline or fill, so it
27// does not read as a button. The glyph is an SVG in a mid grey; the name is a Text and follows the theme.
28function renderBranch(ui: DesktopUi, branch: string) {
29 const { Box, Svg, Text } = ui
30 const icon = branchIconSvg(BRANCH_ICON_SIZE, BRANCH_ICON_COLOR)
31
32 return (
33 <Box key="current-branch" flexDirection="row" alignItems="center" gap={1}>
34 <Svg source={icon.source} width={icon.width} height={icon.height} alt="Branch" />
35 <Text dimColor>{ellipsize(branch, BRANCH_LABEL_MAX)}</Text>
36 </Box>
37 )
38}
39
40function renderMeter(ui: DesktopUi, item: UsageItem) {
41 const { Box, Svg, Text } = ui
42 const ring = usageRingSvg(item.percent, RING_SIZE, levelColor(item.percent))
43 const percent = `${Math.round(item.percent)}%`
44
45 return (
46 <Box key={item.key} width={CELL_WIDTH} flexDirection="row" alignItems="center" gap={1}>
47 <Box width={RING_SLOT_WIDTH} flexShrink={0}>
48 <Svg source={ring.source} width={ring.width} height={ring.height} alt={`${item.label} ${percent}`} />
49 </Box>
50 <Box width={LABEL_WIDTH} flexShrink={0}>
51 <Text dimColor>{item.label}</Text>
52 </Box>
53 <Box width={PERCENT_WIDTH} flexShrink={0}>
54 <Text>{percent}</Text>
55 </Box>
56 {item.reset === '' ? null : <Text dimColor wrap="truncate">{`↻ ${item.reset}`}</Text>}
57 </Box>
58 )
59}
60
61function renderCost(ui: DesktopUi, costUsd: number) {
62 const { Box, Text } = ui
63
64 return (
65 <Box key="cost" width={CELL_WIDTH} flexDirection="row" alignItems="center" gap={1}>
66 <Box width={RING_SLOT_WIDTH} flexShrink={0} />
67 <Box width={LABEL_WIDTH} flexShrink={0}>
68 <Text dimColor>Cost</Text>
69 </Box>
70 <Box width={COST_WIDTH} flexShrink={0}>
71 <Text>{formatCost(costUsd)}</Text>
72 </Box>
73 </Box>
74 )
75}
76
77function renderUsage(ui: DesktopUi, usage: UsageSnapshot | undefined, nowMs: number) {
78 const { Box } = ui
79
80 if (usage === undefined) {
81 return null
82 }
83
84 const items = usageItems(usage, nowMs)
85
86 if (items.length === 0 && usage.costUsd === undefined) {
87 return null
88 }
89
90 return (
91 <Box key="usage" flexDirection="row" flexWrap="wrap" alignItems="center" rowGap={0}>
92 {items.map(item => renderMeter(ui, item))}
93 {usage.costUsd === undefined ? null : renderCost(ui, usage.costUsd)}
94 </Box>
95 )
96}
97
98// The button that opens the pane and the branch the session is on (nothing outside a repository) on the left;
99// on the right the account's usage windows as rings, the context window and what the session has cost, in cells that
100// line up when the band wraps.
101export function renderBand(ui: DesktopUi, branch: string, usage: UsageSnapshot | undefined, nowMs: number, onOpen: () => void) {
102 const { Box, Button } = ui
103
104 return (
105 <Box flexDirection="row" flexWrap="wrap" alignItems="center" justifyContent="space-between" columnGap={2} rowGap={0}>
106 <Box key="band-left" flexDirection="row" alignItems="center" gap={2}>
107 <Button key="open-graph" label="Wukong Git Graph" onPress={onOpen} />
108 {branch === '' ? null : renderBranch(ui, branch)}
109 </Box>
110 {renderUsage(ui, usage, nowMs)}
111 </Box>
112 )
113}
114hooks/ui/pane.tsx 120 lines1import type { ElementTable } from 'claude-code'
2
3import type { ViewState } from '../../types'
4import { AUTHOR_WIDTH, DATE_WIDTH, HASH_WIDTH } from '../constants'
5import { graphCells } from '../layout/graph-width'
6import { columnCount } from '../layout/lanes'
7import { windowEnd } from '../layout/window'
8import { planColumns } from './columns'
9import type { ColumnPlan } from './columns'
10import type { Actions } from './actions'
11import { renderDetail } from './commit-detail'
12import { renderRow } from './commit-row'
13import { renderToolbar } from './toolbar'
14
15type DesktopUi = ElementTable<'desktop'>
16
17function renderHeader(ui: DesktopUi, columns: number, plan: ColumnPlan) {
18 const { Box, Text } = ui
19 return (
20 <Box flexDirection="row">
21 <Box width={graphCells(columns)}>
22 <Text bold>Graph</Text>
23 </Box>
24 <Box key="description-header" flexGrow={1} flexShrink={1} width={0} minWidth={0}>
25 <Text bold>Description</Text>
26 </Box>
27 {plan.isDateShown ? (
28 <Box width={DATE_WIDTH}>
29 <Text bold>Date</Text>
30 </Box>
31 ) : null}
32 {plan.isAuthorShown ? (
33 <Box width={AUTHOR_WIDTH}>
34 <Text bold>Author</Text>
35 </Box>
36 ) : null}
37 {plan.isHashShown ? (
38 <Box width={HASH_WIDTH}>
39 <Text bold>Commit</Text>
40 </Box>
41 ) : null}
42 </Box>
43 )
44}
45
46const describeFailure = (view: ViewState): string =>
47 view.status === 'error' ? `Could not read the Git history: ${view.errorMessage}` : view.errorMessage
48
49function renderList(ui: DesktopUi, view: ViewState, actions: Actions, bodyColumns: number) {
50 const { Box, Button, Text } = ui
51 const columns = columnCount(view.rows)
52 const plan = planColumns(bodyColumns, graphCells(columns))
53 const end = windowEnd(view, view.windowStart)
54 const shown = view.commits.slice(view.windowStart, end)
55 const drawn = new Set(shown.map(commit => commit.hash))
56
57 const items = shown.flatMap((commit, index) => {
58 const row = view.rows[view.windowStart + index]
59
60 if (row === undefined) {
61 return []
62 }
63
64 const parts = [renderRow(ui, commit, row, columns, plan, view.isRemoteShown, actions)]
65
66 if (view.expandedHash === commit.hash) {
67 parts.push(renderDetail(ui, view, drawn, actions, bodyColumns))
68 }
69
70 return parts
71 })
72
73 const hasNewer = view.windowStart > 0
74 const hasOlder = end < view.commits.length || view.hasMore
75
76 return (
77 <Box flexDirection="column">
78 {renderHeader(ui, columns, plan)}
79 {hasNewer ? <Button key="newer" label="↑ Newer commits" onPress={() => actions.showNewer()} /> : null}
80 {items}
81 {view.errorMessage === '' ? null : <Text color="error">{describeFailure(view)}</Text>}
82 {hasOlder ? <Button key="more" label="Load more" onPress={() => actions.showOlder()} /> : null}
83 </Box>
84 )
85}
86
87function renderBody(ui: DesktopUi, view: ViewState, actions: Actions, bodyColumns: number) {
88 const { Text } = ui
89 const isLoading = view.status === 'idle' || (view.status === 'loading' && view.commits.length === 0)
90
91 if (isLoading) {
92 return <Text dimColor>Loading…</Text>
93 }
94
95 if (view.status === 'no-repo') {
96 return <Text>This directory is not a Git repository.</Text>
97 }
98
99 if (view.status === 'error' && view.commits.length === 0) {
100 return <Text color="error">{describeFailure(view)}</Text>
101 }
102
103 if (view.commits.length === 0) {
104 return <Text dimColor>This repository has no commits yet.</Text>
105 }
106
107 return renderList(ui, view, actions, bodyColumns)
108}
109
110export function renderPane(ui: DesktopUi, view: ViewState, actions: Actions, bodyColumns: number) {
111 const { Box } = ui
112
113 return (
114 <Box flexDirection="column" gap={1}>
115 {renderToolbar(ui, view, actions)}
116 {renderBody(ui, view, actions, bodyColumns)}
117 </Box>
118 )
119}
120hooks/branches.ts 36 lines1import type { ViewState } from '../types'
2import { ALL_BRANCHES, MAX_SELECT_OPTIONS } from './constants'
3
4type BranchView = Pick<ViewState, 'branches' | 'remoteBranches' | 'isRemoteShown'>
5
6export function visibleBranches(view: BranchView): string[] {
7 if (view.isRemoteShown) {
8 return [...view.branches]
9 }
10
11 const remotes = new Set(view.remoteBranches)
12
13 return view.branches.filter(name => !remotes.has(name))
14}
15
16export type BranchOption = { value: string; label: string }
17
18// What the branch Select lists: Show All, then the branches, locals first. A Select takes at most
19// MAX_SELECT_OPTIONS, so a longer list keeps the selected branch and ends in a line that says how many are left
20// out; its empty value is not a branch, so choosing it changes nothing.
21export function branchOptions(view: BranchView & Pick<ViewState, 'branch'>): BranchOption[] {
22 const names = visibleBranches(view)
23 const all = { value: ALL_BRANCHES, label: 'Show All' }
24
25 if (names.length + 1 <= MAX_SELECT_OPTIONS) {
26 return [all, ...names.map(name => ({ value: name, label: name }))]
27 }
28
29 const room = MAX_SELECT_OPTIONS - 2
30 const head = names.slice(0, room)
31 const shown = head.includes(view.branch) || !names.includes(view.branch) ? head : [...head.slice(0, -1), view.branch]
32 const more = { value: '', label: `… ${names.length - shown.length} more (turn off remote branches)` }
33
34 return [all, ...shown.map(name => ({ value: name, label: name })), more]
35}
36