A toolbar above the prompt: open or move the conversation to one of the 100 most recent folders, browse for a folder, subagent status dots, waiting clocks with…

A Claude Code plugin (a hooks-module mod) that draws one row above the prompt, in the Claude desktop app's Code tab and in the terminal: a recent-folder list, status dots for the conversation's subagents, a button that copies the transcript path, and live usage widgets for the context window and the plan's rate limits.
| Control | What it does |
|---|---|
| Folder list | Shows the current folder by its name alone (my-project; "No folder" in a scratch workspace) and lists the most recently used folders: the 64 newest of the 100 the plugin remembers, because Claude Code refuses a Select with more options. Picking one starts the conversation there before the first prompt, or moves the session there afterwards. A name too long for the row keeps its end behind an ellipsis. |
| Browse… (last entry of the folder list) | Opens the Windows folder picker at the most recently used folder rather than the user profile. The picked folder is used and recorded the same way. |
| Subagent dots | One dot per subagent of this conversation, oldest left, shown once the first one starts. Running dots pulse in orange, one after another; finished ones are green, failed ones red, stopped ones a grey ring (also a subagent that ended without a known result). A subagent that waits is a slowly pulsing orange ring, a teammate between turns a still one. Hover for a summary and one line per subagent, newest first: state, type, task and duration. The newest 12 show, +n counting the older ones; the history keeps 50. The ↗ button right of the dots opens the desktop app's Background tasks pane, which lists the subagents with their tasks; in the terminal it only shows a toast. The pane also counts shell commands as tasks, so its count is higher than the number of dots. |
| Copy Transcript Path | Copies the path of this conversation's transcript (.jsonl) to the clipboard. Also available as /copy-transcript-path. |
| Usage widgets (right end) | Left to right: a ring for the model's weekly quota (for example Fable), the token count, a context bar full at 500k tokens, a ring for the 5-hour limit and a ring for the weekly limit. Each ring shows its whole percentage inside. |
?, never 0.5 h limit: 12% (Reset in 3 h 30 min. Runs out in 1 h 5 min.) Context : 350k / 1M
5 h limit: 91% (Reset in 4 h 10 min. Runs out in 5 min.)
7 d limit: 76% (Reset in 5 d 14 h. Runs out in 10 h 45 min.)
Fable : 88% (Reset in 5 d 14 h. Runs out in 4 h 39 min.)
"Runs out in" projects the average rate since the window began. A window that lasts until its reset at that rate says Lasts past reset., a full one Ran out..
The desktop app draws the row only after a session exists, so a new conversation that has not received any input shows no toolbar. Send /folder <name or path> as the first message: this creates the session without a model turn and opens the recent folder whose name matches (exact name, then a name starting with the argument, then a name containing it, then a path containing it) or the given absolute path. /folder alone just creates the session so the toolbar appears.
Moving uses the desktop app's own change_directory tool, so the app asks to approve the folder unless permissions are bypassed. The desktop app applies a move only at a turn you drive: during a running turn it waits for that turn to end; in an idle conversation it waits for your next message (a turn the toolbar itself submits does not count, so the toolbar does not try). Until then the list keeps the current folder, and a grey note beside it names the target, → Python_Lib Moving after your next message..., with its dots cycling. The note clears once the session is there. If the move is refused while a turn is running, the toolbar adds a note asking Claude to move the working directory once the turn is complete.
In a terminal Claude Code session, type:
/plugin install conversation-toolbar --marketplace <owner>/<repo>
Answer y to add the marketplace, then pick the user scope with Enter. The plugin is active in that session at once and in every session started afterwards, including the desktop app's Code tab. The desktop app's own prompt does not accept /plugin, so install from a terminal.
To run a local copy instead, name its folder in CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (paths separated by ; on Windows), or start Claude Code with --plugin-dir <folder>.
%APPDATA%\Claude\claude-code-sessions) fill the list once. Only the folder paths are read.https://api.anthropic.com/api/oauth/usage, the endpoint Claude Code's /usage reads, called with the session's own authorization through the engine. Nothing is sent anywhere else..claude-plugin/plugin.json: the manifest (conversation-toolbar).claude-plugin/marketplace.json: makes this repository a one-plugin marketplacehooks/register.tsx: the hooks: session start, turn boundaries, the slash commands, and the row above the prompthooks/folder_list.ts: path handling and the recent-folder list, with no engine callshooks/subagent_dots.ts: the subagent records and their dots, with no engine callshooks/usage_wheels.ts: the usage figures, their text and the SVG rings and bar, with no engine callsscripts/Pick_Folder.ps1: the folder picker Browse runstypes/index.d.ts: the $.state contracttests/: claude plugin test suitesFrom the repository folder:
claude plugin validate .
claude plugin test .
The engine writes its type declarations into .claude-plugin/types/ when it loads the plugin; after that, tsc -p . type-checks it.
hooks/register.tsx 1149 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, McpToolResult, Register } from 'claude-code'
3
4import type { MonitorRecord, RecentFolderList, SubagentRecord, UsageSnapshot, UsageWindow } from '../types'
5import {
6 findFolder,
7 folderIdentity,
8 folderOptionLabel,
9 isAbsoluteFolder,
10 isFolderInside,
11 isPickableFolder,
12 joinWindowsPath,
13 mergeFolderLists,
14 normalizeFolder,
15 placeFolderFirst,
16 projectDirectoryName,
17 rankSessionFolders,
18 readFolderList,
19 readSessionRecordFolder,
20 type SessionRecordFolder,
21} from './folder_list'
22import {
23 BAR_HEIGHT_PX,
24 BAR_WIDTH_PX,
25 RING_SIZE_PX,
26 contextBarSvg,
27 detailBlockSize,
28 detailBlockSvg,
29 mergeWindows,
30 parseUsageEndpoint,
31 quotaRingSvg,
32 usageText,
33 usageWidgets,
34} from './usage_wheels'
35import {
36 DOTS_HEIGHT_PX,
37 mergeListedAgents,
38 recordSpawn,
39 recordTurnEnd,
40 subagentDotsSvg,
41 subagentDotsWidth,
42 subagentLines,
43 subagentSummary,
44 subagentText,
45} from './subagent_dots'
46import {
47 activeMonitors,
48 applyEndings,
49 expireMonitors,
50 isCountingSeconds,
51 mergeMonitorRecords,
52 monitorClocksSvg,
53 monitorClocksWidth,
54 monitorLines,
55 monitorSummary,
56 monitorText,
57 parseNotificationEndings,
58 parseTranscriptMonitors,
59 readMonitorStart,
60 recordMonitorEnd,
61 recordMonitorStart,
62} from './monitor_clock'
63
64type CopySurface = Parameters<EngineInterface['ui']['copy']>[0]['surface']
65
66const STORE_KEY_RECENT_FOLDERS = 'recentFolders'
67const STORE_KEY_IS_SEEDED = 'isSeededFromDesktopSessions'
68const STORE_KEY_USAGE_ENDPOINT = 'usageEndpoint'
69// The plan usage endpoint the engine's /usage reads (five_hour, seven_day and
70// the model-scoped weekly windows in limits[]); the bearer header is the engine's.
71const USAGE_ENDPOINT_URL = 'https://api.anthropic.com/api/oauth/usage'
72const USAGE_ENDPOINT_BETA = 'oauth-2025-04-20'
73const USAGE_ENDPOINT_INTERVAL_MS = 60_000
74const SESSION_USAGE_INTERVAL_MS = 5_000
75// The value in the folder list that opens the folder picker.
76const BROWSE_OPTION = '<browse for a folder>'
77// The engine refuses a Select with more than 64 options (and with it the whole
78// band), so the list draws at most that many; the store still keeps 100.
79const SELECT_OPTION_LIMIT = 64
80// Newest records first; enough to cover months of sessions without reading
81// every record the desktop app has ever kept.
82const SESSION_RECORD_READ_LIMIT = 1500
83const FOLDER_PICKER_TIMEOUT_MS = 10 * 60 * 1000
84const NO_FOLDER_CHOSEN = '<no folder chosen>'
85// Columns the folder labels may take: what the band leaves beside the Browse
86// and Copy buttons, kept between a readable minimum and a tidy maximum.
87const COLUMNS_BESIDE_FOLDER_LABEL = 40
88const MINIMUM_FOLDER_LABEL_WIDTH = 24
89const MAXIMUM_FOLDER_LABEL_WIDTH = 72
90
91const recentFoldersState = { plugin: 'conversation-toolbar', key: 'recentFolders' } as const
92const usageAtom = atom({ plugin: 'conversation-toolbar', key: 'usage' } as const, null as UsageSnapshot | null)
93// Whether the usage detail row stays open (toggled by the token-count button).
94const usageDetailsPinnedAtom = atom({ plugin: 'conversation-toolbar', key: 'usageDetailsPinned' } as const, false)
95// The folder a move is waiting to reach, and the dots of its note.
96const pendingMoveAtom = atom({ plugin: 'conversation-toolbar', key: 'pendingMove' } as const, null as string | null)
97const movingDotsAtom = atom({ plugin: 'conversation-toolbar', key: 'movingDots' } as const, 1)
98const MOVING_DOTS_MAXIMUM = 6
99const MOVING_DOTS_INTERVAL_MS = 400
100// This session's subagents for the dots, refreshed from `$.agent.list()`.
101const subagentsAtom = atom({ plugin: 'conversation-toolbar', key: 'subagents' } as const, [] as SubagentRecord[])
102const SUBAGENT_LIST_INTERVAL_MS = 2_000
103// The desktop app's session view server and its pane tool.
104const TASKS_PANE_SERVER = 'ccd_view'
105const TASKS_PANE_TOOL = 'show_pane'
106// The button beside the dots that opens the pane: an arrow out to the pane
107// (a math-axis glyph, so it sits on the dots' centre line; the trigram ☰ sat above it).
108const OPEN_TASKS_GLYPH = '↗'
109// Lines the dots' tooltip lists below the summary, newest first.
110const SUBAGENT_TOOLTIP_LINE_LIMIT = 15
111// The main conversation's Monitors for the waiting clocks.
112const monitorsAtom = atom({ plugin: 'conversation-toolbar', key: 'monitors' } as const, [] as MonitorRecord[])
113// How often the clocks' tooltip (waited, stops in) is redrawn while one waits.
114// A waiting clock's countdown in minutes redraws this often; in its last
115// minute, every poll.
116const MONITOR_COUNTDOWN_REFRESH_MS = 10_000
117
118type MoveOutcome = { isMoved: true } | { isMoved: false; reason: string; isDeclined: boolean }
119
120function describeError(error: unknown): string {
121 return error instanceof Error ? error.message : String(error)
122}
123
124function moveAfterTurnMessage(folder: string): string {
125 return `After this conversation turn is complete, move the working directory to ${folder}.`
126}
127async function resolveConfigDirectory($: EngineInterface): Promise<string> {
128 const configured = await $.env.get('CLAUDE_CONFIG_DIR')
129 if (configured) {
130 return configured
131 }
132 const userProfile = await $.env.get('USERPROFILE')
133 const home = userProfile ?? (await $.env.get('HOME')) ?? ''
134
135 return joinWindowsPath(home, '.claude')
136}
137
138async function resolveTranscriptPath($: EngineInterface): Promise<string> {
139 const sessionId = await $.session.id()
140 const projectsDirectory = joinWindowsPath(await resolveConfigDirectory($), 'projects')
141 const fileName = `${sessionId}.jsonl`
142
143 const expected = joinWindowsPath(
144 projectsDirectory,
145 projectDirectoryName(await $.session.cwd()),
146 fileName,
147 )
148 if (await $.fs.exists(expected)) {
149 return expected
150 }
151
152 // The working directory may have changed since the session started; search
153 // every project folder for this session's transcript.
154 const entries = await $.fs.list(projectsDirectory).catch(() => [])
155 for (const entry of entries) {
156 if (entry.kind !== 'dir') {
157 continue
158 }
159 const candidate = joinWindowsPath(projectsDirectory, entry.name, fileName)
160 if (await $.fs.exists(candidate)) {
161 return candidate
162 }
163 }
164
165 return expected
166}
167
168async function copyToClipboard(
169 $: EngineInterface,
170 text: string,
171 surface?: CopySurface,
172): Promise<boolean> {
173 const copied = await $.ui.copy({ text, surface }).catch(() => ({ isCopied: false }))
174 if (copied.isCopied) {
175 return true
176 }
177
178 // A remote surface such as the desktop app may not take the copy; fall back
179 // to the Windows clipboard directly.
180 const script =
181 '[Console]::InputEncoding = [System.Text.Encoding]::UTF8; ' +
182 'Set-Clipboard -Value ([Console]::In.ReadToEnd())'
183 const result = await $.process
184 .run(['powershell', '-NoProfile', '-NonInteractive', '-Command', script], {
185 stdin: text,
186 timeoutMs: 15000,
187 })
188 .catch(() => undefined)
189
190 return result?.exitCode === 0
191}
192
193async function copyTranscriptPath($: EngineInterface, surface?: CopySurface): Promise<string> {
194 const path = await resolveTranscriptPath($)
195 const isCopied = await copyToClipboard($, path, surface)
196 const message = isCopied
197 ? `Copied transcript path: ${path}`
198 : `Could not copy; the transcript path is ${path}`
199 $.ui.toast(message)
200
201 return message
202}
203
204async function resolveAppDataDirectory($: EngineInterface): Promise<string | undefined> {
205 return (await $.env.get('APPDATA')) || undefined
206}
207
208async function resolveScratchRoot($: EngineInterface): Promise<string | undefined> {
209 const appData = await resolveAppDataDirectory($)
210
211 return appData === undefined ? undefined : joinWindowsPath(appData, 'Claude', 'scratch-workspaces')
212}
213
214async function resolveTemporaryRoots($: EngineInterface): Promise<string[]> {
215 // $.env.get takes a literal name, so the module's variables can be listed.
216 const values = [
217 await $.env.get('TEMP').catch(() => undefined),
218 await $.env.get('TMP').catch(() => undefined),
219 await $.env.get('CLAUDE_CODE_TMPDIR').catch(() => undefined),
220 ]
221
222 return values.filter((value): value is string => typeof value === 'string' && value !== '')
223}
224
225// What a person can pick: the stored list less scratch, worktree and temporary
226// folders (older versions recorded some of those).
227async function pickableFolderFilter($: EngineInterface): Promise<(folder: string) => boolean> {
228 const scratchRoot = await resolveScratchRoot($)
229 const temporaryRoots = await resolveTemporaryRoots($)
230
231 return folder => isPickableFolder(folder, scratchRoot, temporaryRoots)
232}
233
234async function readStoredFolders($: EngineInterface): Promise<RecentFolderList> {
235 const isPickable = await pickableFolderFilter($)
236
237 return readFolderList(await $.store.get(STORE_KEY_RECENT_FOLDERS)).filter(isPickable)
238}
239
240// Other sessions write the same store, so every change starts from what it
241// holds now rather than from this session's copy.
242async function saveRecentFolders(
243 $: EngineInterface,
244 change: (stored: RecentFolderList) => RecentFolderList,
245): Promise<void> {
246 const folders = change(await readStoredFolders($))
247 await $.store.set(STORE_KEY_RECENT_FOLDERS, folders)
248 await $.state.set(recentFoldersState, folders)
249}
250
251async function recordFolder($: EngineInterface, folder: string): Promise<void> {
252 if (!(await pickableFolderFilter($))(folder)) {
253 return
254 }
255 await saveRecentFolders($, stored => placeFolderFirst(stored, folder))
256}
257
258type SessionRecordFile = { path: string; mtimeMs: number }
259
260// <APPDATA>\Claude\claude-code-sessions\<account>\<organization>\local_<id>.json
261async function listSessionRecordFiles(
262 $: EngineInterface,
263 sessionsRoot: string,
264): Promise<SessionRecordFile[]> {
265 const files: SessionRecordFile[] = []
266 for (const account of await $.fs.list(sessionsRoot).catch(() => [])) {
267 if (account.kind !== 'dir') {
268 continue
269 }
270 const accountPath = joinWindowsPath(sessionsRoot, account.name)
271 for (const organization of await $.fs.list(accountPath).catch(() => [])) {
272 if (organization.kind !== 'dir') {
273 continue
274 }
275 const organizationPath = joinWindowsPath(accountPath, organization.name)
276 for (const entry of await $.fs.list(organizationPath).catch(() => [])) {
277 if (entry.kind === 'file' && /^local_.+\.json$/.test(entry.name)) {
278 files.push({ path: joinWindowsPath(organizationPath, entry.name), mtimeMs: entry.mtimeMs })
279 }
280 }
281 }
282 }
283
284 return files
285}
286
287// The first time the toolbar loads, the folders of the desktop app's own
288// session records fill the list behind the ones already recorded.
289async function seedFromDesktopSessions($: EngineInterface): Promise<void> {
290 if ((await $.store.get(STORE_KEY_IS_SEEDED)) === true) {
291 return
292 }
293 const appData = await resolveAppDataDirectory($)
294 if (appData === undefined) {
295 return
296 }
297 const sessionsRoot = joinWindowsPath(appData, 'Claude', 'claude-code-sessions')
298 const files = (await listSessionRecordFiles($, sessionsRoot))
299 .sort((first, second) => second.mtimeMs - first.mtimeMs)
300 .slice(0, SESSION_RECORD_READ_LIMIT)
301 const records: SessionRecordFolder[] = []
302 for (const file of files) {
303 const text = await $.fs.read(file.path).catch(() => undefined)
304 const record = typeof text === 'string' ? readSessionRecordFolder(text, file.mtimeMs) : null
305 if (record !== null) {
306 records.push(record)
307 }
308 }
309 const seeded = rankSessionFolders(records).filter(await pickableFolderFilter($))
310 await saveRecentFolders($, stored => mergeFolderLists(stored, seeded))
311 await $.store.set(STORE_KEY_IS_SEEDED, true)
312}
313
314function describeToolResult(result: McpToolResult): string {
315 return result.content
316 .map(block => (typeof block.text === 'string' ? block.text : ''))
317 .join(' ')
318 .trim()
319}
320
321// The desktop app's own tool: it asks the person to approve the folder (unless
322// permissions are bypassed) and moves the session when the current turn ends.
323async function moveSession($: EngineInterface, folder: string): Promise<MoveOutcome> {
324 try {
325 const result = await $.mcp.call('ccd_directory', 'change_directory', { path: folder })
326 if (!result.isError) {
327 return { isMoved: true }
328 }
329 const reason = describeToolResult(result) || 'the desktop app refused the move'
330
331 return { isMoved: false, reason, isDeclined: /declin|denied|reject|cancel/i.test(reason) }
332 } catch (error) {
333 return { isMoved: false, reason: describeError(error), isDeclined: false }
334 }
335}
336
337async function hasConversationStarted($: EngineInterface, isWorking: boolean): Promise<boolean> {
338 return isWorking || (await $.session.turns()) > 0
339}
340
341// Module variables start over on a hot reload; session.start restarts the
342// dots of a move still pending.
343let movingDotsTimer: ReturnType<EngineInterface['clock']['every']> | null = null
344
345function startMovingDots($: EngineInterface): void {
346 if (movingDotsTimer === null) {
347 movingDotsTimer = $.clock.every(MOVING_DOTS_INTERVAL_MS, () =>
348 void update($, movingDotsAtom, dots => ((dots ?? 1) % MOVING_DOTS_MAXIMUM) + 1),
349 )
350 }
351}
352
353function stopMovingDots(): void {
354 movingDotsTimer?.cancel()
355 movingDotsTimer = null
356}
357
358// The desktop app moves the session when a turn ends, not at once: the toolbar
359// shows the target beside the folder list until the session is there.
360async function markPendingMove($: EngineInterface, target: string): Promise<void> {
361 const root = normalizeFolder(await $.session.root().catch(() => ''))
362 if (folderIdentity(root) === folderIdentity(target)) {
363 await update($, pendingMoveAtom, () => null)
364 stopMovingDots()
365 return
366 }
367 await update($, pendingMoveAtom, () => target)
368 startMovingDots($)
369}
370
371// Clears the pending move once the session has reached it.
372async function settlePendingMove($: EngineInterface): Promise<void> {
373 const pending = await read($, pendingMoveAtom)
374 if (pending === null) {
375 stopMovingDots()
376 return
377 }
378 const root = normalizeFolder(await $.session.root())
379 if (folderIdentity(root) === folderIdentity(pending)) {
380 await update($, pendingMoveAtom, () => null)
381 stopMovingDots()
382 } else {
383 startMovingDots($)
384 }
385}
386
387function whenMoveApplies(isWorking: boolean): string {
388 return isWorking ? 'when this turn ends' : 'after your next message'
389}
390
391// What happened, for a command's output; `isShownInToolbar` when the toolbar
392// already shows it (the folder list or the pending-move note), so no toast.
393type FolderOpening = { message: string; isShownInToolbar: boolean }
394
395async function openFolder($: EngineInterface, folder: string, isWorking: boolean): Promise<FolderOpening> {
396 const target = normalizeFolder(folder)
397 await recordFolder($, target).catch(() => undefined)
398 const hasStarted = await hasConversationStarted($, isWorking)
399 const outcome = await moveSession($, target)
400 if (outcome.isMoved) {
401 await markPendingMove($, target).catch(() => undefined)
402 // A turn a plugin submits does not make the desktop app apply the move;
403 // only a message the person sends does.
404 const message = hasStarted
405 ? `This conversation moves to ${target} ${whenMoveApplies(isWorking)}`
406 : `This conversation starts in ${target}`
407 return { message, isShownInToolbar: true }
408 }
409 if (outcome.isDeclined) {
410 return { message: `The move to ${target} was declined`, isShownInToolbar: false }
411 }
412 if (!hasStarted) {
413 return { message: `Could not open ${target}: ${outcome.reason}`, isShownInToolbar: false }
414 }
415
416 // A running conversation may refuse the move; leave the model a note to
417 // make it once this turn is done.
418 const appended = await $.session
419 .append({
420 message: { type: 'user', content: [{ type: 'text', text: moveAfterTurnMessage(target) }] },
421 })
422 .catch((error: unknown) => ({ deny: describeError(error) }))
423 if ('deny' in appended) {
424 return { message: `Could not move to ${target}: ${outcome.reason}`, isShownInToolbar: false }
425 }
426 await markPendingMove($, target).catch(() => undefined)
427
428 return {
429 message: `Could not move now (${outcome.reason}); Claude will move to ${target} after this turn`,
430 isShownInToolbar: true,
431 }
432}
433
434// Toasts only what the toolbar does not show: a failed or declined move.
435async function openFolderWithToast($: EngineInterface, folder: string, isWorking: boolean): Promise<void> {
436 const opening = await openFolder($, folder, isWorking)
437 if (!opening.isShownInToolbar) {
438 $.ui.toast(opening.message)
439 }
440}
441
442// `/folder <name or path>`: the toolbar is drawn only once a session exists,
443// so a new conversation can pick its folder with this as its first message.
444async function runFolderCommand($: EngineInterface, query: string): Promise<string> {
445 const wanted = query.trim().replace(/^"(.*)"$/, '$1')
446 if (wanted === '') {
447 return 'Pick a folder in the toolbar above the prompt, or name one: /folder <name or path>'
448 }
449 const folder = isAbsoluteFolder(wanted) ? wanted : findFolder(await readStoredFolders($), wanted)
450 if (folder === undefined) {
451 return `No recent folder matches "${wanted}"`
452 }
453 if (!(await $.fs.exists(folder).catch(() => false))) {
454 return `${folder} does not exist`
455 }
456
457 return (await openFolder($, folder, false)).message
458}
459
460async function pickFolder($: EngineInterface, initialFolder: string | undefined): Promise<string | null> {
461 const script = joinWindowsPath($.plugin.root, 'scripts', 'Pick_Folder.ps1')
462 const argv = [
463 'powershell',
464 '-NoProfile',
465 '-NonInteractive',
466 '-ExecutionPolicy',
467 'Bypass',
468 '-STA',
469 '-File',
470 script,
471 '-Title',
472 'Choose a folder for this conversation',
473 ]
474 if (initialFolder !== undefined) {
475 argv.push('-InitialFolder', initialFolder)
476 }
477 const result = await $.process.run(argv, { timeoutMs: FOLDER_PICKER_TIMEOUT_MS })
478 if (result.exitCode !== 0) {
479 throw new Error(result.stderr.trim() || `the folder picker exited with code ${result.exitCode}`)
480 }
481 const folder = result.stdout.trim()
482
483 return folder === '' ? null : folder
484}
485
486async function browseAndOpen($: EngineInterface, isWorking: boolean): Promise<void> {
487 try {
488 const lastUsedFolder = (await readStoredFolders($))[0]
489 const folder = await pickFolder($, lastUsedFolder)
490 if (folder !== null) {
491 await openFolderWithToast($, folder, isWorking)
492 }
493 } catch (error) {
494 $.ui.toast(`Could not show the folder picker: ${describeError(error)}`)
495 }
496}
497
498// Module variables start over on a hot reload; this one only saves a redundant
499// store write.
500let lastRecordedFolder = ''
501
502async function recordCurrentFolder($: EngineInterface): Promise<void> {
503 const root = normalizeFolder(await $.session.root())
504 if (folderIdentity(root) === lastRecordedFolder) {
505 return
506 }
507 lastRecordedFolder = folderIdentity(root)
508 await recordFolder($, root)
509}
510
511function usageSnapshot(usage: {
512 context: { tokens?: number; window: number; percent?: number }
513 rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
514}): UsageSnapshot {
515 return {
516 context: { tokens: usage.context.tokens, window: usage.context.window, percent: usage.context.percent },
517 rateLimits: usage.rateLimits.map(window => ({ kind: window.kind, percentUsed: window.percentUsed, resetsAt: window.resetsAt })),
518 }
519}
520
521type UsageEndpointCache = { fetchedAtMs: number; windows: UsageWindow[] }
522
523function readUsageEndpointCache(value: unknown): UsageEndpointCache | null {
524 if (typeof value !== 'object' || value === null) {
525 return null
526 }
527 const cache = value as { fetchedAtMs?: unknown; windows?: unknown }
528 if (typeof cache.fetchedAtMs !== 'number' || !Array.isArray(cache.windows) || cache.fetchedAtMs < processStartedAtMs) {
529 return null
530 }
531 const windows = cache.windows.filter(
532 (window): window is UsageWindow =>
533 typeof window === 'object' && window !== null && typeof window.kind === 'string' && typeof window.percentUsed === 'number',
534 )
535
536 return { fetchedAtMs: cache.fetchedAtMs, windows }
537}
538
539// Module variables start over on a hot reload, as the timers do.
540// The shared cache does not say whose account it read, and switching accounts
541// starts the sessions over, so an answer from before this process may be
542// another account's: it is neither shown nor taken as fresh.
543const processStartedAtMs = Date.now()
544let latestSessionUsage: UsageSnapshot | null = null
545let usageEndpointRequest: Promise<void> | null = null
546// The session's window readings come from response headers, so they are fresh
547// only during a turn and as it ends; 0 before any turn of this process.
548let isTurnRunning = false
549let lastTurnCompletedAtMs = 0
550
551function sessionWindowsReadAtMs(): number {
552 return isTurnRunning ? Date.now() : lastTurnCompletedAtMs
553}
554
555// The plan's usage windows, the Fable weekly quota among them, from the same
556// endpoint the engine's /usage reads. Every session shares one cached answer,
557// so the endpoint is asked at most once a minute however many sessions run.
558async function refreshUsageEndpoint($: EngineInterface, maximumAgeMs: number): Promise<void> {
559 const cached = readUsageEndpointCache(await $.store.get(STORE_KEY_USAGE_ENDPOINT))
560 if (cached !== null && Date.now() - cached.fetchedAtMs < maximumAgeMs) {
561 return
562 }
563 if (usageEndpointRequest !== null) {
564 return usageEndpointRequest
565 }
566 usageEndpointRequest = (async () => {
567 try {
568 const authorization = await $.session.authorize()
569 if (authorization === null) {
570 return
571 }
572 const response = await $.http.fetch(USAGE_ENDPOINT_URL, {
573 auth: authorization.handle,
574 headers: { 'Content-Type': 'application/json', 'anthropic-beta': USAGE_ENDPOINT_BETA },
575 })
576 if (!response.ok) {
577 $.ui.log(`The usage endpoint answered ${response.status}`, { to: 'debug' })
578 return
579 }
580 const windows = parseUsageEndpoint(JSON.parse(response.text))
581 await $.store.set(STORE_KEY_USAGE_ENDPOINT, { fetchedAtMs: Date.now(), windows })
582 } catch (error: unknown) {
583 $.ui.log(`Reading the usage endpoint failed: ${describeError(error)}`, { to: 'debug' })
584 } finally {
585 usageEndpointRequest = null
586 }
587 })()
588
589 return usageEndpointRequest
590}
591
592// The snapshot the widgets draw: the session's context, and the header and
593// endpoint windows, the fresher first. Not written to state from a render.
594async function composeUsage($: EngineInterface): Promise<UsageSnapshot | null> {
595 if (latestSessionUsage === null) {
596 latestSessionUsage = usageSnapshot(await $.session.usage())
597 }
598 const cached = readUsageEndpointCache(await $.store.get(STORE_KEY_USAGE_ENDPOINT))
599
600 return {
601 context: latestSessionUsage.context,
602 rateLimits: mergeWindows(latestSessionUsage.rateLimits, cached?.windows ?? [], sessionWindowsReadAtMs(), cached?.fetchedAtMs ?? 0),
603 }
604}
605
606// Writes the snapshot only when a figure changed, so a quiet session does not redraw.
607async function publishUsage($: EngineInterface): Promise<void> {
608 try {
609 const snapshot = await composeUsage($)
610 const current = await read($, usageAtom)
611 if (JSON.stringify(current) !== JSON.stringify(snapshot)) {
612 await update($, usageAtom, () => snapshot)
613 }
614 } catch (error: unknown) {
615 $.ui.log(`Updating the usage widgets failed: ${describeError(error)}`, { to: 'debug' })
616 }
617}
618
619async function refreshSessionUsage($: EngineInterface): Promise<void> {
620 try {
621 latestSessionUsage = usageSnapshot(await $.session.usage())
622 } catch (error: unknown) {
623 $.ui.log(`Reading the session usage failed: ${describeError(error)}`, { to: 'debug' })
624 return
625 }
626 await publishUsage($)
627}
628
629// Takes the engine's agent list into the dots' records, writing only when a
630// state changed so a quiet session does not redraw.
631async function refreshSubagents($: EngineInterface): Promise<void> {
632 try {
633 const listed = await $.agent.list()
634 const nowMs = Date.now()
635 const current = (await read($, subagentsAtom)) ?? []
636 if (JSON.stringify(mergeListedAgents(current, listed, nowMs)) !== JSON.stringify(current)) {
637 await writeSubagents($, records => mergeListedAgents(records, listed, nowMs))
638 }
639 } catch (error: unknown) {
640 $.ui.log(`Reading the subagent list failed: ${describeError(error)}`, { to: 'debug' })
641 }
642}
643
644// Writes the dots' records and asks for a redraw: the desktop app does not
645// redraw the band on the state change alone, so a dot would wait for the
646// next unrelated redraw (a usage figure, a turn boundary).
647async function writeSubagents(
648 $: EngineInterface,
649 change: (records: SubagentRecord[]) => SubagentRecord[],
650): Promise<void> {
651 await update($, subagentsAtom, records => change(records ?? []))
652 $.ui.invalidate('ui.render')
653}
654
655// Module variables start over on a hot reload, which does not run
656// session.start again; every turn boundary makes sure the list is polled.
657let subagentListTimer: ReturnType<EngineInterface['clock']['every']> | null = null
658
659function ensureSubagentPolling($: EngineInterface): void {
660 if (subagentListTimer === null) {
661 subagentListTimer = $.clock.every(SUBAGENT_LIST_INTERVAL_MS, () => {
662 void refreshSubagents($)
663 void checkMonitorDeadlines($)
664 })
665 void refreshSubagents($)
666 }
667 ensureMonitorBackfill($)
668}
669
670// Writes the clocks' records and asks for a redraw, as writeSubagents does.
671async function writeMonitors(
672 $: EngineInterface,
673 change: (records: MonitorRecord[]) => MonitorRecord[],
674): Promise<void> {
675 await update($, monitorsAtom, records => change(records ?? []))
676 $.ui.invalidate('ui.render')
677}
678
679// Module variables start over on a hot reload, and a Monitor started before
680// this module loaded raised no tool.call here: once per load, the transcript
681// gives the Monitors back.
682let monitorBackfill: Promise<void> | null = null
683
684function ensureMonitorBackfill($: EngineInterface): void {
685 if (monitorBackfill !== null) {
686 return
687 }
688 monitorBackfill = (async () => {
689 try {
690 const transcript = await $.fs.read(await resolveTranscriptPath($))
691 const nowMs = Date.now()
692 const parsed = expireMonitors(parseTranscriptMonitors(typeof transcript === 'string' ? transcript : '', nowMs), nowMs)
693 const current = (await read($, monitorsAtom)) ?? []
694 const merged = mergeMonitorRecords(current, parsed)
695 if (JSON.stringify(merged) !== JSON.stringify(current)) {
696 await writeMonitors($, () => merged)
697 }
698 } catch (error: unknown) {
699 $.ui.log(`Reading the Monitors from the transcript failed: ${describeError(error)}`, { to: 'debug' })
700 }
701 })()
702}
703
704let lastMonitorRedrawMs = 0
705
706// Ends the clocks past their deadline, and redraws a waiting clock now and
707// then so its countdown and tooltip move on.
708async function checkMonitorDeadlines($: EngineInterface): Promise<void> {
709 try {
710 const nowMs = Date.now()
711 const current = (await read($, monitorsAtom)) ?? []
712 const expired = expireMonitors(current, nowMs)
713 if (JSON.stringify(expired) !== JSON.stringify(current)) {
714 lastMonitorRedrawMs = nowMs
715 await writeMonitors($, records => expireMonitors(records, nowMs))
716 } else if (
717 activeMonitors(current).length > 0 &&
718 (isCountingSeconds(activeMonitors(current), nowMs) || nowMs - lastMonitorRedrawMs >= MONITOR_COUNTDOWN_REFRESH_MS)
719 ) {
720 lastMonitorRedrawMs = nowMs
721 $.ui.invalidate('ui.render')
722 }
723 } catch (error: unknown) {
724 $.ui.log(`Checking the Monitor deadlines failed: ${describeError(error)}`, { to: 'debug' })
725 }
726}
727
728function monitorTooltip(active: readonly MonitorRecord[], nowMs: number): string {
729 return [monitorSummary(active), ...monitorLines(active, nowMs)].join('\n')
730}
731
732// The desktop app's own pane of background tasks, opened through the app's
733// session MCP server; a surface without it (the terminal) gets a toast.
734async function openBackgroundTasksPane($: EngineInterface): Promise<void> {
735 try {
736 const result = await $.mcp.call(TASKS_PANE_SERVER, TASKS_PANE_TOOL, { pane: 'tasks' })
737 if (result.isError) {
738 const text = result.content.find(block => block.type === 'text')?.text ?? 'the app refused'
739 $.ui.toast(`Could not open the background tasks pane: ${text}`)
740 }
741 } catch (error: unknown) {
742 $.ui.toast(`Could not open the background tasks pane: ${describeError(error)}`)
743 }
744}
745
746function subagentTooltip(records: readonly SubagentRecord[], nowMs: number): string {
747 const lines = subagentLines(records, nowMs)
748 const shown = lines.slice(0, SUBAGENT_TOOLTIP_LINE_LIMIT)
749 const more = lines.length > shown.length ? [`… ${lines.length - shown.length} more`] : []
750 return [subagentSummary(records), ...shown, ...more].join('\n')
751}
752
753// The usage widgets, left to right: the model's weekly ring (Fable), the
754// context bar and its token count (a button that pins the detail row open),
755// the 5-hour ring and the weekly ring, each ring drawn alike. Hovering a
756// widget shows its SVG title as a tooltip; an Svg takes no press, so the count
757// is the click target. The terminal gets one dim line and the detail row on demand.
758function usageElements(
759 $: EngineInterface,
760 e: Parameters<Parameters<EngineInterface['ui']['resolve']>[0] extends infer Argument ? (argument: Argument) => void : never>[0],
761 widgets: ReturnType<typeof usageWidgets>,
762 isPinned: boolean,
763) {
764 const togglePin = () => void update($, usageDetailsPinnedAtom, value => !value)
765 if (e.surface === 'terminal') {
766 const { Box, Button, Text } = $.ui.resolve(e)
767
768 return {
769 row: (
770 <Box key="usage-widgets" flexDirection="row" columnGap={1}>
771 <Text dimColor>{usageText(widgets)}</Text>
772 <Button key="usage-details" label={isPinned ? 'Hide' : 'Details'} plain dimColor onPress={togglePin} />
773 </Box>
774 ),
775 details: isPinned ? (
776 <Box key="usage-detail-row" flexDirection="column">
777 {widgets.details.map(line => (
778 <Text dimColor>{line}</Text>
779 ))}
780 </Box>
781 ) : null,
782 }
783 }
784 const { Box, Button, Svg } = $.ui.resolve(e)
785 // Tooltips take the single-spaced sentences; only the pinned block is aligned.
786 const { fiveHour: fiveHourLine, weekly: weeklyLine, model: modelLine, context: contextLine } = widgets.tooltips
787 const detailSize = detailBlockSize(widgets.details)
788
789 return {
790 row: (
791 <Box key="usage-widgets" flexDirection="row" columnGap={1} alignItems="center">
792 <Box key="model-weekly-ring">
793 <Svg
794 source={quotaRingSvg(widgets.modelWeekly.window?.percentUsed ?? null, modelLine)}
795 alt={modelLine}
796 width={RING_SIZE_PX}
797 height={RING_SIZE_PX}
798 isInteractive
799 />
800 </Box>
801 <Button key="usage-details" label={widgets.context.label} plain dimColor={!isPinned} onPress={togglePin} />
802 <Box key="context-bar">
803 <Svg
804 source={contextBarSvg(widgets.context.percentOfScale, widgets.context.color, contextLine)}
805 alt={contextLine}
806 width={BAR_WIDTH_PX}
807 height={BAR_HEIGHT_PX}
808 isInteractive
809 />
810 </Box>
811 <Box key="five-hour-ring">
812 <Svg
813 source={quotaRingSvg(widgets.fiveHour?.percentUsed ?? null, fiveHourLine)}
814 alt={fiveHourLine}
815 width={RING_SIZE_PX}
816 height={RING_SIZE_PX}
817 isInteractive
818 />
819 </Box>
820 <Box key="seven-day-ring">
821 <Svg
822 source={quotaRingSvg(widgets.weekly?.percentUsed ?? null, weeklyLine)}
823 alt={weeklyLine}
824 width={RING_SIZE_PX}
825 height={RING_SIZE_PX}
826 isInteractive
827 />
828 </Box>
829 </Box>
830 ),
831 // Drawn only while pinned: the desktop app does not hide a Box drawn
832 // display "none", so a hover reveal would show the row all the time.
833 // The block is Consolas text in an SVG, right-aligned under the widgets.
834 details: isPinned ? (
835 <Box key="usage-detail-row" flexDirection="row" justifyContent="flex-end">
836 <Svg source={detailBlockSvg(widgets.details)} alt={widgets.details.join('; ')} width={detailSize.width} height={detailSize.height} />
837 </Box>
838 ) : null,
839 }
840}
841
842export const register: Register = on => {
843 on('session.start', async ($, e, next) => {
844 await $.command.register({
845 name: 'copy-transcript-path',
846 description: 'Copy the path of this conversation transcript (.jsonl)',
847 })
848 await $.command.register({
849 name: 'folder',
850 description: 'Open or move this conversation to a recent folder (matched by name) or a path',
851 argumentHint: '<name or path>',
852 })
853 await $.state.set(recentFoldersState, await readStoredFolders($))
854 lastRecordedFolder = folderIdentity(e.cwd)
855 await recordFolder($, e.cwd).catch(() => undefined)
856 void seedFromDesktopSessions($).catch((error: unknown) =>
857 $.ui.log(`Reading the desktop session records failed: ${describeError(error)}`, { to: 'debug' }),
858 )
859 // Live usage: the session's own figures (a free local read) every few
860 // seconds, so the context bar follows each response within a turn; the
861 // plan windows from the shared endpoint cache once a minute.
862 void refreshUsageEndpoint($, USAGE_ENDPOINT_INTERVAL_MS - 5_000).then(() => refreshSessionUsage($))
863 // A move still pending from before a reload gets its dots back; the same
864 // cheap check every few seconds clears the note once the session is there.
865 void settlePendingMove($).catch(() => undefined)
866 $.clock.every(SESSION_USAGE_INTERVAL_MS, () => {
867 void refreshSessionUsage($)
868 void settlePendingMove($).catch(() => undefined)
869 })
870 $.clock.every(USAGE_ENDPOINT_INTERVAL_MS, () =>
871 void refreshUsageEndpoint($, USAGE_ENDPOINT_INTERVAL_MS - 5_000).then(() => publishUsage($)),
872 )
873 // The subagent dots follow the engine's agent list (a local read).
874 ensureSubagentPolling($)
875
876 return next(e)
877 })
878
879 // A started subagent gets its dot at once, before the next list read.
880 on('agent.spawn', async ($, e, next) => {
881 ensureSubagentPolling($)
882 const result = await next(e)
883 const agentId = result.agentId
884 if (agentId !== undefined) {
885 const agent = { id: agentId, description: e.description, type: e.subagentType }
886 await writeSubagents($, records => recordSpawn(records, agent, Date.now())).catch(() => undefined)
887 }
888
889 return result
890 })
891
892 // A Monitor of the main conversation gets its waiting clock once the tool
893 // answers with its task id.
894 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
895 ensureSubagentPolling($)
896 const ran = await next(e)
897 if (e.agentId === undefined && ran.deny === undefined && ran.isError !== true) {
898 const monitor = readMonitorStart(ran.result, e.description, Date.now())
899 if (monitor !== null) {
900 await writeMonitors($, records => recordMonitorStart(records, monitor)).catch(() => undefined)
901 }
902 }
903
904 return ran
905 })
906
907 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
908 const ran = await next(e)
909 const taskId = e.task_id ?? e.shell_id
910 if (e.agentId === undefined && taskId !== undefined && ran.deny === undefined && ran.isError !== true) {
911 await writeMonitors($, records => recordMonitorEnd(records, taskId, 'stopped', Date.now())).catch(() => undefined)
912 }
913
914 return ran
915 })
916
917 // A Monitor's last notification carries a status (completed, failed,
918 // killed...); its event notifications carry none.
919 on('prompt.submit', async ($, e, next) => {
920 if (e.origin.kind === 'task-notification') {
921 const endings = parseNotificationEndings(e.text)
922 if (endings.length > 0) {
923 await writeMonitors($, records => applyEndings(records, endings, Date.now())).catch(() => undefined)
924 }
925 }
926
927 return next(e)
928 })
929
930 // A session moved by the desktop app, the model or this toolbar records its
931 // new folder at the next turn boundary.
932 on('turn.start', async ($, e, next) => {
933 isTurnRunning = true
934 ensureSubagentPolling($)
935 await recordCurrentFolder($).catch(() => undefined)
936 await settlePendingMove($).catch(() => undefined)
937
938 return next(e)
939 })
940
941 on('turn.complete', async ($, e, next) => {
942 // A subagent's run ends in a turn.complete of its own: its dot takes the
943 // outcome; the main loop's turn state is untouched.
944 const agentId = e.agentId
945 if (agentId !== undefined) {
946 const result = await next(e)
947 await writeSubagents($, records => recordTurnEnd(records, agentId, e.reason, Date.now())).catch(() => undefined)
948
949 return result
950 }
951 isTurnRunning = false
952 lastTurnCompletedAtMs = Date.now()
953 const result = await next(e)
954 await recordCurrentFolder($).catch(() => undefined)
955 await settlePendingMove($).catch(() => undefined)
956 // A turn spends quota: ask the endpoint again unless another session just did.
957 void refreshUsageEndpoint($, 15_000).then(() => refreshSessionUsage($))
958
959 return result
960 })
961
962 // The engine measures the context fill and the rate-limit windows after each
963 // turn and whenever a plan window moves a point; a moved window also asks
964 // the endpoint again so the Fable ring follows.
965 on('session.measure', async ($, e, next) => {
966 latestSessionUsage = usageSnapshot({ context: e.context, rateLimits: e.rateLimits })
967 await publishUsage($)
968 if (e.changed.includes('rateLimits')) {
969 void refreshUsageEndpoint($, 15_000).then(() => publishUsage($))
970 }
971
972 return next(e)
973 })
974
975 on('command.run', { command: 'copy-transcript-path' }, async $ => {
976 return { text: await copyTranscriptPath($) }
977 })
978
979 on('command.run', { command: 'folder' }, async ($, e) => {
980 return { text: await runFolderCommand($, e.args) }
981 })
982
983 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
984 if (e.props.hasSurvey || e.props.view.agentId !== undefined) {
985 return next(e)
986 }
987
988 const elements = $.ui.resolve(e)
989 const { Box, Button, Text } = elements
990 const copyButton = (
991 <Button
992 key="copy-transcript-path"
993 label="Copy Transcript Path"
994 variant="secondary"
995 onPress={press => void copyTranscriptPath($, press.surface)}
996 />
997 )
998 // The usage widgets at the right end. Before the first publication the
999 // figures are composed here (a render reads state, it never writes it).
1000 const usage = (await read($, usageAtom)) ?? (await composeUsage($).catch(() => null))
1001 const modelId = await $.session.model().catch(() => '')
1002 const usageParts = usageElements($, e, usageWidgets(usage, Date.now(), modelId), await read($, usageDetailsPinnedAtom))
1003 // One dot per subagent of this session, drawn once there is one; hovering
1004 // lists them. The terminal shows the counts as text.
1005 const subagents = (await read($, subagentsAtom)) ?? []
1006 // A clock per Monitor still watching, left of the dots. A hot reload at an
1007 // idle moment raises no turn: the first draw starts the poll and the
1008 // transcript read (their writes come after this draw).
1009 ensureSubagentPolling($)
1010 const monitors = activeMonitors((await read($, monitorsAtom)) ?? [])
1011 let subagentIndicator = null
1012 if (subagents.length > 0 || monitors.length > 0) {
1013 // An Svg takes no press and a Button holds no Svg, so a one-glyph plain
1014 // button beside the dots opens the desktop app's background tasks pane.
1015 const openTasks = (
1016 <Button
1017 key="open-background-tasks"
1018 label={OPEN_TASKS_GLYPH}
1019 plain
1020 dimColor
1021 onPress={() => void openBackgroundTasksPane($)}
1022 />
1023 )
1024 const nowMs = Date.now()
1025 if (e.surface !== 'terminal' && 'Svg' in elements) {
1026 const { Svg } = elements
1027 subagentIndicator = (
1028 <Box key="subagent-dots" flexDirection="row" alignItems="center">
1029 {monitors.length > 0 ? (
1030 <Box key="monitor-clocks">
1031 <Svg
1032 source={monitorClocksSvg(monitors, monitorTooltip(monitors, nowMs), nowMs)}
1033 alt={monitorSummary(monitors)}
1034 width={monitorClocksWidth(monitors, nowMs)}
1035 height={DOTS_HEIGHT_PX}
1036 isInteractive
1037 />
1038 </Box>
1039 ) : null}
1040 {subagents.length > 0 ? (
1041 <Box key="subagent-dot-row">
1042 <Svg
1043 source={subagentDotsSvg(subagents, subagentTooltip(subagents, nowMs))}
1044 alt={subagentSummary(subagents)}
1045 width={subagentDotsWidth(subagents)}
1046 height={DOTS_HEIGHT_PX}
1047 isInteractive
1048 />
1049 </Box>
1050 ) : null}
1051 {openTasks}
1052 </Box>
1053 )
1054 } else {
1055 subagentIndicator = (
1056 <Box key="subagent-dots" flexDirection="row" columnGap={1}>
1057 {monitors.length > 0 ? <Text dimColor>{monitorText(monitors)}</Text> : null}
1058 {subagents.length > 0 ? <Text dimColor>{subagentText(subagents)}</Text> : null}
1059 {openTasks}
1060 </Box>
1061 )
1062 }
1063 }
1064 if (!('Select' in elements)) {
1065 return (
1066 <Box flexDirection="column">
1067 <Box flexDirection="row" columnGap={1}>
1068 {subagentIndicator}
1069 {copyButton}
1070 {usageParts.row}
1071 </Box>
1072 {usageParts.details}
1073 </Box>
1074 )
1075 }
1076 const { Select } = elements
1077
1078 const recentFolders = await $.state.get(recentFoldersState)
1079 // Before session.start has filled the state (or in a test), read the store.
1080 const isPickable = await pickableFolderFilter($)
1081 const stored = (recentFolders.version === 0 ? await readStoredFolders($) : (recentFolders.value ?? [])).filter(isPickable)
1082 // The current folder always shows, even one the list does not keep (a
1083 // worktree); a scratch workspace shows as "No folder".
1084 const root = normalizeFolder(await $.session.root())
1085 const scratchRoot = await resolveScratchRoot($)
1086 const isScratch = scratchRoot !== undefined && isFolderInside(root, scratchRoot)
1087 const isListed = stored.some(folder => folderIdentity(folder) === folderIdentity(root))
1088 const folders = isScratch || isListed ? stored : [root, ...stored]
1089 const currentFolder = isScratch
1090 ? undefined
1091 : folders.find(folder => folderIdentity(folder) === folderIdentity(root))
1092 const labelWidth = Math.min(
1093 MAXIMUM_FOLDER_LABEL_WIDTH,
1094 Math.max(MINIMUM_FOLDER_LABEL_WIDTH, e.props.bodyColumns - COLUMNS_BESIDE_FOLDER_LABEL),
1095 )
1096 // The folders, then "Browse…" last, which opens the folder picker.
1097 const options = [
1098 ...[
1099 ...(currentFolder === undefined ? [{ value: NO_FOLDER_CHOSEN, label: 'No folder' }] : []),
1100 ...folders.map(folder => ({ value: folder, label: folderOptionLabel(folder, labelWidth) })),
1101 ].slice(0, SELECT_OPTION_LIMIT - 1),
1102 { value: BROWSE_OPTION, label: 'Browse…' },
1103 ]
1104 const isWorking = e.props.isWorking
1105 // A move the desktop app has not made yet: "→ Python_Lib Moving after
1106 // your next message..." in grey beside the list, the dots cycling. The
1107 // dots are padded with no-break spaces so the row does not shift.
1108 const pendingMove = await read($, pendingMoveAtom)
1109 const isMovePending = pendingMove !== null && folderIdentity(pendingMove) !== folderIdentity(root)
1110 const movingDots = '.'.repeat(await read($, movingDotsAtom)).padEnd(MOVING_DOTS_MAXIMUM, ' ')
1111 const pendingMoveNote = isMovePending ? (
1112 <Box key="pending-move" flexDirection="row" columnGap={1} alignItems="center">
1113 <Text dimColor>→</Text>
1114 <Text dimColor bold>{folderOptionLabel(pendingMove, labelWidth)}</Text>
1115 <Text dimColor>{`Moving ${whenMoveApplies(isWorking)}${movingDots}`}</Text>
1116 </Box>
1117 ) : null
1118
1119 return (
1120 <Box flexDirection="column" width="100%">
1121 <Box flexDirection="row" justifyContent="space-between" columnGap={1} width="100%">
1122 <Box flexDirection="row" columnGap={1} alignItems="center">
1123 <Select
1124 key="recent-folders"
1125 options={options}
1126 value={currentFolder ?? NO_FOLDER_CHOSEN}
1127 onSelect={value => {
1128 if (value === BROWSE_OPTION) {
1129 // Redraw afterwards so the list shows the current folder again.
1130 void browseAndOpen($, isWorking).finally(() => $.ui.invalidate('ui.render'))
1131 } else if (value !== NO_FOLDER_CHOSEN) {
1132 void openFolderWithToast($, value, isWorking)
1133 }
1134 }}
1135 />
1136 {pendingMoveNote}
1137 {subagentIndicator}
1138 </Box>
1139 <Box flexDirection="row" columnGap={2} alignItems="center">
1140 {copyButton}
1141 {usageParts.row}
1142 </Box>
1143 </Box>
1144 {usageParts.details}
1145 </Box>
1146 )
1147 })
1148}
1149hooks/folder_list.ts 281 lines1export const RECENT_FOLDER_LIMIT = 100
2
3export function joinWindowsPath(...parts: string[]): string {
4 return parts.map(part => part.replace(/[\\/]+$/, '')).join('\\')
5}
6
7// Claude Code names each project folder after the working directory with every
8// character outside [A-Za-z0-9] replaced by '-'.
9export function projectDirectoryName(workingDirectory: string): string {
10 return workingDirectory.replace(/[^a-zA-Z0-9]/g, '-')
11}
12
13const ELLIPSIS = '\u2026'
14// East Asian wide characters take two columns.
15const WIDE_CHARACTER =
16 /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/
17
18function isWindowsPath(folder: string): boolean {
19 return /^[A-Za-z]:[\\/]/.test(folder) || /^[\\/]{2}[^\\/]/.test(folder)
20}
21
22export function isAbsoluteFolder(folder: string): boolean {
23 return isWindowsPath(folder) || folder.startsWith('/')
24}
25
26export function displayWidth(text: string): number {
27 let width = 0
28 for (const character of text) {
29 width += WIDE_CHARACTER.test(character) ? 2 : 1
30 }
31
32 return width
33}
34
35// Keeps the end of the text behind an ellipsis.
36function keepEndWithinWidth(text: string, maxWidth: number): string {
37 if (displayWidth(text) <= maxWidth) {
38 return text
39 }
40 const characters = [...text]
41 let kept = ''
42 let width = displayWidth(ELLIPSIS)
43 for (let index = characters.length - 1; index >= 0; index -= 1) {
44 const character = characters[index] ?? ''
45 const characterWidth = displayWidth(character)
46 if (width + characterWidth > maxWidth) {
47 break
48 }
49 kept = character + kept
50 width += characterWidth
51 }
52
53 return ELLIPSIS + kept
54}
55
56// Shortens a path to the width by dropping its middle folders: the drive (or
57// share) and as many trailing folders as fit stay, `E:\…\Projects\Chemistry`.
58export function compressPath(path: string, maxWidth: number): string {
59 if (displayWidth(path) <= maxWidth) {
60 return path
61 }
62 const separator = path.includes('\\') ? '\\' : '/'
63 const segments = path.split(separator)
64 const headCount = path.startsWith(separator + separator) ? 4 : 1
65 const head = segments.slice(0, headCount).join(separator)
66 const rest = segments.slice(headCount).filter(segment => segment !== '')
67 let tail = ''
68 for (let index = rest.length - 1; index >= 0; index -= 1) {
69 const candidateTail = tail === '' ? (rest[index] ?? '') : `${rest[index] ?? ''}${separator}${tail}`
70 if (displayWidth(`${head}${separator}${ELLIPSIS}${separator}${candidateTail}`) > maxWidth) {
71 break
72 }
73 tail = candidateTail
74 }
75 if (tail !== '') {
76 return `${head}${separator}${ELLIPSIS}${separator}${tail}`
77 }
78
79 return keepEndWithinWidth(path, maxWidth)
80}
81
82// Windows paths are written with backslashes, an upper-case drive letter and
83// no trailing separator (a drive root keeps its own).
84export function normalizeFolder(folder: string): string {
85 const trimmed = folder.trim()
86 if (!isWindowsPath(trimmed)) {
87 return trimmed.length > 1 ? trimmed.replace(/\/+$/, '') : trimmed
88 }
89 const backslashed = trimmed
90 .replace(/\//g, '\\')
91 .replace(/^([a-z]):/, (_match, drive: string) => `${drive.toUpperCase()}:`)
92
93 return /^[A-Z]:\\$/.test(backslashed) ? backslashed : backslashed.replace(/\\+$/, '')
94}
95
96// Two spellings of one Windows folder differ only in case.
97export function folderIdentity(folder: string): string {
98 const normalized = normalizeFolder(folder)
99
100 return isWindowsPath(normalized) ? normalized.toLowerCase() : normalized
101}
102
103export function folderName(folder: string): string {
104 const normalized = normalizeFolder(folder)
105 const parts = normalized.split(/[\\/]/).filter(part => part !== '')
106
107 return parts.at(-1) ?? normalized
108}
109
110// The folder holding this one; empty for a drive or file-system root.
111export function folderParent(folder: string): string {
112 const normalized = normalizeFolder(folder)
113 const separatorIndex = Math.max(normalized.lastIndexOf('\\'), normalized.lastIndexOf('/'))
114 if (separatorIndex < 0 || separatorIndex === normalized.length - 1) {
115 return ''
116 }
117 const parent = normalized.slice(0, separatorIndex)
118 if (parent === '') {
119 return '/'
120 }
121
122 return /^[A-Za-z]:$/.test(parent) ? `${parent}\\` : parent
123}
124
125// The folder's own name alone, `Python_Lib`; a name past the width keeps its
126// end behind an ellipsis. The full path stays in the option's value.
127export function folderOptionLabel(folder: string, maxWidth: number): string {
128 return keepEndWithinWidth(folderName(folder), maxWidth)
129}
130
131// The recent folder a typed name means: the same name, then a name starting
132// with it, then a name containing it, then a path containing it.
133export function findFolder(folders: readonly string[], query: string): string | undefined {
134 const wanted = query.trim().toLowerCase()
135 if (wanted === '') {
136 return undefined
137 }
138 const named = (matches: (name: string) => boolean) =>
139 folders.find(folder => matches(folderName(folder).toLowerCase()))
140
141 return (
142 named(name => name === wanted) ??
143 named(name => name.startsWith(wanted)) ??
144 named(name => name.includes(wanted)) ??
145 folders.find(folder => folder.toLowerCase().includes(wanted))
146 )
147}
148
149export function mergeFolderLists(
150 first: readonly string[],
151 second: readonly string[],
152 limit = RECENT_FOLDER_LIMIT,
153): string[] {
154 const seen = new Set<string>()
155 const merged: string[] = []
156 for (const folder of [...first, ...second]) {
157 if (folder.trim() === '') {
158 continue
159 }
160 const normalized = normalizeFolder(folder)
161 const identity = folderIdentity(normalized)
162 if (seen.has(identity)) {
163 continue
164 }
165 seen.add(identity)
166 merged.push(normalized)
167 if (merged.length >= limit) {
168 break
169 }
170 }
171
172 return merged
173}
174
175export function placeFolderFirst(
176 folders: readonly string[],
177 folder: string,
178 limit = RECENT_FOLDER_LIMIT,
179): string[] {
180 return mergeFolderLists([folder], folders, limit)
181}
182
183export function isFolderInside(folder: string, parent: string): boolean {
184 const child = folderIdentity(folder)
185 const root = folderIdentity(parent)
186 const prefix = /[\\/]$/.test(root) ? root : `${root}\\`
187
188 return child === root || child.startsWith(prefix)
189}
190
191// Temporary folders: Python's tempfile names (tmpk3j_x9ab), anything under a
192// Temp folder or the session temp folder, and scratch folders named _Claude_tmp.
193const TEMPORARY_FOLDER_PATTERNS = [
194 /[\\/]tmp[a-z0-9_]{5,}([\\/]|$)/i,
195 /[\\/]AppData[\\/]Local[\\/]Temp([\\/]|$)/i,
196 /[\\/]_Claude_Desktop_Temp([\\/]|$)/i,
197 /[\\/]_Claude_tmp([\\/]|$)/i,
198]
199
200// Scratch workspaces ("No folder" sessions), session worktrees and temporary
201// folders are not folders a person picks. `temporaryRoots` are the TEMP, TMP
202// and CLAUDE_CODE_TMPDIR folders of this machine.
203export function isPickableFolder(folder: string, scratchRoot: string | undefined, temporaryRoots: readonly string[] = []): boolean {
204 if (folder.trim() === '') {
205 return false
206 }
207 if (scratchRoot !== undefined && isFolderInside(folder, scratchRoot)) {
208 return false
209 }
210 if (temporaryRoots.some(root => root.trim() !== '' && isFolderInside(folder, root))) {
211 return false
212 }
213 if (TEMPORARY_FOLDER_PATTERNS.some(pattern => pattern.test(folder))) {
214 return false
215 }
216
217 return !/[\\/]\.claude[\\/]worktrees([\\/]|$)/i.test(folder)
218}
219
220// The store holds JSON written by any version of this plugin; keep what is a path.
221export function readFolderList(value: unknown): string[] {
222 if (!Array.isArray(value)) {
223 return []
224 }
225
226 return mergeFolderLists(
227 value.filter((item): item is string => typeof item === 'string'),
228 [],
229 )
230}
231
232export type SessionRecordFolder = { folder: string; lastActivityAt: number }
233
234// One record the desktop app keeps per local session, as JSON text.
235export function readSessionRecordFolder(
236 text: string,
237 fallbackTime: number,
238): SessionRecordFolder | null {
239 let record: unknown
240 try {
241 record = JSON.parse(text)
242 } catch {
243 return null
244 }
245 if (typeof record !== 'object' || record === null) {
246 return null
247 }
248 const fields = record as Record<string, unknown>
249 if (fields.isScratchWorkspace === true) {
250 return null
251 }
252 const folder = [fields.originCwd, fields.cwd].find(
253 (value): value is string => typeof value === 'string' && value.trim() !== '',
254 )
255 if (folder === undefined) {
256 return null
257 }
258 const lastActivityAt =
259 [fields.lastActivityAt, fields.createdAt].find(
260 (value): value is number => typeof value === 'number',
261 ) ?? fallbackTime
262
263 return { folder, lastActivityAt }
264}
265
266// Each folder once, ordered by the newest session that used it.
267export function rankSessionFolders(records: readonly SessionRecordFolder[]): string[] {
268 const newest = new Map<string, SessionRecordFolder>()
269 for (const record of records) {
270 const identity = folderIdentity(record.folder)
271 const known = newest.get(identity)
272 if (known === undefined || record.lastActivityAt > known.lastActivityAt) {
273 newest.set(identity, record)
274 }
275 }
276
277 return [...newest.values()]
278 .sort((first, second) => second.lastActivityAt - first.lastActivityAt)
279 .map(record => normalizeFolder(record.folder))
280}
281hooks/usage_wheels.ts 487 lines1// Pure helpers for the usage widgets at the right end of the toolbar, left to
2// right: the model's weekly ring (Fable), the token count and the context bar,
3// then the 5-hour ring and the weekly ring, every ring the same: the Codex
4// outer arc with the whole percentage inside. Drawing rules and colors are
5// copied from the author's Codex usage widget (its rings.mjs and Widget.cs).
6// No engine calls here.
7
8import type { UsageSnapshot, UsageWindow } from '../types'
9
10export type UsageWidgets = {
11 // The weekly window scoped to one model (Fable), as the usage endpoint lists it.
12 modelWeekly: { window: UsageWindow | undefined; name: string }
13 // `label` is the count alone ("110k"), `figure` the count over the window ("110k / 1M").
14 context: { tokens: number | null; percentOfScale: number | null; color: string; label: string; figure: string }
15 fiveHour: UsageWindow | undefined
16 weekly: UsageWindow | undefined
17 // The detail block's lines, every column padded for Consolas:
18 // "Context : 350k / 1M",
19 // "5 h limit: 91% (Reset in 4 h 10 min. Runs out in 5 min.)",
20 // "7 d limit: 76% (Reset in 5 d 14 h. Runs out in 10 h 45 min.)", "Fable : ...".
21 lines: { context: string; fiveHour: string; weekly: string; model: string }
22 // The same figures with single spaces, for the tooltips:
23 // "5 h limit: 91% (Reset in 4 h 2 min. Runs out in 25 min.)", "Context: 350k / 1M".
24 tooltips: { context: string; fiveHour: string; weekly: string; model: string }
25 // The detail block, in that order, then any other model's weekly window.
26 details: string[]
27}
28
29export const RING_SIZE_PX = 24
30export const BAR_WIDTH_PX = 48
31export const BAR_HEIGHT_PX = 24
32// The context bar is full at 500k tokens, whatever the model's own window.
33export const CONTEXT_BAR_SCALE_TOKENS = 500_000
34// A model-scoped weekly window, as the usage endpoint's limits[] lists it.
35export const MODEL_WEEKLY_KIND_PREFIX = 'model_weekly:'
36
37// The Codex usage widget's palette and thresholds.
38export const CODEX_COLORS = { blue: '#4797ed', green: '#5abd7b', yellow: '#eeb347', red: '#ef6666' } as const
39const UNKNOWN_COLOR = '#9ca3af'
40// Below this share the unused part shows a grey track, as the Codex widget does.
41const LOW_USAGE_VISIBILITY_PERCENT = 25
42const TRACK_COLOR = 'rgba(128,128,128,0.38)'
43
44// rings.mjs contextColor, for a window of 280k tokens or more.
45export function contextColor(tokens: number | null): string {
46 if (tokens === null) {
47 return UNKNOWN_COLOR
48 }
49 return tokens > 400_000 ? CODEX_COLORS.red : tokens >= 300_000 ? CODEX_COLORS.yellow : CODEX_COLORS.blue
50}
51
52// rings.mjs quotaColor.
53export function quotaColor(percent: number | null): string {
54 if (percent === null) {
55 return UNKNOWN_COLOR
56 }
57 return percent > 85 ? CODEX_COLORS.red : percent >= 70 ? CODEX_COLORS.yellow : CODEX_COLORS.green
58}
59
60// Whole percent, rounded down as the Codex widget shows it (99.9 → 99).
61export function wholePercent(percent: number | null): string {
62 return percent === null ? '?' : `${Math.max(0, Math.floor(percent))}%`
63}
64
65export function formatTokens(tokens: number): string {
66 if (tokens >= 1_000_000) {
67 return `${(tokens / 1_000_000).toFixed(tokens % 1_000_000 === 0 ? 0 : 1)}M`
68 }
69 if (tokens >= 1_000) {
70 return `${Math.round(tokens / 1_000)}k`
71 }
72 return String(tokens)
73}
74
75const MINUTE_MS = 60_000
76const HOUR_MS = 60 * MINUTE_MS
77const DAY_MS = 24 * HOUR_MS
78
79const DURATION_UNITS = ['d', 'h', 'min'] as const
80type DurationUnit = (typeof DURATION_UNITS)[number]
81type DurationPart = { unit: DurationUnit; value: number }
82
83// The two largest units of a span of time: days and hours, hours and minutes,
84// or minutes alone.
85export function durationParts(durationMs: number): DurationPart[] {
86 const minutes = Math.max(0, Math.ceil(durationMs / MINUTE_MS))
87 const days = Math.floor(minutes / 1_440)
88 const hours = Math.floor((minutes % 1_440) / 60)
89 const rest = minutes % 60
90 if (days > 0) {
91 return [{ unit: 'd', value: days }, { unit: 'h', value: hours }]
92 }
93 if (hours > 0) {
94 return [{ unit: 'h', value: hours }, { unit: 'min', value: rest }]
95 }
96 return [{ unit: 'min', value: rest }]
97}
98
99// "5 d 3 h", "3 h 30 min", "35 min", single spaces.
100export function formatDuration(durationMs: number): string {
101 return durationParts(durationMs)
102 .map(part => `${part.value} ${part.unit}`)
103 .join(' ')
104}
105
106// Spans left-aligned in a monospaced column, number by number in reading order:
107// each first number padded to the widest first number, each second to the
108// widest second, so "5 d 3 h" stands under "3 h 30 min" and "5 d 14 h" under
109// "4 h 10 min". Units may differ from row to row. Null stays null.
110export function alignDurationsByPosition(durations: readonly (number | null)[]): (string | null)[] {
111 const partLists = durations.map(duration => (duration === null ? null : durationParts(duration)))
112 const widthAt = (position: number) =>
113 Math.max(0, ...partLists.map(parts => (parts?.[position] === undefined ? 0 : String(parts[position].value).length)))
114 const widths = [widthAt(0), widthAt(1)]
115 return partLists.map(parts =>
116 parts === null
117 ? null
118 : parts.map((part, position) => `${String(part.value).padStart(widths[position] ?? 0, ' ')} ${part.unit}`).join(' '),
119 )
120}
121
122// Spans meant to stand one under another in a monospaced column. Every unit
123// the column uses gets a slot as wide as its widest number, so "h" stands under
124// "h" and "min" under "min"; a span without that unit leaves the slot blank:
125// " 5 min", "10 h 45 min", " 4 h 39 min". Null stays null.
126export function alignDurations(durations: readonly (number | null)[]): (string | null)[] {
127 const partLists = durations.map(duration => (duration === null ? null : durationParts(duration)))
128 const widths = new Map<DurationUnit, number>()
129 partLists.flatMap(parts => parts ?? []).forEach(part => {
130 widths.set(part.unit, Math.max(widths.get(part.unit) ?? 0, String(part.value).length))
131 })
132 const slots = DURATION_UNITS.filter(unit => widths.has(unit))
133 return partLists.map(parts => {
134 if (parts === null) {
135 return null
136 }
137 return slots
138 .map(unit => {
139 const width = widths.get(unit) ?? 0
140 const part = parts.find(candidate => candidate.unit === unit)
141 return part === undefined ? ' '.repeat(width + 1 + unit.length) : `${String(part.value).padStart(width, ' ')} ${unit}`
142 })
143 .join(' ')
144 .trimEnd()
145 })
146}
147
148// Time left until the window resets, in milliseconds; null when unknown or past.
149export function resetsInMs(resetsAt: string | undefined, nowMs: number): number | null {
150 if (!resetsAt) {
151 return null
152 }
153 const remaining = Date.parse(resetsAt) - nowMs
154 return Number.isFinite(remaining) && remaining > 0 ? remaining : null
155}
156
157// How long each kind of window runs from one reset to the next.
158export function windowLengthMs(kind: string): number {
159 return kind === 'five_hour' ? 5 * HOUR_MS : 7 * DAY_MS
160}
161
162export type RunOut = { state: 'ranOut' } | { state: 'pastReset' } | { state: 'in'; durationMs: number }
163
164// When the window runs out at the average rate since it began: the percentage
165// used so far over the time elapsed in the window. Null without a reset time.
166export function projectRunOut(window: UsageWindow, nowMs: number): RunOut | null {
167 const remaining = resetsInMs(window.resetsAt, nowMs)
168 if (remaining === null) {
169 return null
170 }
171 if (window.percentUsed >= 100) {
172 return { state: 'ranOut' }
173 }
174 const elapsed = windowLengthMs(window.kind) - remaining
175 if (window.percentUsed <= 0 || elapsed <= 0) {
176 return { state: 'pastReset' }
177 }
178 const untilFull = ((100 - window.percentUsed) * elapsed) / window.percentUsed
179 return untilFull >= remaining ? { state: 'pastReset' } : { state: 'in', durationMs: untilFull }
180}
181
182// `duration` is the run-out span already written, plain or aligned.
183function runOutText(runOut: RunOut, duration: string): string {
184 if (runOut.state === 'ranOut') {
185 return 'Ran out.'
186 }
187 if (runOut.state === 'pastReset') {
188 return 'Lasts past reset.'
189 }
190 return `Runs out in ${duration}.`
191}
192
193// The model's context window, "1M"; "?" without a reading.
194function formatWindow(window: number | undefined): string {
195 return window !== undefined && Number.isFinite(window) && window > 0 ? formatTokens(window) : '?'
196}
197
198// Every label padded to this width, so the colons line up in a monospaced block.
199const DETAIL_LABEL_WIDTH = 9
200
201function detailLabel(label: string): string {
202 return label.padEnd(DETAIL_LABEL_WIDTH, ' ')
203}
204
205// The model-scoped weekly window to show: the one for the session's model family
206// when listed, else the first one listed (the Fable quota on this account).
207export function pickModelWeeklyWindow(windows: readonly UsageWindow[], modelId = ''): UsageWindow | undefined {
208 const scoped = windows.filter(window => window.kind.startsWith(MODEL_WEEKLY_KIND_PREFIX))
209 const family = modelId.toLowerCase()
210 return scoped.find(window => family.includes(window.kind.slice(MODEL_WEEKLY_KIND_PREFIX.length).toLowerCase())) ?? scoped[0]
211}
212
213// One window's figures before they are written out.
214type WindowFigures = { label: string; percent: string; resetMs: number | null; runOut: RunOut | null }
215
216function windowFigures(label: string, window: UsageWindow | undefined, nowMs: number): WindowFigures {
217 if (window === undefined) {
218 return { label, percent: '?', resetMs: null, runOut: null }
219 }
220 return {
221 label,
222 percent: wholePercent(window.percentUsed),
223 resetMs: resetsInMs(window.resetsAt, nowMs),
224 runOut: projectRunOut(window, nowMs),
225 }
226}
227
228function runOutMs(runOut: RunOut | null): number | null {
229 return runOut?.state === 'in' ? runOut.durationMs : null
230}
231
232// "5 h limit: 12% (Reset in 3 h 30 min. Runs out in 2 h 10 min.)", single spaces, for a tooltip.
233function plainWindowLine(figures: WindowFigures): string {
234 const reset = figures.resetMs === null ? null : `Reset in ${formatDuration(figures.resetMs)}.`
235 const runOut = figures.runOut === null ? null : runOutText(figures.runOut, formatDuration(runOutMs(figures.runOut) ?? 0))
236 const tail = [reset, runOut].filter((piece): piece is string => piece !== null).join(' ')
237 return `${figures.label}: ${figures.percent}${tail ? ` (${tail})` : ''}`
238}
239
240// The window lines of the detail block for Consolas: the percentages
241// right-aligned, the reset spans left-aligned number by number, "Runs out in"
242// starting in one column and the run-out spans aligned unit by unit.
243function alignedWindowLines(rows: readonly WindowFigures[]): string[] {
244 const resets = alignDurationsByPosition(rows.map(row => row.resetMs)).map(span => (span === null ? null : `Reset in ${span}.`))
245 const runOutSpans = alignDurations(rows.map(row => runOutMs(row.runOut)))
246 const runOuts = rows.map((row, index) => (row.runOut === null ? null : runOutText(row.runOut, runOutSpans[index] ?? '')))
247 const percentWidth = Math.max(0, ...rows.map(row => row.percent.length))
248 const resetWidth = Math.max(0, ...resets.map(reset => reset?.length ?? 0))
249 return rows.map((row, index) => {
250 const head = `${detailLabel(row.label)}: ${row.percent.padStart(percentWidth, ' ')}`
251 const reset = resets[index]
252 const runOut = runOuts[index]
253 if (reset === null || runOut === null) {
254 return reset === null ? head : `${head} (${reset})`
255 }
256 return `${head} (${reset.padEnd(resetWidth, ' ')} ${runOut})`
257 })
258}
259
260export function usageWidgets(usage: UsageSnapshot | null, nowMs: number, modelId = ''): UsageWidgets {
261 const context = usage?.context
262 const tokens = context?.tokens ?? null
263 const percentOfScale = tokens === null ? null : (tokens / CONTEXT_BAR_SCALE_TOKENS) * 100
264 const windows = usage?.rateLimits ?? []
265 const modelWeekly = pickModelWeeklyWindow(windows, modelId)
266 const name = modelWeekly === undefined ? 'Model' : modelWeekly.kind.slice(MODEL_WEEKLY_KIND_PREFIX.length)
267 const fiveHour = windows.find(window => window.kind === 'five_hour')
268 const weekly = windows.find(window => window.kind === 'seven_day')
269 const otherModels = windows.filter(window => window.kind.startsWith(MODEL_WEEKLY_KIND_PREFIX) && window !== modelWeekly)
270
271 const contextLabel = tokens === null ? '?' : formatTokens(tokens)
272 // "110k / 1M": the count over the model's own context window.
273 const contextFigure = `${contextLabel} / ${formatWindow(context?.window)}`
274 // The windows in detail-block order: 5-hour, weekly, this model's, then other models'.
275 const shown: [string, UsageWindow | undefined][] = [
276 ['5 h limit', fiveHour],
277 ['7 d limit', weekly],
278 [name, modelWeekly],
279 ...otherModels.map((window): [string, UsageWindow] => [window.kind.slice(MODEL_WEEKLY_KIND_PREFIX.length), window]),
280 ]
281 const alignedLines = alignedWindowLines(shown.map(([label, window]) => windowFigures(label, window, nowMs)))
282 const plainLine = (label: string, window: UsageWindow | undefined) => plainWindowLine(windowFigures(label, window, nowMs))
283 const lines = {
284 context: `${detailLabel('Context')}: ${contextFigure}`,
285 fiveHour: alignedLines[0],
286 weekly: alignedLines[1],
287 model: alignedLines[2],
288 }
289
290 return {
291 modelWeekly: { window: modelWeekly, name },
292 context: { tokens, percentOfScale, color: contextColor(tokens), label: contextLabel, figure: contextFigure },
293 fiveHour,
294 weekly,
295 lines,
296 tooltips: {
297 context: `Context: ${contextFigure}`,
298 fiveHour: plainLine('5 h limit', fiveHour),
299 weekly: plainLine('7 d limit', weekly),
300 model: plainLine(name, modelWeekly),
301 },
302 details: [lines.context, ...alignedLines],
303 }
304}
305
306function escapeXml(text: string): string {
307 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
308}
309
310function round(value: number): string {
311 return String(Math.round(value * 1000) / 1000)
312}
313
314function fractionOf(percent: number | null): number {
315 return percent === null ? 0 : Math.min(100, Math.max(0, percent)) / 100
316}
317
318function svgOpen(width: number, height: number, viewWidth: number, viewHeight: number, title: string): string {
319 return (
320 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${viewWidth} ${viewHeight}" role="img">` +
321 `<title>${escapeXml(title)}</title>`
322 )
323}
324
325// Widget.cs outer ring, in a 32-unit box: radius 11, width 2, round caps,
326// clockwise from the top; a grey track while under 25 % used.
327function outerArc(percent: number | null, color: string): string {
328 const fraction = fractionOf(percent)
329 const track =
330 percent !== null && percent < LOW_USAGE_VISIBILITY_PERCENT
331 ? `<circle cx="16" cy="16" r="11" fill="none" stroke="${TRACK_COLOR}" stroke-width="2"/>`
332 : ''
333 if (fraction >= 1) {
334 return track + `<circle cx="16" cy="16" r="11" fill="none" stroke="${color}" stroke-width="2"/>`
335 }
336 if (fraction <= 0) {
337 return track
338 }
339 const angle = fraction * 2 * Math.PI
340 const endX = 16 + 11 * Math.sin(angle)
341 const endY = 16 - 11 * Math.cos(angle)
342 return (
343 track +
344 `<path d="M 16 5 A 11 11 0 ${fraction > 0.5 ? 1 : 0} 1 ${round(endX)} ${round(endY)}"` +
345 ` fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`
346 )
347}
348
349// Digits stand about 0.7 em tall on the baseline, so a baseline this far below
350// the centre centres them; dominant-baseline="central" centres the font's whole
351// ascent-to-descent box instead, which sits the digits low in Segoe UI.
352const DIGIT_HALF_HEIGHT_EM = 0.35
353
354// A quota ring (the Fable, 5-hour and weekly windows alike): the Codex outer
355// ring with the whole percentage inside.
356export function quotaRingSvg(percent: number | null, title: string, sizePx = RING_SIZE_PX): string {
357 const color = quotaColor(percent)
358 const text = percent === null ? '?' : String(Math.max(0, Math.floor(percent)))
359 const fontSize = text.length > 2 ? 10 : 12
360 return (
361 svgOpen(sizePx, sizePx, 32, 32, title) +
362 outerArc(percent, color) +
363 `<text x="16" y="${round(16 + DIGIT_HALF_HEIGHT_EM * fontSize)}" text-anchor="middle" font-family="system-ui, sans-serif"` +
364 ` font-size="${fontSize}" font-weight="600" fill="${color}">${text}</text>` +
365 '</svg>'
366 )
367}
368
369// A horizontal bar, full at CONTEXT_BAR_SCALE_TOKENS, in the context color.
370export function contextBarSvg(percentOfScale: number | null, color: string, title: string): string {
371 const width = BAR_WIDTH_PX
372 const height = BAR_HEIGHT_PX
373 const barHeight = 6
374 const top = (height - barHeight) / 2
375 const fraction = fractionOf(percentOfScale)
376 return (
377 svgOpen(width, height, width, height, title) +
378 `<rect x="0" y="${top}" width="${width}" height="${barHeight}" rx="3" fill="${TRACK_COLOR}"/>` +
379 (fraction > 0 ? `<rect x="0" y="${top}" width="${round(width * fraction)}" height="${barHeight}" rx="3" fill="${color}"/>` : '') +
380 '</svg>'
381 )
382}
383
384function percentField(value: unknown): number | undefined {
385 return typeof value === 'number' && Number.isFinite(value) ? value : undefined
386}
387
388function resetField(value: unknown): string | undefined {
389 if (typeof value === 'string' && value !== '') {
390 return value
391 }
392 return typeof value === 'number' && Number.isFinite(value) ? new Date(value * 1000).toISOString() : undefined
393}
394
395// The windows in an answer of GET https://api.anthropic.com/api/oauth/usage:
396// five_hour and seven_day ({ utilization: percent, resets_at }), and each
397// limits[] entry of kind weekly_scoped with scope.model.display_name (the Fable
398// quota) as `model_weekly:<display name>` ({ percent, resets_at }).
399export function parseUsageEndpoint(body: unknown): UsageWindow[] {
400 if (typeof body !== 'object' || body === null) {
401 return []
402 }
403 const record = body as Record<string, unknown>
404 const windows: UsageWindow[] = []
405 for (const kind of ['five_hour', 'seven_day']) {
406 const entry = record[kind]
407 if (typeof entry === 'object' && entry !== null) {
408 const percent = percentField((entry as Record<string, unknown>).utilization)
409 if (percent !== undefined) {
410 windows.push({ kind, percentUsed: percent, resetsAt: resetField((entry as Record<string, unknown>).resets_at) })
411 }
412 }
413 }
414 const limits = Array.isArray(record.limits) ? record.limits : []
415 for (const limit of limits) {
416 if (typeof limit !== 'object' || limit === null) {
417 continue
418 }
419 const entry = limit as Record<string, unknown>
420 const scope = entry.scope as { model?: { display_name?: unknown } } | null | undefined
421 const name = scope?.model?.display_name
422 const percent = percentField(entry.percent)
423 if (entry.kind === 'weekly_scoped' && typeof name === 'string' && name !== '' && percent !== undefined) {
424 windows.push({ kind: `${MODEL_WEEKLY_KIND_PREFIX}${name}`, percentUsed: percent, resetsAt: resetField(entry.resets_at) })
425 }
426 }
427 return windows
428}
429
430// Where both name the same window the fresher reading wins: the engine's own
431// (response headers) only while its responses are newer than the endpoint's
432// answer, since an idle or finished session keeps its last turn's figures for
433// good. The other source adds the windows the first lacks.
434export function mergeWindows(
435 fromSession: readonly UsageWindow[],
436 fromEndpoint: readonly UsageWindow[],
437 sessionReadAtMs: number,
438 endpointFetchedAtMs: number,
439): UsageWindow[] {
440 const [first, second] = endpointFetchedAtMs > sessionReadAtMs ? [fromEndpoint, fromSession] : [fromSession, fromEndpoint]
441 const kinds = new Set(first.map(window => window.kind))
442 return [...first, ...second.filter(window => !kinds.has(window.kind))]
443}
444
445const DETAIL_FONT_SIZE_PX = 12
446const DETAIL_LINE_HEIGHT_PX = 16
447// Consolas advances 0.55 em per character.
448const DETAIL_CHARACTER_WIDTH_PX = DETAIL_FONT_SIZE_PX * 0.55
449const DETAIL_TEXT_COLOR = '#8a8a8a'
450
451export function detailBlockSize(lines: readonly string[]): { width: number; height: number } {
452 const longest = lines.reduce((maximum, line) => Math.max(maximum, line.length), 0)
453 return {
454 width: Math.ceil(longest * DETAIL_CHARACTER_WIDTH_PX) + 4,
455 height: lines.length * DETAIL_LINE_HEIGHT_PX + 4,
456 }
457}
458
459// The detail block in Consolas: one line per figure, spaces kept so the
460// colons line up (Text takes no font, so the block is drawn as SVG text).
461export function detailBlockSvg(lines: readonly string[]): string {
462 const { width, height } = detailBlockSize(lines)
463 const rows = lines
464 .map(
465 (line, index) =>
466 `<text x="2" y="${2 + index * DETAIL_LINE_HEIGHT_PX + DETAIL_LINE_HEIGHT_PX / 2}" dominant-baseline="central" xml:space="preserve">${escapeXml(line)}</text>`,
467 )
468 .join('')
469 return (
470 svgOpen(width, height, width, height, lines.join('\n')) +
471 `<g font-family="Consolas, 'Cascadia Mono', monospace" font-size="${DETAIL_FONT_SIZE_PX}" fill="${DETAIL_TEXT_COLOR}" style="white-space:pre">` +
472 rows +
473 '</g></svg>'
474 )
475}
476
477// What a surface without Svg (the terminal) shows instead.
478export function usageText(widgets: UsageWidgets): string {
479 const percentOf = (window: UsageWindow | undefined) => wholePercent(window?.percentUsed ?? null)
480 return [
481 `${widgets.modelWeekly.name} ${percentOf(widgets.modelWeekly.window)}`,
482 `ctx ${widgets.context.figure.replace(' / ', '/')}`,
483 `5h ${percentOf(widgets.fiveHour)}`,
484 `1w ${percentOf(widgets.weekly)}`,
485 ].join(' · ')
486}
487hooks/subagent_dots.ts 244 lines1import type { SubagentRecord, SubagentState } from '../types'
2
3// The subagent dots: one dot per subagent of the session, in launch order.
4// Running dots pulse (SMIL, drawn by an interactive Svg); ended ones stand
5// still in their outcome's color. No engine calls here.
6
7// The engine's AgentStatus, spelled out so this module needs no engine types.
8export type ListedAgentStatus = 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
9export type ListedAgent = { id: string; description: string; type: string; status: ListedAgentStatus }
10export type TurnEndReason = 'answer' | 'aborted' | 'refusal' | 'error'
11
12export const SUBAGENT_HISTORY_LIMIT = 50
13export const SUBAGENT_DOT_LIMIT = 12
14export const DOTS_HEIGHT_PX = 24
15const DOT_RADIUS = 4
16const DOT_PITCH = 12
17const OVERFLOW_TEXT_WIDTH = 22
18
19export const SUBAGENT_COLORS = {
20 running: '#d97757',
21 completed: '#5abd7b',
22 failed: '#ef6666',
23 stopped: '#9ca3af',
24} as const
25
26const STATE_LABELS: Record<SubagentState, string> = {
27 running: 'Running',
28 waiting: 'Waiting',
29 idle: 'Idle',
30 completed: 'Completed',
31 failed: 'Failed',
32 stopped: 'Stopped',
33}
34
35export function isActiveState(state: SubagentState): boolean {
36 return state === 'running' || state === 'waiting' || state === 'idle'
37}
38
39export function stateOfStatus(status: ListedAgentStatus): SubagentState {
40 switch (status) {
41 case 'pending':
42 case 'running':
43 return 'running'
44 case 'waiting':
45 return 'waiting'
46 case 'idle':
47 return 'idle'
48 case 'completed':
49 return 'completed'
50 case 'failed':
51 return 'failed'
52 case 'killed':
53 return 'stopped'
54 }
55}
56
57export function stateOfTurnEnd(reason: TurnEndReason): SubagentState {
58 if (reason === 'answer') {
59 return 'completed'
60 }
61 return reason === 'aborted' ? 'stopped' : 'failed'
62}
63
64function withState(record: SubagentRecord, state: SubagentState, nowMs: number): SubagentRecord {
65 if (record.state === state) {
66 return record
67 }
68 return { ...record, state, endedAtMs: isActiveState(state) ? undefined : (record.endedAtMs ?? nowMs) }
69}
70
71function keepNewest(records: SubagentRecord[]): SubagentRecord[] {
72 return records.length > SUBAGENT_HISTORY_LIMIT ? records.slice(records.length - SUBAGENT_HISTORY_LIMIT) : records
73}
74
75// A subagent the Agent tool just started, before the engine lists it.
76export function recordSpawn(
77 records: readonly SubagentRecord[],
78 agent: { id: string; description: string; type: string },
79 nowMs: number,
80): SubagentRecord[] {
81 if (records.some(record => record.id === agent.id)) {
82 return [...records]
83 }
84 return keepNewest([
85 ...records,
86 { id: agent.id, description: agent.description, type: agent.type, state: 'running', startedAtMs: nowMs, hasBeenListed: false },
87 ])
88}
89
90// A subagent's run ended (its turn.complete); a later listing may resume it.
91export function recordTurnEnd(
92 records: readonly SubagentRecord[],
93 id: string,
94 reason: TurnEndReason,
95 nowMs: number,
96): SubagentRecord[] {
97 return records.map(record => (record.id === id ? withState(record, stateOfTurnEnd(reason), nowMs) : record))
98}
99
100// The engine's list wins for every agent it lists. It drops an agent's task a
101// while after the agent ends, so one missing from the list keeps its last
102// state; an active one that was listed before and is gone ended unseen, and
103// is shown as stopped.
104export function mergeListedAgents(
105 records: readonly SubagentRecord[],
106 listed: readonly ListedAgent[],
107 nowMs: number,
108): SubagentRecord[] {
109 const listedById = new Map(listed.map(agent => [agent.id, agent]))
110 const merged = records.map(record => {
111 const agent = listedById.get(record.id)
112 if (agent === undefined) {
113 return record.hasBeenListed && isActiveState(record.state) ? withState(record, 'stopped', nowMs) : record
114 }
115 const updated = withState(record, stateOfStatus(agent.status), nowMs)
116 return updated.hasBeenListed ? updated : { ...updated, hasBeenListed: true }
117 })
118 const known = new Set(records.map(record => record.id))
119 for (const agent of listed) {
120 if (!known.has(agent.id)) {
121 const state = stateOfStatus(agent.status)
122 merged.push({
123 id: agent.id,
124 description: agent.description,
125 type: agent.type,
126 state,
127 startedAtMs: nowMs,
128 endedAtMs: isActiveState(state) ? undefined : nowMs,
129 hasBeenListed: true,
130 })
131 }
132 }
133 return keepNewest(merged)
134}
135
136export function countStates(records: readonly SubagentRecord[]): Record<SubagentState, number> {
137 const counts: Record<SubagentState, number> = { running: 0, waiting: 0, idle: 0, completed: 0, failed: 0, stopped: 0 }
138 for (const record of records) {
139 counts[record.state] += 1
140 }
141 return counts
142}
143
144// "Subagents: 2 running, 3 completed, 1 failed", the states in a fixed order.
145export function subagentSummary(records: readonly SubagentRecord[]): string {
146 const counts = countStates(records)
147 const parts = (Object.keys(STATE_LABELS) as SubagentState[])
148 .filter(state => counts[state] > 0)
149 .map(state => `${counts[state]} ${STATE_LABELS[state].toLowerCase()}`)
150 return `Subagents: ${parts.join(', ')}`
151}
152
153export function formatElapsed(durationMs: number): string {
154 const seconds = Math.max(0, Math.round(durationMs / 1000))
155 if (seconds < 60) {
156 return `${seconds} s`
157 }
158 const minutes = Math.floor(seconds / 60)
159 if (minutes < 60) {
160 return `${minutes} min ${seconds % 60} s`
161 }
162 return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
163}
164
165// One line per subagent, newest first: "Running Explore: Find the config (2 min 5 s)".
166export function subagentLines(records: readonly SubagentRecord[], nowMs: number): string[] {
167 return [...records].reverse().map(record => {
168 const elapsed = formatElapsed((record.endedAtMs ?? nowMs) - record.startedAtMs)
169 return `${STATE_LABELS[record.state]} ${record.type}: ${record.description} (${elapsed})`
170 })
171}
172
173export function escapeXml(text: string): string {
174 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
175}
176
177export function subagentDotsWidth(records: readonly SubagentRecord[]): number {
178 const shown = Math.min(records.length, SUBAGENT_DOT_LIMIT)
179 return (records.length > SUBAGENT_DOT_LIMIT ? OVERFLOW_TEXT_WIDTH : 0) + shown * DOT_PITCH
180}
181
182// Running dots breathe one after another; waiting and idle ones are hollow,
183// a waiting one breathing slowly; ended ones are still.
184function dotMarkup(record: SubagentRecord, centerX: number, index: number): string {
185 const centerY = DOTS_HEIGHT_PX / 2
186 const delay = `${(index * 0.15).toFixed(2)}s`
187 switch (record.state) {
188 case 'running':
189 return (
190 `<circle cx="${centerX}" cy="${centerY}" r="${DOT_RADIUS}" fill="${SUBAGENT_COLORS.running}">` +
191 `<animate attributeName="r" values="${DOT_RADIUS};${DOT_RADIUS * 0.6};${DOT_RADIUS}" dur="1.2s" begin="${delay}" repeatCount="indefinite"/>` +
192 `<animate attributeName="opacity" values="1;0.3;1" dur="1.2s" begin="${delay}" repeatCount="indefinite"/>` +
193 '</circle>'
194 )
195 case 'waiting':
196 return (
197 `<circle cx="${centerX}" cy="${centerY}" r="${DOT_RADIUS - 0.6}" fill="none" stroke="${SUBAGENT_COLORS.running}" stroke-width="1.2">` +
198 `<animate attributeName="opacity" values="1;0.35;1" dur="2.4s" begin="${delay}" repeatCount="indefinite"/>` +
199 '</circle>'
200 )
201 case 'idle':
202 return `<circle cx="${centerX}" cy="${centerY}" r="${DOT_RADIUS - 0.6}" fill="none" stroke="${SUBAGENT_COLORS.running}" stroke-width="1.2"/>`
203 case 'stopped':
204 return `<circle cx="${centerX}" cy="${centerY}" r="${DOT_RADIUS - 0.6}" fill="none" stroke="${SUBAGENT_COLORS.stopped}" stroke-width="1.2"/>`
205 case 'completed':
206 case 'failed':
207 return `<circle cx="${centerX}" cy="${centerY}" r="${DOT_RADIUS}" fill="${SUBAGENT_COLORS[record.state]}"/>`
208 }
209}
210
211// The newest SUBAGENT_DOT_LIMIT dots, oldest left; "+n" before them counts
212// the older ones not drawn. The title is the hover tooltip.
213export function subagentDotsSvg(records: readonly SubagentRecord[], title: string): string {
214 const width = subagentDotsWidth(records)
215 const hidden = Math.max(0, records.length - SUBAGENT_DOT_LIMIT)
216 const shown = records.slice(hidden)
217 const offset = hidden > 0 ? OVERFLOW_TEXT_WIDTH : 0
218 const overflow =
219 hidden > 0
220 ? `<text x="${OVERFLOW_TEXT_WIDTH - 3}" y="${DOTS_HEIGHT_PX / 2 + 4}" text-anchor="end" font-family="system-ui, sans-serif"` +
221 ` font-size="11" fill="${SUBAGENT_COLORS.stopped}">+${hidden}</text>`
222 : ''
223 return (
224 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${DOTS_HEIGHT_PX}" viewBox="0 0 ${width} ${DOTS_HEIGHT_PX}" role="img">` +
225 `<title>${escapeXml(title)}</title>` +
226 overflow +
227 shown.map((record, index) => dotMarkup(record, offset + index * DOT_PITCH + DOT_PITCH / 2, index)).join('') +
228 '</svg>'
229 )
230}
231
232// What a surface without Svg (the terminal) shows instead: "Agents 2 running · 3 done · 1 failed".
233export function subagentText(records: readonly SubagentRecord[]): string {
234 const counts = countStates(records)
235 const active = counts.running + counts.waiting + counts.idle
236 const parts = [
237 active > 0 ? `${active} running` : '',
238 counts.completed > 0 ? `${counts.completed} done` : '',
239 counts.failed > 0 ? `${counts.failed} failed` : '',
240 counts.stopped > 0 ? `${counts.stopped} stopped` : '',
241 ].filter(part => part !== '')
242 return `Agents ${parts.join(' · ')}`
243}
244hooks/monitor_clock.ts 319 lines1import type { MonitorRecord } from '../types'
2import { DOTS_HEIGHT_PX, SUBAGENT_COLORS, escapeXml, formatElapsed } from './subagent_dots'
3
4// The waiting clocks: one clock per Monitor of the main conversation that is
5// still watching, so a conversation idle on a Monitor does not look finished.
6// A clock blinks at 1/2 of a running subagent dot's rate and shows the time
7// left before its Monitor expires. No engine calls here.
8
9export const MONITOR_HISTORY_LIMIT = 20
10export const MONITOR_CLOCK_LIMIT = 6
11// The Monitor's own expiry notification arrives at its deadline; past this
12// grace the clock stops even if the notification was missed.
13export const MONITOR_DEADLINE_GRACE_MS = 60_000
14const CLOCK_RADIUS = 6.2
15const CLOCK_SIZE = 18
16const COUNTDOWN_FONT_PX = 11
17// Roughly one digit's advance at COUNTDOWN_FONT_PX, and the gap after a countdown.
18const COUNTDOWN_CHARACTER_WIDTH = 6.6
19const COUNTDOWN_GAP = 6
20// The running subagent dot pulses over 1.2 s; the clock at 1/2 of that rate.
21const CLOCK_PERIOD_S = 2.4
22// Below this the countdown counts seconds and needs a redraw every poll.
23export const COUNTDOWN_SECONDS_BELOW_MS = 60_000
24
25export type MonitorEnding = { id: string; status: string }
26
27export function isActiveMonitor(record: MonitorRecord): boolean {
28 return record.endedAtMs === undefined
29}
30
31export function activeMonitors(records: readonly MonitorRecord[]): MonitorRecord[] {
32 return records.filter(isActiveMonitor)
33}
34
35function keepNewest(records: MonitorRecord[]): MonitorRecord[] {
36 return records.length > MONITOR_HISTORY_LIMIT ? records.slice(records.length - MONITOR_HISTORY_LIMIT) : records
37}
38
39// What the Monitor tool answered: `{ taskId, timeoutMs, persistent }`.
40export function readMonitorStart(
41 result: unknown,
42 description: string,
43 nowMs: number,
44): MonitorRecord | null {
45 if (typeof result !== 'object' || result === null) {
46 return null
47 }
48 const output = result as { taskId?: unknown; timeoutMs?: unknown; persistent?: unknown }
49 if (typeof output.taskId !== 'string' || output.taskId === '') {
50 return null
51 }
52 const isPersistent = output.persistent === true || output.timeoutMs === 0
53 const timeoutMs = typeof output.timeoutMs === 'number' ? output.timeoutMs : undefined
54 return {
55 id: output.taskId,
56 description,
57 startedAtMs: nowMs,
58 deadlineMs: isPersistent || timeoutMs === undefined ? undefined : nowMs + timeoutMs,
59 }
60}
61
62export function recordMonitorStart(records: readonly MonitorRecord[], monitor: MonitorRecord): MonitorRecord[] {
63 return keepNewest([...records.filter(record => record.id !== monitor.id), monitor])
64}
65
66export function recordMonitorEnd(
67 records: readonly MonitorRecord[],
68 id: string,
69 outcome: string,
70 nowMs: number,
71): MonitorRecord[] {
72 return records.map(record =>
73 record.id === id && isActiveMonitor(record) ? { ...record, endedAtMs: nowMs, outcome } : record,
74 )
75}
76
77// The ending notifications in a task-notification text: a block that carries
78// a `<status>`; a Monitor's event blocks carry none.
79export function parseNotificationEndings(text: string): MonitorEnding[] {
80 if (!text.includes('<task-notification>')) {
81 return []
82 }
83 const endings: MonitorEnding[] = []
84 for (const block of text.split('<task-notification>').slice(1)) {
85 const id = /<task-id>([^<]+)<\/task-id>/.exec(block)?.[1]?.trim()
86 const status = /<status>([^<]+)<\/status>/.exec(block)?.[1]?.trim()
87 if (id !== undefined && status !== undefined && id !== '' && status !== '') {
88 endings.push({ id, status })
89 }
90 }
91 return endings
92}
93
94export function applyEndings(
95 records: readonly MonitorRecord[],
96 endings: readonly MonitorEnding[],
97 nowMs: number,
98): MonitorRecord[] {
99 return endings.reduce<MonitorRecord[]>((current, ending) => recordMonitorEnd(current, ending.id, ending.status, nowMs), [
100 ...records,
101 ])
102}
103
104// Ends the clocks past their deadline and its grace: the expiry notification
105// was missed, or a restart of the engine killed the Monitor without one.
106export function expireMonitors(records: readonly MonitorRecord[], nowMs: number): MonitorRecord[] {
107 return records.map(record => {
108 if (!isActiveMonitor(record)) {
109 return record
110 }
111 if (record.deadlineMs !== undefined && nowMs > record.deadlineMs + MONITOR_DEADLINE_GRACE_MS) {
112 return { ...record, endedAtMs: record.deadlineMs, outcome: 'expired' }
113 }
114 return record
115 })
116}
117
118type TranscriptBlock = { type?: unknown; id?: unknown; name?: unknown; input?: unknown; tool_use_id?: unknown; text?: unknown }
119type TranscriptLine = {
120 type?: unknown
121 timestamp?: unknown
122 message?: { content?: unknown }
123 toolUseResult?: unknown
124 // A queue-operation line's queued text.
125 content?: unknown
126 // A queued_command attachment's prompt.
127 attachment?: { prompt?: unknown }
128}
129
130function lineBlocks(line: TranscriptLine): TranscriptBlock[] {
131 const content = line.message?.content
132 if (typeof content === 'string') {
133 return [{ type: 'text', text: content }]
134 }
135 return Array.isArray(content) ? content.filter((block): block is TranscriptBlock => typeof block === 'object' && block !== null) : []
136}
137
138function lineTimeMs(line: TranscriptLine, fallbackMs: number): number {
139 const parsed = typeof line.timestamp === 'string' ? Date.parse(line.timestamp) : Number.NaN
140 return Number.isFinite(parsed) ? parsed : fallbackMs
141}
142
143// The Monitors of a transcript (.jsonl): each Monitor call with its result's
144// task id, ended by a notification with a status or by a TaskStop. Only the
145// lines that can matter are parsed, so a long transcript stays cheap.
146export function parseTranscriptMonitors(transcript: string, nowMs: number): MonitorRecord[] {
147 const descriptions = new Map<string, string>()
148 let records: MonitorRecord[] = []
149 for (const raw of transcript.split('\n')) {
150 const isMonitorCall = raw.includes('"name":"Monitor"')
151 const isStartResult = raw.includes('"toolUseResult"') && raw.includes('"taskId"')
152 const isTaskStop = raw.includes('"name":"TaskStop"')
153 const isNotification = raw.includes('<task-notification>')
154 if (!isMonitorCall && !isStartResult && !isTaskStop && !isNotification) {
155 continue
156 }
157 let line: TranscriptLine
158 try {
159 line = JSON.parse(raw) as TranscriptLine
160 } catch {
161 continue
162 }
163 const atMs = lineTimeMs(line, nowMs)
164 const blocks = lineBlocks(line)
165 if (line.type === 'assistant') {
166 for (const block of blocks) {
167 if (block.type !== 'tool_use' || typeof block.id !== 'string') {
168 continue
169 }
170 const input = (typeof block.input === 'object' && block.input !== null ? block.input : {}) as {
171 description?: unknown
172 task_id?: unknown
173 shell_id?: unknown
174 }
175 if (block.name === 'Monitor') {
176 descriptions.set(block.id, typeof input.description === 'string' ? input.description : 'Monitor')
177 } else if (block.name === 'TaskStop') {
178 const taskId = typeof input.task_id === 'string' ? input.task_id : input.shell_id
179 if (typeof taskId === 'string') {
180 records = recordMonitorEnd(records, taskId, 'stopped', atMs)
181 }
182 }
183 }
184 continue
185 }
186 // A notification is stored as it is queued (queue-operation), as a
187 // queued_command attachment, or as a user message when it is dequeued.
188 if (line.type === 'queue-operation') {
189 if (typeof line.content === 'string') {
190 records = applyEndings(records, parseNotificationEndings(line.content), atMs)
191 }
192 continue
193 }
194 if (typeof line.attachment?.prompt === 'string') {
195 records = applyEndings(records, parseNotificationEndings(line.attachment.prompt), atMs)
196 continue
197 }
198 if (line.type !== 'user') {
199 continue
200 }
201 const toolResult = blocks.find(block => block.type === 'tool_result')
202 if (toolResult !== undefined) {
203 const description = typeof toolResult.tool_use_id === 'string' ? descriptions.get(toolResult.tool_use_id) : undefined
204 const started = description === undefined ? null : readMonitorStart(line.toolUseResult, description, atMs)
205 if (started !== null) {
206 records = recordMonitorStart(records, started)
207 }
208 continue
209 }
210 // A notification the session received; quoted ones inside tool results
211 // were skipped above.
212 const text = blocks.map(block => (typeof block.text === 'string' ? block.text : '')).join('\n')
213 records = applyEndings(records, parseNotificationEndings(text), atMs)
214 }
215 return records
216}
217
218// Adds the transcript's Monitors to the recorded ones; an ending either side
219// knows wins.
220export function mergeMonitorRecords(current: readonly MonitorRecord[], parsed: readonly MonitorRecord[]): MonitorRecord[] {
221 const byId = new Map(current.map(record => [record.id, record]))
222 for (const record of parsed) {
223 const known = byId.get(record.id)
224 if (known === undefined || (isActiveMonitor(known) && !isActiveMonitor(record))) {
225 byId.set(record.id, known === undefined ? record : { ...known, endedAtMs: record.endedAtMs, outcome: record.outcome })
226 }
227 }
228 return keepNewest([...byId.values()].sort((first, second) => first.startedAtMs - second.startedAtMs))
229}
230
231export function monitorSummary(active: readonly MonitorRecord[]): string {
232 return `Waiting on ${active.length} monitor${active.length === 1 ? '' : 's'}`
233}
234
235// One line per waiting Monitor, newest first: "Monitor: round-29 harvest (waiting 3 min 2 s, stops in 26 min 58 s)".
236export function monitorLines(active: readonly MonitorRecord[], nowMs: number): string[] {
237 return [...active].reverse().map(record => {
238 const waited = formatElapsed(nowMs - record.startedAtMs)
239 const remaining =
240 record.deadlineMs === undefined ? 'no deadline' : `stops in ${formatElapsed(Math.max(0, record.deadlineMs - nowMs))}`
241 return `Monitor: ${record.description} (waiting ${waited}, ${remaining})`
242 })
243}
244
245function shownClocks(active: readonly MonitorRecord[]): readonly MonitorRecord[] {
246 return active.slice(Math.max(0, active.length - MONITOR_CLOCK_LIMIT))
247}
248
249// The time left before a Monitor expires: "12m" (whole minutes, rounded up),
250// "45s" in its last minute, "∞" without a deadline.
251export function monitorCountdown(record: MonitorRecord, nowMs: number): string {
252 if (record.deadlineMs === undefined) {
253 return '∞'
254 }
255 const remainingMs = Math.max(0, record.deadlineMs - nowMs)
256 if (remainingMs < COUNTDOWN_SECONDS_BELOW_MS) {
257 return `${Math.ceil(remainingMs / 1000)}s`
258 }
259 return `${Math.ceil(remainingMs / 60_000)}m`
260}
261
262// Whether a shown countdown counts seconds, so the clocks redraw every poll.
263export function isCountingSeconds(active: readonly MonitorRecord[], nowMs: number): boolean {
264 return shownClocks(active).some(
265 record => record.deadlineMs !== undefined && record.deadlineMs - nowMs < COUNTDOWN_SECONDS_BELOW_MS,
266 )
267}
268
269function countdownWidth(countdown: string): number {
270 return Math.ceil(countdown.length * COUNTDOWN_CHARACTER_WIDTH) + COUNTDOWN_GAP
271}
272
273export function monitorClocksWidth(active: readonly MonitorRecord[], nowMs: number): number {
274 return shownClocks(active).reduce((width, record) => width + CLOCK_SIZE + countdownWidth(monitorCountdown(record, nowMs)), 0)
275}
276
277function countdownMarkup(left: number, countdown: string): string {
278 return (
279 `<text x="${left}" y="${DOTS_HEIGHT_PX / 2}" dominant-baseline="central" font-size="${COUNTDOWN_FONT_PX}" ` +
280 `font-family="system-ui, sans-serif" fill="${SUBAGENT_COLORS.running}">${escapeXml(countdown)}</text>`
281 )
282}
283
284function clockMarkup(centerX: number): string {
285 const centerY = DOTS_HEIGHT_PX / 2
286 const color = SUBAGENT_COLORS.running
287 return (
288 `<g fill="none" stroke="${color}" stroke-width="1.4" stroke-linecap="round">` +
289 `<circle cx="${centerX}" cy="${centerY}" r="${CLOCK_RADIUS}"/>` +
290 `<path d="M${centerX} ${centerY - 3.6}V${centerY}L${centerX + 2.6} ${centerY + 1.6}"/>` +
291 `<animate attributeName="opacity" values="1;0.3;1" dur="${CLOCK_PERIOD_S}s" repeatCount="indefinite"/>` +
292 '</g>'
293 )
294}
295
296// The newest MONITOR_CLOCK_LIMIT clocks, oldest left, each followed by its
297// countdown. The title is the hover tooltip.
298export function monitorClocksSvg(active: readonly MonitorRecord[], title: string, nowMs: number): string {
299 const width = monitorClocksWidth(active, nowMs)
300 let left = 0
301 const parts: string[] = []
302 for (const record of shownClocks(active)) {
303 const countdown = monitorCountdown(record, nowMs)
304 parts.push(clockMarkup(left + CLOCK_SIZE / 2), countdownMarkup(left + CLOCK_SIZE, countdown))
305 left += CLOCK_SIZE + countdownWidth(countdown)
306 }
307 return (
308 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${DOTS_HEIGHT_PX}" viewBox="0 0 ${width} ${DOTS_HEIGHT_PX}" role="img">` +
309 `<title>${escapeXml(title)}</title>` +
310 parts.join('') +
311 '</svg>'
312 )
313}
314
315// What a surface without Svg (the terminal) shows instead: "Monitors 1 waiting".
316export function monitorText(active: readonly MonitorRecord[]): string {
317 return `Monitors ${active.length} waiting`
318}
319types/index.d.ts 60 lines1// Most recently used first, at most 100 absolute paths.
2export type RecentFolderList = string[]
3
4// One rate-limit window as `$.session.usage()` reports it (five_hour, seven_day, seven_day_<tier>...).
5export type UsageWindow = { kind: string; percentUsed: number; resetsAt?: string }
6
7// The figures the usage widgets draw, taken from `$.session.usage()` / `session.measure`.
8export type UsageSnapshot = {
9 context: { tokens?: number; window: number; percent?: number }
10 rateLimits: UsageWindow[]
11}
12
13// A subagent as the dots draw it: running, waiting (held) or idle (a teammate
14// between turns) while active; completed, failed or stopped once ended.
15export type SubagentState = 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'stopped'
16
17export type SubagentRecord = {
18 id: string
19 description: string
20 type: string
21 state: SubagentState
22 startedAtMs: number
23 endedAtMs?: number
24 // Whether `$.agent.list()` has listed it; one listed and then gone ended unseen.
25 hasBeenListed: boolean
26}
27
28// A Monitor the main conversation started: active until its end notification,
29// a TaskStop, or its deadline (absent for a persistent one) passes.
30export type MonitorRecord = {
31 id: string
32 description: string
33 startedAtMs: number
34 deadlineMs?: number
35 endedAtMs?: number
36 // How it ended: the notification's status (completed, failed, killed...),
37 // `stopped` for a TaskStop, `expired` past its deadline.
38 outcome?: string
39}
40
41declare module 'claude-code' {
42 interface PluginState {
43 'conversation-toolbar': {
44 recentFolders: RecentFolderList
45 usage: UsageSnapshot | null
46 usageDetailsPinned: boolean
47 // The folder a move was asked for that the session has not reached yet
48 // (the desktop app moves it when a turn ends); null when none is pending.
49 pendingMove: string | null
50 // How many dots the pending-move note draws now, 1 to 6, cycling.
51 movingDots: number
52 // This session's subagents in launch order, at most 50, kept after the
53 // engine drops them from `$.agent.list()`.
54 subagents: SubagentRecord[]
55 // This session's Monitors, at most 20, the newest last.
56 monitors: MonitorRecord[]
57 }
58 }
59}
60