SLOPSHOPPER

graphite-stack

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.

panebandguardcommandtoaststatus
v2.2.0MITupdated 2026-10-25adrianjost/claude-graphite-stack-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · graphite-stack
│ ┃ graphite-stack-diff ✕ › fix the failing auth test and add an audit log call │ ┃ Hover a branch's line counts and click View │ ┃ diff to see its changes. ⏺ 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 │ │ › /stack │ ⎿ graphite-stack: Graphite stack hidden. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · graphite-stack-diff
Hover a branch's line counts and click View diff to see its changes.
README

Claude Graphite Stack Status

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" />

What it shows

One row per branch, drawn as the same tree gt log draws, from the branch above trunk up to every descendant, side branches included.

DotPR 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 numberunresolved review conversations
↻ restackthe 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
Badgesthe PR's GitHub labels, in their colors; hover one for its description; when they don't fit, +N shows the rest on hover
+12 −3lines 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.

Install

In Claude Code, run:

/plugin install graphite-stack --marketplace adrianjost/claude-graphite-stack-status

Answer y to add the marketplace, then pick a scope.

Requirements

  • The Claude Code desktop app. The band draws nothing in the terminal, VS Code or mobile.
  • gt tracking your branches (the plugin reads Graphite's local metadata, .git/.graphite_metadata.db)
  • gh, signed in to the repository's GitHub host
  • sqlite3 and git on your PATH

Usage

The band appears on its own whenever the session's branch belongs to a Graphite stack.

  • /stack shows or hides it
  • click the band, then press R to refresh or H to hide

Diff pane

Hover a branch's line counts and click View diff to open its changes in a side pane:

  • Overview: the branch's commits and a file list. Click a file to jump to it.
  • Collapsible files: fold files one by one, or use Expand all / Collapse all. Lockfiles and generated files start collapsed.
  • Stack navigation: ◀ / ▶ step through the stack's PRs in order.
  • Options: compare against the parent branch or trunk, and ignore or show whitespace changes.
  • Ask Claude: each file has a button to have Claude review or explain its changes.
  • Live: the diff reloads when the branch changes, for example after a restack.

Keeping the stack current

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.

Settings

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.

Troubleshooting

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.

License

MIT

Source 2 files
hooks/register.tsx 1358 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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            dimColor
types/index.d.ts 93 lines
1export 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