Opinionated framed status band for Claude Code with git, model, context, and the branch's PR review state

Opinionated framed status band for Claude Code with git, model, context, and the branch's PR review state. It takes the place of a command status line and tidies the hint line under the prompt.


This is not ccstatusline or any of the other status line builders. There are no widgets to pick, no themes and no layout options. It draws one fixed layout in one fixed palette, the way its author wanted it. The only setting is whether the PR and CI marks use Nerd Font glyphs. If you want a different look, fork it: the whole thing is one file, hooks/register.tsx.
First row, the same fields a typical status line has:
main for the main checkout), the branch, and staged plus unstaged line counts from git diff --shortstat HEAD(from …) when the session started somewhere else. When the row does not fit, the path moves to its own line.Second row, only when the branch has a pull request, and only what needs attention:
| Shown when | Text |
|---|---|
| GitHub reports it mergeable and no changes are requested | ready to merge (the review state is left out) |
| Review required | review required |
| Changes requested | changes requested |
| CI passed, running, failed | CI ✓, CI ●, CI ✗ 2 failed |
| Merge conflicts, branch behind its base | conflicts, behind |
| Unresolved review threads, when there are any | 3 unresolved |
| Approvals, when there are any | 2 approvals |
| Draft, merged, closed | draft, merged, closed |
| GitHub is still working out the PR after a push, nothing else to show | checking |
PR #2899 is a link to the pull request.
When space is short (the tasks list open under the prompt, a short terminal), the band drops the frame first, then puts the path on the first line, then folds everything onto one line, instead of scrolling behind an n more row.
Hint line under the prompt: the band replaces Claude Code's own PR badge, so that badge is removed from the hint line, together with (shift+tab to cycle).
Git and context refresh every 10 seconds, after each turn and after Bash or file edits. The PR row refreshes every minute, on a change of directory or branch, and right after a gh pr, gh api, git push, git checkout or git switch command. While checks run or GitHub has not yet worked out the merge state, it looks again every 15 seconds.
Mods reach only some of Claude Code's interface. These stay as Claude Code draws them:
⏵⏵ auto mode on)/rc indicatorImage in clipboard), which keeps its line even when emptygit, and gh logged in for the PR rownerdFont: a Nerd Font in your terminal (the glyphs are GitHub octicons)/plugin marketplace add nnemirovsky/cc-status-band
/plugin install status-band@status-band
Then run the setup command once:
/status-band-setup
It asks three things and changes only what you agree to, skipping what is already done:
nerdFontFORCE_HYPERLINK=1, when it is not set (see Links that print the URL); it applies after a restartstatusLineDisabled, so renaming it back restores itIt writes only ~/.claude/settings.json, and the nerdFont option through Claude Code's own config.
| Option | Default | |
|---|---|---|
nerdFont | false | PR and CI marks as octicons (` ) instead of PR ✓ ● ✗` |
Set it from /config, or in ~/.claude/settings.json:
"pluginConfigs": { "status-band@status-band": { "options": { "nerdFont": true } } }
The PR number is an OSC 8 hyperlink. Claude Code decides whether your terminal supports those from its environment, and does not recognise every terminal (agterm, for one). If you see the URL printed after PR #2899, set FORCE_HYPERLINK=1 in the env block of ~/.claude/settings.json, or let /status-band-setup do it.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
Claude Code watches a --plugin-dir folder and reloads the mod on each save.
MIT
hooks/register.tsx 543 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { BandCi, BandGit, BandPr, BandSnap } from '../types'
5
6// A framed band above the prompt, in place of a command status line:
7//
8// ╭──────────────────────────────────────────────────────────────────────────╮
9// │ 𖠰 main ⎇ fix/thing (+12,-3) ✳ Opus 5.5 high 296k/30% ⌘ ~/dev/app │
10// │ PR #2899 review required CI ✓ 1 approval │
11// ╰──────────────────────────────────────────────────────────────────────────╯
12//
13// It also trims the hint line under the prompt: "(shift+tab to cycle)" goes,
14// the engine's mode pill in front of it stays (no hook reaches that pill).
15
16const snap = atom({ plugin: 'status-band', key: 'snap' } as const, null)
17const pr = atom({ plugin: 'status-band', key: 'pr' } as const, null)
18const effort = atom({ plugin: 'status-band', key: 'effort' } as const, null)
19
20// ccstatusline's 256-color palette, as hex: blue 26, brightMagenta 140,
21// yellow 178, cyan 30, magenta 96, green 70, grey 245, red 160.
22const C = {
23 worktree: '#005fd7',
24 branch: '#af87d7',
25 changes: '#d7af00',
26 model: '#008787',
27 effort: '#875f87',
28 path: '#5faf00',
29 grey: '#8a8a8a',
30 warn: '#d7af00',
31 bad: '#d70000',
32 good: '#5faf00',
33 // Claude Code's own highlight of a selected footer pill.
34 selected: '#5dcdce',
35 onSelected: '#282828',
36}
37
38// What the engine puts in the hint line that the band shows instead: the
39// cycle hint beside the mode pill, and the PR badge (or its gh-missing note).
40// The PR and CI glyphs: plain Unicode, or GitHub octicons from a Nerd Font
41// (one cell each) with the nerdFont option on.
42const PLAIN = { pr: 'PR', ciPass: '✓', ciPending: '●', ciFail: '✗' }
43const NERD = { pr: '\uf407', ciPass: '\uf42e', ciPending: '\uf43a', ciFail: '\uf467' }
44
45const HINT_DROP = /\(shift\+tab to cycle\)|\bPR #\d+\b|install gh for PR status/g
46const HINT_DROP_TEST = new RegExp(HINT_DROP.source)
47
48function trimHint(hint: string): string {
49 return hint
50 .replace(HINT_DROP, '')
51 .split('·')
52 .map(part => part.trim())
53 .filter(part => part.length > 0)
54 .join(' · ')
55}
56
57function worktreeName(gitDir: string): string {
58 const linked = gitDir.match(/\.git\/worktrees\/(.+)$/) ?? gitDir.match(/\/worktrees\/(.+)$/)
59 return linked?.[1] ?? 'main'
60}
61
62function shortstat(text: string): { ins: number; del: number } {
63 const ins = text.match(/(\d+) insertions?\(\+\)/)
64 const del = text.match(/(\d+) deletions?\(-\)/)
65 return { ins: Number(ins?.[1] ?? 0), del: Number(del?.[1] ?? 0) }
66}
67
68function modelName(id: string): string {
69 const m = id.match(/claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/)
70 if (m?.[1] && m[2]) {
71 const family = m[1].charAt(0).toUpperCase() + m[1].slice(1)
72 return `${family} ${m[2]}${m[3] ? `.${m[3]}` : ''}`
73 }
74 return id.replace(/\s*\([^)]*context\)$/i, '')
75}
76
77function tilde(path: string, home: string): string {
78 if (home && path === home) return '~'
79 if (home && path.startsWith(`${home}/`)) return `~${path.slice(home.length)}`
80 return path
81}
82
83function tokens(n: number): string {
84 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
85 if (n >= 1000) return `${Math.round(n / 1000)}k`
86 return String(n)
87}
88
89async function readGit($: EngineInterface, cwd: string): Promise<BandGit | null> {
90 const env = { GIT_OPTIONAL_LOCKS: '0' }
91 const head = await $.process.run(
92 ['git', '-C', cwd, 'rev-parse', '--is-inside-work-tree', '--git-dir', '--abbrev-ref', 'HEAD'],
93 { env, timeoutMs: 5000 },
94 )
95 const [inTree, gitDir = '', ref = ''] = head.stdout.trim().split('\n')
96 if (head.exitCode !== 0 || inTree !== 'true') return null
97 let branch = ref
98 if (branch === 'HEAD') {
99 const sha = await $.process.run(['git', '-C', cwd, 'rev-parse', '--short', 'HEAD'], { env, timeoutMs: 5000 })
100 branch = sha.stdout.trim()
101 }
102 const diff = await $.process.run(['git', '-C', cwd, 'diff', '--shortstat', 'HEAD'], { env, timeoutMs: 5000 })
103 return { worktree: worktreeName(gitDir), branch, ...shortstat(diff.stdout) }
104}
105
106async function effortFallback($: EngineInterface, model: string): Promise<string> {
107 const settings = await $.settings.read()
108 const perModel = (settings.modelSettings ?? {}) as Record<string, { effortLevel?: string }>
109 // Keys are canonical names ("claude-opus-5-5"); the session's id may carry
110 // "[1m]" or a date, so take the longest key the id starts with.
111 const id = model.replace(/\[[^\]]*\]$/, '')
112 const key = Object.keys(perModel)
113 .filter(k => id === k || id.startsWith(`${k}-`))
114 .sort((a, b) => b.length - a.length)[0]
115 const level = (key ? perModel[key]?.effortLevel : undefined) ?? (settings.effortLevel as string | undefined)
116 return level ?? 'default'
117}
118
119// The directory and branch the PR row was last looked up for: a change (a
120// `cd`, a checkout, a worktree) looks the PR up again at once instead of
121// waiting for the minute timer.
122let prKey = ''
123let prRecheck: Timer | undefined
124
125async function refresh($: EngineInterface): Promise<void> {
126 const [cwd, root, model, usage, home] = await Promise.all([
127 $.session.cwd(),
128 $.session.root(),
129 $.session.model(),
130 $.session.usage(),
131 $.env.get('HOME'),
132 ])
133 const git = await readGit($, cwd)
134 const seen = await read($, effort)
135 const next: BandSnap = {
136 git,
137 model: modelName(model),
138 effort: seen ?? (await effortFallback($, model)),
139 ctxTokens: usage.context.tokens ?? null,
140 ctxPercent: usage.context.percent ?? null,
141 cwd,
142 root,
143 home: home ?? '',
144 }
145 await update($, snap, () => next)
146 const key = `${cwd}\n${git?.branch ?? ''}`
147 if (key !== prKey) {
148 prKey = key
149 await refreshPr($)
150 }
151}
152
153type Check = { __typename?: string; status?: string; conclusion?: string; state?: string }
154
155function ciOf(checks: Check[]): { ci: BandCi; failed: number } {
156 if (checks.length === 0) return { ci: 'none', failed: 0 }
157 const bad = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE'])
158 let failed = 0
159 let pending = 0
160 for (const c of checks) {
161 if (c.__typename === 'StatusContext') {
162 if (bad.has(c.state ?? '')) failed++
163 else if (c.state === 'PENDING' || c.state === 'EXPECTED') pending++
164 } else if (c.status !== 'COMPLETED') pending++
165 else if (bad.has(c.conclusion ?? '')) failed++
166 }
167 return { ci: failed > 0 ? 'fail' : pending > 0 ? 'pending' : 'pass', failed }
168}
169
170const THREADS_QUERY =
171 'query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100){nodes{isResolved}}}}}'
172
173async function refreshPr($: EngineInterface): Promise<void> {
174 const cwd = await $.session.cwd()
175 const view = await $.process.run(
176 ['gh', 'pr', 'view', '--json', 'number,url,state,isDraft,mergeStateStatus,reviewDecision,statusCheckRollup,latestReviews'],
177 { cwd, timeoutMs: 20000 },
178 )
179 if (view.exitCode !== 0) {
180 await update($, pr, () => null)
181 return
182 }
183 const data = JSON.parse(view.stdout) as {
184 number: number
185 url: string
186 state: string
187 isDraft?: boolean
188 mergeStateStatus?: string
189 reviewDecision: string
190 statusCheckRollup?: Check[]
191 latestReviews?: { state: string }[]
192 }
193 const { ci, failed } = ciOf(data.statusCheckRollup ?? [])
194 const approvals = (data.latestReviews ?? []).filter(r => r.state === 'APPROVED').length
195
196 let unresolved = 0
197 const repo = data.url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\//)
198 if (repo?.[1] && repo[2]) {
199 const threads = await $.process.run(
200 [
201 'gh', 'api', 'graphql',
202 '-f', `query=${THREADS_QUERY}`,
203 '-F', `owner=${repo[1]}`, '-F', `name=${repo[2]}`, '-F', `number=${data.number}`,
204 '--jq', '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved | not)] | length',
205 ],
206 { cwd, timeoutMs: 20000 },
207 )
208 if (threads.exitCode === 0) unresolved = Number(threads.stdout.trim()) || 0
209 }
210
211 const next: BandPr = {
212 number: data.number,
213 url: data.url,
214 state: data.state,
215 reviewDecision: data.reviewDecision ?? '',
216 ci,
217 ciFailed: failed,
218 unresolved,
219 approvals,
220 isDraft: data.isDraft ?? false,
221 mergeState: data.mergeStateStatus ?? '',
222 }
223 await update($, pr, () => next)
224 // Right after a push GitHub has no checks yet and reports the merge state as
225 // UNKNOWN; look again soon rather than waiting out the minute timer.
226 prRecheck?.cancel()
227 prRecheck = undefined
228 if (next.state === 'OPEN' && (next.mergeState === 'UNKNOWN' || next.ci === 'pending')) {
229 prRecheck = $.clock.after(15_000, () => void safely(refreshPr($)))
230 }
231}
232
233async function safely(work: Promise<void>): Promise<void> {
234 try {
235 await work
236 } catch {
237 // A failed git or gh call keeps the last values on screen.
238 }
239}
240
241// /status-band-setup: the three things a fresh install usually needs, each
242// asked first and skipped when already done.
243const SETUP_COMMAND = 'status-band-setup'
244
245type Settings = Record<string, unknown> & { env?: Record<string, string>; statusLine?: unknown }
246
247async function readUserSettings($: EngineInterface, path: string): Promise<Settings> {
248 try {
249 return JSON.parse(await $.fs.read(path)) as Settings
250 } catch {
251 return {}
252 }
253}
254
255async function setup($: EngineInterface): Promise<string> {
256 const home = (await $.env.get('HOME')) ?? ''
257 const path = `${home}/.claude/settings.json`
258 const settings = await readUserSettings($, path)
259 const done: string[] = []
260 let isChanged = false
261
262 const glyphs = await $.ui.ask(`Do you see a pull-request icon here: ?`, {
263 header: 'Nerd Font',
264 options: ['Yes, an icon', 'No, a box or nothing'],
265 })
266 const nerdFont = glyphs.startsWith('Yes')
267 const row = (await $.config.list()).find(r => r.key.endsWith('.nerdFont') && r.key.includes('status-band'))
268 if (row && row.value !== nerdFont) {
269 const set = await $.config.set({ key: row.key, value: nerdFont })
270 if (set.deny === undefined) done.push(`nerdFont ${nerdFont ? 'on' : 'off'}`)
271 }
272
273 const forced = settings.env?.FORCE_HYPERLINK ?? (await $.env.get('FORCE_HYPERLINK'))
274 if (forced === undefined) {
275 const terminal = (await $.env.get('TERM_PROGRAM')) ?? 'unknown'
276 const links = await $.ui.ask(
277 `Claude Code makes links clickable only in terminals it recognises (yours: ${terminal}). Turn on FORCE_HYPERLINK? Yes if your terminal supports OSC 8 links but PR numbers print their URL.`,
278 { header: 'Links', options: ['Turn it on', 'Leave it off'] },
279 )
280 if (links.startsWith('Turn')) {
281 settings.env = { ...settings.env, FORCE_HYPERLINK: '1' }
282 isChanged = true
283 done.push('FORCE_HYPERLINK=1 (applies after a restart)')
284 }
285 }
286
287 if (settings.statusLine !== undefined) {
288 const line = await $.ui.ask(
289 'Turn off your status line? It draws below the band. It is kept as statusLineDisabled, so you can rename it back.',
290 { header: 'Status line', options: ['Turn it off', 'Keep it'] },
291 )
292 if (line.startsWith('Turn')) {
293 settings.statusLineDisabled = settings.statusLine
294 delete settings.statusLine
295 isChanged = true
296 done.push('status line off (kept as statusLineDisabled)')
297 }
298 }
299
300 if (isChanged) await $.fs.write(path, `${JSON.stringify(settings, null, 2)}\n`)
301 return done.length > 0 ? `${done.join('; ')}.` : 'Nothing to change.'
302}
303
304export const register: Register = (on, options) => {
305 const G = options.nerdFont === true ? NERD : PLAIN
306
307 on('session.start', async ($, e, next) => {
308 await $.command.register({
309 name: SETUP_COMMAND,
310 description: 'Set up status-band: Nerd Font glyphs, terminal links, your old status line',
311 })
312 void safely(refresh($))
313 void safely(refreshPr($))
314 $.clock.every(10_000, () => void safely(refresh($)))
315 $.clock.every(60_000, () => void safely(refreshPr($)))
316 return next(e)
317 })
318
319 // The effort the main loop actually asks for, as the script's `effort.level`.
320 on('turn.step', async function* ($, e, next) {
321 if (e.agentId === undefined && e.effort !== undefined) {
322 const level = String(e.effort)
323 if ((await read($, effort)) !== level) await update($, effort, () => level)
324 }
325 return yield* next(e)
326 })
327
328 on('command.run', { command: SETUP_COMMAND }, async $ => {
329 try {
330 return { text: await setup($) }
331 } catch {
332 return { text: 'Setup cancelled, nothing changed.' }
333 }
334 })
335
336 on('turn.complete', async ($, e, next) => {
337 const done = await next(e)
338 if (e.agentId === undefined) void safely(refresh($))
339 return done
340 })
341
342 on('tool.call', async ($, e, next) => {
343 const ran = await next(e)
344 if (e.agentId !== undefined) return ran
345 if (e.tool === 'Bash') {
346 void safely(refresh($))
347 if (/\bgh\s+(pr|api)\b|\bgit\s+(push|checkout|switch)\b/.test(e.command)) void safely(refreshPr($))
348 } else if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'EnterWorktree' || e.tool === 'ExitWorktree') {
349 void safely(refresh($))
350 }
351 return ran
352 })
353
354 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
355 // Nothing to take out: the engine's own line, pills live.
356 if (!HINT_DROP_TEST.test(e.props.hint)) return next(e)
357 // A rewritten hint is drawn as plain text, so a footer selection (↓ onto
358 // `1 shell`) would lose its highlight. While the tasks pill is selected the
359 // hint ends in `Enter to view tasks`: draw the line ourselves, that pill
360 // highlighted as the engine does it. Any other selection gets the engine's
361 // line back whole.
362 const selected = e.props.hint.match(/\bEnter to view (\w+)/)?.[1]
363 if (selected !== undefined || /\bEnter to \w/.test(e.props.hint)) {
364 const parts = trimHint(e.props.hint).split(' · ')
365 const at = selected === 'tasks' ? parts.findIndex(p => /^\d+ (shells?|tasks?|agents?|background)/.test(p)) : -1
366 if (at < 0) return next(e)
367 const { Text } = $.ui.resolve(e)
368 return (
369 <Text wrap="truncate-end">
370 {parts.flatMap((part, i) => [
371 ...(i > 0 ? [<Text dimColor> · </Text>] : []),
372 i === at ? (
373 <Text backgroundColor={C.selected} color={C.onSelected}>
374 {part}
375 </Text>
376 ) : (
377 <Text dimColor>{part}</Text>
378 ),
379 ])}
380 </Text>
381 )
382 }
383 const hint = trimHint(e.props.hint)
384 if (hint.length > 0) return next({ ...e, props: { ...e.props, hint } })
385 // Nothing left: the engine still joins its mode pill to a drawn hint with
386 // " · ", so paint blanks over that separator instead of leaving it dangling.
387 const { Box, Text } = $.ui.resolve(e)
388 return (
389 <Box>
390 <Box position="absolute" left={-3}>
391 <Text>{' '}</Text>
392 </Box>
393 </Box>
394 )
395 })
396
397 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
398 if (e.props.hasSurvey) return next(e)
399 const s = await read($, snap)
400 if (s === null) return next(e)
401 const p = await read($, pr)
402 const { Box, Text, Link } = $.ui.resolve(e)
403
404 const ctxColor = s.ctxPercent === null ? C.grey : s.ctxPercent >= 85 ? C.bad : s.ctxPercent >= 60 ? C.warn : C.grey
405 const cwd = tilde(s.cwd, s.home)
406 const fromRoot = s.cwd !== s.root ? ` (from ${tilde(s.root, s.home)})` : ''
407
408 const gitText = s.git ? `𖠰 ${s.git.worktree} ⎇ ${s.git.branch} (+${s.git.ins},-${s.git.del})` : '𖠰 no git'
409 const ctxText = s.ctxTokens !== null && s.ctxPercent !== null ? ` ${tokens(s.ctxTokens)}/${s.ctxPercent}%` : ''
410 const modelText = `✳ ${s.model} ${s.effort}${ctxText}`
411 const pathLong = `⌘ ${cwd}${fromRoot}`
412 const width = e.props.bodyColumns - 4 // the border and its padding
413 const oneLine = `${gitText} ${modelText} ${pathLong}`.length <= width
414 const path = oneLine || pathLong.length <= width ? pathLong : `⌘ ${cwd}`
415
416 const gitRow = s.git ? (
417 <Text>
418 <Text color={C.worktree}>𖠰 {s.git.worktree}</Text> <Text color={C.branch}>⎇ {s.git.branch}</Text>{' '}
419 <Text color={C.changes}>(+{s.git.ins},-{s.git.del})</Text>
420 </Text>
421 ) : (
422 <Text color={C.worktree}>𖠰 no git</Text>
423 )
424 const modelRow = (
425 <Text>
426 <Text color={C.model}>✳ {s.model}</Text> <Text color={C.effort}>{s.effort}</Text>
427 {ctxText && <Text color={ctxColor}>{ctxText}</Text>}
428 </Text>
429 )
430 const pathRow = <Text color={C.path}>{path}</Text>
431
432 // The PR row: only what needs attention.
433 const parts: { text: string; color: string }[] = []
434 if (p !== null) {
435 const ready = p.state === 'OPEN' && !p.isDraft && p.mergeState === 'CLEAN' && p.reviewDecision !== 'CHANGES_REQUESTED'
436 if (p.state !== 'OPEN') parts.push({ text: p.state.toLowerCase(), color: C.grey })
437 else if (p.isDraft) parts.push({ text: 'draft', color: C.grey })
438 if (!ready && p.state === 'OPEN') {
439 if (p.reviewDecision === 'REVIEW_REQUIRED') parts.push({ text: 'review required', color: C.warn })
440 if (p.reviewDecision === 'CHANGES_REQUESTED') parts.push({ text: 'changes requested', color: C.bad })
441 if (p.mergeState === 'DIRTY') parts.push({ text: 'conflicts', color: C.bad })
442 if (p.mergeState === 'BEHIND') parts.push({ text: 'behind', color: C.warn })
443 }
444 // CI shows on every open PR, ready or not, so a passing run stays visible.
445 if (p.state === 'OPEN') {
446 if (p.ci === 'fail') parts.push({ text: `CI ${G.ciFail} ${p.ciFailed} failed`, color: C.bad })
447 if (p.ci === 'pending') parts.push({ text: `CI ${G.ciPending}`, color: C.warn })
448 if (p.ci === 'pass') parts.push({ text: `CI ${G.ciPass}`, color: C.good })
449 }
450 if (p.unresolved > 0) parts.push({ text: `${p.unresolved} unresolved`, color: C.warn })
451 if (p.approvals > 0) parts.push({ text: `${p.approvals} approval${p.approvals === 1 ? '' : 's'}`, color: C.good })
452 if (parts.length === 0 && p.state === 'OPEN' && p.mergeState === 'UNKNOWN') parts.push({ text: 'checking', color: C.grey })
453 // Last, so the row reads as its verdict.
454 if (ready) parts.push({ text: 'ready to merge', color: C.good })
455 }
456
457 const prRow =
458 p === null ? null : (
459 <Text wrap="truncate">
460 <Link href={p.url}>
461 <Text underline>{`${G.pr} #${p.number}`}</Text>
462 </Link>
463 {parts.map(part => (
464 <Text color={part.color}>{` ${part.text}`}</Text>
465 ))}
466 </Text>
467 )
468
469 // Framed like the engine's own panels when the bottom slot has room: a
470 // round, dim border with a cell of padding each side. The slot shrinks
471 // when other things share it (the tasks list under the prompt, ctrl+t), and
472 // a band taller than `maxRows` would scroll behind an "n more" row, so it
473 // sheds what it can in order: the frame, then the path's own row (the path
474 // joins the first line, cut at the edge), then the PR row (folded onto the
475 // same single line).
476 const lines = (oneLine ? 1 : 2) + (prRow ? 1 : 0)
477 const room = e.props.maxRows
478 if (lines + 2 <= room) {
479 return (
480 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
481 {oneLine ? (
482 <Text wrap="truncate">
483 {gitRow} {modelRow} {pathRow}
484 </Text>
485 ) : (
486 <Box flexDirection="column">
487 <Text wrap="truncate">
488 {gitRow} {modelRow}
489 </Text>
490 <Text wrap="truncate">{pathRow}</Text>
491 </Box>
492 )}
493 {prRow}
494 </Box>
495 )
496 }
497 const headLine = (
498 <Text wrap="truncate">
499 {gitRow} {modelRow} <Text color={C.path}>{pathLong}</Text>
500 </Text>
501 )
502 if (lines <= room && !oneLine) {
503 return (
504 <Box flexDirection="column" paddingX={1}>
505 <Text wrap="truncate">
506 {gitRow} {modelRow}
507 </Text>
508 <Text wrap="truncate">{pathRow}</Text>
509 {prRow}
510 </Box>
511 )
512 }
513 if ((prRow ? 2 : 1) <= room) {
514 return (
515 <Box flexDirection="column" paddingX={1}>
516 {headLine}
517 {prRow}
518 </Box>
519 )
520 }
521 return (
522 <Box paddingX={1}>
523 <Text wrap="truncate">
524 {gitRow} {modelRow}
525 {p !== null && (
526 <Text>
527 {' '}
528 <Link href={p.url}>
529 <Text underline>{`${G.pr} #${p.number}`}</Text>
530 </Link>
531 {parts.map(part => (
532 <Text color={part.color}>{` ${part.text}`}</Text>
533 ))}
534 </Text>
535 )}
536 {' '}
537 <Text color={C.path}>{pathLong}</Text>
538 </Text>
539 </Box>
540 )
541 })
542}
543types/index.d.ts 34 lines1export type BandGit = { worktree: string; branch: string; ins: number; del: number }
2
3export type BandCi = 'pass' | 'fail' | 'pending' | 'none'
4
5export type BandPr = {
6 number: number
7 url: string
8 state: string
9 reviewDecision: string
10 ci: BandCi
11 ciFailed: number
12 unresolved: number
13 approvals: number
14 isDraft: boolean
15 mergeState: string
16}
17
18export type BandSnap = {
19 git: BandGit | null
20 model: string
21 effort: string
22 ctxTokens: number | null
23 ctxPercent: number | null
24 cwd: string
25 root: string
26 home: string
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'status-band': { snap: BandSnap | null; pr: BandPr | null; effort: string | null }
32 }
33}
34