Quota, Claude sessions and background jobs pane

<img src="docs/readme-logo.png" alt="usage logo" width="128">
usage puts your 5-hour and weekly limits in the macOS menu bar or Windows system tray, colored from green to red. Hitting the limit halfway through a long refactor is a bad way to find out you were running low. Now you see it coming. There's nothing to run and no page to open.
繁體中文 · 简体中文 · English · 日本語 · 한국어 | Discussions | Landing page
<img src="docs/showcase-v3.en.png" alt="usage — Claude Code, Codex, and Antigravity quota pinned to the macOS menu bar" width="820">
Runs on macOS 12 or newer and Windows 10/11.
macOS, with Homebrew (recommended):
brew install --cask aqua5230/usage/usage
It lands in your Applications folder, and brew upgrade --cask usage keeps it current. Prefer a direct download? Get usage.app.zip from the latest release, unzip it, and drag usage.app into Applications.
First launch on macOS: if macOS 15 or later blocks it, open System Settings → Privacy & Security, scroll down, and click Open Anyway. On macOS 14 or earlier, right-click usage.app in Finder → Open once. Then click the menu bar icon.
Windows: download usage-windows.zip from the latest release, unzip it, and run usage.exe. No installer is needed. If SmartScreen shows Windows protected your PC, click More info → Run anyway. See Windows Support.
Terminal only, any OS (Linux included): uvx usage-cli opens the terminal interface with nothing to install; uv prepares Python 3.13 by itself. For a persistent usage command, run uv tool install usage-cli. On Linux, usage setup installs the Claude Code status line too. This path has no menu bar or tray app.
usage needs data from at least one of Claude Code, Codex, Antigravity, or Grok CLI, or a running Claude Desktop app.
If you've used Codex, usage picks up its history automatically. For Claude Code, click the "Set Up Status Line" button in the app popover to install the sync hook. Restart the relevant tool afterward (on macOS, fully Cmd+Q Claude Code and re-open it; on Windows, restart your terminal or start a new session).
The same button also sets up a status line for the Antigravity CLI and for Grok CLI when they are installed on your machine, and does nothing at all when they aren't. Any status line you configured there yourself is backed up first and restored when you turn the switch off.
Once set up, the bottom of the Claude Code window will show a status line like this:
<img src="docs/statusline.en.gif" alt="Claude Code statusLine display (English)" width="900">
Gemini ⇄ tag to switch to the separate Claude / GPT pool; the choice is remembered./clear or /compact before the context window bloats, and says why the prompt cache missed./resume, no recap. Off by default.usage status --json hands your Claude Code, Codex, Antigravity, and Grok quota to Starship, tmux, or your own scripts. Ready-made snippets.usage setup on Ubuntu./resume a conversation that sat long enough for its cache to expire, it warns how many tokens the next message will re-send and suggests /compact first./terms opens your term history. Picking terms asks Claude Haiku through your Claude Code, and pauses while your 5-hour quota is at 90% or more.See your quota, other conversations, and background jobs without leaving Claude Code. Available on macOS and Windows.
What you’ll see
How to enable
/reload-plugins./usage-dash./config.usage reads its local plan-usage history. No cookies, login tokens, or API calls are needed. Reset times appear only when its local cache confirms them; they are never estimated.usage never writes that credential back and keeps any refreshed access token in memory only; the call itself reads quota metadata.~/.usage/glossary.json.When Claude Code quota files are unavailable, usage reads Claude Desktop's local plan-usage-history.json, including Microsoft Store installations on Windows. When a recent response in its local Chromium block-file HTTP cache matches the organization, usage also reads the exact session and weekly reset times. Newer cached observations take precedence over throttled history samples; older cache must match the percentages. Missing, unsupported, expired, or inconsistent cache data leaves the countdown unknown.
Desktop samples normally update every 5–15 minutes. The panel shows the observation age, marks it stale after 30 minutes, and stops displaying it after two hours. The most recent organization sample is used; custom desktop profiles are not discovered. These quota caches contain no per-project token counts. Desktop sessions that also write compatible Claude Code logs under ~/.claude/projects/ are counted by the existing project/token reports.
Windows has the full core experience: the system-tray UI with the same 16 themes as macOS, the Claude Code status-line hook, and Codex history parsing all work natively. The tray UI requires Microsoft Edge WebView2 Runtime, which is normally included with Windows 10 and 11.
The system-tray icon shows the remaining session quota percentage for Claude or Codex. Choose Tray Display Source → Claude Code / Codex in the right-click menu or panel menu; the change applies immediately and survives restarts (default: Claude). If Codex has no session window, the icon uses its weekly quota instead and the tooltip identifies that window. Missing quota data shows --. The tooltip summarizes both tools, with the selected source first. Left-click opens the quota themes in WebView2. Right-click also provides Reset Panel Position and Quit; panel switching, refresh, launch at login, and update checks are in the panel menu.
Enable Show Taskbar Quota in either menu for a transparent Codex: 92% label inside the taskbar, immediately left of the notification area. It follows taskbar position, scaling, and light/dark theme, and hides during fullscreen use or taskbar auto-hide. Click the label to open the panel. If buttons leave insufficient space, it moves just outside the taskbar. The normal app icon remains as a menu entry point; the label follows the selected source and quota window. Right-click the label to open the same menu as the tray icon.
The panel opens at the bottom-right of the working area rather than next to the tray icon, and update prompts use a system Yes/No dialog.
Free code signing provided by SignPath.io, certificate by SignPath Foundation.
Team roles:
Privacy policy: this program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it. See Privacy & Data Sources for the network calls usage makes on your behalf and how to avoid them.
Switch between 16 visual themes directly from the UI:
<img src="docs/classic.en.png" width="24%" alt="Classic theme" /> <img src="docs/matrix.en.png" width="24%" alt="Matrix theme" /> <img src="docs/win95.en.png" width="24%" alt="Windows 95 theme" /> <img src="docs/newspaper.en.png" width="24%" alt="Newspaper theme" />
<img src="docs/cloud_observation.en.png" width="24%" alt="Cloud Observation theme" /> <img src="docs/aquarium.en.png" width="24%" alt="Midnight Aquarium theme" /> <img src="docs/prism_arcade.en.png" width="24%" alt="Prism Arcade theme" /> <img src="docs/stained_glass.en.png" width="24%" alt="Stained Glass theme" /> <img src="docs/origami.en.png" width="24%" alt="Origami theme" /> <img src="docs/black_hole.en.png" width="24%" alt="Black Hole theme" /> <img src="docs/world_cup.en.png" width="24%" alt="World Cup 2026 theme" /> <img src="docs/lepidoptera.en.png" width="24%" alt="Lepidoptera theme" /> <img src="docs/migration.en.png" width="24%" alt="Migration theme" /> <img src="docs/catppuccin.en.png" width="24%" alt="Catppuccin theme" /> <img src="docs/sketchbook.en.png" width="24%" alt="Sketchbook theme" /> <img src="docs/heart_monitor.en.png" width="24%" alt="Heart Monitor theme" />
If the menu bar shows --, it's usually not broken — there's just no local data yet.
| Symptom | Likely cause | Fix |
|---|---|---|
Menu bar shows -- | No data yet, or Claude Code hook not refreshed | Run one Codex conversation. For Claude Code, click "Set Up Status Line" (from source: python3 main.py --setup) |
ImportError from main.py inside usage.app | The bundled main.py needs the bundle's own interpreter and cannot be run by hand | Don't run that copy. Click "Set Up Status Line" in the app, or clone the repo to run from source |
| Accidentally hit "Quit" | Process terminated | Relaunch usage.app from Spotlight or Applications. (launchctl start com.lollapalooza.usage only works if you enabled Launch at Login.) |
| Status says "N minutes stale" | Claude Code isn't running | Open Claude Code and let it run |
| Codex section is empty | No Codex history found | Run a Codex conversation to generate logs |
| Today's cost shows $0.00 | Model pricing missing | Delete ~/.usage/pricing_cache.json or check USAGE_DEBUG=1 |
| Antigravity card is missing | Antigravity CLI not installed or not signed in | Install and sign in to the Antigravity CLI; the card appears automatically once a background quota fetch succeeds |
| App won't open | macOS Gatekeeper blocked it | See First launch on macOS |
| Windows shows "Windows protected your PC" | SmartScreen doesn't recognize the download yet | Click More info → Run anyway |
| Feature | usage | ccusage | TokenTracker |
|---|---|---|---|
| Always on screen | ✅ | — | ✅ |
| macOS menu bar & Windows system tray | ✅ | — | macOS only |
| Claude Code & Codex usage | ✅ | ✅ | ✅ |
| Antigravity usage (Gemini and Claude / GPT) | ✅ | — | — |
| Grok CLI usage | ✅ | — | — |
| Muse Code token spend | ✅ | — | — |
| Claude Code & Codex service-status alerts | ✅ | — | — |
| HTML deep reports & UI | ✅ | ✅ | — |
| Claude Code helpers (Token Saver, Progress Concierge, Beginner and Quota-Aware modes, health check) | ✅ | — | — |
| AI Update Daily | ✅ | — | — |
| Open-source license | AGPL-3.0 | MIT | — |
usage to read.uvx usage-cli) runs on Linux.Building from source, configuring custom agents, or running the terminal TUI? See the development docs.
Licensed under AGPL-3.0-only (see LICENSE). If you fork or redistribute a modified version, please credit the original author and link back to: https://github.com/aqua5230/usage
hooks/register.tsx 392 lines1import { t, configure, statusArgv } from './strings'
2import { atom, read, update } from 'claude-code'
3import type { Hook, Register } from 'claude-code'
4import type { Run, Session } from '../types'
5import { matchAgent, isShellBackgrounded, parseNotifications, backgroundTasks, liveBgTasks, STALE_MS } from './parse'
6import { parseQuota, hideAgents, byTightest, showsUnavailable, effectivePercent, dockQuotaLine, staleAge, refreshedAgo } from './quota'
7import { compactLines } from './compact'
8import { parseLiveSession, liveSessions, tasklistPids, toSession, newest, isBusy, waitingText, settleNotifications, sessionMark } from './sessions'
9import { contextPercent, contextColor, parseContext, staleContext, completedAgo, isWaiting, visibleAgents, backgroundCount, visibleRuns } from './display'
10const PANE = 'usage-dash'
11const quotas = atom({ plugin: 'usage-dash', key: 'quotas' } as const, { agents: {} })
12const updated = atom({ plugin: 'usage-dash', key: 'updated' } as const, null)
13const quotaError = atom({ plugin: 'usage-dash', key: 'quotaError' } as const, '')
14const sessions = atom({ plugin: 'usage-dash', key: 'sessions' } as const, [])
15const pendingToasts = atom({ plugin: 'usage-dash', key: 'pendingToasts' } as const, [])
16const sessionError = atom({ plugin: 'usage-dash', key: 'sessionError' } as const, '')
17const agents = atom({ plugin: 'usage-dash', key: 'agents' } as const, [])
18const runs = atom({ plugin: 'usage-dash', key: 'runs' } as const, [])
19const bgTasks = atom({ plugin: 'usage-dash', key: 'bgTasks' } as const, [])
20const collapsed = atom({ plugin: 'usage-dash', key: 'collapsed' } as const, { quota: false, sessions: false, runs: false })
21const more = atom({ plugin: 'usage-dash', key: 'more' } as const, false)
22const moreRuns = atom({ plugin: 'usage-dash', key: 'moreRuns' } as const, false)
23type Dollar = Parameters<Hook<'session.start'>>[0]
24const KEEP_DONE_MS = 300000
25function isVisible(r: Run, now: number): boolean { return r.end === null ? now - r.start < STALE_MS : now - r.end < KEEP_DONE_MS }
26async function getHome($: Dollar) { return (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) }
27function clearLater($: Dollar) {
28 $.clock.after(KEEP_DONE_MS + 1000, async () => {
29 const now = await $.clock.now()
30 await update($, runs, list => list.filter(r => isVisible(r, now)))
31 await reportContext($, null)
32 })
33}
34async function finish($: Dollar, text: string) {
35 const done = parseNotifications(text)
36 if (!done.length) return
37 const end = await $.clock.now()
38 await update($, runs, list => list.map(r => {
39 const d = done.find(x => x.toolUseId === r.id)
40 return d && r.end === null ? { ...r, end, status: d.status } : r
41 }))
42 await update($, bgTasks, list => liveBgTasks(list.filter(task => !done.some(d => d.taskId === task.id)), end))
43 await reportContext($, null)
44 clearLater($)
45}
46async function refreshQuota($: Dollar) {
47 const home = await getHome($)
48 const failures: string[] = []
49 const commands = statusArgv ? [statusArgv] : ['usage', ...(home ? [`${home}/.local/bin/usage`] : [])].map(executable => [executable, 'status', '--json'])
50 for (const argv of commands) {
51 try {
52 const result = await $.process.run(argv, { timeoutMs: 10000 })
53 if (result.exitCode !== 0) throw new Error(`${argv[0]}: ${result.stderr || t('exit_code', { code: result.exitCode })}`)
54 const parsed = parseQuota(result.stdout)
55 let preferences = ''
56 try { if (home) preferences = await $.fs.read(`${home}/.claude/usage-preferences.json`) } catch { /* missing preferences are normal */ }
57 const data = hideAgents(parsed, preferences)
58 const now = await $.clock.now()
59 await update($, quotas, () => data)
60 await update($, updated, () => now)
61 await update($, quotaError, () => '')
62 return
63 } catch (error) { failures.push(String(error)) }
64 }
65 await update($, quotaError, () => t('quota_error', { error: failures.join('; ') }))
66}
67async function reportContext($: Dollar, waiting: boolean | null = false) {
68 try {
69 const home = await getHome($), sessionId = await $.session.id()
70 if (!home || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return
71 const directory = `${home}/.usage/claude-pane/live`
72 const path = `${directory}/${sessionId}.json`
73 const now = await $.clock.now()
74 const jobs = backgroundCount((await read($, runs)).filter(r => isVisible(r, now)), visibleAgents(await read($, agents)), liveBgTasks(await read($, bgTasks), now))
75 let previous: ReturnType<typeof parseContext> = null
76 try { previous = parseContext(await $.fs.read(path)) } catch { /* no previous context is normal */ }
77 if (waiting === null && previous?.jobs === jobs) return
78 let percent = previous?.percent, tokens = previous?.tokens
79 try {
80 const context = (await $.session.usage()).context
81 percent = contextPercent(context.percent) ?? percent
82 tokens = context.tokens !== undefined && Number.isInteger(context.tokens) && context.tokens >= 0 ? context.tokens : tokens
83 } catch { /* waiting can be reported without usage */ }
84 await $.fs.write(path, JSON.stringify({ sessionId, percent, tokens, waiting: waiting ?? previous?.waiting ?? false, jobs, updatedAt: now }))
85 } catch { /* live reporting is best effort, as requested */ }
86}
87async function readContexts($: Dollar, directory: string, rows: Session[]) {
88 const openIds = new Set(rows.map(row => row.id)), now = await $.clock.now()
89 try {
90 for (const file of await $.fs.list(directory)) {
91 if (file.kind !== 'file' || file.isLink || !/^[A-Za-z0-9_-]+\.json$/.test(file.name)) continue
92 const sessionId = file.name.slice(0, -5), path = `${directory}/${file.name}`
93 try {
94 const context = parseContext(await $.fs.read(path))
95 if (!context || context.sessionId !== sessionId) continue
96 const row = rows.find(row => row.id === sessionId)
97 if (row) {
98 row.contextPercent = context.percent
99 row.contextTokens = context.tokens
100 row.jobs = context.jobs
101 if (context.waiting !== undefined) {
102 row.waiting = context.waiting
103 row.waitingUpdatedAt = context.updatedAt
104 }
105 }
106 if (staleContext(sessionId, Math.max(file.mtimeMs, context.updatedAt), openIds, now)) {
107 if ((await $.env.get('OS')) === 'Windows_NT') await $.process.run(['cmd', '/d', '/c', 'del', '/f', '/q', path.replaceAll('/', '\\')])
108 else await $.process.run(['rm', '-f', path])
109 }
110 } catch { /* unavailable live records do not affect the session list */ }
111 }
112 } catch { /* a missing live directory is normal before the first report */ }
113}
114async function refreshSessions($: Dollar) {
115 const failures: string[] = []
116 try {
117 const home = await getHome($)
118 if (!home) throw new Error(t('home_missing'))
119 const directory = `${home}/.claude/sessions`
120 const candidates = []
121 for (const file of await $.fs.list(directory)) {
122 if (!file.name.endsWith('.json')) continue
123 try {
124 const row = parseLiveSession(await $.fs.read(`${directory}/${file.name}`))
125 if (row) candidates.push(row)
126 } catch (error) { failures.push(`${file.name}:${String(error)}`) }
127 }
128 let psOutput = ''
129 if (candidates.length) {
130 try {
131 if ((await $.env.get('OS')) === 'Windows_NT') {
132 const result = await $.process.run(['tasklist', '/FO', 'CSV', '/NH'])
133 if (result.exitCode === 0) psOutput = tasklistPids(result.stdout)
134 } else {
135 const result = await $.process.run(['ps', '-o', 'pid=', '-p', candidates.map(row => row.pid).join(',')])
136 if (result.exitCode === 0) psOutput = result.stdout
137 }
138 } catch { /* ps failure means no live processes */ }
139 }
140 const rows: Session[] = []
141 for (const row of liveSessions(candidates, psOutput)) {
142 let transcript = ''
143 try {
144 if ((await $.env.get('OS')) === 'Windows_NT') {
145 let path = ''
146 for (const dir of await $.fs.list(`${home}/.claude/projects`)) {
147 const candidate = `${home}/.claude/projects/${dir.name}/${row.sessionId}.jsonl`
148 if (dir.kind === 'dir' && await $.fs.exists(candidate)) { path = candidate; break }
149 }
150 if (path) {
151 if ((await $.fs.stat(path)).size <= 4 * 1024 * 1024) {
152 transcript = (await $.fs.read(path)).slice(-262144)
153 } else {
154 const result = await $.process.run(['findstr', '/L', '/C:ai-title', '/C:last-prompt', path.replaceAll('/', '\\')])
155 if (result.exitCode === 0) transcript = result.stdout
156 }
157 }
158 } else {
159 const found = await $.process.run(['/bin/sh', '-c', 'ls "$1"/*/"$2".jsonl 2>/dev/null | head -1', 'sh', `${home}/.claude/projects`, row.sessionId])
160 const path = found.exitCode === 0 ? found.stdout.trim() : ''
161 if (path) {
162 const result = await $.process.run(['tail', '-c', '262144', path])
163 if (result.exitCode !== 0) throw new Error(result.stderr || t('exit_code', { code: result.exitCode }))
164 transcript = result.stdout
165 }
166 }
167 } catch (error) { failures.push(`${row.sessionId}:${String(error)}`) }
168 rows.push(toSession(row, transcript))
169 }
170 await readContexts($, `${home}/.usage/claude-pane/live`, rows)
171 const previous = await read($, sessions), currentId = await $.session.id()
172 const settled = settleNotifications(await read($, pendingToasts), previous, rows, currentId)
173 await update($, sessions, () => newest(rows))
174 await update($, pendingToasts, () => settled.pending)
175 for (const { session, kind } of settled.toast) {
176 const title = session.title.length > 40 ? `${session.title.slice(0,40)}…` : session.title
177 $.ui.toast(t(`session_${kind}`, { title: `${session.source} · ${title}` }), { timeoutMs: 8000 })
178 }
179 } catch (error) { failures.push(String(error)) }
180 await update($, sessionError, () => failures.length ? t('session_error', { error: failures.join('; ') }) : '')
181}
182async function refreshAgents($: Dollar) {
183 let list: Awaited<ReturnType<Dollar['agent']['list']>> = []
184 try { list = await $.agent.list() } catch { /* unavailable agents are an empty list */ }
185 const now = await $.clock.now()
186 await update($, bgTasks, tasks => liveBgTasks(tasks, now))
187 await update($, agents, () => visibleAgents(list))
188 await reportContext($, null)
189}
190async function refreshSessionLists($: Dollar) {
191 await refreshAgents($)
192 await refreshSessions($)
193}
194let quotaTimer: { cancel(): void } | undefined
195let sessionTimer: { cancel(): void } | undefined
196let redrawTimer: { cancel(): void } | undefined
197export const register: Register = (on) => {
198 on('session.start', async ($, e, next) => {
199 configure()
200 try {
201 const root = $.plugin.root.replace(/[\\/]\.claude-plugin[\\/]?$/, '')
202 const path = `${root}/usage-pane.json`
203 if (await $.fs.exists(path)) {
204 const value = JSON.parse(await $.fs.read(path))
205 if (!value || typeof value.strings !== 'object' || value.strings === null ||
206 Array.isArray(value.strings) || !Object.values(value.strings).every(v => typeof v === 'string') ||
207 !Array.isArray(value.status_argv) || !value.status_argv.length ||
208 !value.status_argv.every((v: unknown) => typeof v === 'string' && v.length)) {
209 throw new Error(t('invalid_sidecar'))
210 }
211 configure(value)
212 }
213 } catch (error) {
214 await $.ui.log(t('sidecar_error', { error: String(error) }))
215 }
216 await $.command.register({ name: PANE, description: t('description') })
217 void $.ui.open({ id: PANE, title: t('title') })
218 quotaTimer?.cancel(); sessionTimer?.cancel(); redrawTimer?.cancel()
219 quotaTimer = $.clock.every(60000, () => refreshQuota($))
220 sessionTimer = $.clock.every(15000, () => refreshSessionLists($))
221 redrawTimer = $.clock.every(1000, () => $.ui.invalidate('ui.render'))
222 await reportContext($)
223 await refreshQuota($)
224 await refreshSessionLists($)
225 return next(e)
226 })
227 on('turn.complete', async ($, e, next) => {
228 const result = await next(e)
229 await reportContext($)
230 return result
231 })
232 on('classic.Stop', async ($, e, next) => {
233 const result = await next(e)
234 const now = await $.clock.now()
235 await update($, bgTasks, list => backgroundTasks(e.background_tasks ?? [], list, now))
236 await reportContext($, null)
237 return result
238 })
239 on('classic.PermissionRequest', async ($, e, next) => {
240 await reportContext($, true)
241 return next(e)
242 })
243 on('classic.Notification', async ($, e, next) => {
244 if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') await reportContext($, true)
245 return next(e)
246 })
247 on('classic.Elicitation', async ($, e, next) => {
248 await reportContext($, true)
249 return next(e)
250 })
251 on('classic.PostToolUse', async ($, e, next) => {
252 await reportContext($)
253 return next(e)
254 })
255 on('classic.PermissionDenied', async ($, e, next) => {
256 await reportContext($)
257 return next(e)
258 })
259 on('classic.ElicitationResult', async ($, e, next) => {
260 await reportContext($)
261 return next(e)
262 })
263 on('command.run', { command: PANE }, async $ => {
264 await $.ui.open({ id: PANE, title: t('title') })
265 return { text: t('opened') }
266 })
267 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
268 const agent = matchAgent(e.command)
269 if (agent === null) return next(e)
270 const start = await $.clock.now()
271 const run: Run = { id: e.tool_use_id ?? `run-${start}`, agent, label: (e.description ?? '').slice(0,24), start, end: null, status: 'running' }
272 await update($, runs, list => [...list.filter(r => isVisible(r,start)), run])
273 await reportContext($, null)
274 const ran = await next(e)
275 if (ran.deny !== undefined) {
276 await update($, runs, list => list.filter(r => r.id !== run.id))
277 await reportContext($, null)
278 return ran
279 }
280 const result = ran.result as { backgroundTaskId?: string } | undefined
281 if (result?.backgroundTaskId === undefined) {
282 const end = await $.clock.now()
283 const status = isShellBackgrounded(e.command) ? 'launched' : ran.isError ? 'failed' : 'completed'
284 await update($, runs, list => list.map(r => r.id === run.id ? { ...r, end, status } : r))
285 await reportContext($, null)
286 clearLater($)
287 }
288 return ran
289 })
290 on('prompt.submit', async ($, e, next) => {
291 await reportContext($)
292 if (e.origin.kind === 'task-notification') await finish($, e.text)
293 return next(e)
294 })
295 on('session.receive', async ($, e, next) => {
296 if (e.origin.kind === 'task-notification') await finish($, e.text)
297 return next(e)
298 })
299 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
300 const { Box, Text, Button } = $.ui.resolve(e)
301 const now = await $.clock.now()
302 const data = await read($, quotas), list = await read($, sessions), fold = await read($, collapsed)
303 const shown = (await read($, runs)).filter(r => isVisible(r,now))
304 const expanded = await read($, more), last = await read($, updated), runsExpanded = await read($, moreRuns)
305 const qe = await read($, quotaError), se = await read($, sessionError)
306 const current = await $.session.id()
307 const agentList = visibleAgents(await read($, agents))
308 const tasks = liveBgTasks(await read($, bgTasks), now)
309 const jobs = backgroundCount(shown, agentList, tasks)
310 const running = shown.filter(r => r.end === null).length + tasks.length
311 if (e.props.placement === 'inline') {
312 return <Box flexDirection="column">
313 {compactLines(data,list,running,now,last).map((line,i) => <Text key={String(i)} wrap="wrap">
314 {line.length ? line.map((part,j) => <Text key={String(j)} color={part.color} dimColor={!part.color} bold={part.bold}>{part.text}</Text>) : <Text dimColor>{' '}</Text>}
315 </Text>)}
316 </Box>
317 }
318 return <Box flexDirection="column">
319 <Box flexDirection="column">
320 <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
321 {!fold.quota && <Box flexDirection="column">
322 {byTightest(data, ['claude-code','codex','antigravity','grok'], now, last === null ? 0 : (now-last)/1000).map(key => {
323 const agent = data.agents[key]
324 if (!agent || (!agent.available && !showsUnavailable(key, agent.reason))) return null
325 const name = key === 'claude-code' ? 'Claude' : key === 'codex' ? 'Codex' : key === 'antigravity' ? 'agy' : 'Grok'
326 if (!agent.available) return <Text key={key}><Text color={key === 'claude-code' ? '#d97757' : key === 'codex' ? '#10a37f' : key === 'antigravity' ? '#4285f4' : 'white'} bold={key === 'grok'}>{name.padEnd(8)}</Text><Text dimColor>{t(`unavailable_${agent.reason ?? 'no_data'}`)}</Text></Text>
327 const age = staleAge(agent.age_seconds ?? 0, now, last)
328 const groups = agent.groups ?? [{ name: '', five_hour: agent.five_hour, seven_day: agent.seven_day }]
329 const rows = groups.flatMap(group => [
330 ...(groups.length > 1 && group.name ? [[group.name === 'GEMINI MODELS' ? 'Gemini' : group.name === 'CLAUDE AND GPT MODELS' ? 'Claude / GPT' : group.name, undefined] as const] : []),
331 ...([[t('five_hour'),group.five_hour],[t('week'),group.seven_day],[t('period'),agent.period]] as const).filter(([,window]) => window),
332 ])
333 return <Box key={key} flexDirection="column">
334 {rows.map(([label,window],i) => {
335 const line = window ? dockQuotaLine(label,window,now,last === null ? 0 : (now-last)/1000,e.props.bodyColumns) : null
336 // Under 50% stays gray so only the windows worth watching carry color.
337 const color = window && effectivePercent(window,now,last === null ? 0 : (now-last)/1000) >= 50 ? line?.color : undefined
338 return <Text key={String(i)}><Text color={key === 'claude-code' ? '#d97757' : key === 'codex' ? '#10a37f' : key === 'antigravity' ? '#4285f4' : 'white'} bold={key === 'grok'}>{i === 0 ? `${name.padEnd(8)}` : ' '}</Text><Text dimColor>{line ? line.label : label}</Text>{line && <Text color={color} dimColor={!color}>{line.filled}</Text>}{line && <Text dimColor>{line.empty}</Text>}{line && ' '}{line && <Text color={color} dimColor={!color}>{line.percent}</Text>}{line && <Text dimColor>{line.countdown}</Text>}{i === 0 && age && <Text color="yellow">{age}</Text>}</Text>
339 })}
340 </Box>
341 })}
342 {!Object.keys(data.agents).length && <Text dimColor>{t('none')}</Text>}
343 {qe && <Text color="red">{qe}</Text>}
344 </Box>}
345 </Box>
346 <Box position="absolute" top={0} left={2} right={2} justifyContent="space-between"><Button key="quota" plain label={`[ ${fold.quota ? '▸' : '▾'} ${t('quota')} ]`} onPress={() => update($, collapsed, v => ({ ...v, quota: !v.quota }))} /></Box>
347 </Box>
348 <Box flexDirection="column">
349 <Box flexDirection="column" borderStyle="round" borderColor="blue" paddingX={1}>
350 {!fold.sessions && <Box flexDirection="column">
351 {list.slice(0,expanded ? list.length : 4).map(s => {
352 const waiting = s.status === 'waiting' || isWaiting({ waiting: s.waiting, updatedAt: s.waitingUpdatedAt ?? 0 }, now), mark = sessionMark(s, waiting, now)
353 return <Box key={s.id} flexDirection="column">
354 <Box justifyContent="space-between"><Box flexShrink={1}><Text wrap="truncate-end"><Text color={mark.color} dimColor={!mark.color}>{mark.text}</Text><Text dimColor>{` ${s.source}${s.source ? ' ' : ''}`}</Text>{s.id === current && <Text color="cyan">{t('here')}</Text>}{s.title || t('untitled')}</Text></Box>{s.contextPercent !== undefined && <Box flexShrink={0} marginLeft={1}><Text color={contextColor(s.contextPercent, s.contextTokens)} dimColor={contextColor(s.contextPercent, s.contextTokens) === undefined}>{`${s.contextPercent}%`}</Text></Box>}</Box>
355 {(waiting || s.preview) && <Box justifyContent="space-between"><Box flexShrink={1}><Text wrap="truncate-end">{' '}{waiting && <Text color="yellow">{waitingText(s.waitingFor)}</Text>}{s.preview && <Text dimColor>{`${waiting ? ' · ' : ''}${s.preview}`}</Text>}</Text></Box></Box>}
356 </Box>
357 })}
358 {!list.length && <Text dimColor>{t('none')}</Text>}
359 {list.length > 4 && <Button key="more" plain label={expanded ? `[ − ${t('less')} ]` : `[ + ${t('more', { count: list.length-4 })} ]`} onPress={() => update($, more, v => !v)} />}
360 {se && <Text color="red">{se}</Text>}
361 </Box>}
362 </Box>
363 <Box position="absolute" top={0} left={2} right={2} justifyContent="space-between">
364 <Button key="sessions" plain label={`[ ${fold.sessions ? '▸' : '▾'} ${t('sessions')} ]`} onPress={() => update($, collapsed, v => ({ ...v, sessions: !v.sessions }))} />
365 <Text>{' '}<Text dimColor>{t('busy_count', { count: list.filter(s => isBusy(s)).length, total: list.length })}</Text>{' '}</Text>
366 </Box>
367 </Box>
368 <Box flexDirection="column">
369 <Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
370 {!fold.runs && <Box flexDirection="column">
371 {visibleRuns(shown, runsExpanded, tasks).rows.map(r => {
372 const done = r.end !== null
373 const seconds = Math.max(0, Math.floor(((r.end ?? now)-r.start)/1000))
374 const elapsed = t('elapsed', { minutes: Math.floor(seconds/60), seconds: String(seconds%60).padStart(2,'0') })
375 const prefix = done ? `${r.status === 'completed' ? '✓' : r.status === 'launched' ? '↗' : '✗'} ` : ''
376 return <Text key={r.id} dimColor={done} wrap="truncate-end"><Text color={done ? undefined : 'green'}>{`${prefix}${elapsed}`}</Text>{' '}<Text color={done ? undefined : 'cyan'}>{r.agent}</Text>{` ${r.label}`}<Text dimColor>{completedAgo(r,now)}</Text></Text>
377 })}
378 {agentList.map(agent => <Text key={`agent-${agent.id}`} wrap="truncate-end"><Text color={agent.status === 'running' ? 'green' : agent.status === 'waiting' ? 'yellow' : undefined} dimColor={agent.status === 'idle' || agent.status === 'pending'}>{agent.status === 'waiting' ? t('waiting') : t(`agent_${agent.status}`)}</Text>{' '}<Text color="cyan">{agent.type}</Text>{` ${agent.description}`}</Text>)}
379 {(runsExpanded || visibleRuns(shown, false, tasks).hiddenDone > 0) && <Button key="moreRuns" plain label={runsExpanded ? `[ − ${t('less')} ]` : `[ + ${t('more', { count: visibleRuns(shown, false, tasks).hiddenDone })} ]`} onPress={() => update($, moreRuns, v => !v)} />}
380 {!shown.length && !agentList.length && !tasks.length && <Text dimColor>{t('none')}</Text>}
381 </Box>}
382 </Box>
383 <Box position="absolute" top={0} left={2} right={2} justifyContent="space-between">
384 <Button key="runs" plain label={`[ ${fold.runs ? '▸' : '▾'} ${t('runs')} ]`} onPress={() => update($, collapsed, v => ({ ...v, runs: !v.runs }))} />
385 <Text>{' '}<Text color={jobs > 0 ? 'yellow' : undefined} dimColor={jobs === 0}>{jobs}</Text>{' '}</Text>
386 </Box>
387 </Box>
388 <Box justifyContent="flex-end"><Text dimColor>{refreshedAgo(now, last)}</Text></Box>
389 </Box>
390 })
391}
392hooks/strings.ts 71 lines1// English defaults mirror i18n.json; tests enforce parity.
2export const defaults: Record<string, string> = {
3 "claude_pane_quota": "Quota",
4 "claude_pane_five_hour": "5h",
5 "claude_pane_week": "Week",
6 "claude_pane_period": "Period",
7 "claude_pane_sessions": "Claude sessions",
8 "claude_pane_session_done": "Done: {title}",
9 "claude_pane_session_waiting": "Waiting for you: {title}",
10 "claude_pane_busy": "Busy",
11 "claude_pane_waiting": "waiting for you",
12 "claude_pane_unavailable_not_signed_in": "Not signed in · sign in once in its CLI",
13 "claude_pane_unavailable_no_data": "No data yet · shows up after first use",
14 "claude_pane_unavailable_error": "Couldn't read · retrying automatically",
15 "claude_pane_waiting_input": "needs your answer",
16 "claude_pane_waiting_dialog": "dialog open",
17 "claude_pane_waiting_sandbox": "sandbox permission",
18 "claude_pane_waiting_worker": "subagent request",
19 "claude_pane_waiting_goal": "confirm the goal",
20 "claude_pane_waiting_for": "waiting: {reason}",
21 "claude_pane_agent_running": "running",
22 "claude_pane_agent_idle": "idle",
23 "claude_pane_agent_pending": "pending",
24 "claude_pane_busy_count": "{count} busy / {total}",
25 "claude_pane_idle": "Idle {ago}",
26 "claude_pane_runs": "Background jobs",
27 "claude_pane_none": "(none)",
28 "claude_pane_untitled": "(untitled)",
29 "claude_pane_no_project": "(no project)",
30 "claude_pane_here": "(here)",
31 "claude_pane_more": "{count} more",
32 "claude_pane_less": "Show less",
33 "claude_pane_terminal": "Terminal",
34 "claude_pane_desktop": "Desktop",
35 "claude_pane_minutes": "{count}m",
36 "claude_pane_hours": "{count}h",
37 "claude_pane_days": "{count}d",
38 "claude_pane_seconds": "{count}s",
39 "claude_pane_minutes_ago": "{count}m ago",
40 "claude_pane_seconds_ago": "{count}s ago",
41 "claude_pane_just_now": "just now",
42 "claude_pane_failed": "failed",
43 "claude_pane_stopped": "stopped",
44 "claude_pane_completed": "completed",
45 "claude_pane_completed_ago": "{ago} · {status}",
46 "claude_pane_quota_invalid": "Quota JSON has no agents",
47 "claude_pane_exit_code": "exit code {code}",
48 "claude_pane_quota_error": "Cannot read quota: {error}",
49 "claude_pane_session_error": "Cannot read sessions: {error}",
50 "claude_pane_home_missing": "HOME is not set",
51 "claude_pane_description": "Open the quota, Claude sessions and background jobs pane",
52 "claude_pane_title": "Usage",
53 "claude_pane_opened": "Usage pane opened.",
54 "claude_pane_compact_summary": "Sessions {busy} busy / {total} · Background jobs ",
55 "claude_pane_sidecar_error": "Cannot read pane settings: {error}",
56 "claude_pane_invalid_sidecar": "Invalid usage-pane.json",
57 "claude_pane_remaining": "left",
58 "claude_pane_reset_done": "Reset",
59 "claude_pane_hours_ago": "{count}h ago",
60 "claude_pane_elapsed": "{minutes}m{seconds}s"
61}
62let strings = defaults
63export let statusArgv: string[] | undefined
64export function configure(value?: { strings?: Record<string, string>; status_argv?: string[] }) {
65 strings = { ...defaults, ...value?.strings }
66 statusArgv = value?.status_argv
67}
68export function t(key: string, values: Record<string, string | number> = {}): string {
69 return (strings[`claude_pane_${key}`] ?? key).replace(/\{(\w+)\}/g, (match, name) => String(values[name] ?? match))
70}
71hooks/parse.ts 59 lines1import { t } from './strings'
2import type { Agent, BgTask } from '../types'
3
4export const STALE_MS = 3600000
5
6export function liveBgTasks(tasks: BgTask[], now: number): BgTask[] {
7 return tasks.filter(task => now - task.start < STALE_MS)
8}
9
10export function backgroundTasks(tasks: readonly { id: string; type: string; description: string; command?: string }[], previous: BgTask[], now: number): BgTask[] {
11 return liveBgTasks(tasks.filter(task => task.type !== 'subagent' && !(task.type === 'shell' && matchAgent(task.command ?? '')))
12 .map(task => ({ id: task.id, type: task.type, label: task.description || task.command || '', start: previous.find(old => old.id === task.id)?.start ?? now })), now)
13}
14
15// A dispatch starts a command segment: line start, or after && || ; | — optionally behind VAR=value prefixes.
16const DISPATCH = /(?:^|&&|\|\||[;|\n])\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(codex exec|agy -p|grok --prompt-file|muse exec)(?=\s|$)/
17
18const AGENT_OF: Record<string, Agent> = {
19 'codex exec': 'codex',
20 'agy -p': 'agy',
21 'grok --prompt-file': 'grok',
22 'muse exec': 'muse',
23}
24
25export function matchAgent(command: string): Agent | null {
26 // drop heredoc bodies only: a brief written with cat <<'EOF' may come before the dispatch line
27 const head = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g, '')
28 if (/\s--help(?=\s|$)/.test(head)) return null
29 const key = DISPATCH.exec(head)?.[1]
30 return key ? (AGENT_OF[key] ?? null) : null
31}
32
33// A trailing lone & backgrounds in the shell: the tool call returns at once and no notification follows.
34export function isShellBackgrounded(command: string): boolean {
35 return /(?:^|[^&])&\s*$/.test(command.trimEnd())
36}
37
38// The task-notification text format is not a typed field; parse.test.ts pins it.
39export function parseNotifications(text: string): { toolUseId?: string; taskId?: string; status: string }[] {
40 return text
41 .split('<task-notification>')
42 .slice(1)
43 .flatMap(block => {
44 const id = /<tool-use-id>([^<]+)<\/tool-use-id>/.exec(block)?.[1]
45 const taskId = /<task-id>([^<]+)<\/task-id>/.exec(block)?.[1]
46 const status = /<status>([^<]+)<\/status>/.exec(block)?.[1]
47 return (id || taskId) && status ? [{ ...(id ? { toolUseId: id } : {}), ...(taskId ? { taskId } : {}), status }] : []
48 })
49}
50
51export function formatElapsed(ms: number): string {
52 const s = Math.max(0, Math.floor(ms / 1000))
53 if (s < 60) return t('seconds', { count: s })
54 const m = Math.floor(s / 60)
55 if (m < 60) return `${t('minutes', { count: m })}${t('seconds', { count: String(s % 60).padStart(2, '0') })}`
56 return `${t('hours', { count: Math.floor(m / 60) })}${t('minutes', { count: String(m % 60).padStart(2, '0') })}`
57}
58
59hooks/quota.ts 107 lines1import { t } from './strings'
2import type { Quotas, Window } from '../types'
3export function byTightest(data: Quotas, order: readonly string[], now = Date.now(), elapsedSeconds = 0): string[] {
4 return order.map((key, index) => {
5 const agent = data.agents[key]
6 const windows = [agent?.five_hour, agent?.seven_day, agent?.period, ...(agent?.groups ?? []).flatMap(group => [group.five_hour, group.seven_day])]
7 const used = Math.max(-Infinity, ...windows.map(window => window ? effectivePercent(window, now, elapsedSeconds) : undefined).filter((value): value is number => typeof value === 'number' && Number.isFinite(value)))
8 return { key, index, used, available: agent?.available === true }
9 }).sort((a,b) => Number(b.available) - Number(a.available) || b.used - a.used || a.index - b.index).map(row => row.key)
10}
11// agy and Grok are optional, so like the menu bar they stay hidden unless they are set up and failing.
12export function showsUnavailable(key: string, reason?: string): boolean {
13 return key === 'claude-code' || key === 'codex' || reason === 'error'
14}
15export function parseQuota(text: string): Quotas {
16 // A window without a numeric used_percent has no data; drop it so it is hidden instead of drawn as 0%.
17 const value = JSON.parse(text, (_key, v) =>
18 v && typeof v === 'object' && 'used_percent' in v && !Number.isFinite(v.used_percent) ? undefined : v)
19 if (!value || typeof value.agents !== 'object' || value.agents === null) throw new Error(t('quota_invalid'))
20 const agents = Object.fromEntries(Object.entries(value.agents).filter(([, agent]) =>
21 agent !== null && typeof agent === 'object').map(([key, agent]) => {
22 const row = agent as Quotas['agents'][string]
23 return [key, row.available === true ? row : { available: false, reason: ['not_signed_in','no_data','error'].includes(row.reason ?? '') ? row.reason : 'no_data' }]
24 })) as Quotas['agents']
25 // agy's Claude / GPT pool barely moves and the menu bar panel already shows it; in the pane it is noise.
26 const agy = agents.antigravity
27 if (agy?.groups) agents.antigravity = { ...agy, groups: agy.groups.filter(group => !['CLAUDE AND GPT MODELS', 'Claude / GPT'].includes(group.name)) }
28 return { agents }
29}
30export function hideAgents(data: Quotas, preferences: string): Quotas {
31 let value: unknown
32 try { value = JSON.parse(preferences) } catch { return { ...data, agents: { ...data.agents } } }
33 if (!value || typeof value !== 'object' || Array.isArray(value)) return { ...data, agents: { ...data.agents } }
34 const keys: Record<string,string> = { hide_claude_section:'claude-code', hide_codex_section:'codex', hide_agy_section:'antigravity', hide_grok_section:'grok' }
35 const hidden = new Set(Object.entries(keys).filter(([key]) => (value as Record<string,unknown>)[key] === true).map(([,agent]) => agent))
36 return { ...data, agents: Object.fromEntries(Object.entries(data.agents).filter(([agent]) => !hidden.has(agent))) }
37}
38export function countdown(seconds: number): string {
39 const s = Math.max(0, Number.isFinite(seconds) ? seconds : 0)
40 if (s < 3600) return t('minutes', { count: Math.floor(s / 60) })
41 if (s < 86400) return t('hours', { count: Math.floor(s / 3600) })
42 return t('days', { count: Math.floor(s / 86400) })
43}
44export function effectivePercent(window: Window, now: number, elapsedSeconds = 0): number {
45 const reset = window.resets_at ?? (now / 1000 - elapsedSeconds + (window.resets_in_seconds ?? Infinity))
46 return reset <= now / 1000 ? 0 : window.used_percent
47}
48export function quotaLine(label: string, window: Window, now: number, ageSeconds = 0, bodyColumns?: number) {
49 const used = effectivePercent(window, now, ageSeconds)
50 const percent = Math.max(0, Math.min(100, Number.isFinite(used) ? used : 0))
51 const barWidth = bodyColumns === undefined ? 16 : Math.max(10, Math.min(20, Math.floor(bodyColumns - 24)))
52 const filled = Math.round(percent * barWidth / 100)
53 const seconds = window.resets_at === undefined ? (window.resets_in_seconds ?? 0) - ageSeconds : window.resets_at - now / 1000
54 // CJK characters take two columns; pad every label to the width of 5小時
55 const width = [...label].reduce((sum, ch) => sum + (ch.charCodeAt(0) > 0xff ? 2 : 1), 0)
56 const parts = {
57 label: `${label}${' '.repeat(Math.max(0, 5 - width))} `,
58 filled: '━'.repeat(filled),
59 empty: '─'.repeat(barWidth - filled),
60 percent: `${Math.round(percent)}%`.padStart(4),
61 countdown: ` ↻${seconds <= 0 && (window.resets_at !== undefined || window.resets_in_seconds !== undefined) ? t('reset_done') : countdown(seconds)}`,
62 }
63 return { ...parts, text: `${parts.label}${parts.filled}${parts.empty} ${parts.percent}${parts.countdown}`,
64 color: percent >= 80 ? 'red' : percent >= 50 ? 'yellow' : 'green' }
65}
66
67export function fmtDuration(seconds: number): string {
68 if (seconds >= 86400) return `${Math.floor(seconds / 86400)}d${Math.floor(seconds % 86400 / 3600)}h`
69 if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h${Math.floor(seconds % 3600 / 60)}m`
70 if (seconds >= 60) return `${Math.floor(seconds / 60)}min`
71 return `${Math.trunc(seconds)}s`
72}
73export function progressParts(value: number, bodyColumns = 44) {
74 const percent = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0))
75 const width = bodyColumns < 44 ? 8 : 10
76 // Python's round uses the even neighbour for exact halves.
77 const round = (value: number) => value % 1 === 0.5 ? 2 * Math.round(value / 2) : Math.round(value)
78 const filled = round(percent * width / 100)
79 return { filled: '■'.repeat(filled), empty: '□'.repeat(width - filled), percent: `${round(percent)}%`,
80 color: percent < 50 ? '#00d787' : percent < 80 ? '#ffaf00' : '#d70000' }
81}
82export function resetTime(window: Window, now: number, elapsedSeconds = 0): string {
83 const reset = window.resets_at ?? (now / 1000 - elapsedSeconds + (window.resets_in_seconds ?? Infinity))
84 if (reset <= now / 1000) return t('reset_done')
85 const remain = Math.trunc(reset) - Math.trunc(now / 1000)
86 if (!Number.isFinite(remain)) return ''
87 const word = t('remaining'), duration = fmtDuration(remain)
88 return word === '剩' ? `${word}${duration}` : `${duration} ${word}`
89}
90export function dockQuotaLine(label: string, window: Window, now: number, elapsedSeconds = 0, bodyColumns = 44) {
91 const parts = progressParts(effectivePercent(window, now, elapsedSeconds), bodyColumns)
92 const width = [...label].reduce((sum, ch) => sum + (ch.charCodeAt(0) > 0xff ? 2 : 1), 0)
93 const time = resetTime(window, now, elapsedSeconds)
94 return { ...parts, label: `${label}${' '.repeat(Math.max(0, 5 - width))} `,
95 percent: parts.percent.padStart(4), countdown: time ? ` ${time}` : '' }
96}
97export function refreshedAgo(now: number, last: number | null): string {
98 if (last === null) return '↻ —'
99 const seconds = Math.max(0, Math.floor((now - last) / 1000))
100 return `↻ ${seconds < 60 ? t('seconds_ago', { count: seconds }) : t(seconds < 3600 ? 'minutes_ago' : 'hours_ago', { count: Math.floor(seconds / (seconds < 3600 ? 60 : 3600)) })}`
101}
102export function staleAge(ageSeconds: number, now: number, last: number | null): string {
103 const age = Math.max(0, ageSeconds + (last === null ? 0 : (now - last) / 1000))
104 if (age <= 600) return ''
105 return ` · ${t(age >= 3600 ? 'hours_ago' : 'minutes_ago', { count: Math.floor(age / (age >= 3600 ? 3600 : 60)) })}`
106}
107hooks/compact.ts 52 lines1import { t } from './strings'
2import type { Quotas, Session, Window } from '../types'
3import { quotaLine, refreshedAgo } from './quota'
4import { isBusy } from './sessions'
5
6export type CompactPart = { text: string; color?: string; bold?: boolean }
7export function compactLines(data: Quotas, sessions: Session[], running: number, now: number, last: number | null): [CompactPart[], CompactPart[], CompactPart[]] {
8 const first: CompactPart[] = [], second: CompactPart[] = []
9 function percent(row: CompactPart[], window: Window | undefined) {
10 if (!window) return
11 const line = quotaLine('', window, now, last === null ? 0 : (now-last)/1000)
12 row.push({ text: line.percent.trim(), color: line.color })
13 }
14 for (const [key, name, color, row] of [
15 ['claude-code', 'Claude', '#d97757', first],
16 ['codex', 'Codex', '#10a37f', first],
17 ['antigravity', 'agy', '#4285f4', second],
18 ['grok', 'Grok', 'white', second],
19 ] as const) {
20 const agent = data.agents[key]
21 if (!agent?.available) continue
22 if (row.length) row.push({ text: ' ' })
23 row.push({ text: `◆ ${name}`, color, bold: key === 'grok' })
24 if (key === 'antigravity') {
25 let hasGroup = false
26 for (const group of agent.groups ?? [{ name: '', five_hour: agent.five_hour, seven_day: agent.seven_day }]) {
27 if (!group.five_hour && !group.seven_day) continue
28 const label = group.name === 'GEMINI MODELS' ? 'Gemini' : ['CLAUDE AND GPT MODELS', 'Claude / GPT'].includes(group.name) ? 'Claude/GPT' : group.name
29 row.push({ text: `${hasGroup ? ' ' : ' '}${label}${label ? ' ' : ''}` })
30 percent(row, group.five_hour)
31 if (group.five_hour && group.seven_day) row.push({ text: '/' })
32 percent(row, group.seven_day)
33 hasGroup = true
34 }
35 } else if (key === 'grok') {
36 if (agent.period) row.push({ text: ' ' })
37 percent(row, agent.period)
38 } else {
39 for (const [label, window] of [[t('five_hour'), agent.five_hour], [t('week'), agent.seven_day]] as const) {
40 if (!window) continue
41 row.push({ text: ` ${label} ` })
42 percent(row, window)
43 }
44 }
45 }
46 return [first, second, [
47 { text: t('compact_summary', { busy: sessions.filter(isBusy).length, total: sessions.length }) },
48 { text: String(running), color: running > 0 ? 'yellow' : undefined },
49 { text: ` · ${refreshedAgo(now, last)}` },
50 ]]
51}
52hooks/sessions.ts 86 lines1import { t } from './strings'
2import type { Session } from '../types'
3export function parseSession(text: string): Pick<Session, 'title' | 'source'> & { preview: string } {
4 let title = '', prompt = '', source = '', preview = ''
5 for (const line of text.split('\n')) {
6 let row
7 try { row = JSON.parse(line) } catch { continue }
8 if (!row || typeof row !== 'object') continue
9 if (row.type === 'ai-title' && typeof row.aiTitle === 'string') title = row.aiTitle.trim()
10 if (row.type === 'last-prompt' && typeof row.lastPrompt === 'string') prompt = row.lastPrompt.trim()
11 if (typeof row.entrypoint === 'string') source = row.entrypoint
12 if (row.type === 'assistant' && row.isSidechain !== true) {
13 const content = row.message?.content
14 const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(block => block?.type === 'text' && typeof block.text === 'string').map(block => block.text).join(' ') : ''
15 const clean = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/\*\*|__|`/g, '').replace(/^\s*(?:#+\s*|[-*>]\s+|\d+[.)]\s+)/gm, '').replace(/\s+/g, ' ').trim().slice(0,200)
16 if (clean) preview = clean
17 }
18 }
19 return { title: title || prompt || t('untitled'), source: source === 'cli' ? t('terminal') : source === 'claude-desktop' ? t('desktop') : source, preview }
20}
21export type LiveSession = { pid: number; sessionId: string; name: string; entrypoint: string; status: string; statusUpdatedAt: number; cwd: string; waitingFor?: string }
22export function parseLiveSession(text: string): LiveSession | null {
23 let row
24 try { row = JSON.parse(text) } catch { return null }
25 if (!row || typeof row !== 'object' || !Number.isSafeInteger(row.pid) || row.pid <= 0 || typeof row.sessionId !== 'string' || !row.sessionId) return null
26 const timestamp = row.statusUpdatedAt ?? row.updatedAt
27 if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) return null
28 return { pid: row.pid, sessionId: row.sessionId, name: typeof row.name === 'string' ? row.name : '', entrypoint: typeof row.entrypoint === 'string' ? row.entrypoint : '', status: typeof row.status === 'string' ? row.status : '', statusUpdatedAt: timestamp, cwd: typeof row.cwd === 'string' ? row.cwd : '', ...(typeof row.waitingFor === 'string' && row.waitingFor.trim() ? { waitingFor: row.waitingFor } : {}) }
29}
30export function liveSessions(rows: LiveSession[], psOutput: string): LiveSession[] {
31 const pids = new Set(psOutput.trim().split(/\s+/).map(Number))
32 return rows.filter(row => pids.has(row.pid))
33}
34export function tasklistPids(output: string): string {
35 return output.split(/\r?\n/).map(line => line.match(/^"(?:[^"]|"")*","([^"]*)"/)?.[1]).filter((pid): pid is string => !!pid).join(' ')
36}
37export function toSession(row: LiveSession, transcript = ''): Session {
38 const { title, preview } = parseSession(transcript)
39 let cwd = row.cwd
40 if (!cwd) for (const line of transcript.split('\n')) {
41 try { const entry = JSON.parse(line); if (typeof entry?.cwd === 'string') cwd = entry.cwd } catch { /* skip malformed transcript rows */ }
42 }
43 return { id: row.sessionId, pid: row.pid, title: title === t('untitled') ? row.name : title, source: projectSource(cwd, row.entrypoint), mtimeMs: row.statusUpdatedAt, status: row.status, ...(preview ? { preview } : {}), ...(row.waitingFor ? { waitingFor: row.waitingFor } : {}) }
44}
45export function isBusy(session: Session): boolean { return session.status === 'busy' || (session.jobs ?? 0) > 0 }
46export function sessionNotifications(previous: Session[], next: Session[], currentId: string): { session: Session; kind: 'done' | 'waiting' }[] {
47 const busy = new Set(previous.filter(isBusy).map(session => session.id))
48 const waiting = new Set(previous.filter(session => session.status === 'waiting').map(session => session.id))
49 return next.filter(session => session.id !== currentId && ((session.status === 'idle' && !isBusy(session)) || (session.status === 'waiting' && !waiting.has(session.id))) && busy.has(session.id)).map(session => ({ session, kind: session.status === 'waiting' ? 'waiting' : 'done' }))
50}
51export function waitingText(reason?: string): string {
52 if (!reason?.trim()) return t('waiting')
53 const keys: Record<string,string> = { 'input needed':'input', 'dialog open':'dialog', 'sandbox request':'sandbox', 'worker request':'worker', 'goal proposal':'goal' }
54 const key = Object.hasOwn(keys,reason) ? keys[reason] : undefined
55 return t('waiting_for', { reason: key ? t(`waiting_${key}`) : reason })
56}
57export function settleNotifications(pending: { id: string; kind: 'done' | 'waiting' }[], previous: Session[], next: Session[], currentId: string): { toast: { session: Session; kind: 'done' | 'waiting' }[]; pending: { id: string; kind: 'done' | 'waiting' }[] } {
58 const toast = pending.flatMap(({ id, kind }) => {
59 const session = next.find(session => session.id === id && id !== currentId && session.status === (kind === 'done' ? 'idle' : 'waiting') && (kind !== 'done' || !isBusy(session)))
60 return session ? [{ session, kind }] : []
61 })
62 return { toast, pending: sessionNotifications(previous,next,currentId).map(({session,kind}) => ({id:session.id,kind})) }
63}
64const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
65// Spinner while working, ? while waiting on you, ✓ for ten minutes after finishing, then a gray dot.
66export function sessionMark(session: Session, waiting: boolean, now: number): { text: string; color?: string } {
67 if (waiting) return { text: '?', color: 'yellow' }
68 if (isBusy(session)) return { text: SPINNER[Math.floor(now / 1000) % SPINNER.length]!, color: 'green' }
69 return now - session.mtimeMs < 600000 ? { text: '✓', color: 'green' } : { text: '●' }
70}
71export function sessionStatus(session: Session, now: number): string {
72 return isBusy(session) ? t('busy') : t('idle', { ago: ago(session.mtimeMs, now) })
73}
74export function newest(sessions: Session[]): Session[] {
75 return [...sessions].sort((a,b) => Number(isBusy(b)) - Number(isBusy(a)) || b.mtimeMs - a.mtimeMs)
76}
77
78export function ago(time: number, now: number): string {
79 const minutes = Math.max(0, Math.floor((now - time) / 60000))
80 return minutes === 0 ? t('just_now') : t('minutes_ago', { count: minutes })
81}
82export function projectSource(cwd: string, entrypoint: string): string {
83 const project = cwd.replace(/[\\/]+$/, '').split(/[\\/]+/).pop() || (cwd.startsWith('/') ? '/' : t('no_project'))
84 return `${project}${entrypoint && entrypoint !== 'cli' ? ` · ${t('desktop')}` : ''}`
85}
86hooks/display.ts 41 lines1import { t } from './strings'
2import type { AgentInfo } from 'claude-code'
3import type { Run, BgTask } from '../types'
4import { ago } from './sessions'
5export type LiveContext = { sessionId: string; percent?: number; tokens?: number; waiting?: boolean; jobs?: number; updatedAt: number }
6export function contextPercent(percent: number | undefined): number | undefined {
7 return typeof percent === 'number' && Number.isFinite(percent) ? Math.round(Math.max(0, Math.min(100, percent))) : undefined
8}
9export function contextColor(percent: number, tokens?: number): 'yellow' | 'red' | undefined {
10 return percent >= 80 || (tokens !== undefined && tokens >= 400000) ? 'red' : percent >= 50 || (tokens !== undefined && tokens >= 200000) ? 'yellow' : undefined
11}
12export function parseContext(text: string): LiveContext | null {
13 try {
14 const row = JSON.parse(text)
15 if (!row || typeof row.sessionId !== 'string' || !row.sessionId || (row.percent !== undefined && (!Number.isInteger(row.percent) || row.percent < 0 || row.percent > 100)) || (row.waiting !== undefined && typeof row.waiting !== 'boolean') || typeof row.updatedAt !== 'number' || !Number.isFinite(row.updatedAt)) return null
16 return { sessionId: row.sessionId, ...(row.percent === undefined ? {} : { percent: row.percent }), ...(Number.isInteger(row.tokens) && row.tokens >= 0 ? { tokens: row.tokens } : {}), ...(row.waiting === undefined ? {} : { waiting: row.waiting }), ...(Number.isInteger(row.jobs) && row.jobs >= 0 ? { jobs: row.jobs } : {}), updatedAt: row.updatedAt }
17 } catch { return null }
18}
19export function staleContext(sessionId: string, updatedAt: number, openIds: Set<string>, now: number): boolean {
20 return !openIds.has(sessionId) && now - updatedAt > 86400000
21}
22export function completedAgo(run: Pick<Run, 'end' | 'status'>, now: number): string {
23 return run.end === null ? '' : ` · ${t('completed_ago', { ago: ago(run.end, now), status: ['killed', 'stopped', 'cancelled'].includes(run.status) ? t('stopped') : run.status === 'failed' ? t('failed') : t('completed') })}`
24}
25export function isWaiting(context: Pick<LiveContext, 'waiting' | 'updatedAt'>, now: number): boolean {
26 return context.waiting === true && now - context.updatedAt <= 600000
27}
28export function visibleAgents(agents: AgentInfo[]): AgentInfo[] {
29 return agents.filter(agent => ['pending','running','waiting','idle'].includes(agent.status))
30}
31export function backgroundCount(runs: Pick<Run, 'end'>[], agents: AgentInfo[], tasks: BgTask[] = []): number {
32 return tasks.length + runs.filter(run => run.end === null).length + visibleAgents(agents).length
33}
34// Running jobs always show; finished ones only fill the rows left out of four, unless expanded.
35export function visibleRuns(runs: Run[], expanded: boolean, tasks: BgTask[] = []): { rows: (Omit<Run, 'agent'> & { agent: string })[]; hiddenDone: number } {
36 const jobs = [...runs, ...tasks.map(task => ({ id: task.id, agent: task.type, label: task.label, start: task.start, end: null, status: 'running' }))]
37 const done = jobs.filter(r => r.end !== null)
38 const kept = new Set(expanded ? done : done.slice(Math.max(0, done.length - Math.max(0, 4 - (jobs.length - done.length)))))
39 return { rows: jobs.filter(r => r.end === null || kept.has(r)), hiddenDone: done.length - kept.size }
40}
41types/index.d.ts 16 lines1export type Agent = 'codex' | 'agy' | 'grok' | 'muse'
2export type Run = { id: string; agent: Agent; label: string; start: number; end: number | null; status: string }
3export type BgTask = { id: string; type: string; label: string; start: number }
4export type Window = { used_percent: number; resets_in_seconds?: number; resets_at?: number }
5export type QuotaAgent = { available: boolean; reason?: 'not_signed_in' | 'no_data' | 'error'; model?: string; tier?: string; age_seconds?: number; five_hour?: Window; seven_day?: Window; period?: Window; groups?: { name: string; five_hour?: Window; seven_day?: Window }[] }
6export type Quotas = { agents: Record<string, QuotaAgent> }
7export type Session = { id: string; title: string; source: string; mtimeMs: number; pid: number; status: string; preview?: string; waitingFor?: string; contextPercent?: number; contextTokens?: number; waiting?: boolean; waitingUpdatedAt?: number; jobs?: number }
8declare module 'claude-code' {
9 interface PluginState {
10 'usage-dash': {
11 quotas: Quotas; updated: number | null; quotaError: string; sessions: Session[]; sessionError: string; pendingToasts: { id: string; kind: 'done' | 'waiting' }[];
12 runs: Run[]; bgTasks: BgTask[]; agents: AgentInfo[]; collapsed: { quota: boolean; sessions: boolean; runs: boolean }; more: boolean; moreRuns: boolean;
13 }
14 }
15}
16