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

My Claude Code mods and skills, as a plugin marketplace.
/plugin marketplace add bilal-psd/skills
/plugin install dev-band@bilal-skills
Mods live in mods/, skills in skills/.
| Plugin | Kind | What it does |
|---|---|---|
dev-band | mod | A 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-card | mod | Adds ask_many: the built-in question dialog for 5+ questions, asked one after another. |
sitrep | skill | /sitrep: a 12-line status of the session — now, last step, needs you, next. |
wrap | skill | /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.
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).MIT
hooks/register.tsx 653 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
653types/index.d.ts 44 lines1export 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