SLOPSHOPPER

session-dashboard

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…

newpanebandguardcommandtoast
v0.2.1MITupdated 2026-10-06netgfx/claude-session-dashboard-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-dashboard
│ ┃ Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ SESSION DASHBOARD │ ┃ ⏺ Read(src/auth.ts) │ ┃ Cache (Cold) ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ TOOL CALLS ERRORS ⎿ Added 2 lines, removed 1 line │ ┃ 9 1 ⏺ Bash(bun test) │ ┃ main 9 · sub 0 tool error 0s ago ⎿ 3 pass, 1 fail │ ┃ e: ▸ show errors │ ┃ ──────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ BACKGROUND 0 running │ ┃ No background commands in this session. ✻ Worked for 42s · done 4:20 PM │ ┃ ──────────────────────────────────────────── │ ┃ LINKED FOLDERS › /session-dashboard │ ┃ None. Press + to link a folder into the work │ ┃ ──────────────────────────────────────────── │ ┃ TASKS │ ┃ │ ┃ ● now done took 0:00 │ ┃ continuing │ ┃ ↳ answered │ ┃ ──────────────────────────────────────────── │ ┃ TOKENS 0 requests │ ┃ in out c.re │ ┃ main agent 0 0 │ ┃ total 0 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dashboard
SESSION DASHBOARD q: hide Cache (Cold) no ─────────────────────────────────────────────────────── TOOL CALLS ERRORS 9 1 main 9 · sub 0 tool error 0s ago e: ▸ show errors ─────────────────────────────────────────────────────── BACKGROUND 0 running No background commands in this session. ─────────────────────────────────────────────────────── LINKED FOLDERS [ + ] None. Press + to link a folder into the workspace. ─────────────────────────────────────────────────────── TASKS ● now done took 0:00 continuing ↳ answered ─────────────────────────────────────────────────────── TOKENS 0 requests in out c.read c.write main agent 0 0 0 0 total 0 0 0 0 context 49% of 200k · $0.42 ─────────────────────────────────────────────────────── USAGE LIMITS 5-hour ██████████ 31% weekly —
README

session-dashboard

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

What it shows

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

Install

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

Settings (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 |

Develop

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.

Source 4 files
hooks/register.js 1011 lines
1// 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}
1011
hooks/lib.js 707 lines
1// 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}
707
hooks/view.js 874 lines
1// 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}
874
types/index.d.ts 9 lines
1declare 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