SLOPSHOPPER

git-pane

Live git status pane: branch, changes with line counts, open PRs/MRs (GitHub or GitLab), and the Linear issue or Teamwork task from the branch name

newpanebandguardcommandtoast
v0.2.0no licenseupdated 2026-10-07xavortm/claude-mods/mods/git-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-pane
│ ┃ Git ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ━━ GIT ━━━━━━━━━━━━━━ │ git-pane │ │ ┃ ⎇ M src/auth.ts [ copy ] ⏺ Read(src/auth.ts) │ PR list failed: SyntaxError: JSON Parse │ │ ┃ M src/auth.ts ⎿ Read 6 lines │ error: Unexpected EOF │ │ ┃ ○ no PR yet ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ≡ no commits beyond develop ⎿ Added 2 lines, re╭────────────────────────────────────────────╮ │ ┃ ⏺ Bash(bun test) │ git-pane │ │ ┃ Changes (1) +3 ⎿ 3 pass, 1 fail │ PR list failed: SyntaxError: JSON Parse │ │ ┃ ? src/auth.test.ts +3 │ error: Unexpected EOF │ │ ┃ ● Done. refresh now re╰────────────────────────────────────────────╯ │ ┃ Open PRs (0) │ ┃ No open PRs ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ━━ TEAMWORK ━━━━━━━━━━ › /git-pane │ ┃ no task id in the branch name ⎿ git-pane: Git pane opened. │ │ ⎇ M src/auth.ts ✎ 1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ git-pane: ⎇ M src/auth.ts

Draws

Band
⎇ M src/auth.ts ✎ 1
Pane · Git
━━ GIT ━━━━━━━━━━━━━━ ⎇ M src/auth.ts [ copy ] M src/auth.ts ○ no PR yet ≡ no commits beyond develop Changes (1) +3 ? src/auth.test.ts +3 Open PRs (0) No open PRs ━━ TEAMWORK ━━━━━━━━━━ no task id in the branch name
README

claude-mods

My Claude Code mods.

Mods

  • git-pane: live git status pane (/git-pane)

git-pane

<img width="661" height="439" alt="image" src="https://github.com/user-attachments/assets/c39adc65-4540-4419-a307-120f6c5a6dd4" />

  • Branch line above the prompt, with ahead/behind, dirty count and a worktree badge.
  • Changes list with line counts and a 15-file limit.
  • Open PRs or MRs with checks and a checkout button. Uses gh for GitHub and glab for GitLab.
  • Linear issue (ABC-123 in the branch name) or Teamwork task (7 to 9 digit id in the branch name). Each needs its MCP server connected.

Setting: blockWorktrees (off by default) denies EnterWorktree and new git worktrees. Change it in /config, then run /reload-plugins.

Install

