SLOPSHOPPER

dev-band

tmux-style band above the prompt: git, context, 5h/7d limits, cache warmth; unfolds a sitrep after 30 idle minutes

newbandcommandtoastpromptmodel
v0.1.0MITupdated 2026-10-10bilal-psd/skills/mods/dev-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dev-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 › /sitrep-card ⎿ dev-band: Writing the sitrep into the band. ⎇ HEAD ±1 5h ▕███████▌░░░░░░░░░░░░░░░░▏ 31% NaNdNaNh cache ● warm 60m ── sitrep: writing… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⎇ HEAD ±1 5h ▕███████▌░░░░░░░░░░░░░░░░▏ 31% NaNdNaNh cache ● warm 60m ── sitrep: writing…
README

skills

My Claude Code mods and skills, as a plugin marketplace.

/plugin marketplace add bilal-psd/skills
/plugin install dev-band@bilal-skills

What's here

Mods live in mods/, skills in skills/.

PluginKindWhat it does
dev-bandmodA band above the prompt: git branch and changes, 5h/7d usage limits, prompt-cache warmth. Warns when context gets big (c compact, x clear, l later). After 30 idle minutes it unfolds a sitrep of the session; /sitrep-card writes one now.
question-cardmodAdds ask_many: the built-in question dialog for 5+ questions, asked one after another.
sitrepskill/sitrep: a 12-line status of the session — now, last step, needs you, next.
wrapskill/wrap: updates the work plan and AGENTS.md so a fresh session can pick up where this one left off.

Install any of them by name: /plugin install <name>@bilal-skills. Run /reload-plugins if a new command doesn't show up.

Notes

  • dev-band writes its sitrep from your own ~/.claude/skills/sitrep/SKILL.md if you have one, otherwise from the copy it ships.
  • dev-band's usage tracking is adapted from Vosssa/claude-usage-band (MIT, see mods/dev-band/LICENSE-usage-band).

License

MIT

