Shows the Graphite stack of your current branch above the Claude Code prompt: stack tree, PR state, review status, open conversations, failing CI and labels.

A Claude Code plugin that shows the Graphite stack of the branch Claude is working on, right above the prompt in the Claude Code desktop app.
<img width="935" alt="The graphite-stack band above the Claude Code prompt, showing a stack of six PRs with their state, review status, failing CI and labels" src="https://github.com/user-attachments/assets/b3b49b33-2cc8-47bf-8504-5210b60d0cdb" />
One row per branch, drawn as the same tree gt log draws, from the branch above trunk up to every descendant, side branches included.
| Dot | PR state: ● grey draft, ● green open, ● purple merged, ● red closed, ◯ not submitted |
| Red ✕ | CI is failing. Hover it for the failing checks, each linked to its run |
| Review icon | ✓ approved, 💬 changes requested, 👤+ no reviewers yet, 🕐 waiting for review |
| Small number | unresolved review conversations |
| ↻ restack | the branch is no longer based on its parent's head, and its line to the parent is dotted; click it to have Claude restack it |
| Badges | the PR's GitHub labels, in their colors; hover one for its description; when they don't fit, +N shows the rest on hover |
| +12 −3 | lines added and removed against the parent branch, ignoring whitespace; hover and click View diff to see the diff in a side pane |
The title links to the PR. GitHub data refreshes every minute, every 30 seconds while CI is running, and every 5 minutes after 10 quiet minutes. It also refreshes right after Claude runs git push, gt submit, gt sync or gh pr …. Other git/gt commands only re-read the local stack. Sessions on the same repo share results, and polling pauses while no app window shows the session. When any PR fails CI, the status line says so.
In Claude Code, run:
/plugin install graphite-stack --marketplace adrianjost/claude-graphite-stack-status
Answer y to add the marketplace, then pick a scope.
gt tracking your branches (the plugin reads Graphite's local metadata, .git/.graphite_metadata.db)gh, signed in to the repository's GitHub hostsqlite3 and git on your PATHThe band appears on its own whenever the session's branch belongs to a Graphite stack.
/stack shows or hides itR to refresh or H to hideHover a branch's line counts and click View diff to open its changes in a side pane:
Sync with main in the band's header runs gt sync and gt restack right away, so the whole stack is based on the latest origin/main. A branch's ↻ restack button does the same for that branch's stack. The header shows progress while they run, and neither pushes anything. If a rebase hits a conflict, the working tree has uncommitted changes or a command fails, the job is handed to Claude to finish.
Pick where clicking a PR title opens it with the Links picker in the band's header: GitHub (default) or Graphite. The choice is remembered across sessions.
When something keeps the band from working, it shows a ⚠ line with the reason, for example a missing gh or sqlite3, a gt version too old to read, gh not signed in, or GitHub's rate limit. Click its button to have Claude fix the problem, or to look into it and suggest a fix when the cause isn't clear.
MIT
hooks/register.tsx 1358 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register, Timer } from 'claude-code'
3
4import type { CiState, DiffFile, DiffView, Issue, Lane, LinkTarget, PrInfo, StackRow, StackSnapshot } from '../types'
5
6const POLL_MS = 60_000
7const POLL_BUSY_MS = 30_000
8const POLL_QUIET_MS = 5 * 60_000
9const QUIET_AFTER_MS = 10 * 60_000
10const MIN_GAP_MS = 15_000
11const LOCAL_DELAY_MS = 1_500
12const SHARED_CACHE_MS = 55_000
13const GITHUB_COMMAND = /\b(git\s+push|gt\s+(submit|ss|sync|merge|get|land|repo\s+sync)|gh\s+pr)\b/
14const EMPTY: StackSnapshot = { branch: '', rows: [], columns: 1, updatedAt: 0, isLoading: false }
15const snapshot = atom({ plugin: 'graphite-stack', key: 'snapshot' } as const, EMPTY)
16const isHidden = atom({ plugin: 'graphite-stack', key: 'isHidden' } as const, false)
17const DIFF_PANE = 'graphite-stack-diff'
18const DIFF_MAX_CHARS = 90_000
19const DIFF_DRAW_BUDGET = 95_000
20const diffView = atom({ plugin: 'graphite-stack', key: 'diffView' } as const, null as DiffView | null)
21const linkTarget = atom({ plugin: 'graphite-stack', key: 'linkTarget' } as const, 'github' as LinkTarget)
22const working = atom({ plugin: 'graphite-stack', key: 'working' } as const, null as string | null)
23const STACK_ACTION_TIMEOUT_MS = 3 * 60_000
24
25type MetaRow = { branch_name: string; parent_branch_name: string | null; children: string | null; validation_result: string | null }
26
27type GhCheck = {
28 __typename: string
29 name?: string
30 context?: string
31 status?: string
32 conclusion?: string
33 state?: string
34 detailsUrl?: string
35 targetUrl?: string
36 workflowName?: string
37 startedAt?: string
38}
39
40type GqlCheck = {
41 __typename: string
42 name?: string
43 status?: string
44 conclusion?: string
45 detailsUrl?: string
46 startedAt?: string
47 checkSuite?: { workflowRun?: { workflow?: { name: string } } | null } | null
48 context?: string
49 state?: string
50 targetUrl?: string
51}
52
53type GqlPr = {
54 number: number
55 title: string
56 url: string
57 state: string
58 isDraft: boolean
59 reviewDecision: string | null
60 labels: { nodes: { name: string; color: string; description: string | null }[] }
61 reviewRequests: { totalCount: number }
62 latestReviews: { totalCount: number }
63 reviewThreads: { nodes: { isResolved: boolean }[] }
64 commits: { nodes: { commit: { statusCheckRollup: { contexts: { nodes: GqlCheck[] } } | null } }[] }
65}
66
67const PR_FIELDS =
68 'number title url state isDraft reviewDecision labels(first:20){nodes{name color description}} ' +
69 'reviewRequests{totalCount} latestReviews{totalCount} reviewThreads(first:100){nodes{isResolved}} ' +
70 'commits(last:1){nodes{commit{statusCheckRollup{contexts(first:100){nodes{__typename ' +
71 '... on CheckRun{name status conclusion detailsUrl startedAt checkSuite{workflowRun{workflow{name}}}} ' +
72 '... on StatusContext{context state targetUrl}}}}}}}'
73
74let isRefreshing = false
75type Mode = 'local' | 'github' | 'force'
76const RANK: Record<Mode, number> = { local: 0, github: 1, force: 2 }
77const stronger = (a: Mode | undefined, b: Mode) => (a && RANK[a] >= RANK[b] ? a : b)
78let lastGithubAt = 0
79let lastChangeAt = Date.now()
80let scheduled: Timer | undefined
81let pendingMode: Mode | undefined
82let queuedMode: Mode | undefined
83let pollTimer: Timer | undefined
84const attachedClients = new Set<string>()
85let hasSeenAttach = false
86
87class Problem extends Error {
88 constructor(readonly issue: Issue) {
89 super(issue.message)
90 }
91}
92
93const TOOLS: Record<string, string> = {
94 git: 'git',
95 gh: 'The GitHub CLI (gh)',
96 gt: 'The Graphite CLI (gt)',
97 sqlite3: 'sqlite3',
98}
99
100async function exec($: Engine, cwd: string, argv: string[], timeoutMs = 30_000) {
101 try {
102 return await $.process.run(argv, { cwd, timeoutMs })
103 } catch (error) {
104 const tool = argv[0] ?? ''
105 throw new Problem({
106 message: `${TOOLS[tool] ?? tool} isn't installed or not on Claude Code's PATH`,
107 detail: String(error),
108 fix: {
109 label: 'Fix',
110 prompt:
111 `The graphite-stack plugin needs \`${tool}\`, but Claude Code can't start it. ` +
112 'Check whether it is installed and on the PATH Claude Code uses, and help me install or fix it.',
113 },
114 })
115 }
116}
117
118async function run($: Engine, cwd: string, argv: string[]) {
119 const result = await exec($, cwd, argv)
120 return result.exitCode === 0 ? result.stdout.trim() : undefined
121}
122
123async function gtVersion($: Engine, cwd: string) {
124 try {
125 return (await run($, cwd, ['gt', '--version'])) ?? 'unknown version'
126 } catch {
127 return 'not installed'
128 }
129}
130
131function detachedIssue(branch: string, trunk: string): Issue {
132 return {
133 message: `Graphite has lost track of ${branch}'s parent, so it's shown on ${trunk}`,
134 detail: 'Usually the parent branch was merged and deleted.',
135 fix: {
136 label: 'Track it',
137 prompt:
138 `Graphite has lost track of the parent of the branch \`${branch}\` (its metadata says BAD_PARENT_NAME, ` +
139 'usually because the parent was merged and deleted). Find the right parent, most likely trunk or the branch its PR targets, ' +
140 're-attach it with `gt track`, and restack if needed. Switch back to the branch I was on afterwards and don\'t push anything.',
141 },
142 }
143}
144
145function githubIssue(error: unknown, updatedAt: number): Issue {
146 const text = error instanceof Error ? error.message : String(error)
147 const since = updatedAt ? ` · showing data from ${new Date(updatedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : ''
148 if (error instanceof Problem) return error.issue
149 if (/rate limit/i.test(text)) {
150 return { message: `GitHub API rate limit reached${since}`, detail: 'It resets within the hour.', canFix: false }
151 }
152 if (/auth|log ?in|401|credentials|token/i.test(text)) {
153 return {
154 message: `gh isn't signed in to GitHub${since}`,
155 detail: text,
156 fix: {
157 label: 'Sign in',
158 prompt: 'The graphite-stack plugin can\'t load PR data because the GitHub CLI isn\'t signed in. Run `gh auth status` and help me sign in.',
159 },
160 }
161 }
162 if (/not a GitHub repository/.test(text)) {
163 return { message: "This repository's origin isn't on GitHub, so there's no PR data", canFix: false }
164 }
165 return { message: `Couldn't load PR data from GitHub${since}`, detail: text }
166}
167
168function checkState(check: GhCheck): CiState {
169 if (check.__typename === 'StatusContext') {
170 if (check.state === 'SUCCESS') return 'pass'
171 if (check.state === 'PENDING' || check.state === 'EXPECTED') return 'pending'
172 return 'fail'
173 }
174 if (check.status !== 'COMPLETED') return 'pending'
175 return ['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(check.conclusion ?? '') ? 'pass' : 'fail'
176}
177
178function latestChecks(checks: GhCheck[]) {
179 const latest = new Map<string, GhCheck>()
180 for (const check of checks) {
181 const key = `${check.workflowName ?? ''}/${check.name ?? check.context ?? ''}`
182 const seen = latest.get(key)
183 if (!seen || (check.startedAt ?? '') >= (seen.startedAt ?? '')) latest.set(key, check)
184 }
185 return [...latest.values()]
186}
187
188async function diffStat($: Engine, cwd: string, parent: string, branch: string) {
189 const out = await run($, cwd, ['git', 'diff', '-w', '--numstat', `${parent}...${branch}`])
190 if (out === undefined) return undefined
191 let added = 0
192 let removed = 0
193 for (const line of out.split('\n')) {
194 const [a, r] = line.split('\t')
195 added += Number(a) || 0
196 removed += Number(r) || 0
197 }
198 return added || removed ? { added, removed } : undefined
199}
200
201class GitHubError extends Error {}
202
203async function githubRepo($: Engine, cwd: string) {
204 const url = (await run($, cwd, ['git', 'remote', 'get-url', 'origin'])) ?? ''
205 const [, owner, name] = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) ?? []
206 return owner && name ? { owner, name } : undefined
207}
208
209function toPrInfo(pr: GqlPr): PrInfo {
210 const checks: GhCheck[] = (pr.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? []).map(check => ({
211 ...check,
212 workflowName: check.checkSuite?.workflowRun?.workflow?.name,
213 }))
214 const ciCounts = { pass: 0, fail: 0, pending: 0 }
215 const failing: PrInfo['failing'] = []
216 for (const check of latestChecks(checks)) {
217 const state = checkState(check)
218 ciCounts[state === 'none' ? 'pass' : state]++
219 if (state === 'fail') {
220 failing.push({ name: check.name ?? check.context ?? '?', workflow: check.workflowName, url: check.detailsUrl ?? check.targetUrl })
221 }
222 }
223 const ci: CiState =
224 ciCounts.fail > 0 ? 'fail' : ciCounts.pending > 0 ? 'pending' : ciCounts.pass > 0 ? 'pass' : 'none'
225
226 return {
227 number: pr.number,
228 title: pr.title,
229 url: pr.url,
230 state: pr.state,
231 isDraft: pr.isDraft,
232 reviewDecision: pr.reviewDecision ?? '',
233 labels: pr.labels.nodes.map(l => ({ name: l.name, color: l.color, description: l.description ?? undefined })),
234 ci,
235 ciCounts,
236 failing,
237 openThreads: pr.reviewThreads.nodes.filter(t => !t.isResolved).length,
238 hasReviewers: pr.reviewRequests.totalCount + pr.latestReviews.totalCount > 0,
239 }
240}
241
242type SharedCache = Record<string, { at: number; pr: PrInfo | null }>
243
244async function loadPrs($: Engine, cwd: string, branches: string[], force: boolean) {
245 const prs = new Map<string, PrInfo | undefined>()
246 if (!branches.length) return prs
247 const repo = await githubRepo($, cwd)
248 if (!repo) throw new GitHubError('origin is not a GitHub repository')
249 const cacheKey = `prs:${repo.owner}/${repo.name}`
250 const cache = ((await $.store.get(cacheKey)) ?? {}) as SharedCache
251 const now = Date.now()
252 if (!force && branches.every(branch => now - (cache[branch]?.at ?? 0) < SHARED_CACHE_MS)) {
253 for (const branch of branches) prs.set(branch, cache[branch]?.pr ?? undefined)
254 return prs
255 }
256 const fields = branches.map(
257 (branch, i) =>
258 `b${i}:pullRequests(headRefName:${JSON.stringify(branch)},first:1,orderBy:{field:CREATED_AT,direction:DESC}){nodes{${PR_FIELDS}}}`,
259 )
260 const query = `query{repository(owner:${JSON.stringify(repo.owner)},name:${JSON.stringify(repo.name)}){${fields.join(' ')}}}`
261 const result = await exec($, cwd, ['gh', 'api', 'graphql', '-f', `query=${query}`])
262 if (result.exitCode !== 0) throw new GitHubError(result.stderr.trim())
263 const data = JSON.parse(result.stdout) as { data?: { repository?: Record<string, { nodes: GqlPr[] } | null> } }
264 const found = data.data?.repository
265 if (!found) throw new GitHubError('unexpected GitHub response')
266 const fresh: SharedCache = {}
267 for (const [branch, entry] of Object.entries(cache)) if (now - entry.at < 60 * 60_000) fresh[branch] = entry
268 branches.forEach((branch, i) => {
269 const node = found[`b${i}`]?.nodes[0]
270 const pr = node ? toPrInfo(node) : undefined
271 prs.set(branch, pr)
272 fresh[branch] = { at: now, pr: pr ?? null }
273 })
274 await $.store.set(cacheKey, fresh)
275 return prs
276}
277
278const LANGUAGES: Record<string, string> = {
279 ts: 'typescript', tsx: 'tsx', mts: 'typescript', cts: 'typescript',
280 js: 'javascript', jsx: 'jsx', mjs: 'javascript', cjs: 'javascript',
281 json: 'json', md: 'markdown', css: 'css', scss: 'scss', html: 'html', svg: 'xml', xml: 'xml',
282 py: 'python', go: 'go', rs: 'rust', rb: 'ruby', java: 'java', kt: 'kotlin', swift: 'swift',
283 sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', yml: 'yaml', yaml: 'yaml', toml: 'toml',
284 graphql: 'graphql', gql: 'graphql', vue: 'vue', svelte: 'svelte', php: 'php', c: 'c', h: 'c', cpp: 'cpp',
285}
286
287const languageOf = (path: string) => LANGUAGES[path.split('.').pop()?.toLowerCase() ?? '']
288
289const GENERATED =
290 /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|go\.sum|poetry\.lock|composer\.lock|Gemfile\.lock)$|\.gen(\.[^/]*)?$|\.min\.(js|css)$|\.snap$|(^|\/)(generated|__generated__|insurance-tables)\//
291
292function parseDiff(out: string): DiffFile[] {
293 const files: DiffFile[] = []
294 for (const chunk of out.split(/^diff --git /m).slice(1)) {
295 const lines = chunk.split('\n')
296 const target = lines.find(line => line.startsWith('+++ '))?.slice(4)
297 const source = lines.find(line => line.startsWith('--- '))?.slice(4)
298 const path = (target && target !== '/dev/null' ? target : source ?? lines[0] ?? '?').replace(/^[ab]\//, '')
299 const start = lines.findIndex(line => line.startsWith('@@'))
300 const hunks = start < 0 ? '' : lines.slice(start).join('\n').trimEnd()
301 let added = 0
302 let removed = 0
303 for (const line of start < 0 ? [] : lines.slice(start)) {
304 if (line.startsWith('+')) added++
305 else if (line.startsWith('-')) removed++
306 }
307 const isNew = lines.some(line => line.startsWith('new file mode')) || source === '/dev/null'
308 const body = isNew
309 ? lines
310 .slice(start)
311 .filter(line => line.startsWith('+'))
312 .map(line => line.slice(1))
313 .join('\n')
314 : hunks
315 const isTooLarge = body.length > DIFF_MAX_CHARS
316 files.push({
317 path,
318 added,
319 removed,
320 hunks: isTooLarge ? '' : body,
321 isTooLarge,
322 isBinary: start < 0,
323 isNew,
324 isGenerated: GENERATED.test(path),
325 })
326 }
327 return files
328}
329
330type DiffTarget = Pick<DiffView, 'branch' | 'parent' | 'trunk' | 'compareTo' | 'showWhitespace' | 'title' | 'url'>
331
332async function loadDiff($: Engine, target: DiffTarget, keepCollapsed?: string[]) {
333 await update($, diffView, view => ({
334 ...target,
335 root: view?.root ?? '',
336 commits: view?.branch === target.branch ? view.commits : [],
337 files: view?.branch === target.branch ? view.files : [],
338 collapsed: view?.collapsed ?? [],
339 isLoading: true,
340 }))
341 const cwd = await $.session.cwd()
342 const base = target.compareTo === 'trunk' ? target.trunk : target.parent
343 const [out, log, head, root] = await Promise.all([
344 run($, cwd, ['git', 'diff', ...(target.showWhitespace ? [] : ['-w']), '--no-color', '--no-ext-diff', `${base}...${target.branch}`]),
345 run($, cwd, ['git', 'log', '--format=%h%x09%s', `${base}..${target.branch}`]),
346 run($, cwd, ['git', 'rev-parse', target.branch]),
347 run($, cwd, ['git', 'rev-parse', '--show-toplevel']),
348 ])
349 const files = out === undefined ? [] : parseDiff(out)
350 const paths = new Set(files.map(file => file.path))
351 await update($, diffView, () => ({
352 ...target,
353 root: root ?? '',
354 headSha: head,
355 commits: (log ?? '')
356 .split('\n')
357 .filter(Boolean)
358 .map(line => {
359 const [sha = '', ...subject] = line.split('\t')
360 return { sha, subject: subject.join('\t') }
361 }),
362 files,
363 collapsed: keepCollapsed
364 ? keepCollapsed.filter(path => paths.has(path))
365 : files.filter(file => file.isGenerated).map(file => file.path),
366 isLoading: false,
367 error: out === undefined ? `git diff ${base}...${target.branch} failed` : undefined,
368 }))
369}
370
371function stackOrder(rows: StackRow[]) {
372 return [...rows].reverse().filter(row => !row.isTrunk && row.parent)
373}
374
375async function openDiff($: Engine, row: StackRow, options?: Partial<Pick<DiffView, 'compareTo' | 'showWhitespace'>>) {
376 if (!row.parent) return
377 const [view, s] = await Promise.all([read($, diffView), read($, snapshot)])
378 const trunk = s.rows.find(r => r.isTrunk)?.branch ?? row.parent
379 const isSame = view?.branch === row.branch
380 void $.ui.open({ id: DIFF_PANE, title: row.pr ? `#${row.pr.number} changes` : 'Branch changes' })
381 await loadDiff(
382 $,
383 {
384 branch: row.branch,
385 parent: row.parent,
386 trunk,
387 compareTo: options?.compareTo ?? view?.compareTo ?? 'parent',
388 showWhitespace: options?.showWhitespace ?? view?.showWhitespace ?? false,
389 title: row.pr ? `#${row.pr.number} ${row.pr.title}` : row.branch,
390 url: row.pr?.url,
391 },
392 isSame && options ? view?.collapsed : undefined,
393 )
394}
395
396async function refreshDiff($: Engine) {
397 const view = await read($, diffView)
398 if (!view?.branch || view.isLoading) return
399 const cwd = await $.session.cwd()
400 const head = await run($, cwd, ['git', 'rev-parse', view.branch])
401 if (head && head !== view.headSha) await loadDiff($, view, view.collapsed)
402}
403
404async function readMetadata($: Engine, cwd: string, gitDir: string) {
405 const db = `${gitDir}/.graphite_metadata.db`
406 if ((await run($, cwd, ['test', '-e', db])) === undefined) {
407 const legacy = await run($, cwd, ['git', 'for-each-ref', 'refs/branch-metadata', '--count=1', '--format=%(refname)'])
408 if (!legacy) return undefined
409 throw new Problem({
410 message: 'This repo\'s stack data is in an older Graphite format. Update gt to read it',
411 detail: `gt ${await gtVersion($, cwd)}`,
412 fix: {
413 label: 'Update gt',
414 prompt:
415 'The graphite-stack plugin can\'t read this repo\'s Graphite stack: the stack metadata is stored in git refs ' +
416 '(refs/branch-metadata), the format of older gt versions, instead of .git/.graphite_metadata.db. ' +
417 'Check which gt version I have and how it was installed, then help me update the Graphite CLI and migrate the repo.',
418 },
419 })
420 }
421 const result = await exec($, cwd, ['sqlite3', '-json', db, 'select branch_name, parent_branch_name, children, validation_result from branch_metadata'])
422 if (result.exitCode !== 0) {
423 const version = await gtVersion($, cwd)
424 const isSchema = /no such (table|column)/i.test(result.stderr)
425 throw new Problem({
426 message: isSchema
427 ? `Graphite changed its stack data format (gt ${version}). The plugin may need an update`
428 : "Couldn't read Graphite's stack data",
429 detail: result.stderr.trim(),
430 })
431 }
432 return JSON.parse(result.stdout.trim() || '[]') as MetaRow[]
433}
434
435async function buildRows(
436 $: Engine,
437 cwd: string,
438 branch: string,
439): Promise<{ rows: StackRow[]; columns: number; detached?: string } | string> {
440 const gitDir = await run($, cwd, ['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'])
441 if (!gitDir) return 'Not a git repository.'
442 const metadata = await readMetadata($, cwd, gitDir)
443 if (!metadata) return 'No Graphite metadata in this repository.'
444 const meta = new Map<string, MetaRow>()
445 for (const row of metadata) meta.set(row.branch_name, row)
446
447 const trunk = metadata.find(row => row.validation_result === 'TRUNK')?.branch_name ?? 'main'
448 const current = meta.get(branch)
449 if (!current || branch === trunk) return `${branch} is not part of a Graphite stack.`
450
451 const parentOf = (name: string) => {
452 const parent = meta.get(name)?.parent_branch_name
453 return parent && meta.has(parent) ? parent : undefined
454 }
455 let root = branch
456 const seen = new Set([root])
457 for (let parent = parentOf(root); parent && parent !== trunk && !seen.has(parent); parent = parentOf(parent)) {
458 seen.add(parent)
459 root = parent
460 }
461 const detached = parentOf(root) ? undefined : root
462
463 const childrenOf = (name: string) =>
464 (JSON.parse(meta.get(name)?.children ?? '[]') as string[]).filter(child => meta.has(child))
465 const rows: StackRow[] = []
466 let columns = 1
467 const layout = (name: string, col: number, open: number[], openOwners: string[], parent?: string) => {
468 const children = childrenOf(name)
469 children.forEach((child, i) => {
470 layout(
471 child,
472 col + i,
473 [...open, ...children.slice(0, i).map((_, k) => col + k)],
474 [...openOwners, ...children.slice(0, i)],
475 name,
476 )
477 })
478 const corners = children.slice(1).map((_, i) => col + i + 1)
479 columns = Math.max(columns, col + 1, ...corners.map(c => c + 1))
480 rows.push({
481 branch: name,
482 parent,
483 lane: {
484 col,
485 up: children.length > 0,
486 down: true,
487 pipes: open,
488 corners,
489 pipeOwners: openOwners,
490 cornerOwners: children.slice(1),
491 upOwner: children[0],
492 },
493 isCurrent: name === branch,
494 isTrunk: false,
495 needsRestack: false,
496 })
497 }
498 layout(root, 0, [], [], trunk)
499 rows.push({
500 branch: trunk,
501 lane: { col: 0, up: true, down: false, pipes: [], corners: [], upOwner: root },
502 isCurrent: false,
503 isTrunk: true,
504 needsRestack: false,
505 })
506
507 return { rows, columns, detached }
508}
509
510function requestRefresh($: Engine, mode: Mode) {
511 pendingMode = stronger(pendingMode, mode)
512 if (scheduled) return
513 const wait = pendingMode === 'local' ? LOCAL_DELAY_MS : Math.max(0, lastGithubAt + MIN_GAP_MS - Date.now())
514 scheduled = $.clock.after(Math.max(1, wait), () => {
515 scheduled = undefined
516 const run = pendingMode ?? 'local'
517 pendingMode = undefined
518 void refresh($, run)
519 })
520}
521
522const isPaused = () => hasSeenAttach && attachedClients.size === 0
523
524async function pollDelay($: Engine) {
525 const rows = (await read($, snapshot)).rows
526 if (rows.some(row => row.pr?.state === 'OPEN' && row.pr.ciCounts.pending > 0)) return POLL_BUSY_MS
527 return Date.now() - lastChangeAt > QUIET_AFTER_MS ? POLL_QUIET_MS : POLL_MS
528}
529
530async function schedulePoll($: Engine) {
531 pollTimer?.cancel()
532 pollTimer = $.clock.after(await pollDelay($), () => {
533 void (async () => {
534 if (!isPaused() && !(await read($, isHidden))) requestRefresh($, 'github')
535 await schedulePoll($)
536 })()
537 })
538}
539
540async function stackAction($: Engine, branch?: string) {
541 if (await read($, working)) return
542 const label = branch ? `Restacking ${branch}…` : 'Syncing with main…'
543 await update($, working, () => label)
544 const handOff = (reason: string) =>
545 void $.prompt.submit({
546 text:
547 `${reason} Bring my Graphite stack up to date with origin/main: run \`gt sync\` (accept updating trunk and ` +
548 'deleting merged branches), then `gt restack`, resolving any conflicts (`gt continue` after each). ' +
549 "Switch back to the branch I was on afterwards, don't push anything, and tell me what changed.",
550 asUser: true,
551 })
552 try {
553 const cwd = await $.session.cwd()
554 const original = await run($, cwd, ['git', 'branch', '--show-current'])
555 const dirty = await run($, cwd, ['git', 'status', '--porcelain', '--untracked-files=no'])
556 if (dirty) return handOff('The working tree has uncommitted changes, so the graphite-stack plugin did not sync on its own.')
557
558 const steps = [
559 ['gt', 'sync', '--force', '--no-interactive'],
560 ['gt', 'restack', '--no-interactive', ...(branch ? ['--branch', branch] : [])],
561 ]
562 for (const argv of steps) {
563 const result = await exec($, cwd, argv, STACK_ACTION_TIMEOUT_MS)
564 if (result.exitCode === 0) continue
565 const output = `${result.stdout}\n${result.stderr}`.trim().split('\n').slice(-15).join('\n')
566 const rebasing = (await run($, cwd, ['git', 'status']))?.includes('rebase in progress') ||
567 /conflict/i.test(output)
568 return void $.prompt.submit({
569 text:
570 `\`${argv.join(' ')}\` ${rebasing ? 'stopped on a conflict' : 'failed'}:\n\n\`\`\`\n${output}\n\`\`\`\n\n` +
571 (rebasing
572 ? 'Resolve the conflicts and run `gt continue` until the restack finishes. '
573 : 'Find out what went wrong and finish syncing and restacking my Graphite stack. ') +
574 `Switch back to ${original ? `\`${original}\`` : 'the branch I was on'} afterwards and don't push anything.`,
575 asUser: true,
576 })
577 }
578 if (original && (await run($, cwd, ['git', 'branch', '--show-current'])) !== original) {
579 await run($, cwd, ['git', 'checkout', original])
580 }
581 $.ui.toast(branch ? `Restacked ${branch}` : 'Stack synced with main')
582 } catch (error) {
583 if (error instanceof Problem) await update($, snapshot, s => ({ ...s, issue: error.issue }))
584 else handOff(`The graphite-stack plugin failed to sync (${String(error)}).`)
585 } finally {
586 await update($, working, () => null)
587 void refresh($, 'local')
588 }
589}
590
591async function refresh($: Engine, mode: Mode) {
592 if (isRefreshing) {
593 queuedMode = stronger(queuedMode, mode)
594 return
595 }
596 if (mode === 'github' && Date.now() - lastGithubAt < MIN_GAP_MS) {
597 requestRefresh($, 'github')
598 mode = 'local'
599 }
600 isRefreshing = true
601 if (mode !== 'local') lastGithubAt = Date.now()
602 try {
603 const cwd = await $.session.cwd()
604 const branch = (await run($, cwd, ['git', 'branch', '--show-current'])) ?? ''
605 await update($, snapshot, s => ({ ...s, isLoading: true }))
606 const built = branch ? await buildRows($, cwd, branch) : 'Detached HEAD.'
607 if (typeof built === 'string') {
608 await update($, snapshot, () => ({ branch, rows: [], columns: 1, updatedAt: Date.now(), isLoading: false }))
609 return
610 }
611
612 const before = await read($, snapshot)
613 const previous = new Map(before.rows.map(row => [row.branch, row.pr]))
614 let ghIssue = before.githubIssue
615 const prs =
616 mode === 'local'
617 ? undefined
618 : await loadPrs(
619 $,
620 cwd,
621 built.rows.filter(row => !row.isTrunk).map(row => row.branch),
622 mode === 'force',
623 ).then(
624 result => {
625 ghIssue = undefined
626 return result
627 },
628 error => {
629 ghIssue = githubIssue(error, before.updatedAt)
630 return undefined
631 },
632 )
633 const isStale = mode === 'local' ? !!before.isStale : !prs
634 if (prs && [...prs].some(([branch, pr]) => JSON.stringify(pr) !== JSON.stringify(previous.get(branch)))) {
635 lastChangeAt = Date.now()
636 }
637 const rows = await Promise.all(
638 built.rows.map(async row => {
639 if (row.isTrunk) return row
640 const parent = row.parent
641 const [pr, isAncestor, diff] = await Promise.all([
642 prs ? prs.get(row.branch) : previous.get(row.branch),
643 parent
644 ? $.process
645 .run(['git', 'merge-base', '--is-ancestor', parent, row.branch], { cwd })
646 .then(r => r.exitCode === 0)
647 : Promise.resolve(true),
648 parent ? diffStat($, cwd, parent, row.branch) : Promise.resolve(undefined),
649 ])
650 return { ...row, pr, diff, needsRestack: !isAncestor }
651 }),
652 )
653
654 await update($, snapshot, s => ({
655 branch,
656 rows,
657 columns: built.columns,
658 updatedAt: prs ? Date.now() : s.updatedAt,
659 isStale,
660 githubIssue: ghIssue,
661 issue: ghIssue ?? (built.detached ? detachedIssue(built.detached, built.rows.find(row => row.isTrunk)?.branch ?? 'main') : undefined),
662 isLoading: false,
663 }))
664 void refreshDiff($)
665 const failing = rows.filter(r => r.pr?.ci === 'fail').length
666 $.ui.status(failing ? `stack: ${failing} PR${failing > 1 ? 's' : ''} failing CI` : undefined)
667 } catch (error) {
668 const issue: Issue =
669 error instanceof Problem ? error.issue : { message: 'The Graphite stack couldn\'t be refreshed', detail: String(error) }
670 await update($, snapshot, s => ({ ...s, rows: error instanceof Problem ? [] : s.rows, issue, isLoading: false }))
671 } finally {
672 isRefreshing = false
673 if (queuedMode) {
674 const next = queuedMode
675 queuedMode = undefined
676 void refresh($, next)
677 }
678 }
679}
680
681const DOT = {
682 draft: '#8b949e',
683 open: '#2da44e',
684 merged: '#8250df',
685 closed: '#cf222e',
686}
687const REVIEW = {
688 approved: '#2da44e',
689 waiting: '#8b949e',
690 feedback: '#8b949e',
691}
692const ICON = 16
693const CI_ICON = 14
694const LINE = '#8b949e'
695const COL = 14
696const ROW = 24
697const R = 4.5
698const GAP = 2
699const CURVE = 5
700const STROKE = 1.5
701
702function prDot(pr: PrInfo): string {
703 if (pr.state === 'MERGED') return DOT.merged
704 if (pr.state === 'CLOSED') return DOT.closed
705 return pr.isDraft ? DOT.draft : DOT.open
706}
707
708function reviewStatus(pr?: PrInfo): { alt: string; body: string } {
709 const icon = (body: string) => `<g fill="none" stroke="${REVIEW.waiting}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${body}</g>`
710 if (pr?.state !== 'OPEN') return { alt: '', body: '' }
711 if (pr.reviewDecision === 'APPROVED') {
712 return { alt: 'approved', body: icon(`<path d="M3.5 8.5 6.5 11.5 12.5 4.5" stroke="${REVIEW.approved}"/>`) }
713 }
714 if (pr.reviewDecision === 'CHANGES_REQUESTED') {
715 return {
716 alt: 'changes requested',
717 body: icon('<path d="M3.5 3h9a1.5 1.5 0 0 1 1.5 1.5v5.5a1.5 1.5 0 0 1-1.5 1.5H8l-3 2.5v-2.5H3.5A1.5 1.5 0 0 1 2 10V4.5A1.5 1.5 0 0 1 3.5 3z"/>'),
718 }
719 }
720 if (pr.isDraft) return { alt: 'draft', body: '' }
721 if (!pr.hasReviewers) {
722 return {
723 alt: 'no reviewers',
724 body: icon('<circle cx="6.5" cy="5.5" r="2.5"/><path d="M2 13.5c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4M13 6.5v4M11 8.5h4"/>'),
725 }
726 }
727 return { alt: 'waiting for review', body: icon('<circle cx="8" cy="8" r="6"/><path d="M8 4.8V8l2.2 1.4"/>') }
728}
729
730type MetaLayout = { hasCi: boolean; numberChars: number; threadDigits: number }
731
732const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace'
733const NUMBER_FONT = 11.5
734const NUMBER_CHAR = NUMBER_FONT * 0.6
735
736function metaLayout(prs: PrInfo[]): MetaLayout {
737 return {
738 hasCi: prs.some(pr => pr.ci === 'fail'),
739 numberChars: Math.max(0, ...prs.map(pr => `#${pr.number}`.length)),
740 threadDigits: Math.max(0, ...prs.map(pr => (pr.state === 'OPEN' && pr.openThreads ? `${pr.openThreads}`.length : 0))),
741 }
742}
743
744const REVIEW_TIPS: Record<string, string> = {
745 approved: 'Approved',
746 'changes requested': 'Changes requested',
747 'no reviewers': 'No reviewers requested yet',
748 'waiting for review': 'Waiting for review',
749}
750
751function metaSvg(layout: MetaLayout, pr?: PrInfo) {
752 const cy = ROW / 2
753 const numberX = layout.hasCi ? CI_ICON + 6 : 0
754 const leftWidth = numberX + Math.ceil(layout.numberChars * NUMBER_CHAR) + 7
755 const reviewWidth = ICON + (layout.threadDigits ? 3 + Math.ceil(layout.threadDigits * 6.5) : 0)
756 const left: string[] = []
757 const right: string[] = []
758 const alt: string[] = []
759 const tip: string[] = []
760 if (pr) {
761 alt.push(`#${pr.number}`)
762 if (pr.ci === 'fail') {
763 alt.push('CI failing')
764 left.push(
765 `<g transform="translate(0 ${cy - CI_ICON / 2})"><circle cx="7" cy="7" r="6.2" fill="${DOT.closed}"/>` +
766 `<path d="M4.8 4.8l4.4 4.4M9.2 4.8l-4.4 4.4" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/></g>`,
767 )
768 }
769 left.push(
770 `<text x="${numberX}" y="${cy}" dy="0.35em" font-size="${NUMBER_FONT}" font-family="${MONO}" fill="${LINE}">#${pr.number}</text>`,
771 )
772 const review = reviewStatus(pr)
773 const label = REVIEW_TIPS[review.alt]
774 if (label) tip.push(label)
775 right.push(`<g transform="translate(0 ${cy - ICON / 2})">${review.body}</g>`)
776 const threads = pr.state === 'OPEN' ? pr.openThreads : 0
777 if (threads) {
778 tip.push(`${threads} unresolved conversation${threads === 1 ? '' : 's'}`)
779 right.push(
780 `<text x="${ICON + 2.5}" y="${cy}" dy="0.35em" font-size="10.5" font-weight="600" ` +
781 `font-family="system-ui, -apple-system, sans-serif" fill="${LINE}">${threads}</text>`,
782 )
783 }
784 }
785 const svg = (width: number, parts: string[]) =>
786 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${ROW}" viewBox="0 0 ${width} ${ROW}">${parts.join('')}</svg>`
787 return {
788 left: { width: leftWidth, alt: alt.join(', ') || 'no pull request', source: svg(leftWidth, left) },
789 review: { width: reviewWidth, alt: tip.join(', ') || 'no review status', tip: tip.join(' · '), source: svg(reviewWidth, right) },
790 }
791}
792
793
794const diffText = (diff: { added: number; removed: number }) => [`+${diff.added}`, `−${diff.removed}`] as const
795const DIFF_FONT = 11
796const DIFF_CHAR = DIFF_FONT * 0.6
797
798function diffSvg(diff: { added: number; removed: number }, chars: number) {
799 const width = Math.ceil(chars * DIFF_CHAR) + 2
800 const [added, removed] = diffText(diff)
801 return {
802 width,
803 alt: `${diff.added} lines added, ${diff.removed} removed`,
804 source:
805 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${ROW}" viewBox="0 0 ${width} ${ROW}">` +
806 `<text x="${width}" y="${ROW / 2}" dy="0.35em" text-anchor="end" font-size="${DIFF_FONT}" font-family="${MONO}">` +
807 `<tspan fill="${DOT.open}">${added}</tspan><tspan fill="${DOT.closed}" dx="${DIFF_CHAR}">${removed}</tspan></text></svg>`,
808 }
809}
810
811
812const esc = (text: string) =>
813 text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
814
815const DOT_STEP = 4
816const DOT_R = 1.25
817
818function dotsBetween(a: number, b: number, place: (at: number) => string) {
819 const out: string[] = []
820 for (let at = Math.ceil(a / DOT_STEP) * DOT_STEP; at <= b; at += DOT_STEP) out.push(place(at))
821 return out.join('')
822}
823
824function laneSvg(lane: Lane, columns: number, branch: string, color?: string, isBehind = (_: string) => false) {
825 const x = (c: number) => COL / 2 + c * COL
826 const cy = ROW / 2
827 const solid = `stroke="${LINE}" stroke-width="${STROKE}" stroke-opacity="0.7" fill="none" stroke-linecap="round"`
828 const dot = (dx: number, dy: number) => `<circle cx="${dx}" cy="${dy}" r="${DOT_R}" fill="${LINE}"/>`
829 const vertical = (vx: number, y1: number, y2: number, dotted: boolean) =>
830 dotted ? dotsBetween(y1, y2, y => dot(vx, y)) : `<line x1="${vx}" y1="${y1}" x2="${vx}" y2="${y2}" ${solid}/>`
831 const horizontal = (x1: number, x2: number, dotted: boolean) =>
832 dotted ? dotsBetween(x1, x2, hx => dot(hx, cy)) : `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" ${solid}/>`
833 const behind = (owner?: string) => !!owner && isBehind(owner)
834 const parts: string[] = []
835 lane.pipes.forEach((c, i) => parts.push(vertical(x(c), 0, ROW, behind(lane.pipeOwners?.[i]))))
836 if (lane.up) parts.push(vertical(x(lane.col), 0, cy - R - GAP, behind(lane.upOwner)))
837 if (lane.down) parts.push(vertical(x(lane.col), cy + R + GAP, ROW, behind(branch)))
838 let from = x(lane.col) + R + GAP
839 lane.corners.forEach((c, i) => {
840 const owners = (lane.cornerOwners ?? []).slice(i)
841 if (x(c) - CURVE > from) parts.push(horizontal(from, x(c) - CURVE, owners.length > 0 && owners.every(behind)))
842 from = x(c) - CURVE
843 if (behind(lane.cornerOwners?.[i])) {
844 const t = 0.5
845 const qx = (1 - t) ** 2 * x(c) + 2 * (1 - t) * t * x(c) + t ** 2 * (x(c) - CURVE)
846 const qy = (1 - t) ** 2 * (cy - CURVE) + 2 * (1 - t) * t * cy + t ** 2 * cy
847 parts.push(vertical(x(c), 0, cy - CURVE, true), dot(qx, qy))
848 } else {
849 parts.push(`<path d="M${x(c)} 0 V${cy - CURVE} Q${x(c)} ${cy} ${x(c) - CURVE} ${cy}" ${solid}/>`)
850 }
851 })
852 parts.push(
853 color
854 ? `<circle cx="${x(lane.col)}" cy="${cy}" r="${R}" fill="${color}"/>`
855 : `<circle cx="${x(lane.col)}" cy="${cy}" r="${R - STROKE / 2}" fill="none" stroke="${LINE}" stroke-width="${STROKE}"/>`,
856 )
857 const width = columns * COL
858 return {
859 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${ROW}" viewBox="0 0 ${width} ${ROW}">${parts.join('')}</svg>`,
860 width,
861 }
862}
863
864function pillSvg(label: { name: string; color: string }) {
865 const pill = pillParts(label.name, `#${label.color}`, labelInk(label.color), 0, 0)
866 return {
867 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${pill.width}" height="18" viewBox="0 0 ${pill.width} 18">${pill.body}</svg>`,
868 width: pill.width,
869 height: 18,
870 }
871}
872
873const LABELS_MAX = 160
874const PILL_GAP = 4
875
876function pillParts(name: string, fill: string, ink: string, x: number, y: number, fillOpacity = 1) {
877 const font = 11
878 const width = Math.ceil(name.length * font * 0.6 + 14)
879 const height = 18
880 return {
881 width,
882 body:
883 `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="${height / 2}" fill="${fill}" fill-opacity="${fillOpacity}"/>` +
884 `<text x="${x + width / 2}" y="${y + height / 2}" dy="0.35em" text-anchor="middle" font-size="${font}" ` +
885 `font-family="${MONO}" fill="${ink}">${esc(name)}</text>`,
886 }
887}
888
889function labelInk(color: string) {
890 const [r, g, b] = [0, 2, 4].map(i => parseInt(color.slice(i, i + 2), 16) || 0)
891 return 0.299 * (r ?? 0) + 0.587 * (g ?? 0) + 0.114 * (b ?? 0) > 150 ? '#1f2328' : '#ffffff'
892}
893
894function labelsSvg(labels: PrInfo['labels']) {
895 const widthOf = (name: string) => pillParts(name, '', '', 0, 0).width
896 const total = (names: string[]) => names.reduce((sum, name) => sum + widthOf(name), 0) + Math.max(0, names.length - 1) * PILL_GAP
897 let shown = labels
898 if (total(labels.map(l => l.name)) > LABELS_MAX) {
899 const more = `+${labels.length}`
900 shown = []
901 for (const label of labels) {
902 if (total([...shown, label].map(l => l.name).concat(more)) <= LABELS_MAX) shown = [...shown, label]
903 }
904 }
905 const hidden = labels.length - shown.length
906 const y = (ROW - 18) / 2
907 const pills = [
908 ...shown.map(label => ({ label, ...pillParts(label.name, `#${label.color}`, labelInk(label.color), 0, y) })),
909 ...(hidden ? [{ label: undefined, ...pillParts(`+${hidden}`, LINE, LINE, 0, y, 0.22) }] : []),
910 ]
911 return pills.map((pill, i) => {
912 const width = pill.width + (i < pills.length - 1 ? PILL_GAP : 0)
913 return {
914 label: pill.label,
915 width,
916 source: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${ROW}" viewBox="0 0 ${width} ${ROW}">${pill.body}</svg>`,
917 }
918 })
919}
920
921function graphiteUrl(pr: PrInfo) {
922 const [, owner, repo] = pr.url.match(/github\.com\/([^/]+)\/([^/]+)\//) ?? []
923 return owner && repo ? `https://app.graphite.com/github/pr/${owner}/${repo}/${pr.number}` : pr.url
924}
925
926const fixPrompt = (issue: Issue) =>
927 `The graphite-stack Claude Code plugin (github.com/adrianjost/claude-graphite-stack-status) shows this problem: ` +
928 `"${issue.message}"${issue.detail ? ` (details: ${issue.detail})` : ''}. ` +
929 'Investigate the cause on this machine, search the web if that helps, and suggest a fix.'
930
931export const register: Register = on => {
932
933 on('session.start', async ($, e, next) => {
934 const started = await next(e)
935 const stored = await $.store.get('linkTarget')
936 if (stored === 'github' || stored === 'graphite') await update($, linkTarget, () => stored)
937 await $.command.register({
938 name: 'stack',
939 description: 'Show or hide the Graphite stack above the prompt',
940 })
941 void refresh($, 'github').then(() => schedulePoll($))
942
943 return started
944 })
945
946 on('command.run', { command: 'stack' }, async $ => {
947 const hidden = await update($, isHidden, h => !h)
948 if (!hidden) void refresh($, 'force')
949
950 return { text: hidden ? 'Graphite stack hidden.' : 'Graphite stack shown.' }
951 })
952
953 on('session.attach', async ($, e, next) => {
954 if (e.surface !== 'terminal') {
955 const wasPaused = isPaused()
956 hasSeenAttach = true
957 attachedClients.add(e.clientId)
958 if (wasPaused) requestRefresh($, 'github')
959 }
960 return next(e)
961 })
962
963 on('session.detach', async ($, e, next) => {
964 if (e.surface !== 'terminal') hasSeenAttach = true
965 attachedClients.delete(e.clientId)
966 return next(e)
967 })
968
969 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
970 const ran = await next(e)
971 if (GITHUB_COMMAND.test(e.command)) requestRefresh($, 'force')
972 else if (/\b(gt|git)\s/.test(e.command)) requestRefresh($, 'local')
973
974 return ran
975 })
976
977 on('ui.render', { component: 'Pane', requestId: DIFF_PANE }, async ($, e) => {
978 const elements = $.ui.resolve(e)
979 if (!('Select' in elements)) return <elements.Text dimColor>The diff view needs the Claude Code desktop app.</elements.Text>
980 const { Box, Text, Link, Code, Button, Select } = elements
981 const view = await read($, diffView)
982 if (!view?.branch) return <Text dimColor>Hover a branch's line counts and click View diff to see its changes.</Text>
983 const order = stackOrder((await read($, snapshot)).rows)
984 const at = order.findIndex(row => row.branch === view.branch)
985 const previous = at > 0 ? order[at - 1] : undefined
986 const following = at >= 0 ? order[at + 1] : undefined
987 const added = view.files.reduce((sum, file) => sum + file.added, 0)
988 const removed = view.files.reduce((sum, file) => sum + file.removed, 0)
989 const collapsed = new Set(view.collapsed)
990 const setCollapsed = (paths: string[]) => void update($, diffView, v => (v ? { ...v, collapsed: paths } : v))
991 const toggle = (path: string) =>
992 setCollapsed(collapsed.has(path) ? view.collapsed.filter(p => p !== path) : [...view.collapsed, path])
993 const base = view.compareTo === 'trunk' ? view.trunk : view.parent
994 let budget = DIFF_DRAW_BUDGET
995
996 return (
997 <Box flexDirection="column" gap={1}>
998 <Box flexDirection="column">
999 <Box flexDirection="row" alignItems="center" gap={1}>
1000 {previous && (
1001 <Box flexShrink={0}>
1002 <Button key="diff-previous" plain label="◀" onPress={() => void openDiff($, previous)} />
1003 </Box>
1004 )}
1005 <Box flexGrow={1} flexShrink={1} minWidth={0}>
1006 <Text bold wrap="truncate-end">{view.title}</Text>
1007 </Box>
1008 {following && (
1009 <Box flexShrink={0}>
1010 <Button key="diff-next" plain label="▶" onPress={() => void openDiff($, following)} />
1011 </Box>
1012 )}
1013 </Box>
1014 <Text dimColor>
1015 {view.isLoading
1016 ? 'loading…'
1017 : `${view.files.length} file${view.files.length === 1 ? '' : 's'} · +${added} −${removed} · ` +
1018 `${view.commits.length} commit${view.commits.length === 1 ? '' : 's'} · ` +
1019 `${at + 1} of ${order.length} in the stack`}
1020 </Text>
1021 </Box>
1022 <Box flexDirection="row" alignItems="center" gap={2} flexWrap="wrap">
1023 <Select
1024 key="diff-compare"
1025 label="Compare to"
1026 value={view.compareTo}
1027 options={[
1028 { value: 'parent', label: `parent (${view.parent})` },
1029 { value: 'trunk', label: view.trunk },
1030 ]}
1031 onSelect={value => {
1032 const row = order[at]
1033 if (row) void openDiff($, row, { compareTo: value === 'trunk' ? 'trunk' : 'parent' })
1034 }}
1035 />
1036 <Select
1037 key="diff-whitespace"
1038 label="Whitespace"
1039 value={view.showWhitespace ? 'shown' : 'ignored'}
1040 options={[
1041 { value: 'ignored', label: 'ignored' },
1042 { value: 'shown', label: 'shown' },
1043 ]}
1044 onSelect={value => {
1045 const row = order[at]
1046 if (row) void openDiff($, row, { showWhitespace: value === 'shown' })
1047 }}
1048 />
1049 <Button key="diff-expand-all" plain label="Expand all" onPress={() => setCollapsed([])} />
1050 <Button key="diff-collapse-all" plain label="Collapse all" onPress={() => setCollapsed(view.files.map(f => f.path))} />
1051 </Box>
1052 {view.error && <Text color="error">{view.error}</Text>}
1053 {view.commits.length > 0 && (
1054 <Box flexDirection="column">
1055 <Text dimColor>Commits since {base}</Text>
1056 {view.commits.map(commit => (
1057 <Box flexDirection="row" gap={1}>
1058 <Text dimColor>{commit.sha}</Text>
1059 <Text wrap="truncate-end">{commit.subject}</Text>
1060 </Box>
1061 ))}
1062 </Box>
1063 )}
1064 {view.files.length > 1 && (
1065 <Box flexDirection="column">
1066 <Text dimColor>Files</Text>
1067 {view.files.map((file, i) => (
1068 <Box flexDirection="row" gap={1}>
1069 <Box flexGrow={1} flexShrink={1} minWidth={0}>
1070 <Button
1071 key={`jump-${i}`}
1072 plain
1073 dimColor={file.isGenerated}
1074 label={file.path}
1075 onPress={() => {
1076 if (collapsed.has(file.path)) toggle(file.path)
1077 void $.ui.scroll({ in: DIFF_PANE, to: { key: `file-${i}` } })
1078 }}
1079 />
1080 </Box>
1081 <Text color="success">+{file.added}</Text>
1082 <Text color="error">−{file.removed}</Text>
1083 </Box>
1084 ))}
1085 </Box>
1086 )}
1087 {view.files.map((file, i) => {
1088 const isOpen = !collapsed.has(file.path)
1089 const size = file.hunks.length
1090 const fits = size <= budget
1091 if (isOpen && fits) budget -= size
1092 return (
1093 <Box key={`file-${i}`} flexDirection="column">
1094 <Box flexDirection="row" alignItems="center" gap={1}>
1095 <Box flexShrink={0}>
1096 <Button key={`toggle-${i}`} plain label={isOpen ? '▾' : '▸'} onPress={() => toggle(file.path)} />
1097 </Box>
1098 <Box flexShrink={1} minWidth={0}>
1099 {view.root ? (
1100 <Link href={`file://${view.root}/${file.path}`}>
1101 <Text bold wrap="truncate-start">{file.path}</Text>
1102 </Link>
1103 ) : (
1104 <Text bold wrap="truncate-start">{file.path}</Text>
1105 )}
1106 </Box>
1107 {file.isNew && <Text color="success">new</Text>}
1108 {file.isGenerated && <Text dimColor>generated</Text>}
1109 <Text color="success">+{file.added}</Text>
1110 <Text color="error">−{file.removed}</Text>
1111 <Box flexGrow={1} />
1112 <Box flexShrink={0}>
1113 <Button
1114 key={`ask-${i}`}
1115 plain
1116 dimColor
1117 label="Ask Claude"
1118 onPress={() =>
1119 void $.prompt.submit({
1120 text:
1121 `Review the changes to \`${file.path}\` in ${view.title} (branch \`${view.branch}\`, compared to \`${base}\`). ` +
1122 'Explain what changed, and point out bugs, risks or anything unclear.',
1123 asUser: true,
1124 })
1125 }
1126 />
1127 </Box>
1128 </Box>
1129 {!isOpen ? null : file.isBinary ? (
1130 <Text dimColor>binary file</Text>
1131 ) : file.isTooLarge || !fits ? (
1132 <Box flexDirection="row" gap={1}>
1133 <Text dimColor>
1134 {file.isTooLarge ? 'too large to show here' : 'collapse other files to show this one'}
1135 </Text>
1136 {view.url && <Link href={`${view.url}/files`} label="open on GitHub" />}
1137 </Box>
1138 ) : file.isNew ? (
1139 file.hunks && <Code source={file.hunks} language={languageOf(file.path)} path={file.path} startLine={1} />
1140 ) : (
1141 file.hunks && <Code source={file.hunks} format="diff" language={languageOf(file.path)} path={file.path} />
1142 )}
1143 </Box>
1144 )
1145 })}
1146 </Box>
1147 )
1148 })
1149
1150 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1151 const s = await read($, snapshot)
1152 if (e.props.hasSurvey || (await read($, isHidden)) || (!s.rows.length && !s.issue)) return next(e)
1153
1154 if (e.surface !== 'desktop') return next(e)
1155 const elements = $.ui.resolve(e)
1156 if (!('Svg' in elements)) return next(e)
1157 const { Box, Text, Button, Link, Svg, Select } = elements
1158
1159 const links = await read($, linkTarget)
1160 const busy = await read($, working)
1161 const behind = new Set(s.rows.filter(row => row.needsRestack).map(row => row.branch))
1162 const prUrl = (pr: PrInfo) => (links === 'graphite' ? graphiteUrl(pr) : pr.url)
1163 const prs = s.rows.filter(row => row.pr)
1164 const failing = prs.filter(row => row.pr?.ci === 'fail').length
1165 const diffChars = Math.max(
1166 0,
1167 ...s.rows.map(row => (row.diff ? diffText(row.diff).join(' ').length : 0)),
1168 )
1169 const layout = metaLayout(prs.flatMap(row => (row.pr ? [row.pr] : [])))
1170 const summary = [
1171 `${prs.length} PR${prs.length === 1 ? '' : 's'}`,
1172 failing ? `${failing} failing` : '',
1173 busy ?? (s.isLoading ? 'refreshing…' : ''),
1174
1175 ].filter(Boolean).join(' · ')
1176
1177 return (
1178 <Box flexDirection="column">
1179 <Box flexDirection="row" alignItems="center" gap={1} marginBottom={1}>
1180 <Text bold>Graphite stack</Text>
1181 <Text dimColor>{summary}</Text>
1182 <Box flexGrow={1} />
1183 <Select
1184 key="links"
1185 label="Links"
1186 value={links}
1187 options={[
1188 { value: 'github', label: 'GitHub' },
1189 { value: 'graphite', label: 'Graphite' },
1190 ]}
1191 onSelect={value => {
1192 const target: LinkTarget = value === 'graphite' ? 'graphite' : 'github'
1193 void update($, linkTarget, () => target)
1194 void $.store.set('linkTarget', target)
1195 }}
1196 />
1197 <Button
1198 key="sync"
1199 plain
1200 dimColortypes/index.d.ts 93 lines1export type LinkTarget = 'github' | 'graphite'
2
3export type DiffFile = {
4 path: string
5 added: number
6 removed: number
7 hunks: string
8 isTooLarge: boolean
9 isBinary: boolean
10 isNew: boolean
11 isGenerated: boolean
12}
13
14export type DiffView = {
15 branch: string
16 parent: string
17 trunk: string
18 compareTo: 'parent' | 'trunk'
19 showWhitespace: boolean
20 title: string
21 url?: string
22 root: string
23 headSha?: string
24 commits: { sha: string; subject: string }[]
25 files: DiffFile[]
26 collapsed: string[]
27 isLoading: boolean
28 error?: string
29}
30
31export type Issue = {
32 message: string
33 detail?: string
34 fix?: { label: string; prompt: string }
35 canFix?: false
36}
37
38export type CiState = 'pass' | 'fail' | 'pending' | 'none'
39
40export type PrInfo = {
41 number: number
42 title: string
43 url: string
44 state: string
45 isDraft: boolean
46 reviewDecision: string
47 labels: { name: string; color: string; description?: string }[]
48 ci: CiState
49 ciCounts: { pass: number; fail: number; pending: number }
50 failing: { name: string; workflow?: string; url?: string }[]
51 openThreads: number
52 hasReviewers: boolean
53}
54
55export type Lane = {
56 col: number
57 up: boolean
58 down: boolean
59 pipes: number[]
60 corners: number[]
61 pipeOwners?: string[]
62 cornerOwners?: string[]
63 upOwner?: string
64}
65
66export type StackRow = {
67 branch: string
68 parent?: string
69 lane: Lane
70 isCurrent: boolean
71 isTrunk: boolean
72 needsRestack: boolean
73 pr?: PrInfo
74 diff?: { added: number; removed: number }
75}
76
77export type StackSnapshot = {
78 branch: string
79 rows: StackRow[]
80 columns: number
81 updatedAt: number
82 isStale?: boolean
83 issue?: Issue
84 githubIssue?: Issue
85 isLoading: boolean
86}
87
88declare module 'claude-code' {
89 interface PluginState {
90 'graphite-stack': { snapshot: StackSnapshot; isHidden: boolean; linkTarget: LinkTarget; diffView: DiffView | null; working: string | null }
91 }
92}
93