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

My Claude Code mods.
git-pane: live git status pane (/git-pane)<img width="661" height="439" alt="image" src="https://github.com/user-attachments/assets/c39adc65-4540-4419-a307-120f6c5a6dd4" />
gh for GitHub and glab for GitLab.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.
/plugin install git-pane --marketplace xavortm/claude-modshooks/register.tsx 541 lines1import { 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}
541types/index.d.ts 60 lines1export 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