Source 2 files
hooks/register.tsx 653 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type { Git, Sitrep, Snap } from '../types'
5
6// Usage tracking (limits, context, cache TTL learning) adapted from
7// Vosssa/claude-usage-band (MIT, see LICENSE-usage-band). Drawing and the sitrep are ours.
8
9const TTL_SUB = 60 * 60_000
10const TTL_API = 5 * 60_000
11const MIN_PROMPT = 20_000
12const NAG = 400_000
13const NAG_HARD = 600_000
14const IDLE_MS = 30 * 60_000
15
16const snap = atom({ plugin: 'dev-band', key: 'snap' } as const, { ctxWin: 0, sub: false, lastAt: 0, now: 0 } as Snap)
17const git = atom({ plugin: 'dev-band', key: 'git' } as const, null as Git | null)
18const sitrep = atom({ plugin: 'dev-band', key: 'sitrep' } as const, null as Sitrep | null)
19const isBusy = atom({ plugin: 'dev-band', key: 'isBusy' } as const, false)
20const isPinned = atom({ plugin: 'dev-band', key: 'isPinned' } as const, false)
21// Kept in session state, not a timer, so a mod reload or /reload-plugins can't lose the countdown.
22const idleSince = atom({ plugin: 'dev-band', key: 'idleSince' } as const, 0)
23const sitrepFor = atom({ plugin: 'dev-band', key: 'sitrepFor' } as const, 0)
24
25// GitHub-dark-ish terminal palette.
26const OK = '#3fb950', WARN = '#d29922', BAD = '#f85149', WARM = '#f0883e', COOL = '#58a6ff', ACCENT = '#bc8cff'
27const tone = (p: number) => (p >= 90 ? BAD : p >= 70 ? WARN : OK)
28
29const k = (n: number) => (n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : `${Math.round(n / 1e3)}k`)
30const left = (ms: number) => {
31  const m = Math.max(0, Math.ceil(ms / 60_000))
32  if (m < 60) return `${m}m`
33  const h = Math.floor(m / 60)
34  return h < 48 ? `${h}h${String(m % 60).padStart(2, '0')}m` : `${Math.floor(h / 24)}d${h % 24}h`
35}
36const hhmm = (ms: number) => {
37  const d = new Date(ms)
38  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
39}
40
41// Keep the start and the end, which carry the meaning: feat/add-us…-band
42const mid = (t: string, max: number) => {
43  const c = [...t]
44  if (c.length <= max) return t
45  const head = Math.ceil((max - 1) / 2)
46  return `${c.slice(0, head).join('')}…${c.slice(c.length - (max - 1 - head)).join('')}`
47}
48// A long path keeps its last two folders: ~/dev/lifeos/apps/web → …/apps/web
49const shortDir = (d: string, max: number) => {
50  if ([...d].length <= max) return d
51  const parts = d.split('/').filter(Boolean)
52  return mid(`…/${parts.slice(-2).join('/')}`, max)
53}
54// Greedy word wrap to a number of monospace cells.
55const wrap = (t: string, n: number): string[] => {
56  const out: string[] = []
57  let line = ''
58  for (const w of t.split(/\s+/).filter(Boolean)) {
59    if (!line) line = w
60    else if ([...line].length + 1 + [...w].length <= n) line += ` ${w}`
61    else {
62      out.push(line)
63      line = w
64    }
65  }
66  if (line) out.push(line)
67  return out.length ? out : ['']
68}
69
70const MAX_DIR = 24
71const MAX_BRANCH = 28
72
73// Eighth-block bar: ▕█████▋░░░░▏
74const bar = (frac: number, width: number) => {
75  const f = Math.max(0, Math.min(1, frac)) * width
76  const full = Math.floor(f)
77  const part = ' ▏▎▍▌▋▊▉'[Math.round((f - full) * 8)]
78  const fill = '█'.repeat(full) + (full < width && part !== ' ' ? part : '')
79  return fill + '░'.repeat(Math.max(0, width - [...fill].length))
80}
81
82// ---- desktop drawing: monospace SVG segments ----
83// The desktop app sets Text in a proportional font, so block characters break apart.
84// Each segment is drawn as its own small SVG in a monospace face instead.
85
86type Run = { text: string; color?: string; dim?: boolean; bold?: boolean } | { bar: number; color: string; width: number } | { dot: string }
87const FS = 12.5
88// Measured in the desktop app: its monospace face advances ~0.65em, so size for 0.66.
89const CW = FS * 0.66
90const H = 20
91const esc = (t: string) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
92
93const seg = (runs: Run[]) => {
94  let x = 0
95  const parts: string[] = []
96  for (const r of runs) {
97    if ('bar' in r) {
98      const f = Math.max(0, Math.min(1, r.bar))
99      parts.push(`<rect x="${x}" y="${H / 2 - 3}" width="${r.width}" height="6" rx="3" class="track"/>`)
100      if (f > 0) parts.push(`<rect x="${x}" y="${H / 2 - 3}" width="${Math.max(6, f * r.width).toFixed(1)}" height="6" rx="3" fill="${r.color}"/>`)
101      x += r.width + CW
102    } else if ('dot' in r) {
103      parts.push(`<circle cx="${x + 4}" cy="${H / 2}" r="3.5" fill="${r.dot}"/>`)
104      x += 8 + CW
105    } else {
106      const cls = r.dim ? 'dim' : r.color ? '' : 'fg'
107      const fill = r.color && !r.dim ? ` fill="${r.color}"` : ''
108      parts.push(`<text x="${x}" y="${H / 2 + FS * 0.36}" class="${cls}"${fill}${r.bold ? ' font-weight="600"' : ''}>${esc(r.text)}</text>`)
109      x += r.text.length * CW
110    }
111  }
112  const w = Math.ceil(x)
113  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${H}" viewBox="0 0 ${w} ${H}">
114<style>text{font:${FS}px ui-monospace,'SF Mono',Menlo,monospace;white-space:pre}.fg{fill:#24292f}.dim{fill:#6e7781}.track{fill:#000;fill-opacity:.1}
115@media (prefers-color-scheme:dark){.fg{fill:#e6edf3}.dim{fill:#8b949e}.track{fill:#fff;fill-opacity:.12}}</style>
116${parts.join('')}</svg>`
117  return { svg, w }
118}
119
120// ---- usage (from usage-band) ----
121
122type Limits = readonly SessionRateLimit[]
123const withLimits = (s: Snap, rl: Limits): Snap => {
124  if (!rl.length) return s
125  const h5 = rl.find(r => r.kind === 'five_hour')
126  const wk = rl.find(r => r.kind === 'seven_day')
127  return { ...s, sub: true, h5: h5?.percentUsed, h5Reset: h5?.resetsAt, wk: wk?.percentUsed, wkReset: wk?.resetsAt }
128}
129const fold = (u: Pick<SessionUsage, 'context' | 'rateLimits'>, cached?: Limits) => (s: Snap): Snap => ({
130  ...withLimits(s, u.rateLimits.length ? u.rateLimits : cached ?? []),
131  ctxPct: u.context.percent, ctxTok: u.context.tokens, ctxWin: u.context.window,
132})
133type SavedLimits = { at: number; rl: Limits }
134const parseLimits = (v: unknown): SavedLimits | undefined =>
135  Array.isArray(v) ? { at: 0, rl: v as Limits }
136    : v && typeof v === 'object' && Array.isArray((v as SavedLimits).rl) ? (v as SavedLimits) : undefined
137const aged = (rl: Limits, now: number): Limits =>
138  rl.map(r => (r.resetsAt && Date.parse(r.resetsAt) <= now ? { kind: r.kind, percentUsed: 0 } : r))
139
140type Seen = Record<string, number>
141async function remember($: EngineInterface, t: number) {
142  const [id, got] = await Promise.all([$.session.id(), $.store.get('lastAt')])
143  const seen = { ...((got ?? {}) as Seen), [id]: t }
144  const keep = Object.entries(seen).sort((a, b) => b[1] - a[1]).slice(0, 30)
145  await $.store.set('lastAt', Object.fromEntries(keep))
146}
147
148async function transcriptAt($: EngineInterface, id: string): Promise<number> {
149  try {
150    const [cwd, cfg, home] = await Promise.all([$.session.cwd(), $.env.get('CLAUDE_CONFIG_DIR'), $.env.get('HOME')])
151    const base = cfg ?? `${home}/.claude`
152    const st = await $.fs.stat(`${base}/projects/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl`)
153    return st.kind === 'file' ? st.mtimeMs : 0
154  } catch {
155    return 0
156  }
157}
158
159let storedLimits = ''
160
161// ---- git ----
162
163async function refreshGit($: EngineInterface) {
164  const cwd = await $.session.cwd()
165  const home = (await $.env.get('HOME')) ?? ''
166  const dir = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd
167  const noRepo: Git = { branch: '', dirty: 0, ahead: 0, behind: 0, dir, isRepo: false }
168  try {
169    const r = await $.process.run(['git', 'status', '--porcelain=v1', '-b'], { timeoutMs: 5000 })
170    if (r.exitCode !== 0) return update($, git, () => noRepo)
171    const [head, ...rest] = r.stdout.split('\n')
172    const m = head.match(/^## (?:No commits yet on )?([^.\s]+)(?:\.\.\.\S+)?(?: \[(.*)\])?/)
173    const info = m?.[2] ?? ''
174    const g: Git = {
175      branch: m?.[1] ?? 'HEAD',
176      dirty: rest.filter(l => l.trim()).length,
177      ahead: Number(info.match(/ahead (\d+)/)?.[1] ?? 0),
178      behind: Number(info.match(/behind (\d+)/)?.[1] ?? 0),
179      dir,
180      isRepo: true,
181    }
182    await update($, git, () => g)
183  } catch {
184    await update($, git, () => noRepo)
185  }
186}
187
188// ---- sitrep ----
189
190const promptFor = (skill: string) => `You are writing a sitrep for the user, who has stepped away from this session.
191Follow these instructions for content, tone and brevity:
192
193<sitrep-skill>
194${skill}
195</sitrep-skill>
196
197Answer with JSON only, no prose and no code fence:
198{"now": "<one sentence>", "last": ["<bullet>", ...], "needsYou": "<what you need, or null>", "next": ["<bullet>", ...]}
199Keep every string short and plain. Use null for needsYou unless work genuinely cannot continue without the user.`
200
201const parseSitrep = (text: string, at: number): Sitrep | null => {
202  const a = text.indexOf('{')
203  const b = text.lastIndexOf('}')
204  if (a < 0 || b < a) return null
205  try {
206    const raw = JSON.parse(text.slice(a, b + 1))
207    const list = (v: unknown) => (Array.isArray(v) ? v.filter(x => typeof x === 'string' && x.trim()).slice(0, 4) : [])
208    return {
209      now: typeof raw.now === 'string' ? raw.now : '',
210      last: list(raw.last),
211      needsYou: typeof raw.needsYou === 'string' && raw.needsYou.trim() ? raw.needsYou : null,
212      next: list(raw.next).slice(0, 3),
213      at,
214    }
215  } catch {
216    return null
217  }
218}
219
220let sitrepError = ''
221
222// Your own sitrep skill wins; the copy shipped with the mod covers installs without one.
223async function sitrepSkill($: EngineInterface) {
224  const home = await $.env.get('HOME')
225  for (const path of [home && `${home}/.claude/skills/sitrep/SKILL.md`, `${$.plugin.root}/hooks/sitrep.md`]) {
226    if (!path) continue
227    try {
228      return await $.fs.read(path)
229    } catch {}
230  }
231  return 'Sections: Now, Last step, Needs you (only if blocked), Next. Under 12 lines. Plain words. Name who does each step.'
232}
233
234
235// Checked once a minute: 30 minutes since the last turn ended, and no sitrep yet for that idle stretch.
236async function checkIdle($: EngineInterface, now: number) {
237  const since = await read($, idleSince)
238  if (!since || now - since < IDLE_MS) return
239  if ((await read($, sitrepFor)) === since) return
240  await update($, sitrepFor, () => since)
241  await writeSitrep($)
242}
243
244async function writeSitrep($: EngineInterface) {
245  if (await read($, isBusy)) return
246  await update($, isBusy, () => true)
247  sitrepError = ''
248  try {
249    const skill = await sitrepSkill($)
250    const reply = await $.model.fork({ prompt: promptFor(skill) })
251    if (!reply.isAnswered) {
252      if (reply.reason !== 'nothing-to-fork') sitrepError = `sitrep failed (${reply.reason})`
253      return
254    }
255    const s = parseSitrep(reply.text, await $.clock.now())
256    if (!s) {
257      sitrepError = 'sitrep: unreadable reply'
258      return
259    }
260    await update($, sitrep, () => s)
261    if (s.needsYou) $.ui.toast(`Needs you: ${s.needsYou}`, { timeoutMs: 10000 })
262  } finally {
263    await update($, isBusy, () => false)
264  }
265}
266
267async function closeSitrep($: EngineInterface) {
268  await update($, sitrep, () => null)
269  await update($, isPinned, () => false)
270}
271
272// The band's compact shortcut: say what happened instead of failing silently.
273// The desktop app runs Claude Code headless, where $.session.compact is unavailable;
274// running the built-in /compact command does the same and reports its own progress.
275// Not awaited: compaction outlasts the 10s budget a ui.press hook gets, so the press
276// returns at once and a failure is reported whenever it arrives.
277function compactNow($: EngineInterface) {
278  // A slash command has to go through command.run; prompt.submit refuses text starting with /.
279  $.command.run({ command: 'compact' }).catch(err => {
280    $.ui.log(`dev-band: could not start /compact: ${String(err).slice(0, 160)}`)
281    void $.prompt.fill({ text: '/compact' })
282  })
283}
284
285// ---- hooks ----
286
287export const register: Register = on => {
288  on('session.start', async ($, e, next) => {
289    const [now, u, ttl, seen, limits, id] = await Promise.all([
290      $.clock.now(), $.session.usage(), $.store.get('ttl'), $.store.get('lastAt'), $.store.get('limits'), $.session.id(),
291    ])
292    const got = parseLimits(limits)
293    const cached = got && aged(got.rl, now)
294    const saved = ((seen ?? {}) as Seen)[id] || (u.context.tokens ? await transcriptAt($, id) : 0)
295    await update($, snap, s => ({
296      ...fold(u, cached)(s),
297      now,
298      limitsAt: u.rateLimits.length ? now : got?.at ?? 0,
299      ttl: ttl === '5m' || ttl === '1h' ? ttl : s.ttl,
300      lastAt: Math.max(s.lastAt, Math.min(saved, now)),
301    }))
302    await refreshGit($)
303    await $.command.register({ name: 'sitrep-card', description: 'Unfold the sitrep in the band now instead of waiting 30 idle minutes' })
304    $.clock.every(60_000, async () => {
305      const [t, v] = await Promise.all([$.clock.now(), $.store.get('limits')])
306      const fresh = parseLimits(v)
307      await update($, snap, s => (fresh && fresh.at > (s.limitsAt ?? 0)
308        ? { ...withLimits(s, aged(fresh.rl, t)), limitsAt: fresh.at, now: t }
309        : { ...s, now: t }))
310      await refreshGit($)
311      await checkIdle($, t)
312    })
313    // A load with no countdown running (first load, or a reload of an older version) starts one now.
314    if (!(await read($, idleSince))) await update($, idleSince, () => now)
315    // Catch up at once if the idle stretch already passed while the mod was reloading.
316    void checkIdle($, now)
317    return next(e)
318  })
319
320  on('command.run', { command: 'sitrep-card' }, async $ => {
321    void writeSitrep($)
322    return { text: 'Writing the sitrep into the band.' }
323  })
324
325  on('session.measure', async ($, e, next) => {
326    const now = await $.clock.now()
327    const isResponse = e.changed.includes('context')
328    const tok = e.context.tokens ?? 0
329    const prev = await read($, snap)
330    if (tok >= NAG && !prev.warned) {
331      $.ui.toast(`Context is at ${k(tok)} tokens: every turn re-sends all of it. Consider compacting or clearing.`)
332    }
333    await update($, snap, s => ({
334      ...fold(e)(s), now, lastAt: isResponse ? now : s.lastAt,
335      limitsAt: e.rateLimits.length ? now : s.limitsAt,
336      warned: tok >= NAG,
337      snooze: tok < NAG ? 0 : s.snooze,
338    }))
339    const rl = JSON.stringify(e.rateLimits)
340    if (e.rateLimits.length && rl !== storedLimits) {
341      storedLimits = rl
342      await $.store.set('limits', { at: now, rl: e.rateLimits })
343    }
344    return next(e)
345  })
346
347  // You're back: the sitrep has done its job, unless pinned.
348  on('prompt.submit', async ($, e, next) => {
349    await update($, idleSince, () => 0)
350    if (!(await read($, isPinned))) await closeSitrep($)
351    return next(e)
352  })
353
354  on('turn.start', async ($, e, next) => {
355    await update($, idleSince, () => 0)
356    return next(e)
357  })
358
359  on('turn.complete', async ($, e, next) => {
360    const now = await $.clock.now()
361    await update($, snap, s => ({ ...s, lastAt: now, now }))
362    await remember($, now)
363    void refreshGit($)
364    if (!e.agentId) await update($, idleSince, () => now)
365    return next(e)
366  })
367
368  on('turn.step', async function* ($, e, next) {
369    const first = e.index === 0 && !e.agentId
370    const [before, sentAt] = first ? await Promise.all([read($, snap), $.clock.now()]) : [undefined, 0]
371    const r = yield* next(e)
372    const u = r.usage
373    if (!before || !u) return r
374    const total = u.cache_read_input_tokens + u.cache_creation_input_tokens + u.input_tokens
375    if (total < MIN_PROMPT) return r
376    const hit = u.cache_read_input_tokens / total >= 0.5
377    const gap = before.lastAt ? sentAt - before.lastAt : -1
378    const sameModel = !before.model || before.model === u.model
379    let ttl = before.ttl
380    if (sameModel && hit && gap > 6 * 60_000) ttl = '1h'
381    else if (sameModel && !hit && gap > 6 * 60_000 && gap < 55 * 60_000 && u.cache_read_input_tokens / total < 0.2) ttl = '5m'
382    if (ttl && ttl !== before.ttl) await $.store.set('ttl', ttl)
383    const ttlMs = (ttl ?? (before.sub ? '1h' : '5m')) === '1h' ? TTL_SUB : TTL_API
384    if (!hit && gap >= 0 && gap < ttlMs && sameModel && total >= 50_000) {
385      $.ui.toast(`Cache miss: that message re-read ${k(total - u.cache_read_input_tokens)} tokens at full price.`)
386    }
387    await update($, snap, s => ({
388      ...s, ttl, model: u.model, hit: u.cache_read_input_tokens / total,
389      missTok: hit ? undefined : total - u.cache_read_input_tokens,
390    }))
391    return r
392  })
393
394  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
395    if (e.props.hasSurvey) return next(e)
396    const [s, g, rep, busy, pinned] = await Promise.all([
397      read($, snap), read($, git), read($, sitrep), read($, isBusy), read($, isPinned),
398    ])
399    const { Box, Button, Text } = $.ui.resolve(e)
400    const now = Math.max(s.now, s.lastAt)
401    const cols = e.props.bodyColumns || 120
402
403    // One model for both surfaces. Lower prio drops first when the line won't fit.
404    type Spec = { key: string; prio: number; alt: string; runs: Run[] }
405    const specs: Spec[] = []
406    const tok = s.ctxTok ?? 0
407    const cp = s.ctxPct ?? 0
408
409    if (g && !g.isRepo) {
410      specs.push({ key: 'git', prio: 3, alt: `${g.dir}, not a git repo`, runs: [{ text: shortDir(g.dir, MAX_DIR), color: ACCENT }] })
411    } else if (g) {
412      const r: Run[] = [{ text: `⎇ ${mid(g.branch, MAX_BRANCH)}`, color: ACCENT }]
413      if (g.dirty) r.push({ text: ` ±${g.dirty}`, color: WARN })
414      if (g.ahead) r.push({ text: ` ↑${g.ahead}`, dim: true })
415      if (g.behind) r.push({ text: ` ↓${g.behind}`, dim: true })
416      specs.push({ key: 'git', prio: 3, alt: `branch ${g.branch}, ${g.dirty} changed`, runs: r })
417    }
418
419    // Bars start at their minimum; whatever room is left after the text is theirs.
420    const BW = 24
421    const limit = (key: string, label: string, prio: number, pct?: number, reset?: string) => {
422      if (pct === undefined) return
423      const isReset = !!reset && Date.parse(reset) <= now
424      const p = isReset ? 0 : pct
425      const color = tone(p)
426      const tail = reset && !isReset ? ` ${left(Date.parse(reset) - now)}` : ''
427      specs.push({
428        key, prio, alt: `${label} limit ${Math.round(p)}%${tail}`,
429        runs: [{ text: `${label} `, dim: true }, { bar: p / 100, color, width: BW }, { text: `${Math.round(p)}%`, color, bold: true }, ...(tail ? [{ text: tail, dim: true }] : [])],
430      })
431    }
432    limit('h5', '5h', 5, s.h5, s.h5Reset)
433    limit('wk', '7d', 2, s.wk, s.wkReset)
434
435    const ttl = (s.ttl ?? (s.sub ? '1h' : '5m')) === '1h' ? TTL_SUB : TTL_API
436    const rem = s.lastAt ? s.lastAt + ttl - now : 0
437    // Only a miss is worth the space; a hit is the normal case.
438    const miss = s.missTok ? ` ✗${k(s.missTok)}` : ''
439    const cache: Run[] = e.props.isWorking
440      ? [{ text: 'cache ', dim: true }, { dot: WARM }, { text: 'hot', color: WARM }]
441      : rem > 0
442        ? [{ text: 'cache ', dim: true }, { dot: WARM }, { text: 'warm', color: WARM }, { text: ` ${Math.max(0, Math.ceil(rem / 60_000))}m`, dim: true }, ...(miss ? [{ text: miss, color: BAD }] : [])]
443        : [{ text: 'cache ', dim: true }, { dot: COOL }, { text: s.lastAt ? 'cold' : '—', color: COOL }]
444    specs.push({ key: 'cache', prio: 4, alt: 'prompt cache', runs: cache })
445
446    // Width of a spec in cells (terminal) or px (desktop).
447    const isTerm = e.surface === 'terminal'
448    let TW = 3
449    const cells = (r: Run[]) => r.reduce((n, x) => n + ('bar' in x ? (TW ? TW + 3 : 0) : 'dot' in x ? 2 : [...x.text].length), 0)
450    const GAP_T = 3
451    const GAP_PX = 24
452    const avail = isTerm ? cols - 2 : cols * 8 - 24
453    const width = (list: Spec[]) =>
454      list.reduce((n, x) => n + (isTerm ? cells(x.runs) : seg(x.runs).w), 0) + Math.max(0, list.length - 1) * (isTerm ? GAP_T : GAP_PX)
455    let shown = [...specs]
456    // First shorten the cache to its state, then drop whole segments by priority.
457    if (width(shown) > avail) {
458      shown = shown.map(x => (x.key === 'cache' ? { ...x, runs: x.runs.filter(r => !('text' in r) || !r.dim || r.text === 'cache ') } : x))
459    }
460    while (width(shown) > avail && shown.length > 1) {
461      const lowest = shown.reduce((m, x) => (x.prio < m.prio ? x : m))
462      shown = shown.filter(x => x !== lowest)
463    }
464    // Spare room goes to the bars, up to a cap, leaving a little air.
465    const nBars = shown.reduce((n, x) => n + x.runs.filter(r => 'bar' in r).length, 0)
466    if (nBars) {
467      const spare = avail - width(shown) - (isTerm ? 4 : 48)
468      if (isTerm && TW) TW = Math.min(24, TW + Math.max(0, Math.floor(spare / nBars)))
469      if (!isTerm) {
470        const grow = Math.max(0, Math.floor(spare / nBars))
471        shown = shown.map(x => ({ ...x, runs: x.runs.map(r => ('bar' in r ? { ...r, width: Math.min(220, r.width + grow) } : r)) }))
472      }
473    }
474
475    // Big-context row: the cheapest move given the cache.
476    const isCold = s.lastAt > 0 && rem <= 0
477    const isExpiring = !isCold && rem > 0 && rem <= 10 * 60_000
478    const nag = !e.props.isWorking && tok >= Math.max(NAG, s.snooze ?? 0)
479    const nagRow = nag ? (
480      <Box key="nag" flexDirection="row" alignItems="center" gap={1} paddingX={1}>
481        <Text color={isCold ? COOL : tok >= NAG_HARD ? BAD : WARN}>!</Text>
482        <Text dimColor wrap="truncate-end">
483          {isCold
484            ? `cache cold: next message re-reads ${k(tok)}; clear is free`
485            : isExpiring
486              ? `cache expires in ${left(rem)}: compact while it's cheap`
487              : `${k(tok)} in context: every turn re-sends it`}
488        </Text>
489        <Button key="compact" label="compact" hotkey="c" variant={isCold ? 'secondary' : 'primary'} onPress={() => compactNow($)} />
490        <Button key="clear" label="clear" hotkey="x" variant={isCold ? 'primary' : 'secondary'} onPress={() => void $.prompt.fill({ text: '/clear' })} />
491        <Button key="later" label="later" hotkey="l" onPress={() => update($, snap, x => ({ ...x, snooze: tok + 100_000 }))} />
492      </Box>
493    ) : null
494
495    // The sitrep unfolds under the metrics.
496    const showRep = rep && (!e.props.isWorking || pinned)
497    const bullets = (items: string[], p: string) =>
498      items.map((t, i) => (
499        <Text key={`${p}${i}`} wrap="wrap">
500          <Text dimColor>{'    · '}</Text>
501          {t}
502        </Text>
503      ))
504    const repView = showRep && rep ? (
505      <Box key="rep" flexDirection="column" paddingX={1} marginTop={1}>
506        <Box flexDirection="row" justifyContent="space-between">
507          <Text dimColor>
508            {`── sitrep @ ${hhmm(rep.at)}${pinned ? ' · pinned' : ''} ──`}
509          </Text>
510          <Box flexDirection="row" gap={1}>
511            <Button key="pin" label={pinned ? 'unpin' : 'pin'} hotkey="p" onPress={() => update($, isPinned, v => !v)} />
512            <Button key="close" label="close" hotkey="d" role="dismiss" onPress={() => closeSitrep($)} />
513          </Box>
514        </Box>
515        <Text wrap="wrap">
516          <Text color={COOL} bold>{'  now  '}</Text>
517          {rep.now}
518        </Text>
519        {rep.last.length > 0 && <Text color={COOL} bold>{'  last'}</Text>}
520        {bullets(rep.last, 'l')}
521        {rep.needsYou && (
522          <Text wrap="wrap">
523            <Text color={WARN} bold>{'  you  '}</Text>
524            <Text color={WARN}>{rep.needsYou}</Text>
525          </Text>
526        )}
527        {rep.next.length > 0 && <Text color={COOL} bold>{'  next'}</Text>}
528        {bullets(rep.next, 'n')}
529      </Box>
530    ) : busy ? (
531      <Box key="rep" paddingX={1}>
532        <Text dimColor>── sitrep: writing…</Text>
533      </Box>
534    ) : sitrepError ? (
535      <Box key="rep" paddingX={1}>
536        <Text color={BAD}>{`── ${sitrepError}`}</Text>
537      </Box>
538    ) : null
539
540    if (!isTerm) {
541      const { Svg } = $.ui.resolve(e) as unknown as { Svg: (p: { source: string; alt: string; width?: number; height?: number }) => unknown }
542      const svgRow = (key: string, runs: Run[], alt: string) => {
543        const d = seg(runs)
544        return <Svg key={key} source={d.svg} alt={alt || ' '} width={d.w} height={H} />
545      }
546      // A label column, then text wrapped to the band's width:
547      //   now   one sentence
548      //   last  · bullet
549      const LABEL = 6
550      // bodyColumns is in the app's own monospace cells, which are at least as wide as ours.
551      const textCells = Math.max(20, cols - LABEL - 6)
552      const block = (key: string, label: string, color: string, items: string[], bullet: boolean, textColor?: string) => {
553        const rows: unknown[] = []
554        items.forEach((item, i) => {
555          wrap(item, textCells).forEach((ln, j) => {
556            const first = i === 0 && j === 0
557            const lead = bullet ? (j === 0 ? '· ' : '  ') : ''
558            rows.push(svgRow(`${key}${i}-${j}`, [
559              first ? { text: label.padEnd(LABEL), color, bold: true } : { text: ' '.repeat(LABEL) },
560              { text: lead, dim: true },
561              textColor ? { text: ln, color: textColor } : { text: ln },
562            ], first ? `${label}: ${item}` : ln))
563          })
564        })
565        return rows
566      }
567      const nagText = isCold
568        ? `cache cold: next message re-reads ${k(tok)} · clear is free`
569        : isExpiring
570          ? `cache expires in ${left(rem)} · compact while it's cheap`
571          : `${k(tok)} in context · every turn re-sends it`
572      const nagColor = isCold ? COOL : tok >= NAG_HARD ? BAD : WARN
573      const nagDesk = nag ? (
574        <Box key="nag" flexDirection="row" justifyContent="space-between" alignItems="center" paddingX={1} marginTop={1}>
575          {svgRow('nagmsg', [{ text: '! ', color: nagColor, bold: true }, { text: nagText, dim: true }], nagText)}
576          {svgRow('nagkeys', [
577            { text: 'c', color: COOL }, { text: ' compact', dim: isCold, ...(isCold ? {} : { color: undefined }) },
578            { text: '   x', color: COOL }, { text: ' clear', dim: !isCold },
579            { text: '   l', color: COOL }, { text: ' later', dim: true },
580          ], 'c compact, x clear, l later')}
581          <Box display="none">
582            <Button key="compact" label="compact" hotkey="c" onPress={() => compactNow($)} />
583            <Button key="clear" label="clear" hotkey="x" onPress={() => void $.prompt.fill({ text: '/clear' })} />
584            <Button key="later" label="later" hotkey="l" onPress={() => update($, snap, x => ({ ...x, snooze: tok + 100_000 }))} />
585          </Box>
586        </Box>
587      ) : null
588
589      const repDesk = showRep && rep ? (
590        <Box key="rep" flexDirection="column" paddingX={1} marginTop={1}>
591          <Box flexDirection="row" justifyContent="space-between" alignItems="center">
592            {svgRow('hdr', [
593              { text: '── ', dim: true },
594              { text: 'sitrep', color: COOL, bold: true },
595              { text: ` @ ${hhmm(rep.at)}${pinned ? ' · pinned' : ''}`, dim: true },
596            ], `sitrep at ${hhmm(rep.at)}`)}
597            {svgRow('hdrkeys', [
598              { text: 'p', color: COOL }, { text: pinned ? ' unpin' : ' pin', dim: true },
599              { text: '   d', color: COOL }, { text: ' close', dim: true },
600            ], 'p pin, d close')}
601            {/* Keys only: the buttons stay mounted for their hotkeys but are not drawn. */}
602            <Box display="none">
603              <Button key="pin" label={pinned ? 'unpin' : 'pin'} hotkey="p" onPress={() => update($, isPinned, v => !v)} />
604              <Button key="close" label="close" hotkey="d" onPress={() => closeSitrep($)} />
605            </Box>
606          </Box>
607          <Box height={1} />
608          {block('now', 'now', COOL, [rep.now], false)}
609          {block('last', 'last', COOL, rep.last, true)}
610          {rep.needsYou ? block('you', 'you', WARN, [rep.needsYou], false, WARN) : null}
611          {block('next', 'next', COOL, rep.next, true)}
612        </Box>
613      ) : busy ? (
614        <Box key="rep" paddingX={1} marginTop={1}>{svgRow('busy', [{ text: '── sitrep: writing…', dim: true }], 'sitrep: writing')}</Box>
615      ) : sitrepError ? (
616        <Box key="rep" paddingX={1} marginTop={1}>{svgRow('err', [{ text: `── ${sitrepError}`, color: BAD }], sitrepError)}</Box>
617      ) : null
618      return (
619        <Box flexDirection="column">
620          <Box key="line" flexDirection="row" alignItems="center" justifyContent="space-between" columnGap={3} paddingX={1}>
621            {shown.map(x => {
622              const d = seg(x.runs)
623              return <Svg key={x.key} source={d.svg} alt={x.alt} width={d.w} height={H} />
624            })}
625          </Box>
626          {nagDesk}
627          {repDesk}
628        </Box>
629      )
630    }
631
632    const termRun = (r: Run, i: number) =>
633      'bar' in r ? (TW ? <Text key={i} color={r.color}>{`▕${bar(r.bar, TW)}▏ `}</Text> : null)
634        : 'dot' in r ? <Text key={i} color={r.dot}>{'● '}</Text>
635          : <Text key={i} color={r.dim ? undefined : r.color} dimColor={r.dim} bold={r.bold}>{r.text}</Text>
636    const line = (
637      <Box key="line" flexDirection="row" justifyContent="space-between" columnGap={GAP_T} paddingX={1}>
638        {shown.map(x => (
639          <Text key={x.key} wrap="truncate-end">{x.runs.map(termRun)}</Text>
640        ))}
641      </Box>
642    )
643
644    return (
645      <Box flexDirection="column">
646        {line}
647        {nagRow}
648        {repView}
649      </Box>
650    )
651  })
652}
653
types/index.d.ts 44 lines
1export type Snap = {
2  h5?: number
3  h5Reset?: string
4  wk?: number
5  wkReset?: string
6  ctxPct?: number
7  ctxTok?: number
8  ctxWin: number
9  sub: boolean
10  lastAt: number
11  now: number
12  snooze?: number
13  warned?: boolean
14  ttl?: '5m' | '1h'
15  model?: string
16  hit?: number
17  missTok?: number
18  limitsAt?: number
19}
20
21export type Git = { branch: string; dirty: number; ahead: number; behind: number; dir: string; isRepo: boolean }
22
23export type Sitrep = {
24  now: string
25  last: string[]
26  needsYou: string | null
27  next: string[]
28  at: number
29}
30
31declare module 'claude-code' {
32  interface PluginState {
33    'dev-band': {
34      snap: Snap
35      git: Git | null
36      sitrep: Sitrep | null
37      isBusy: boolean
38      isPinned: boolean
39      idleSince: number
40      sitrepFor: number
41    }
42  }
43}
44