A side pane for the current session: prompt cache hot/cold, tool calls and errors, background commands with restart and stop, the approved plan, task list…

A Claude Code mod that adds a live side pane for the current session.
SESSION DASHBOARD hide
███ Cache (Hot) expires in 54:12
last request 97% cached · TTL 60m (auto)
─────────────────────────────────────────────────────
TOOL CALLS ERRORS
128 3
main 101 · sub 27 HTTP 404 2m ago
▸ show errors
─────────────────────────────────────────────────────
BACKGROUND 1 running
PID PORT COMMAND
18320 5000 dotnet run [↻] [✕]
─────────────────────────────────────────────────────
LINKED FOLDERS 2 [+]
shared-docs → D:\team\shared-docs [✎] [✕]
api → D:\src\backend-api [✎] [✕]
─────────────────────────────────────────────────────
TASKS
● now working 1:24
Repurpose the tasks section
↳ Bash · Run the tests
● plan Repurpose the tasks section · approved 4m ago
1 Parse the plan from ExitPlanMode
2 Draw each group in its own color
3 Add tests
● subagents 1 running
▶ Explore · find routing files 12k
☑ general-purpose · review the diff 48k
● swarm 1 of 2 working · /swarm
▶ scout · Bash: npm test 52k
! fixer · needs approval
─────────────────────────────────────────────────────
TOKENS 42 requests
in out c.read c.write
main agent 1.2k 18k 2.1M 140k
subagents (1) 310 4.2k 380k 60k
Explore · … 310 4.2k 380k 60k
total 1.5k 22k 2.5M 200k
context 34% of 200k · $1.84
─────────────────────────────────────────────────────
USAGE LIMITS
5-hour ███████ 23% 2h 15m
weekly ███ 8% 3d 4h
─────────────────────────────────────────────────────
TOTAL DISTRIBUTION 2.7M tokens · 14 turns
▄▄▄▄ ██ input 1.5k
▄▄▄▄▄▄▄▄▄▄ ██ output 18k
████████████ ██ tool use 64k
██████86%█████ ██ cache read 2.3M
████████████ ██ cache write 200k
▀▀▀▀▀▀▀▀▀▀ ██ retries 4.1k
p: show small %
tool use and retries are estimated from call sizes
| Section | Where the data comes from | | :- | :- | | Cache (Hot / Cold) | Each main-thread model request (turn.step usage). Hot (orange) until the cache TTL passes since the last request, then Cold (blue). Flags a request that missed a warm cache as invalidated. | | Tool calls / errors | Every tool.call, main agent and subagents. Errors: tool errors, permission denials, HTTP 4xx/5xx and "command not found", tracebacks, build errors found in command output, failed background tasks, API errors (StopFailure). | | Background | Commands Claude runs with run_in_background (Bash, PowerShell, Monitor) or that you push to the background with Ctrl+B. PID and listening ports come from the OS process table. ↻ stops the command and starts it again; ✕ stops it (and kills any child process that survives). | | Linked folders | + opens your OS folder picker (Windows folder dialog, macOS choose folder, zenity or kdialog on Linux; with none, a path field). The folder is linked into the workspace root (a symlink, or a junction on Windows without Developer Mode) and added as /<name> under a # Linked folders (session-dashboard) header in .gitignore. ✎ renames the link and its .gitignore entry; ✕ removes the link and the entry, never the folder itself. The list is kept per workspace across sessions. | | Tasks | What the session is working through, each group in its own color. now: what the main agent is on, labelled working (green), waiting (amber: a permission dialog, a question or a plan waiting for you) or done (grey, with answered, interrupted or stopped on an API error), with your request (or the task in progress) and its current step, such as Bash · Run the tests or thinking. plan (purple): the steps of the plan last approved in plan mode (ExitPlanMode), ticked when the turn that carries it out ends. task list (blue): TaskCreate / TaskUpdate / TaskList or TodoWrite, in sessions that have those tools; since Claude Code 2.1.233 newer models get them only in background and cloud sessions or with CLAUDE_CODE_ENABLE_TODO_TOOLS=1. subagents (teal): each subagent as type · description, with its status and tokens; the last 8 stay listed. swarm (pink): with swarm-mod loaded, its agents' activity and tokens, and ! / ? when one waits on you; its teammates are not listed again under subagents. | | Tokens | Input, output, cache read and cache write per agent, with subagents labelled by type and description, plus context window use and session cost. | | Total distribution | Appears once the first turn ends and refreshes at the end of each turn. A pie of every token the session has used, split into input, output, tool use, cache read, cache write and retries. Slices wide enough carry their share inside; show small % (p) lists the narrow ones' shares in the ledger, and pressing a ledger entry shows or hides its share. Tool use is the output spent writing tool calls plus the tool results sent back (≈4 characters a token); retries are a request resent after one that got no response, and calls that redo a tool that just failed. |
This repo is a Claude Code plugin marketplace. Add it and install the plugin:
/plugin marketplace add netgfx/claude-session-dashboard-mod
/plugin install session-dashboard@claude-session-dashboard-mod
Or run it from a local checkout:
claude --plugin-dir /path/to/claude-session-dashboard-mod
Then type in the Claude Code prompt:
| Command | What it does | | :- | :- | | /dashboard or /session-dashboard | Toggles the pane | | /dashboard open / /dashboard close | Opens or closes it | | /dashboard reset | Zeroes the counters |
The pane only ever opens as a sidebar on the right. Claude Code docks panes there only in the fullscreen layout, at 110 columns or wider. On the main screen it would put the pane above the prompt, so the dashboard stays closed there and /dashboard shows a toast explaining why. To get the fullscreen layout, start Claude Code with CLAUDE_CODE_NO_FLICKER=1 (outside tmux). On start, the pane opens by itself once the terminal is at least 144 columns wide.
Keys while the pane has focus (click it, or Ctrl+X then Tab): q hides it, e shows or hides the error list, s rescans processes, x clears ended commands, l links a folder, p shows the narrow slices' shares in the distribution, Tab moves between the ↻ / ✕ buttons, Esc returns to the prompt.
Installing through the marketplace loads it in every session. For a local checkout, add the directory to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (env).
userConfig)| Key | Default | Meaning | | :- | :- | :- | | auto_open | true | Open the pane when a session starts | | force_task_list | false | Ask Claude to keep a task list on every prompt. Only useful where the task tools exist (older models, or CLAUDE_CODE_ENABLE_TODO_TOOLS=1) | | cache_ttl_minutes | 0 | Prompt cache lifetime. 0 = 60 min when signed in with a subscription, 5 min with an API key |
claude plugin validate .
claude plugin test
Tested with Claude Code 2.1.287 on Windows 11. PID and port lookup uses PowerShell Get-CimInstance and netstat on Windows, ps and lsof elsewhere.
hooks/register.js 1011 lines1// session-dashboard: a side pane with the prompt cache's state, tool calls and
2// errors, background commands, the task list, and token usage per agent.
3
4import {
5 addMix,
6 applyTaskCreate,
7 applyTaskList,
8 applyTaskUpdate,
9 applyTodoWrite,
10 backgroundIdOf,
11 baseName,
12 cacheStatus,
13 classifyToolResult,
14 cleanPath,
15 describeToolCall,
16 emptySnapshot,
17 emptyTokens,
18 estimateTokens,
19 firstLine,
20 gitignoreAdd,
21 gitignoreRemove,
22 isValidLinkName,
23 isWithin,
24 joinPath,
25 matchProcess,
26 mergeAgents,
27 parseLsof,
28 parseNetstat,
29 parsePlan,
30 parseProcessList,
31 parseTaskNotification,
32 promptFromMessages,
33 pushError,
34 splitUsage,
35 swarmRows,
36 uniqueName,
37 WAIT_TOOLS,
38} from './lib.js'
39import { renderDashboard } from './view.js'
40
41const PANE = 'session-dashboard'
42const NO_DOCK =
43 'Dashboard opens as a right-hand sidebar only in the fullscreen layout: start Claude Code with CLAUDE_CODE_NO_FLICKER=1 in a terminal at least 110 columns wide'
44const BACKGROUND_TOOLS = ['Bash', 'PowerShell', 'Monitor']
45
46// Added to each prompt with `force_task_list` on, so the model keeps a task list
47// the pane can show. Off by default: newer models have no task tool to keep one
48const TASK_NUDGE =
49 'Keep a task list for this request with your task tool (TaskCreate/TaskUpdate, or TodoWrite if that is the one you have) ' +
50 'whenever the work takes more than one step: create the tasks before starting, mark exactly one in_progress while you work on it, ' +
51 'and mark each completed as soon as it is done. For a single quick step, one task is enough.'
52
53// Lists every process as `pid<TAB>ppid<TAB>command line`
54const PS_SCRIPT =
55 '[Console]::OutputEncoding=[Text.Encoding]::UTF8; ' +
56 'Get-CimInstance Win32_Process | ForEach-Object { "{0}`t{1}`t{2}" -f $_.ProcessId,$_.ParentProcessId,$_.CommandLine }'
57
58// Opens the OS folder picker and prints the chosen path (nothing on cancel).
59// -STA for WinForms; the TopMost owner brings the dialog above the terminal.
60const PICK_PS =
61 'Add-Type -AssemblyName System.Windows.Forms; ' +
62 '$o = New-Object System.Windows.Forms.Form -Property @{ TopMost = $true; ShowInTaskbar = $false }; ' +
63 '$d = New-Object System.Windows.Forms.FolderBrowserDialog; ' +
64 "$d.Description = 'Pick a folder to link into the workspace'; $d.ShowNewFolderButton = $false; " +
65 "if ($d.ShowDialog($o) -eq 'OK') { [Console]::OutputEncoding=[Text.Encoding]::UTF8; $d.SelectedPath }"
66const PICKERS_WINDOWS = [['powershell', '-NoProfile', '-NonInteractive', '-STA', '-Command', PICK_PS]]
67const PICKERS_UNIX = [
68 // `tell me to activate` brings the dialog in front of the terminal
69 ['osascript', '-e', 'tell me to activate', '-e', 'POSIX path of (choose folder with prompt "Pick a folder to link into the workspace")'],
70 ['zenity', '--file-selection', '--directory', '--title=Pick a folder to link into the workspace'],
71 ['kdialog', '--getexistingdirectory', '.', '--title', 'Pick a folder to link into the workspace'],
72]
73// Paths ride in environment variables, so no quoting can break them.
74// A symbolic link needs Developer Mode or admin; a junction needs neither.
75const LINK_PS =
76 "$ErrorActionPreference = 'Stop'; " +
77 'try { New-Item -ItemType SymbolicLink -Path $env:SD_LINK -Target $env:SD_TARGET | Out-Null } ' +
78 'catch { New-Item -ItemType Junction -Path $env:SD_LINK -Target $env:SD_TARGET | Out-Null }'
79// Deletes the link alone: a non-recursive delete refuses a real, non-empty folder
80const UNLINK_PS =
81 "$ErrorActionPreference = 'Stop'; " +
82 "$i = Get-Item -LiteralPath $env:SD_LINK -Force; if (-not $i.LinkType) { throw 'not a link' }; " +
83 '[System.IO.Directory]::Delete($env:SD_LINK)'
84
85// What the pane shows. Mirrored into $.state so it survives a hot reload.
86let S = emptySnapshot()
87let options = {}
88let paneOpen = false
89// Whether the terminal docks panes beside the transcript (fullscreen layout).
90// Learned from the first drawing; undefined until then.
91let isFullscreen
92// auto_open waits here until a drawing says the pane would dock on the right
93let wantsAutoOpen = false
94let errorsOpen = false
95// Whether the request under "now" shows in full or cut to one line
96let promptOpen = false
97let ttlMs = 5 * 60_000
98let ttlSource = 'default'
99let isWindows = false
100let scanning = false
101let ticks = 0
102let usage = null
103const agentLabels = new Map()
104// Per agent ('main' or its id): the last request, tool-result tokens its next
105// request will carry, and the tools that failed since that request
106const steps = new Map()
107// Total distribution: whether the narrow slices' shares show, and the ledger
108// entries pressed to show theirs
109let distUi = { showSmall: false, shown: new Set() }
110// A main-agent turn is under way and not yet counted: both turn.complete and
111// classic.Stop end a turn, and whichever comes first counts it
112let isTurnOpen = false
113// Folders linked into the workspace root, kept in $.store per workspace
114let links = []
115let linkRoot
116// editing: the link whose name is being typed; adding: the path field is up
117// (no folder picker here); busy: what the section is doing right now
118let linkUi = { editing: null, adding: false, busy: null }
119
120export function register(on, opts) {
121 options = opts ?? {}
122
123 on('session.start', async ($, e, next) => {
124 const started = await next(e)
125 await restore($)
126 await detectEnvironment($)
127 await loadLinks($)
128 paneOpen = (await $.ui.panes()).some((p) => p.id === PANE)
129
130 // One timer drives the countdown, the usage figures, and the process scan
131 $.clock.every(1000, () => onTick($))
132
133 // Opened unasked only as a right-hand sidebar, never above the prompt:
134 // the AbovePrompt hook opens it once it learns the layout docks panes
135 wantsAutoOpen = options.auto_open !== false && !paneOpen
136
137 // Register commands last: a name that is taken throws
138 await $.command.register({
139 name: 'session-dashboard',
140 description: 'Toggle the session dashboard pane (open | close | reset)',
141 argumentHint: '[open|close|reset]',
142 immediate: true,
143 })
144 try {
145 await $.command.register({
146 name: 'dashboard',
147 description: 'Toggle the session dashboard pane (open | close | reset)',
148 argumentHint: '[open|close|reset]',
149 immediate: true,
150 })
151 } catch {
152 // Another plugin or a built-in owns /dashboard; /session-dashboard still works
153 }
154 return started
155 })
156
157 // /clear, /resume and /branch start a new conversation: start the counts over
158 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
159 S = emptySnapshot()
160 agentLabels.clear()
161 steps.clear()
162 changed($)
163 return next(e)
164 })
165
166 // ---- Commands -----------------------------------------------------------
167
168 on('command.run', { command: ['dashboard', 'session-dashboard'] }, async ($, e) => {
169 const arg = String(e.args ?? '').trim().toLowerCase()
170 if (arg === 'reset') {
171 S = emptySnapshot()
172 changed($)
173 $.ui.toast('Counters reset')
174 return {}
175 }
176 const isUp = (await $.ui.panes()).some((p) => p.id === PANE)
177 if (arg === 'close' || (isUp && arg !== 'open')) {
178 await $.ui.close({ id: PANE })
179 paneOpen = false
180 return {}
181 }
182 if (!e.presentation?.isFullscreen) {
183 $.ui.toast(NO_DOCK)
184 return {}
185 }
186 const opened = await $.ui.open({ id: PANE, title: 'Dashboard', focus: true, columns: 54 })
187 paneOpen = true
188 if (!opened.isPlaced) $.ui.toast('Dashboard is waiting: ' + opened.reason)
189 await refreshUsage($)
190 $.ui.invalidate('ui.render')
191 return {}
192 })
193
194 on('ui.close', async ($, e, next) => {
195 if (e.id === PANE) paneOpen = false
196 return next(e)
197 })
198
199 // ---- Tool calls, errors, tasks, background commands ---------------------
200
201 on('tool.call', async ($, e, next) => {
202 S.toolCalls.total += 1
203 if (e.agentId === undefined) S.toolCalls.main += 1
204 else S.toolCalls.sub += 1
205 if (e.agentId === undefined) setNow($, WAIT_TOOLS.includes(e.tool) ? 'waiting' : 'working', { step: describeToolCall(e) })
206 $.ui.invalidate('ui.render')
207
208 let result
209 try {
210 result = await next(e)
211 } catch (error) {
212 pushError(S, { at: Date.now(), tool: e.tool, kind: 'tool crashed', message: firstLine(error?.message) })
213 changed($)
214 throw error
215 }
216 afterToolCall($, e, result)
217 return result
218 })
219
220 // Background tasks report back when they end
221 on('session.receive', async ($, e, next) => {
222 if (e.origin?.kind === 'task-notification') {
223 const note = parseTaskNotification(e.text)
224 const proc = note && S.procs.find((p) => p.taskId === note.id)
225 if (proc) {
226 const failed = note.status === 'failed' || (note.exitCode !== undefined && note.exitCode !== 0)
227 proc.status = failed ? 'failed' : note.status === 'killed' ? 'stopped' : 'exited'
228 if (failed) {
229 pushError(S, {
230 at: Date.now(),
231 tool: proc.tool,
232 kind: 'background failed',
233 message: proc.command + (note.exitCode !== undefined ? ' (exit ' + note.exitCode + ')' : ''),
234 })
235 }
236 changed($)
237 }
238 }
239 return next(e)
240 })
241
242 // At the end of each response, the engine lists the background work still in flight
243 on('classic.Stop', async ($, e, next) => {
244 try {
245 reconcileBackground($, e.background_tasks)
246 } catch {
247 // An unexpected task list must not keep the turn from ending
248 }
249 if (S.now && S.now.status !== 'done') setNow($, 'done', { step: 'answered' })
250 endTurn($)
251 return next(e)
252 })
253
254 // ---- What the main agent is doing now -----------------------------------
255
256 on('turn.start', async ($, e, next) => {
257 const headline = firstLine(e.text, 100)
258 // The whole prompt, for the expanded view (a Text child holds at most 10,000 characters)
259 const prompt = String(e.text ?? '').replace(/\r\n/g, '\n').trim().slice(0, 9000)
260 if (headline) promptOpen = false
261 // A continuation keeps the request it continues
262 setNow($, 'working', {
263 step: 'thinking',
264 since: Date.now(),
265 ...(headline || !S.now ? { headline: headline || 'continuing', prompt } : {}),
266 })
267 isTurnOpen = true
268 return next(e)
269 })
270
271 on('turn.complete', async ($, e, next) => {
272 if (e.agentId === undefined) {
273 setNow($, 'done', { step: TURN_ENDS[e.reason] ?? 'answered' })
274 endTurn($)
275 }
276 return next(e)
277 })
278
279 // A permission dialog: the agent waits on you until it is answered
280 on('classic.PermissionRequest', async ($, e, next) => {
281 const what = describeToolCall({ tool: e.tool_name, ...(e.tool_input ?? {}) })
282 setNow($, 'waiting', { step: (e.agent_id ? 'subagent needs approval: ' : 'needs approval: ') + what, waitAgent: e.agent_id })
283 return next(e)
284 })
285
286 on('classic.SubagentStart', async ($, e, next) => {
287 await refreshAgents($)
288 return next(e)
289 })
290
291 on('classic.SubagentStop', async ($, e, next) => {
292 const a = S.agents[e.agent_id]
293 if (a) S.agents = { ...S.agents, [a.id]: { ...a, status: 'completed' } }
294 await refreshAgents($)
295 return next(e)
296 })
297
298 on('classic.StopFailure', async ($, e, next) => {
299 pushError(S, { at: Date.now(), tool: 'API', kind: 'API error', message: firstLine(e.error_details ?? e.error) })
300 changed($)
301 return next(e)
302 })
303
304 // ---- Tokens and the prompt cache ----------------------------------------
305
306 on('turn.step', async function* ($, e, next) {
307 if (e.agentId === undefined) isTurnOpen = true
308 if (e.agentId === undefined && S.now?.status === 'working') setNow($, 'working', { step: 'thinking' })
309 const result = yield* next(e)
310 recordStep(e, result)
311 if (result?.usage) await recordUsage($, e.agentId, result.usage)
312 return result
313 })
314
315 // ---- Force a task list --------------------------------------------------
316
317 on('prompt.submit', async ($, e, next) => {
318 if (options.force_task_list !== true) return next(e)
319 return next({ ...e, context: [...(e.context ?? []), TASK_NUDGE] })
320 })
321
322 // ---- Drawing ------------------------------------------------------------
323
324 // Drawn on every screen update: learns whether the layout docks panes, and
325 // makes the deferred auto-open once it would land on the right
326 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
327 if (e.surface === 'terminal' && e.viewport?.isFullscreen !== undefined) isFullscreen = e.viewport.isFullscreen
328 if (wantsAutoOpen && isFullscreen !== undefined) {
329 wantsAutoOpen = false
330 if (isFullscreen && !paneOpen) {
331 paneOpen = true
332 void $.ui.open({ id: PANE, title: 'Dashboard', columns: 54 })
333 }
334 }
335 return next(e)
336 })
337
338 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
339 if (e.requestId !== PANE) return next(e)
340 // Seated above the prompt (a pane kept from a main-screen session): close it
341 if (e.surface === 'terminal' && e.props.placement === 'inline') {
342 paneOpen = false
343 void $.ui.close({ id: PANE })
344 }
345 const E = $.ui.resolve(e)
346 const now = Date.now()
347 const redraw = () => $.ui.invalidate('ui.render')
348 // Read while drawing, so the pane is drawn again when swarm-mod writes it
349 const swarm = await readSwarm($)
350
351 return renderDashboard(
352 E,
353 {
354 width: (e.props.bodyColumns ?? 54) - 1,
355 now,
356 snap: S,
357 cache: cacheStatus(S.cache, now, ttlMs),
358 ttlMs,
359 ttlSource,
360 usage,
361 errorsOpen,
362 promptOpen,
363 links,
364 linkUi,
365 distUi,
366 swarm,
367 },
368 {
369 refresh: (proc) => restartProcess($, proc),
370 close: (proc) => closeProcess($, proc),
371 toggleErrors: () => {
372 errorsOpen = !errorsOpen
373 redraw()
374 },
375 togglePrompt: () => {
376 promptOpen = !promptOpen
377 redraw()
378 },
379 rescan: () => scanProcesses($, true),
380 clearExited: () => {
381 S.procs = S.procs.filter((p) => p.status === 'running')
382 changed($)
383 },
384 closePane: async () => {
385 await $.ui.close({ id: PANE })
386 paneOpen = false
387 },
388 addLink: () => pickAndLink($),
389 addLinkPath: (path) => {
390 linkUi.adding = false
391 redraw()
392 if (cleanPath(path)) return withBusy($, 'linking…', () => linkFolder($, path))
393 },
394 editLink: (link) => {
395 linkUi.editing = linkUi.editing === link.name ? null : link.name
396 redraw()
397 },
398 renameLink: (link, name) => {
399 linkUi.editing = null
400 redraw()
401 return withBusy($, 'renaming…', () => renameLink($, link, name))
402 },
403 unlink: (link) => withBusy($, 'unlinking…', () => unlinkFolder($, link)),
404 toggleSmallPct: () => {
405 distUi.showSmall = !distUi.showSmall
406 redraw()
407 },
408 togglePct: (kind) => {
409 if (distUi.shown.has(kind)) distUi.shown.delete(kind)
410 else distUi.shown.add(kind)
411 redraw()
412 },
413 },
414 )
415 })
416}
417
418// ---------------------------------------------------------------------------
419// State
420
421async function restore($) {
422 const { value } = await $.state.get({ plugin: 'session-dashboard', key: 'snapshot' })
423 if (value && typeof value === 'object') S = { ...emptySnapshot(), ...value }
424 // Saved before the whole prompt was kept: find it in the transcript
425 if (S.now?.headline && !S.now.prompt) {
426 try {
427 const prompt = promptFromMessages(await $.session.messages(), S.now.headline)
428 if (prompt) S.now = { ...S.now, prompt }
429 } catch {
430 // the one-line headline still shows
431 }
432 }
433}
434
435/** Saves the snapshot for the next hot reload and redraws the pane. */
436function changed($) {
437 $.state.set({ plugin: 'session-dashboard', key: 'snapshot' }, S).catch(() => {})
438 $.ui.invalidate('ui.render')
439}
440
441async function detectEnvironment($) {
442 const cwd = await $.session.cwd()
443 isWindows = /^[A-Za-z]:[\\/]/.test(cwd) || cwd.startsWith('\\\\')
444
445 const configured = Number(options.cache_ttl_minutes ?? 0)
446 if (configured > 0) {
447 ttlMs = configured * 60_000
448 ttlSource = 'config'
449 return
450 }
451 // Subscription (OAuth) sessions get the 1-hour prompt cache; API keys the 5-minute one
452 try {
453 const auth = await $.session.authorize()
454 ttlMs = auth?.kind === 'bearer' ? 60 * 60_000 : 5 * 60_000
455 ttlSource = 'auto'
456 } catch {
457 ttlMs = 5 * 60_000
458 ttlSource = 'default'
459 }
460}
461
462async function refreshUsage($) {
463 try {
464 usage = await $.session.usage()
465 } catch {
466 // Keep the last figures
467 }
468}
469
470async function onTick($) {
471 ticks += 1
472 if (paneOpen) {
473 if (ticks % 2 === 0) await Promise.all([refreshUsage($), refreshAgents($)])
474 $.ui.invalidate('ui.render')
475 }
476 // Scan every 5 s while the pane is open, every 30 s otherwise
477 const hasRunning = S.procs.some((p) => p.status === 'running')
478 if (hasRunning && ticks % (paneOpen ? 5 : 30) === 0) await scanProcesses($, false)
479}
480
481// ---------------------------------------------------------------------------
482// Tool calls
483
484function afterToolCall($, e, result) {
485 // The tool (or the permission dialog before it) answered: back to work
486 if (S.now?.status === 'waiting' && S.now.waitAgent === e.agentId) S.now = { ...S.now, status: 'working', waitAgent: undefined, at: Date.now() }
487 const error = classifyToolResult(e.tool, result)
488 if (error) pushError(S, { at: Date.now(), tool: e.tool, agentId: e.agentId, ...error })
489
490 // What the tool answered goes back to the model with the agent's next request
491 const st = stepOf(e.agentId)
492 st.pending += estimateTokens(result?.text ?? result?.deny ?? '')
493 if (error) st.failedTools.add(e.tool)
494
495 const ok = result && !result.deny && !result.isError
496 if (ok) {
497 if (e.tool === 'ExitPlanMode' && e.agentId === undefined) void approvePlan($, result.result)
498 else if (e.tool === 'TodoWrite' && e.agentId === undefined) applyTodoWrite(S, e.todos)
499 else if (e.tool === 'TaskCreate') applyTaskCreate(S, e, result.result)
500 else if (e.tool === 'TaskUpdate') applyTaskUpdate(S, e)
501 else if (e.tool === 'TaskList') applyTaskList(S, result.result)
502 else if (e.tool === 'TaskStop') {
503 const id = e.task_id ?? e.shell_id
504 for (const p of S.procs) if (p.taskId === id && p.status === 'running') p.status = 'stopped'
505 }
506 }
507 if (ok && BACKGROUND_TOOLS.includes(e.tool)) {
508 const taskId = backgroundIdOf(result)
509 if (taskId || e.run_in_background) trackBackground($, e, taskId)
510 }
511 changed($)
512}
513
514function trackBackground($, e, taskId) {
515 const input = {}
516 for (const k of ['command', 'description', 'timeout', 'timeout_ms']) if (e[k] !== undefined) input[k] = e[k]
517 S.procs = [
518 ...S.procs.filter((p) => p.taskId !== taskId || !taskId),
519 {
520 id: taskId ?? e.tool_use_id,
521 taskId,
522 tool: e.tool,
523 input,
524 command: e.command ?? e.description ?? e.tool,
525 status: 'running',
526 startedAt: Date.now(),
527 pid: 0,
528 rootPid: 0,
529 pids: [],
530 ports: [],
531 seen: false,
532 misses: 0,
533 },
534 ]
535 // Give the process a moment to start and open its port
536 $.clock.after(1500, () => scanProcesses($, false))
537 $.clock.after(6000, () => scanProcesses($, false))
538}
539
540function reconcileBackground($, tasks) {
541 if (!Array.isArray(tasks)) return
542 const live = new Set(tasks.map((t) => t.id))
543 let isChanged = false
544 for (const p of S.procs) {
545 if (p.status === 'running' && p.taskId && !live.has(p.taskId)) {
546 p.status = 'exited'
547 isChanged = true
548 }
549 }
550 // Shell tasks the pane doesn't know yet, such as ones started before a reload
551 for (const t of tasks) {
552 if (t.type !== 'shell' || !t.command || S.procs.some((p) => p.taskId === t.id)) continue
553 S.procs = [
554 ...S.procs,
555 {
556 id: t.id,
557 taskId: t.id,
558 tool: 'Bash',
559 input: { command: t.command, description: t.description },
560 command: t.command,
561 status: 'running',
562 startedAt: Date.now(),
563 pid: 0,
564 rootPid: 0,
565 pids: [],
566 ports: [],
567 seen: false,
568 misses: 0,
569 },
570 ]
571 isChanged = true
572 }
573 if (isChanged) changed($)
574}
575
576// ---------------------------------------------------------------------------
577// Plan, turns and agents
578
579/** A plan approved in plan mode: its text is in the result, or in its file. */
580async function approvePlan($, out) {
581 let text = typeof out?.plan === 'string' ? out.plan : ''
582 if (!text && out?.filePath) {
583 try {
584 text = await $.fs.read(out.filePath)
585 } catch {
586 // No plan to show
587 }
588 }
589 const { title, steps } = parsePlan(text)
590 if (!steps.length) return
591 S.plan = { title, steps, at: Date.now(), status: 'running' }
592 changed($)
593}
594
595const TURN_ENDS = { answer: 'answered', aborted: 'interrupted', error: 'stopped on an API error', refusal: 'refused' }
596
597/** Moves the main agent's status line; `fields` overrides headline, step or since. */
598function setNow($, status, fields = {}) {
599 const now = Date.now()
600 // waitAgent: whose tool call the wait is on (undefined for the main agent)
601 S.now = { headline: '', step: '', since: now, ...S.now, waitAgent: undefined, ...fields, status, at: now }
602 changed($)
603}
604
605/** A turn ended: the plan it carried out is done, and the distribution shows the split as of now. */
606function endTurn($) {
607 if (S.plan?.status === 'running') S.plan = { ...S.plan, status: 'done', doneAt: Date.now() }
608 const total = Object.values(S.mix).reduce((a, b) => a + b, 0)
609 if (total > 0) S.dist = { ...S.mix, at: Date.now(), turns: (S.dist?.turns ?? 0) + (isTurnOpen || !S.dist ? 1 : 0) }
610 isTurnOpen = false
611 changed($)
612}
613
614async function refreshAgents($) {
615 try {
616 const next = mergeAgents(S.agents, await $.agent.list(), Date.now(), 'swarm-mod')
617 if (JSON.stringify(next) !== JSON.stringify(S.agents)) {
618 S.agents = next
619 changed($)
620 }
621 } catch {
622 // Keep the last list
623 }
624}
625
626async function readSwarm($) {
627 try {
628 const { value } = await $.state.get({ plugin: 'swarm-mod', key: 'snapshot' })
629 return swarmRows(value)
630 } catch {
631 // swarm-mod isn't loaded
632 return []
633 }
634}
635
636// ---------------------------------------------------------------------------
637// Tokens
638
639function stepOf(agentId) {
640 const key = agentId ?? 'main'
641 let st = steps.get(key)
642 if (!st) {
643 st = { turnId: null, index: -1, failed: false, pending: 0, failedTools: new Set() }
644 steps.set(key, st)
645 }
646 return st
647}
648
649/** Adds one model request to the running token split. */
650function recordStep(e, result) {
651 const st = stepOf(e.agentId)
652 if (!result?.usage) {
653 // No response at all: the request failed, and the next one resends it
654 if (result && result.stopReason === null) st.failed = true
655 return
656 }
657 const isRetry = st.failed || (st.turnId === e.turnId && st.index === e.index)
658 const { mix, pendingLeft } = splitUsage(result.usage, {
659 toolUses: result.toolUses,
660 pendingToolTokens: st.pending,
661 failedTools: st.failedTools,
662 isRetry,
663 })
664 S.mix = addMix(S.mix, mix)
665 Object.assign(st, { turnId: e.turnId, index: e.index, failed: false, pending: pendingLeft })
666 st.failedTools.clear()
667}
668
669async function recordUsage($, agentId, u) {
670 const read = u.cache_read_input_tokens ?? 0
671 const write = u.cache_creation_input_tokens ?? 0
672 const input = u.input_tokens ?? 0
673 const output = u.output_tokens ?? 0
674 const now = Date.now()
675
676 let row
677 if (agentId === undefined) {
678 row = S.tokens.main
679 // The main thread's request decides whether the cache is warm
680 const c = S.cache
681 const prompt = read + write + input
682 const isMiss = c.requests > 0 && prompt > 0 && read / prompt < 0.5
683 const wasExpired = c.lastAt > 0 && now - c.lastAt > ttlMs
684 S.cache = {
685 lastAt: now,
686 read,
687 write,
688 input,
689 requests: c.requests + 1,
690 misses: c.misses + (isMiss ? 1 : 0),
691 lastMiss: isMiss ? (wasExpired ? 'expired' : 'invalidated') : null,
692 }
693 } else {
694 row = S.tokens.agents[agentId]
695 if (!row) {
696 row = { ...emptyTokens(await agentLabel($, agentId)), id: agentId }
697 S.tokens.agents[agentId] = row
698 }
699 }
700 row.input += input
701 row.output += output
702 row.cacheRead += read
703 row.cacheWrite += write
704 row.requests += 1
705 changed($)
706}
707
708async function agentLabel($, agentId) {
709 if (!agentLabels.has(agentId)) {
710 try {
711 for (const a of await $.agent.list()) agentLabels.set(a.id, a.type + ' · ' + a.description)
712 } catch {
713 // Fall through to the generic label
714 }
715 }
716 return agentLabels.get(agentId) ?? 'internal ' + agentId.slice(0, 6)
717}
718
719// ---------------------------------------------------------------------------
720// Processes
721
722/** Every OS process with its parent and command line, and the TCP ports each one listens on. */
723async function listProcesses($) {
724 if (isWindows) {
725 const [list, net] = await Promise.all([
726 $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', PS_SCRIPT], { timeoutMs: 20_000 }),
727 $.process.run(['netstat', '-ano'], { timeoutMs: 10_000 }).catch(() => ({ stdout: '' })),
728 ])
729 return { procs: parseProcessList(list.stdout, true), ports: parseNetstat(net.stdout) }
730 }
731 const [list, net] = await Promise.all([
732 $.process.run(['ps', '-axo', 'pid=,ppid=,args='], { timeoutMs: 10_000 }),
733 $.process.run(['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpn'], { timeoutMs: 10_000 }).catch(() => ({ stdout: '' })),
734 ])
735 return { procs: parseProcessList(list.stdout, false), ports: parseLsof(net.stdout) }
736}
737
738async function scanProcesses($, isManual) {
739 if (scanning) return
740 const running = S.procs.filter((p) => p.status === 'running')
741 if (running.length === 0) {
742 if (isManual) $.ui.toast('Nothing running to scan')
743 return
744 }
745 scanning = true
746 try {
747 const { procs, ports } = await listProcesses($)
748 for (const p of running) {
749 const hit = matchProcess(p.command, procs, ports)
750 if (hit) {
751 Object.assign(p, hit, { seen: true, misses: 0 })
752 } else if (p.seen) {
753 p.misses += 1
754 // Gone on two scans in a row: it ended
755 if (p.misses >= 2) {
756 p.status = 'exited'
757 p.ports = []
758 }
759 }
760 }
761 changed($)
762 } catch (error) {
763 if (isManual) $.ui.toast('Scan failed: ' + firstLine(error?.message))
764 } finally {
765 scanning = false
766 }
767}
768
769async function stopProcess($, proc) {
770 if (proc.taskId) {
771 try {
772 await $.tool.call({ tool: 'TaskStop', task_id: proc.taskId })
773 } catch {
774 // The task may be gone already; the OS kill below covers it
775 }
776 }
777 // Whatever outlived the task (a detached child, a server that ignores the signal).
778 // Look again first, so a pid the OS has since reused is never killed.
779 try {
780 const { procs, ports } = await listProcesses($)
781 const left = matchProcess(proc.command, procs, ports)
782 if (left) {
783 const argv = isWindows ? ['taskkill', '/PID', String(left.rootPid), '/T', '/F'] : ['kill', '-TERM', ...left.pids.map(String)]
784 await $.process.run(argv, { timeoutMs: 10_000 })
785 }
786 } catch {
787 // Already gone, or the OS refused; the task stop above is what counts
788 }
789 proc.status = 'stopped'
790 proc.ports = []
791}
792
793async function closeProcess($, proc) {
794 if (proc.status !== 'running') {
795 S.procs = S.procs.filter((p) => p.id !== proc.id)
796 changed($)
797 return
798 }
799 await stopProcess($, proc)
800 changed($)
801 $.ui.toast('Stopped: ' + proc.command)
802}
803
804async function restartProcess($, proc) {
805 if (proc.status === 'running') await stopProcess($, proc)
806 const args = { tool: proc.tool, ...proc.input }
807 if (proc.tool !== 'Monitor') args.run_in_background = true
808 args.consent = 'The user pressed "Refresh" on the session-dashboard pane to restart this command: ' + proc.command
809
810 let result
811 try {
812 result = await $.tool.call(args)
813 } catch (error) {
814 result = { deny: firstLine(error?.message) || 'could not start' }
815 }
816 if (result.deny || result.isError) {
817 pushError(S, { at: Date.now(), tool: proc.tool, kind: 'restart failed', message: firstLine(result.deny ?? result.text) })
818 changed($)
819 $.ui.toast('Restart failed: ' + firstLine(result.deny ?? result.text))
820 return
821 }
822 const taskId = backgroundIdOf(result)
823 S.procs = S.procs.map((p) =>
824 p.id !== proc.id
825 ? p
826 : {
827 ...p,
828 id: taskId ?? p.id + '-r',
829 taskId,
830 status: 'running',
831 startedAt: Date.now(),
832 pid: 0,
833 rootPid: 0,
834 pids: [],
835 ports: [],
836 seen: false,
837 misses: 0,
838 restarts: (p.restarts ?? 0) + 1,
839 },
840 )
841 changed($)
842 $.ui.toast('Restarted: ' + proc.command)
843 $.clock.after(1500, () => scanProcesses($, false))
844 $.clock.after(6000, () => scanProcesses($, false))
845}
846
847// ---------------------------------------------------------------------------
848// Linked folders
849
850const linksKey = (root) => 'links:' + (isWindows ? root.toLowerCase() : root)
851// Case-insensitive everywhere: Windows and macOS disks are, and a clash ln
852// missed would put the new link inside the folder already holding the name
853const sameName = (a, b) => a.toLowerCase() === b.toLowerCase()
854
855async function workspaceRoot($) {
856 linkRoot ??= await $.session.root()
857 return linkRoot
858}
859
860/** Names at the workspace root, and which of them are links. */
861async function rootEntries($, root) {
862 const entries = await $.fs.list(root).catch(() => [])
863 return { names: entries.map((x) => x.name), linkNames: new Set(entries.filter((x) => x.isLink).map((x) => x.name)) }
864}
865
866/** The saved list, minus any link deleted outside the dashboard. */
867async function loadLinks($) {
868 try {
869 linkRoot = undefined
870 const root = await workspaceRoot($)
871 const saved = await $.store.get(linksKey(root))
872 const list = Array.isArray(saved) ? saved : []
873 const { linkNames } = await rootEntries($, root)
874 links = list.filter((l) => linkNames.has(l.name))
875 if (links.length !== list.length) await saveLinks($)
876 } catch {
877 links = []
878 }
879}
880
881async function saveLinks($) {
882 await $.store.set(linksKey(await workspaceRoot($)), links)
883 $.ui.invalidate('ui.render')
884}
885
886async function withBusy($, label, fn) {
887 if (linkUi.busy) return
888 linkUi.busy = label
889 $.ui.invalidate('ui.render')
890 try {
891 await fn()
892 } catch (error) {
893 $.ui.toast('Linked folders: ' + firstLine(error?.message))
894 } finally {
895 linkUi.busy = null
896 $.ui.invalidate('ui.render')
897 }
898}
899
900async function editGitignore($, root, edit) {
901 const path = joinPath(root, '.gitignore', isWindows)
902 const before = await $.fs.read(path).catch(() => '')
903 const after = edit(before)
904 if (after !== before) await $.fs.write(path, after)
905}
906
907/** Runs the OS folder picker: `{ path }`, `{ cancelled }`, or `{ unavailable }` when none opens. */
908async function pickFolder($) {
909 for (const argv of isWindows ? PICKERS_WINDOWS : PICKERS_UNIX) {
910 let r
911 try {
912 r = await $.process.run(argv, { timeoutMs: 5 * 60_000 })
913 } catch {
914 continue // Not installed, or no answer in time
915 }
916 const out = r.stdout.trim()
917 if (r.exitCode === 0 && out) return { path: out.split(/\r?\n/).pop() }
918 // A picker that could not open (no display) says why on stderr; a cancel doesn't, or says "cancel"
919 if (r.exitCode !== 0 && r.stderr.trim() && !/cancel/i.test(r.stderr)) continue
920 return { cancelled: true }
921 }
922 return { unavailable: true }
923}
924
925function pickAndLink($) {
926 return withBusy($, 'pick a folder…', async () => {
927 const picked = await pickFolder($)
928 if (picked.path) {
929 linkUi.busy = 'linking…'
930 await linkFolder($, picked.path)
931 } else if (picked.unavailable) {
932 linkUi.adding = true
933 $.ui.toast('No folder picker on this machine: type the folder path instead')
934 }
935 })
936}
937
938async function makeLink($, target, link) {
939 const r = isWindows
940 ? await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', LINK_PS], { env: { SD_LINK: link, SD_TARGET: target } })
941 : // -n: an existing link at `link` is an error, never a folder to link inside
942 await $.process.run(['ln', '-sn', target, link])
943 if (r.exitCode !== 0) throw new Error('could not link ' + baseName(link) + ': ' + firstLine(r.stderr || r.stdout))
944}
945
946async function removeLink($, link) {
947 const r = isWindows
948 ? await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', UNLINK_PS], { env: { SD_LINK: link } })
949 : await $.process.run(['rm', link])
950 if (r.exitCode !== 0) throw new Error('could not remove ' + baseName(link) + ': ' + firstLine(r.stderr || r.stdout))
951}
952
953async function linkFolder($, rawPath) {
954 const target = cleanPath(rawPath)
955 const root = await workspaceRoot($)
956 const stat = await $.fs.stat(target).catch(() => undefined)
957 if (stat?.kind !== 'dir') throw new Error('not a folder: ' + target)
958 if (isWithin(root, target) || isWithin(target, root)) {
959 throw new Error('that folder is the workspace, inside it, or holds it')
960 }
961 const base = baseName(target).replace(/[:*?"<>|]/g, '_').replace(/[. ]+$/, '') || 'linked'
962 const { names } = await rootEntries($, root)
963 const name = uniqueName(base, names)
964 await makeLink($, target, joinPath(root, name, isWindows))
965 await editGitignore($, root, (text) => gitignoreAdd(text, name))
966 links = [...links, { name, target, addedAt: Date.now() }]
967 await saveLinks($)
968 $.ui.toast('Linked ' + name + ' → ' + target)
969}
970
971async function renameLink($, link, rawName) {
972 const name = String(rawName ?? '').trim()
973 if (!name || name === link.name) return
974 if (!isValidLinkName(name)) throw new Error('"' + name + '" is not a valid folder name')
975 const root = await workspaceRoot($)
976 const { names, linkNames } = await rootEntries($, root)
977 if (names.some((n) => n !== link.name && sameName(n, name))) throw new Error(name + ' already exists in the workspace')
978 if (!linkNames.has(link.name)) throw new Error(link.name + ' is no longer a link')
979
980 const oldPath = joinPath(root, link.name, isWindows)
981 const newPath = joinPath(root, name, isWindows)
982 if (sameName(name, link.name)) {
983 // A case-only change on a case-insensitive file system: go through a temporary name
984 const tmp = oldPath + '.sd-rename'
985 await makeLink($, link.target, tmp)
986 await removeLink($, oldPath)
987 await makeLink($, link.target, newPath)
988 await removeLink($, tmp)
989 } else {
990 // The new link first, so a failure leaves the old one in place
991 await makeLink($, link.target, newPath)
992 await removeLink($, oldPath)
993 }
994 await editGitignore($, root, (text) => gitignoreAdd(gitignoreRemove(text, link.name), name))
995 links = links.map((l) => (l.name === link.name ? { ...l, name } : l))
996 await saveLinks($)
997 $.ui.toast('Renamed ' + link.name + ' → ' + name)
998}
999
1000async function unlinkFolder($, link) {
1001 const root = await workspaceRoot($)
1002 const { linkNames } = await rootEntries($, root)
1003 // Gone already: just forget it. Never deletes anything that isn't a link.
1004 if (linkNames.has(link.name)) await removeLink($, joinPath(root, link.name, isWindows))
1005 await editGitignore($, root, (text) => gitignoreRemove(text, link.name))
1006 links = links.filter((l) => l.name !== link.name)
1007 if (linkUi.editing === link.name) linkUi.editing = null
1008 await saveLinks($)
1009 $.ui.toast('Unlinked ' + link.name + ' (the folder itself is untouched)')
1010}
1011hooks/lib.js 707 lines1// Pure helpers: no mods API calls here, so they can be imported and unit tested.
2
3export const MAX_ERRORS = 50
4
5/** A fresh, empty snapshot of everything the dashboard tracks. */
6export function emptySnapshot() {
7 return {
8 toolCalls: { total: 0, main: 0, sub: 0 },
9 errors: [],
10 errorCount: 0,
11 procs: [],
12 tasks: { source: null, items: [] },
13 tokens: { main: emptyTokens('main'), agents: {} },
14 cache: { lastAt: 0, read: 0, write: 0, input: 0, misses: 0, requests: 0, lastMiss: null },
15 // The plan last approved in plan mode: `{ title, steps, at, status }`
16 plan: null,
17 // Subagents seen this session, by id: `{ id, type, description, status, at }`
18 agents: {},
19 // What the main agent is doing: `{ status, headline, step, since, at }`,
20 // status 'working' | 'waiting' | 'done'; null before the first turn
21 now: null,
22 // Running token split by kind, and the copy the pane shows, taken when a turn ends
23 mix: emptyMix(),
24 dist: null,
25 }
26}
27
28export function emptyTokens(label) {
29 return { label, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0 }
30}
31
32/** 1234 -> "1.2k", 1234567 -> "1.2M". */
33export function fmtNum(n) {
34 n = Number(n) || 0
35 if (n < 1000) return String(n)
36 if (n < 1e6) return (n / 1e3).toFixed(n < 1e4 ? 1 : 0) + 'k'
37 return (n / 1e6).toFixed(n < 1e7 ? 2 : 1) + 'M'
38}
39
40/** 75000 -> "1:15", 3723000 -> "1:02:03". */
41export function fmtDuration(ms) {
42 const s = Math.max(0, Math.floor(ms / 1000))
43 const h = Math.floor(s / 3600)
44 const m = Math.floor((s % 3600) / 60)
45 const sec = String(s % 60).padStart(2, '0')
46 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
47}
48
49/** "3m ago" style, coarse. */
50export function fmtAgo(ms) {
51 const s = Math.max(0, Math.floor(ms / 1000))
52 if (s < 60) return s + 's ago'
53 if (s < 3600) return Math.floor(s / 60) + 'm ago'
54 return Math.floor(s / 3600) + 'h ago'
55}
56
57/** Time left until a reset, coarse: "3d 4h", "2h 15m", "15m". */
58export function fmtUntil(ms) {
59 const m = Math.max(0, Math.floor(ms / 60000))
60 const d = Math.floor(m / 1440)
61 const h = Math.floor((m % 1440) / 60)
62 if (d > 0) return d + 'd ' + h + 'h'
63 if (h > 0) return h + 'h ' + (m % 60) + 'm'
64 return (m % 60) + 'm'
65}
66
67export function firstLine(text, max = 120) {
68 const line = String(text ?? '').split(/\r?\n/).find((l) => l.trim()) ?? ''
69 return line.trim().slice(0, max)
70}
71
72/**
73 * The whole prompt a request headline was cut from: the newest user message
74 * whose first line starts the same way. For a snapshot saved before prompts
75 * were kept. '' when none matches.
76 */
77export function promptFromMessages(messages, headline, max = 9000) {
78 if (!Array.isArray(messages) || !headline) return ''
79 for (let i = messages.length - 1; i >= 0; i--) {
80 const m = messages[i]
81 if (m?.role !== 'user' || typeof m.text !== 'string') continue
82 const text = m.text
83 .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
84 .replace(/\r\n/g, '\n')
85 .trim()
86 if (text && firstLine(text, 100) === headline) return text.slice(0, max)
87 }
88 return ''
89}
90
91/** Pads or cuts a string to exactly `n` columns. */
92export function fit(text, n) {
93 text = String(text)
94 if (n <= 0) return ''
95 if (text.length > n) return n > 1 ? text.slice(0, n - 1) + '…' : text.slice(0, n)
96 return text.padEnd(n)
97}
98
99/** Like `fit`, but cuts from the start so the end of the string (a path's tail) stays. */
100export function fitStart(text, n) {
101 text = String(text)
102 if (n <= 0) return ''
103 if (text.length > n) return n > 1 ? '…' + text.slice(text.length - n + 1) : text.slice(-n)
104 return text.padEnd(n)
105}
106
107// ---------------------------------------------------------------------------
108// Errors
109
110// Output patterns that mean something failed even when the tool reported success
111const ERROR_PATTERNS = [
112 [/\bHTTP\/\d(?:\.\d)?\s+([45]\d\d)\b/, (m) => 'HTTP ' + m[1]],
113 [/\bstatus(?:\s*code)?\s*[:=]?\s*([45]\d\d)\b/i, (m) => 'HTTP ' + m[1]],
114 [/\b([45]\d\d)\s+(?:Not Found|Internal Server Error|Bad Request|Unauthorized|Forbidden|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i, (m) => 'HTTP ' + m[1]],
115 [/command not found|is not recognized as (?:an internal or external command|the name of a cmdlet)/i, () => 'command not found'],
116 [/Traceback \(most recent call last\)/, () => 'Python traceback'],
117 [/Unhandled exception|UnhandledPromiseRejection|Uncaught \w*Error/i, () => 'unhandled exception'],
118 [/npm ERR!|error TS\d+|error CS\d+|BUILD FAILED|Build FAILED/, (m) => m[0].replace(/\s+$/, '')],
119 [/\bsegmentation fault\b|\bcore dumped\b|\bpanicked at\b/i, () => 'crash'],
120]
121
122/**
123 * Looks at a finished tool call and says whether it was an error.
124 * @returns `{ kind, message }` or null
125 */
126export function classifyToolResult(tool, result) {
127 if (!result) return null
128 if (typeof result.deny === 'string') return { kind: 'denied', message: firstLine(result.deny) }
129 const text = typeof result.text === 'string' ? result.text : ''
130 if (result.isError) return { kind: 'tool error', message: firstLine(text) || tool + ' failed' }
131 // Only scan output for tools that run things or fetch things
132 if (!/^(Bash|PowerShell|WebFetch|Monitor)$|^mcp__/.test(tool)) return null
133 const scanned = text.slice(0, 200_000)
134 for (const [re, label] of ERROR_PATTERNS) {
135 const m = scanned.match(re)
136 if (m) return { kind: label(m), message: firstLine(scanned.slice(Math.max(0, m.index - 40)), 120) }
137 }
138 return null
139}
140
141export function pushError(snap, error) {
142 snap.errorCount += 1
143 snap.errors = [error, ...snap.errors].slice(0, MAX_ERRORS)
144}
145
146// ---------------------------------------------------------------------------
147// Tasks
148
149/** TodoWrite replaces the whole list. */
150export function applyTodoWrite(snap, todos) {
151 if (!Array.isArray(todos)) return
152 snap.tasks = {
153 source: 'todo',
154 items: todos.map((t, i) => ({
155 id: 'todo-' + i,
156 subject: t.content,
157 activeForm: t.activeForm,
158 status: t.status,
159 })),
160 }
161}
162
163export function applyTaskCreate(snap, input, result) {
164 const id = result?.task?.id ?? 'task-' + (snap.tasks.items.length + 1)
165 if (snap.tasks.source !== 'task') snap.tasks = { source: 'task', items: [] }
166 if (snap.tasks.items.some((t) => t.id === id)) return
167 snap.tasks.items = [
168 ...snap.tasks.items,
169 { id, subject: input.subject, activeForm: input.activeForm, status: 'pending' },
170 ]
171}
172
173export function applyTaskUpdate(snap, input) {
174 if (input.status === 'deleted') {
175 snap.tasks.items = snap.tasks.items.filter((t) => t.id !== input.taskId)
176 return
177 }
178 snap.tasks.items = snap.tasks.items.map((t) =>
179 t.id !== input.taskId
180 ? t
181 : {
182 ...t,
183 subject: input.subject ?? t.subject,
184 activeForm: input.activeForm ?? t.activeForm,
185 status: input.status ?? t.status,
186 },
187 )
188}
189
190/** TaskList answers with the whole list, which resyncs ours. */
191export function applyTaskList(snap, result) {
192 const tasks = result?.tasks
193 if (!Array.isArray(tasks)) return
194 const prev = new Map(snap.tasks.items.map((t) => [t.id, t]))
195 snap.tasks = {
196 source: 'task',
197 items: tasks.map((t) => ({ id: t.id, subject: t.subject, status: t.status, activeForm: prev.get(t.id)?.activeForm })),
198 }
199}
200
201// ---------------------------------------------------------------------------
202// Plan and agents
203
204const MAX_PLAN_STEPS = 12
205export const MAX_AGENTS = 8
206
207/** Markdown inline marks off: `**a**` -> "a", "[x](y)" -> "x", "`c`" -> "c". */
208function plainText(text) {
209 return text
210 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
211 .replace(/(\*\*|__|`)/g, '')
212 .replace(/\s+/g, ' ')
213 .trim()
214}
215
216/**
217 * The steps of a plan approved in plan mode, from its markdown: the top-level
218 * numbered items, else the `##` headings, else the top-level bullets.
219 * @returns `{ title, steps: string[] }`
220 */
221export function parsePlan(markdown) {
222 let title = ''
223 const numbered = []
224 const headings = []
225 const bullets = []
226 let isFenced = false
227 for (const line of String(markdown ?? '').split(/\r?\n/)) {
228 if (/^\s*```/.test(line)) isFenced = !isFenced
229 if (isFenced) continue
230 let m
231 if (!title && (m = line.match(/^#\s+(.+)/))) title = plainText(m[1])
232 else if ((m = line.match(/^#{2,3}\s+(.+)/))) headings.push(plainText(m[1]))
233 else if ((m = line.match(/^ ?\d+[.)]\s+(?:\[[ xX]\]\s+)?(.+)/))) numbered.push(plainText(m[1]))
234 else if ((m = line.match(/^[-*+]\s+(?:\[[ xX]\]\s+)?(.+)/))) bullets.push(plainText(m[1]))
235 }
236 const steps = numbered.length >= 2 ? numbered : headings.length >= 2 ? headings : bullets.length ? bullets : [...numbered, ...headings]
237 return { title: title.replace(/^plan:\s*/i, ''), steps: steps.filter(Boolean).slice(0, MAX_PLAN_STEPS) }
238}
239
240/**
241 * Merges `$.agent.list()` into the subagents seen so far. Ones started by
242 * `skipPlugin` (swarm-mod, which lists its own) are left out; ones the list
243 * no longer has finished.
244 */
245export function mergeAgents(known, listed, now, skipPlugin) {
246 const out = { ...known }
247 const live = new Set()
248 for (const a of listed ?? []) {
249 if (!a?.id || (skipPlugin && a.spawnedBy === skipPlugin)) continue
250 live.add(a.id)
251 const prev = out[a.id]
252 out[a.id] = { id: a.id, type: a.type, description: a.description || a.name || '', status: a.status, at: prev?.at ?? now }
253 }
254 for (const id of Object.keys(out)) {
255 if (!live.has(id) && ['pending', 'running', 'waiting', 'idle'].includes(out[id].status)) out[id] = { ...out[id], status: 'completed' }
256 }
257 // The running ones, then the latest finished, up to MAX_AGENTS
258 const isLive = (a) => ['pending', 'running', 'waiting'].includes(a.status)
259 const kept = Object.values(out)
260 .sort((a, b) => Number(isLive(b)) - Number(isLive(a)) || b.at - a.at)
261 .slice(0, MAX_AGENTS)
262 return Object.fromEntries(kept.map((a) => [a.id, a]))
263}
264
265// Tools that block on the user until they answer
266export const WAIT_TOOLS = ['AskUserQuestion', 'ExitPlanMode']
267
268/** A tool call as a short line for the pane: "Bash · Run the tests", "Read · view.js". */
269export function describeToolCall(e) {
270 const tool = String(e?.tool ?? '?')
271 const mcp = tool.match(/^mcp__(.+?)__(.+)$/)
272 if (mcp) return mcp[1] + ' · ' + mcp[2]
273 const detail = (() => {
274 switch (tool) {
275 case 'Bash':
276 case 'PowerShell':
277 case 'Monitor':
278 return e.description || e.command
279 case 'Read':
280 case 'Edit':
281 case 'Write':
282 case 'NotebookEdit':
283 return baseName(e.file_path ?? e.notebook_path ?? '')
284 case 'Grep':
285 case 'Glob':
286 return e.pattern
287 case 'WebFetch':
288 return String(e.url ?? '').replace(/^https?:\/\//, '').split('/')[0]
289 case 'WebSearch':
290 return e.query
291 case 'Agent':
292 case 'Task':
293 return [e.subagent_type, e.description].filter(Boolean).join(' · ')
294 case 'Skill':
295 return e.skill
296 case 'AskUserQuestion':
297 return 'asking you a question'
298 case 'ExitPlanMode':
299 return 'plan ready for approval'
300 default:
301 return e.description
302 }
303 })()
304 const text = firstLine(detail ?? '', 80)
305 return text ? tool + ' · ' + text : tool
306}
307
308/** swarm-mod's snapshot as rows: `{ name, status, activity, tokens, pending }`. */
309export function swarmRows(snapshot) {
310 if (!snapshot || !Array.isArray(snapshot.order) || !snapshot.agents) return []
311 return snapshot.order
312 .map((k) => snapshot.agents[k])
313 .filter(Boolean)
314 .map((a) => {
315 const t = a.tokens ?? {}
316 return {
317 name: String(a.name ?? '?'),
318 status: String(a.status ?? ''),
319 activity: firstLine(a.activity || a.task || '', 80),
320 tokens: (t.input ?? 0) + (t.output ?? 0) + (t.cacheRead ?? 0) + (t.cacheWrite ?? 0),
321 pending: a.pending ? (a.pending.kind === 'question' ? 'question' : 'approval') : null,
322 }
323 })
324}
325
326// ---------------------------------------------------------------------------
327// Cache
328
329/**
330 * Whether the main thread's prompt cache is still warm.
331 * @returns `{ isHot, remainingMs, hitRate, lastMiss, isNew }`; `lastMiss` is `expired`, `invalidated` or null
332 */
333export function cacheStatus(cache, now, ttlMs) {
334 if (!cache.lastAt) return { isHot: false, remainingMs: 0, hitRate: null, lastMiss: null, isNew: true }
335 const remainingMs = cache.lastAt + ttlMs - now
336 const prompt = cache.read + cache.write + cache.input
337 const hitRate = prompt > 0 ? cache.read / prompt : null
338 return { isHot: remainingMs > 0, remainingMs, hitRate, lastMiss: cache.lastMiss ?? null, isNew: false }
339}
340
341// ---------------------------------------------------------------------------
342// Processes
343
344const SHELLS = /(?:^|[\\/])(bash|sh|zsh|dash|fish|pwsh|powershell|cmd|conhost)(?:\.exe)?$/i
345
346/** Strips quotes and escapes and collapses whitespace, so a command matches its shell's command line. */
347export function normalizeCommand(text) {
348 return String(text ?? '')
349 .replace(/['"`\\]/g, ' ')
350 .replace(/\s+/g, ' ')
351 .trim()
352 .toLowerCase()
353}
354
355/** Splits `C:\a b\x.exe --flag` style command lines into the executable's base name. */
356export function exeName(cmdline) {
357 const s = String(cmdline ?? '').trim()
358 const m = s.startsWith('"') ? s.slice(1).split('"')[0] : s.split(/\s+/)[0]
359 return m.split(/[\\/]/).pop() ?? ''
360}
361
362/** Parses `pid<TAB>ppid<TAB>cmdline` lines (Windows) or `ps -o pid=,ppid=,args=` output. */
363export function parseProcessList(text, isWindows) {
364 const procs = []
365 for (const line of String(text).split(/\r?\n/)) {
366 if (!line.trim()) continue
367 let m
368 if (isWindows) m = line.match(/^(\d+)\t(\d+)\t(.*)$/)
369 else m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/)
370 if (!m) continue
371 procs.push({ pid: Number(m[1]), ppid: Number(m[2]), cmd: m[3] })
372 }
373 return procs
374}
375
376/** `netstat -ano` (Windows) -> Map<pid, port[]> for listening TCP sockets. */
377export function parseNetstat(text) {
378 const ports = new Map()
379 for (const line of String(text).split(/\r?\n/)) {
380 const m = line.match(/^\s*TCP\s+\S*:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i)
381 if (!m) continue
382 addPort(ports, Number(m[2]), Number(m[1]))
383 }
384 return ports
385}
386
387/** `lsof -nP -iTCP -sTCP:LISTEN -Fpn` -> Map<pid, port[]>. */
388export function parseLsof(text) {
389 const ports = new Map()
390 let pid = 0
391 for (const line of String(text).split(/\r?\n/)) {
392 if (line.startsWith('p')) pid = Number(line.slice(1))
393 else if (line.startsWith('n') && pid) {
394 const m = line.match(/:(\d+)$/)
395 if (m) addPort(ports, pid, Number(m[1]))
396 }
397 }
398 return ports
399}
400
401function addPort(ports, pid, port) {
402 const list = ports.get(pid) ?? []
403 if (!list.includes(port)) list.push(port)
404 ports.set(pid, list)
405}
406
407/**
408 * Finds the OS processes behind a background command: the outermost process
409 * whose command line contains the command, and everything under it.
410 * @returns `{ rootPid, pid, ports, pids }` or null when nothing matches
411 */
412export function matchProcess(command, procs, ports, excludePids = new Set()) {
413 const needle = normalizeCommand(command)
414 if (!needle) return null
415 const tokens = needle.split(' ').filter((t) => t.length >= 3).slice(0, 6)
416 const matches = (cmd) => {
417 const hay = normalizeCommand(cmd)
418 if (hay.includes(needle)) return true
419 return tokens.length >= 2 && tokens.every((t) => hay.includes(t))
420 }
421 const hits = procs.filter((p) => !excludePids.has(p.pid) && !/Get-CimInstance Win32_Process|session-dashboard-scan/.test(p.cmd) && matches(p.cmd))
422 if (hits.length === 0) return null
423 const hitSet = new Set(hits.map((p) => p.pid))
424 // The root is a hit whose parent isn't a hit
425 const root = hits.find((p) => !hitSet.has(p.ppid)) ?? hits[0]
426 const children = new Map()
427 for (const p of procs) {
428 const list = children.get(p.ppid) ?? []
429 list.push(p)
430 children.set(p.ppid, list)
431 }
432 const tree = []
433 const queue = [root]
434 const seen = new Set()
435 while (queue.length) {
436 const p = queue.shift()
437 if (seen.has(p.pid)) continue
438 seen.add(p.pid)
439 tree.push(p)
440 queue.push(...(children.get(p.pid) ?? []))
441 }
442 const allPorts = []
443 let portOwner = null
444 for (const p of tree) {
445 for (const port of ports.get(p.pid) ?? []) {
446 if (!allPorts.includes(port)) allPorts.push(port)
447 portOwner ??= p
448 }
449 }
450 // Show the process doing the work: the port owner, else the deepest non-shell process
451 const worker = portOwner ?? [...tree].reverse().find((p) => !SHELLS.test(exeName(p.cmd))) ?? root
452 return { rootPid: root.pid, pid: worker.pid, ports: allPorts.sort((a, b) => a - b), pids: tree.map((p) => p.pid) }
453}
454
455/** Reads `<task-id>` and `<status>` from a background task notification. */
456export function parseTaskNotification(text) {
457 const id = String(text).match(/<task[-_]id>\s*([^<\s]+)\s*<\/task[-_]id>/i)?.[1]
458 const status = String(text).match(/<status>\s*([^<\s]+)\s*<\/status>/i)?.[1]
459 const exit = String(text).match(/exit(?:ed with)?\s*code\s*[:=]?\s*(-?\d+)/i)?.[1]
460 return id ? { id, status: status?.toLowerCase(), exitCode: exit === undefined ? undefined : Number(exit) } : null
461}
462
463/** The background task id a Bash/PowerShell/Monitor result carries. */
464export function backgroundIdOf(result) {
465 const r = result?.result
466 if (!r || typeof r !== 'object') return undefined
467 return r.backgroundTaskId ?? r.taskId ?? r.task_id ?? undefined
468}
469
470// ---------------------------------------------------------------------------
471// Linked folders
472
473export const GITIGNORE_HEADER = '# Linked folders (session-dashboard)'
474
475/** The last segment of a path: `C:\work\notes\` -> "notes". */
476export function baseName(path) {
477 const parts = String(path).split(/[\\/]+/).filter(Boolean)
478 return parts[parts.length - 1] ?? ''
479}
480
481/** Trims what a picker or a pasted path carries: whitespace, quotes, a trailing separator. */
482export function cleanPath(path) {
483 let p = String(path ?? '').trim()
484 if (/^(["']).*\1$/.test(p)) p = p.slice(1, -1).trim()
485 // Keep a drive or filesystem root's own separator
486 if (p.length > 1 && !/^[A-Za-z]:[\\/]$/.test(p)) p = p.replace(/[\\/]+$/, '')
487 return p
488}
489
490/** A link's name: one path segment the file system and git both take. */
491export function isValidLinkName(name) {
492 if (!name || name === '.' || name === '..' || name.length > 255) return false
493 if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return false
494 // Windows drops a trailing dot or space; git would then ignore the wrong name
495 return !/[. ]$/.test(name) && name.trim() === name
496}
497
498/**
499 * `base`, or `base-2`, `base-3`, … if a sibling already holds the name, ignoring
500 * case: Windows and macOS disks do.
501 */
502export function uniqueName(base, taken) {
503 const used = new Set([...taken].map((s) => s.toLowerCase()))
504 if (!used.has(base.toLowerCase())) return base
505 for (let i = 2; ; i++) if (!used.has((base + '-' + i).toLowerCase())) return base + '-' + i
506}
507
508/** Joins a folder and one name with the platform's separator. */
509export function joinPath(root, name, isWindows) {
510 return String(root).replace(/[\\/]+$/, '') + (isWindows ? '\\' : '/') + name
511}
512
513/** Whether `inner` is `outer` or lies beneath it, ignoring case as Windows and macOS disks do. */
514export function isWithin(inner, outer) {
515 const norm = (p) => cleanPath(p).replace(/[\\/]+/g, '/').toLowerCase()
516 const a = norm(inner)
517 const b = norm(outer)
518 return a === b || a.startsWith(b.endsWith('/') ? b : b + '/')
519}
520
521/** The .gitignore line for a link at the workspace root, glob characters escaped. */
522export function gitignoreEntry(name) {
523 // No trailing slash: git sees a symlink as a file, and "name/" matches folders only
524 return '/' + name.replace(/[\\*?[\]!#]/g, '\\$&')
525}
526
527function splitLines(text) {
528 return text === '' ? [] : text.replace(/\r?\n$/, '').split(/\r?\n/)
529}
530
531/** .gitignore text with the link's entry added under the mod's header. Unchanged if present. */
532export function gitignoreAdd(text, name) {
533 const eol = /\r\n/.test(text) ? '\r\n' : '\n'
534 const lines = splitLines(text)
535 const entry = gitignoreEntry(name)
536 if (lines.includes(entry)) return text
537 let at = lines.indexOf(GITIGNORE_HEADER)
538 if (at < 0) {
539 if (lines.length && lines[lines.length - 1].trim() !== '') lines.push('')
540 lines.push(GITIGNORE_HEADER)
541 at = lines.length - 1
542 }
543 let end = at + 1
544 while (end < lines.length && lines[end].startsWith('/')) end++
545 lines.splice(end, 0, entry)
546 return lines.join(eol) + eol
547}
548
549/** .gitignore text without the link's entry; drops the header once nothing is under it. */
550export function gitignoreRemove(text, name) {
551 const eol = /\r\n/.test(text) ? '\r\n' : '\n'
552 const entry = gitignoreEntry(name)
553 let lines = splitLines(text)
554 if (!lines.includes(entry)) return text
555 lines = lines.filter((l) => l !== entry)
556 const at = lines.indexOf(GITIGNORE_HEADER)
557 if (at >= 0 && !(lines[at + 1] ?? '').startsWith('/')) {
558 lines.splice(at, 1)
559 if (at > 0 && lines[at - 1] === '' && (at === lines.length || lines[at] === '')) lines.splice(at - 1, 1)
560 }
561 return lines.length ? lines.join(eol) + eol : ''
562}
563
564// ---------------------------------------------------------------------------
565// Token distribution
566
567export const MIX_KINDS = ['input', 'output', 'tool', 'cacheRead', 'cacheWrite', 'retry']
568
569export function emptyMix() {
570 return { input: 0, output: 0, tool: 0, cacheRead: 0, cacheWrite: 0, retry: 0 }
571}
572
573/** Rough token count of a piece of text or a value's JSON: ~4 characters a token. */
574export function estimateTokens(value) {
575 if (value === undefined || value === null) return 0
576 const text = typeof value === 'string' ? value : JSON.stringify(value) ?? ''
577 return Math.ceil(text.length / 4)
578}
579
580/**
581 * Splits one model request's usage into the distribution's kinds; the parts add up
582 * to the request's total. Tool use is the output spent writing tool calls plus the
583 * tool results the request sends; retries are a request resent after a failed one,
584 * and the calls that redo a tool that just failed.
585 * @param u the API usage
586 * @param o `{ toolUses, pendingToolTokens, failedTools: Set<name>, isRetry }`
587 * @returns `{ mix, pendingLeft }`: the parts, and the tool-result tokens not yet sent
588 */
589export function splitUsage(u, o = {}) {
590 const input = u.input_tokens ?? 0
591 const output = u.output_tokens ?? 0
592 const cacheRead = u.cache_read_input_tokens ?? 0
593 const cacheWrite = u.cache_creation_input_tokens ?? 0
594 const mix = emptyMix()
595 const pending = Math.max(0, o.pendingToolTokens ?? 0)
596 if (o.isRetry) {
597 mix.retry = input + output + cacheRead + cacheWrite
598 return { mix, pendingLeft: pending }
599 }
600 // Tool results are new content: written to the cache, or sent uncached
601 const fromWrite = Math.min(pending, cacheWrite)
602 const fromInput = Math.min(pending - fromWrite, input)
603 let toolOut = 0
604 let retryOut = 0
605 for (const use of o.toolUses ?? []) {
606 const n = estimateTokens(use?.input) + 10
607 if (o.failedTools?.has(use?.name)) retryOut += n
608 else toolOut += n
609 }
610 // Never more than the output the request reported
611 const over = toolOut + retryOut - output
612 if (over > 0) {
613 const cut = Math.min(over, toolOut)
614 toolOut -= cut
615 retryOut -= over - cut
616 }
617 mix.input = input - fromInput
618 mix.cacheRead = cacheRead
619 mix.cacheWrite = cacheWrite - fromWrite
620 mix.output = output - toolOut - retryOut
621 mix.tool = toolOut + fromWrite + fromInput
622 mix.retry = retryOut
623 return { mix, pendingLeft: pending - fromWrite - fromInput }
624}
625
626export function addMix(a, b) {
627 const out = emptyMix()
628 for (const k of MIX_KINDS) out[k] = (a?.[k] ?? 0) + (b?.[k] ?? 0)
629 return out
630}
631
632/** 0.4 -> "0.4%", 42.3 -> "42%"; a share under 0.1 is "<0.1%". */
633export function fmtPct(pct) {
634 if (!(pct > 0)) return '0%'
635 if (pct < 0.1) return '<0.1%'
636 if (pct < 10) return pct.toFixed(1).replace(/\.0$/, '') + '%'
637 return Math.round(pct) + '%'
638}
639
640/**
641 * Which slice each pixel of a pie lies in, clockwise from 12 o'clock.
642 * @param values the slices' sizes
643 * @param size the pie's diameter in pixels
644 * @returns rows of slice indexes, -1 outside the pie
645 */
646export function pieGrid(values, size) {
647 const total = values.reduce((a, b) => a + Math.max(0, b), 0)
648 const ends = []
649 let acc = 0
650 for (const v of values) {
651 acc += Math.max(0, v)
652 ends.push(total > 0 ? acc / total : 0)
653 }
654 const c = size / 2
655 const r = c - 0.2
656 const grid = []
657 for (let y = 0; y < size; y++) {
658 const row = []
659 for (let x = 0; x < size; x++) {
660 const dx = x + 0.5 - c
661 const dy = y + 0.5 - c
662 if (total <= 0 || dx * dx + dy * dy > r * r) {
663 row.push(-1)
664 continue
665 }
666 let f = Math.atan2(dx, -dy) / (2 * Math.PI)
667 if (f < 0) f += 1
668 let i = ends.findIndex((end, k) => f < end && values[k] > 0)
669 // Rounding at the very end of the circle: the last slice with a size
670 if (i < 0) i = values.reduce((last, v, k) => (v > 0 ? k : last), -1)
671 row.push(i)
672 }
673 grid.push(row)
674 }
675 return grid
676}
677
678/**
679 * Where a slice's label fits inside the pie: a run of `length` cells, each with both
680 * of its half-block pixels in the slice, near the middle of the slice's arc.
681 * @param grid from `pieGrid`; a cell is two pixel rows
682 * @returns `{ row, col }` in cells, or null when the slice is too narrow for it
683 */
684export function labelSpot(grid, values, index, length) {
685 const total = values.reduce((a, b) => a + Math.max(0, b), 0)
686 if (!(values[index] > 0) || total <= 0) return null
687 const before = values.slice(0, index).reduce((a, b) => a + Math.max(0, b), 0)
688 const angle = ((before + values[index] / 2) / total) * 2 * Math.PI
689 const size = grid.length
690 const c = size / 2
691 const fits = (row, col) => {
692 for (let x = col; x < col + length; x++) {
693 if (grid[row * 2]?.[x] !== index || grid[row * 2 + 1]?.[x] !== index) return false
694 }
695 return true
696 }
697 // A slice holding nearly the whole pie can take its label at the center
698 for (const k of values[index] / total > 0.75 ? [0, 0.3, 0.55] : [0.55, 0.4, 0.7]) {
699 const px = c + Math.sin(angle) * k * c
700 const py = c - Math.cos(angle) * k * c
701 const row = Math.min(Math.floor(size / 2) - 1, Math.max(0, Math.floor(py / 2)))
702 const mid = Math.round(px - length / 2)
703 for (const col of [mid, mid - 1, mid + 1]) if (col >= 0 && col + length <= size && fits(row, col)) return { row, col }
704 }
705 return null
706}
707hooks/view.js 874 lines1// Builds the pane's element tree from plain data. No mods API calls here:
2// register.js passes in the element table and the callbacks.
3
4import { fit, fitStart, fmtAgo, fmtDuration, fmtNum, fmtPct, fmtUntil, labelSpot, pieGrid } from './lib.js'
5
6export const COLORS = {
7 hot: '#F2862E',
8 cold: '#3B8EEA',
9 ok: '#3FB950',
10 bad: '#F85149',
11 warn: '#D29922',
12 accent: '#A371F7',
13 hourly: '#E3C341',
14 agent: '#39C5CF',
15 swarm: '#DB61A2',
16}
17
18/**
19 * @param E the element table from `$.ui.resolve(e)`
20 * @param v `{ width, now, snap, cache, ttlMs, usage, errorsOpen, links, linkUi, distUi, swarm }`
21 * @param act `{ refresh(proc), close(proc), toggleErrors(), rescan(), clearExited(), closePane(),
22 * addLink(), addLinkPath(path), editLink(link), renameLink(link, name), unlink(link),
23 * toggleSmallPct(), togglePct(kind) }`
24 */
25export function renderDashboard(E, v, act) {
26 const { Box, Text } = E
27 const W = Math.max(28, v.width)
28 const rule = () => Text({ dimColor: true, wrap: 'truncate-end', children: ['─'.repeat(W)] })
29
30 return Box({
31 flexDirection: 'column',
32 width: W,
33 children: [
34 header(E, v, act, W),
35 cacheSection(E, v, W),
36 rule(),
37 activitySection(E, v, act, W),
38 rule(),
39 processSection(E, v, act, W),
40 rule(),
41 linkSection(E, v, act, W),
42 rule(),
43 taskSection(E, v, W, act),
44 rule(),
45 tokenSection(E, v, W),
46 rule(),
47 limitSection(E, v, W),
48 // Once a turn has ended
49 ...(v.snap.dist ? [rule(), distSection(E, v, act, W)] : []),
50 ],
51 })
52}
53
54function header(E, v, act, W) {
55 const { Box, Text, Button } = E
56 return Box({
57 flexDirection: 'row',
58 justifyContent: 'space-between',
59 width: W,
60 marginBottom: 1,
61 children: [
62 Text({ bold: true, children: ['SESSION DASHBOARD'] }),
63 // One cell (~8px) in from the right edge, clear of the pane's own close X
64 Box({
65 marginRight: 1,
66 children: [Button({ key: 'pane-close', label: 'hide', hotkey: 'q', plain: true, dimColor: true, onPress: act.closePane })],
67 }),
68 ],
69 })
70}
71
72function sectionTitle(E, title, extra) {
73 const { Box, Text } = E
74 return Box({
75 flexDirection: 'row',
76 columnGap: 1,
77 children: [Text({ bold: true, dimColor: true, children: [title] }), ...(extra ? [extra] : [])],
78 })
79}
80
81// ---------------------------------------------------------------------------
82
83function cacheSection(E, v, W) {
84 const { Box, Text } = E
85 const c = v.cache
86 const color = c.isHot ? COLORS.hot : COLORS.cold
87 const word = c.isHot ? 'Hot' : 'Cold'
88
89 let detail
90 if (c.isNew) detail = 'no request yet'
91 else if (c.isHot) detail = 'expires in ' + fmtDuration(c.remainingMs)
92 else detail = 'expired ' + fmtAgo(-c.remainingMs)
93
94 const lines = []
95 if (!c.isNew) {
96 const hit = c.hitRate === null ? '—' : Math.round(c.hitRate * 100) + '%'
97 lines.push(
98 Text({
99 dimColor: true,
100 wrap: 'truncate-end',
101 children: ['last request ' + hit + ' cached · TTL ' + Math.round(v.ttlMs / 60000) + 'm' + (v.ttlSource === 'auto' ? ' (auto)' : '')],
102 }),
103 )
104 if (c.lastMiss === 'invalidated') {
105 lines.push(Text({ color: COLORS.cold, wrap: 'truncate-end', children: ['↯ last request missed the cache (invalidated)'] }))
106 } else if (c.lastMiss === 'expired') {
107 lines.push(Text({ color: COLORS.cold, wrap: 'truncate-end', children: ['last request rebuilt an expired cache'] }))
108 } else if (v.snap.cache.misses > 0) {
109 lines.push(Text({ dimColor: true, wrap: 'truncate-end', children: [v.snap.cache.misses + ' cache miss' + (v.snap.cache.misses === 1 ? '' : 'es') + ' this session'] }))
110 }
111 }
112
113 return Box({
114 flexDirection: 'column',
115 children: [
116 Box({
117 flexDirection: 'row',
118 columnGap: 1,
119 width: W,
120 children: [
121 // The colored block: three cells of background color
122 Text({ backgroundColor: color, children: [' '] }),
123 Text({ bold: true, children: ['Cache (', Text({ bold: true, color, children: [word] }), ')'] }),
124 Box({ flexGrow: 1, justifyContent: 'flex-end', children: [Text({ dimColor: true, wrap: 'truncate-start', children: [detail] })] }),
125 ],
126 }),
127 ...lines,
128 ],
129 })
130}
131
132// ---------------------------------------------------------------------------
133
134function activitySection(E, v, act, W) {
135 const { Box, Text, Button } = E
136 const s = v.snap
137 const half = Math.floor(W / 2)
138 const errColor = s.errorCount > 0 ? COLORS.bad : COLORS.ok
139
140 const stat = (label, value, color, sub) =>
141 Box({
142 flexDirection: 'column',
143 width: half,
144 children: [
145 Text({ dimColor: true, bold: true, children: [label] }),
146 Text({ bold: true, color, children: [String(value)] }),
147 Text({ dimColor: true, wrap: 'truncate-end', children: [sub] }),
148 ],
149 })
150
151 const lastErr = s.errors[0]
152 const errorRows = v.errorsOpen
153 ? s.errors.slice(0, 6).map((err, i) =>
154 Box({
155 key: 'err-' + i,
156 flexDirection: 'row',
157 columnGap: 1,
158 children: [
159 Text({ color: COLORS.bad, children: ['✕'] }),
160 Text({
161 wrap: 'truncate-end',
162 children: [fit(err.kind, 14).trimEnd() + ' · ' + (err.tool ?? '') + (err.message ? ': ' + err.message : '')],
163 }),
164 ],
165 }),
166 )
167 : []
168
169 return Box({
170 flexDirection: 'column',
171 children: [
172 Box({
173 flexDirection: 'row',
174 width: W,
175 children: [
176 stat('TOOL CALLS', s.toolCalls.total, COLORS.accent, 'main ' + s.toolCalls.main + ' · sub ' + s.toolCalls.sub),
177 stat('ERRORS', s.errorCount, errColor, lastErr ? lastErr.kind + ' ' + fmtAgo(v.now - lastErr.at) : 'none so far'),
178 ],
179 }),
180 ...(s.errorCount > 0
181 ? [
182 Button({
183 key: 'errors-toggle',
184 label: v.errorsOpen ? '▾ hide errors' : '▸ show errors',
185 hotkey: 'e',
186 plain: true,
187 dimColor: true,
188 onPress: act.toggleErrors,
189 }),
190 ]
191 : []),
192 ...errorRows,
193 ],
194 })
195}
196
197// ---------------------------------------------------------------------------
198
199function processSection(E, v, act, W) {
200 const { Box, Text, Button } = E
201 const procs = v.snap.procs
202 const running = procs.filter((p) => p.status === 'running').length
203 const PID = 7
204 const PORT = 7
205 const ACTIONS = 12
206 const CMD = Math.max(8, W - PID - PORT - ACTIONS - 1)
207
208 const title = sectionTitle(
209 E,
210 'BACKGROUND',
211 Text({ dimColor: true, children: [running + ' running' + (procs.length > running ? ' · ' + (procs.length - running) + ' ended' : '')] }),
212 )
213
214 if (procs.length === 0) {
215 return Box({
216 flexDirection: 'column',
217 children: [title, Text({ dimColor: true, children: ['No background commands in this session.'] })],
218 })
219 }
220
221 const headerRow = Box({
222 flexDirection: 'row',
223 children: [
224 Text({ dimColor: true, children: [fit('PID', PID)] }),
225 Text({ dimColor: true, children: [fit('PORT', PORT)] }),
226 Text({ dimColor: true, children: [fit('COMMAND', CMD)] }),
227 ],
228 })
229
230 const rows = procs.map((p) => {
231 const alive = p.status === 'running'
232 const statusColor = alive ? COLORS.ok : p.status === 'failed' ? COLORS.bad : undefined
233 const port = p.ports?.length ? p.ports.join(',') : '—'
234 return Box({
235 key: 'proc-' + p.id,
236 flexDirection: 'row',
237 width: W,
238 children: [
239 Text({ color: statusColor, dimColor: !alive, children: [fit(p.pid ? String(p.pid) : alive ? '…' : '—', PID)] }),
240 Text({ color: p.ports?.length ? COLORS.cold : undefined, dimColor: !p.ports?.length, children: [fit(port, PORT)] }),
241 Box({
242 width: CMD,
243 flexShrink: 1,
244 children: [Text({ dimColor: !alive, wrap: 'truncate-end', children: [p.command + (alive ? '' : ' (' + p.status + ')')] })],
245 }),
246 Box({
247 flexDirection: 'row',
248 columnGap: 1,
249 marginLeft: 1,
250 children: [
251 Button({ key: 'refresh-' + p.id, label: '↻', onPress: () => act.refresh(p) }),
252 Button({ key: 'close-' + p.id, label: alive ? '✕' : '–', onPress: () => act.close(p) }),
253 ],
254 }),
255 ],
256 })
257 })
258
259 const footer = Box({
260 flexDirection: 'row',
261 columnGap: 2,
262 children: [
263 Button({ key: 'rescan', label: 'rescan', hotkey: 's', plain: true, dimColor: true, onPress: act.rescan }),
264 ...(procs.length > running
265 ? [Button({ key: 'clear-ended', label: 'clear ended', hotkey: 'x', plain: true, dimColor: true, onPress: act.clearExited })]
266 : []),
267 ],
268 })
269
270 return Box({ flexDirection: 'column', children: [title, headerRow, ...rows, footer] })
271}
272
273// ---------------------------------------------------------------------------
274
275function linkSection(E, v, act, W) {
276 const { Box, Text, Button, Input } = E
277 const links = v.links
278 const ui = v.linkUi
279
280 const title = Box({
281 flexDirection: 'row',
282 width: W,
283 children: [
284 Box({
285 flexGrow: 1,
286 flexDirection: 'row',
287 columnGap: 1,
288 children: [
289 Text({ bold: true, dimColor: true, children: ['LINKED FOLDERS'] }),
290 ...(links.length ? [Text({ dimColor: true, children: [String(links.length)] })] : []),
291 ...(ui.busy ? [Text({ color: COLORS.warn, children: [ui.busy] })] : []),
292 ],
293 }),
294 Box({
295 marginRight: 1,
296 children: [Button({ key: 'link-add', label: '+', hotkey: 'l', onPress: act.addLink })],
297 }),
298 ],
299 })
300
301 const rows = links.map((l) => {
302 if (ui.editing === l.name) {
303 return Input({
304 key: 'link-name-' + l.name,
305 label: 'rename',
306 value: l.name,
307 submitLabel: 'rename',
308 autoFocus: true,
309 onSubmit: (name) => act.renameLink(l, name),
310 })
311 }
312 // Fixed widths, as in the background rows, so a long path never pushes ✎ / ✕ off the edge
313 const NAME = Math.min(18, Math.max(6, Math.floor(W / 3)))
314 const ACTIONS = 12
315 const PATH = Math.max(4, W - NAME - ACTIONS - 1)
316 return Box({
317 key: 'link-' + l.name,
318 flexDirection: 'row',
319 width: W,
320 children: [
321 Text({ bold: true, color: COLORS.accent, wrap: 'truncate-end', children: [fit(l.name, NAME)] }),
322 Box({
323 width: PATH,
324 flexShrink: 1,
325 children: [Text({ dimColor: true, wrap: 'truncate-start', children: ['→ ' + fitStart(l.target, PATH - 2)] })],
326 }),
327 Box({
328 flexDirection: 'row',
329 flexShrink: 0,
330 columnGap: 1,
331 marginLeft: 1,
332 children: [
333 Button({ key: 'link-edit-' + l.name, label: '✎', onPress: () => act.editLink(l) }),
334 Button({ key: 'link-remove-' + l.name, label: '✕', onPress: () => act.unlink(l) }),
335 ],
336 }),
337 ],
338 })
339 })
340
341 const pathInput = ui.adding
342 ? [
343 Input({
344 key: 'link-path',
345 label: 'folder',
346 placeholder: 'path to a folder on this machine',
347 submitLabel: 'link',
348 autoFocus: true,
349 onSubmit: act.addLinkPath,
350 }),
351 ]
352 : []
353
354 const empty = links.length === 0 && !ui.adding ? [Text({ dimColor: true, wrap: 'truncate-end', children: ['None. Press + to link a folder into the workspace.'] })] : []
355
356 return Box({ flexDirection: 'column', children: [title, ...rows, ...pathInput, ...empty] })
357}
358
359// ---------------------------------------------------------------------------
360
361// What the main agent is doing now, then the plan from plan mode, the task
362// tools' list, subagents and swarm-mod's agents, each group in its own color
363function taskSection(E, v, W, act) {
364 const { Box, Text } = E
365 const groups = [nowGroup(E, v, W, act),planGroup(E, v, W), taskGroup(E, v, W), agentGroup(E, v, W), swarmGroup(E, v, W)].filter(Boolean)
366 const title = sectionTitle(E, 'TASKS')
367 if (groups.length === 0) {
368 return Box({ flexDirection: 'column', children: [title, Text({ dimColor: true, children: ['No plan, tasks or agents yet.'] })] })
369 }
370 return Box({ flexDirection: 'column', rowGap: 1, children: [title, ...groups] })
371}
372
373/** A group's first line: its name in its color, then dim details. */
374function groupTitle(E, name, color, detail) {
375 const { Box, Text } = E
376 return Box({
377 flexDirection: 'row',
378 columnGap: 1,
379 children: [Text({ color, bold: true, children: ['● ' + name] }), ...(detail ? [Text({ dimColor: true, wrap: 'truncate-end', children: [detail] })] : [])],
380 })
381}
382
383/** One row: a status mark, the label, and dim text on the right. */
384function itemRow(E, key, mark, label, opts = {}) {
385 const { Box, Text } = E
386 return Box({
387 key,
388 flexDirection: 'row',
389 columnGap: 1,
390 children: [
391 Text({ color: mark.color, children: [mark.ch] }),
392 Box({
393 flexGrow: 1,
394 flexShrink: 1,
395 children: [
396 Text({ color: opts.color, bold: opts.bold, dimColor: opts.dim, strikethrough: opts.strike, wrap: 'truncate-end', children: [label ?? ''] }),
397 ],
398 }),
399 ...(opts.right ? [Box({ flexShrink: 0, children: [opts.right] })] : []),
400 ],
401 })
402}
403
404function progressBar(E, done, total, W, color) {
405 const { Box, Text } = E
406 const barWidth = Math.min(12, Math.max(4, W - 30))
407 const filled = total ? Math.round((done / total) * barWidth) : 0
408 return Box({
409 flexDirection: 'row',
410 columnGap: 1,
411 children: [
412 Text({ dimColor: true, children: [done + '/' + total] }),
413 Box({ flexDirection: 'row', children: [Text({ color, children: ['▰'.repeat(filled)] }), Text({ dimColor: true, children: ['▱'.repeat(barWidth - filled)] })] }),
414 ],
415 })
416}
417
418const NOW_COLORS = { working: COLORS.ok, waiting: COLORS.warn, done: '#8B949E' }
419
420// The status as a filled label, the request (or the task in progress), and its current step
421function nowGroup(E, v, W, act) {
422 const { Box, Text, Button } = E
423 const n = v.snap.now
424 if (!n) return null
425 const color = NOW_COLORS[n.status] ?? NOW_COLORS.done
426 const isDone = n.status === 'done'
427 const active = v.snap.tasks.items.find((t) => t.status === 'in_progress')
428 const headline = (!isDone && active ? active.activeForm || active.subject : n.headline) || 'continuing'
429 // Counts up while working or waiting; on done it stops at the total the request took
430 const when = isDone ? 'took ' + fmtDuration(n.at - n.since) : fmtDuration(v.now - n.since)
431 return Box({
432 flexDirection: 'column',
433 children: [
434 Box({
435 flexDirection: 'row',
436 columnGap: 1,
437 children: [
438 Text({ color, bold: true, children: ['● now'] }),
439 Text({ backgroundColor: color, color: inkOn(color), bold: true, children: [' ' + n.status + ' '] }),
440 Text({ dimColor: true, children: [when] }),
441 ],
442 }),
443 ...promptRows(E, v, W, act, { color, isDone, headline, showsPrompt: isDone || !active }),
444 ...(n.step ? [itemRow(E, 'now-step', { ch: '↳', color }, n.step, { color: isDone ? undefined : color, dim: isDone })] : []),
445 ],
446 })
447}
448
449// The request under "now": one line, with a pressable … when there's more of it;
450// pressed, the whole prompt wraps below, with ▴ less to fold it again
451function promptRows(E, v, W, act, { color, isDone, headline, showsPrompt }) {
452 const { Box, Text, Button } = E
453 const full = showsPrompt ? String(v.snap.now.prompt || v.snap.now.headline || '').trim() : ''
454 const room = W - 2 // the mark column and its gap
455 const canExpand = Boolean(full) && Boolean(act?.togglePrompt) && (full !== headline || headline.length > room)
456 if (!canExpand) return [itemRow(E, 'now-headline', { ch: ' ', color }, headline, { bold: !isDone, dim: isDone })]
457
458 const toggle = (label) =>
459 Button({ key: 'prompt-toggle', label, plain: true, dimColor: label !== '…', onPress: act.togglePrompt })
460 if (v.promptOpen) {
461 return [
462 Box({
463 key: 'now-prompt',
464 flexDirection: 'row',
465 columnGap: 1,
466 children: [
467 Text({ color, children: [' '] }),
468 Box({ flexGrow: 1, flexShrink: 1, children: [Text({ bold: !isDone, dimColor: isDone, wrap: 'wrap', children: [full] })] }),
469 ],
470 }),
471 Box({ key: 'now-prompt-less', flexDirection: 'row', columnGap: 1, children: [Text({ children: [' '] }), toggle('▴ less')] }),
472 ]
473 }
474 // Cut it ourselves, leaving one column for the … button
475 const cut = headline.slice(0, Math.max(1, room - 1)).trimEnd()
476 return [
477 Box({
478 key: 'now-headline',
479 flexDirection: 'row',
480 columnGap: 1,
481 children: [
482 Text({ color, children: [' '] }),
483 Box({
484 flexDirection: 'row',
485 flexShrink: 1,
486 children: [Text({ bold: !isDone, dimColor: isDone, wrap: 'truncate-end', children: [cut] }), toggle('…')],
487 }),
488 ],
489 }),
490 ]
491}
492
493function planGroup(E, v, W) {
494 const { Box } = E
495 const p = v.snap.plan
496 if (!p?.steps?.length) return null
497 const isDone = p.status === 'done'
498 const detail = (p.title ? p.title + ' · ' : '') + (isDone ? 'done ' + fmtAgo(v.now - (p.doneAt ?? p.at)) : 'approved ' + fmtAgo(v.now - p.at))
499 return Box({
500 flexDirection: 'column',
501 children: [
502 groupTitle(E, 'plan', COLORS.accent, detail),
503 ...p.steps.map((s, i) =>
504 itemRow(E, 'plan-' + i, isDone ? { ch: '☑', color: COLORS.ok } : { ch: String(i + 1).padStart(2), color: COLORS.accent }, s, { dim: isDone }),
505 ),
506 ],
507 })
508}
509
510function taskGroup(E, v, W) {
511 const { Box, Text } = E
512 const items = v.snap.tasks.items
513 if (items.length === 0) return null
514 const done = items.filter((t) => t.status === 'completed').length
515
516 // In progress first, then pending, then completed, keeping order within each
517 const rank = { in_progress: 0, pending: 1, completed: 2 }
518 const sorted = items.map((t, i) => [t, i]).sort((a, b) => (rank[a[0].status] ?? 1) - (rank[b[0].status] ?? 1) || a[1] - b[1]).map(([t]) => t)
519
520 return Box({
521 flexDirection: 'column',
522 children: [
523 Box({ flexDirection: 'row', columnGap: 1, children: [groupTitle(E, 'task list', COLORS.cold), progressBar(E, done, items.length, W, COLORS.cold)] }),
524 ...sorted.map((t) =>
525 t.status === 'completed'
526 ? itemRow(E, 'task-' + t.id, { ch: '☑', color: COLORS.ok }, t.subject, { dim: true, strike: true })
527 : t.status === 'in_progress'
528 ? itemRow(E, 'task-' + t.id, { ch: '▶', color: COLORS.cold }, t.activeForm || t.subject, { color: COLORS.cold, bold: true })
529 : itemRow(E, 'task-' + t.id, { ch: '☐', color: COLORS.cold }, t.subject),
530 ),
531 ],
532 })
533}
534
535const AGENT_MARKS = {
536 pending: { ch: '◌', color: COLORS.agent },
537 running: { ch: '▶', color: COLORS.agent },
538 waiting: { ch: '◌', color: COLORS.warn },
539 idle: { ch: '◌', color: undefined },
540 completed: { ch: '☑', color: COLORS.ok },
541 failed: { ch: '✗', color: COLORS.bad },
542 killed: { ch: '■', color: undefined },
543}
544
545function agentGroup(E, v, W) {
546 const { Box, Text } = E
547 const list = Object.values(v.snap.agents ?? {})
548 if (list.length === 0) return null
549 const running = list.filter((a) => a.status === 'running' || a.status === 'pending').length
550 const tokens = v.snap.tokens.agents ?? {}
551 return Box({
552 flexDirection: 'column',
553 children: [
554 groupTitle(E, 'subagents', COLORS.agent, running ? running + ' running' : 'all finished'),
555 ...list.map((a) => {
556 const t = tokens[a.id]
557 const total = t ? t.input + t.output + t.cacheRead + t.cacheWrite : 0
558 const isLive = a.status === 'running' || a.status === 'pending'
559 return itemRow(E, 'agent-' + a.id, AGENT_MARKS[a.status] ?? AGENT_MARKS.idle, a.type + ' · ' + (a.description || a.id.slice(0, 6)), {
560 color: isLive ? COLORS.agent : undefined,
561 bold: isLive,
562 dim: !isLive,
563 right: total ? Text({ dimColor: true, children: [fmtNum(total)] }) : undefined,
564 })
565 }),
566 ],
567 })
568}
569
570const SWARM_LIVE = ['starting', 'launching', 'pending', 'working', 'running', 'waiting']
571
572function swarmGroup(E, v, W) {
573 const { Box, Text } = E
574 const rows = v.swarm ?? []
575 if (rows.length === 0) return null
576 const working = rows.filter((a) => SWARM_LIVE.includes(a.status)).length
577 return Box({
578 flexDirection: 'column',
579 children: [
580 groupTitle(E, 'swarm', COLORS.swarm, working + ' of ' + rows.length + ' working · /swarm'),
581 ...rows.map((a, i) => {
582 const isLive = SWARM_LIVE.includes(a.status)
583 const mark = a.pending
584 ? { ch: a.pending === 'question' ? '?' : '!', color: a.pending === 'question' ? COLORS.swarm : COLORS.bad }
585 : a.status === 'failed'
586 ? { ch: '✗', color: COLORS.bad }
587 : a.status === 'done'
588 ? { ch: '☑', color: COLORS.ok }
589 : isLive
590 ? { ch: '▶', color: COLORS.swarm }
591 : { ch: '◌', color: undefined }
592 const what = a.pending ? (a.pending === 'question' ? 'has a question' : 'needs approval') : a.activity || a.status
593 return itemRow(E, 'swarm-' + i, mark, a.name + ' · ' + what, {
594 color: a.pending ? mark.color : isLive ? COLORS.swarm : undefined,
595 bold: isLive || !!a.pending,
596 dim: !isLive && !a.pending,
597 right: a.tokens ? Text({ dimColor: true, children: [fmtNum(a.tokens)] }) : undefined,
598 })
599 }),
600 ],
601 })
602}
603
604// ---------------------------------------------------------------------------
605
606function tokenSection(E, v, W) {
607 const { Box, Text } = E
608 const t = v.snap.tokens
609 const agents = Object.values(t.agents)
610 const NUM = 7
611 // The cache columns are wider so "c.read" and "c.write" don't run together
612 const CACHE = 9
613 const NAME = Math.max(8, W - NUM * 2 - CACHE * 2)
614
615 const row = (key, name, r, opts = {}) =>
616 Box({
617 key,
618 flexDirection: 'row',
619 children: [
620 Text({ bold: opts.bold, color: opts.color, wrap: 'truncate-end', children: [fit(name, NAME)] }),
621 Text({ bold: opts.bold, children: [fmtNum(r.input).padStart(NUM)] }),
622 Text({ bold: opts.bold, children: [fmtNum(r.output).padStart(NUM)] }),
623 Text({ bold: opts.bold, dimColor: !opts.bold, children: [fmtNum(r.cacheRead).padStart(CACHE)] }),
624 Text({ bold: opts.bold, dimColor: !opts.bold, children: [fmtNum(r.cacheWrite).padStart(CACHE)] }),
625 ],
626 })
627
628 const sum = (list) =>
629 list.reduce(
630 (a, r) => ({
631 input: a.input + r.input,
632 output: a.output + r.output,
633 cacheRead: a.cacheRead + r.cacheRead,
634 cacheWrite: a.cacheWrite + r.cacheWrite,
635 }),
636 { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
637 )
638
639 const subTotal = sum(agents)
640 const total = sum([t.main, ...agents])
641
642 const headerRow = Box({
643 flexDirection: 'row',
644 children: [
645 Text({ dimColor: true, children: [fit('', NAME)] }),
646 ...['in', 'out'].map((h) => Text({ dimColor: true, children: [h.padStart(NUM)] })),
647 ...['c.read', 'c.write'].map((h) => Text({ dimColor: true, children: [h.padStart(CACHE)] })),
648 ],
649 })
650
651 const agentRows = agents
652 .slice(-6)
653 .map((a) => row('tok-' + a.id, ' ' + a.label, a, {}))
654
655 const usage = v.usage
656 const ctx = usage?.context
657 const pct = ctx?.percent
658 const ctxColor = pct === undefined ? undefined : pct >= 85 ? COLORS.bad : pct >= 60 ? COLORS.warn : COLORS.ok
659 const footer = Box({
660 flexDirection: 'row',
661 columnGap: 1,
662 children: [
663 Text({ dimColor: true, children: ['context'] }),
664 Text({ color: ctxColor, bold: true, children: [pct === undefined ? '—' : pct + '%'] }),
665 Text({ dimColor: true, children: ['of ' + fmtNum(ctx?.window ?? 0)] }),
666 ...(usage?.cost ? [Text({ dimColor: true, children: ['· $' + usage.cost.usd.toFixed(2)] })] : []),
667 ],
668 })
669
670 return Box({
671 flexDirection: 'column',
672 children: [
673 sectionTitle(E, 'TOKENS', Text({ dimColor: true, children: [t.main.requests + agents.reduce((n, a) => n + a.requests, 0) + ' requests'] })),
674 headerRow,
675 row('tok-main', 'main agent', t.main, { color: COLORS.accent }),
676 ...(agents.length ? [row('tok-sub', 'subagents (' + agents.length + ')', subTotal, { color: COLORS.accent })] : []),
677 ...agentRows,
678 row('tok-total', 'total', total, { bold: true }),
679 Text({ children: [' '] }),
680 footer,
681 ],
682 })
683}
684
685// ---------------------------------------------------------------------------
686
687function limitSection(E, v, W) {
688 const { Box, Text } = E
689 const limits = v.usage?.rateLimits ?? []
690 const LABEL = 7
691 const PCT = 5
692 const RESET = 10
693 const BAR = Math.max(4, W - LABEL - PCT - RESET - 2)
694
695 // Only the used part is drawn: no bar means no usage
696 const bar = (key, label, limit, color) => {
697 const pct = limit?.percentUsed ?? 0
698 const cells = pct > 0 ? Math.max(1, Math.min(BAR, Math.round((pct / 100) * BAR))) : 0
699 const resetMs = limit?.resetsAt ? Date.parse(limit.resetsAt) - v.now : NaN
700 return Box({
701 key,
702 flexDirection: 'row',
703 children: [
704 Text({ dimColor: true, children: [fit(label, LABEL)] }),
705 Text({ color, children: [fit('█'.repeat(cells), BAR)] }),
706 Text({ bold: true, color: pct > 0 ? color : undefined, dimColor: pct === 0, children: [(limit ? Math.round(pct) + '%' : '—').padStart(PCT)] }),
707 Text({ dimColor: true, wrap: 'truncate-end', children: [' ' + (resetMs > 0 ? fmtUntil(resetMs) : '').padStart(RESET + 1)] }),
708 ],
709 })
710 }
711
712 const title = sectionTitle(E, 'USAGE LIMITS')
713 if (limits.length === 0) {
714 return Box({
715 flexDirection: 'column',
716 children: [title, Text({ dimColor: true, wrap: 'truncate-end', children: ['No rate-limit readings yet (subscription only).'] })],
717 })
718 }
719
720 return Box({
721 flexDirection: 'column',
722 children: [
723 title,
724 bar('limit-5h', '5-hour', limits.find((l) => l.kind === 'five_hour'), COLORS.hourly),
725 bar('limit-7d', 'weekly', limits.find((l) => l.kind === 'seven_day'), COLORS.ok),
726 ],
727 })
728}
729
730// ---------------------------------------------------------------------------
731
732// Slice order around the pie, clockwise from 12 o'clock, and the ledger's
733const SLICES = [
734 { kind: 'input', label: 'input', color: COLORS.cold },
735 { kind: 'output', label: 'output', color: COLORS.ok },
736 { kind: 'tool', label: 'tool use', color: COLORS.accent },
737 { kind: 'cacheRead', label: 'cache read', color: COLORS.hourly },
738 { kind: 'cacheWrite', label: 'cache write', color: COLORS.hot },
739 { kind: 'retry', label: 'retries', color: COLORS.bad },
740]
741const PIE = 16 // diameter in pixels: 16 columns by 8 rows of half blocks
742const LEDGER = 26
743
744/** Dark text on a light slice, light on a dark one. */
745function inkOn(hex) {
746 const n = parseInt(hex.slice(1), 16)
747 const lum = 0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)
748 return lum > 140 ? '#111111' : '#FFFFFF'
749}
750
751function distSection(E, v, act, W) {
752 const { Box, Text, Button } = E
753 const d = v.snap.dist
754 const ui = v.distUi ?? { showSmall: false, shown: new Set() }
755 const values = SLICES.map((s) => Math.max(0, d[s.kind] ?? 0))
756 const total = values.reduce((a, b) => a + b, 0)
757 const pcts = values.map((x) => (total > 0 ? (x / total) * 100 : 0))
758 const grid = pieGrid(values, PIE)
759
760 // Each slice wide enough for it carries its share inside; the rest are "small"
761 const labels = new Map()
762 const small = new Set()
763 SLICES.forEach((s, i) => {
764 if (!(values[i] > 0)) return
765 const text = fmtPct(pcts[i])
766 const spot = labelSpot(grid, values, i, text.length)
767 if (spot) labels.set(spot.row * PIE + spot.col, { text, color: s.color })
768 else small.add(s.kind)
769 })
770
771 // Two pixels a cell: the top one in the foreground of ▀, the bottom one behind it
772 const pieRows = []
773 for (let row = 0; row < PIE / 2; row++) {
774 const cells = []
775 for (let col = 0; col < PIE; col++) {
776 const label = labels.get(row * PIE + col)
777 if (label) {
778 cells.push({ ch: label.text, color: inkOn(label.color), bg: label.color, bold: true })
779 col += label.text.length - 1
780 continue
781 }
782 const top = SLICES[grid[row * 2][col]]?.color
783 const bottom = SLICES[grid[row * 2 + 1][col]]?.color
784 if (top && bottom) cells.push({ ch: '▀', color: top, bg: top === bottom ? top : bottom })
785 else if (top) cells.push({ ch: '▀', color: top })
786 else if (bottom) cells.push({ ch: '▄', color: bottom })
787 else cells.push({ ch: ' ' })
788 }
789 // Runs of one style become one Text
790 const runs = []
791 for (const c of cells) {
792 const last = runs[runs.length - 1]
793 if (last && last.color === c.color && last.bg === c.bg && last.bold === c.bold) last.ch += c.ch
794 else runs.push({ ...c })
795 }
796 pieRows.push(
797 Text({
798 key: 'pie-' + row,
799 children: runs.map((r) => Text({ color: r.color, backgroundColor: r.bg, bold: r.bold, children: [r.ch] })),
800 }),
801 )
802 }
803
804 const pie = Box({
805 flexDirection: 'column',
806 width: PIE,
807 flexShrink: 0,
808 children: [
809 ...pieRows,
810 ...(small.size
811 ? [
812 Button({
813 key: 'dist-small',
814 label: ui.showSmall ? 'hide small %' : 'show small %',
815 hotkey: 'p',
816 plain: true,
817 dimColor: true,
818 onPress: act.toggleSmallPct,
819 }),
820 ]
821 : []),
822 ],
823 })
824
825 const isStacked = W < PIE + 2 + LEDGER
826 const LW = isStacked ? W : W - PIE - 2
827 const ledger = Box({
828 flexDirection: 'column',
829 width: LW,
830 children: SLICES.map((s, i) => {
831 const showPct = ui.shown.has(s.kind) || (ui.showSmall && small.has(s.kind))
832 return Box({
833 key: 'dist-row-' + s.kind,
834 flexDirection: 'row',
835 width: LW,
836 columnGap: 1,
837 children: [
838 Text({ color: s.color, children: ['██'] }),
839 Button({ key: 'dist-' + s.kind, label: s.label, plain: true, dimColor: values[i] === 0, onPress: () => act.togglePct(s.kind) }),
840 Box({
841 flexGrow: 1,
842 justifyContent: 'flex-end',
843 columnGap: 1,
844 children: [
845 ...(showPct ? [Text({ bold: true, color: s.color, children: [fmtPct(pcts[i])] })] : []),
846 Text({ dimColor: true, children: [fmtNum(values[i]).padStart(6)] }),
847 ],
848 }),
849 ],
850 })
851 }),
852 })
853
854 const turns = d.turns ?? 1
855 return Box({
856 flexDirection: 'column',
857 children: [
858 sectionTitle(
859 E,
860 'TOTAL DISTRIBUTION',
861 Text({ dimColor: true, wrap: 'truncate-end', children: [fmtNum(total) + ' tokens · ' + turns + ' turn' + (turns === 1 ? '' : 's')] }),
862 ),
863 Box({
864 flexDirection: isStacked ? 'column' : 'row',
865 columnGap: 2,
866 rowGap: 1,
867 width: W,
868 children: [pie, ledger],
869 }),
870 Text({ dimColor: true, wrap: 'truncate-end', children: ['tool use and retries are estimated from call sizes'] }),
871 ],
872 })
873}
874types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 'session-dashboard': {
4 // Everything the pane shows, kept so a hot reload doesn't zero the counts
5 snapshot: Record<string, unknown>
6 }
7 }
8}
9