/plugin install git-pane --marketplace xavortm/claude-mods
Source 2 files
hooks/register.tsx 541 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BranchInfo, GitSnapshot, LinearInfo, Pr, TeamworkInfo } from '../types'
5
6const PANE = 'git-pane'
7// Linear-style issue key in a branch name, like ABC-123.
8const ISSUE_ID = /(?<![a-z0-9])[a-z]{2,6}-\d{1,6}(?![a-z0-9])/i
9const snapshot = atom({ plugin: 'git-pane', key: 'snapshot' } as const, null)
10const branchInfo = atom({ plugin: 'git-pane', key: 'branchInfo' } as const, null)
11const prs = atom({ plugin: 'git-pane', key: 'prs' } as const, null)
12const linear = atom({ plugin: 'git-pane', key: 'linear' } as const, null)
13const expanded = atom({ plugin: 'git-pane', key: 'expanded' } as const, false)
14const teamwork = atom({ plugin: 'git-pane', key: 'teamwork' } as const, null)
15
16type Run = (argv: readonly string[]) => Promise<{ exitCode: number; stdout: string; stderr: string }>
17
18const collect = async (run: Run): Promise<GitSnapshot> => {
19  const status = await run(['git', 'status', '--porcelain=v1', '-b'])
20  if (status.exitCode !== 0) {
21    return { branch: '', ahead: 0, behind: 0, worktree: null, files: [], issue: null, task: null, error: status.stderr.trim() || 'git failed' }
22  }
23  const [head = '', ...rest] = status.stdout.split('\n').filter(Boolean)
24  const branch = head.replace(/^## /, '').split('...')[0] ?? ''
25  const ahead = Number(head.match(/ahead (\d+)/)?.[1] ?? 0)
26  const behind = Number(head.match(/behind (\d+)/)?.[1] ?? 0)
27  const numstat = await run(['git', 'diff', 'HEAD', '--numstat', '--no-renames'])
28  const counts = new Map<string, { add: number | null; del: number | null }>()
29  for (const l of numstat.exitCode === 0 ? numstat.stdout.split('\n') : []) {
30    const m = l.match(/^(\d+|-)\t(\d+|-)\t(.+)$/)
31    if (m) counts.set(m[3]!, { add: m[1] === '-' ? null : Number(m[1]), del: m[2] === '-' ? null : Number(m[2]) })
32  }
33  const dirs = await run(['git', 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel'])
34  const [gitDir = '', commonDir = '', top = ''] = dirs.stdout.split('\n')
35  const isWorktree = gitDir !== '' && commonDir !== '' && gitDir !== commonDir
36
37  return {
38    branch,
39    ahead,
40    behind,
41    worktree: isWorktree ? top.split('/').pop() || 'worktree' : null,
42    files: rest.map(l => {
43      const path = l.slice(3)
44      const c = counts.get(path.split(' -> ').pop() ?? path)
45
46      return { code: l.slice(0, 2), path, add: c?.add ?? null, del: c?.del ?? null }
47    }),
48    issue: branch.match(ISSUE_ID)?.[0].toUpperCase() ?? null,
49    task: branch.match(/(?<!\d)\d{7,9}(?!\d)/)?.[0] ?? null,
50    error: null,
51  }
52}
53
54const fileLook = (code: string): { icon: string; color: string } => {
55  if (code.includes('?')) return { icon: '?', color: 'gray' }
56  if (code.includes('D')) return { icon: '✖', color: 'red' }
57  if (code.includes('A')) return { icon: '+', color: 'green' }
58  if (code.includes('R')) return { icon: '➜', color: 'magenta' }
59  if (code.includes('U')) return { icon: '⚠', color: 'red' }
60  return { icon: code[0] !== ' ' ? '●' : '✎', color: code[0] !== ' ' ? 'green' : 'yellow' }
61}
62
63const MAX_DIRS = 3
64const MAX_FILES = 15
65
66// Keeps the last MAX_DIRS directories and marks anything cut with a leading "…/".
67const splitPath = (raw: string): { dir: string; name: string } => {
68  const parts = (raw.split(' -> ').pop() ?? raw).split('/')
69  const name = parts.pop() ?? ''
70  const dirs = parts.slice(-MAX_DIRS)
71
72  return { dir: dirs.length ? `${parts.length > MAX_DIRS ? '…/' : ''}${dirs.join('/')}/` : '', name }
73}
74
75const statusColor =(type: string): string =>
76  type === 'completed' ? 'green' : type === 'started' ? 'yellow' : type === 'canceled' ? 'red' : 'blue'
77
78async function refresh($: EngineInterface) {
79  const next = await collect(argv => $.process.run(argv, { timeoutMs: 5000 }))
80  await update($, snapshot, () => next)
81  $.ui.status(next.error ? undefined : `⎇ ${next.branch}${next.worktree ? ' 🌳 WORKTREE' : ''}`)
82}
83
84let hostKind: Promise<'gitlab' | 'github'> | null = null
85
86// GitLab remotes use glab, everything else uses gh. Detected once from the origin URL.
87const detectHost = ($: EngineInterface) =>
88  (hostKind ??= $.process
89    .run(['git', 'remote', 'get-url', 'origin'], { timeoutMs: 5000 })
90    .then(r => (/gitlab/i.test(r.stdout) ? 'gitlab' : 'github')))
91
92const glMrState = (s: string): string => (s === 'merged' ? 'MERGED' : s === 'opened' ? 'OPEN' : 'CLOSED')
93
94const glChecks = (status: string): Pr['checks'] =>
95  status === 'mergeable' ? 'pass' : status === 'ci_still_running' ? 'pending' : status === 'ci_must_pass' ? 'fail' : 'none'
96
97async function refreshPrs($: EngineInterface) {
98  try {
99    if ((await detectHost($)) === 'gitlab') {
100      const r = await $.process.run(['glab', 'mr', 'list', '--per-page', '10', '-F', 'json'], { timeoutMs: 15000 })
101      if (r.exitCode !== 0) throw new Error(r.stderr.trim() || 'glab failed')
102      const list: Pr[] = JSON.parse(r.stdout || '[]').map((m: any): Pr => ({
103        number: m.iid,
104        title: m.title,
105        branch: m.source_branch,
106        base: m.target_branch,
107        isDraft: !!m.draft,
108        review: '',
109        checks: glChecks(m.detailed_merge_status ?? ''),
110      }))
111      await update($, prs, () => list)
112      return
113    }
114    const r = await $.process.run(
115      ['gh', 'pr', 'list', '--state', 'open', '--limit', '10', '--json', 'number,title,headRefName,baseRefName,isDraft,reviewDecision,statusCheckRollup'],
116      { timeoutMs: 15000 },
117    )
118    if (r.exitCode !== 0) throw new Error(r.stderr.trim() || 'gh failed')
119    const list: Pr[] = JSON.parse(r.stdout).map((p: any): Pr => {
120      const cs: any[] = p.statusCheckRollup ?? []
121      const bad = cs.some(c => ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED'].includes(c.conclusion ?? c.state))
122      const wait = cs.some(c => !(c.conclusion ?? c.state) || ['PENDING', 'IN_PROGRESS', 'QUEUED', 'EXPECTED'].includes(c.status ?? c.state))
123      return {
124        number: p.number,
125        title: p.title,
126        branch: p.headRefName,
127        base: p.baseRefName,
128        isDraft: p.isDraft,
129        review: p.reviewDecision ?? '',
130        checks: cs.length === 0 ? 'none' : bad ? 'fail' : wait ? 'pending' : 'pass',
131      }
132    })
133    await update($, prs, () => list)
134  } catch (err) {
135    $.ui.toast(`PR list failed: ${String(err).slice(0, 80)}`)
136    await update($, prs, () => [])
137  }
138}
139
140async function refreshBranch($: EngineInterface, branch: string) {
141  const run: Run = argv => $.process.run(argv, { timeoutMs: 15000 })
142  let pr: BranchInfo['pr'] = null
143  if ((await detectHost($)) === 'gitlab') {
144    const r = await run(['glab', 'mr', 'list', '--source-branch', branch, '--all', '--per-page', '1', '-F', 'json'])
145    if (r.exitCode === 0) {
146      const m = JSON.parse(r.stdout || '[]')[0]
147      if (m) pr = { number: m.iid, title: m.title, url: m.web_url, state: glMrState(m.state), base: m.target_branch }
148    }
149  } else {
150    const r = await run(['gh', 'pr', 'list', '--head', branch, '--state', 'all', '--limit', '1', '--json', 'number,title,url,state,baseRefName'])
151    if (r.exitCode === 0) {
152      const p = JSON.parse(r.stdout || '[]')[0]
153      if (p) pr = { number: p.number, title: p.title, url: p.url, state: p.state, base: p.baseRefName }
154    }
155  }
156  const anc = await run(['git', 'merge-base', '--is-ancestor', 'HEAD', 'origin/develop'])
157  const base = pr?.base ?? 'develop'
158  const sl = branch === base ? null : await run(['git', 'shortlog', '-sn', '--no-merges', `origin/${base}..HEAD`])
159  const authors = (sl?.exitCode === 0 ? sl.stdout : '')
160    .split('\n')
161    .map(l => l.match(/^\s*(\d+)\s+(.+)$/))
162    .filter((m): m is RegExpMatchArray => m !== null)
163    .map(m => ({ count: Number(m[1]), name: m[2]! }))
164  await update($, branchInfo, () => ({ branch, pr, isInDevelop: anc.exitCode === 0, base, authors }))
165}
166
167const humanize = (branch: string): string => {
168  const words = branch.replace(/^[a-z]+\//, '').replace(/^[a-z]{2,6}-\d+-?/i, '').replace(/[-_]+/g, ' ').trim()
169  return words ? words[0]!.toUpperCase() + words.slice(1) : branch
170}
171
172async function copy($: EngineInterface, text: string) {
173  const r = await $.process.run(['sh', '-c', 'printf %s "$1" | pbcopy', '_', text], { timeoutMs: 5000 })
174  $.ui.toast(r.exitCode === 0 ? `✔ Copied ${text.length > 60 ? text.slice(0, 57) + '…' : text}` : '✖ Copy failed', { timeoutMs: 3000 })
175}
176
177async function checkout($: EngineInterface, n: number) {
178  const gitlab = (await detectHost($)) === 'gitlab'
179  const r = await $.process.run(gitlab ? ['glab', 'mr', 'checkout', String(n)] : ['gh', 'pr', 'checkout', String(n)], { timeoutMs: 30000 })
180  $.ui.toast(r.exitCode === 0 ? `✔ Checked out ${gitlab ? 'MR !' : 'PR #'}${n}` : `✖ ${r.stderr.trim().split('\n').pop()}`, { timeoutMs: 7000 })
181  await refresh($)
182  const snap = await read($, snapshot)
183  if (snap) await refreshBranch($, snap.branch)
184}
185
186async function refreshLinear($: EngineInterface, id: string) {
187  try {
188    const r = await $.tool.call({
189      tool: 'mcp__linear__get_issue',
190      id,
191      fields: ['id', 'title', 'status', 'statusType', 'assignee', 'priority', 'url'],
192    })
193    const text = typeof r.text === 'string' ? r.text : JSON.stringify(r.result ?? '')
194    const j = JSON.parse(text.slice(text.indexOf('{')))
195    const pick = (v: unknown): string =>
196      typeof v === 'string' ? v : v && typeof v === 'object' ? String((v as { name?: string }).name ?? '') : ''
197    const info: LinearInfo = {
198      id,
199      title: pick(j.title),
200      status: pick(j.status),
201      statusType: pick(j.statusType),
202      assignee: pick(j.assignee),
203      priority: pick(j.priority),
204      url: pick(j.url),
205    }
206    await update($, linear, () => info)
207  } catch (err) {
208    await update($, linear, () => ({ id, error: String(err).slice(0, 80) }))
209  }
210}
211
212const TW = 'mcp__claude_ai_Teamwork__'
213const twUsers = new Map<number, string>()
214
215const twJson = (r: { text?: unknown; result?: unknown }): any => {
216  const res: any = r.result
217  const blocks = Array.isArray(res) ? res : Array.isArray(res?.content) ? res.content : null
218  const text =
219    typeof r.text === 'string'
220      ? r.text
221      : blocks
222        ? blocks.map((b: any) => (typeof b?.text === 'string' ? b.text : '')).join('')
223        : typeof res === 'string'
224          ? res
225          : JSON.stringify(res ?? '')
226  const body = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
227  try {
228    return JSON.parse(body)
229  } catch (err) {
230    throw new Error(`${String(err).slice(0, 30)} len=${text.length} head=${text.slice(0, 25)} tail=${text.slice(-25)}`)
231  }
232}
233
234async function twAssignee($: EngineInterface, userId: number | undefined): Promise<string> {
235  if (!userId) return ''
236  const hit = twUsers.get(userId)
237  if (hit !== undefined) return hit
238  try {
239    const u = twJson(await $.tool.call({ tool: `${TW}twprojects-get_user`, id: userId, fields: ['id', 'firstName', 'lastName'] })).user ?? {}
240    const name = `${u.firstName ?? ''} ${u.lastName ?? ''}`.trim()
241    twUsers.set(userId, name)
242
243    return name
244  } catch {
245    return ''
246  }
247}
248
249async function refreshTeamwork($: EngineInterface, id: string) {
250  try {
251    const t = twJson(
252      await $.tool.call({
253        tool: `${TW}twprojects-get_task`,
254        id: Number(id),
255        fields: ['id', 'name', 'status', 'priority', 'assignees', 'tasklist'],
256      }),
257    ).task
258    if (!t) throw new Error('task not found')
259    const info: TeamworkInfo = {
260      id,
261      title: String(t.name ?? ''),
262      status: String(t.status ?? ''),
263      assignee: await twAssignee($, t.assignees?.[0]?.id),
264      tasklist: String(t.tasklist?.meta?.name ?? ''),
265      priority: String(t.priority ?? ''),
266      url: String(t.meta?.webLink ?? ''),
267    }
268    await update($, teamwork, () => info)
269  } catch (err) {
270    await update($, teamwork, () => ({ id, error: String(err).slice(0, 200) }))
271  }
272}
273
274export const register: Register = (on, options) => {
275  let lastIssue: string | null = null
276  let lastTask: string | null = null
277  let lastTaskAt = 0
278  let lastBranch = ''
279  let lastAt = 0
280
281  on('session.start', async ($, e, next) => {
282    await $.command.register({ name: 'git-pane', description: 'Show live git status + Linear/Teamwork task in a pane' })
283    $.clock.every(3000, () => void refresh($))
284    void refreshPrs($)
285    $.clock.every(60000, async () => {
286      void refreshPrs($)
287      const snap = await read($, snapshot)
288      if (snap && !snap.error) void refreshBranch($, snap.branch)
289    })
290
291    return next(e)
292  })
293
294  on('command.run', { command: 'git-pane' }, async $ => {
295    await $.ui.open({ id: PANE, title: ' Git' })
296    await refresh($)
297    void refreshPrs($)
298    const snap = await read($, snapshot)
299    if (snap && !snap.error) void refreshBranch($, snap.branch)
300
301    return { text: 'Git pane opened.' }
302  })
303
304  // Re-fetch Linear when the branch's issue changes, else at most every 2 minutes.
305  on('tool.call', async ($, e, next) => {
306    const ran = await next(e)
307    await refresh($)
308    const s = await read($, snapshot)
309    const now = await $.clock.now()
310    if (s && !s.error && s.branch !== lastBranch) {
311      lastBranch = s.branch
312      void refreshBranch($, s.branch)
313    }
314    if (s?.issue && (s.issue !== lastIssue || now - lastAt > 120000)) {
315      lastIssue = s.issue
316      lastAt = now
317      void refreshLinear($, s.issue)
318    }
319    if (s?.task && (s.task !== lastTask || now - lastTaskAt > 120000)) {
320      lastTask = s.task
321      lastTaskAt = now
322      void refreshTeamwork($, s.task)
323    }
324
325    return ran
326  })
327
328  // Opt-in via the blockWorktrees setting: keep work in the main checkout.
329  if (options.blockWorktrees === true) {
330    const NO_WORKTREE = 'Worktrees are blocked by the git-pane blockWorktrees setting. Use a normal branch in the main checkout instead.'
331    on('tool.call', { tool: 'EnterWorktree' }, () => ({ deny: NO_WORKTREE }))
332    on('tool.call', { tool: 'Bash' }, async (_$, e, next) => {
333      if (/\bgit\s+worktree\s+add\b/.test(e.command)) return { deny: NO_WORKTREE }
334
335      return next(e)
336    })
337  }
338
339  // Always-on branch line right above the prompt.
340  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
341    const s = await read($, snapshot)
342    if (!s || s.error) return next(e)
343    const { Box, Text } = $.ui.resolve(e)
344    const isProtected = s.branch === 'develop' || s.branch === 'master'
345    const dirty = s.files.length
346
347    return (
348      <Box>
349        <Text color="magenta">⎇ </Text>
350        <Text bold color={isProtected ? 'red' : 'cyan'}>{s.branch}</Text>
351        {s.ahead > 0 && <Text color="green"> ↑{s.ahead}</Text>}
352        {s.behind > 0 && <Text color="yellow"> ↓{s.behind}</Text>}
353        {s.issue && <Text color="blue"> ◆ {s.issue}</Text>}
354        {s.task && <Text color="blue"> ◆ #{s.task}</Text>}
355        <Text color={dirty ? 'yellow' : 'green'}> {dirty ? `✎ ${dirty}` : '✔'}</Text>
356        {s.worktree && <Text bold color="black" backgroundColor="yellow"> 🌳 WORKTREE {s.worktree} </Text>}
357      </Box>
358    )
359  })
360
361  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
362    const { Box, Text, Button } = $.ui.resolve(e)
363    const s = await read($, snapshot)
364    const l = await read($, linear)
365    const tw = await read($, teamwork)
366    const open = (await read($, expanded)) === true
367    const pl = await read($, prs)
368    const bi = await read($, branchInfo)
369    if (!s) return <Text dimColor>⏳ Loading…</Text>
370    if (s.error) return <Text color="red">✖ {s.error}</Text>
371
372    const protectedBranch = s.branch === 'develop' || s.branch === 'master'
373    const totalAdd = s.files.reduce((n, f) => n + (f.add ?? 0), 0)
374    const totalDel = s.files.reduce((n, f) => n + (f.del ?? 0), 0)
375
376    const norm = (x: string): string => x.toLowerCase().replace(/[^a-z0-9]/g, '')
377    const hasLinear = !!l && l.id === s.issue && 'title' in l
378    const linearTitle = hasLinear && 'title' in l ? l.title : ''
379    const ownPr = bi && bi.branch === s.branch ? bi : null
380    const authors = ownPr?.authors ?? []
381    const desc = ownPr?.pr?.title || (hasLinear ? '' : humanize(s.branch))
382    const showDesc = desc !== '' && norm(desc) !== norm(linearTitle)
383
384    return (
385      <Box flexDirection="column">
386        <Text bold color="magenta">━━ GIT ━━━━━━━━━━━━━━</Text>
387        <Box>
388          <Text>
389            <Text color="magenta">⎇ </Text>
390            <Text bold color={protectedBranch ? 'red' : 'cyan'}>{s.branch}</Text>
391            {s.ahead > 0 && <Text color="green"> ↑{s.ahead}</Text>}
392            {s.behind > 0 && <Text color="yellow"> ↓{s.behind}</Text>}
393            {protectedBranch && <Text color="red"> ⚠ protected</Text>}
394          </Text>
395          <Box marginLeft={1}>
396            <Button key="copy-branch" label="copy" onPress={() => copy($, s.branch)} />
397          </Box>
398        </Box>
399        {showDesc && <Text wrap="truncate-end" dimColor>  {desc}</Text>}
400        {s.worktree && (
401          <Text bold color="black" backgroundColor="yellow"> 🌳 WORKTREE: {s.worktree} </Text>
402        )}
403        {ownPr !== null && (
404          <Box flexDirection="column">
405            {ownPr.pr === null && <Text color="gray">  ○ no PR yet</Text>}
406            {ownPr.pr !== null && (
407              <Text wrap="truncate-end">
408                <Text color={ownPr.pr.state === 'MERGED' ? 'magenta' : ownPr.pr.state === 'OPEN' ? 'green' : 'red'}>
409                  {'  '}{ownPr.pr.state === 'MERGED' ? '✔ merged' : ownPr.pr.state === 'OPEN' ? '◌ open' : '✖ closed'} PR #{ownPr.pr.number}
410                </Text>
411                <Text color={ownPr.pr.base === 'master' && !s.branch.startsWith('hotfix/') ? 'red' : 'cyan'} bold> → {ownPr.pr.base}</Text>
412                {ownPr.pr.base === 'master' && !s.branch.startsWith('hotfix/') && <Text color="red"> ⚠ targets master</Text>}
413              </Text>
414            )}
415            {ownPr.pr !== null && (
416              <Box>
417                <Box flexShrink={1}>
418                  <Text dimColor wrap="truncate-end">  {ownPr.pr.url}</Text>
419                </Box>
420                <Box marginLeft={1}>
421                  <Button key="copy-pr-url" label="copy" onPress={() => copy($, ownPr.pr!.url)} />
422                </Box>
423              </Box>
424            )}
425            {ownPr.pr === null && ownPr.isInDevelop && <Text color="yellow">  ≡ no commits beyond develop</Text>}
426            {ownPr.pr !== null && ownPr.pr.state !== 'MERGED' && ownPr.isInDevelop && <Text color="magenta">  ✔ already contained in develop</Text>}
427            {authors.length > 0 && (
428              <Text wrap="truncate-end">
429                <Text color="green">  👥 </Text>
430                {authors.map(a => `${a.name} (${a.count})`).join(', ')}
431                <Text dimColor> since {ownPr.base ?? 'develop'}</Text>
432              </Text>
433            )}
434          </Box>
435        )}
436
437        <Box flexDirection="column" marginTop={1}>
438          <Text bold color="magenta">
439            Changes <Text dimColor>({s.files.length})</Text>
440            {totalAdd > 0 && <Text color="green"> +{totalAdd}</Text>}
441            {totalDel > 0 && <Text color="red"> -{totalDel}</Text>}
442          </Text>
443          {s.files.length === 0 && <Text color="green">✔ Working tree clean</Text>}
444          {(open ? s.files : s.files.slice(0, MAX_FILES)).map(f => {
445            const { icon, color } = fileLook(f.code)
446            const { dir, name } = splitPath(f.path)
447            return (
448              <Text wrap="truncate-start">
449                <Text color={color}>{icon} </Text>
450                <Text color="gray">{dir}</Text>
451                <Text bold color={color}>{name}</Text>
452                {f.add !== null && f.add > 0 && <Text color="green"> +{f.add}</Text>}
453                {f.del !== null && f.del > 0 && <Text color="red"> -{f.del}</Text>}
454              </Text>
455            )
456          })}
457          {s.files.length > MAX_FILES && (
458            <Button
459              key="files-toggle"
460              label={open ? '− show less' : `+ ${s.files.length - MAX_FILES} more`}
461              onPress={() => update($, expanded, v => !v)}
462            />
463          )}
464        </Box>
465
466        <Box flexDirection="column" marginTop={1}>
467          <Text bold color="magenta">Open PRs <Text dimColor>({pl?.length ?? '…'})</Text></Text>
468          {pl && pl.length === 0 && <Text dimColor>No open PRs</Text>}
469          {(pl ?? []).map(p => {
470            const ck = p.checks === 'pass' ? ['✔', 'green'] : p.checks === 'fail' ? ['✖', 'red'] : p.checks === 'pending' ? ['◔', 'yellow'] : ['·', 'gray']
471            const rv = p.review === 'APPROVED' ? ['👍', 'green'] : p.review === 'CHANGES_REQUESTED' ? ['✎', 'red'] : ['○', 'gray']
472            return (
473              <Box flexWrap="wrap">
474                <Box flexShrink={1}>
475                  <Text color={ck[1]}>{ck[0]} </Text>
476                  <Text color={rv[1]}>{rv[0]} </Text>
477                  <Text color={p.branch === s.branch ? 'cyan' : 'white'} bold={p.branch === s.branch}>#{p.number} </Text>
478                  <Text wrap="truncate-end" dimColor={p.isDraft}>{p.isDraft ? '[draft] ' : ''}{p.title} </Text>
479                </Box>
480                <Box marginLeft={2}>
481                  <Text color={p.base === 'master' ? 'red' : 'cyan'}>→{p.base} </Text>
482                  {p.branch !== s.branch && (
483                    <Button key={`pr-${p.number}`} label="checkout" onPress={() => checkout($, p.number)} />
484                  )}
485                </Box>
486              </Box>
487            )
488          })}
489        </Box>
490
491        {s.issue && (
492        <Box flexDirection="column" marginTop={1}>
493          <Text bold color="blue">━━ LINEAR ━━━━━━━━━━━━</Text>
494          {s.issue && (
495            <Text>
496              <Text color="blue">◆ </Text>
497              <Text bold color="blue">{s.issue}</Text>
498              {l && 'status' in l && l.id === s.issue && (
499                <Text color={statusColor(l.statusType)}> ● {l.status}</Text>
500              )}
501            </Text>
502          )}
503          {s.issue && hasLinear && 'title' in l && (
504            <Box flexDirection="column">
505              <Text wrap="truncate-end">  {l.title}</Text>
506              <Text dimColor>  👤 {l.assignee || 'unassigned'}  ⚑ {l.priority || 'no priority'}</Text>
507            </Box>
508          )}
509          {s.issue && l && l.id === s.issue && 'error' in l && <Text color="red">  ✖ Linear: {l.error}</Text>}
510          {s.issue && (!l || l.id !== s.issue) && <Text dimColor>  ⏳ fetching from Linear…</Text>}
511        </Box>
512        )}
513
514        {(s.task || !s.issue) && (
515        <Box flexDirection="column" marginTop={1}>
516          <Text bold color="blue">━━ TEAMWORK ━━━━━━━━━━</Text>
517          {!s.task && <Text dimColor>  no task id in the branch name</Text>}
518          {s.task && (
519            <Text>
520              <Text color="blue">◆ </Text>
521              <Text bold color="blue">#{s.task}</Text>
522              {tw && tw.id === s.task && 'status' in tw && (
523                <Text color={tw.status === 'completed' ? 'green' : tw.status === 'new' ? 'blue' : 'yellow'}> ● {tw.status}</Text>
524              )}
525            </Text>
526          )}
527          {s.task && tw && tw.id === s.task && 'title' in tw && (
528            <Box flexDirection="column">
529              <Text wrap="truncate-end">  {tw.title}</Text>
530              <Text dimColor wrap="truncate-end">  👤 {tw.assignee || 'unassigned'}  ☰ {tw.tasklist || 'no list'}</Text>
531            </Box>
532          )}
533          {s.task && tw && tw.id === s.task && 'error' in tw && <Text color="red">  ✖ Teamwork: {tw.error}</Text>}
534          {s.task && (!tw || tw.id !== s.task) && <Text dimColor>  ⏳ fetching from Teamwork…</Text>}
535        </Box>
536        )}
537      </Box>
538    )
539  })
540}
541
types/index.d.ts 60 lines
1export type LinearInfo = {
2  id: string
3  title: string
4  status: string
5  statusType: string
6  assignee: string
7  priority: string
8  url: string
9} | { id: string; error: string }
10
11export type TeamworkInfo = {
12  id: string
13  title: string
14  status: string
15  assignee: string
16  tasklist: string
17  priority: string
18  url: string
19} | { id: string; error: string }
20
21export type Pr = {
22  number: number
23  title: string
24  branch: string
25  base: string
26  isDraft: boolean
27  review: string
28  checks: 'pass' | 'fail' | 'pending' | 'none'
29}
30
31export type BranchInfo = {
32  branch: string
33  pr: { number: number; title: string; url: string; state: string; base: string } | null
34  isInDevelop: boolean
35  base: string
36  authors: { name: string; count: number }[]
37}
38
39export type GitSnapshot = {
40  branch: string
41  ahead: number
42  behind: number
43  worktree: string | null
44  files: { code: string; path: string; add: number | null; del: number | null }[]
45  issue: string | null
46  task: string | null
47  error: string | null
48}
49
50declare module 'claude-code' {
51  // Teamwork's claude.ai connector isn't always connected when the generated MCP types are written.
52  interface McpToolInputs {
53    'mcp__claude_ai_Teamwork__twprojects-get_task': { id: number; fields?: string[] }
54    'mcp__claude_ai_Teamwork__twprojects-get_user': { id: number; fields?: string[] }
55  }
56  interface PluginState {
57    'git-pane': { snapshot: GitSnapshot | null; linear: LinearInfo | null; teamwork: TeamworkInfo | null; prs: Pr[] | null; branchInfo: BranchInfo | null; expanded: boolean }
58  }
59}
60