SLOPSHOPPER

dev-dash

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dev-dash
│ ┃ dev-dash ✕ › fix the failing auth test and add an audit log call │ ┃ [ ● Dashboard ] [ Entertainment ] [ Custom ] │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ DEV-DASH ⎿ Read 6 lines │ ┃ │ ■ you ■ sessions ■ agents ■ limits ■ ⏺ Update(src/auth.ts) │ ┃ │ ✓ all clear · 0 sess ⎿ Added 2 lines, removed 1 line │ ┃ │ 5h ▰▰▱▱▱ 31% ⏺ Bash(bun test) │ ┃ │ ♥ claude beat ⎿ 3 pass, 1 fail │ ┃ │ synced 0s ago │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ──────────────────────────── │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ✓ nothing needs youchecked 0s ago › /dash │ ┃ │ No live sessions. ⎿ dev-dash: Dashboard opened. │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ 1: ▾ Attention all clear │ ┃ │ ✓ Nothing needs you. │ ┃ │ No live sessions. │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ 2: ▾ Sessions (0) 0 running · 0 waiting │ ┃ │ No live Claude sessions. │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · dev-dash
[ ● Dashboard ] [ Entertainment ] [ Custom ] ╭──────────────────────────────────────────────────────╮ │ DEV-DASH │ │ ■ you ■ sessions ■ agents ■ limits ■ PRs │ │ ✓ all clear · 0 sess │ │ 5h ▰▰▱▱▱ 31% │ │ ♥ claude beat ╭╮ │ │ synced 0s ago ││ │ │ ││ │ │ ────────────────────────────╯● │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ ✓ nothing needs youchecked 0s ago │ │ No live sessions. │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 1: ▾ Attention all clear │ │ ✓ Nothing needs you. │ │ No live sessions. │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 2: ▾ Sessions (0) 0 running · 0 waiting │ │ No live Claude sessions. │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 3: ▾ Agents (0) 0 working · 0 recent │ │ No subagents in the last 30 minutes. │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 4: ▾ Monitor 5h 31% · alerts off │ │ usage limits │ │ 5h ▰▰▱▱▱ 31% no reset time │ │ events · alerts off (a) │ │ quiet so far │ ╰──────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ 5: ▾ Work in flight feat/auth-refresh · 2 dirty │ feat/auth-refresh ↑0 ↓0 ● 2 uncommitted +14 −3 not comm ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────╮ │ 6: ▾ PRs & CI gh unavailable │ │ ! JSON Parse error: Unexpected EOF │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 7: ▾ Plan (0) no plans │ │ No session has a todo list right now. │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 8: ▾ Sources (0) none yet │ │ Nothing yet: fills when a session running dev-das… │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ 9: ▾ Files (0) no edits this turn │ │ No session running dev-dash has edited a file in … │ ╰──────────────────────────────────────────────────────╯ [ refresh ] [ alerts: off ] [ theme: Claude ] [ help ] [ clo ctrl+x tab to use the keys
README

claude-dev-dashboard

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

What it shows

SectionKeyContents
Band above the promptOne 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 cardThe 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.
HeaderOverall 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.
Attention1Sessions 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.
Sessions2Every 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.
Agents3Every 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.
Monitor4Usage 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 flight5Current branch, ahead/behind, uncommitted lines (+/−), stashes, recent and merged branches, and worktrees.
PRs & CI6Your open PRs across all your GitHub repos, with CI, review state, age and conflicts, plus the PRs waiting for your review.
Plan7A 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.
Sources8URLs and searches sessions fetched (WebFetch, WebSearch), newest first, with the session that made them. Plugin sessions only.
Files9Files each session edited this turn, with lines added and removed against HEAD. Plugin sessions only.

Tabs

The pane has three tabs along its top edge. Move onto one with the arrow keys or Tab and press Enter.

TabHolds
DashboardEverything described above: Attention, Sessions, Agents, Monitor, Work in flight, PRs & CI, Plan, Sources and Files.
EntertainmentCards 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).
CustomEmpty 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.

Keyboard

The pane uses the same keys as the rest of Claude Code, with no letter shortcuts to learn.

KeyDoes
ctrl+x tabTake the keyboard from the prompt to the pane. /dash does the same and opens the pane if it is closed.
EscGive the keyboard back to the prompt.
↑ ↓ or TabMove the highlight to the previous or next row or button.
EnterPress the highlighted button. On a row marker (›), it opens that row's actions.
1–9Fold 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:

  • copy …: puts claude --resume <id> on the clipboard for a session or agent, the link for a PR, or a one-line summary for the rest.
  • snooze 15m and dismiss (Attention rows only): hide the item in the pane, the band above the prompt and the toasts. Snooze lasts 15 minutes; dismiss lasts until the item changes (a session that waits again, or fails differently, comes back). bring back appears under Attention while anything is hidden.

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.

Alerts

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.

Session summaries (opt-in)

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

Install

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 included

Requirements

  • git 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.
  • For PRs & CI and /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.
  • Usage limits appear on Pro and Max plans, after the session's first reply.

What dev-dash reads and writes

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.
  • On the Entertainment tab only, while it is showing: only if you turned Spotify on: what the Spotify app on this machine is playing, from the Windows media session (PowerShell, read-only), 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.
  • In that same file, for the Plan, Sources and Files sections: the text of the task in progress in its todo list, the URLs it fetched (scheme, host and path only; credentials, query strings and fragments are dropped before anything is stored) and the searches it ran (cut to 80 characters), and the paths of files it edited this turn with their added and removed line counts. For the test badge: the last test run it started, as a runner label such as 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.
  • Its own plugin store: the alerts, band, summaries and handoff on/off choices, the icon style, and the /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.

Development

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.

License

MIT

Source 19 files
hooks/register.tsx 1413 lines
1import { 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 lines
1// 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}
380
hooks/exe.ts 76 lines
1// 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}
76
hooks/handoff.ts 90 lines
1// 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}
90
hooks/entertainment.ts 95 lines
1// 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 })
95
hooks/keys.ts 96 lines
1// 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 }
96
hooks/icons.ts 30 lines
1// 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}
30
hooks/progress.ts 87 lines
1// 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)
87
hooks/testrun.ts 57 lines
1// 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` : ''
57
hooks/render.tsx 1047 lines
1// 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}
1047
hooks/buddy.ts 488 lines
1// 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
488
hooks/themes.ts 88 lines
1// 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