Docked session sidebar: model, usage, session, workspace, CI and tasks

A Claude Code plugin marketplace with one mod, sidebar: a pane docked beside the transcript that shows the session's model, usage, workspace, CI and tasks. It replaces a multi-line status line, so the prompt box stays in one place.
It's built on Claude Code's function-hooks plugin API, which is early access and may change between releases.
At a Claude Code prompt:
/plugin install sidebar --marketplace pthexton/claude-sidebar-mod
Answer y to add the marketplace, then pick a scope (user scope loads it in every session).
From a local clone instead:
git clone https://github.com/pthexton/claude-sidebar-mod.git
claude plugin marketplace add /path/to/claude-sidebar-mod
claude plugin install sidebar@pt-mods --scope user
A marketplace added from a folder is read from that folder, so edits there reach new sessions without reinstalling (/reload-plugins for running ones).
The sidebar only docks beside the transcript in the fullscreen layout. Add to ~/.claude/settings.json:
{ "tui": "fullscreen" }
(or run /tui and pick fullscreen). The pane opens by itself at 144 terminal columns or wider. Below that, run /sidebar to open it (from 110 columns). Drag its edge to resize it; Claude Code remembers the width.
| Section | Shows | Needs |
|---|---|---|
| Model | model, context bar, 5h / 7d rate-limit bars (subscriptions only), prompt-cache chip | nothing; see the two optional extras below for a fuller cache chip |
| Session | cost, elapsed time, API time, effort, session id with a copy button | nothing |
| Workspace | repo (and (wt) in a worktree), cwd, project root, branch, added dirs | git on PATH; added dirs need the status line feed |
| CI | the branch's PR, check counts, failing and pending checks, refresh and open-PR buttons | gh, signed in (gh auth login). Hidden when there's no PR or gh can't answer |
| Tasks | Claude's own task list (TodoWrite, TaskCreate, TaskUpdate) for the main conversation | nothing |
| Cost | the billing discount in use, the session cost at list price, and where to change the discount. Hidden when the discount is 0 | nothing |
Costs are shown after a discount off Anthropic's published prices, 9% by default. That applies to the session cost (Claude Code's own figure, scaled) and to the cache rebuild cost. Both are marked *, and the Cost section at the bottom shows the discount and the session cost at list price.
To change it, open /config and set Billing discount (%) under the sidebar plugin. Set it to 0 to see list prices; the Cost section then disappears.
Commands: /sidebar shows or hides the pane (its close mark ignores clicks, so a stray one can't close it), /ci-refresh fetches CI now, /open-pr opens the PR in your browser (open on macOS, xdg-open on Linux).
Mods can't read some of what Claude Code gives a status line command. sidebar/statusline-feed.sh is a status line command that saves that input per session to ~/.claude/state/statusline/<session_id>.json for the sidebar, and prints nothing, so no status line is drawn under the prompt. It needs jq. Point your status line at it in ~/.claude/settings.json:
{
"statusLine": { "type": "command", "command": "/path/to/claude-sidebar-mod/sidebar/statusline-feed.sh" }
}
With it, the sidebar shows:
Without it, fast mode comes from the fastMode setting, and the cache chip assumes a 1-hour TTL from the last response. Either way, after a --resume the cache chip stays hidden until the first reply.
The cache chip always shows the rebuild size in tokens. To also show what a cold cache would cost to rebuild (↻$0.37 (45.9k), yellow from $1, red from $5), create ~/.claude/state/model-pricing.tsv. Without it, or for a model it doesn't list, no dollar figure is shown. The figure has the billing discount taken off.
It's tab separated, USD per million tokens, one row per model. Lines starting with # and the header row are skipped:
model_id input write_5m write_1h cache_read output fast_input
claude-opus-5-5 5 6.25 10 0.5 25 30
claude-haiku-4-5 1 1.25 2 0.1 5
model_id is matched by longest prefix, so dated or suffixed ids (claude-haiku-4-5-20251001, claude-opus-5-5[1m]) find their row.write_5m / write_1h are the cache-write prices for each TTL.fast_input is the fast-mode input price, or empty for a model without fast mode. In fast mode the write price is scaled by fast_input / input.cache_read and output aren't used yet, but keep the columns in that order.Take the figures from Anthropic's pricing page, and update them when prices change.
Claude Code can't bind function keys, but it can bind chords to the CI commands. In ~/.claude/keybindings.json:
{
"bindings": [
{
"context": "Chat",
"bindings": { "ctrl+x r": "command:ci-refresh", "ctrl+x o": "command:open-pr" }
}
]
}
To use F5 / F6 anyway, have your terminal send those chords: in Ghostty, keybind = f5=text:\x18r and keybind = f6=text:\x18o; in macOS Terminal, Settings > Profiles > Keyboard, Send Text \030r and \030o.
claude plugin validate sidebar
claude plugin test sidebar
Claude Code writes the API types into sidebar/.claude-plugin/types/ when it loads the mod (git ignores them), after which npx -p typescript@5 tsc -p sidebar type-checks it. To run a working copy for one session without installing it: claude --plugin-dir sidebar.
hooks/register.tsx 688 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionMeasureInput, SessionUsage } from 'claude-code'
3
4import type { CiCheck, CiStatus, RateWindow, TaskLine, Usage, Workspace } from '../types'
5import {
6 isNoPrError,
7 isPrCreated,
8 notableChecks,
9 parseChecks,
10 parsePr,
11 shouldFetch,
12 summarize,
13} from './ci'
14import { FEED_DIR, parseFeed, resolveCache } from './feed'
15import { costColor, discounted, parsePricing, recacheCost, toDiscount } from './pricing'
16import {
17 applyTaskCreate,
18 applyTaskUpdate,
19 applyTodoWrite,
20 isTaskDim,
21 taskColor,
22 taskIcon,
23} from './tasks'
24
25// A session sidebar: Model, Session, Workspace, CI and Tasks sections in a
26// pane docked beside the fullscreen transcript. See README.md for what each
27// section needs.
28
29const PANE = 'sidebar'
30const PANE_COLUMNS = 40
31
32// The prompt cache TTL isn't exposed to mods: the status line feed carries
33// the real one, and without it this session's 1h TTL is assumed.
34const CACHE_TTL = '1h'
35
36const usage = atom({ plugin: 'sidebar', key: 'usage' } as const, null)
37const lastStep = atom({ plugin: 'sidebar', key: 'lastStep' } as const, null)
38const apiMs = atom({ plugin: 'sidebar', key: 'apiMs' } as const, 0)
39const workspace = atom({ plugin: 'sidebar', key: 'workspace' } as const, null)
40const now = atom({ plugin: 'sidebar', key: 'now' } as const, 0)
41const pricing = atom({ plugin: 'sidebar', key: 'pricing' } as const, [])
42const claudeTasks = atom({ plugin: 'sidebar', key: 'claudeTasks' } as const, [])
43const ci = atom({ plugin: 'sidebar', key: 'ci' } as const, null)
44const isCiFetching = atom({ plugin: 'sidebar', key: 'isCiFetching' } as const, false)
45const isFastMode = atom({ plugin: 'sidebar', key: 'isFastMode' } as const, false)
46const feed = atom({ plugin: 'sidebar', key: 'feed' } as const, null)
47// A change made in /config this session; null until then, when the module's
48// own options (read as it loaded) are the setting.
49const discountChange = atom({ plugin: 'sidebar', key: 'discountChange' } as const, null)
50
51// statusline-feed.sh rewrites its file whenever the status line updates.
52const FEED_POLL_MS = 2000
53
54// How often the CI refresh policy is checked; shouldFetch decides whether
55// that tick actually calls gh.
56const CI_TICK_MS = 15_000
57// Bindable in ~/.claude/keybindings.json as command:ci-refresh and
58// command:open-pr (README.md suggests ctrl+x r / ctrl+x o). Function keys
59// can't be bound: the keybinding matcher has no names for them.
60const CMD_CI_REFRESH = 'ci-refresh'
61const CMD_OPEN_PR = 'open-pr'
62
63// Per-model prices, optional (format in README.md). Missing or unreadable
64// means the cache chip shows the rebuild size in tokens and no dollar cost.
65const PRICING_FILE = '.claude/state/model-pricing.tsv'
66
67const refreshPricing = async ($: EngineInterface, home: string | undefined) => {
68 if (home === undefined) return
69 try {
70 const rows = parsePricing(await $.fs.read(`${home}/${PRICING_FILE}`))
71 await update($, pricing, () => rows)
72 } catch {
73 // Keep whatever was loaded before.
74 }
75}
76
77const toUsage = (u: SessionUsage | SessionMeasureInput, startedAt: number): Usage => ({
78 contextPercent: u.context.percent,
79 rateLimits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
80 costUsd: u.cost?.usd,
81 startedAt,
82})
83
84const git = async ($: EngineInterface, cwd: string, args: string[]) => {
85 try {
86 const r = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 5000 })
87 return r.exitCode === 0 ? r.stdout.trim() : undefined
88 } catch {
89 return undefined
90 }
91}
92
93const refreshWorkspace = async ($: EngineInterface) => {
94 const cwd = await $.session.cwd()
95 const [sessionId, model, root, repo, home] = await Promise.all([
96 $.session.id(),
97 $.session.model(),
98 $.session.root(),
99 $.session.repo(),
100 $.env.get('HOME'),
101 ])
102 let branch: string | undefined
103 let isWorktree = false
104 let commitSha: string | undefined
105 if (repo !== null) {
106 branch = await git($, cwd, ['branch', '--show-current'])
107 commitSha = await git($, cwd, ['rev-parse', 'HEAD'])
108 const dirs = await git($, cwd, ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'])
109 const [gitDir, commonDir] = (dirs ?? '').split('\n')
110 isWorktree = gitDir !== undefined && commonDir !== undefined && gitDir !== commonDir
111 }
112 const repoName = repo === null ? undefined : repo.root.split('/').at(-1)
113 const ws: Workspace = { sessionId, model, cwd, root, branch, commitSha, isWorktree, repoName, isGit: repo !== null, home }
114 await update($, workspace, () => ws)
115 await refreshPricing($, home)
116 return ws
117}
118
119// The model alone, without the git calls: cheap enough for the 1s tick, which
120// catches a switch no hook announces (the picker, /config, an auto fallback).
121const refreshModel = async ($: EngineInterface) => {
122 const [model, ws] = await Promise.all([$.session.model(), read($, workspace)])
123 if (ws !== null && ws.model !== model) await update($, workspace, () => ({ ...ws, model }))
124}
125
126// Fast mode bills at a higher per-token rate, so turning on gets a toast.
127const setFastMode = async ($: EngineInterface, isOn: boolean) => {
128 if (isOn === (await read($, isFastMode))) return
129 await update($, isFastMode, () => isOn)
130 if (isOn) $.ui.toast('⚡ Fast mode is ON: higher per-token cost. /fast turns it off.', { timeoutMs: 10_000 })
131}
132
133// The mod API has no fast-mode flag. The status line feed carries the
134// session's real state; without a feed, fall back to the `fastMode` setting
135// /fast persists (a session-only toggle may never reach settings).
136const refreshFastMode = async ($: EngineInterface) => {
137 const fromFeed = (await read($, feed))?.isFastMode
138 if (fromFeed !== undefined) return setFastMode($, fromFeed)
139 try {
140 await setFastMode($, (await $.settings.read()).fastMode === true)
141 } catch {
142 // Settings unreadable: leave it as it was.
143 }
144}
145
146const refreshFeed = async ($: EngineInterface) => {
147 const ws = await read($, workspace)
148 if (ws === null || ws.home === undefined) return
149 let text: string
150 try {
151 text = await $.fs.read(`${ws.home}/${FEED_DIR}/${ws.sessionId}.json`)
152 } catch {
153 return // No feed (statusline-feed.sh isn't the status line): fallbacks apply.
154 }
155 const parsed = parseFeed(text)
156 if (parsed === null) return
157 await update($, feed, () => parsed)
158 if (parsed.isFastMode !== undefined) await setFastMode($, parsed.isFastMode)
159}
160
161const gh = ($: EngineInterface, cwd: string, args: string[]) =>
162 $.process.run(['gh', ...args], { cwd, timeoutMs: 30_000 })
163
164// Fetches the branch's PR and checks with gh. `force` skips the refresh
165// policy (a manual refresh, a PR just created); otherwise shouldFetch decides.
166const refreshCi = async ($: EngineInterface, force: boolean): Promise<CiStatus | null> => {
167 const ws = await read($, workspace)
168 if (ws === null || !ws.isGit || ws.branch === undefined || ws.branch === '') {
169 await update($, ci, () => null)
170 return null
171 }
172 const branch = ws.branch
173 const commitSha = ws.commitSha ?? ''
174 const prev = await read($, ci)
175 const at = await $.clock.now()
176 if (!force && !shouldFetch(prev, branch, commitSha, at)) return prev
177 if (await read($, isCiFetching)) return prev
178
179 await update($, isCiFetching, () => true)
180 try {
181 const view = await gh($, ws.cwd, ['pr', 'view', '--json', 'number,title,url,createdAt'])
182 let status: CiStatus
183 if (view.exitCode !== 0) {
184 status = isNoPrError(view.stderr)
185 ? { branch, commitSha, pr: null, checks: [], fetchedAt: at }
186 : {
187 ...(prev ?? { branch, commitSha, pr: null, checks: [] }),
188 fetchedAt: at,
189 error: view.stderr.trim().split('\n')[0] ?? 'gh pr view failed',
190 }
191 } else {
192 const pr = parsePr(view.stdout)
193 const checks =
194 pr === null ? [] : parseChecks((await gh($, ws.cwd, ['pr', 'checks', '--json', 'bucket,name,workflow'])).stdout)
195 status = { branch, commitSha, pr, checks, fetchedAt: await $.clock.now() }
196 }
197 await update($, ci, () => status)
198 return status
199 } catch (err) {
200 const message = err instanceof Error ? err.message : String(err)
201 await update($, ci, old => (old === null || old === undefined ? null : { ...old, error: message }))
202 return prev
203 } finally {
204 await update($, isCiFetching, () => false)
205 }
206}
207
208const ciSummaryText = (status: CiStatus | null) => {
209 if (status === null) return 'CI: not a git branch.'
210 if (status.error !== undefined) return `CI: ${status.error}`
211 if (status.pr === null) return `CI: no PR for ${status.branch}.`
212 const s = summarize(status.checks)
213 return `CI #${status.pr.number}: ${s.pass} pass, ${s.fail} fail, ${s.pending} pending`
214}
215
216const ciRefreshAction = async ($: EngineInterface) => {
217 $.ui.toast('Refreshing CI…', { timeoutMs: 2000 })
218 $.ui.toast(ciSummaryText(await refreshCi($, true)))
219}
220
221const openPrAction = async ($: EngineInterface) => {
222 let status = await read($, ci)
223 if (!status?.pr) status = await refreshCi($, true)
224 const pr = status?.pr
225 if (pr === null || pr === undefined) {
226 $.ui.toast(ciSummaryText(status))
227 return
228 }
229 // macOS has `open`; Linux desktops have `xdg-open`.
230 let isOpened = false
231 for (const opener of ['open', 'xdg-open']) {
232 try {
233 isOpened = (await $.process.run([opener, pr.url], { timeoutMs: 5000 })).exitCode === 0
234 } catch {
235 isOpened = false
236 }
237 if (isOpened) break
238 }
239 $.ui.toast(isOpened ? `Opening PR #${pr.number}` : `Couldn't open ${pr.url}`)
240}
241
242// ---- formatting ---------------------------------------------------------
243
244const barColor = (pct: number) => (pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green')
245
246// 9 -> "9%", 7.5 -> "7.5%".
247const fmtPercent = (pct: number) => `${Number(pct.toFixed(2))}%`
248
249const fmtDuration = (ms: number) => {
250 const total = Math.max(0, Math.floor(ms / 1000))
251 const d = Math.floor(total / 86400)
252 const h = Math.floor((total % 86400) / 3600)
253 const m = Math.floor((total % 3600) / 60)
254 const s = total % 60
255 let out = ''
256 if (d > 0) out += `${d}d `
257 if (d > 0 || h > 0) out += `${h}h `
258 if (d > 0 || h > 0 || m > 0) out += `${m}m `
259 return `${out}${s}s`
260}
261
262const fmtRemaining = (resetsAt: string | undefined, at: number) => {
263 if (resetsAt === undefined) return '-'
264 const secs = Math.floor((Date.parse(resetsAt) - at) / 1000)
265 if (!(secs > 0)) return '0:00'
266 return `${Math.floor(secs / 3600)}:${String(Math.floor((secs % 3600) / 60)).padStart(2, '0')}`
267}
268
269const fmtTokens = (n: number) =>
270 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
271
272const shortPath = (p: string, home: string | undefined, max: number) => {
273 const tilde = home !== undefined && p.startsWith(home) ? `~${p.slice(home.length)}` : p
274 return tilde.length > max ? `…${tilde.slice(-(max - 1))}` : tilde
275}
276
277const effortColor = (effort: string) =>
278 ({ max: 'magenta', xhigh: 'magenta', high: 'cyan', medium: 'blue', low: 'gray' })[effort] ?? 'white'
279
280const windowLabel = (kind: string) => ({ five_hour: '5h', seven_day: '7d' })[kind] ?? kind
281
282// ---- hooks --------------------------------------------------------------
283
284export const register: Register = (on, options) => {
285 const discountOption = toDiscount(options.discountPercent)
286
287 // The discount row in /config: show a change at once, whether or not the
288 // engine reloads the module with the new options.
289 on('config.set', async ($, e, next) => {
290 const set = await next(e)
291 if (set.deny === undefined && e.provider.plugin === 'sidebar' && e.key.endsWith('.discountPercent')) {
292 await update($, discountChange, () => toDiscount(set.value))
293 }
294
295 return set
296 })
297
298 on('session.start', async ($, e, next) => {
299 await $.command.register({ name: 'sidebar', description: 'Show or hide the session sidebar pane' })
300
301 const u = await $.session.usage()
302 await update($, usage, () => toUsage(u, u.startedAt))
303 const tick = async () => {
304 const t = await $.clock.now()
305 await update($, now, () => t)
306 await refreshModel($)
307 }
308 await tick()
309 await refreshWorkspace($)
310 await refreshFeed($)
311 await refreshFastMode($)
312
313 // Drives the elapsed time and the cache countdown between events.
314 $.clock.every(1000, () => void tick())
315 $.clock.every(FEED_POLL_MS, () => void refreshFeed($))
316
317 // CI: first fetch off the start-up path (gh takes a second or two), then
318 // the refresh policy on every tick.
319 await $.command.register({ name: CMD_CI_REFRESH, description: 'Refresh the sidebar CI status now (ctrl+x r)' })
320 await $.command.register({ name: CMD_OPEN_PR, description: "Open this branch's PR in the browser (ctrl+x o)" })
321 $.clock.after(0, () => void refreshCi($, false))
322 $.clock.every(CI_TICK_MS, () => void refreshCi($, false))
323
324 void $.ui.open({ id: PANE, title: 'Session', columns: PANE_COLUMNS })
325
326 return next(e)
327 })
328
329 // /sidebar toggles the pane: the one way to close it, since the close mark
330 // is ignored (below).
331 on('command.run', { command: 'sidebar' }, async $ => {
332 if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
333 await $.ui.close({ id: PANE })
334 return { text: 'Sidebar closed. /sidebar opens it again.' }
335 }
336 const opened = await $.ui.open({ id: PANE, title: 'Session', columns: PANE_COLUMNS })
337
338 return { text: opened.isPlaced ? 'Sidebar opened.' : `Sidebar not placed: ${opened.reason}` }
339 })
340
341 // ctrl+x r / ctrl+x o (and the pane's buttons). Answering with no text means a key
342 // press adds nothing to the conversation; the toast is the feedback.
343 on('command.run', { command: CMD_CI_REFRESH }, async $ => {
344 await ciRefreshAction($)
345
346 return {}
347 })
348
349 // The built-in /fast: re-read the setting as soon as it has toggled.
350 on('command.run', { command: 'fast' }, async ($, e, next) => {
351 const ran = await next(e)
352 await refreshFastMode($)
353
354 return ran
355 })
356
357 // The built-in /model: show the new model as soon as the command returns.
358 on('command.run', { command: 'model' }, async ($, e, next) => {
359 const ran = await next(e)
360 await refreshModel($)
361
362 return ran
363 })
364
365 on('command.run', { command: CMD_OPEN_PR }, async $ => {
366 await openPrAction($)
367
368 return {}
369 })
370
371 // A stray click on the pane's close mark (or its close key) keeps it open.
372 // Answering without next() refuses the close; the plugin's own close and an
373 // unload still go through.
374 on('ui.close', ($, e, next) => {
375 if (e.id === PANE && e.origin.kind === 'person') return { value: undefined }
376
377 return next(e)
378 })
379
380 on('session.measure', async ($, e, next) => {
381 await update($, usage, prev => toUsage(e, prev?.startedAt ?? 0))
382
383 return next(e)
384 })
385
386 // Every main-loop model request: time it for "api", and keep its token
387 // footprint and effort for the cache and effort chips.
388 on('turn.step', async function* ($, e, next) {
389 const startedAt = await $.clock.now()
390 const result = yield* next(e)
391 if (e.agentId !== undefined) return result
392
393 const at = await $.clock.now()
394 await update($, apiMs, ms => ms + (at - startedAt))
395 if (result.usage !== null) {
396 const u = result.usage
397 const contextTokens =
398 u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
399 const effort = e.effort === undefined ? undefined : String(e.effort)
400 await update($, lastStep, () => ({ at, contextTokens, effort, model: u.model }))
401 }
402
403 return result
404 })
405
406 // Branch, model and cwd can all move during a turn.
407 on('turn.complete', async ($, e, next) => {
408 if (e.agentId === undefined) {
409 await refreshWorkspace($)
410 await refreshFastMode($)
411 // A commit or branch switch this turn is a reason to fetch; off the
412 // turn's end so gh never delays it.
413 $.clock.after(0, () => void refreshCi($, false))
414 }
415
416 return next(e)
417 })
418
419 // A PR Claude just opened: fetch at once rather than wait for the no-PR poll.
420 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
421 const ran = await next(e)
422 if (ran.deny === undefined && ran.isError !== true && isPrCreated(e.command, ran.result.stdout)) {
423 $.clock.after(0, () => void refreshCi($, true))
424 }
425
426 return ran
427 })
428
429 // Claude's own task tools, main loop only (a subagent's list is its own).
430 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
431 const ran = await next(e)
432 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
433 await update($, claudeTasks, () => applyTodoWrite(e.todos))
434 }
435
436 return ran
437 })
438
439 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
440 const ran = await next(e)
441 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
442 const { id, subject } = ran.result.task
443 await update($, claudeTasks, list => applyTaskCreate(list, id, subject))
444 }
445
446 return ran
447 })
448
449 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
450 const ran = await next(e)
451 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true && ran.result.success) {
452 await update($, claudeTasks, list => applyTaskUpdate(list, e))
453 }
454
455 return ran
456 })
457
458 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
459 const { Box, Button, Text } = $.ui.resolve(e)
460 const [u, step, api, ws, at, prices, claude, ciStatus, isFetching, isFast, fed, changed] = await Promise.all([
461 read($, usage),
462 read($, lastStep),
463 read($, apiMs),
464 read($, workspace),
465 read($, now),
466 read($, pricing),
467 read($, claudeTasks),
468 read($, ci),
469 read($, isCiFetching),
470 read($, isFastMode),
471 read($, feed),
472 read($, discountChange),
473 ])
474 const discount = changed ?? discountOption
475 const listCost = u?.costUsd ?? 0
476 const home = ws?.home
477 const effort = fed?.effort ?? step?.effort
478 const pathWidth = Math.max(16, e.props.bodyColumns - 6)
479
480 const Bar = ({ pct, width }: { pct: number; width: number }) => {
481 const filled = Math.min(width, Math.floor((pct * width) / 100))
482 return (
483 <Text>
484 <Text color={barColor(pct)}>{'█'.repeat(filled)}{'░'.repeat(width - filled)}</Text> {Math.round(pct)}%
485 </Text>
486 )
487 }
488
489 const Heading = ({ color, children }: { color: string; children: string }) => (
490 <Text bold color={color}>── {children} ──</Text>
491 )
492
493 const cacheChip = () => {
494 const view = resolveCache(fed, step, CACHE_TTL)
495 const model = step?.model ?? ws?.model
496 if (view === null || model === undefined) return null
497 const { tokens, ttl } = view
498 const left = view.expiresAt - at
499 const toks = fmtTokens(tokens)
500 const listPrice = recacheCost(prices, model, tokens, ttl, isFast)
501 const cost = listPrice === undefined ? undefined : discounted(listPrice, discount)
502 const amount =
503 cost === undefined ? toks : `$${cost.toFixed(2)}${discount > 0 ? '*' : ''} (${toks})`
504 return left > 0 ? (
505 <Text>
506 <Text dimColor>cache </Text>
507 <Text color="green">warm</Text> <Text dimColor>{Math.floor(left / 60000)}m</Text>{' '}
508 <Text color={cost === undefined ? 'gray' : costColor(cost)}>↻{amount}</Text>
509 </Text>
510 ) : (
511 <Text>
512 <Text dimColor>cache </Text>
513 <Text color="red">cold ↻{amount}</Text>
514 </Text>
515 )
516 }
517
518 const taskRow = (t: TaskLine) => (
519 <Text wrap="truncate-end" color={taskColor(t.status)} dimColor={isTaskDim(t.status)}>
520 {taskIcon(t.status)} {t.title}
521 </Text>
522 )
523
524 const checkRow = (c: CiCheck) => (
525 <Text wrap="truncate-end">
526 {' '}
527 <Text color={c.bucket === 'fail' ? 'red' : 'yellow'}>{c.bucket === 'fail' ? '✗' : '◔'}</Text> {c.name}
528 {c.workflow !== undefined && c.workflow !== c.name && <Text dimColor> ({c.workflow})</Text>}
529 </Text>
530 )
531
532 const ciSection = () => {
533 if (ciStatus === null || ciStatus.pr === null) return null
534 const pr = ciStatus.pr
535 const s = summarize(ciStatus.checks)
536 return (
537 <Box flexDirection="column">
538 <Text> </Text>
539 <Heading color="green">CI</Heading>
540 <Text wrap="truncate-end">
541 <Text dimColor>#{pr.number}</Text> {pr.title}
542 </Text>
543 <Text>
544 {s.pass > 0 && <Text color="green">✓ {s.pass} </Text>}
545 {s.fail > 0 && <Text color="red">✗ {s.fail} </Text>}
546 {s.pending > 0 && <Text color="yellow">◔ {s.pending} </Text>}
547 {s.cancel > 0 && <Text color="yellow">⊘ {s.cancel} </Text>}
548 {s.skip > 0 && <Text dimColor>⊝ {s.skip}</Text>}
549 {ciStatus.checks.length === 0 && <Text dimColor>no checks yet</Text>}
550 </Text>
551 {notableChecks(ciStatus.checks).map(checkRow)}
552 {ciStatus.error !== undefined && (
553 <Text color="red" wrap="truncate-end">! {ciStatus.error}</Text>
554 )}
555 <Text dimColor>
556 {isFetching ? 'refreshing…' : `checked ${fmtDuration(at - ciStatus.fetchedAt)} ago`}
557 </Text>
558 <Box flexDirection="row">
559 <Button key="ci-refresh" label="refresh" onPress={() => void ciRefreshAction($)} />
560 <Text> </Text>
561 <Button key="open-pr" label="open PR" onPress={() => void openPrAction($)} />
562 </Box>
563 <Box flexDirection="row">
564 <Text dimColor>^X r refresh ^X o open PR</Text>
565 </Box>
566 </Box>
567 )
568 }
569
570 const rateRow = (r: RateWindow) => (
571 <Text>
572 <Text dimColor>{windowLabel(r.kind).padEnd(5)}</Text>
573 <Bar pct={r.percentUsed} width={12} /> <Text dimColor>{fmtRemaining(r.resetsAt, at)}</Text>
574 </Text>
575 )
576
577 return (
578 <Box flexDirection="column">
579 <Heading color="magenta">Model</Heading>
580 <Text color="cyan">{ws?.model ?? step?.model ?? '?'}</Text>
581 {isFast && (
582 <Text bold color="red" wrap="truncate-end">
583 ⚡ FAST MODE ON: /fast to turn off
584 </Text>
585 )}
586 <Text>
587 <Text dimColor>ctx </Text>
588 <Bar pct={u?.contextPercent ?? 0} width={12} />
589 </Text>
590 {(u?.rateLimits ?? []).map(rateRow)}
591 {cacheChip()}
592
593 <Text> </Text>
594 <Heading color="yellow">Session</Heading>
595 <Text>
596 <Text dimColor>cost </Text>
597 <Text color="yellow">${discounted(listCost, discount).toFixed(2)}</Text>
598 {discount > 0 && <Text dimColor>*</Text>}
599 </Text>
600 <Text>
601 <Text dimColor>time </Text>
602 {u === null ? '-' : fmtDuration(at - u.startedAt)}
603 </Text>
604 <Text>
605 <Text dimColor>api </Text>
606 {fmtDuration(fed?.apiDurationMs ?? api)}
607 </Text>
608 {effort !== undefined && (
609 <Text>
610 <Text dimColor>eff </Text>
611 <Text color={effortColor(effort)}>{effort}</Text>
612 {isFast && <Text color="yellow"> ⚡</Text>}
613 </Text>
614 )}
615 {ws !== null && (
616 <Box flexDirection="column">
617 <Box flexDirection="row">
618 <Text dimColor>sid </Text>
619 <Button
620 key="copy-sid"
621 label="copy"
622 onPress={press => void $.ui.copy({ text: ws.sessionId, surface: press.surface })}
623 />
624 </Box>
625 <Text wrap="truncate-end">{ws.sessionId}</Text>
626 </Box>
627 )}
628
629 {ws !== null && (
630 <Box flexDirection="column">
631 <Text> </Text>
632 <Heading color="blue">Workspace</Heading>
633 {ws.repoName !== undefined && (
634 <Text>
635 <Text dimColor>repo </Text>
636 {ws.repoName}
637 {ws.isWorktree && <Text dimColor> (wt)</Text>}
638 </Text>
639 )}
640 <Text>
641 <Text dimColor>cwd </Text>
642 {shortPath(ws.cwd, home, pathWidth)}
643 </Text>
644 {ws.root !== ws.cwd && (
645 <Text>
646 <Text dimColor>proj </Text>
647 {shortPath(ws.root, home, pathWidth)}
648 </Text>
649 )}
650 {ws.branch !== undefined && ws.branch !== '' && (
651 <Text>
652 <Text dimColor>br </Text>
653 {ws.branch}
654 </Text>
655 )}
656 {(fed?.addedDirs.length ?? 0) > 0 && <Text dimColor>added:</Text>}
657 {(fed?.addedDirs ?? []).map(d => (
658 <Text wrap="truncate-start"> {shortPath(d, home, e.props.bodyColumns - 2)}</Text>
659 ))}
660 </Box>
661 )}
662
663 {ciSection()}
664
665 {claude.length > 0 && (
666 <Box flexDirection="column">
667 <Text> </Text>
668 <Heading color="cyan">Tasks</Heading>
669 {claude.map(taskRow)}
670 </Box>
671 )}
672
673 {discount > 0 && (
674 <Box flexDirection="column">
675 <Text> </Text>
676 <Heading color="yellow">Cost</Heading>
677 <Text wrap="truncate-end">* {fmtPercent(discount)} discount off list prices</Text>
678 <Text>
679 <Text dimColor>list </Text>${listCost.toFixed(2)}
680 </Text>
681 <Text dimColor wrap="wrap">Change it in /config: Billing discount (%)</Text>
682 </Box>
683 )}
684 </Box>
685 )
686 })
687}
688hooks/ci.ts 75 lines1import type { CiCheck, CiPr, CiStatus } from '../types'
2
3// The branch's PR and checks, from gh, and when to fetch them again. The mod
4// runs gh itself on a timer, so there is no cache file or lock.
5
6export const parsePr = (json: string): CiPr | null => {
7 try {
8 const pr: unknown = JSON.parse(json)
9 if (typeof pr !== 'object' || pr === null) return null
10 const { number, title, url, createdAt } = pr as Record<string, unknown>
11 return typeof number === 'number' && typeof title === 'string' && typeof url === 'string'
12 ? { number, title, url, createdAt: typeof createdAt === 'string' ? createdAt : undefined }
13 : null
14 } catch {
15 return null
16 }
17}
18
19// `gh pr checks` exits non-zero while checks fail or pend, so its stdout is
20// read whatever the exit code.
21export const parseChecks = (json: string): CiCheck[] => {
22 try {
23 const rows: unknown = JSON.parse(json)
24 if (!Array.isArray(rows)) return []
25 return rows.flatMap(r =>
26 typeof r?.bucket === 'string' && typeof r?.name === 'string'
27 ? [{ bucket: r.bucket, name: r.name, workflow: typeof r.workflow === 'string' && r.workflow !== '' ? r.workflow : undefined }]
28 : [],
29 )
30 } catch {
31 return []
32 }
33}
34
35// gh's way of saying the branch has no PR: an expected state, not an error.
36export const isNoPrError = (stderr: string) => /no (open )?pull requests? (found|associated)/i.test(stderr)
37
38// When to fetch again:
39// nothing fetched for this branch -> now
40// local commit moved -> now (CI reports on another sha)
41// any check pending -> every 60s
42// no PR -> every 300s (one may be opened elsewhere)
43// PR under 10 min old, no checks -> every 30s (workflows still registering)
44// all checks terminal -> never (F5 forces)
45export const shouldFetch = (ci: CiStatus | null, branch: string, commitSha: string, now: number) => {
46 if (ci === null || ci.branch !== branch) return true
47 if (commitSha !== '' && ci.commitSha !== commitSha) return true
48 const age = now - ci.fetchedAt
49 if (ci.checks.some(c => c.bucket === 'pending')) return age >= 60_000
50 if (ci.pr === null) return age >= 300_000
51 if (ci.checks.length === 0) {
52 const prAge = ci.pr.createdAt === undefined ? Infinity : now - Date.parse(ci.pr.createdAt)
53 if (prAge < 600_000) return age >= 30_000
54 }
55 return false
56}
57
58export type CiSummary = { pass: number; fail: number; pending: number; cancel: number; skip: number }
59
60export const summarize = (checks: readonly CiCheck[]): CiSummary => ({
61 pass: checks.filter(c => c.bucket === 'pass').length,
62 fail: checks.filter(c => c.bucket === 'fail').length,
63 pending: checks.filter(c => c.bucket === 'pending').length,
64 cancel: checks.filter(c => c.bucket === 'cancel').length,
65 skip: checks.filter(c => c.bucket === 'skipping').length,
66})
67
68// The checks worth naming: failures and in-flight jobs, at most six.
69export const notableChecks = (checks: readonly CiCheck[]) =>
70 checks.filter(c => c.bucket === 'fail' || c.bucket === 'pending').slice(0, 6)
71
72// A Bash call that really opened a PR prints its URL (a --dry-run or --help doesn't).
73export const isPrCreated = (command: string, stdout: string) =>
74 command.includes('gh pr create') && /https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/.test(stdout)
75hooks/feed.ts 63 lines1import type { Feed, LastStep } from '../types'
2
3// The status line JSON statusline-feed.sh saves per session, at
4// ~/.claude/state/statusline/<session_id>.json.
5export const FEED_DIR = '.claude/state/statusline'
6
7const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
8
9export const parseFeed = (json: string): Feed | null => {
10 let raw: unknown
11 try {
12 raw = JSON.parse(json)
13 } catch {
14 return null
15 }
16 if (typeof raw !== 'object' || raw === null) return null
17 const j = raw as {
18 fast_mode?: unknown
19 effort?: { level?: unknown }
20 prompt_cache?: { ttl?: unknown; warm?: unknown; expires_at?: unknown; recache_tokens_if_cold?: unknown }
21 workspace?: { added_dirs?: unknown }
22 cost?: { total_api_duration_ms?: unknown }
23 }
24 const cache = j.prompt_cache ?? {}
25 const expiresAt = num(cache.expires_at)
26 const dirs = j.workspace?.added_dirs
27 return {
28 isFastMode: typeof j.fast_mode === 'boolean' ? j.fast_mode : undefined,
29 cacheTtl: cache.ttl === '5m' || cache.ttl === '1h' ? cache.ttl : undefined,
30 isCacheWarm: typeof cache.warm === 'boolean' ? cache.warm : undefined,
31 // The status line gives seconds.
32 cacheExpiresAt: expiresAt === undefined ? undefined : expiresAt * 1000,
33 recacheTokens: num(cache.recache_tokens_if_cold),
34 effort: typeof j.effort?.level === 'string' ? j.effort.level : undefined,
35 apiDurationMs: num(j.cost?.total_api_duration_ms),
36 addedDirs: Array.isArray(dirs) ? dirs.filter((d): d is string => typeof d === 'string') : [],
37 }
38}
39
40const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
41
42export type CacheView = { tokens: number; expiresAt: number; ttl: '5m' | '1h' }
43
44// What the cache chip shows, from the best source there is:
45// 1. the status line's prompt_cache;
46// 2. this process's last main-loop response, plus the TTL.
47// Null when neither has a figure: a fresh session before its first reply, or
48// a resumed one before this process's first request (prompt_cache is null
49// then, and mods can't hook SessionStart, which has the resume figures).
50export const resolveCache = (
51 feed: Feed | null,
52 step: LastStep | null,
53 assumedTtl: '5m' | '1h',
54): CacheView | null => {
55 const ttl = feed?.cacheTtl ?? assumedTtl
56 if (feed?.recacheTokens !== undefined && feed.cacheExpiresAt !== undefined) {
57 // A cold cache reads as expired now, whatever expires_at says.
58 return { tokens: feed.recacheTokens, expiresAt: feed.isCacheWarm === false ? 0 : feed.cacheExpiresAt, ttl }
59 }
60 if (step !== null) return { tokens: step.contextTokens, expiresAt: step.at + TTL_MS[ttl], ttl }
61 return null
62}
63hooks/pricing.ts 49 lines1import type { Price } from '../types'
2
3// Reads ~/.claude/state/model-pricing.tsv (optional; see README.md), tab
4// separated, USD per million tokens:
5// model_id, input, write_5m, write_1h, cache_read, output, fast_input
6export const parsePricing = (tsv: string): Price[] =>
7 tsv.split('\n').flatMap(line => {
8 if (line.startsWith('#') || line.startsWith('model_id')) return []
9 const [id, inp, w5, w1, , , fast] = line.split('\t')
10 const input = Number(inp)
11 const write5m = Number(w5)
12 const write1h = Number(w1)
13 if (!id || ![input, write5m, write1h].every(Number.isFinite)) return []
14 const fastInput = fast === undefined || fast.trim() === '' ? undefined : Number(fast)
15 return [{ id, input, write5m, write1h, fastInput: Number.isFinite(fastInput) ? fastInput : undefined }]
16 })
17
18// Dollar cost of re-writing the cache if the next submit lands cold. The
19// model matches by longest id prefix, so dated or suffixed ids resolve
20// ("claude-haiku-4-5-20251001", "claude-opus-5-5[1m]"). In fast mode the
21// cache-write multiplier stacks on the fast input price, so the write rate
22// scales by fast_input / input.
23export const recacheCost = (
24 prices: Price[],
25 model: string,
26 tokens: number,
27 ttl: '5m' | '1h',
28 isFast = false,
29) => {
30 const id = model.replace(/\[.*$/, '')
31 const best = prices
32 .filter(p => id === p.id || id.startsWith(`${p.id}-`))
33 .sort((a, b) => b.id.length - a.id.length)[0]
34 if (best === undefined) return undefined
35 const write = ttl === '1h' ? best.write1h : best.write5m
36 const rate = isFast && best.fastInput !== undefined && best.input > 0 ? (write * best.fastInput) / best.input : write
37 return (tokens * rate) / 1_000_000
38}
39
40// The discountPercent setting as a usable rate: 0 to 100, anything else 0.
41export const toDiscount = (value: unknown) =>
42 typeof value === 'number' && Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0
43
44// A list-price figure (Claude Code's session cost, a rebuild estimate) after
45// the billing discount.
46export const discounted = (usd: number, percent: number) => usd * (1 - percent / 100)
47
48export const costColor =(usd: number) => (usd >= 5 ? 'red' : usd >= 1 ? 'yellow' : 'gray')
49hooks/tasks.ts 34 lines1import type { TaskLine } from '../types'
2
3// Claude's own task tools, replayed from their tool calls: the mod has no
4// way to ask for the list, so it keeps its own copy.
5
6// TodoWrite sends the whole list every time.
7export const applyTodoWrite = (todos: readonly { content: string; status: string }[]): TaskLine[] =>
8 todos.map((t, i) => ({ id: String(i), title: t.content, status: t.status }))
9
10export const applyTaskCreate = (list: TaskLine[], id: string, subject: string): TaskLine[] => [
11 ...list.filter(t => t.id !== id),
12 { id, title: subject, status: 'pending' },
13]
14
15export const applyTaskUpdate = (
16 list: TaskLine[],
17 change: { taskId: string; subject?: string; status?: string },
18): TaskLine[] =>
19 change.status === 'deleted'
20 ? list.filter(t => t.id !== change.taskId)
21 : list.map(t =>
22 t.id === change.taskId
23 ? { ...t, title: change.subject ?? t.title, status: change.status ?? t.status }
24 : t,
25 )
26
27export const taskIcon = (status: string) =>
28 ({ completed: '✓', in_progress: '→', blocked: '✗', cancelled: '–' })[status] ?? ' '
29
30export const taskColor = (status: string) =>
31 ({ in_progress: 'green', blocked: 'red' })[status]
32
33export const isTaskDim = (status: string) => status === 'completed' || status === 'cancelled'
34types/index.d.ts 90 lines1export type RateWindow = { kind: string; percentUsed: number; resetsAt?: string }
2
3// What session.measure / $.session.usage() last reported.
4export type Usage = {
5 contextPercent?: number
6 rateLimits: RateWindow[]
7 costUsd?: number
8 startedAt: number
9}
10
11// The main loop's most recent model response, for the prompt-cache chip.
12export type LastStep = {
13 at: number
14 // Tokens the next request re-sends (and re-writes if the cache went cold).
15 contextTokens: number
16 effort?: string
17 model: string
18}
19
20// One row of ~/.claude/state/model-pricing.tsv, USD per million tokens.
21// fastInput is absent for a model with no fast mode.
22export type Price = { id: string; input: number; write5m: number; write1h: number; fastInput?: number }
23
24// One row of the Tasks section: pending, in_progress or completed.
25export type TaskLine = { id: string; title: string; status: string }
26
27// What statusline-feed.sh saved from the status line's JSON for this session:
28// the facts the mod API doesn't expose. Each is absent when the JSON had none.
29export type Feed = {
30 isFastMode?: boolean
31 cacheTtl?: '5m' | '1h'
32 isCacheWarm?: boolean
33 // Epoch milliseconds.
34 cacheExpiresAt?: number
35 recacheTokens?: number
36 effort?: string
37 // The session's API time, resumed sessions included.
38 apiDurationMs?: number
39 addedDirs: string[]
40}
41
42// gh pr checks' bucket: pass, fail, pending, skipping, cancel.
43export type CiCheck = { bucket: string; name: string; workflow?: string }
44
45export type CiPr = { number: number; title: string; url: string; createdAt?: string }
46
47// The branch's PR and its checks, fetched straight from gh by the mod.
48export type CiStatus = {
49 branch: string
50 commitSha: string
51 pr: CiPr | null
52 checks: CiCheck[]
53 fetchedAt: number
54 // Set when the last fetch failed (auth, network); the data is the last good one.
55 error?: string
56}
57
58export type Workspace = {
59 sessionId: string
60 model: string
61 cwd: string
62 root: string
63 branch?: string
64 commitSha?: string
65 isWorktree: boolean
66 repoName?: string
67 isGit: boolean
68 home?: string
69}
70
71declare module 'claude-code' {
72 interface PluginState {
73 sidebar: {
74 usage: Usage | null
75 lastStep: LastStep | null
76 apiMs: number
77 workspace: Workspace | null
78 pricing: Price[]
79 claudeTasks: TaskLine[]
80 ci: CiStatus | null
81 isCiFetching: boolean
82 isFastMode: boolean
83 feed: Feed | null
84 // The billing discount, percent, once changed in /config this session.
85 discountChange: number | null
86 now: number
87 }
88 }
89}
90