Shows the in-progress Beads task, its src/<branch> merge target and the current worktree/branch at the top of the flightdeck pane

hooks/register.tsx 133 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Bead, BeadBandInfo } from '../types'
5
6const info = atom({ plugin: 'bead-band', key: 'info' } as const, null)
7
8// Хост может не передать PATH из zsh — тогда bd ищется по пути Homebrew
9const BD_CANDIDATES = ['bd', '/opt/homebrew/bin/bd']
10const SOURCE_LABEL_PREFIX = 'src/'
11const WORKTREE_BRANCH_PREFIX = 'bd-'
12const TITLE_CHARS = 48
13const RUN_TIMEOUT_MS = 10_000
14
15type RawBead = { id?: unknown; title?: unknown; labels?: unknown }
16
17const clip = (s: string, max: number) => (s.length > max ? s.slice(0, max - 1) + '…' : s)
18
19async function runText($: EngineInterface, argv: string[]): Promise<string | null> {
20 try {
21 const r = await $.process.run(argv, { timeoutMs: RUN_TIMEOUT_MS })
22 return r.exitCode === 0 ? r.stdout.trim() : null
23 } catch {
24 return null
25 }
26}
27
28async function runBd($: EngineInterface, args: string[]): Promise<string | null> {
29 for (const bd of BD_CANDIDATES) {
30 const out = await runText($, [bd, ...args])
31 if (out !== null) return out
32 }
33 return null
34}
35
36function toBead(raw: RawBead): Bead | null {
37 if (typeof raw.id !== 'string' || typeof raw.title !== 'string') return null
38 const labels = Array.isArray(raw.labels) ? raw.labels.filter((l): l is string => typeof l === 'string') : []
39 const source = labels.find(l => l.startsWith(SOURCE_LABEL_PREFIX))
40 return { id: raw.id, title: raw.title, source: source ? source.slice(SOURCE_LABEL_PREFIX.length) : null }
41}
42
43function parseBeads(json: string | null): Bead[] {
44 if (!json) return []
45 try {
46 const parsed: unknown = JSON.parse(json)
47 const list = Array.isArray(parsed) ? parsed : [parsed]
48 return list.map(x => toBead((x ?? {}) as RawBead)).filter((b): b is Bead => b !== null)
49 } catch {
50 return []
51 }
52}
53
54async function collect($: EngineInterface): Promise<BeadBandInfo> {
55 const [toplevel, branch, inProgressJson] = await Promise.all([
56 runText($, ['git', 'rev-parse', '--show-toplevel']),
57 runText($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD']),
58 runBd($, ['list', '--status', 'in_progress', '--json', '--brief', '--readonly']),
59 ])
60 let beads = parseBeads(inProgressJson)
61
62 // В воркчтри bd-<ID> его бид — главный, даже если он ещё не in_progress
63 if (branch?.startsWith(WORKTREE_BRANCH_PREFIX)) {
64 const ownId = branch.slice(WORKTREE_BRANCH_PREFIX.length)
65 const own = beads.find(b => b.id === ownId) ?? parseBeads(await runBd($, ['show', ownId, '--json', '--readonly']))[0]
66 if (own) beads = [own, ...beads.filter(b => b.id !== own.id)]
67 }
68
69 return { worktree: toplevel ? toplevel.split('/').pop() ?? null : null, branch, beads }
70}
71
72async function refresh($: EngineInterface) {
73 const next = await collect($)
74 await update($, info, () => next)
75}
76
77const FLIGHTDECK_PANE = 'flightdeck'
78
79export const register: Register = on => {
80 on('session.start', async ($, e, next) => {
81 const result = await next(e)
82 void refresh($)
83 return result
84 })
85
86 // Бид или ветка могли смениться за ход — перечитываем в фоне
87 on('turn.complete', async ($, e, next) => {
88 const result = await next(e)
89 void refresh($)
90 return result
91 })
92
93 // Строка задачи — первой строкой панели flightdeck: над полем ввода она теснила подсказки next-steps
94 on('ui.render', { component: 'Pane', requestId: FLIGHTDECK_PANE }, async ($, e, next) => {
95 const inner = await next(e)
96 const data = await read($, info)
97 if (!data || (!data.branch && data.beads.length === 0)) return inner
98
99 const { Box, Text } = $.ui.resolve(e)
100 const [main, ...rest] = data.beads
101 const where = [data.worktree, data.branch].filter(Boolean).join(' · ')
102
103 return (
104 <Box flexDirection="column" alignItems="stretch" flexGrow={1}>
105 <Box flexDirection="row" gap={1} minWidth={0}>
106 {main ? (
107 <>
108 <Text color="claude">●</Text>
109 <Text bold>{main.id}</Text>
110 <Text wrap="truncate-end">{clip(main.title, TITLE_CHARS)}</Text>
111 </>
112 ) : (
113 <Text dimColor>нет задачи в работе</Text>
114 )}
115 {main && rest.length > 0 ? <Text dimColor>+{rest.length}</Text> : null}
116 {where ? <Text dimColor>{where}</Text> : null}
117 {main ? (
118 main.source ? (
119 <Text dimColor>→ {main.source}</Text>
120 ) : (
121 <>
122 <Text dimColor>→</Text>
123 <Text color="warning">нет src/</Text>
124 </>
125 )
126 ) : null}
127 </Box>
128 <Box>{inner}</Box>
129 </Box>
130 )
131 })
132}
133types/index.d.ts 15 lines1export type Bead = { id: string; title: string; source: string | null }
2
3export type BeadBandInfo = {
4 /** Имя рабочего дерева (папка корня репозитория) */
5 worktree: string | null
6 branch: string | null
7 beads: Bead[]
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'bead-band': { info: BeadBandInfo | null }
13 }
14}
15