Developer dashboard pane: what needs you, every Claude session and subagent, usage limits, git and PRs

A developer dashboard for Claude Code. It opens a pane inside your session that answers "what needs me right now?" across every Claude session and subagent on your machine, your usage limits, your git work, and your pull requests.
╭──────────────────────────────────────────────╮
│ ◆ 2 need you · 4 sess · 1 live · 2 agents │
│ 5h ▰▰▰▰▰▱▱▱ 62% 7d ▰▰▱▱▱▱▱▱ 23% │
│ ▁▂▃▅▆▇▆▅ activity · synced 2s ago │
╰──────────────────────────────────────────────╯
▾ Attention (2) ──────────────────────────────
◆ Fix flaky tests · input needed 3m
⟳ Login work may be stuck · npm test failed 3× in a row
▾ Sessions (4) ───────────────────────────────
[WAIT] Fix flaky tests · input needed 3m
▰▰▰▰▰▰▰▱ ctx 85% · $1.25 · Bash: npm test compact soon
▾ Agents (2) ─────────────────────────────────
◐ Research dashboard ideas · working 4m
general-purpose · Claude mods · 23 steps
↳ WebSearch: claude code dashboards
▾ Monitor ────────────────────────────────────
5h ▰▰▰▰▰▱▱▱ 62% out in ~40m at this pace · resets in 1h20
events 🔔 alerts on
14:02 ◆ Fix flaky tests needs input
13:58 ✓ agent finished: Research dashboard ideas
▸ Work in flight main · 3 dirty · +120 −30
▸ PRs & CI 1 mine · 0 to review
| Section | Key | Contents |
|---|---|---|
| Band above the prompt | One line under your conversation while the pane is closed: the most urgent thing that needs you, plus limits, context, running sessions and busy agents. /dash-band off hides it. | |
| Focus card | The Flight Deck look: every section is a card with a rounded border in its colour (you, sessions, agents, limits, PRs). The first thing that needs you grows into a focus card at the top: who, what they are asking, the last tool step, the copy / snooze / dismiss buttons, and the next two in the queue (1 of N). When nothing needs you it turns green and says so, with the latest event. Nothing was removed: the Attention list, every section and every key work as before. | |
| Header | Overall status (green all clear, yellow needs you), session and agent counts, total cost, usage-limit bars, and claude beat: a bold red line graph of how much effort Claude put in at each sync, drawn as one connected line of solid line characters (─ │ ╭ ╮ ╰ ╯; five rows high in a wide dock, four when narrow). Effort is a score: 3 for each running session, 2 for each agent at work, 1 for each session waiting on you, and 0.6 for each tool call this session made since the last sync. The line rises and falls with it, scaled to the busiest sync in view, and runs flat along the bottom when nothing is happening. | |
| Attention | 1 | Sessions waiting on you (and what for), sessions that look stuck, risky commands (rm -rf, force push, reset --hard, …), two sessions editing the same file, a usage limit about to run out, a disk running low, your PRs with failing CI or conflicts, and PRs awaiting your review. |
| Sessions | 2 | Every running Claude Code session on this machine, CLI or desktop, plugin or not: its title, repo and branch, state and for how long, and uptime. Sessions that also load the plugin add context use, cost, their latest step and a test badge (✓ tests pass · npm test · 2m ago or ✗ tests failed) for the last test run they started. |
| Agents | 3 | Every subagent of every running session: description, type, owning session, steps taken, and what it's doing now. Finished and stopped agents stay for 30 minutes. |
| Monitor | 4 | Usage limits (5-hour, 7-day) with a forecast of when you'll run out at the current pace, context bars per session with a per-turn sparkline and prompt-cache hit rate, free disk space, and a live event feed. |
| Work in flight | 5 | Current branch, ahead/behind, uncommitted lines (+/−), stashes, recent and merged branches, and worktrees. |
| PRs & CI | 6 | Your open PRs across all your GitHub repos, with CI, review state, age and conflicts, plus the PRs waiting for your review. |
| Plan | 7 | A progress bar per session that keeps a todo list (TodoWrite): done of total, and the task in progress. A finished plan stays for 10 minutes. Plugin sessions only. |
| Sources | 8 | URLs and searches sessions fetched (WebFetch, WebSearch), newest first, with the session that made them. Plugin sessions only. |
| Files | 9 | Files each session edited this turn, with lines added and removed against HEAD. Plugin sessions only. |
The pane has three tabs along its top edge. Move onto one with the arrow keys or Tab and press Enter.
| Tab | Holds |
|---|---|
| Dashboard | Everything described above: Attention, Sessions, Agents, Monitor, Work in flight, PRs & CI, Plan, Sources and Files. |
| Entertainment | Cards for things you keep for fun. Today: Now playing, the track the Spotify app on this machine is playing (opt-in, off by default), and a Stocks card that says it is not set up (see below). |
| Custom | Empty on purpose, kept for widgets you choose yourself. |
On Entertainment and Custom the large header is replaced by one line, so a session waiting for you still shows. The 1–9 fold keys belong to the Dashboard tab.
Now playing is off until you turn it on, with the turn on button on the card or /dash-spotify on (off or no argument to switch back). Nothing is read while it is off. When on, it uses a local command and no network or login: the Windows media session (the info behind the volume overlay), AppleScript on macOS, playerctl on Linux. It shows playing or paused, is read only while the pane is open and the Entertainment tab is showing, every few seconds at most, and the track name is shown but never stored. Only your on/off choice is remembered.
Stocks needs prices from the internet, and dev-dash makes no network calls beyond git and gh (see CONTRIBUTING.md), so nothing is fetched. A source has to be approved by the project owner before a watchlist can go there.
The pane uses the same keys as the rest of Claude Code, with no letter shortcuts to learn.
| Key | Does |
|---|---|
| ctrl+x tab | Take the keyboard from the prompt to the pane. /dash does the same and opens the pane if it is closed. |
| Esc | Give the keyboard back to the prompt. |
| ↑ ↓ or Tab | Move the highlight to the previous or next row or button. |
| Enter | Press the highlighted button. On a row marker (›), it opens that row's actions. |
| 1–9 | Fold or unfold a section. |
Every row in Attention, Sessions, Agents and PRs starts with a small ›. Move onto it and press Enter and the row's buttons appear under it:
claude --resume <id> on the clipboard for a session or agent, the link for a PR, or a one-line summary for the rest.The buttons at the bottom (refresh, alerts, help, close) are reached the same way. help lists these keys inside the pane.
Snoozes are forgotten when Claude Code restarts. Rows in the Plan, Sources and Files sections have no › yet.
dev-dash shows a short toast, with a soft chime, when something needs you: a session starts waiting, a task that ran over 5 minutes finishes, an agent finishes, CI goes red or back to green, a session looks stuck or runs a risky command, a disk runs low, or this session's context crosses 75%. Everything else only goes to the event feed. Turn alerts off with /dash-alerts off (or a in the pane); the choice is remembered.
The chime plays where Claude Code has an audio player: macOS terminals and the desktop app. Windows and Linux terminals stay silent. Risky commands are only flagged, never blocked.
/dash-summaries on makes each session that runs dev-dash write a short label after every finished turn, e.g. ↳ Fixing the login redirect bug, shown under it in the Sessions section. Each label is one small Haiku call per finished turn through your own Claude Code session (a few hundred input tokens, at most 40 output), so it uses a little of your usage. It's off by default, and /dash-summaries off clears the labels.
What's sent to the model is your last prompt (first 300 characters), the last few tool steps, and the start of Claude's reply (first 500 characters). The label is stored in your session's status file in ~/.claude/dev-dash/sessions/. Sessions that don't run dev-dash have no label.
The plugin is built on Claude Code's mods (function hooks) API, which is in early access and may change between releases.
/plugin marketplace add RanaRauff/claude-dev-dashboard
/plugin install dev-dash@claude-dev-dashboard
Then, in any session:
/dash opens the dashboard with the keyboard on it (or brings the keyboard back to it); it never opens by itself/dash-hide closes it (or press the close button)/dash-alerts on|off turns toasts and the chime on or off/dash-band on|off shows or hides the line above the prompt/dash-handoff on|off writes (or stops writing) a handoff note each time a session compacts (on by default)/dash-beat [line|buddy <name>|list] chooses what the header draws: line (the default) is the red claude beat; a buddy is a small pixel-art character that lives in the beat's own slot (5 rows, 4 in a narrow pane) and acts out what your sessions are doing: it sleeps with rising z's when all is quiet, walks, runs or flies across the slot while a session works, and hops and uses its power (fire breath, thunder, a shadow ball, a water gun, leaves) when something needs you. dev-dash draws the scene itself in half blocks (two square pixels per cell, so the slot is about 30 x 10 pixels); in the terminal it is repainted in place about eight times a second, elsewhere it steps with the pane. A buddy is a pack in ~/.claude/dev-dash/buddy/<name>.json that dev-dash only reads (no program, no network), made with tools/make-buddy.py (Python only): from a hand-drawn .sprite text file (python tools/make-buddy.py tools/ember.sprite builds the example; the format, motions and effects are in the file and the tool's help), which gives the sharpest result at this size, or from animated GIFs with a pixel-art-aware downscale (python tools/make-buddy.py <name> --idle a.gif --working b.gif --motion working=fly --fx working=fire), which works best with small, flat sprites. Packs made by the earlier version (pre-drawn text frames) still play. Sprites are other people's artwork: make packs from art you may use, and do not commit them here./dash-theme [name|list] changes the colours: auto (the default, follows your terminal's own colours), claude, nord, neon, crt (amber), light, hacker (terminal green) and mono (glyphs and weight only). With no name it cycles, and so does the theme button in the footer. The exact-colour themes want a truecolor terminal. The choice is remembered./dash-focus on|off folds the Sessions, Agents, Monitor, Work and PRs cards to one line each while something urgently needs you (on by default); open one with its arrow./dash-icons emoji|nerd|ascii chooses how the icon in the Watching boxes is drawn. emoji (the default, 🐙) works in any font. nerd draws the official GitHub mark, which is not an ordinary character: it needs a Nerd Font set as your terminal's font, and shows as an empty box without one. ascii is plain letters. The choice is remembered./dash-watch pr <number or URL> keeps an eye on a pull request and shows it in a Watching section at the top of the pane, one outlined box each (the GitHub icon, PR #11, its title and small icon marks such as ✔ ready ● CI ✔ approved; boxes sit two across when the pane is wide, and a box turns yellow with a ◆ line when something changed) with where it stands (draft, open, or ready to merge: open, not a draft, no merge conflicts, CI neither failing nor running, no review outstanding), its CI (passing, failing, running, no CI checks) and whether it is approved, not approved or has changes requested, and marks it when any of that changes or it is merged or closed./dash-watch issue <number or URL> does the same for a GitHub issue (open or closed, how many comments, who it is assigned to; it fires on new comments, assignment and label changes, closing and reopening), and /dash-watch run <run id or URL> for a GitHub Actions run (queued, running, passed, failed, cancelled; it fires when it finishes). A GitHub URL is read as what it is, and a bare number is a pull request. Anything else (a GitLab, Jira or Linear link, say) is not followed yet: the command says so, and names what reading it would need (a CLI or an MCP server connected to Claude Code). Nothing is contacted./dash-watch list shows what is watched, /dash-watch clear <number|all> removes them. A bare number means the repository of the session you typed it in/dash-summaries on|off adds a one-line "what is it doing" under each session (off by default, see below)/dash-refresh (or the refresh button) refreshes everything now, PRs includedgit on your PATH. If git or gh is installed but this session can't find it (an app started before it was installed keeps its old PATH until it is restarted), dev-dash tries the standard Windows install folders (%ProgramFiles%\Git\cmd\git.exe, %ProgramFiles%\GitHub CLI\gh.exe) before giving up. Without git the Files section still lists what was edited, just without line counts./dash-watch, the GitHub CLI logged in with gh auth login. Without it, that section shows a hint and the rest still works. If gh is installed but this session can't find it (an app started before it was installed keeps its old PATH until it is restarted), dev-dash tries the standard Windows install folder (%ProgramFiles%\GitHub CLI\gh.exe) before giving up.Mods run with Claude Code's access to your machine, so here is everything this one touches.
Reads
~/.claude/sessions/*.json: Claude Code's list of running sessions. The .key files beside them are never read.~/.claude/projects/<project>/<session>.jsonl, only for sessions that have no title, keeping just the AI-generated title or last-prompt lines.~/.claude/projects/<project>/<session>/subagents/agent-*.jsonl and .meta.json: each subagent's description, type, step count and latest step.~/.claude/dev-dash/sessions/*.json: status files written by other sessions running dev-dash.osascript (macOS) or playerctl (Linux). The track name is shown in the pane and not stored. No network.git in your working directories, tasklist (Windows) or ps to see which sessions are still alive, gh api graphql for your PRs, gh pr view, gh issue view or gh run view for each GitHub item you asked /dash-watch to follow (about once a minute, only while the pane is open, and once when you add one), and once a minute PowerShell Get-PSDrive (Windows) or df -Pk for free disk space.Writes
~/.claude/dev-dash/sessions/<session-id>.json, every 5 seconds: this session's state, its one-line summary (only if summaries are on), cost, context use and its per-turn trend, cache hit rate, latest step, a stuck flag, a risky-command flag (the pattern name, not the command), and the files it edited in the last 30 minutes.npm test or pytest, whether it passed, and when. Never the command line, its arguments or its output.~/.claude/dev-dash/handoffs/<time>-<session>.md, once each time this session's conversation compacts (/compact or automatic): a short note with the session's repo, branch and directory, a claude --resume line, how far its todo list got, its last step, the files it edited recently, and the summary text the compaction kept (cut to 2000 characters). These files stay on your machine. Only the newest 30 are kept: when a new note is written, dev-dash deletes older files in that folder, only files named like its own notes (a date and time, a short session id and .md), one bare file name at a time inside that folder, and nothing else. /dash-handoff off stops writing them. Compactions inside subagents are not written.~/.claude/dev-dash/last-error.txt, only when a refresh fails: the time, where it failed, and the first 1500 characters of the error's stack trace (which can contain local file paths). Only the latest error is kept: the file is overwritten, never appended to./dash-watch list (for each watch: its kind (pull request, issue or run), owner/repo, number, title, its last state in words such as open · CI passing · approved, open · 2 comments · unassigned or running, the small marks drawn from it (an icon and a word each, such as ✔ approved or 👤 ana, so an assignee's login can be among them), and times; at most 10, dropped 24 hours after they were added or last changed). /dash-watch clear all empties it.Network: the gh call to GitHub and, only if you turn summaries on, one small model call per finished turn through your own Claude Code session. Nothing else leaves your machine.
CLAUDE_CONFIG_DIR is honoured if you have moved Claude's config directory. The session registry and transcript formats are internal to Claude Code and may change between releases.
plugins/dev-dash/
├── .claude-plugin/plugin.json manifest
├── hooks/hooks.json points at the hooks module
├── hooks/register.tsx data collection, hooks and commands
├── hooks/render.tsx the pane's drawing
├── hooks/monitor.ts pure monitoring logic (agents, limits, events, stuck, collisions)
├── hooks/*.test.ts(x) tests
├── sounds/chime.wav the alert chime
└── types/index.d.ts state contract
Load your working copy into a session, then check and test it:
claude --plugin-dir ./plugins/dev-dash
claude plugin validate ./plugins/dev-dash
claude plugin test ./plugins/dev-dash
Once the plugin has loaded, Claude Code writes its type declarations to plugins/dev-dash/.claude-plugin/types/ (git-ignored), and tsc -p plugins/dev-dash type-checks it.
Ideas and research: docs/dashboard-ideas.md, docs/x-thread-mods.md and what people are building with mods. See TODO.md for what is planned and who has what, and CONTRIBUTING.md for how to propose changes.
hooks/register.tsx 1413 lines1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4import type {
5 AgentRow,
6 BranchRow,
7 DiskRow,
8 CiState,
9 ChangedFile,
10 EventRow,
11 GitInfo,
12 LimitRow,
13 NowPlaying,
14 PlanProgress,
15 PrInfo,
16 PrRow,
17 SessionRow,
18 SessionState,
19 Snapshot,
20 SourceRow,
21 TestRun,
22 IconStyle,
23 WatchKind,
24 WatchRow,
25 BeatStyle,
26 ThemeId,
27 WorktreeRow,
28} from '../types'
29import {
30 agentStateOf,
31 bytes,
32 cacheHitPct,
33 callKey,
34 changesBetween,
35 crossedSteps,
36 emptySeen,
37 isDiskLow,
38 limitLabel,
39 LONG_TASK_MS,
40 parseDf,
41 parseShortstat,
42 parseWindowsDisks,
43 remember,
44 riskyReason,
45 runwayMs,
46 stepLabel,
47 stuckReason,
48 SUMMARY_SYSTEM,
49 summarizeAgent,
50 summaryPrompt,
51 tidySummary,
52} from './monitor'
53import type { AgentMeta, AgentSummary, CallMark, Sample } from './monitor'
54import { runExe as runExeWith } from './exe'
55import { HANDOFFS_KEPT, handoffName, handoffNote, staleNotes } from './handoff'
56import { isTab, nowPlayingCommand, parseNowPlaying, unavailable } from './entertainment'
57import type { Platform } from './entertainment'
58import { isMuted } from './keys'
59import { DEFAULT_ICON_STYLE, ICON_STYLES, parseIconStyle } from './icons'
60import { addSource, countLines, parseNumstat, planOf, sourceOf, TURN_FILES_KEPT } from './progress'
61import { testRunOf } from './testrun'
62import { pushActivity, registerDashPane } from './render'
63import { NAME_OK, drawScene, getBuddy, getStage, parsePack, setBuddy, toRaster } from './buddy'
64import { THEMES, isTheme, nextTheme, themeByName } from './themes'
65import type { Reading } from './watch'
66import {
67 addWatch,
68 clearWatches,
69 isExpired,
70 ISSUE_FIELDS,
71 parseWatchArgs,
72 pollable,
73 PR_FIELDS,
74 readIssue,
75 readPr,
76 readRun,
77 RUN_FIELDS,
78 stepWatch,
79 WATCH_EVERY_MS,
80 WATCH_HELP,
81 watchName,
82} from './watch'
83
84const PANE = 'dev-dash'
85const BUDDY_MS = 125
86const TICK_MS = 5000
87const PR_EVERY_TICKS = 12
88const STALE_MS = 90_000
89// Same refs as ./render.tsx; defined here because the validator reads each module's atoms from its own source.
90const snap = atom({ plugin: 'dev-dash', key: 'snap' } as const, null)
91const activity = atom({ plugin: 'dev-dash', key: 'activity' } as const, [])
92const paneOpen = atom({ plugin: 'dev-dash', key: 'paneOpen' } as const, false)
93const snoozed = atom({ plugin: 'dev-dash', key: 'snoozed' } as const, {})
94const tab = atom({ plugin: 'dev-dash', key: 'tab' } as const, 'dashboard')
95const dismissed = atom({ plugin: 'dev-dash', key: 'dismissed' } as const, [])
96const blink = atom({ plugin: 'dev-dash', key: 'blink' } as const, 0)
97
98const lines = (s: string) => s.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
99const base = (p: string) => p.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? p
100const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
101const pad = (n: number) => String(n).padStart(2, '0')
102
103const ago = (ms: number) => {
104 const s = Math.max(0, Math.round(ms / 1000))
105 if (s < 60) return `${s}s`
106 if (s < 3600) return `${Math.floor(s / 60)}m`
107 if (s < 86400) return `${Math.floor(s / 3600)}h${pad(Math.floor((s % 3600) / 60))}`
108 return `${Math.floor(s / 86400)}d`
109}
110
111// Your PRs across every repository, not just the one the session sits in, from one
112// GitHub search. The rollup state is the latest commit's combined CI result.
113const PR_QUERY = `query($mine: String!, $review: String!) {
114 mine: search(query: $mine, type: ISSUE, first: 20) { nodes { ...pr } }
115 review: search(query: $review, type: ISSUE, first: 20) { nodes { ...pr } }
116}
117fragment pr on PullRequest {
118 number title url createdAt
119 repository { nameWithOwner }
120 author { login }
121 reviewDecision mergeable
122 commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
123}`
124
125const ciOf = (state: string | undefined | null): CiState => {
126 const s = (state ?? '').toUpperCase()
127 if (!s) return 'none'
128 if (s === 'FAILURE' || s === 'ERROR') return 'failing'
129 if (s === 'PENDING' || s === 'EXPECTED') return 'pending'
130 return 'passing'
131}
132
133type GhPr = {
134 number?: number
135 title?: string
136 url?: string
137 createdAt?: string
138 repository?: { nameWithOwner?: string }
139 author?: { login?: string } | null
140 reviewDecision?: string | null
141 mergeable?: string
142 commits?: { nodes?: Array<{ commit?: { statusCheckRollup?: { state?: string } | null } }> }
143}
144
145const toRow = (p: GhPr): PrRow => ({
146 number: p.number ?? 0,
147 title: p.title ?? '',
148 repo: (p.repository?.nameWithOwner ?? '').split('/').pop() ?? '',
149 url: p.url ?? '',
150 author: p.author?.login ?? '',
151 ageDays: Math.floor((Date.now() - Date.parse(p.createdAt ?? '')) / 86_400_000) || 0,
152 review: (p.reviewDecision || 'NONE').replace('_', ' ').toLowerCase(),
153 ci: ciOf(p.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state),
154 conflicts: p.mergeable === 'CONFLICTING',
155})
156
157const ctx = {
158 selfId: '',
159 dir: '',
160 handoffDir: '',
161 handoffOn: true,
162 registryDir: '',
163 root: '',
164 projectsDir: '',
165 isOpen: false,
166 ticks: 0,
167 prs: null as PrInfo | null,
168 alertsOn: true,
169 seen: emptySeen(),
170 isFirstLook: true,
171 events: [] as EventRow[],
172 limitSamples: new Map<string, Sample[]>(),
173 /** Tool calls this session made since the last claude beat sample. */
174 toolCalls: 0,
175 limits: [] as LimitRow[],
176 lastContextPct: null as number | null,
177 calls: [] as CallMark[],
178 edits: new Map<string, number>(),
179 bandOn: true,
180 summariesOn: false,
181 isSummarizing: false,
182 lastPrompt: '',
183 risky: null as { label: string; at: number } | null,
184 ctxTrend: [] as number[],
185 cacheHit: null as number | null,
186 disks: [] as DiskRow[],
187 lowDisks: new Set<string>(),
188 isWindows: false,
189 platform: 'linux' as Platform,
190 nowPlaying: null as NowPlaying | null,
191 nowPlayingAt: 0,
192 spotifyOn: false,
193 focusOn: true,
194 theme: 'auto' as ThemeId,
195 beatStyle: 'line' as BeatStyle,
196 buddyName: '',
197 plan: null as PlanProgress | null,
198 planAt: 0,
199 sources: [] as SourceRow[],
200 turnEdits: new Map<string, string>(),
201 turnFiles: [] as ChangedFile[],
202 lastTest: null as TestRun | null,
203 watches: [] as WatchRow[],
204 watchPolledAt: 0,
205 iconStyle: DEFAULT_ICON_STYLE as IconStyle,
206}
207
208const RISKY_WINDOW_MS = 10 * 60_000
209const DISK_EVERY_TICKS = 12
210const SOUND = { asset: 'sounds/chime.wav' }
211
212const EVENTS_KEPT = 30
213const EDIT_WINDOW_MS = 30 * 60_000
214const LIMIT_WINDOW_MS = 30 * 60_000
215const AGENT_RECENT_MS = 30 * 60_000
216const normPath = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
217const slugOf = (cwd: string) => cwd.replace(/[^A-Za-z0-9]/g, '-')
218const me = {
219 id: '',
220 name: '',
221 app: '',
222 hasPlugin: true,
223 waitingFor: '',
224 cwd: '',
225 repo: '',
226 branch: '',
227 state: 'idle' as SessionState,
228 stateSince: Date.now(),
229 startedAt: Date.now(),
230 lastTool: '',
231 stuck: '',
232 editing: [] as string[],
233 risky: '',
234 ctxTrend: [] as number[],
235 cacheHitPct: null as number | null,
236 summary: '',
237 plan: null as PlanProgress | null,
238 planAt: 0,
239 sources: [] as SourceRow[],
240 turnFiles: [] as ChangedFile[],
241 lastTest: null as TestRun | null,
242}
243
244// Run `git` or `gh`. The process Claude Code runs in can have an older PATH than the machine: an app that was
245// started before they were installed keeps its old environment until it is restarted. So if the program cannot be
246// started on Windows, try its standard install folder before giving up, and remember whichever worked.
247// What could go wrong: the fallback paths are fixed strings under %ProgramFiles%, the arguments are passed as an
248// array with no shell, and a path that stops working is forgotten and looked up again.
249const WINDOWS_INSTALLS: Record<'git' | 'gh', (programFiles: string) => string[]> = {
250 gh: pf => [`${pf}\\GitHub CLI\\gh.exe`],
251 git: pf => [`${pf}\\Git\\cmd\\git.exe`, `${pf}\\Git\\bin\\git.exe`],
252}
253const exeFound = new Map<string, string>()
254
255// The lookup logic is in exe.ts (it tells a timeout from a program that is not there by how long the call ran, since
256// the API gives no wording for either); this wires it to the engine.
257const runExe = ($: Engine, name: 'git' | 'gh', args: string[], timeoutMs: number) =>
258 runExeWith({
259 run: (argv, timeout) => $.process.run(argv, { timeoutMs: timeout }),
260 name,
261 args,
262 timeoutMs,
263 found: exeFound,
264 fallbacks: async () => {
265 const programFiles = (await $.env.get('OS')) === 'Windows_NT' ? await $.env.get('ProgramFiles') : undefined
266
267 return programFiles ? WINDOWS_INSTALLS[name](programFiles) : []
268 },
269 })
270
271async function git($: Engine, args: string[]) {
272 try {
273 const r = await runExe($, 'git', args, 10_000)
274 return r.exitCode === 0 ? r.stdout : null
275 } catch {
276 return null
277 }
278}
279
280async function heartbeat($: Engine) {
281 if (!ctx.dir || !ctx.selfId) return
282 let costUsd: number | null = null
283 let contextPct: number | null = null
284 const now = Date.now()
285 try {
286 const u = await $.session.usage()
287 costUsd = u.cost?.usd ?? null
288 contextPct = u.context.percent ?? null
289 trackLimits(u.rateLimits, now)
290 } catch {}
291 for (const step of crossedSteps(ctx.lastContextPct, contextPct)) {
292 note($, { at: now, tone: step >= 90 ? 'bad' : 'warn', text: `this session's context is at ${step}%${step >= 75 ? ': /compact soon' : ''}` }, step >= 75)
293 }
294 if (contextPct !== null) ctx.lastContextPct = contextPct
295 for (const [file, at] of ctx.edits) if (now - at > EDIT_WINDOW_MS) ctx.edits.delete(file)
296 me.editing = [...ctx.edits.keys()]
297 me.risky = ctx.risky && now - ctx.risky.at < RISKY_WINDOW_MS ? ctx.risky.label : ''
298 me.ctxTrend = ctx.ctxTrend
299 me.cacheHitPct = ctx.cacheHit
300 me.plan = ctx.plan
301 me.planAt = ctx.planAt
302 me.sources = ctx.sources
303 me.turnFiles = ctx.turnFiles
304 me.lastTest = ctx.lastTest
305 const row: SessionRow = { ...me, costUsd, contextPct, updatedAt: now }
306 await $.fs.write(`${ctx.dir}/${ctx.selfId}.json`, JSON.stringify(row)).catch(() => undefined)
307}
308
309// Claude Code's own record of running sessions: one <pid>.json per process,
310// written whether or not this plugin is loaded. Only *.json is read; the
311// .key files beside them are not ours to touch.
312type RegistryEntry = {
313 pid: number
314 sessionId: string
315 cwd: string
316 startedAt: number
317 name?: string
318 nameSource?: string
319 waitingFor?: string
320 entrypoint?: string
321 status?: string
322 statusUpdatedAt?: number
323 updatedAt?: number
324}
325
326const stateOf = (status: string | undefined): SessionState => {
327 const s = (status ?? '').toLowerCase()
328 if (/wait|permission|input|blocked|ask/.test(s)) return 'waiting'
329 if (/busy|running|working|thinking/.test(s)) return 'running'
330 return 'idle'
331}
332
333const appOf = (entrypoint: string | undefined) =>
334 !entrypoint ? '' : entrypoint === 'claude-desktop' ? 'desktop' : entrypoint === 'cli' ? 'cli' : entrypoint.replace(/^claude-/, '')
335
336const gitCache = new Map<string, { at: number; repo: string; branch: string }>()
337
338// A readable name for sessions whose registry name is only derived from their
339// folder ("2025-2a"): the AI title Claude Code writes into the transcript, else
340// the last prompt. Only those two record types are kept; the rest is dropped.
341const titleCache = new Map<string, { at: number; title: string }>()
342const TITLE_TTL_MS = 60_000
343const READ_LIMIT = 4 * 1024 * 1024 - 64 * 1024
344
345const titleIn = (text: string) => {
346 let title = ''
347 let prompt = ''
348 for (const line of text.split(/\r?\n/)) {
349 if (!line.includes('-title"') && !line.includes('"last-prompt"')) continue
350 try {
351 const d = JSON.parse(line) as { type?: string; aiTitle?: string; customTitle?: string; lastPrompt?: string }
352 if (d.type === 'custom-title' && d.customTitle) title = d.customTitle
353 else if (d.type === 'ai-title' && d.aiTitle) title = d.aiTitle
354 else if (d.type === 'last-prompt' && d.lastPrompt) prompt = d.lastPrompt
355 } catch {}
356 }
357 const tidy = (s: string) => s.replace(/\s+/g, ' ').trim()
358
359 return title ? tidy(title) : prompt ? `“${cut(tidy(prompt), 48)}”` : ''
360}
361
362async function titleOf($: Engine, reg: RegistryEntry): Promise<string> {
363 const hit = titleCache.get(reg.sessionId)
364 if (hit && Date.now() - hit.at < TITLE_TTL_MS) return hit.title
365 const path = `${ctx.projectsDir}/${reg.cwd.replace(/[^A-Za-z0-9]/g, '-')}/${reg.sessionId}.jsonl`
366 let title = ''
367 try {
368 const stat = await $.fs.stat(path)
369 if (stat.size <= READ_LIMIT) {
370 title = titleIn(await $.fs.read(path))
371 } else {
372 for (const argv of [
373 ['grep', '-a', '-F', '-e', '-title"', '-e', '"last-prompt"', path],
374 ['findstr', '/L', 'ai-title custom-title last-prompt', path],
375 ]) {
376 try {
377 const r = await $.process.run(argv, { timeoutMs: 10_000 })
378 if (r.exitCode === 0) {
379 title = titleIn(r.stdout)
380 break
381 }
382 } catch {}
383 }
384 }
385 } catch {}
386 titleCache.set(reg.sessionId, { at: Date.now(), title })
387
388 return title
389}
390
391async function livePids($: Engine): Promise<Set<number> | null> {
392 try {
393 const r = await $.process.run(['tasklist', '/FO', 'CSV', '/NH'], { timeoutMs: 10_000 })
394 if (r.exitCode === 0) {
395 return new Set(lines(r.stdout).map(l => Number(l.split('","')[1])).filter(n => n > 0))
396 }
397 } catch {}
398 try {
399 const r = await $.process.run(['ps', '-A', '-o', 'pid='], { timeoutMs: 10_000 })
400 if (r.exitCode === 0) return new Set(lines(r.stdout).map(Number).filter(n => n > 0))
401 } catch {}
402
403 return null
404}
405
406async function whereIs($: Engine, cwd: string) {
407 const hit = gitCache.get(cwd)
408 if (hit && Date.now() - hit.at < 30_000) return hit
409 const [top, branch] = await Promise.all([
410 git($, ['-C', cwd, 'rev-parse', '--show-toplevel']),
411 git($, ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD']),
412 ])
413 const found = { at: Date.now(), repo: base(top?.trim() || cwd), branch: branch?.trim() || '' }
414 gitCache.set(cwd, found)
415
416 return found
417}
418
419async function readHeartbeats($: Engine): Promise<Map<string, SessionRow>> {
420 const entries = await $.fs.list(ctx.dir).catch(() => [])
421 const now = Date.now()
422 const beats = new Map<string, SessionRow>()
423 for (const f of entries) {
424 if (f.kind !== 'file' || !f.name.endsWith('.json')) continue
425 if (f.mtimeMs > 0 && now - f.mtimeMs > STALE_MS) continue
426 try {
427 const row = JSON.parse(await $.fs.read(`${ctx.dir}/${f.name}`)) as SessionRow
428 if (row.id && row.state !== 'ended' && now - row.updatedAt < STALE_MS) beats.set(row.id, row)
429 } catch {}
430 }
431
432 return beats
433}
434
435async function readSessions($: Engine): Promise<SessionRow[]> {
436 const [entries, alive, beats] = await Promise.all([
437 $.fs.list(ctx.registryDir).catch(() => []),
438 livePids($),
439 readHeartbeats($),
440 ])
441 const rows: SessionRow[] = []
442 const seen = new Set<string>()
443 for (const f of entries) {
444 if (f.kind !== 'file' || !f.name.endsWith('.json')) continue
445 let reg: RegistryEntry
446 try {
447 reg = JSON.parse(await $.fs.read(`${ctx.registryDir}/${f.name}`)) as RegistryEntry
448 } catch {
449 continue
450 }
451 if (!reg.sessionId || seen.has(reg.sessionId)) continue
452 if (alive && !alive.has(reg.pid)) continue
453 seen.add(reg.sessionId)
454 const beat = beats.get(reg.sessionId)
455 const where = beat?.branch ? { repo: beat.repo, branch: beat.branch } : await whereIs($, reg.cwd)
456 const isDerived = !reg.name || reg.nameSource === 'derived'
457 const name = (isDerived ? await titleOf($, reg) : '') || reg.name || ''
458 rows.push({
459 id: reg.sessionId,
460 name,
461 waitingFor: reg.status === 'waiting' || beat?.state === 'waiting' ? (reg.waitingFor ?? '') : '',
462 app: appOf(reg.entrypoint),
463 hasPlugin: beat !== undefined,
464 cwd: reg.cwd,
465 repo: where.repo,
466 branch: where.branch,
467 state: beat ? beat.state : stateOf(reg.status),
468 stateSince: beat?.stateSince ?? reg.statusUpdatedAt ?? reg.startedAt,
469 startedAt: reg.startedAt,
470 lastTool: beat?.lastTool ?? '',
471 costUsd: beat?.costUsd ?? null,
472 contextPct: beat?.contextPct ?? null,
473 updatedAt: beat?.updatedAt ?? reg.updatedAt ?? reg.startedAt,
474 stuck: beat?.stuck ?? '',
475 editing: beat?.editing ?? [],
476 risky: beat?.risky ?? '',
477 ctxTrend: beat?.ctxTrend ?? [],
478 cacheHitPct: beat?.cacheHitPct ?? null,
479 summary: beat?.summary ?? '',
480 plan: beat?.plan ?? null,
481 planAt: beat?.planAt ?? 0,
482 sources: beat?.sources ?? [],
483 turnFiles: beat?.turnFiles ?? [],
484 lastTest: beat?.lastTest ?? null,
485 })
486 }
487 for (const beat of beats.values()) {
488 if (!seen.has(beat.id)) rows.push(beat)
489 }
490 const order: Record<SessionState, number> = { waiting: 0, running: 1, idle: 2, ended: 3 }
491
492 return rows.sort((a, b) => order[a.state] - order[b.state] || b.startedAt - a.startedAt)
493}
494
495// ---------------------------------------------------------------------------
496// Monitoring: limits, events and alerts
497// ---------------------------------------------------------------------------
498function trackLimits(readings: ReadonlyArray<{ kind: string; percentUsed: number; resetsAt?: string }>, now: number) {
499 ctx.limits = readings.map(r => {
500 const kept = [...(ctx.limitSamples.get(r.kind) ?? []), { at: now, pct: r.percentUsed }].filter(s => now - s.at <= LIMIT_WINDOW_MS)
501 // A reset drops the percentage: start the pace over from there.
502 const resetAt = kept.findLastIndex((s, i) => i > 0 && s.pct < kept[i - 1].pct)
503 const fromReset = resetAt > 0 ? kept.slice(resetAt) : kept
504 ctx.limitSamples.set(r.kind, fromReset)
505 const resetsAt = r.resetsAt ? Date.parse(r.resetsAt) : NaN
506 return { kind: limitLabel(r.kind), pct: r.percentUsed, resetsAt: Number.isFinite(resetsAt) ? resetsAt : null, etaMs: runwayMs(fromReset) }
507 })
508}
509
510function note($: Engine, event: EventRow, isAlert: boolean) {
511 ctx.events = [event, ...ctx.events].slice(0, EVENTS_KEPT)
512 if (isAlert && ctx.alertsOn) {
513 $.ui.toast(event.text, { timeoutMs: 6000 })
514 // Plays where the host has a player (macOS terminals, the desktop app); silent elsewhere.
515 void $.audio.play(SOUND).catch(() => undefined)
516 }
517}
518
519// ---------------------------------------------------------------------------
520// Disk space, once a minute
521// ---------------------------------------------------------------------------
522async function readDisks($: Engine) {
523 try {
524 const r = ctx.isWindows
525 ? await $.process.run(
526 [
527 'powershell',
528 '-NoProfile',
529 '-Command',
530 'Get-PSDrive -PSProvider FileSystem | ForEach-Object { "$($_.Name) $([int64]$_.Free) $([int64]$_.Used)" }',
531 ],
532 { timeoutMs: 15_000 },
533 )
534 : await $.process.run(['df', '-Pk'], { timeoutMs: 10_000 })
535 if (r.exitCode !== 0) return
536 ctx.disks = ctx.isWindows ? parseWindowsDisks(r.stdout) : parseDf(r.stdout)
537 } catch {
538 return
539 }
540 const now = Date.now()
541 for (const d of ctx.disks) {
542 const isLow = isDiskLow(d)
543 if (isLow && !ctx.lowDisks.has(d.name)) {
544 note($, { at: now, tone: 'bad', text: `disk ${d.name} is low: ${bytes(d.freeBytes)} free` }, true)
545 }
546 if (isLow) ctx.lowDisks.add(d.name)
547 else ctx.lowDisks.delete(d.name)
548 }
549}
550
551// ---------------------------------------------------------------------------
552// Agents: every live session's subagents, from the transcripts Claude Code keeps
553// (<projects>/<slug>/<session>/subagents/agent-<id>.jsonl and .meta.json).
554// ---------------------------------------------------------------------------
555const agentCache = new Map<string, { stamp: string; sum: AgentSummary; meta: AgentMeta }>()
556
557async function readAgents($: Engine, sessions: readonly SessionRow[]): Promise<AgentRow[]> {
558 const now = Date.now()
559 const rows: AgentRow[] = []
560 for (const s of sessions) {
561 const dir = `${ctx.projectsDir}/${slugOf(s.cwd)}/${s.id}/subagents`
562 const entries = await $.fs.list(dir).catch(() => [])
563 const byName = new Map(entries.map(f => [f.name, f] as const))
564 for (const f of entries) {
565 const m = /^agent-(.+)\.jsonl$/.exec(f.name)
566 if (!m || f.kind !== 'file') continue
567 const id = m[1]
568 const metaFile = byName.get(`agent-${id}.meta.json`)
569 const lastActive = Math.max(f.mtimeMs, metaFile?.mtimeMs ?? 0)
570 const stamp = `${f.mtimeMs}:${f.size}:${metaFile?.mtimeMs ?? 0}`
571 let hit = agentCache.get(`${s.id}/${id}`)
572 if (!hit || hit.stamp !== stamp) {
573 let meta: AgentMeta = {}
574 let sum: AgentSummary = { startedAt: null, steps: 0, doing: '', isFinished: false }
575 try {
576 if (metaFile) meta = JSON.parse(await $.fs.read(`${dir}/${metaFile.name}`)) as AgentMeta
577 } catch {}
578 try {
579 if (f.size <= READ_LIMIT) sum = summarizeAgent(await $.fs.read(`${dir}/${f.name}`))
580 } catch {}
581 hit = { stamp, sum, meta }
582 agentCache.set(`${s.id}/${id}`, hit)
583 }
584 const state = agentStateOf(hit.meta, hit.sum, lastActive, now, true)
585 if ((state === 'done' || state === 'stopped') && now - lastActive > AGENT_RECENT_MS) continue
586 rows.push({
587 id,
588 sessionId: s.id,
589 sessionName: s.name || s.repo,
590 type: hit.meta.agentType ?? 'agent',
591 description: hit.meta.description || 'subagent',
592 state,
593 isBackground: hit.meta.requestShape === 'background',
594 startedAt: hit.sum.startedAt ?? lastActive,
595 lastActive,
596 doing: hit.sum.doing,
597 steps: hit.sum.steps,
598 })
599 }
600 }
601 const order = { working: 0, quiet: 1, done: 2, stopped: 3 } as const
602
603 return rows.sort((a, b) => order[a.state] - order[b.state] || b.lastActive - a.lastActive)
604}
605
606async function readGit($: Engine): Promise<GitInfo | null> {
607 const branch = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD']))?.trim()
608 if (!branch) return null
609 const [status, counts, stash, head, refs, wt, diff] = await Promise.all([
610 git($, ['status', '--porcelain']),
611 git($, ['rev-list', '--left-right', '--count', '@{u}...HEAD']),
612 git($, ['stash', 'list']),
613 git($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']),
614 git($, ['for-each-ref', '--sort=-committerdate', '--count=8', '--format=%(refname:short)|%(committerdate:relative)', 'refs/heads']),
615 git($, ['worktree', 'list', '--porcelain']),
616 git($, ['diff', '--shortstat', 'HEAD']),
617 ])
618 const [behind, ahead] = counts ? counts.trim().split(/\s+/).map(Number) : [0, 0]
619 const baseRef = head?.trim().replace(/^origin\//, '') || 'main'
620 const merged = new Set(lines((await git($, ['branch', '--merged', baseRef, '--format=%(refname:short)'])) ?? ''))
621 const branches: BranchRow[] = lines(refs ?? '').map(l => {
622 const [name, age] = l.split('|')
623 return { name, age: (age ?? '').replace(' ago', ''), isMerged: merged.has(name) && name !== baseRef && name !== branch }
624 })
625 const worktrees: WorktreeRow[] = []
626 for (const l of lines(wt ?? '')) {
627 if (l.startsWith('worktree ')) worktrees.push({ path: l.slice(9), branch: '(detached)' })
628 else if (l.startsWith('branch ') && worktrees.length > 0) {
629 worktrees[worktrees.length - 1].branch = l.slice(7).replace('refs/heads/', '')
630 }
631 }
632
633 return {
634 branch,
635 upstream: counts !== null,
636 ahead: ahead || 0,
637 behind: behind || 0,
638 dirty: lines(status ?? '').length,
639 stashes: lines(stash ?? '').length,
640 base: baseRef,
641 branches,
642 worktrees,
643 ...parseShortstat(diff ?? ''),
644 }
645}
646
647const runGh = ($: Engine, args: string[], timeoutMs: number) => runExe($, 'gh', args, timeoutMs)
648
649async function readPrs($: Engine): Promise<PrInfo> {
650 try {
651 const r = await runGh(
652 $,
653 [
654 'api',
655 'graphql',
656 '-f',
657 `query=${PR_QUERY}`,
658 '-f',
659 'mine=is:pr is:open author:@me archived:false',
660 '-f',
661 'review=is:pr is:open review-requested:@me archived:false',
662 ],
663 20_000,
664 )
665 if (r.exitCode !== 0) throw new Error(lines(r.stderr)[0] ?? 'gh failed')
666 const data = (JSON.parse(r.stdout) as { data?: { mine?: { nodes?: GhPr[] }; review?: { nodes?: GhPr[] } } }).data
667 const keep = (nodes: GhPr[] | undefined) => (nodes ?? []).filter(p => typeof p.number === 'number').map(toRow)
668 return {
669 error: null,
670 mine: keep(data?.mine?.nodes),
671 toReview: keep(data?.review?.nodes).sort((a, b) => b.ageDays - a.ageDays),
672 fetchedAt: Date.now(),
673 }
674 } catch (err) {
675 const msg = String((err as Error)?.message ?? err)
676 const error = /ENOENT|not found|cannot start|not recognized/i.test(msg)
677 ? 'gh CLI not found: install it, then run gh auth login'
678 : cut(msg, 70)
679 return { error, mine: [], toReview: [], fetchedAt: Date.now() }
680 }
681}
682
683// One small model call after a finished turn; the label is written to this session's status file.
684async function summarize($: Engine, answer: string) {
685 if (!ctx.summariesOn || ctx.isSummarizing) return
686 ctx.isSummarizing = true
687 try {
688 const r = await $.model.complete({
689 model: 'haiku',
690 system: SUMMARY_SYSTEM,
691 prompt: summaryPrompt(ctx.lastPrompt, answer, ctx.calls.map(c => c.label)),
692 maxTokens: 40,
693 timeoutMs: 20_000,
694 })
695 if (r.isAnswered) {
696 me.summary = tidySummary(r.text)
697 await heartbeat($)
698 await publish($, undefined)
699 }
700 } catch {
701 // A failed label is not worth an alert; the next turn tries again.
702 } finally {
703 ctx.isSummarizing = false
704 }
705}
706
707// A refresh that fails must never stop a command from answering or a hook from finishing. The error is
708// written to ~/.claude/dev-dash/last-error.txt (and only the latest), so a failure can be read afterwards.
709async function logError($: Engine, where: string, err: unknown) {
710 try {
711 if (!ctx.root) return
712 const detail = String((err as Error)?.stack ?? err).slice(0, 1500)
713 await $.fs.write(`${ctx.root}/last-error.txt`, `${new Date().toISOString()} ${where}\n${detail}\n`)
714 } catch {}
715}
716
717async function publishNow($: Engine, gitInfo: GitInfo | null | undefined, sample = false) {
718 const sessions = await readSessions($)
719 const agents = await readAgents($, sessions)
720 if (sample) {
721 const calls = ctx.toolCalls
722 ctx.toolCalls = 0
723 await update($, activity, h => pushActivity(h, sessions, agents, calls))
724 }
725 const now = Date.now()
726 const mine = ctx.prs && !ctx.prs.error ? ctx.prs.mine : null
727 const muting = { snoozed: (await read($, snoozed)) ?? {}, dismissed: (await read($, dismissed)) ?? [], now }
728 for (const c of changesBetween(ctx.seen, sessions, agents, mine, ctx.selfId, now, ctx.isFirstLook)) {
729 // A row you snoozed or dismissed in the pane stays in the feed but stops making noise.
730 const isMutedRow = c.itemId !== undefined && isMuted(muting, c.itemId)
731 note($, { at: c.at, tone: c.tone, text: c.text }, c.isAlert && !isMutedRow)
732 }
733 ctx.seen = remember(sessions, agents, mine)
734 ctx.isFirstLook = false
735 await update($, snap, s => {
736 const next: Snapshot = {
737 selfId: ctx.selfId,
738 sessions,
739 agents,
740 limits: ctx.limits,
741 events: ctx.events,
742 alertsOn: ctx.alertsOn,
743 bandOn: ctx.bandOn,
744 summariesOn: ctx.summariesOn,
745 paneOpen: ctx.isOpen,
746 disks: ctx.disks,
747 nowPlaying: ctx.nowPlaying,
748 spotifyOn: ctx.spotifyOn,
749 focusOn: ctx.focusOn,
750 theme: ctx.theme,
751 beatStyle: ctx.beatStyle,
752 buddyName: ctx.buddyName,
753 git: gitInfo === undefined ? (s?.git ?? null) : gitInfo,
754 prs: ctx.prs,
755 watches: ctx.watches,
756 iconStyle: ctx.iconStyle,
757 updatedAt: Date.now(),
758 }
759 return next
760 })
761}
762
763async function publish($: Engine, gitInfo: GitInfo | null | undefined, sample = false) {
764 try {
765 await publishNow($, gitInfo, sample)
766 } catch (err) {
767 await logError($, 'publish', err)
768 }
769}
770
771async function setState($: Engine, state: SessionState) {
772 if (me.state === state) return
773 me.state = state
774 me.stateSince = Date.now()
775 await heartbeat($)
776 await publish($, undefined)
777}
778
779// Files this turn edited, with lines added/removed against HEAD. A file git has no diff for
780// but lists as untracked is new: all of its lines count as added.
781async function readTurnFiles($: Engine): Promise<ChangedFile[]> {
782 const files: ChangedFile[] = []
783 for (const file of [...ctx.turnEdits.values()].slice(-TURN_FILES_KEPT)) {
784 const out = await git($, ['diff', '--numstat', 'HEAD', '--', file])
785 // git could not answer (not found, not a repository, or the file is outside one): the file was still edited
786 // this turn, so it is listed, with its line counts marked as unknown rather than left out.
787 if (out === null) {
788 files.push({ path: file, added: 0, removed: 0, counted: false })
789 continue
790 }
791 const diff = parseNumstat(out)[0]
792 if (diff) {
793 files.push({ path: file, added: diff.added, removed: diff.removed })
794 continue
795 }
796 const isNew = ((await git($, ['ls-files', '--others', '--exclude-standard', '--', file])) ?? '').trim() !== ''
797 if (!isNew) continue
798 try {
799 files.push({ path: file, added: countLines(await $.fs.read(file)), removed: 0 })
800 } catch {}
801 }
802
803 return files
804}
805
806// Keep only the newest HANDOFFS_KEPT notes. There is no delete in the file API, so this asks the OS;
807// staleNotes only ever names files that look exactly like the ones we write.
808async function pruneHandoffs($: Engine) {
809 const entries = await $.fs.list(ctx.handoffDir).catch(() => [])
810 for (const name of staleNotes(entries.filter(f => f.kind === 'file').map(f => f.name))) {
811 // Run in the notes folder and pass only the bare name (it has matched NOTE_NAME: no spaces, separators,
812 // wildcards or leading dash). Passing a full path through cmd's re-parsing is what could split on a
813 // home directory with a space.
814 await $.process
815 .run(ctx.isWindows ? ['cmd', '/c', 'del', '/q', name] : ['rm', '-f', name], { cwd: ctx.handoffDir, timeoutMs: 10_000 })
816 .catch(() => undefined)
817 }
818}
819
820// ---------------------------------------------------------------------------
821// /dash-watch: pull requests the person asked to keep an eye on
822// ---------------------------------------------------------------------------
823const isWatchRow = (v: unknown): v is WatchRow =>
824 !!v && typeof v === 'object' && typeof (v as WatchRow).id === 'string' && typeof (v as WatchRow).number === 'number' && typeof (v as WatchRow).repo === 'string'
825
826// The list lives in the plugin store, so every session sees the same one; each cycle starts from it.
827async function loadWatches($: Engine): Promise<WatchRow[]> {
828 const v = await $.store.get('watches').catch(() => undefined)
829
830 return Array.isArray(v) ? v.filter(isWatchRow) : []
831}
832
833// How each kind is read: the gh subcommand, the fields asked for, and the function that turns the answer into a reading.
834const GH_READ: Record<WatchKind, { args: string[]; fields: string; read: (json: unknown) => Reading | null }> = {
835 pr: { args: ['pr', 'view'], fields: PR_FIELDS, read: readPr },
836 issue: { args: ['issue', 'view'], fields: ISSUE_FIELDS, read: readIssue },
837 run: { args: ['run', 'view'], fields: RUN_FIELDS, read: readRun },
838}
839
840async function readWatch($: Engine, w: WatchRow) {
841 const g = GH_READ[w.kind] ?? GH_READ.pr
842 try {
843 const r = await runGh($, [...g.args, String(w.number), '--repo', w.repo, '--json', g.fields], 20_000)
844 return r.exitCode === 0 ? g.read(JSON.parse(r.stdout)) : null
845 } catch {
846 return null
847 }
848}
849
850// One look at every live watch: no faster than WATCH_EVERY_MS unless forced (adding a watch takes its baseline).
851// A change after the baseline fires the watch: a line in the event feed and a mark in the section. Never a toast.
852async function pollWatches($: Engine, force: boolean) {
853 const now = Date.now()
854 if (!force && now - ctx.watchPolledAt < WATCH_EVERY_MS) return
855 ctx.watchPolledAt = now
856 let list = (await loadWatches($)).filter(w => !isExpired(w, now))
857 for (const w of pollable(list, now)) {
858 const r = await readWatch($, w)
859 if (!r) continue
860 const next = stepWatch(w, r, now)
861 if (next.firedAt > w.firedAt) {
862 const tone = /failing|failed|conflict/.test(next.fired) ? 'bad' : /merged|passed|ready/.test(next.fired) ? 'ok' : 'info'
863 note($, { at: now, tone, text: `watch ${watchName(w)} ${w.repo}: ${next.fired}` }, false)
864 }
865 list = list.map(x => (x.id === w.id ? next : x))
866 }
867 ctx.watches = list
868 await $.store.set('watches', list).catch(() => undefined)
869}
870
871async function tick($: Engine, withPrs: boolean) {
872 try {
873 await tickNow($, withPrs)
874 } catch (err) {
875 await logError($, 'tick', err)
876 }
877}
878
879// What Spotify on this machine is playing, read with a local command. Only while the pane is open and the
880// Entertainment tab is the one showing, and no more often than every NOW_PLAYING_EVERY_MS. On Windows each read starts
881// a PowerShell, so it is not made more often than a track name needs. Returns whether the reading changed.
882const NOW_PLAYING_EVERY_MS = 10_000
883
884async function readNowPlaying($: Engine) {
885 const now = Date.now()
886 if (now - ctx.nowPlayingAt < NOW_PLAYING_EVERY_MS) return false
887 ctx.nowPlayingAt = now
888 const before = `|`
889 try {
890 const r = await $.process.run(nowPlayingCommand(ctx.platform), { timeoutMs: 8000 })
891 ctx.nowPlaying = parseNowPlaying(ctx.platform, r.stdout, r.exitCode, now)
892 } catch {
893 ctx.nowPlaying = unavailable(now)
894 }
895
896 return `|` !== before
897}
898
899async function tickNow($: Engine, withPrs: boolean) {
900 const g = await readGit($)
901 if (g) me.branch = g.branch
902 // Only while a turn runs: turn.complete already took the final numbers, and an idle session
903 // should not keep spawning git for files it edited minutes ago.
904 if (me.state === 'running' && ctx.turnEdits.size > 0) ctx.turnFiles = await readTurnFiles($)
905 await heartbeat($)
906 if (withPrs) ctx.prs = await readPrs($)
907 const showing = ctx.isOpen ? await read($, tab) : null
908 // The list is read from the store inside, so a watch another session added is picked up too.
909 if (ctx.isOpen) await pollWatches($, false)
910 if (ctx.ticks % DISK_EVERY_TICKS === 0) await readDisks($)
911 await publish($, g, true)
912 // Spotify last, and not waited for: a slow read (up to its 8 s limit) must not hold up the PR and watch polling or this
913 // tick's publish. If the track changed, it is published once the read is back.
914 if (ctx.spotifyOn && isTab(showing) && showing === 'entertainment') {
915 void readNowPlaying($)
916 .then(changed => (changed ? publish($, g) : undefined))
917 .catch(() => undefined)
918 }
919}
920
921// What `a`, `r` and `q` do. The typed commands and the footer keys run the same code. (A plugin cannot run its
922// own slash commands through $.command.run, which skips the calling plugin's hooks, so the keys call these.)
923// Spotify is opt-in. Off means nothing is read at all: no command runs, and the card says how to turn it on.
924async function setSpotify($: Engine, arg: string) {
925 const a = arg.trim().toLowerCase()
926 ctx.spotifyOn = a === 'on' ? true : a === 'off' ? false : !ctx.spotifyOn
927 await $.store.set('spotifyOn', ctx.spotifyOn).catch(() => undefined)
928 ctx.nowPlaying = null
929 ctx.nowPlayingAt = 0
930 if (ctx.spotifyOn && ctx.isOpen && (await read($, tab)) === 'entertainment') await readNowPlaying($)
931 await publish($, undefined)
932
933 return ctx.spotifyOn
934 ? 'Spotify is on: the Entertainment tab shows what the Spotify app on this machine is playing. Turn it off with /dash-spotify off.'
935 : 'Spotify is off. Nothing is read.'
936}
937
938// Load the buddy pack the header is set to use (a JSON file made by tools/make-buddy.py). Null when it is not a pack.
939async function loadBuddy($: Engine) {
940 setBuddy(null)
941 if (ctx.beatStyle !== 'buddy' || !NAME_OK.test(ctx.buddyName)) return false
942 const text = await $.fs.read(`${ctx.root}/buddy/${ctx.buddyName}.json`).catch(() => '')
943 const pack = text ? parsePack(text) : null
944 setBuddy(pack)
945
946 return pack !== null
947}
948
949// /dash-beat: `line` (the default red line), `buddy [name]`, a bare name, `list`, or nothing to switch between them.
950async function setBeat($: Engine, arg: string) {
951 const [word = '', second = ''] = arg.trim().split(/\s+/)
952 const w = word.toLowerCase()
953 if (w === 'list') {
954 const found = (await $.fs.list(`${ctx.root}/buddy`).catch(() => []))
955 .filter(f => f.kind === 'file' && f.name.endsWith('.json'))
956 .map(f => f.name.slice(0, -5))
957 .filter(n => NAME_OK.test(n))
958
959 return found.length
960 ? `Buddies: ${found.join(', ')}. Use /dash-beat buddy <name>, or /dash-beat line for the red line.`
961 : 'No buddies yet. Make one with tools/make-buddy.py from a .sprite file or animated GIFs (see the README), then /dash-beat list.'
962 }
963 if (w === 'line' || (w === '' && ctx.beatStyle === 'buddy')) {
964 ctx.beatStyle = 'line'
965 setBuddy(null)
966 await $.store.set('beatStyle', 'line').catch(() => undefined)
967 await publish($, undefined)
968
969 return 'The header shows the claude beat line.'
970 }
971 const name = w === '' || w === 'buddy' ? second || ctx.buddyName : w
972 if (!name) return 'Which buddy? /dash-beat list shows the ones you have.'
973 if (!NAME_OK.test(name)) return 'A buddy name is letters, digits, - and _ only.'
974 const was = { style: ctx.beatStyle, name: ctx.buddyName }
975 ctx.beatStyle = 'buddy'
976 ctx.buddyName = name
977 if (!(await loadBuddy($))) {
978 ctx.beatStyle = was.style
979 ctx.buddyName = was.name
980 await loadBuddy($)
981
982 return `No buddy called "${name}" (or its file is not a pack this version can play). /dash-beat list shows the ones you have.`
983 }
984 await $.store.set('beatStyle', 'buddy').catch(() => undefined)
985 await $.store.set('buddyName', name).catch(() => undefined)
986 await publish($, undefined)
987
988 return `The header shows ${name}. /dash-beat line goes back to the red line.`
989}
990
991// /dash-theme: no argument or `next` cycles, `list` names them, a name picks one. The footer button cycles.
992async function setTheme($: Engine, arg: string) {
993 const a = arg.trim().toLowerCase()
994 if (a === 'list') return `Themes: ${THEMES.map(t => `${t.id} (${t.blurb})`).join(' · ')}. Now: ${ctx.theme}.`
995 const picked = a && a !== 'next' ? themeByName(a) : undefined
996 if (a && a !== 'next' && !picked) return `No theme called "${arg.trim()}". Try /dash-theme list.`
997 ctx.theme = picked ? picked.id : nextTheme(ctx.theme)
998 await $.store.set('theme', ctx.theme).catch(() => undefined)
999 await publish($, undefined)
1000 const t = themeByName(ctx.theme)
1001
1002 return `Theme: ${t?.label ?? ctx.theme}. ${t?.blurb ?? ''}${ctx.theme === 'auto' ? '' : ' (The exact colours want a truecolor terminal.)'}`
1003}
1004
1005// The focus fold is on by default: while something urgently needs you, the other cards fold to one line each.
1006async function setFocus($: Engine, arg: string) {
1007 const a = arg.trim().toLowerCase()
1008 ctx.focusOn = a === 'on' ? true : a === 'off' ? false : !ctx.focusOn
1009 await $.store.set('focusOn', ctx.focusOn).catch(() => undefined)
1010 await publish($, undefined)
1011
1012 return ctx.focusOn
1013 ? 'Focus is on: while something needs you, the other cards fold to one line each. Open one with its arrow.'
1014 : 'Focus is off: every card stays as you left it.'
1015}
1016
1017async function setAlerts($: Engine, arg: string) {
1018 const a = arg.trim().toLowerCase()
1019 ctx.alertsOn = a === 'on' ? true : a === 'off' ? false : !ctx.alertsOn
1020 await $.store.set('alertsOn', ctx.alertsOn).catch(() => undefined)
1021 await publish($, undefined)
1022
1023 return `Dashboard alerts ${ctx.alertsOn ? 'on' : 'off'}.`
1024}
1025
1026async function refreshNow($: Engine) {
1027 titleCache.clear()
1028 gitCache.clear()
1029 await tick($, true)
1030
1031 return `Dashboard refreshed${ctx.prs?.error ? `; PRs: ${ctx.prs.error}` : ''}.`
1032}
1033
1034async function closePane($: Engine) {
1035 ctx.isOpen = false
1036 await update($, paneOpen, () => false)
1037 await $.ui.close({ id: PANE })
1038 await publish($, undefined)
1039}
1040
1041export const register: Register = on => {
1042 on('session.start', async ($, e, next) => {
1043 const ran = await next(e)
1044 ctx.selfId = await $.session.id()
1045 const home = ((await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '').replace(/\\/g, '/')
1046 const config = ((await $.env.get('CLAUDE_CONFIG_DIR')) || `${home}/.claude`).replace(/\\/g, '/')
1047 ctx.root = `${config}/dev-dash`
1048 ctx.dir = `${config}/dev-dash/sessions`
1049 ctx.handoffDir = `${config}/dev-dash/handoffs`
1050 ctx.registryDir = `${config}/sessions`
1051 ctx.projectsDir = `${config}/projects`
1052 me.id = ctx.selfId
1053 me.cwd = await $.session.cwd()
1054 const top = (await git($, ['rev-parse', '--show-toplevel']))?.trim()
1055 me.repo = base(top || me.cwd)
1056 try {
1057 me.startedAt = (await $.session.usage()).startedAt
1058 } catch {}
1059
1060 await $.command.register({ name: 'dash', description: 'Open the developer dashboard pane' })
1061 await $.command.register({ name: 'dash-hide', description: 'Hide the developer dashboard pane' })
1062 await $.command.register({ name: 'dash-alerts', description: 'Turn dashboard toasts on or off (on | off, or toggle)' })
1063 await $.command.register({ name: 'dash-band', description: 'Show or hide the dev-dash line above the prompt (on | off, or toggle)' })
1064 const stored = await $.store.get('alertsOn').catch(() => undefined)
1065 if (typeof stored === 'boolean') ctx.alertsOn = stored
1066 const storedBand = await $.store.get('bandOn').catch(() => undefined)
1067 if (typeof storedBand === 'boolean') ctx.bandOn = storedBand
1068 await $.command.register({ name: 'dash-beat', description: 'What the header draws: line (the claude beat, default) | buddy <name> (an animated character) | list' })
1069 const storedBeat = await $.store.get('beatStyle').catch(() => undefined)
1070 const storedBuddy = await $.store.get('buddyName').catch(() => undefined)
1071 if (typeof storedBuddy === 'string' && NAME_OK.test(storedBuddy)) ctx.buddyName = storedBuddy
1072 if (storedBeat === 'buddy' || storedBeat === 'line') ctx.beatStyle = storedBeat
1073 await loadBuddy($)
1074 await $.command.register({ name: 'dash-theme', description: 'Colour theme: auto (default) | claude | nord | neon | crt | light | mono, or no name to cycle; list shows them' })
1075 const storedTheme = await $.store.get('theme').catch(() => undefined)
1076 if (isTheme(storedTheme)) ctx.theme = storedTheme
1077 await $.command.register({ name: 'dash-focus', description: 'Fold the other cards to one line while something needs you (on | off, or toggle; on by default)' })
1078 const storedFocus = await $.store.get('focusOn').catch(() => undefined)
1079 if (typeof storedFocus === 'boolean') ctx.focusOn = storedFocus
1080 await $.command.register({ name: 'dash-spotify', description: 'Show what Spotify is playing on the Entertainment tab (on | off, or toggle; off by default)' })
1081 const storedSpotify = await $.store.get('spotifyOn').catch(() => undefined)
1082 if (typeof storedSpotify === 'boolean') ctx.spotifyOn = storedSpotify
1083 await $.command.register({ name: 'dash-summaries', description: 'Write a one-line summary per session after each turn (on | off; uses a small model call per turn)' })
1084 const storedSummaries = await $.store.get('summariesOn').catch(() => undefined)
1085 if (typeof storedSummaries === 'boolean') ctx.summariesOn = storedSummaries
1086 await $.command.register({ name: 'dash-icons', description: 'Icons in the Watching boxes: emoji (any font, default) | nerd (the official GitHub mark, needs a Nerd Font) | ascii' })
1087 const storedIcons = parseIconStyle(String(await $.store.get('iconStyle').catch(() => '')))
1088 if (storedIcons) ctx.iconStyle = storedIcons
1089 await $.command.register({ name: 'dash-handoff', description: 'Write a handoff note when a session compacts (on | off, or toggle)' })
1090 const storedHandoff = await $.store.get('handoffOn').catch(() => undefined)
1091 if (typeof storedHandoff === 'boolean') ctx.handoffOn = storedHandoff
1092 await $.command.register({ name: 'dash-watch', description: 'Keep an eye on a pull request: pr <number or URL> | list | clear <number|all>' })
1093 ctx.watches = (await loadWatches($)).filter(w => !isExpired(w, Date.now()))
1094 await $.command.register({ name: 'dash-refresh', description: 'Refresh the dashboard now, PRs included' })
1095 ctx.isWindows = (await $.env.get('OS')) === 'Windows_NT'
1096 if (ctx.isWindows) ctx.platform = 'windows'
1097 else {
1098 const uname = await $.process.run(['uname', '-s'], { timeoutMs: 5000 }).catch(() => undefined)
1099 ctx.platform = uname?.stdout.trim() === 'Darwin' ? 'mac' : 'linux'
1100 }
1101 // A reload of the mod starts this module over; the host's state remembers the pane was open.
1102 ctx.isOpen = (await read($, paneOpen)) === true
1103 await tick($, ctx.isOpen)
1104 // A half-second beat while the pane is open: the claude beat's tip blinks and the buddy steps with it. Only a
1105 // counter changes: no data is collected.
1106 $.clock.every(500, async () => {
1107 if (ctx.isOpen) await update($, blink, v => ((v ?? 0) + 1) % 1_000_000)
1108 })
1109 // A sprite buddy on the terminal is a Raster: repaint it in place about eight times a second, so it walks and
1110 // flies smoothly without redrawing the pane. Nothing runs unless the pane is open and showing one.
1111 $.clock.every(BUDDY_MS, async () => {
1112 const pack = getBuddy()
1113 const at = getStage()
1114 if (!ctx.isOpen || ctx.beatStyle !== 'buddy' || pack?.kind !== 'sprite' || !at) return
1115 await $.ui.blit({ requestId: PANE, key: 'buddy', cells: toRaster(drawScene(pack, at.mood, Date.now(), at.cols, at.rows)) }).catch(() => undefined)
1116 })
1117 $.clock.every(TICK_MS, async () => {
1118 ctx.ticks += 1
1119 await tick($, ctx.isOpen && (ctx.prs === null || ctx.ticks % PR_EVERY_TICKS === 0))
1120 })
1121
1122 return ran
1123 })
1124
1125 on('session.end', async ($, e, next) => {
1126 me.state = 'ended'
1127 await heartbeat($)
1128
1129 return next(e)
1130 })
1131
1132 on('turn.start', async ($, e, next) => {
1133 ctx.turnEdits.clear()
1134 ctx.turnFiles = []
1135 await setState($, 'running')
1136
1137 return next(e)
1138 })
1139
1140 on('turn.complete', async ($, e, next) => {
1141 const ran = await next(e)
1142 ctx.cacheHit = cacheHitPct(e.usage) ?? ctx.cacheHit
1143 try {
1144 const pct = (await $.session.usage()).context.percent
1145 if (typeof pct === 'number') ctx.ctxTrend = [...ctx.ctxTrend, Math.round(pct)].slice(-12)
1146 } catch {}
1147 if (!e.agentId && e.durationMs >= LONG_TASK_MS) {
1148 note($, { at: Date.now(), tone: 'ok', text: `this session finished a ${Math.round(e.durationMs / 60_000)}m task` }, true)
1149 }
1150 if (ctx.turnEdits.size > 0) ctx.turnFiles = await readTurnFiles($)
1151 await setState($, 'idle')
1152 if (!e.agentId && !e.isAborted) void summarize($, e.answer)
1153
1154 return ran
1155 })
1156
1157 on('prompt.submit', async ($, e, next) => {
1158 ctx.lastPrompt = e.text
1159
1160 return next(e)
1161 })
1162
1163 on('command.run', { command: 'dash-summaries' }, async ($, e) => {
1164 const arg = (e.args ?? '').trim().toLowerCase()
1165 ctx.summariesOn = arg === 'on' ? true : arg === 'off' ? false : !ctx.summariesOn
1166 await $.store.set('summariesOn', ctx.summariesOn)
1167 if (!ctx.summariesOn) me.summary = ''
1168 await heartbeat($)
1169 await publish($, undefined)
1170
1171 return {
1172 text: ctx.summariesOn
1173 ? 'Session summaries on: one small Haiku call after each finished turn. Turn off with /dash-summaries off.'
1174 : 'Session summaries off.',
1175 }
1176 })
1177
1178 on('tool.check', async ($, e, next) => {
1179 const r = await next(e)
1180 if (r.decision === 'ask') await setState($, 'waiting')
1181
1182 return r
1183 })
1184
1185 on('tool.call', async ($, e, next) => {
1186 ctx.toolCalls += 1
1187 const input = e as unknown as Record<string, unknown>
1188 me.lastTool = stepLabel(e.tool, input)
1189 if (e.tool === 'AskUserQuestion') {
1190 await setState($, 'waiting')
1191 const r = await next(e)
1192 await setState($, 'running')
1193 return r
1194 }
1195 await setState($, 'running')
1196 if ((e.tool === 'Bash' || e.tool === 'PowerShell') && typeof input.command === 'string') {
1197 const label = riskyReason(input.command)
1198 if (label) ctx.risky = { label, at: Date.now() }
1199 }
1200 if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(e.tool)) {hooks/monitor.ts 380 lines1// dev-dash: pure monitoring logic. Nothing here touches `$`, so every function
2// can be tested on plain values.
3
4import type { AgentRow, CiState, EventRow, PrRow, SessionRow, SessionState } from '../types'
5import { ids } from './keys'
6
7const baseName = (p: string) => p.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? p
8const oneLine = (s: string, n = 60) => {
9 const t = s.replace(/\s+/g, ' ').trim()
10 return t.length > n ? `${t.slice(0, n - 1)}…` : t
11}
12
13// ---------------------------------------------------------------------------
14// What a tool call is doing, in a few words
15// ---------------------------------------------------------------------------
16export const toolDetail = (tool: string, input: Record<string, unknown> | undefined): string => {
17 const s = (k: string) => (typeof input?.[k] === 'string' ? (input[k] as string) : '')
18 switch (tool) {
19 case 'Bash':
20 case 'PowerShell':
21 return oneLine(s('description') || s('command'), 50)
22 case 'Read':
23 case 'Edit':
24 case 'Write':
25 case 'MultiEdit':
26 case 'NotebookEdit':
27 return baseName(s('file_path') || s('notebook_path'))
28 case 'Grep':
29 case 'Glob':
30 return oneLine(s('pattern'), 40)
31 case 'WebSearch':
32 return oneLine(s('query'), 50)
33 case 'WebFetch': {
34 const url = s('url')
35 return oneLine(url.replace(/^https?:\/\//, ''), 50)
36 }
37 case 'Agent':
38 case 'Task':
39 return oneLine(s('description'), 50)
40 case 'SendMessage':
41 return s('to')
42 default:
43 return ''
44 }
45}
46
47export const stepLabel = (tool: string, input?: Record<string, unknown>) => {
48 const detail = toolDetail(tool, input)
49 return detail ? `${tool}: ${detail}` : tool
50}
51
52// ---------------------------------------------------------------------------
53// Subagent transcripts (<projects>/<slug>/<session>/subagents/agent-<id>.jsonl)
54// ---------------------------------------------------------------------------
55export type AgentSummary = {
56 startedAt: number | null
57 steps: number
58 doing: string
59 isFinished: boolean
60}
61
62type Block = { type?: string; name?: string; input?: Record<string, unknown> }
63type Line = { type?: string; timestamp?: string; message?: { content?: unknown; stop_reason?: string } }
64
65/** Reads an agent transcript: when it started, how many tool steps, its latest step, and whether it handed back. */
66export const summarizeAgent = (text: string): AgentSummary => {
67 let startedAt: number | null = null
68 let steps = 0
69 let doing = ''
70 let isFinished = false
71 for (const raw of text.split(/\r?\n/)) {
72 if (!raw) continue
73 let d: Line
74 try {
75 d = JSON.parse(raw) as Line
76 } catch {
77 continue
78 }
79 if (startedAt === null && d.timestamp) {
80 const t = Date.parse(d.timestamp)
81 if (Number.isFinite(t)) startedAt = t
82 }
83 if (d.type !== 'assistant') {
84 if (d.type === 'user') isFinished = false
85 continue
86 }
87 const blocks = Array.isArray(d.message?.content) ? (d.message?.content as Block[]) : []
88 const uses = blocks.filter(b => b?.type === 'tool_use')
89 if (uses.length === 0) {
90 // A reply with no tool call ends the agent's turn.
91 if (blocks.some(b => b?.type === 'text')) isFinished = true
92 continue
93 }
94 steps += uses.length
95 const last = uses[uses.length - 1]
96 const tool = last.name ?? 'tool'
97 isFinished = tool === 'SubagentHandback'
98 doing = isFinished ? 'handed back its report' : stepLabel(tool, last.input)
99 }
100
101 return { startedAt, steps, doing, isFinished }
102}
103
104export type AgentMeta = {
105 agentType?: string
106 description?: string
107 requestShape?: string
108 stoppedByUser?: boolean
109}
110
111/** How long without a transcript write before a still-open agent counts as quiet rather than working. */
112export const QUIET_AFTER_MS = 90_000
113
114export const agentStateOf = (meta: AgentMeta, sum: AgentSummary, lastActive: number, now: number, isSessionAlive: boolean) => {
115 if (meta.stoppedByUser) return 'stopped' as const
116 if (sum.isFinished) return 'done' as const
117 if (!isSessionAlive) return 'stopped' as const
118 return now - lastActive < QUIET_AFTER_MS ? ('working' as const) : ('quiet' as const)
119}
120
121// ---------------------------------------------------------------------------
122// Usage-limit runway
123// ---------------------------------------------------------------------------
124export type Sample = { at: number; pct: number }
125
126/** Time until 100% at the pace seen across `samples` (oldest first); null when flat, falling, or too little history. */
127export const runwayMs = (samples: readonly Sample[]): number | null => {
128 if (samples.length < 2) return null
129 const first = samples[0]
130 const last = samples[samples.length - 1]
131 const dt = last.at - first.at
132 const dp = last.pct - first.pct
133 if (dt < 120_000 || dp <= 0) return null
134 return Math.max(0, ((100 - last.pct) / dp) * dt)
135}
136
137export const limitLabel = (kind: string) =>
138 ({ five_hour: '5h', seven_day: '7d', seven_day_opus: '7d opus', seven_day_sonnet: '7d sonnet', spend_limit: 'spend' })[kind] ??
139 kind.replace(/_/g, ' ')
140
141// ---------------------------------------------------------------------------
142// Stuck detection for this session's own tool calls
143// ---------------------------------------------------------------------------
144export type CallMark = { key: string; label: string; isOk: boolean; at: number }
145
146export const callKey = (tool: string, input: Record<string, unknown> | undefined) => {
147 const main = input?.command ?? input?.file_path ?? input?.pattern ?? input?.url ?? input?.query ?? ''
148 return `${tool}|${typeof main === 'string' ? main : JSON.stringify(main)}`
149}
150
151/** '' when fine; otherwise why it looks stuck. `calls` is newest last. */
152export const stuckReason = (calls: readonly CallMark[]): string => {
153 const recent = calls.slice(-6)
154 const last = recent[recent.length - 1]
155 if (!last) return ''
156 const tail3 = recent.slice(-3)
157 if (tail3.length === 3 && tail3.every(c => c.key === last.key && !c.isOk)) {
158 return `${last.label} failed ${tail3.length}× in a row`
159 }
160 if (recent.length === 6 && recent.every(c => c.key === last.key)) return `repeating ${last.label}`
161 return ''
162}
163
164// ---------------------------------------------------------------------------
165// Two sessions editing the same file
166// ---------------------------------------------------------------------------
167export type Collision = { file: string; sessions: string[] }
168
169export const collisionsOf = (sessions: readonly SessionRow[]): Collision[] => {
170 const byFile = new Map<string, string[]>()
171 for (const r of sessions) {
172 for (const f of r.editing ?? []) byFile.set(f, [...(byFile.get(f) ?? []), r.name || r.repo])
173 }
174
175 return [...byFile.entries()].filter(([, who]) => who.length > 1).map(([file, who]) => ({ file, sessions: who }))
176}
177
178// ---------------------------------------------------------------------------
179// git diff --shortstat
180// ---------------------------------------------------------------------------
181export const parseShortstat = (s: string) => ({
182 added: Number(/(\d+) insertion/.exec(s)?.[1] ?? 0),
183 removed: Number(/(\d+) deletion/.exec(s)?.[1] ?? 0),
184})
185
186// ---------------------------------------------------------------------------
187// Events: what changed between two snapshots
188// ---------------------------------------------------------------------------
189export type Seen = {
190 sessions: Map<string, { state: SessionState; since: number; stuck: string; risky: string }>
191 agents: Map<string, AgentRow['state']>
192 ci: Map<number, CiState>
193}
194
195export const emptySeen = (): Seen => ({ sessions: new Map(), agents: new Map(), ci: new Map() })
196
197/** `itemId` ties a change to a row in the Attention section, so snoozing or dismissing that row also silences its toast. */
198export type Change = EventRow & { isAlert: boolean; itemId?: string }
199
200/**
201 * Compares the last view with the new one. `isAlert` marks what is worth a toast:
202 * something now needs the person. The first call (empty `seen`) reports nothing.
203 */
204export const changesBetween = (
205 seen: Seen,
206 sessions: readonly SessionRow[],
207 agents: readonly AgentRow[],
208 mine: readonly PrRow[] | null,
209 selfId: string,
210 now: number,
211 isFirst: boolean,
212): Change[] => {
213 const out: Change[] = []
214 const name = (r: SessionRow) => r.name || `${r.repo}${r.branch ? `@${r.branch}` : ''}`
215
216 for (const r of sessions) {
217 const was = seen.sessions.get(r.id)
218 if (!isFirst && was) {
219 if (r.state === 'waiting' && was.state !== 'waiting' && r.id !== selfId) {
220 out.push({ at: now, tone: 'warn', text: `${name(r)} needs ${r.waitingFor || 'you'}`, isAlert: true, itemId: ids.wait(r) })
221 }
222 if (r.state === 'idle' && was.state === 'running' && now - was.since > 60_000 && r.id !== selfId) {
223 const isLong = now - was.since >= LONG_TASK_MS
224 out.push({ at: now, tone: 'ok', text: `${name(r)} finished${isLong ? ` after ${Math.round((now - was.since) / 60_000)}m` : ''}`, isAlert: isLong })
225 }
226 if (r.risky && r.risky !== was.risky) {
227 out.push({ at: now, tone: 'bad', text: `${name(r)} ran a risky command: ${r.risky}`, isAlert: true, itemId: ids.risky(r) })
228 }
229 if (r.stuck && r.stuck !== was.stuck) {
230 out.push({ at: now, tone: 'bad', text: `${name(r)} may be stuck: ${r.stuck}`, isAlert: true, itemId: ids.stuck(r) })
231 }
232 }
233 if (!isFirst && !was && r.id !== selfId) out.push({ at: now, tone: 'info', text: `${name(r)} started`, isAlert: false })
234 }
235
236 for (const a of agents) {
237 const was = seen.agents.get(a.id)
238 if (isFirst) continue
239 if (!was && (a.state === 'working' || a.state === 'quiet')) {
240 out.push({ at: now, tone: 'info', text: `agent started: ${a.description}`, isAlert: false })
241 } else if (was && was !== a.state && a.state === 'done') {
242 out.push({ at: now, tone: 'ok', text: `agent finished: ${a.description}`, isAlert: true })
243 } else if (was && was !== a.state && a.state === 'stopped') {
244 out.push({ at: now, tone: 'warn', text: `agent stopped: ${a.description}`, isAlert: false })
245 }
246 }
247
248 for (const p of mine ?? []) {
249 const was = seen.ci.get(p.number)
250 if (isFirst || was === undefined || was === p.ci) continue
251 if (p.ci === 'failing') out.push({ at: now, tone: 'bad', text: `#${p.number} CI went red`, isAlert: true, itemId: ids.ci(p) })
252 else if (p.ci === 'passing' && was === 'failing') out.push({ at: now, tone: 'ok', text: `#${p.number} CI is green again`, isAlert: true })
253 }
254
255 return out
256}
257
258export const remember = (sessions: readonly SessionRow[], agents: readonly AgentRow[], mine: readonly PrRow[] | null): Seen => ({
259 sessions: new Map(sessions.map(r => [r.id, { state: r.state, since: r.stateSince, stuck: r.stuck, risky: r.risky }] as const)),
260 agents: new Map(agents.map(a => [a.id, a.state] as const)),
261 ci: new Map((mine ?? []).map(p => [p.number, p.ci] as const)),
262})
263
264/** Context thresholds crossed upward since `last` (e.g. 48 → 77 crosses 50 and 75). */
265export const CONTEXT_STEPS = [50, 75, 90] as const
266export const crossedSteps = (last: number | null, now: number | null) =>
267 last === null || now === null ? [] : CONTEXT_STEPS.filter(step => last < step && now >= step)
268
269// ---------------------------------------------------------------------------
270// Risky shell commands (flagged, never blocked)
271// ---------------------------------------------------------------------------
272const RISKY: ReadonlyArray<[RegExp, string]> = [
273 [/\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|--recursive\s+--force|--force\s+--recursive)\b/i, 'rm -rf'],
274 [/\bgit\s+push\b[^\n;&|]*\s(--force(?!-with-lease)|-f\b)/i, 'git push --force'],
275 [/\bgit\s+reset\s+--hard\b/i, 'git reset --hard'],
276 [/\bgit\s+clean\s+-[a-z]*f/i, 'git clean -f'],
277 [/\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/i, 'discard all changes'],
278 [/\bgit\s+branch\s+-D\b/, 'git branch -D'],
279 [/\bdrop\s+(database|table|schema)\b/i, 'DROP'],
280 [/\btruncate\s+table\b/i, 'TRUNCATE'],
281 [/\bRemove-Item\b[^\n]*-Recurse[^\n]*-Force|\bRemove-Item\b[^\n]*-Force[^\n]*-Recurse/i, 'Remove-Item -Recurse -Force'],
282 [/\b(del|erase)\s+(\/[a-z]\s+)*\/s\b/i, 'del /s'],
283 [/\brd\s+\/s\b|\brmdir\s+\/s\b/i, 'rmdir /s'],
284 [/\bmkfs(\.\w+)?\b|\bformat\s+[a-z]:/i, 'format disk'],
285 [/\bdd\s+if=.*\bof=\/dev\//i, 'dd to a device'],
286 [/\bchmod\s+-R\s+777\b/i, 'chmod -R 777'],
287 [/\b(kubectl|terraform)\s+(delete|destroy)\b/i, 'infra delete'],
288]
289
290/** The risky pattern a shell command matches, or ''. */
291export const riskyReason = (command: string): string => RISKY.find(([re]) => re.test(command))?.[1] ?? ''
292
293// ---------------------------------------------------------------------------
294// Prompt cache
295// ---------------------------------------------------------------------------
296export type TokenUsage = {
297 input_tokens: number
298 cache_read_input_tokens: number
299 cache_creation_input_tokens: number
300}
301
302/** Share of input tokens served from cache, 0-100, or null with no input. */
303export const cacheHitPct = (u: TokenUsage | undefined | null): number | null => {
304 if (!u) return null
305 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
306 return total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : null
307}
308
309// ---------------------------------------------------------------------------
310// Disk space
311// ---------------------------------------------------------------------------
312export type Disk = { name: string; freeBytes: number; totalBytes: number }
313
314/** PowerShell `Get-PSDrive` lines: "<Name> <Free> <Used>". */
315export const parseWindowsDisks = (out: string): Disk[] =>
316 out
317 .split(/\r?\n/)
318 .map(l => l.trim().split(/\s+/))
319 .filter(p => p.length === 3 && /^\d+$/.test(p[1]) && /^\d+$/.test(p[2]))
320 .map(([name, free, used]) => ({ name: `${name}:`, freeBytes: Number(free), totalBytes: Number(free) + Number(used) }))
321 .filter(d => d.totalBytes > 0)
322
323/** `df -Pk` output: real filesystems only, by mount point. */
324export const parseDf = (out: string): Disk[] =>
325 out
326 .split(/\r?\n/)
327 .slice(1)
328 .map(l => l.trim().split(/\s+/))
329 .filter(p => p.length >= 6 && p[0].startsWith('/dev/') && /^\d+$/.test(p[1]))
330 .map(p => ({ name: p.slice(5).join(' '), freeBytes: Number(p[3]) * 1024, totalBytes: Number(p[1]) * 1024 }))
331
332export const LOW_DISK_PCT = 10
333export const LOW_DISK_BYTES = 5 * 1024 ** 3
334export const isDiskLow = (d: Disk) => d.freeBytes < LOW_DISK_BYTES || (d.freeBytes / d.totalBytes) * 100 < LOW_DISK_PCT
335
336export const bytes = (n: number) => {
337 const units = ['B', 'KB', 'MB', 'GB', 'TB']
338 let i = 0
339 let v = n
340 while (v >= 1024 && i < units.length - 1) {
341 v /= 1024
342 i += 1
343 }
344 return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`
345}
346
347/** A task counts as long, worth a ping when it ends, after this long running. */
348export const LONG_TASK_MS = 5 * 60_000
349
350// ---------------------------------------------------------------------------
351// One-line session summaries (opt-in: one small model call per finished turn)
352// ---------------------------------------------------------------------------
353const squash = (s: string) => s.replace(/\s+/g, ' ').trim()
354const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
355
356export const SUMMARY_SYSTEM =
357 'You write one-line status labels for a developer dashboard. Reply with the label only: no quotes, no markdown, no preamble.'
358
359/** The prompt for the label. Inputs are clipped, so a call stays small (a few hundred tokens). */
360export const summaryPrompt = (request: string, answer: string, steps: readonly string[]) =>
361 [
362 'A coding assistant session just finished a turn. In at most 12 words, present tense, say what the session is working on.',
363 `Request: ${clip(squash(request), 300) || '(none)'}`,
364 `Latest steps: ${steps.slice(-5).map(s => clip(squash(s), 60)).join('; ') || '(none)'}`,
365 `Reply: ${clip(squash(answer), 500) || '(none)'}`,
366 ].join('\n')
367
368/** Cleans the model's reply into one short line; '' when nothing usable. */
369export const tidySummary = (text: string, max = 80): string => {
370 const line = (text.split(/\r?\n/).find(l => l.trim()) ?? '')
371 .replace(/^[\s>*#\-"'`“‘]+/, '')
372 .replace(/[\s"'`”’]+$/, '')
373 .replace(/\*\*|__|`/g, '')
374 .replace(/^(status|label|summary)\s*:\s*/i, '')
375 .replace(/\.$/, '')
376 const out = squash(line)
377
378 return out.length > max ? `${out.slice(0, max - 1)}…` : out
379}
380hooks/exe.ts 76 lines1// dev-dash: finding and running `git` and `gh` when the process PATH may be stale. No session needed, so it tests
2// with a stub for the process call and a stub clock.
3//
4// $.process.run rejects both when a program cannot be started and when it started but ran past its time limit
5// (the child is killed and the call rejects). The API gives no wording for either, so the two are told apart by
6// time: a program that cannot start fails almost at once, a timeout takes the whole allowance. A call that ran for
7// (nearly) its whole timeoutMs was a timeout and is rethrown as it is; one that failed sooner is treated as "not
8// there" and the next place is tried.
9
10/** A call that ran for at least this much less than its allowance than the full timeoutMs still counts as a timeout. */
11export const TIMEOUT_SLACK_MS = 250
12
13/** True when a call that started at `startedAt` and failed at `endedAt` ran for (nearly) all of `timeoutMs`. */
14export function ranToTimeout(startedAt: number, endedAt: number, timeoutMs: number): boolean {
15 // The slack never takes more than half the allowance, so a very short timeout still means something.
16 return endedAt - startedAt >= Math.max(timeoutMs / 2, timeoutMs - TIMEOUT_SLACK_MS)
17}
18
19export type RunExe<R> = {
20 /** Starts `argv` (argv[0] the program) with the given time limit; rejects if it cannot start or runs out of time. */
21 run: (argv: string[], timeoutMs: number) => Promise<R>
22 /** The program as it is called by name: `git`, `gh`. */
23 name: string
24 args: string[]
25 timeoutMs: number
26 /** Where else to look, in order, asked only once the program by name could not be started. */
27 fallbacks: () => Promise<string[]>
28 /** What worked last time, by name; the caller keeps it between calls. */
29 found: Map<string, string>
30 now?: () => number
31}
32
33type Attempt<R> = { value: R } | { err: unknown; isTimeout: boolean }
34
35/**
36 * Runs the program: by the path that worked last time, else by name, else from each fallback in turn. A timeout is
37 * rethrown at once from wherever it happened (a slow program is not retried elsewhere, and a remembered path that
38 * timed out is kept); any other failure moves on to the next place. If nowhere works, the first failure is thrown.
39 */
40export async function runExe<R>(o: RunExe<R>): Promise<R> {
41 const now = o.now ?? Date.now
42 const attempt = async (exe: string): Promise<Attempt<R>> => {
43 const started = now()
44 try {
45 return { value: await o.run([exe, ...o.args], o.timeoutMs) }
46 } catch (err) {
47 return { err, isTimeout: ranToTimeout(started, now(), o.timeoutMs) }
48 }
49 }
50
51 const known = o.found.get(o.name)
52 if (known) {
53 const a = await attempt(known)
54 if ('value' in a) return a.value
55 if (a.isTimeout) throw a.err
56 o.found.delete(o.name)
57 }
58 const first = await attempt(o.name)
59 if ('value' in first) {
60 o.found.set(o.name, o.name)
61
62 return first.value
63 }
64 if (first.isTimeout) throw first.err
65 for (const exe of await o.fallbacks()) {
66 const a = await attempt(exe)
67 if ('value' in a) {
68 o.found.set(o.name, exe)
69
70 return a.value
71 }
72 if (a.isTimeout) throw a.err
73 }
74 throw first.err
75}
76hooks/handoff.ts 90 lines1// dev-dash: the handoff note written when a session compacts. Pure functions only, so they test
2// without a session; register.tsx collects the facts and writes the file.
3//
4// The note is for the person (or the next session) picking the work up after the conversation was
5// squeezed down: where the session was, what it was doing, what it had touched, and the summary
6// the compaction kept.
7
8import type { PlanProgress } from '../types'
9
10export const SUMMARY_MAX = 2000
11export const FILES_MAX = 15
12
13export type HandoffFacts = {
14 at: number
15 sessionId: string
16 name: string
17 repo: string
18 branch: string
19 cwd: string
20 /** What compacted: the person's `/compact`, the engine at its threshold, a plugin. */
21 trigger: string
22 tokensBefore?: number
23 tokensAfter?: number
24 plan: PlanProgress | null
25 lastTool: string
26 /** Files edited in the last 30 minutes, as the heartbeat keeps them. */
27 files: readonly string[]
28 /** The summary the compaction kept: the text of its first message. */
29 summary: string
30}
31
32const pad = (n: number) => String(n).padStart(2, '0')
33
34/** `2026-10-03 16:42` in the machine's time. */
35export const stampOf = (at: number) => {
36 const d = new Date(at)
37 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
38}
39
40/** A file name for the note: sortable by time, and safe on every filesystem. */
41export const handoffName = (at: number, sessionId: string) => {
42 const d = new Date(at)
43 const day = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
44
45 return `${day}-${sessionId.replace(/[^A-Za-z0-9-]/g, '').slice(0, 8) || 'session'}.md`
46}
47
48/** How many notes are kept in all; older ones are deleted when a new one is written. */
49export const HANDOFFS_KEPT = 30
50
51/** Exactly what `handoffName` makes, so nothing else in the folder is ever touched. */
52const NOTE_NAME = /^\d{8}-\d{6}-[A-Za-z0-9-]{1,8}\.md$/
53
54/** The notes to delete so that only the newest `keep` remain. The name starts with the time, so it sorts by age. */
55export const staleNotes = (names: readonly string[], keep = HANDOFFS_KEPT): string[] =>
56 names
57 .filter(n => NOTE_NAME.test(n))
58 .sort()
59 .slice(0, Math.max(0, names.filter(n => NOTE_NAME.test(n)).length - keep))
60
61const tokens = (n: number | undefined) => (n === undefined ? '' : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
62
63/** A cut that says it cut. */
64export const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(1, n - 1)).trimEnd()}…` : s)
65
66export function handoffNote(f: HandoffFacts): string {
67 const size = f.tokensBefore !== undefined && f.tokensAfter !== undefined ? ` (${tokens(f.tokensBefore)} -> ${tokens(f.tokensAfter)} tokens)` : ''
68 const where = [f.repo, f.branch].filter(Boolean).join('@')
69 const lines = [
70 `# Handoff: ${f.name || where || f.sessionId}`,
71 '',
72 `Compacted ${stampOf(f.at)} (${f.trigger})${size}.`,
73 where ? `Repo: ${where}${f.cwd ? ` · ${f.cwd}` : ''}` : f.cwd ? `Directory: ${f.cwd}` : '',
74 `Resume: \`claude --resume ${f.sessionId}\``,
75 ]
76 if (f.plan) {
77 lines.push('', `## Plan: ${f.plan.done}/${f.plan.total} done`, f.plan.done < f.plan.total && f.plan.current ? `In progress: ${f.plan.current}` : '')
78 }
79 if (f.lastTool) lines.push('', `Last step: ${f.lastTool}`)
80 if (f.files.length > 0) {
81 const shown = f.files.slice(0, FILES_MAX)
82 lines.push('', '## Files touched recently', ...shown.map(p => `- ${p}`))
83 if (f.files.length > shown.length) lines.push(`- … and ${f.files.length - shown.length} more`)
84 }
85 const summary = f.summary.trim()
86 lines.push('', '## What the compaction kept', summary ? clip(summary, SUMMARY_MAX) : '(no summary text)')
87
88 return `${lines.filter((l, i, all) => !(l === '' && all[i - 1] === '') && l !== undefined).join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n`
89}
90hooks/entertainment.ts 95 lines1// dev-dash: the Entertainment tab's data. Pure functions only, so they test without a session.
2//
3// "Now playing" is an opt-in (off until the person turns it on). It reads what the Spotify app on THIS machine
4// reports, with a local command and no network and no login: the media information Windows shows on its volume
5// overlay, AppleScript on macOS, playerctl on Linux. Nothing is sent anywhere or written to disk.
6
7import type { NowPlaying, TabId } from '../types'
8
9export const TABS: ReadonlyArray<{ id: TabId; label: string }> = [
10 { id: 'dashboard', label: 'Dashboard' },
11 { id: 'entertainment', label: 'Entertainment' },
12 { id: 'custom', label: 'Custom' },
13]
14
15export const isTab = (v: unknown): v is TabId => TABS.some(t => t.id === v)
16
17export type Platform = 'windows' | 'mac' | 'linux'
18
19// Windows keeps a "media session" for each app that plays media (the info behind the volume overlay): the app, the
20// track and whether it is playing or paused. Unlike the app's window title it keeps the track while paused. It is
21// there once Spotify has started a song. No session means Spotify is either open with nothing started, or closed.
22const WINDOWS_SCRIPT = [
23 "$ErrorActionPreference='Stop'",
24 'try {',
25 ' Add-Type -AssemblyName System.Runtime.WindowsRuntime',
26 " $a=([System.WindowsRuntimeSystemExtensions].GetMethods()|Where-Object{$_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1'})[0]",
27 ' function W($o,$t){$k=$a.MakeGenericMethod($t).Invoke($null,@($o));$k.Wait(-1)|Out-Null;$k.Result}',
28 ' [void][Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager,Windows.Media.Control,ContentType=WindowsRuntime]',
29 ' $m=W ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager]::RequestAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager])',
30 " foreach($s in $m.GetSessions()){ if($s.SourceAppUserModelId -match 'Spotify'){",
31 ' $p=W ($s.TryGetMediaPropertiesAsync()) ([Windows.Media.Control.GlobalSystemMediaTransportControlsSessionMediaProperties])',
32 " Write-Output ($s.GetPlaybackInfo().PlaybackStatus.ToString()+'|'+$p.Artist+' - '+$p.Title); return } }",
33 '} catch {}',
34 "if(Get-Process -Name Spotify -ErrorAction SilentlyContinue){'OPEN'}else{'CLOSED'}",
35].join('\n')
36
37/** The command (as separate arguments, no shell) that prints what Spotify is playing on this platform. */
38export const nowPlayingCommand = (platform: Platform): string[] => {
39 if (platform === 'windows') return ['powershell', '-NoProfile', '-Command', WINDOWS_SCRIPT]
40 if (platform === 'mac') {
41 return [
42 'osascript',
43 '-e', 'if application "Spotify" is not running then return "NOT_RUNNING"',
44 '-e', 'tell application "Spotify"',
45 '-e', 'if player state is playing then return "Playing|" & (artist of current track) & " - " & (name of current track)',
46 '-e', 'if player state is paused then return "Paused|" & (artist of current track) & " - " & (name of current track)',
47 '-e', 'return ""',
48 '-e', 'end tell',
49 ]
50 }
51
52 return ['playerctl', '-p', 'spotify', 'metadata', '--format', '{{status}}|{{artist}} - {{title}}']
53}
54
55const clean = (s: string) => s.replace(/\s+/g, ' ').trim()
56
57/** `Playing|Artist - Title`, `Paused|...`, or a status with no track: Windows, macOS and Linux all print this shape. */
58function statusLine(out: string, at: number): NowPlaying | null {
59 const m = /^(Playing|Paused|Stopped|Changing|Opened|Closed)\|(.*)$/i.exec(out)
60 if (!m) return null
61 const status = m[1].toLowerCase()
62 const track = clean(m[2])
63 if (!track || track === '-') return { state: 'idle', track: '', at }
64 if (status === 'playing') return { state: 'playing', track, at }
65 if (status === 'paused') return { state: 'paused', track, at }
66
67 return { state: 'idle', track: '', at }
68}
69
70/**
71 * What the command printed, as a reading. `exitCode` matters on Linux, where no player is a non-zero exit.
72 * Windows prints a status line, or OPEN (Spotify running, nothing started) or CLOSED (not running).
73 */
74export function parseNowPlaying(platform: Platform, stdout: string, exitCode: number, at: number): NowPlaying {
75 const out = clean(stdout)
76 if (platform === 'windows') {
77 if (out === 'CLOSED') return { state: 'closed', track: '', at }
78 if (out === 'OPEN') return { state: 'idle', track: '', at }
79
80 return statusLine(out, at) ?? { state: 'unavailable', track: '', at }
81 }
82 if (platform === 'mac') {
83 if (out === 'NOT_RUNNING') return { state: 'closed', track: '', at }
84 if (!out) return { state: 'idle', track: '', at }
85
86 return statusLine(out, at) ?? { state: 'unavailable', track: '', at }
87 }
88 if (exitCode !== 0 || !out) return { state: 'closed', track: '', at }
89
90 return statusLine(out, at) ?? { state: 'unavailable', track: '', at }
91}
92
93/** The command could not run at all (the tool is missing): shown as "unavailable", not as "closed". */
94export const unavailable = (at: number): NowPlaying => ({ state: 'unavailable', track: '', at })
95hooks/keys.ts 96 lines1// dev-dash: row ids, the actions a row offers, and snooze/dismiss. Pure functions only, so they test without a session.
2//
3// The pane is driven with the generic keys the engine already gives a focused pane: Tab and the arrow keys move
4// the focus ring over the Buttons, Enter presses the one in the ring, Esc gives the keyboard back to the prompt and
5// ctrl+x tab takes it back. Every row has a small marker Button as its stop in the ring, and pressing it opens the
6// row's actions (copy, snooze, dismiss). No letter hotkeys.
7
8import type { AgentRow, DiskRow, LimitRow, PrRow, SessionRow } from '../types'
9import type { DashSection } from '../types'
10
11// ---------------------------------------------------------------------------
12// The keys, as the help panel explains them
13// ---------------------------------------------------------------------------
14export const HELP_KEYS: ReadonlyArray<readonly [string, string]> = [
15 ['↑ ↓ or Tab', 'move the highlight to the previous or next row or button'],
16 ['Enter', 'press the highlighted button; on a row, open its actions'],
17 ['Esc', 'give the keyboard back to the prompt'],
18 ['ctrl+x tab', 'bring the keyboard back to this pane (or type /dash)'],
19 ['1 – 9', 'fold or unfold a section (Dashboard tab)'],
20 ['tab buttons', 'at the top: Dashboard, Entertainment, Custom. Move onto one and press Enter'],
21]
22
23export const SNOOZE_MS = 15 * 60_000
24
25// ---------------------------------------------------------------------------
26// Row ids. Each is stable while the thing it names is the same thing, and changes when it is a new
27// episode (a session that waits again, a new stuck reason), so a dismissal does not hide the next one.
28// ---------------------------------------------------------------------------
29export const ids = {
30 wait: (r: SessionRow) => `wait:${r.id}:${r.stateSince}`,
31 stuck: (r: SessionRow) => `stuck:${r.id}:${r.stuck}`,
32 risky: (r: SessionRow) => `risky:${r.id}:${r.risky}`,
33 disk: (d: DiskRow) => `disk:${d.name}`,
34 clash: (file: string) => `clash:${file}`,
35 limit: (l: LimitRow) => `limit:${l.kind}`,
36 ci: (p: PrRow) => `ci:${p.repo}#${p.number}`,
37 review: (p: PrRow) => `review:${p.repo}#${p.number}`,
38 session: (r: SessionRow) => `s:${r.id}`,
39 agent: (a: AgentRow) => `a:${a.id}`,
40 pr: (p: PrRow) => `pr:${p.repo}#${p.number}`,
41 rv: (p: PrRow) => `rv:${p.repo}#${p.number}`,
42}
43
44export const resumeCommand = (sessionId: string) => `claude --resume ${sessionId}`
45
46// ---------------------------------------------------------------------------
47// Snooze and dismiss
48// ---------------------------------------------------------------------------
49export type Muting = { snoozed: Record<string, number>; dismissed: readonly string[]; now: number }
50
51export const noMuting = (now: number): Muting => ({ snoozed: {}, dismissed: [], now })
52
53export const isMuted = (m: Muting, id: string) => m.dismissed.includes(id) || (m.snoozed[id] ?? 0) > m.now
54
55/** `snoozed` with `id` added and expired entries dropped. */
56export const snoozeAdd = (snoozed: Record<string, number>, id: string, now: number): Record<string, number> => {
57 const out: Record<string, number> = {}
58 for (const [k, until] of Object.entries(snoozed)) if (until > now) out[k] = until
59 out[id] = now + SNOOZE_MS
60
61 return out
62}
63
64export const dismissAdd = (dismissed: readonly string[], id: string, keep = 200): string[] =>
65 dismissed.includes(id) ? [...dismissed] : [...dismissed, id].slice(-keep)
66
67// ---------------------------------------------------------------------------
68// The rows that can be opened, in the order the pane draws them
69// ---------------------------------------------------------------------------
70export type Item = {
71 id: string
72 kind: 'attention' | 'session' | 'agent' | 'pr' | 'review'
73 /** What Copy puts on the clipboard. */
74 copy: string
75 /** What the copy button is called, and what the toast says was copied. */
76 what: string
77 /** True for Attention items, the only ones that can be snoozed or dismissed. */
78 canMute: boolean
79}
80
81export const itemById = (items: readonly Item[], id: string | null | undefined) => items.find(i => i.id === id)
82
83export type RowAction = { id: 'copy' | 'snooze' | 'dismiss'; label: string }
84
85/** The buttons a row shows when it is opened. Only Attention items can be muted. */
86export const actionsFor = (item: Item): RowAction[] => [
87 { id: 'copy', label: `copy ${item.what}` },
88 ...(item.canMute ? ([{ id: 'snooze', label: 'snooze 15m' }, { id: 'dismiss', label: 'dismiss' }] as const) : []),
89]
90
91/** Which of the sections that have rows are folded away: their rows are not shown. */
92export const isShown = (folded: readonly DashSection[], id: DashSection) => !folded.includes(id)
93
94/** How many rows the pane draws per section. */
95export type Caps = { reviews: number; sessions: number; agents: number }
96hooks/icons.ts 30 lines1// dev-dash: the icons for where a watch lives. Pure data, so it tests without a session.
2//
3// The official GitHub mark is not a Unicode character, so a terminal can only show it from the font it is set to.
4// Nerd Fonts carry it, but in most terminals it shows as an empty box, so the default is the emoji style, which
5// every font has; `/dash-icons nerd` opts in to the official mark and `ascii` falls back to plain letters. The
6// style is the person's choice and is remembered. A source gets its own entry here when there are watches for it.
7
8import type { IconStyle, WatchKind } from '../types'
9
10export type Source = 'github'
11
12export const ICON_STYLES: readonly IconStyle[] = ['emoji', 'nerd', 'ascii']
13export const DEFAULT_ICON_STYLE: IconStyle = 'emoji'
14
15/** Nerd Fonts: Font Awesome `nf-fa-github` (U+F09B). */
16export const ICONS: Record<Source, Record<IconStyle, string>> = {
17 github: { emoji: '🐙', nerd: '', ascii: 'GH' },
18}
19
20export const sourceOf = (_kind: WatchKind): Source => 'github'
21
22export const iconFor = (source: Source, style: IconStyle | undefined): string => ICONS[source][style ?? DEFAULT_ICON_STYLE]
23
24/** `nerd`, `emoji` or `ascii` (any case), else null. */
25export const parseIconStyle = (s: string): IconStyle | null => {
26 const t = s.trim().toLowerCase()
27
28 return (ICON_STYLES as readonly string[]).includes(t) ? (t as IconStyle) : null
29}
30hooks/progress.ts 87 lines1// dev-dash: per-session progress data (plan progress, sources fetched, files changed this turn).
2// Pure functions only, so they test without a session. register.tsx collects, progress-view.tsx draws.
3
4import type { ChangedFile, PlanProgress, SourceRow } from '../types'
5
6export const SOURCES_KEPT = 20
7export const TURN_FILES_KEPT = 12
8
9const text = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
10
11/** Progress of the list a session keeps with TodoWrite; null when the input has no todos. */
12export function planOf(input: Record<string, unknown>): PlanProgress | null {
13 const todos = input.todos
14 if (!Array.isArray(todos) || todos.length === 0) return null
15 const items = todos.map(t => (t && typeof t === 'object' ? (t as Record<string, unknown>) : {}))
16 const done = items.filter(t => t.status === 'completed').length
17 const active = items.find(t => t.status === 'in_progress') ?? items.find(t => t.status === 'pending')
18 const current = active ? text(active.activeForm) || text(active.content) : ''
19
20 return { done, total: items.length, current }
21}
22
23export const QUERY_MAX = 80
24
25/**
26 * What is safe to keep of a fetched URL: scheme, host and path. Userinfo (`user:pass@`),
27 * query and fragment can hold tokens or keys, and the label is written to disk, so they never get in.
28 */
29export function stripUrl(url: string): string {
30 const m = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)([^?#]*)/i.exec(url)
31 if (!m) return url.split(/[?#]/)[0].replace(/\/\/[^/@\s]*@/, '//')
32 const host = m[2].slice(m[2].lastIndexOf('@') + 1)
33
34 return `${m[1].toLowerCase()}://${host}${m[3]}`
35}
36
37/** A URL a session fetched or a query it searched, from one tool call; null for any other tool. */
38export function sourceOf(tool: string, input: Record<string, unknown>, at: number): SourceRow | null {
39 if (tool === 'WebFetch' && text(input.url)) return { at, kind: 'fetch', label: stripUrl(text(input.url)) }
40 if (tool === 'WebSearch' && text(input.query)) {
41 const q = text(input.query)
42 return { at, kind: 'search', label: q.length > QUERY_MAX ? `${q.slice(0, QUERY_MAX - 1)}…` : q }
43 }
44
45 return null
46}
47
48/** Newest first, one entry per source (a repeat moves to the front), capped. */
49export function addSource(list: readonly SourceRow[], next: SourceRow, keep = SOURCES_KEPT): SourceRow[] {
50 const rest = list.filter(s => !(s.kind === next.kind && s.label === next.label))
51
52 return [next, ...rest].slice(0, keep)
53}
54
55/** `https://example.com/a/b?x=1` -> `example.com/a/b`; anything else comes back as it was. */
56export function shortUrl(url: string): string {
57 const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)([^?#]*)/i.exec(url)
58 if (!m) return url
59 const path = m[2].replace(/\/+$/, '')
60
61 return `${m[1].replace(/^www\./, '')}${path}`
62}
63
64/** Output of `git diff --numstat`: `added<TAB>removed<TAB>path`, with `-` for binary files. */
65export function parseNumstat(out: string): ChangedFile[] {
66 const files: ChangedFile[] = []
67 for (const line of out.split(/\r?\n/)) {
68 const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line)
69 if (m) files.push({ path: m[3], added: m[1] === '-' ? 0 : Number(m[1]), removed: m[2] === '-' ? 0 : Number(m[2]) })
70 }
71
72 return files
73}
74
75/** Lines in a file's text, for a new file git has no diff for yet. */
76export const countLines = (s: string) => (s === '' ? 0 : s.split(/\r?\n/).length - (s.endsWith('\n') ? 1 : 0))
77
78export const totals = (files: readonly ChangedFile[]) => ({
79 added: files.reduce((n, f) => n + f.added, 0),
80 removed: files.reduce((n, f) => n + f.removed, 0),
81})
82
83/** A plan counts as live while it is unfinished, or for a while after it finished. */
84export const PLAN_LINGER_MS = 10 * 60_000
85export const isPlanLive = (p: PlanProgress | null | undefined, now: number, updatedAt: number) =>
86 !!p && (p.done < p.total || now - updatedAt < PLAN_LINGER_MS)
87hooks/testrun.ts 57 lines1// dev-dash: spotting a test run in a shell command, for the per-session test badge.
2// Pure functions only, so they test without a session. register.tsx records the outcome of each call.
3//
4// Only a short runner label ("npm test", "pytest") is ever kept, never the command line itself:
5// a command can carry arguments, paths or secrets.
6
7import type { TestRun } from '../types'
8
9/** [pattern, label]. Matched against one command segment with quoted text already removed. */
10const RUNNERS: ReadonlyArray<readonly [RegExp, string]> = [
11 [/\b(npm|pnpm|yarn|bun)\s+(?:run\s+)?(test)(?::\S+)?(?=\s|$)/, '$1 $2'],
12 [/\bnpx\s+(vitest|jest|mocha)\b/, '$1'],
13 [/(?:^|\s)(vitest|jest|mocha)(?=\s|$)/, '$1'],
14 [/\bpython3?\s+-m\s+(pytest|unittest)\b/, '$1'],
15 [/(?:^|\s)(pytest|tox|nox|rspec|phpunit)(?=\s|$)/, '$1'],
16 [/\bgo\s+test\b/, 'go test'],
17 [/\bcargo\s+(test|nextest)\b/, 'cargo $1'],
18 [/\bdotnet\s+test\b/, 'dotnet test'],
19 [/\b(mvn|mvnw)\b(?:\s+\S+)*?\s+test\b/, '$1 test'],
20 [/\bgradlew?\s+test\b/, 'gradle test'],
21 [/\bclaude\s+plugin\s+test\b/, 'claude plugin test'],
22]
23
24/** Commands that mention a runner without running it (searching, printing, committing). */
25const NOT_RUNNING = new Set(['echo', 'printf', 'grep', 'egrep', 'rg', 'cat', 'type', 'ls', 'dir', 'find', 'head', 'tail', 'sed', 'awk', 'git', 'gh', 'which', 'where', 'man', 'help', 'less', 'more', 'select-string', 'get-content'])
26
27/** The command with anything inside quotes blanked, so `git commit -m "fix jest"` mentions nothing. */
28const unquoted = (cmd: string) => cmd.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, ' ')
29
30/** `cd x && npm test | tee out` -> ['cd x', 'npm test', 'tee out'] */
31const segments = (cmd: string) => unquoted(cmd).split(/&&|\|\||;|\||\r?\n/).map(s => s.trim()).filter(Boolean)
32
33/** A short runner label if the command runs a test suite, else null. */
34export function testRunnerOf(command: string): string | null {
35 for (const seg of segments(command)) {
36 const first = seg.split(/\s+/)[0].replace(/^.*[\\/]/, '').toLowerCase()
37 if (NOT_RUNNING.has(first)) continue
38 for (const [re, label] of RUNNERS) {
39 const m = re.exec(seg)
40 if (m) return label.replace(/\$(\d)/g, (_, i) => m[Number(i)] ?? '').trim()
41 }
42 }
43
44 return null
45}
46
47/** The outcome of a call that ran `command`, or null when it was not a test run. */
48export const testRunOf = (command: string, isOk: boolean, at: number): TestRun | null => {
49 const runner = testRunnerOf(command)
50
51 return runner ? { ok: isOk, at, runner } : null
52}
53
54/** The badge text for a session row: `✓ tests pass · npm test · 2m ago`. */
55export const testBadge = (t: TestRun | null | undefined, now: number, ago: (ms: number) => string) =>
56 t ? `${t.ok ? '✓ tests pass' : '✗ tests failed'} · ${t.runner} · ${ago(now - t.at)} ago` : ''
57hooks/render.tsx 1047 lines1// dev-dash: the Pane's drawing. A drop-in for register.tsx's `ui.render` hook.
2//
3// Wiring (see design.md, "Dropping it in"):
4// import { registerDashPane, pushActivity, activity } from './render'
5// registerDashPane(on, { onHide: () => { ctx.isOpen = false } }) // replaces the old on('ui.render', ...)
6// // optional, feeds the header sparkline; outside any render hook:
7// await update($, activity, h => pushActivity(h, sessions))
8//
9// Only elements from `$.ui.resolve(e)` (Box, Text, Button), JSX on `h`, no DOM/Node.
10// Width comes from the Pane's `e.props.bodyColumns` (the box inside the frame),
11// then `e.viewport.columns`, then 60.
12
13import { atom, read, update } from 'claude-code'
14import type { On } from 'claude-code'
15
16import type { AgentRow, AgentState, CiState, DashSection, DiskRow, EventRow, LimitRow, PrRow, SessionRow, SessionState, Snapshot } from '../types'
17import { attentionOf, isLimitAtRisk, itemsOf } from './attention'
18import { claudeBeat, effortOf, lane, tipRow } from './beat'
19import { HELP_KEYS, actionsFor, dismissAdd, ids, itemById, snoozeAdd } from './keys'
20import type { Item, Muting, RowAction } from './keys'
21import { bytes, isDiskLow } from './monitor'
22import { TABS, isTab } from './entertainment'
23import { buddyRuns, drawScene, getBuddy, moodOf, setStage, toRaster } from './buddy'
24import { applyLook, applyTheme, themeByName } from './themes'
25import type { Look, Roles } from './themes'
26import { progressSections } from './progress-view'
27import { customView, entertainmentView } from './tabs-view'
28import { testBadge } from './testrun'
29import { watchingSection } from './watch-view'
30
31export const PANE = 'dev-dash'
32
33export const snap = atom({ plugin: 'dev-dash', key: 'snap' } as const, null)
34export const collapsed = atom({ plugin: 'dev-dash', key: 'collapsed' } as const, [])
35export const expanded = atom({ plugin: 'dev-dash', key: 'expanded' } as const, [])
36export const blink = atom({ plugin: 'dev-dash', key: 'blink' } as const, 0)
37export const activity = atom({ plugin: 'dev-dash', key: 'activity' } as const, [])
38export const openRow = atom({ plugin: 'dev-dash', key: 'openRow' } as const, '')
39export const snoozed = atom({ plugin: 'dev-dash', key: 'snoozed' } as const, {})
40export const dismissed = atom({ plugin: 'dev-dash', key: 'dismissed' } as const, [])
41export const showHelp = atom({ plugin: 'dev-dash', key: 'help' } as const, false)
42export const tabState = atom({ plugin: 'dev-dash', key: 'tab' } as const, 'dashboard')
43
44// ---------------------------------------------------------------------------
45// Palette: semantic roles on the 8 ANSI names, which every terminal theme
46// (dark or light) remaps to readable values. No hex: a fixed hex that reads on
47// black can vanish on white. Swap the right-hand side to retheme.
48// ---------------------------------------------------------------------------
49// A live table: applyTheme (themes.ts) points it at the chosen theme at the start of each render, so every colour
50// below is read when it is drawn. `you` is the colour of "this needs you".
51export const LOOK: Look = { border: 'round', caps: false }
52export const TONE: Roles = {
53 you: 'yellow',
54 ok: 'green',
55 warn: 'yellow',
56 bad: 'red',
57 info: 'cyan',
58 accent: 'magenta',
59 mute: 'gray',
60}
61
62// Read through getters: the theme can change between renders.
63const STATE_TONE = {
64 get running() { return TONE.ok },
65 get waiting() { return TONE.you },
66 get idle() { return TONE.mute },
67 get ended() { return TONE.mute },
68} as Record<SessionState, string>
69const STATE_BADGE: Record<SessionState, string> = { running: ' RUN ', waiting: ' WAIT ', idle: ' IDLE ', ended: ' END ' }
70
71// ---------------------------------------------------------------------------
72// Pure helpers (no $, safe to unit test)
73// ---------------------------------------------------------------------------
74const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
75const pad2 = (n: number) => String(n).padStart(2, '0')
76
77/** Cut to at most `n` cells with a trailing ellipsis. */
78export const cut = (s: string, n: number) => (n <= 0 ? '' : s.length > n ? `${s.slice(0, Math.max(1, n - 1))}…` : s)
79
80/** Compact age: 42s, 7m, 3h05, 2d. */
81export const ago = (ms: number) => {
82 const s = Math.max(0, Math.round(ms / 1000))
83 if (s < 60) return `${s}s`
84 if (s < 3600) return `${Math.floor(s / 60)}m`
85 if (s < 86400) return `${Math.floor(s / 3600)}h${pad2(Math.floor((s % 3600) / 60))}`
86 return `${Math.floor(s / 86400)}d`
87}
88
89export const money = (n: number) => `$${n.toFixed(2)}`
90
91/** Segmented meter: bar(62, 5) === '▰▰▰▱▱'. */
92export const bar = (pct: number, cells: number) => {
93 const on = Math.round((clamp(pct, 0, 100) / 100) * cells)
94 return '▰'.repeat(on) + '▱'.repeat(Math.max(0, cells - on))
95}
96
97const SPARK = '▁▂▃▄▅▆▇█'
98/** Last `width` samples as block glyphs, scaled to the window's max (min scale 1). */
99export const sparkline = (values: readonly number[], width: number) => {
100 const v = values.slice(-Math.max(0, width))
101 if (v.length === 0) return ''
102 const max = Math.max(1, ...v)
103 return v.map(x => SPARK[clamp(Math.round((x / max) * 7), 0, 7)]).join('')
104}
105
106/** One activity sample per tick: sessions that are running or waiting. Keeps the last `keep`. */
107export const pushActivity = (
108 history: readonly number[] | undefined,
109 sessions: readonly SessionRow[],
110 agents: readonly AgentRow[] = [],
111 toolCalls = 0,
112 keep = 48,
113) =>
114 [
115 ...(history ?? []),
116 effortOf(
117 sessions.filter(r => r.state === 'running').length,
118 sessions.filter(r => r.state === 'waiting').length,
119 agents.filter(a => a.state === 'working').length,
120 toolCalls,
121 ),
122 ].slice(-keep)
123
124export const ctxTone = (pct: number) => (pct >= 80 ? TONE.bad : pct >= 60 ? TONE.warn : TONE.ok)
125
126/** Responsive metrics for a body `cols` wide. Narrow below 60. */
127export const layoutFor = (cols: number | undefined) => {
128 const W = clamp(Math.floor(cols ?? 60), 30, 140)
129 const isNarrow = W < 60
130 return {
131 W,
132 isNarrow,
133 barCells: isNarrow ? 5 : 10,
134 sparkCells: clamp(W - 26, 8, 32),
135 branchRows: isNarrow ? 4 : 6,
136 sessionRows: isNarrow ? 6 : 10,
137 }
138}
139
140const where = (r: SessionRow) => (r.branch ? `${r.repo}@${r.branch}` : r.repo)
141const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
142const FOCUS_FOLD: DashSection[] = ['sessions', 'agents', 'monitor', 'work', 'prs']
143const toggle = (list: readonly DashSection[] | undefined, id: DashSection) =>
144 (list ?? []).includes(id) ? (list ?? []).filter(x => x !== id) : [...(list ?? []), id]
145
146// ---------------------------------------------------------------------------
147// Monitoring helpers
148// ---------------------------------------------------------------------------
149export const limitTone = (l: LimitRow, now: number) =>
150 l.pct >= 90 || isLimitAtRisk(l, now) ? TONE.bad : l.pct >= 70 ? TONE.warn : TONE.ok
151
152export const limitNote = (l: LimitRow, now: number) => {
153 const resets = l.resetsAt !== null ? `resets in ${ago(l.resetsAt - now)}` : ''
154 if (isLimitAtRisk(l, now)) return `out in ~${ago(l.etaMs ?? 0)} at this pace${resets ? ` · ${resets}` : ''}`
155 return resets || 'no reset time'
156}
157
158const AGENT_LOOK: Record<AgentState, { glyph: string; tone: string; word: string }> = {
159 working: { glyph: '◐', get tone() { return TONE.ok }, word: 'working' },
160 quiet: { glyph: '◌', get tone() { return TONE.warn }, word: 'quiet' },
161 done: { glyph: '✓', get tone() { return TONE.mute }, word: 'done' },
162 stopped: { glyph: '■', get tone() { return TONE.mute }, word: 'stopped' },
163}
164
165/** A spinner frame for working agents, advancing every sync. */
166const SPIN = '◐◓◑◒'
167export const spinFrame = (at: number) => SPIN[Math.floor(at / 5000) % SPIN.length]
168
169const EVENT_TONE = {
170 get ok() { return TONE.ok },
171 get warn() { return TONE.warn },
172 get bad() { return TONE.bad },
173 get info() { return TONE.info },
174} as Record<EventRow['tone'], string>
175const EVENT_GLYPH: Record<EventRow['tone'], string> = { ok: '✓', warn: '◆', bad: '✗', info: '·' }
176
177export const clockOf = (at: number) => {
178 const d = new Date(at)
179 return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
180}
181
182const CI: Record<CiState, { glyph: string; tone: string; inverse: boolean }> = {
183 failing: { glyph: ' ✗ CI ', get tone() { return TONE.bad }, inverse: true },
184 pending: { glyph: '◌ CI', get tone() { return TONE.warn }, inverse: false },
185 passing: { glyph: '✓ CI', get tone() { return TONE.ok }, inverse: false },
186 none: { glyph: '· no CI', get tone() { return TONE.mute }, inverse: false },
187}
188
189const reviewChip = (p: PrRow): { text: string; tone: string } => {
190 if (p.review === 'approved') return { text: '✓ approved', tone: TONE.ok }
191 if (p.review.startsWith('changes')) return { text: '± changes', tone: TONE.bad }
192 if (p.review.includes('required')) return { text: '… review', tone: TONE.warn }
193 return { text: 'no review', tone: TONE.mute }
194}
195
196// ---------------------------------------------------------------------------
197// The hook
198// ---------------------------------------------------------------------------
199export function registerDashPane(on: On, hooks: { onHide?: () => void } = {}) {
200 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
201 const { Box, Button, Text } = $.ui.resolve(e)
202 const s = await read($, snap)
203 applyTheme(TONE, s?.theme)
204 applyLook(LOOK, s?.theme)
205 const folded = (await read($, collapsed)) ?? []
206 const opened2 = (await read($, expanded)) ?? []
207 const tickN = (await read($, blink)) ?? 0
208 const isPulse = Math.floor(tickN / 2) % 2 === 0 // one second on, one second off
209 const samples = (await read($, activity)) ?? []
210 const L = layoutFor(e.props.bodyColumns ?? (e.viewport ? e.viewport.columns - 2 : undefined))
211 const { W, isNarrow } = L
212 const now = Date.now()
213 const muting: Muting = {
214 snoozed: (await read($, snoozed)) ?? {},
215 dismissed: (await read($, dismissed)) ?? [],
216 now,
217 }
218 const opened = (await read($, openRow)) || ''
219 const isHelpOn = (await read($, showHelp)) === true
220 const storedTab = await read($, tabState)
221 const tab = isTab(storedTab) ? storedTab : 'dashboard'
222
223 // A card: the Flight Deck look. A rounded border in the section's colour; the heading is its first line.
224 const Frame = (p: { tone?: string; children?: unknown }) => (
225 <Box borderStyle={LOOK.border} borderColor={p.tone ?? TONE.mute} paddingX={1} flexDirection="column" width={W}>
226 {p.children}
227 </Box>
228 )
229
230 // A section heading: `▾ Title meta`, and folded `▸ Title summary`.
231 // The plain Button with a hotkey is the fold control (pressable while the pane holds the keys).
232 const Heading = (p: { id: DashSection; hotkey: string; title: string; tone?: string; summary: string }) => {
233 const isOpen = !shut.includes(p.id)
234 return (
235 <Box flexDirection="row">
236 <Button
237 key={`fold-${p.id}`}
238 plain
239 hotkey={p.hotkey}
240 label={isOpen ? '▾' : '▸'}
241 onPress={() => (isFocus && FOCUS_FOLD.includes(p.id) ? update($, expanded, list => toggle(list, p.id)) : update($, collapsed, list => toggle(list, p.id)))}
242 />
243 <Text bold color={p.tone}> {LOOK.caps ? p.title.toUpperCase() : p.title} </Text>
244 <Box flexGrow={1} />
245 <Text dimColor wrap="truncate-end">{cut(p.summary, Math.max(8, W - p.title.length - 12))}</Text>
246 </Box>
247 )
248 }
249
250 // What the buttons under an opened row do. (The row's own press only opens or closes them.)
251 const undo = async () => {
252 await update($, snoozed, () => ({}))
253 await update($, dismissed, () => [])
254 return $.ui.toast('Brought back what you snoozed or dismissed')
255 }
256 const runAction = async (action: RowAction['id'], it: Item) => {
257 await update($, openRow, () => '')
258 if (action === 'copy') {
259 const r = await $.ui.copy({ text: it.copy, surface: e.surface })
260 return $.ui.toast(r.isCopied ? `Copied the ${it.what}` : 'Could not copy to the clipboard')
261 }
262 if (action === 'snooze') {
263 await update($, snoozed, m => snoozeAdd(m ?? {}, it.id, Date.now()))
264 return $.ui.toast('Snoozed for 15 minutes')
265 }
266 await update($, dismissed, d => dismissAdd(d ?? [], it.id))
267 return $.ui.toast('Dismissed until it changes')
268 }
269
270 // A selectable row. Its marker is a stop for Tab and the arrow keys; Enter on it opens or closes the row's
271 // actions, which are buttons right under it. The first marker takes the focus ring when the pane gets the keyboard.
272 const Row = (p: { id: string; children?: unknown }) => {
273 const it = itemById(items, p.id)
274 const isOpen = p.id === opened
275 return (
276 <Box key={`row-${p.id}`} flexDirection="column">
277 <Box flexDirection="row">
278 <Button
279 key={`pick-${p.id}`}
280 plain
281 dimColor
282 autoFocus={p.id === items[0]?.id ? true : undefined}
283 label={isOpen ? '▾' : '›'}
284 onPress={() => update($, openRow, v => (v === p.id ? '' : p.id))}
285 />
286 <Text> </Text>
287 <Box flexDirection="column" flexGrow={1}>{p.children}</Box>
288 </Box>
289 {isOpen && it && (
290 <Box paddingLeft={2} flexDirection="row" flexWrap="wrap" columnGap={1}>
291 {actionsFor(it).map(a => (
292 <Button key={`act-${a.id}-${p.id}`} label={a.label} onPress={() => runAction(a.id, it)} />
293 ))}
294 </Box>
295 )}
296 </Box>
297 )
298 }
299
300 if (!s) {
301 return (
302 <Box flexDirection="column">
303 <Box borderStyle="round" borderColor={TONE.mute} paddingX={1} width={W}>
304 <Text dimColor>Collecting… first sync takes a few seconds</Text>
305 </Box>
306 </Box>
307 )
308 }
309
310 const att = attentionOf(s, muting)
311 // Flight Deck focus: while something urgent needs you, the five big cards fold to one line each (open one with its
312 // arrow). `shut` is what is folded right now: the focus fold for those five, the person's own folds for the rest.
313 const isFocus = s.focusOn !== false && att.urgent > 0
314 const shut: DashSection[] = [
315 ...folded.filter(id => !(isFocus && FOCUS_FOLD.includes(id))),
316 ...(isFocus ? FOCUS_FOLD.filter(id => !opened2.includes(id)) : []),
317 ]
318 const caps = { reviews: isNarrow ? 3 : 5, sessions: L.sessionRows, agents: isNarrow ? 4 : 8 }
319 const items = itemsOf(s, att, shut, caps)
320 const g = s.git
321 const costs = s.sessions.map(r => r.costUsd).filter((c): c is number => c !== null)
322 const cost = costs.length ? costs.reduce((a, b) => a + b, 0) : null
323 const live = s.sessions.filter(r => r.state === 'running').length
324 const headTone = att.urgent > 0 ? TONE.you : att.total > 0 ? TONE.info : TONE.ok
325 const agents = s.agents ?? []
326 const busyAgents = agents.filter(a => a.state === 'working' || a.state === 'quiet')
327 const limits = s.limits ?? []
328 // "claude beat": a line graph of how much effort Claude put in at each sync, flat along the bottom when idle.
329 const beat = claudeBeat(samples, L.sparkCells, isNarrow ? 4 : 5)
330 const tipAt = tipRow(samples, L.sparkCells, isNarrow ? 4 : 5)
331 // A buddy (an animated character from a pack) can stand in for the claude beat: /dash-beat buddy <name>.
332 // A sprite pack is drawn at the slot's size and the current time; on the terminal it is one Raster that a timer
333 // repaints in place (smooth motion without redrawing the pane), elsewhere text runs that step with the pane.
334 const buddy = s.beatStyle === 'buddy' ? getBuddy() : null
335 const buddyMood = moodOf(att.urgent, live)
336 const slot = { cols: L.sparkCells, rows: isNarrow ? 4 : 5 }
337 const els = $.ui.resolve(e)
338 const Raster = e.surface === 'terminal' && 'Raster' in els ? els.Raster : undefined
339 const isRaster = buddy?.kind === 'sprite' && Raster !== undefined
340 setStage(isRaster ? { mood: buddyMood, ...slot } : null)
341 const buddyRows = buddy && !isRaster ? buddyRuns(buddy, buddyMood, now, tickN, slot.cols, slot.rows) : []
342 const buddyHeight = buddy?.kind === 'cells' ? buddy.rows : slot.rows
343
344 // ---- header card ------------------------------------------------------
345 const header = (
346 <Box borderStyle={LOOK.border} borderColor={headTone} paddingX={1} flexDirection="column" width={W}>
347 <Box flexDirection="row" justifyContent="center">
348 <Text bold>DEV-DASH</Text>
349 </Box>
350 <Box flexDirection="row" justifyContent="center" columnGap={1}>
351 <Text color={TONE.you}>■ you</Text>
352 <Text color={TONE.info}>■ sessions</Text>
353 <Text color={TONE.accent}>■ agents</Text>
354 <Text color={TONE.ok}>■ limits</Text>
355 <Text color={TONE.warn}>■ PRs</Text>
356 </Box>
357 <Box flexDirection="row" flexWrap="wrap">
358 <Text bold color={headTone}>{att.total > 0 ? `◆ ${att.total} need${att.total === 1 ? 's' : ''} you` : '✓ all clear'}</Text>
359 <Text dimColor> · {plural(s.sessions.length, isNarrow ? 'sess' : 'session', isNarrow ? 'sess' : 'sessions')}</Text>
360 {live > 0 && <Text color={TONE.ok}> · {live} live</Text>}
361 {busyAgents.length > 0 && (
362 <Text color={TONE.accent}> · {plural(busyAgents.length, 'agent')}</Text>
363 )}
364 {cost !== null && <Text dimColor> · {money(cost)}</Text>}
365 </Box>
366 {limits.length > 0 && (
367 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
368 {limits.map(l => (
369 <Text color={limitTone(l, now)}>
370 {l.kind} {bar(l.pct, isNarrow ? 5 : 8)} {Math.round(l.pct)}%
371 </Text>
372 ))}
373 </Box>
374 )}
375 <Box flexDirection="row" columnGap={1}>
376 <Box flexDirection="column">
377 <Text bold color={TONE.bad}>{buddy ? `♥ ${buddy.name}` : '♥ claude beat'}</Text>
378 <Text dimColor wrap="truncate-end">{buddy ? `${buddyMood} · ` : ''}synced {ago(now - s.updatedAt)} ago</Text>
379 </Box>
380 <Box flexDirection="column">
381 {buddy && isRaster && Raster && buddy.kind === 'sprite' && (
382 <Raster key="buddy" columns={slot.cols} rows={slot.rows} cells={toRaster(drawScene(buddy, buddyMood, now, slot.cols, slot.rows))} />
383 )}
384 {buddy && !isRaster &&
385 Array.from({ length: buddyHeight }, (_, i) => (
386 <Text>
387 {(buddyRows[i] ?? []).map(([t, fg, bg]) => (
388 <Text color={fg ?? undefined} backgroundColor={bg ?? undefined}>{t}</Text>
389 ))}
390 {(buddyRows[i] ?? []).length === 0 ? ' ' : ''}
391 </Text>
392 ))}
393 {!buddy && beat.map((row, at) => {
394 // The tip of the line (its newest sample) blinks, so a quiet pane still shows that it is alive.
395 const tip = row.slice(-1)
396 const isTip = at === tipAt && tip !== ' ' && row.length > 1
397 return (
398 <Text bold color={TONE.bad}>
399 {isTip ? row.slice(0, -1) : row}
400 {isTip ? <Text bold={isPulse} dimColor={!isPulse} color={TONE.bad}>{isPulse ? '●' : '○'}</Text> : null}
401 </Text>
402 )
403 })}
404 </Box>
405 </Box>
406 </Box>
407 )
408
409 // ---- Focus: the one thing that needs you grows into a card; the rest wait in a short queue -------------------
410 type Need = { id: string; tone: string; glyph: string; name: string; verb: string; say: string; age: string; row?: SessionRow }
411 const needs: Need[] = [
412 ...att.waiting.map(r => ({ id: ids.wait(r), tone: TONE.you, glyph: '◆', name: r.name || where(r), verb: 'waiting on you', say: r.waitingFor || 'waiting', age: ago(now - r.stateSince), row: r })),
413 ...att.stuck.map(r => ({ id: ids.stuck(r), tone: TONE.bad, glyph: '⟳', name: r.name || where(r), verb: 'may be stuck', say: String(r.stuck), age: ago(now - r.stateSince), row: r })),
414 ...att.risky.map(r => ({ id: ids.risky(r), tone: TONE.bad, glyph: '⚡', name: r.name || where(r), verb: 'ran something risky', say: String(r.risky), age: ago(now - r.stateSince), row: r })),
415 ...att.disks.map(d => ({ id: ids.disk(d), tone: TONE.bad, glyph: '▼', name: `disk ${d.name}`, verb: 'is low', say: `${bytes(d.freeBytes)} free of ${bytes(d.totalBytes)}`, age: '' })),
416 ...att.collisions.map(c => ({ id: ids.clash(c.file), tone: TONE.bad, glyph: '⚠', name: c.file.split('/').pop() ?? c.file, verb: 'edited twice', say: `edited by ${c.sessions.join(' + ')}`, age: '' })),
417 ...att.limits.map(l => ({ id: ids.limit(l), tone: TONE.bad, glyph: '▲', name: `${l.kind} limit`, verb: `${Math.round(l.pct)}%`, say: limitNote(l, now), age: '' })),
418 ...att.failing.map(p => ({ id: ids.ci(p), tone: TONE.bad, glyph: '✗', name: `#${p.number}`, verb: p.conflicts ? 'has conflicts' : 'CI failing', say: `${p.repo ? `${p.repo} · ` : ''}${p.title}`, age: '' })),
419 ...att.reviews.map(p => ({ id: ids.review(p), tone: TONE.info, glyph: '◎', name: `#${p.number}`, verb: 'wants your review', say: `@${p.author} · ${p.repo ? `${p.repo} · ` : ''}${p.title}`, age: `${p.ageDays}d` })),
420 ]
421 const lead = needs[0]
422 const leadItem = lead ? itemById(items, lead.id) : undefined
423 const focus = lead ? (
424 <Frame tone={lead.tone}>
425 <Box flexDirection="row">
426 <Text bold color={lead.tone} wrap="truncate-end">{lead.glyph} {cut(lead.name, W - 30)} · {lead.verb}</Text>
427 <Box flexGrow={1} />
428 <Text dimColor>{lead.age ? `${lead.age} · ` : ''}1 of {needs.length}</Text>
429 </Box>
430 {lead.row && (
431 <Text dimColor wrap="truncate-end">
432 {[where(lead.row), lead.row.contextPct !== null ? `ctx ${Math.round(lead.row.contextPct)}%` : '', lead.row.costUsd !== null ? money(lead.row.costUsd) : ''].filter(Boolean).join(' · ')}
433 </Text>
434 )}
435 <Text wrap="wrap">“{cut(lead.say, W * 2 - 12)}”</Text>
436 {lead.row?.lastTool && (
437 <Text dimColor wrap="truncate-end">last step <Text color={TONE.info}>{lead.row.lastTool}</Text></Text>
438 )}
439 {leadItem && (
440 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
441 {actionsFor(leadItem).map(a => (
442 <Button key={`focus-${a.id}`} label={a.label} onPress={() => runAction(a.id, leadItem)} />
443 ))}
444 </Box>
445 )}
446 {needs.slice(1, 3).map((n, i) => (
447 <Box flexDirection="row">
448 <Text dimColor>{i === 0 ? 'next ' : 'then '}</Text>
449 <Text color={n.tone} wrap="truncate-end">{n.glyph} {cut(`${n.name} ${n.verb}`, W - 20)}</Text>
450 <Box flexGrow={1} />
451 <Text dimColor>{n.age}</Text>
452 </Box>
453 ))}
454 </Frame>
455 ) : (
456 <Frame tone={TONE.ok}>
457 <Box flexDirection="row">
458 <Text bold color={TONE.ok}>✓ nothing needs you</Text>
459 <Box flexGrow={1} />
460 <Text dimColor>checked {ago(now - s.updatedAt)} ago</Text>
461 </Box>
462 <Text wrap="truncate-end">{s.sessions.length === 0 ? 'No live sessions.' : `${plural(live, 'session')} working, ${s.sessions.length - live} idle or waiting.`}</Text>
463 {(s.events ?? [])[0] && (
464 <Text dimColor wrap="truncate-end">last: {cut(s.events[0].text, W - 22)} · {ago(now - s.events[0].at)} ago</Text>
465 )}
466 </Frame>
467 )
468
469 // ---- Attention ----------------------------------------------------------
470 const attention = (
471 <Frame tone={headTone}>
472 <Heading
473 id="attention"
474 hotkey="1"
475 title={att.total > 0 ? `Attention (${att.total})` : 'Attention'}
476 tone={att.total > 0 ? headTone : TONE.ok}
477 summary={
478 att.total > 0
479 ? [
480 att.waiting.length ? `${att.waiting.length} waiting` : '',
481 att.stuck.length ? `${att.stuck.length} stuck` : '',
482 att.risky.length ? `${att.risky.length} risky` : '',
483 att.disks.length ? 'disk' : '',
484 att.collisions.length ? `${att.collisions.length} clash` : '',
485 att.limits.length ? 'limit' : '',
486 att.failing.length ? `${att.failing.length} failing` : '',
487 att.reviews.length ? `${att.reviews.length} review` : '',
488 ]
489 .filter(Boolean)
490 .join(' · ')
491 : 'all clear'
492 }
493 />
494 {!folded.includes('attention') && (
495 <Box flexDirection="column">
496 {att.total === 0 && <Text color={TONE.ok}>✓ Nothing needs you.</Text>}
497 {att.total === 0 && (
498 <Text dimColor wrap="truncate-end">
499 {s.sessions.length === 0 ? 'No live sessions.' : `${plural(live, 'session')} working, the rest idle.`}
500 </Text>
501 )}
502 {att.waiting.map(r => (
503 <Row id={ids.wait(r)}>
504 <Text color={TONE.warn} wrap="truncate-end">
505 ◆ {cut(r.name || where(r), W - 22)} · {r.waitingFor || 'waiting'} {ago(now - r.stateSince)}
506 </Text>
507 </Row>
508 ))}
509 {att.stuck.map(r => (
510 <Row id={ids.stuck(r)}>
511 <Text color={TONE.bad} wrap="truncate-end">
512 ⟳ {cut(r.name || where(r), W - 30)} may be stuck · {r.stuck}
513 </Text>
514 </Row>
515 ))}
516 {att.risky.map(r => (
517 <Row id={ids.risky(r)}>
518 <Text color={TONE.bad} wrap="truncate-end">
519 ⚡ {cut(r.name || where(r), W - 30)} ran {r.risky}
520 </Text>
521 </Row>
522 ))}
523 {att.disks.map(d => (
524 <Row id={ids.disk(d)}>
525 <Text color={TONE.bad} wrap="truncate-end">
526 ▼ disk {d.name} low · {bytes(d.freeBytes)} free of {bytes(d.totalBytes)}
527 </Text>
528 </Row>
529 ))}
530 {att.collisions.map(c => (
531 <Row id={ids.clash(c.file)}>
532 <Text color={TONE.bad} wrap="truncate-end">
533 ⚠ {cut(c.file.split('/').pop() ?? c.file, 28)} edited by {c.sessions.join(' + ')}
534 </Text>
535 </Row>
536 ))}
537 {att.limits.map(l => (
538 <Row id={ids.limit(l)}>
539 <Text color={TONE.bad} wrap="truncate-end">
540 ▲ {l.kind} limit {Math.round(l.pct)}% · {limitNote(l, now)}
541 </Text>
542 </Row>
543 ))}
544 {att.failing.map(p => (
545 <Row id={ids.ci(p)}>
546 <Text color={TONE.bad} wrap="truncate-end">
547 ✗ #{p.number} {p.conflicts ? 'conflicts' : 'CI failing'} · {p.repo ? `${p.repo} · ` : ''}{cut(p.title, W - 26 - p.repo.length)}
548 </Text>
549 </Row>
550 ))}
551 {att.reviews.slice(0, caps.reviews).map(p => (
552 <Row id={ids.review(p)}>
553 <Text color={TONE.info} wrap="truncate-end">
554 ◎ review #{p.number} @{p.author} · {p.repo ? `${p.repo} · ` : ''}{p.ageDays}d · {cut(p.title, W - 32 - p.author.length - p.repo.length)}
555 </Text>
556 </Row>
557 ))}
558 {att.reviews.length > caps.reviews && (
559 <Text dimColor>+{att.reviews.length - caps.reviews} more reviews</Text>
560 )}
561 {att.hidden > 0 && (
562 <Box flexDirection="row" columnGap={1}>
563 <Text dimColor>+{att.hidden} snoozed or dismissed</Text>
564 <Button key="act-undo" label="bring back" onPress={undo} />
565 </Box>
566 )}
567 </Box>
568 )}
569 </Frame>
570 )
571
572 // ---- Sessions -------------------------------------------------------------
573 const sessionRow = (r: SessionRow) => {
574 const isSelf = r.id === s.selfId
575 const label = r.name || where(r)
576 const head = `${cut(label, W - 26)}${isSelf ? ' (this)' : ''} · ${r.state === 'waiting' && r.waitingFor ? r.waitingFor : r.state} ${ago(now - r.stateSince)}`
577 const up = `up ${ago(now - r.startedAt)}`
578 const usage = [r.contextPct !== null ? `ctx ${Math.round(r.contextPct)}%` : '', r.costUsd !== null ? money(r.costUsd) : '', r.lastTool]
579 const who = [r.name ? where(r) : '', r.app, r.hasPlugin ? '' : 'no plugin', up]
580 const join = (xs: string[]) => xs.filter(Boolean).join(' · ')
581 const hasCtx = r.contextPct !== null
582 return (
583 <Row id={ids.session(r)}>
584 <Box flexDirection="column">
585 <Box flexDirection="row">
586 <Text inverse color={STATE_TONE[r.state]}>{STATE_BADGE[r.state]}</Text>
587 <Text bold={isSelf} wrap="truncate-end"> {head}</Text>
588 </Box>
589 {r.summary && (
590 <Box paddingLeft={7}>
591 <Text color={TONE.info} wrap="truncate-end">↳ {cut(r.summary, W - 10)}</Text>
592 </Box>
593 )}
594 {r.lastTest && (
595 <Box paddingLeft={7}>
596 <Text color={r.lastTest.ok ? TONE.ok : TONE.bad} wrap="truncate-end">{testBadge(r.lastTest, now, ago)}</Text>
597 </Box>
598 )}
599 {hasCtx ? (
600 <Box flexDirection="row" paddingLeft={7}>
601 <Text color={ctxTone(r.contextPct ?? 0)}>{bar(r.contextPct ?? 0, L.barCells)} </Text>
602 <Text dimColor wrap="truncate-end">{join(isNarrow ? usage : [...usage, ...who])}</Text>
603 {(r.contextPct ?? 0) >= 80 && <Text color={TONE.bad}> compact soon</Text>}
604 </Box>
605 ) : (
606 <Box paddingLeft={7}>
607 <Text dimColor wrap="truncate-end">{join([...who, ...usage])}</Text>
608 </Box>
609 )}
610 {hasCtx && isNarrow && (
611 <Box paddingLeft={7}>
612 <Text dimColor wrap="truncate-end">{join(who)}</Text>
613 </Box>
614 )}
615 </Box>
616 </Row>
617 )
618 }
619 const sessions = (
620 <Frame tone={TONE.info}>
621 <Heading
622 id="sessions"
623 hotkey="2"
624 title={`Sessions (${s.sessions.length})`}
625 summary={`${live} running · ${att.waiting.length} waiting${cost !== null ? ` · ${money(cost)}` : ''}`}
626 />
627 {!shut.includes('sessions') && (
628 <Box flexDirection="column">
629 {s.sessions.length === 0 && <Text dimColor>No live Claude sessions.</Text>}
630 {s.sessions.length > 0 && !s.summariesOn && !s.sessions.some(r => r.summary) && (
631 <Text dimColor wrap="truncate-end">/dash-summaries on adds a one-line "doing" per session</Text>
632 )}
633 {s.sessions.slice(0, L.sessionRows).map(sessionRow)}
634 {s.sessions.length > L.sessionRows && <Text dimColor>+{s.sessions.length - L.sessionRows} more</Text>}
635 </Box>
636 )}
637 </Frame>
638 )
639
640 // ---- Agents -------------------------------------------------------------------
641 // One time axis for every agent shown, so the swimlanes line up: from the earliest start to now.
642 const laneFrom = Math.min(now, ...agents.slice(0, caps.agents).map(a => a.startedAt))
643 const laneCells = isNarrow ? 6 : 12
644 const agentRow = (a: AgentRow) => {
645 const look = AGENT_LOOK[a.state]
646 const isLive = a.state === 'working'
647 const age = isLive || a.state === 'quiet' ? `${ago(now - a.startedAt)}` : `${ago(now - a.lastActive)} ago`
648 return (
649 <Row id={ids.agent(a)}>
650 <Box flexDirection="column">
651 <Box flexDirection="row">
652 <Text color={look.tone} bold={isLive}>{isLive ? spinFrame(now) : look.glyph} </Text>
653 <Text bold={isLive} dimColor={!isLive && a.state !== 'quiet'} wrap="truncate-end">
654 {cut(a.description, W - 22)}
655 </Text>
656 <Text dimColor> · {look.word} {age}</Text>
657 </Box>
658 <Box paddingLeft={2} flexDirection="row">
659 <Text color={look.tone}>{lane(a.startedAt, isLive || a.state === 'quiet' ? now : a.lastActive, laneFrom, now, laneCells)} </Text>
660 <Text dimColor wrap="truncate-end">
661 {[a.type, a.sessionName, a.steps ? plural(a.steps, 'step') : ''].filter(Boolean).join(' · ')}
662 </Text>
663 </Box>
664 {a.doing && (isLive || a.state === 'quiet') && (
665 <Box paddingLeft={2}>
666 <Text color={TONE.info} wrap="truncate-end">↳ {cut(a.doing, W - 6)}</Text>
667 </Box>
668 )}
669 </Box>
670 </Row>
671 )
672 }
673 const agentRows = caps.agents
674 const agentsSection = (
675 <Frame tone={TONE.accent}>
676 <Heading
677 id="agents"
678 hotkey="3"
679 title={`Agents (${agents.length})`}
680 tone={busyAgents.length > 0 ? TONE.accent : undefined}
681 summary={`${busyAgents.length} working · ${agents.length - busyAgents.length} recent`}
682 />
683 {!shut.includes('agents') && (
684 <Box flexDirection="column">
685 {agents.length === 0 && <Text dimColor>No subagents in the last 30 minutes.</Text>}
686 {agents.slice(0, agentRows).map(agentRow)}
687 {agents.length > agentRows && <Text dimColor>+{agents.length - agentRows} more</Text>}
688 </Box>
689 )}
690 </Frame>
691 )
692
693 // ---- Monitor ------------------------------------------------------------------
694 const withCtx = s.sessions.filter(r => r.contextPct !== null)
695 const events = (s.events ?? []).slice(0, isNarrow ? 5 : 8)
696 const monitor = (
697 <Frame tone={att.limits.length > 0 ? TONE.bad : TONE.ok}>
698 <Heading
699 id="monitor"
700 hotkey="4"
701 title="Monitor"
702 tone={att.limits.length > 0 ? TONE.bad : undefined}
703 summary={[
704 ...limits.map(l => `${l.kind} ${Math.round(l.pct)}%`),
705 s.events?.length ? `${s.events.length} events` : '',
706 s.alertsOn ? 'alerts on' : 'alerts off',
707 ]
708 .filter(Boolean)
709 .join(' · ')}
710 />
711 {!shut.includes('monitor') && (
712 <Box flexDirection="column">
713 <Text dimColor>usage limits</Text>
714 {limits.length === 0 && (
715 <Text dimColor wrap="wrap">{' '}not reported yet (shows after the first reply on a Pro/Max plan)</Text>
716 )}
717 {limits.map(l => (
718 <Box flexDirection="row">
719 <Text>{' '}{l.kind.padEnd(isNarrow ? 3 : 9)} </Text>
720 <Text color={limitTone(l, now)}>{bar(l.pct, L.barCells)} {String(Math.round(l.pct)).padStart(3)}% </Text>
721 <Text dimColor={!isLimitAtRisk(l, now)} color={isLimitAtRisk(l, now) ? TONE.bad : undefined} wrap="truncate-end">
722 {limitNote(l, now)}
723 </Text>
724 </Box>
725 ))}
726 {withCtx.length > 0 && <Text dimColor>context</Text>}
727 {withCtx.map(r => {
728 const trend = (r.ctxTrend ?? []).length > 1 ? sparkline(r.ctxTrend, isNarrow ? 6 : 12) : ''
729 const cache = r.cacheHitPct !== null && r.cacheHitPct !== undefined ? `cache ${r.cacheHitPct}%` : ''
730 return (
731 <Box flexDirection="column">
732 <Box flexDirection="row">
733 <Text>{' '}</Text>
734 <Text color={ctxTone(r.contextPct ?? 0)}>{bar(r.contextPct ?? 0, L.barCells)} {String(Math.round(r.contextPct ?? 0)).padStart(3)}% </Text>
735 <Text dimColor wrap="truncate-end">{cut(r.name || where(r), W - L.barCells - 14)}</Text>
736 </Box>
737 {(trend || cache) && (
738 <Box flexDirection="row" paddingLeft={2}>
739 {trend && <Text color={TONE.info}>{trend} </Text>}
740 <Text dimColor>{[trend ? 'per turn' : '', cache].filter(Boolean).join(' · ')}</Text>
741 </Box>
742 )}
743 </Box>
744 )
745 })}
746 {(s.disks ?? []).length > 0 && <Text dimColor>disk</Text>}
747 {(s.disks ?? []).slice(0, 4).map((d: DiskRow) => {
748 const used = 100 - (d.freeBytes / d.totalBytes) * 100
749 return (
750 <Box flexDirection="row">
751 <Text>{' '}{cut(d.name, 8).padEnd(isNarrow ? 3 : 9)} </Text>
752 <Text color={isDiskLow(d) ? TONE.bad : used >= 80 ? TONE.warn : TONE.ok}>
753 {bar(used, L.barCells)} {String(Math.round(used)).padStart(3)}%{' '}
754 </Text>
755 <Text dimColor>{bytes(d.freeBytes)} free</Text>
756 </Box>
757 )
758 })}
759 <Text dimColor>events · alerts {s.alertsOn ? 'on' : 'off'} (a)</Text>
760 {events.length === 0 && <Text dimColor>{' '}quiet so far</Text>}
761 {events.map(ev => (
762 <Text color={EVENT_TONE[ev.tone]} wrap="truncate-end">
763 {' '}
764 <Text dimColor>{clockOf(ev.at)} </Text>
765 {EVENT_GLYPH[ev.tone]} {cut(ev.text, W - 12)}
766 </Text>
767 ))}
768 </Box>
769 )}
770 </Frame>
771 )
772
773 // ---- Work in flight -------------------------------------------------------
774 const sessionByCwd = new Map(s.sessions.map(r => [r.cwd.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(), r] as const))
775 const others = g ? g.branches.filter(b => b.name !== g.branch) : []
776 const merged = others.filter(b => b.isMerged)
777 const work = (
778 <Frame tone={TONE.mute}>
779 <Heading
780 id="work"
781 hotkey="5"
782 title="Work in flight"
783 summary={g ? `${g.branch} · ${g.dirty} dirty${merged.length ? ` · ${merged.length} merged` : ''}` : 'no repo'}
784 />
785 {!shut.includes('work') && (
786 <Box flexDirection="column">
787 {!g && <Text dimColor>Not a git repository.</Text>}
788 {g && (
789 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
790 <Text bold>{cut(g.branch, W - 6)}</Text>
791 {g.upstream ? (
792 <Text color={g.behind > 0 ? TONE.warn : undefined} dimColor={g.ahead + g.behind === 0}>↑{g.ahead} ↓{g.behind}</Text>
793 ) : (
794 <Text dimColor>no upstream</Text>
795 )}
796 {g.dirty > 0 ? <Text color={TONE.warn}>● {g.dirty} uncommitted</Text> : <Text color={TONE.ok}>✓ clean</Text>}
797 {g.stashes > 0 && <Text dimColor>≡ {plural(g.stashes, 'stash', 'stashes')}</Text>}
798 {(g.added ?? 0) + (g.removed ?? 0) > 0 && (
799 <Text>
800 <Text color={TONE.ok}>+{g.added}</Text> <Text color={TONE.bad}>−{g.removed}</Text>
801 <Text dimColor> not committed</Text>
802 </Text>
803 )}
804 </Box>
805 )}
806 {g &&
807 others.slice(0, L.branchRows).map(b => (
808 <Text color={b.isMerged ? TONE.accent : undefined} dimColor={!b.isMerged} wrap="truncate-end">
809 {b.isMerged ? '✓' : '·'} {cut(b.name, W - (b.isMerged ? 34 : 18))} · {b.age}
810 {b.isMerged ? ` · merged into ${g.base}` : ''}
811 </Text>
812 ))}
813 {g && others.length > L.branchRows && <Text dimColor>+{others.length - L.branchRows} more branches</Text>}
814 {g && g.worktrees.length > 1 && <Text dimColor>worktrees</Text>}
815 {g &&
816 g.worktrees.length > 1 &&
817 g.worktrees.map(w => {
818 const used = sessionByCwd.get(w.path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase())
819 const who = used ? (used.id === s.selfId ? 'this session' : `session ${used.id.slice(0, 8)}`) : ''
820 const name = w.path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? w.path
821 return (
822 <Text dimColor wrap="truncate-end">
823 {' '}{cut(name, 18)} [{w.branch}]{who ? ` ← ${who}` : ''}
824 </Text>
825 )
826 })}
827 </Box>
828 )}
829 </Frame>
830 )
831
832 // ---- PRs & CI -------------------------------------------------------------
833 const mineRow = (p: PrRow) => {
834 const ci = CI[p.ci]
835 const rv = reviewChip(p)
836 return (
837 <Row id={ids.pr(p)}>
838 <Box flexDirection="column">
839 <Box flexDirection="row" columnGap={1}>
840 <Text bold>#{p.number}</Text>
841 {p.repo && <Text color={TONE.accent}>{cut(p.repo, isNarrow ? 12 : 20)}</Text>}
842 <Text color={ci.tone} inverse={ci.inverse}>{ci.glyph}</Text>
843 <Text color={rv.tone}>{rv.text}</Text>
844 {p.conflicts && <Text color={TONE.bad}>conflicts</Text>}
845 <Text dimColor>{p.ageDays}d</Text>
846 {!isNarrow && <Text wrap="truncate-end">{p.title}</Text>}
847 </Box>
848 {isNarrow && (
849 <Box paddingLeft={2}>
850 <Text dimColor wrap="truncate-end">{p.title}</Text>
851 </Box>
852 )}
853 </Box>
854 </Row>
855 )
856 }
857 const prs = s.prs
858 const prSummary = !prs ? 'loading' : prs.error ? 'gh unavailable' : `${prs.mine.length} mine · ${prs.toReview.length} to review`
859 const prSection = (
860 <Frame tone={TONE.warn}>
861 <Heading id="prs" hotkey="6" title="PRs & CI" summary={prSummary} />
862 {!shut.includes('prs') && (
863 <Box flexDirection="column">
864 {!prs && <Text dimColor>loading…</Text>}
865 {prs?.error && <Text color={TONE.warn} wrap="wrap">! {prs.error}</Text>}
866 {prs && !prs.error && (
867 <Box flexDirection="column">
868 <Text dimColor>mine ({prs.mine.length})</Text>
869 {prs.mine.length === 0 && <Text dimColor>{' '}none open</Text>}
870 {prs.mine.map(mineRow)}
871 <Text dimColor>to review ({prs.toReview.length})</Text>
872 {prs.toReview.length === 0 && <Text dimColor>{' '}inbox zero</Text>}
873 {prs.toReview.map(p => (
874 <Row id={ids.rv(p)}>
875 <Text wrap="truncate-end" color={p.ageDays >= 3 ? TONE.warn : undefined}>
876 #{p.number} @{p.author} · {p.repo ? `${p.repo} · ` : ''}{p.ageDays}d · {cut(p.title, W - 21 - p.author.length - p.repo.length)}
877 </Text>
878 </Row>
879 ))}
880 <Text dimColor>gh synced {ago(now - prs.fetchedAt)} ago</Text>
881 </Box>
882 )}
883 </Box>
884 )}
885 </Frame>
886 )
887
888 const framed = (node: unknown, tone: string) => (node ? <Frame tone={tone}>{node}</Frame> : null)
889 const watching = watchingSection({
890 Box,
891 Text,
892 Heading,
893 folded,
894 watches: s.watches ?? [],
895 iconStyle: s.iconStyle,
896 W,
897 now,
898 tone: TONE,
899 border: LOOK.border,
900 fmt: { ago, cut },
901 })
902 const progress = progressSections({
903 Box,
904 Text,
905 Heading,
906 folded,
907 sessions: s.sessions,
908 selfId: s.selfId,
909 W: W - 4,
910 isNarrow,
911 now,
912 tone: TONE,
913 fmt: { ago, cut, bar },
914 })
915
916 // ---- footer: the buttons for the whole pane, reached with the arrows like everything else ----------
917 const helpPanel = isHelpOn && (
918 <Box borderStyle={LOOK.border} borderColor={TONE.info} paddingX={1} flexDirection="column" width={W} marginTop={1}>
919 <Text bold>Keys</Text>
920 {HELP_KEYS.map(([key, what]) => (
921 <Text wrap="truncate-end">
922 <Text bold color={TONE.accent}>{key.padEnd(isNarrow ? 12 : 14)}</Text>
923 {cut(what, W - (isNarrow ? 18 : 20))}
924 </Text>
925 ))}
926 <Text dimColor wrap="truncate-end">A › marks a row; press it to see what you can do with that row.</Text>
927 </Box>
928 )
929
930 // refresh, alerts and close are answered by ui.press hooks in register.tsx (they need that module's state).
931 const footer = (
932 <Box flexDirection="column" marginTop={1}>
933 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
934 <Button key="key-refresh" label="refresh" onPress={() => undefined} />
935 <Button key="key-alerts" label={s.alertsOn ? 'alerts: on' : 'alerts: off'} onPress={() => undefined} />
936 <Button key="key-theme" label={`theme: ${themeByName(s.theme)?.label ?? 'Auto'}`} onPress={() => undefined} />
937 <Button key="key-help" label={isHelpOn ? 'hide help' : 'help'} onPress={() => update($, showHelp, v => !v)} />
938 <Button key="key-close" label="close" onPress={() => undefined} />
939 </Box>
940 <Text dimColor wrap="truncate-end">
941 {e.props.isFocused ? '↑ ↓ move · Enter press · Esc back to the prompt' : 'ctrl+x tab to use the keys'}
942 </Text>
943 </Box>
944 )
945
946 // ---- tabs: Dashboard is everything above; the other two are drawn by tabs-view.tsx -------------------
947 // Each tab is a Button, so Tab and the arrows reach them like everything else. On a tab with no rows the
948 // active tab takes the focus ring when the pane gets the keyboard.
949 const tabBar = (
950 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
951 {TABS.map(t => (
952 <Button
953 key={`tab-${t.id}`}
954 label={t.id === tab ? `● ${t.label}` : t.label}
955 variant={t.id === tab ? 'primary' : 'secondary'}
956 autoFocus={t.id === tab && tab !== 'dashboard' ? true : undefined}
957 onPress={() => update($, tabState, () => t.id)}
958 />
959 ))}
960 </Box>
961 )
962 // Away from the Dashboard tab the big header is replaced by one line, so a session waiting for you still shows.
963 const statusLine = (
964 <Box flexDirection="row" marginTop={1}>
965 <Text bold color={headTone}>{att.total > 0 ? `◆ ${att.total} need${att.total === 1 ? 's' : ''} you` : '✓ all clear'}</Text>
966 <Text dimColor wrap="truncate-end">{limits.length > 0 ? ` · ${limits.map(l => `${l.kind} ${Math.round(l.pct)}%`).join(' · ')}` : ''}{att.total > 0 ? ' · see the Dashboard tab' : ''}</Text>
967 </Box>
968 )
969 const tabView = { Box, Text, Button, W, now, tone: TONE, border: LOOK.border, fmt: { ago, cut } }
970
971 return (
972 <Box flexDirection="column" width={W}>
973 {tabBar}
974 {tab === 'dashboard' ? header : statusLine}
975 {helpPanel}
976 {tab === 'dashboard' && watching}
977 {tab === 'dashboard' && focus}
978 {tab === 'dashboard' && attention}
979 {tab === 'dashboard' && sessions}
980 {tab === 'dashboard' && agentsSection}
981 {tab === 'dashboard' && monitor}
982 {tab === 'dashboard' && work}
983 {tab === 'dashboard' && prSection}
984 {tab === 'dashboard' && framed(progress.plan, TONE.accent)}
985 {tab === 'dashboard' && framed(progress.sources, TONE.info)}
986 {tab === 'dashboard' && framed(progress.files, TONE.mute)}
987 {tab === 'entertainment' && entertainmentView(tabView, s.nowPlaying ?? null, s.spotifyOn === true)}
988 {tab === 'custom' && customView(tabView)}
989 {footer}
990 </Box>
991 )
992 })
993 // ---- The band above the prompt: one line, there while the pane is closed ----
994 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
995 const s = await read($, snap)
996 if (!s || !s.bandOn || s.paneOpen || e.props.hasSurvey) return next(e)
997 applyTheme(TONE, s.theme)
998 const { Box, Text } = $.ui.resolve(e)
999 const now = Date.now()
1000 const W = Math.max(30, e.props.bodyColumns)
1001 const att = attentionOf(s, {
1002 snoozed: (await read($, snoozed)) ?? {},
1003 dismissed: (await read($, dismissed)) ?? [],
1004 now,
1005 })
1006 const name = (r: SessionRow) => r.name || where(r)
1007
1008 // The single most urgent thing, in the same order as the Attention section.
1009 const first =
1010 att.waiting[0] ? `${name(att.waiting[0])} · ${att.waiting[0].waitingFor || 'waiting'} ${ago(now - att.waiting[0].stateSince)}`
1011 : att.stuck[0] ? `${name(att.stuck[0])} may be stuck`
1012 : att.risky[0] ? `${name(att.risky[0])} ran ${att.risky[0].risky}`
1013 : att.collisions[0] ? `${att.collisions[0].sessions.join(' + ')} editing the same file`
1014 : att.limits[0] ? `${att.limits[0].kind} limit ${limitNote(att.limits[0], now)}`
1015 : att.disks[0] ? `disk ${att.disks[0].name} low`
1016 : att.failing[0] ? `#${att.failing[0].number} ${att.failing[0].conflicts ? 'conflicts' : 'CI failing'}`
1017 : ''
1018 const lead =
1019 att.urgent > 0 ? { text: `◆ ${att.urgent} need${att.urgent === 1 ? 's' : ''} you`, tone: TONE.you }
1020 : att.reviews.length > 0 ? { text: `◎ ${plural(att.reviews.length, 'review')} waiting`, tone: TONE.info }
1021 : { text: '✓ all clear', tone: TONE.ok }
1022
1023 const self = s.sessions.find(r => r.id === s.selfId)
1024 const running = s.sessions.filter(r => r.state === 'running').length
1025 const agentsBusy = (s.agents ?? []).filter(a => a.state === 'working' || a.state === 'quiet').length
1026 const facts: Array<{ text: string; tone?: string }> = [
1027 ...(s.limits ?? []).map(l => ({ text: `${l.kind} ${Math.round(l.pct)}%`, tone: limitTone(l, now) })),
1028 ...(self?.contextPct !== null && self?.contextPct !== undefined ? [{ text: `ctx ${Math.round(self.contextPct)}%`, tone: ctxTone(self.contextPct) }] : []),
1029 ...(running > 0 ? [{ text: `● ${running} running`, tone: TONE.ok }] : []),
1030 ...(agentsBusy > 0 ? [{ text: `◐ ${plural(agentsBusy, 'agent')}`, tone: TONE.accent }] : []),
1031 ]
1032 const room = W - lead.text.length - 9
1033 const detail = first ? cut(first, Math.max(0, Math.min(room - 30, 60))) : ''
1034
1035 return (
1036 <Box flexDirection="row" width={W}>
1037 <Text bold={att.urgent > 0} color={lead.tone} dimColor={att.total === 0}>{lead.text}</Text>
1038 {detail && <Text color={lead.tone} wrap="truncate-end"> · {detail}</Text>}
1039 {facts.map(f => (
1040 <Text dimColor={att.total === 0} color={att.total === 0 ? undefined : f.tone}> {f.text}</Text>
1041 ))}
1042 <Text dimColor> /dash</Text>
1043 </Box>
1044 )
1045 })
1046}
1047hooks/buddy.ts 488 lines1// dev-dash: the "buddy", an animated character that can stand in for the claude beat (/dash-beat buddy <name>).
2//
3// A buddy is a pack made by tools/make-buddy.py: a JSON file this module checks and plays back; it runs nothing and
4// reads no network. Pure functions, so they test without a session. (The one piece of state, the loaded pack, is a
5// module variable the register hook fills and the pane reads.)
6//
7// Two kinds of pack:
8// - v1, "cells": every frame already drawn as coloured text runs. Played as is.
9// - v2, "sprite": small pixel sprites (a palette and rows of palette letters, about 10 pixels tall) plus, per mood, a
10// motion and an effect. The scene is drawn here, at the slot's own size and the current time: the sprite walks,
11// flies, hops or sleeps across the beat's slot while its effect (fire, sparks, a shadow ball, water, z's) fills the
12// rest, two pixels per terminal row in half blocks (▀ ▄ █, the block elements every terminal font has).
13
14export type BuddyMood = 'idle' | 'working' | 'needs'
15/** One run of text in one colour: [text, foreground, background]. */
16export type BuddyRun = [string, string | null, string | null]
17export type BuddyFrame = { ms: number; rows: BuddyRun[][] }
18
19export const MOTIONS = ['sit', 'stand', 'walk', 'run', 'fly', 'float', 'hop', 'shake'] as const
20export const EFFECTS = ['none', 'zzz', 'sparks', 'thunder', 'fire', 'shadow', 'water', 'leaves', 'dust'] as const
21export type BuddyMotion = (typeof MOTIONS)[number]
22export type BuddyFx = (typeof EFFECTS)[number]
23/** One mood of a sprite pack: how it moves, what it gives off, and its frames (rows of palette letters, '.' is clear). */
24export type SpriteMood = { motion: BuddyMotion; fx: BuddyFx; ms: number; frames: string[][] }
25
26export type CellPack = { kind: 'cells'; name: string; cols: number; rows: number; moods: Record<BuddyMood, BuddyFrame[]> }
27export type SpritePack = {
28 kind: 'sprite'
29 name: string
30 palette: Record<string, string>
31 /** Where effects come out (the mouth), in sprite pixels with the sprite facing right. */
32 mouth: [number, number]
33 moods: Record<BuddyMood, SpriteMood>
34}
35export type BuddyPack = CellPack | SpritePack
36
37/** A pack name is a file name inside one folder, so it may not contain a path. */
38export const NAME_OK = /^[a-z0-9][a-z0-9_-]{0,31}$/i
39
40const COLOUR = /^#[0-9a-f]{6}$/i
41const MAX_FRAMES = 12
42const MAX_ROWS = 8
43const MAX_COLS = 60
44const MAX_SPRITE_H = 16
45const MAX_SPRITE_W = 40
46const MAX_PALETTE = 32
47const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
48
49// ------------------------------------------------------------------------------------------------------- v1 cells
50const asRuns = (row: unknown): BuddyRun[] | null => {
51 if (!Array.isArray(row)) return null
52 const out: BuddyRun[] = []
53 for (const r of row) {
54 if (!Array.isArray(r) || typeof r[0] !== 'string') return null
55 const fg = r[1] ?? null
56 const bg = r[2] ?? null
57 if ((fg !== null && !(typeof fg === 'string' && COLOUR.test(fg))) || (bg !== null && !(typeof bg === 'string' && COLOUR.test(bg)))) return null
58 out.push([r[0], fg, bg])
59 }
60
61 return out
62}
63
64const asFrames = (v: unknown): BuddyFrame[] | null => {
65 if (!Array.isArray(v) || v.length === 0 || v.length > MAX_FRAMES) return null
66 const out: BuddyFrame[] = []
67 for (const f of v) {
68 if (typeof f !== 'object' || f === null || !Array.isArray((f as BuddyFrame).rows) || (f as BuddyFrame).rows.length > MAX_ROWS) return null
69 const rows: BuddyRun[][] = []
70 for (const row of (f as BuddyFrame).rows) {
71 const runs = asRuns(row)
72 if (!runs || runs.reduce((n, r) => n + r[0].length, 0) > MAX_COLS) return null
73 rows.push(runs)
74 }
75 const ms = Number((f as BuddyFrame).ms)
76 out.push({ ms: Number.isFinite(ms) ? clamp(ms, 50, 2000) : 200, rows })
77 }
78
79 return out
80}
81
82const parseCells = (p: { name?: unknown; moods: Record<string, unknown> }): CellPack | null => {
83 const idle = asFrames((p.moods.idle as { frames?: unknown } | undefined)?.frames)
84 if (!idle) return null
85 const working = asFrames((p.moods.working as { frames?: unknown } | undefined)?.frames) ?? idle
86 const needs = asFrames((p.moods.needs as { frames?: unknown } | undefined)?.frames) ?? working
87 const rows = Math.max(...[idle, working, needs].map(fr => Math.max(...fr.map(f => f.rows.length))))
88 const cols = Math.max(...[idle, working, needs].map(fr => Math.max(...fr.map(f => Math.max(0, ...f.rows.map(r => r.reduce((n, x) => n + x[0].length, 0)))))))
89
90 return { kind: 'cells', name: typeof p.name === 'string' ? p.name : 'buddy', cols, rows, moods: { idle, working, needs } }
91}
92
93// ------------------------------------------------------------------------------------------------------ v2 sprites
94const asSpriteMood = (v: unknown, palette: Record<string, string>): SpriteMood | null => {
95 if (typeof v !== 'object' || v === null) return null
96 const m = v as { motion?: unknown; fx?: unknown; ms?: unknown; frames?: unknown }
97 if (!Array.isArray(m.frames) || m.frames.length === 0 || m.frames.length > MAX_FRAMES) return null
98 const frames: string[][] = []
99 for (const f of m.frames) {
100 if (!Array.isArray(f) || f.length === 0 || f.length > MAX_SPRITE_H) return null
101 for (const row of f) {
102 if (typeof row !== 'string' || row.length > MAX_SPRITE_W) return null
103 for (const ch of row) if (ch !== '.' && !(ch in palette)) return null
104 }
105 frames.push(f as string[])
106 }
107 const ms = Number(m.ms)
108 // An unknown motion or effect (from a newer tool) plays as standing still with none, rather than refusing the pack.
109 const motion = (MOTIONS as readonly string[]).includes(String(m.motion)) ? (m.motion as BuddyMotion) : 'stand'
110 const fx = (EFFECTS as readonly string[]).includes(String(m.fx)) ? (m.fx as BuddyFx) : 'none'
111
112 return { motion, fx, ms: Number.isFinite(ms) ? clamp(ms, 60, 2000) : 250, frames }
113}
114
115const parseSprite = (p: { name?: unknown; palette?: unknown; mouth?: unknown; moods: Record<string, unknown> }): SpritePack | null => {
116 if (typeof p.palette !== 'object' || p.palette === null || Array.isArray(p.palette)) return null
117 const entries = Object.entries(p.palette as Record<string, unknown>)
118 if (entries.length === 0 || entries.length > MAX_PALETTE) return null
119 const palette: Record<string, string> = Object.create(null)
120 for (const [k, c] of entries) {
121 if (!/^[A-Za-z0-9]$/.test(k) || typeof c !== 'string' || !COLOUR.test(c)) return null
122 palette[k] = c.toLowerCase()
123 }
124 const idle = asSpriteMood(p.moods.idle, palette)
125 if (!idle) return null
126 const working = asSpriteMood(p.moods.working, palette) ?? idle
127 const needs = asSpriteMood(p.moods.needs, palette) ?? working
128 const mouth: [number, number] =
129 Array.isArray(p.mouth) && p.mouth.length === 2 && p.mouth.every(n => Number.isInteger(n) && n >= 0 && n < MAX_SPRITE_W)
130 ? [p.mouth[0] as number, p.mouth[1] as number]
131 : [Math.max(...idle.frames[0].map(r => r.length)) - 1, Math.floor(idle.frames[0].length / 2)]
132
133 return { kind: 'sprite', name: typeof p.name === 'string' ? p.name : 'buddy', palette, mouth, moods: { idle, working, needs } }
134}
135
136/** A pack from the text of its file, or null if it is not a pack this version can play. Moods that are missing fall back. */
137export function parsePack(text: string): BuddyPack | null {
138 let raw: unknown
139 try {
140 raw = JSON.parse(text)
141 } catch {
142 return null
143 }
144 if (typeof raw !== 'object' || raw === null) return null
145 const p = raw as { v?: unknown; name?: unknown; palette?: unknown; mouth?: unknown; moods?: Record<string, unknown> }
146 if (typeof p.moods !== 'object' || p.moods === null) return null
147 if (p.v === 1) return parseCells(p as { moods: Record<string, unknown> })
148 if (p.v === 2) return parseSprite(p as { moods: Record<string, unknown> })
149
150 return null
151}
152
153/** What the buddy is doing: hopping when something needs you, busy when a session is running, else resting. */
154export const moodOf = (urgent: number, running: number): BuddyMood => (urgent > 0 ? 'needs' : running > 0 ? 'working' : 'idle')
155
156/** The frame of a cell pack for step `tick` of a mood (the pane steps twice a second). Resting is slower than working. */
157export const frameAt = (pack: CellPack, mood: BuddyMood, tick: number): BuddyFrame => {
158 const frames = pack.moods[mood]
159 const every = mood === 'idle' ? 2 : 1
160
161 return frames[Math.floor(Math.max(0, tick) / every) % frames.length]
162}
163
164// --------------------------------------------------------------------------------------------------------- scene
165/** One terminal cell: a glyph, its colour and the colour behind it (null: the terminal's own). */
166export type BuddyCell = { ch: string; fg: string | null; bg: string | null }
167
168// a small deterministic hash, so a scene is a pure function of the time
169const rand = (a: number, b: number, c = 0) => {
170 let h = Math.imul(a | 0, 374761393) ^ Math.imul(b | 0, 668265263) ^ Math.imul(c | 0, 2246822519)
171 h = Math.imul(h ^ (h >>> 13), 1274126177)
172 h ^= h >>> 16
173
174 return (h >>> 0) / 4294967296
175}
176
177const hex2 = (n: number) => clamp(Math.round(n), 0, 255).toString(16).padStart(2, '0')
178const mix = (c: string, to: string, f: number) => {
179 const a = [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16))
180 const b = [1, 3, 5].map(i => parseInt(to.slice(i, i + 2), 16))
181
182 return `#${a.map((v, i) => hex2(v + (b[i] - v) * f)).join('')}`
183}
184
185/**
186 * Which rows of a sprite to keep so it fits `h` rows: the rows most like their neighbours go first (a body row
187 * repeated, never the eyes or the ears), decided on one frame so every frame of a mood loses the same rows.
188 */
189const keepRows = (rows: string[], h: number): number[] => {
190 const keep = rows.map((_, i) => i)
191 const diff = (a: string, b: string) => {
192 let n = 0
193 for (let i = 0; i < Math.max(a.length, b.length); i++) if ((a[i] ?? '.') !== (b[i] ?? '.')) n++
194 return n
195 }
196 while (keep.length > Math.max(1, h)) {
197 let best = 1
198 let score = Infinity
199 for (let k = 1; k < keep.length - 1; k++) {
200 const v = Math.min(diff(rows[keep[k]], rows[keep[k - 1]]), diff(rows[keep[k]], rows[keep[k + 1]]))
201 if (v < score) {
202 score = v
203 best = k
204 }
205 }
206 keep.splice(keep.length > 2 ? best : keep.length - 1, 1)
207 }
208
209 return keep
210}
211
212const FIRE = ['#fff6c8', '#ffe066', '#ffb020', '#ff7a1a', '#e8401a', '#b02810']
213const SPARK = ['#ffffff', '#fff27a', '#ffd400']
214const WATER = ['#d8f2ff', '#78c8ff', '#3890e8']
215const SHADOW = ['#2a1440', '#5a2c90', '#9058e0', '#e0a8ff']
216const LEAF = ['#88e070', '#40b048', '#2a7a30']
217const DUST = ['#8a8478', '#5e5a52']
218const ZZZ = '#9fb0d8'
219
220/**
221 * The buddy's scene at time `t` (ms), drawn for a slot of `cols` x `rows` cells: rows of cells, top first.
222 * The same time always draws the same scene.
223 */
224export function drawScene(pack: SpritePack, mood: BuddyMood, t: number, cols: number, rows: number): BuddyCell[][] {
225 const W = Math.max(1, cols)
226 const H = Math.max(2, rows * 2)
227 const px: (string | null)[] = new Array(W * H).fill(null)
228 const glyphs = new Map<number, BuddyCell>()
229 const put = (x: number, y: number, c: string | null) => {
230 x = Math.round(x)
231 y = Math.round(y)
232 if (c && x >= 0 && x < W && y >= 0 && y < H) px[y * W + x] = c
233 }
234 const free = (x: number, y: number) => x >= 0 && x < W && y >= 0 && y < H && px[Math.round(y) * W + Math.round(x)] === null
235
236 const m = pack.moods[mood]
237 const step = Math.floor(Math.max(0, t) / m.ms)
238 const keep = m.frames[0].length > H ? keepRows(m.frames[0], H) : null
239 const frame = m.frames[step % m.frames.length]
240 const sprite = keep ? keep.map(i => frame[i] ?? '') : frame
241 const h = sprite.length
242 const w = Math.max(...sprite.map(r => r.length))
243 const span = Math.max(0, W - w)
244 const room = H - h
245 const secs = Math.max(0, t) / 1000
246
247 // ---- where the sprite is, and which way it faces
248 let x = Math.min(1, span)
249 let y = room
250 let flip = false
251 const pingPong = (speed: number) => {
252 if (span === 0) return
253 const d = (secs * speed) % (2 * span)
254 x = Math.floor(d < span ? d : 2 * span - d)
255 flip = d >= span
256 }
257 switch (m.motion) {
258 case 'walk':
259 pingPong(4)
260 break
261 case 'run':
262 pingPong(9)
263 if (room > 0 && step % 2 === 1) y = room - 1
264 break
265 case 'fly':
266 pingPong(6)
267 y = room > 0 ? Math.max(0, room - 1 - (Math.sin(secs * 4) > 0 ? 1 : 0)) : Math.sin(secs * 4) > 0 ? 0 : 1
268 break
269 case 'float':
270 x = clamp(Math.round(span * 0.25 + Math.sin(secs * 0.8) * Math.min(4, span / 4)), 0, span)
271 y = room > 0 ? Math.round((room - 1) / 2 + Math.sin(secs * 2.2) * Math.max(1, room / 2)) : Math.sin(secs * 2.2) > 0.3 ? 1 : 0
272 break
273 case 'hop': {
274 const hop = [0, 1, 2, 3, 3, 2, 1, 0, 0, 0][Math.floor(secs * 10) % 10]
275 x = Math.min(2, span)
276 if (room > 0) y = room - Math.min(room, hop)
277 else x += hop > 1 ? 1 : 0
278 break
279 }
280 case 'shake':
281 x = Math.min(1, span) + (Math.floor(secs * 12) % 2)
282 break
283 default:
284 break
285 }
286
287 // ---- the sprite (thunder makes it flash)
288 const strike = Math.floor(secs * 6) % 4 // thunder: a bolt on steps 0 and 1, the body flashing on 0
289 const flash = m.fx === 'thunder' && strike === 0
290 const dim = m.fx === 'zzz'
291 for (let r = 0; r < h; r++) {
292 const row = sprite[r]
293 for (let c = 0; c < row.length; c++) {
294 const k = row[c]
295 if (k === '.') continue
296 let col = pack.palette[k]
297 if (flash) col = mix(col, '#ffffff', 0.3)
298 else if (dim) col = mix(col, '#000000', 0.1)
299 put(flip ? x + (w - 1 - c) : x + c, y + r, col)
300 }
301 }
302 const body = new Set(px.map((c, i) => (c ? i : -1)).filter(i => i >= 0))
303
304 // ---- the effect
305 const dir = flip ? -1 : 1
306 const mx = flip ? x + (w - 1 - clamp(pack.mouth[0], 0, w - 1)) : x + clamp(pack.mouth[0], 0, w - 1)
307 const my = y + clamp(keep ? keep.filter(i => i < pack.mouth[1]).length : pack.mouth[1], 0, h - 1)
308 const tick = Math.floor(secs * 10) // effects step ten times a second
309 switch (m.fx) {
310 case 'fire': {
311 // breathe: a stream that grows from the mouth to the edge of the slot, flickers, then stops for a breath
312 const cycle = secs % 2.6
313 const reach = cycle < 0.3 ? 0 : Math.min(W, Math.floor((cycle - 0.3) * 30))
314 const fade = cycle > 2.2 ? Math.floor((cycle - 2.2) * 40) : 0
315 for (let d = 1 + fade; d <= reach; d++) {
316 const fx = mx + dir * d
317 const wob = Math.round(Math.sin(d * 0.9 - secs * 14) * 0.6)
318 const r = Math.min(2, Math.floor(d / 4) + (rand(d, tick) > 0.6 ? 1 : 0))
319 for (let o = -r; o <= r; o++) {
320 if (d > 6 && rand(d, o, tick) < 0.18) continue
321 const heat = clamp(Math.floor(d / 3) + Math.abs(o) + (rand(o, d, tick + 7) > 0.7 ? 1 : 0), 0, FIRE.length - 1)
322 if (free(fx, my + o + wob)) put(fx, my + o + wob, FIRE[heat])
323 }
324 }
325 break
326 }
327 case 'sparks':
328 case 'thunder': {
329 // little zigzags crackling around the body, and for thunder a bolt from the top of the slot
330 const n = m.fx === 'thunder' ? 4 : 2
331 for (let i = 0; i < n; i++) {
332 const sx = Math.round(x - 3 + rand(i, tick) * (w + 6))
333 const sy = Math.floor(rand(tick, i, 3) * (H - 2))
334 const c = SPARK[Math.floor(rand(i, tick, 9) * SPARK.length)]
335 for (const [dx, dy] of [[0, 0], [1, 1], [0, 2]]) if (free(sx + dx, sy + dy)) put(sx + dx, sy + dy, c)
336 }
337 if (m.fx === 'thunder' && strike < 2) {
338 // a zigzag bolt, top of the slot to the ground, on one side of the body or the other
339 const side = Math.floor(secs * 1.5) % 2 === 0 || x < 5 ? 1 : -1 // never over the body
340 const bx = side > 0 ? x + w + 2 : x - 5
341 const zig = [2, 1, 0, 1, 2, 3, 2, 1, 2, 3, 2, 1, 0, 1, 2, 3]
342 for (let by = 0; by < H; by++) {
343 const zx = bx + zig[by % zig.length]
344 put(zx, by, SPARK[0])
345 put(zx + 1, by, SPARK[2])
346 }
347 }
348 break
349 }
350 case 'shadow': {
351 // a shadow ball: grows in front of the mouth, then flies off to the edge
352 const cycle = secs % 2.4
353 const travel = cycle < 0.8 ? 0 : Math.floor((cycle - 0.8) * 22)
354 const r = cycle < 0.3 ? 1 : 2 // a dot, then a round ball (radius 2 plus a half: no square corners)
355 const cx = mx + dir * (2 + r + travel)
356 const cy = clamp(my, r, H - 1 - r)
357 for (let oy = -r; oy <= r; oy++) {
358 for (let ox = -r; ox <= r; ox++) {
359 const d = Math.hypot(ox, oy)
360 if (d > r + 0.5) continue
361 const ring = d > r - 0.6
362 const c = ring ? (rand(ox, oy, tick) > 0.6 ? SHADOW[3] : SHADOW[2]) : d < 0.5 ? SHADOW[0] : SHADOW[1]
363 if (free(cx + ox, cy + oy) || !body.has(Math.round(cy + oy) * W + Math.round(cx + ox))) put(cx + ox, cy + oy, c)
364 }
365 }
366 // wisps left behind the floating body
367 for (let i = 0; i < 3; i++) {
368 const age = (tick + i * 3) % 9
369 const wx = x - dir * (1 + age) + (flip ? w : 0)
370 const wy = y + h - 2 - Math.floor(age / 3) - Math.floor(rand(i, Math.floor(tick / 9)) * 3)
371 if (free(wx, wy)) put(wx, wy, age < 4 ? SHADOW[2] : SHADOW[1])
372 }
373 break
374 }
375 case 'water': {
376 // a water gun: drops in an arc from the mouth
377 for (let i = 0; i < 7; i++) {
378 const ph = (secs * 14 + i * 2.6) % 18
379 const dx = 1 + ph * 1.2
380 const dy = -0.6 * ph + 0.06 * ph * ph
381 // each drop two pixels long, its leading pixel the lighter
382 if (free(mx + dir * dx, my + dy)) put(mx + dir * dx, my + dy, WATER[i % 2 ? 0 : 1])
383 if (free(mx + dir * (dx - 1), my + dy)) put(mx + dir * (dx - 1), my + dy, WATER[i % 2 ? 1 : 2])
384 }
385 break
386 }
387 case 'leaves': {
388 for (let i = 0; i < 4; i++) {
389 const a = secs * 2.5 + (i * Math.PI) / 2
390 const lx = x + w / 2 + Math.cos(a) * (w / 2 + 2)
391 const ly = y + h / 2 + Math.sin(a) * (h / 2)
392 const c = LEAF[i % LEAF.length]
393 if (free(lx, ly)) put(lx, ly, c)
394 if (free(lx + 1, ly)) put(lx + 1, ly, LEAF[(i + 1) % LEAF.length])
395 }
396 break
397 }
398 case 'dust': {
399 for (let i = 0; i < 3; i++) {
400 const age = (tick + i * 2) % 6
401 const dx = flip ? x + w + age : x - 1 - age
402 if (free(dx, H - 1 - (age > 2 ? 1 : 0))) put(dx, H - 1 - (age > 2 ? 1 : 0), DUST[age > 2 ? 1 : 0])
403 }
404 break
405 }
406 case 'zzz': {
407 // z's rise from above the head, one more each beat, then start over
408 const n = 1 + (Math.floor(secs / 0.9) % 3)
409 const zx = Math.floor(flip ? x - 1 : x + w)
410 for (let i = 0; i < n; i++) {
411 const cx = zx + (flip ? -i : i)
412 const cy = Math.max(0, Math.floor((y + 1) / 2) + 1 - i)
413 if (cx >= 0 && cx < W) glyphs.set(cy * W + cx, { ch: i === 2 ? 'Z' : 'z', fg: ZZZ, bg: null })
414 }
415 break
416 }
417 default:
418 break
419 }
420
421 // ---- half blocks: two pixels a cell, the top one in the glyph's colour
422 const out: BuddyCell[][] = []
423 for (let cy = 0; cy < rows; cy++) {
424 const line: BuddyCell[] = []
425 for (let cx = 0; cx < W; cx++) {
426 const g = glyphs.get(cy * W + cx)
427 if (g) {
428 line.push(g)
429 continue
430 }
431 const top = px[cy * 2 * W + cx]
432 const bot = cy * 2 + 1 < H ? px[(cy * 2 + 1) * W + cx] : null
433 if (top && bot) line.push(top === bot ? { ch: '█', fg: top, bg: null } : { ch: '▀', fg: top, bg: bot })
434 else if (top) line.push({ ch: '▀', fg: top, bg: null })
435 else if (bot) line.push({ ch: '▄', fg: bot, bg: null })
436 else line.push({ ch: ' ', fg: null, bg: null })
437 }
438 out.push(line)
439 }
440
441 return out
442}
443
444/** Cells as text runs (one <Text> per run), the form every surface draws. Trailing blanks are dropped. */
445export const toRuns = (cells: BuddyCell[][]): BuddyRun[][] =>
446 cells.map(line => {
447 const runs: BuddyRun[] = []
448 let end = line.length
449 while (end > 0 && line[end - 1].ch === ' ') end--
450 for (const c of line.slice(0, end)) {
451 const last = runs[runs.length - 1]
452 if (last && last[1] === c.fg && last[2] === c.bg) last[0] += c.ch
453 else runs.push([c.ch, c.fg, c.bg])
454 }
455 return runs
456 })
457
458/** Cells packed for a terminal `Raster`: base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
459export const toRaster = (cells: BuddyCell[][]): string => {
460 const DEFAULT = 0x01000000
461 const col = (c: string | null) => (c ? parseInt(c.slice(1), 16) : DEFAULT)
462 const words: number[] = []
463 for (const line of cells) for (const c of line) words.push(c.ch.codePointAt(0) ?? 32, c.ch === ' ' ? DEFAULT : col(c.fg), col(c.bg))
464 const bytes = new Uint8Array(new Uint32Array(words).buffer)
465 let bin = ''
466 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
467
468 return btoa(bin)
469}
470
471/** What the pane shows for a pack: rows of runs for `cols` x `rows` cells at time `t` (cell packs step on `tick`). */
472export const buddyRuns = (pack: BuddyPack, mood: BuddyMood, t: number, tick: number, cols: number, rows: number): BuddyRun[][] =>
473 pack.kind === 'cells' ? frameAt(pack, mood, tick).rows : toRuns(drawScene(pack, mood, t, cols, rows))
474
475let loaded: BuddyPack | null = null
476export const setBuddy = (pack: BuddyPack | null) => {
477 loaded = pack
478}
479export const getBuddy = () => loaded
480
481/** What the pane last drew the buddy at, so a timer can repaint it in place between redraws. */
482export type BuddyStage = { mood: BuddyMood; cols: number; rows: number }
483let stage: BuddyStage | null = null
484export const setStage = (s: BuddyStage | null) => {
485 stage = s
486}
487export const getStage = () => stage
488hooks/themes.ts 88 lines1// dev-dash: colour themes (Flight Deck design, "Themes"). Pure data, so it tests without a session.
2//
3// Every colour in the pane is a role (you, ok, warn, bad, info, accent, mute), never a fixed value, so a theme only has
4// to say what each role looks like. `auto` is the default and uses the eight named terminal colours, which every
5// terminal theme (dark or light) remaps to something readable. The others use exact colours and want a truecolor terminal.
6
7import type { ThemeId } from '../types'
8
9export type Roles = { you: string; ok: string; warn: string; bad: string; info: string; accent: string; mute: string }
10
11export const THEMES: ReadonlyArray<{ id: ThemeId; label: string; blurb: string; tone: Roles }> = [
12 {
13 id: 'auto',
14 label: 'Auto',
15 blurb: 'The default: your terminal\'s own colours, so it follows your terminal theme, dark or light.',
16 tone: { you: 'yellow', ok: 'green', warn: 'yellow', bad: 'red', info: 'cyan', accent: 'magenta', mute: 'gray' },
17 },
18 {
19 id: 'claude',
20 label: 'Claude',
21 blurb: 'Matched to Claude Code\'s clay.',
22 tone: { you: '#D97757', ok: '#6CC47C', warn: '#E8B04F', bad: '#F2706A', info: '#7AA7FF', accent: '#B49CFF', mute: '#8D93A0' },
23 },
24 {
25 id: 'nord',
26 label: 'Nord',
27 blurb: 'Soft and low-contrast for long sessions.',
28 tone: { you: '#D08770', ok: '#A3BE8C', warn: '#EBCB8B', bad: '#BF616A', info: '#81A1C1', accent: '#B48EAD', mute: '#8892A6' },
29 },
30 {
31 id: 'neon',
32 label: 'Neon',
33 blurb: 'Saturated, for screenshots and streams.',
34 tone: { you: '#FF6AC1', ok: '#4DFA9A', warn: '#FFE66D', bad: '#FF5470', info: '#5CE1FF', accent: '#B98CFF', mute: '#8D86B5' },
35 },
36 {
37 id: 'crt',
38 label: 'Amber CRT',
39 blurb: 'One hue with a phosphor look. State is carried by the glyphs.',
40 tone: { you: '#FFE0A0', ok: '#FFC65C', warn: '#FFB43A', bad: '#FF8A4A', info: '#FFB43A', accent: '#E8A23A', mute: '#A8761F' },
41 },
42 {
43 id: 'light',
44 label: 'Light',
45 blurb: 'For light terminals: the same hues, darker.',
46 tone: { you: '#BC4C00', ok: '#1A7F37', warn: '#9A6700', bad: '#CF222E', info: '#0969DA', accent: '#8250DF', mute: '#6E7781' },
47 },
48 {
49 id: 'hacker',
50 label: 'Hacker',
51 blurb: 'Terminal green on black: plain +--+ borders, CAPITAL headings, red kept for danger. Best on a dark background.',
52 tone: { you: '#CCFFCC', ok: '#00FF41', warn: '#B6FF00', bad: '#FF3B3B', info: '#2DE2A6', accent: '#7CFF6B', mute: '#2E8B3E' },
53 },
54 {
55 id: 'mono',
56 label: 'Mono',
57 blurb: 'For 16-colour terminals and colour-blind users: glyphs and weight only.',
58 tone: { you: '#FFFFFF', ok: '#C4C4C4', warn: '#FFFFFF', bad: '#FFFFFF', info: '#C4C4C4', accent: '#C4C4C4', mute: '#8C8C8C' },
59 },
60]
61
62export const isTheme = (v: unknown): v is ThemeId => THEMES.some(t => t.id === v)
63
64/** Look a theme up by what someone typed (`nord`, `Amber CRT`, `crt`); undefined when it is not one. */
65export const themeByName = (name: string) => {
66 const n = name.trim().toLowerCase().replace(/[\s_-]+/g, '')
67
68 return THEMES.find(t => t.id === n || t.label.toLowerCase().replace(/\s+/g, '') === n)
69}
70
71/** The theme after `id`, wrapping around: what the footer button does. */
72export const nextTheme = (id: ThemeId): ThemeId => THEMES[(THEMES.findIndex(t => t.id === id) + 1) % THEMES.length].id
73
74/** How a theme draws, beyond colour: the card border and whether headings are in capitals. */
75export type Look = { border: 'round' | 'single' | 'double' | 'classic'; caps: boolean }
76const LOOKS: Partial<Record<ThemeId, Look>> = { hacker: { border: 'classic', caps: true } }
77
78/** Point the live look (borders, capitals) at a theme. Every theme but Hacker uses round borders and normal case. */
79export const applyLook = (into: Look, id: unknown) => {
80 Object.assign(into, LOOKS[id as ThemeId] ?? { border: 'round', caps: false })
81}
82
83/** Point the live colour table at a theme. Unknown ids fall back to `auto`. */
84export const applyTheme = (into: Roles, id: unknown) => {
85 const t = THEMES.find(x => x.id === id) ?? THEMES[0]
86 Object.assign(into, t.tone)
87}
88