SLOPSHOPPER

wukong-git-graph

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

newpanebandcommandtoastprocess
v0.3.0no licenseupdated 2026-10-21wukongnotnull/wukong-git-graph/claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wukong-git-graph
│ ┃ Wukong Git Graph ✕ › fix the failing auth test and add an audit log call │ ┃ Wukong Git Graph works only in the desktop │ ┃ app. ⏺ 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 │ │ › /git-graph │ ⎿ wukong-git-graph: Wukong Git Graph opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Wukong Git Graph
Wukong Git Graph works only in the desktop app.
README

Wukong Git Graph

在 Codex 或 Claude Code 中查看当前项目的 Git 提交和分支。图谱只读,不会更改代码或 Git 历史,也不会上传项目内容。

让 AI 助手帮你安装

复制下面的提示词,发给 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 历史,也不要改用网页方式。如果当前工具不支持、缺少依赖或面板未能打开,请说明原因,不要声称安装成功。

打开图谱

  • Codex:选择 More tools → Plugins and MCPs → Wukong Git Graph,或输入 /wukong-git-graph 并选择 Wukong Git Graph。菜单入口会优先使用项目 root;宿主未提供 roots 时,通过调用携带的聊天 ID 自动读取当前项目目录(需要 codex 命令可用)。仍无法确定项目时,会显示 Repository path 输入框;填入本地仓库绝对路径,点击 Open repository。路径无效时可在面板内修改并重试。
  • Claude Code:输入 /git-graph,或点击 Wukong Git Graph 按钮。

面板显示当前项目的提交、分支和文件改动。

手动安装

Codex

需要 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 Code

在电脑终端中运行:

claude plugin marketplace add wukongnotnull/wukong-git-graph
claude plugin install wukong-git-graph@wukong-git-graph

在 Claude Code 插件管理中启用插件,然后新开一个本地项目会话。更新插件时运行:

claude plugin update wukong-git-graph
Source 33 files
hooks/register.tsx 147 lines
1import 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}
147
hooks/constants.ts 59 lines
1export 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]
59
hooks/controller.ts 426 lines
1import { 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}
426
hooks/errors.ts 5 lines
1// 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}
5
hooks/git/current-branch.ts 9 lines
1export 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}
9
hooks/git/run-git.ts 59 lines
1import 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}
59
hooks/usage.ts 87 lines
1import 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)}`
87
hooks/state.ts 24 lines
1import 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}
24
hooks/ui/actions.ts 65 lines
1import 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}
65
hooks/ui/band.tsx 114 lines
1import 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}
114
hooks/ui/pane.tsx 120 lines
1import 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}
120
hooks/branches.ts 36 lines
1import 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