SLOPSHOPPER

conversation-toolbar

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…

newbandguardcommandtoastprompt
v0.13.0MITupdated 2026-10-15liyuanhe211/Claude_Desktop_Toolbar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · conversation-toolbar
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ conversation-toolbar │ ● conversation-toolbar: The usage endpoint answered 0 │ Copied transcript path: │ ● conversation-toolbar: Reading the Monitors from the │ /Users/dev\.claude\projects\-work-app\preview-session.jsonl │ ⏺ Read(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /copy-transcript-path ⎿ conversation-toolbar: Copied transcript path: /Users/dev\.claude\projects\-work-app\preview-session.jsonl ● conversation-toolbar: The usage endpoint answered 0 app ▾ [ Copy Transcript Path ] Model ? · ctx 97k/200k · 5h 31% · 1w ? Details ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app ▾ [ Copy Transcript Path ] Model ? · ctx 97k/200k · 5h 31% · 1w ? Details
README

Conversation Toolbar

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.

The toolbar above the prompt: subagents finishing, the context growing, a ring's tooltip, the pinned details, Copy Transcript Path and the folder list

Features

ControlWhat it does
Folder listShows 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 dotsOne 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 PathCopies 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.

Usage widgets

  • Colors follow the Codex usage widget: quotas green below 70 %, yellow to 85 %, red above; the context bar blue below 300k tokens, yellow to 400k, red above. A grey track shows only under 25 % used, and a missing reading shows ?, never 0.
  • Refresh: the context every 5 seconds; the plan windows every minute and after each turn, from one cached reading shared by all sessions.
  • Hover a ring or the bar for its figures in one sentence: 5 h limit: 12% (Reset in 3 h 30 min. Runs out in 1 h 5 min.)
  • Press the token count to pin a monospaced block with every figure, aligned:
  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..

  • In the terminal the widgets are one text line with a Details button.

Choosing a folder for a new conversation

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.

Requirements

  • Windows. The folder picker is a PowerShell script, and the folder handling expects Windows paths.
  • Claude Code 2.1.275 or later, which loads hooks-module plugins and installs them from a marketplace in one step. The Claude desktop app's Code tab runs a bundled Claude Code and draws the toolbar too.

Install

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

Data and privacy

  • Recent folders live in the plugin's own store under the Claude Code configuration directory, shared by all sessions on the machine. Every session's starting folder, every folder picked here, and every folder a session is moved to goes to the front; scratch workspaces, session worktrees and temporary folders are never recorded.
  • First load: the folders of the desktop app's own session records (%APPDATA%\Claude\claude-code-sessions) fill the list once. Only the folder paths are read.
  • Usage: the context and the 5-hour and weekly windows come from the session itself. The model-scoped weekly quota comes from 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.

Layout

  • .claude-plugin/plugin.json: the manifest (conversation-toolbar)
  • .claude-plugin/marketplace.json: makes this repository a one-plugin marketplace
  • hooks/register.tsx: the hooks: session start, turn boundaries, the slash commands, and the row above the prompt
  • hooks/folder_list.ts: path handling and the recent-folder list, with no engine calls
  • hooks/subagent_dots.ts: the subagent records and their dots, with no engine calls
  • hooks/usage_wheels.ts: the usage figures, their text and the SVG rings and bar, with no engine calls
  • scripts/Pick_Folder.ps1: the folder picker Browse runs
  • types/index.d.ts: the $.state contract
  • tests/: claude plugin test suites

Development

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

License

MIT

Source 6 files
hooks/register.tsx 1149 lines
1import { 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}
1149
hooks/folder_list.ts 281 lines
1export 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}
281
hooks/usage_wheels.ts 487 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
487
hooks/subagent_dots.ts 244 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
244
hooks/monitor_clock.ts 319 lines
1import 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}
319
types/index.d.ts 60 lines
1// 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