SLOPSHOPPER

status-band

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

newbandspinnerguardcommandprocess
v0.2.3MITupdated 2026-10-09nnemirovsky/cc-status-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · status-band
› fix the failing auth test and add an audit log call ⏺ 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 › /status-band-setup ⎿ status-band: FORCE_HYPERLINK=1 (applies after a restart). ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 𖠰 no git ✳ Opus 5.5 default 97k/49% ⌘ /work/app │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 𖠰 no git ✳ Opus 5.5 default 97k/49% ⌘ /work/app │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

Status Band

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.

Status Band while the PR's checks run

Status Band once the PR is ready to merge

It is opinionated, not configurable

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.

What it shows

First row, the same fields a typical status line has:

  • the worktree (main for the main checkout), the branch, and staged plus unstaged line counts from git diff --shortstat HEAD
  • the model and the effort level its requests actually use
  • tokens in the context window and the share used: grey, yellow from 60%, red from 85%
  • the working directory, with (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 whenText
GitHub reports it mergeable and no changes are requestedready to merge (the review state is left out)
Review requiredreview required
Changes requestedchanges requested
CI passed, running, failedCI ✓, CI ●, CI ✗ 2 failed
Merge conflicts, branch behind its baseconflicts, behind
Unresolved review threads, when there are any3 unresolved
Approvals, when there are any2 approvals
Draft, merged, closeddraft, merged, closed
GitHub is still working out the PR after a push, nothing else to showchecking

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.

What it cannot change

Mods reach only some of Claude Code's interface. These stay as Claude Code draws them:

  • the permission mode pill (⏵⏵ auto mode on)
  • the /rc indicator
  • the notification row above the prompt (the usage-limit warning, Image in clipboard), which keeps its line even when empty

Requirements

  • Claude Code 2.1.289 or later, with function-hook mods available to your account
  • git, and gh logged in for the PR row
  • For nerdFont: a Nerd Font in your terminal (the glyphs are GitHub octicons)

Install

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

  • whether you see a Nerd Font icon, to set nerdFont
  • whether to set FORCE_HYPERLINK=1, when it is not set (see Links that print the URL); it applies after a restart
  • whether to turn off your status line, if you have one, since it would draw under the band; it is kept as statusLineDisabled, so renaming it back restores it

It writes only ~/.claude/settings.json, and the nerdFont option through Claude Code's own config.

Settings

OptionDefault
nerdFontfalsePR 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 } } }

Links that print the URL

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.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

Claude Code watches a --plugin-dir folder and reloads the mod on each save.

License

MIT

Source 2 files
hooks/register.tsx 543 lines
1import { 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}
543
types/index.d.ts 34 lines
1export 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