SLOPSHOPPER

sidebar

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

newpaneguardcommandtoastprocess
★ 1v0.2.0no licenseupdated 2026-10-09pthexton/claude-sidebar-mod/sidebar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sidebar
│ ┃ sidebar ✕ › fix the failing auth test and add an audi╭───────────────────────╮ │ ┃ ── Model ── │ sidebar │ │ ┃ claude-opus-5-5 ⏺ Read(src/auth.ts) │ CI: not a git branch. │ │ ┃ ctx █████░░░░░░░ 49% ⎿ Read 6 lines ╰───────────────────────╯ │ ┃ 5h ███░░░░░░░░░ 31% 0:00 ⏺ Update(src/auth.ts) ╭───────────────────────╮ │ ┃ ⎿ Added 2 lines, removed 1 line │ sidebar │ │ ┃ ── Session ── ⏺ Bash(bun test) │ CI: not a git branch. │ │ ┃ cost $0.38* ⎿ 3 pass, 1 fail ╰───────────────────────╯ │ ┃ time 30m 0s │ ┃ api 0s ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ sid [ copy ] │ ┃ preview-session ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ── Workspace ── › /sidebar │ ┃ repo app ⎿ sidebar: Sidebar closed. /sidebar opens it again. │ ┃ cwd /work/app │ ┃ │ ┃ ── Cost ── │ ┃ * 9% discount off list prices │ ┃ list $0.42 │ ┃ Change it in /config: Billing discount (%) │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sidebar
── Model ── claude-opus-5-5 ctx █████░░░░░░░ 49% 5h ███░░░░░░░░░ 31% 0:00 ── Session ── cost $0.38* time 30m 0s api 0s sid [ copy ] preview-session ── Workspace ── repo app cwd /work/app ── Cost ── * 9% discount off list prices list $0.42 Change it in /config: Billing discount (%)
README

claude-sidebar-mod

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.

Install

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).

Required setup

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.

Sections, and what each needs

SectionShowsNeeds
Modelmodel, context bar, 5h / 7d rate-limit bars (subscriptions only), prompt-cache chipnothing; see the two optional extras below for a fuller cache chip
Sessioncost, elapsed time, API time, effort, session id with a copy buttonnothing
Workspacerepo (and (wt) in a worktree), cwd, project root, branch, added dirsgit on PATH; added dirs need the status line feed
CIthe branch's PR, check counts, failing and pending checks, refresh and open-PR buttonsgh, signed in (gh auth login). Hidden when there's no PR or gh can't answer
TasksClaude's own task list (TodoWrite, TaskCreate, TaskUpdate) for the main conversationnothing
Costthe billing discount in use, the session cost at list price, and where to change the discount. Hidden when the discount is 0nothing

Billing discount

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).

Optional extras

Status line feed: fast mode, real cache TTL, added dirs

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:

  • a red FAST MODE ON line (and a toast) when fast mode is on, since it bills at a higher rate;
  • the cache chip's real TTL, expiry and rebuild size;
  • effort from the start of the session;
  • API time including time before a resume;
  • added dirs.

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.

Pricing file: cache rebuild cost in dollars

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.

Keys for CI

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.

Developing

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.

Source 6 files
hooks/register.tsx 688 lines
1import { 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}
688
hooks/ci.ts 75 lines
1import 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)
75
hooks/feed.ts 63 lines
1import 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}
63
hooks/pricing.ts 49 lines
1import 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')
49
hooks/tasks.ts 34 lines
1import 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'
34
types/index.d.ts 90 lines
1export 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