The project's dev servers from .claude/launch.json in a pane: start, stop and restart each, see which answer and read their output

Mods for Claude Code. A mod is a Claude Code plugin that adds interface to Claude Code itself (a pane, a slash command) through the engine's hooks API. This repository holds our own mods, forks of others' (each kept under its own license, with a link back), and is a plugin marketplace that installs them.
The panes draw in the desktop app (as a side pane, with charts) and in the terminal (a simpler pane of text cells). Tested on Claude Code 2.1.296, the version CI pins.
| Mod | What it shows | Opens with | Needs |
|---|---|---|---|
overview | the current session at a glance: context, limits, activity, agents, the branches it started and their pull requests, cost | on its own when a session starts; /overview opens it again | git for Workspaces; gh signed in for pull requests |
issues | the open GitHub issues of the session's repository, newest change first, with actions on each | on its own when a session starts; /issues opens it again | a GitHub repository; gh signed in |
launcher | the project's dev servers from .claude/launch.json (the desktop app's preview list), each with its state, URL, Start, Stop and Restart, and its output | on its own when a session starts; /launcher (or /preview) opens it again; /launcher start <name>, /launcher stop <name> | a .claude/launch.json; lsof to tell which ports answer |
lab | for mod authors: 77 experiments probing what the engine draws (SVG gradients and filters, animation, hover, Client modules, every element), with the engine's verdict on each surface in mods/lab/verdicts.json | the index on its own when a session starts; /lab, /lab <id>, /lab gallery | nothing |
Copies of other people's mods, changed here to fit these. Each keeps its original license and author; its README opens with where it came from, the upstream commit it was taken at, and what this fork changed.
| Mod | What it does | Original | License |
|---|---|---|---|
filetree | an IDE-style file tree of the session's folder: git status and lines changed per file, and a shimmer on what Claude reads, writes and commits. /filetree opens it | data-goblin/claude-code-filetree, by Kurt Buhler | MIT |
blast-radius | holds a risky shell command (rm -rf, git reset --hard, git clean, force push, migrations) and shows what it would change, with Proceed and Cancel | anthropics/claude-code-playground, by Anthropic | Apache-2.0 |
overview's Workspaces section.gh auth login), for issues and for the pull requests in overview's Workspaces. Without it overview still draws everything else, and its Workspaces section shows a note in place of pull requests (gh not installed, or gh not authenticated (run gh auth login)); issues shows the same note in place of the list.bun install, then bun build.ts). Installing from the marketplace needs nothing else./overviewOpens by itself when a session starts; /overview brings it back after you close it. Its sections, each folding open and shut:
gh every 30 seconds while any is open.A footer shows the session's share of the machine's cpu, memory and network.
/issuesOpens by itself when a session starts; /issues brings it back. Lists the open issues of the session's repository (read with gh issue list).
gh issue create).gh:refined.refined asks first whether to refine it.sprint/<date> branch, then one PR for your approval with auto-merge off.These prompts encode an opinionated way of working (worktrees, a subagent per issue, an integration branch reviewed by you). Read mods/issues/hooks/register.tsx for their exact wording before you rely on them.
/launcherThe desktop app's preview launcher, in a pane that works in the terminal too. Opens by itself when a session starts; /launcher brings it back. It lists every configuration in the project's .claude/launch.json (the session's folder, or the nearest one above it up to the git root), one row each: its name, its state, its URL once the port answers, and Start, Stop and Restart.
http://localhost:<port>); running elsewhere (green: something this session didn't start answers on the port, such as the desktop app or another session; it can't be started twice); failed (red: it exited with an error, and its row unfolds to show why).runtimeExecutable runtimeArgs… in the configuration's cwd (the project root by default) with PORT set to its port and its env over that. With autoPort, a taken port moves it to the next free one./launcher start <name>, /launcher stop <name>, /launcher restart <name>, /launcher list. Each answers in text, so they work without the pane. /preview does the same.lsof (an HTTP request to each port where lsof isn't installed), every 2 s while the pane is shown, and not at all while it isn't.npm run serve starting a node server, ending the spawn loop ends the grandchild node too, so its port was free at once.The servers it starts are children of the mod: they end when the session ends, and when the mod reloads (/reload-plugins, or a rebuild with hot reloading on). Servers started elsewhere aren't touched; Stop is only for the ones this session started.
At the prompt of a Claude Code session in a terminal:
/plugin marketplace add satoramoto/awesome-claude
/plugin install overview@awesome-claude
/plugin install issues@awesome-claude
Pick a scope when asked (user scope loads the mod in every session). Each mod is active straight away. Or from a shell: claude plugin marketplace add satoramoto/awesome-claude, then claude plugin install overview@awesome-claude.
Run these from a terminal session; a mod installed at user scope then loads in the desktop app too.
An installed mod updates only when its version changes; every release of a mod raises it. To fetch the newest versions by hand, at the prompt:
/plugin marketplace update awesome-claude
/plugin update overview@awesome-claude
or from a shell: claude plugin marketplace update awesome-claude, then claude plugin update overview@awesome-claude (and the same for each mod you installed). The new version loads after a restart or /reload-plugins.
Auto-update is off by default for marketplaces other than Anthropic's. Turn it on in /plugin → Marketplaces → awesome-claude → Enable auto-update, or with "autoUpdate": true next to source in extraKnownMarketplaces in ~/.claude/settings.json:
"extraKnownMarketplaces": {
"awesome-claude": {
"source": { "source": "github", "repo": "satoramoto/awesome-claude" },
"autoUpdate": true
}
}
Loading from a clone (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS, below) gets no marketplace updates: git pull the clone instead. To pull at every session start, a SessionStart hook in ~/.claude/settings.json:
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "git -C ~/Code/satoramoto/awesome-claude pull -q --ff-only" }] }
]
}
The compiled mods are committed, so a clone runs as is. A folder of plugins passed to --plugin-dir loads each one in it:
git clone https://github.com/satoramoto/awesome-claude ~/Code/satoramoto/awesome-claude
claude --plugin-dir ~/Code/satoramoto/awesome-claude/plugins
git clone https://github.com/satoramoto/awesome-claude ~/Code/satoramoto/awesome-claude
cd ~/Code/satoramoto/awesome-claude
bun install
bun build.ts
Then point every session at the compiled folder, in ~/.claude/settings.json, with the full path to your clone's plugins folder:
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/Code/satoramoto/awesome-claude/plugins",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
With bun run watch running, a save under mods/ (or in zen's kit, with ZEN set) recompiles and the session reloads the mod. These two variables are what this setup uses on Claude Code 2.1.296; they may change in later versions. Don't also install the mods from the marketplace, or each loads twice.
AGENTS.md lists the commands and checks. The project's skills, which Claude Code loads when working in this repository, hold the how-to:
.claude/skills/mod-authoring/: writing a mod in this repository.claude/skills/storybook-prototyping/: designing how it looks in Storybook.claude/skills/headless-verification/: checking it works without a person watching| Folder | What it is |
|---|---|
mods/<name>/ | a mod's source: .claude-plugin/plugin.json, hooks/, types/. Imports the kit as @kit/… |
zen's src/harness/claude-code/kit/ | the kit, from the pinned @satoramoto/zen dev dependency: components (ui.tsx), colors and glyphs (tokens.ts), number formatting (format.ts), icons (icons.ts), timing a mod's own work (perf.ts), skins (skin.ts, skins.ts) |
zen.ts | where zen's kit and bench are: node_modules/@satoramoto/zen, or the zen checkout ZEN names |
plugins/<name>/ | the compiled mods: what Claude Code loads and the marketplace installs. Generated by bun build.ts; don't edit |
.claude-plugin/marketplace.json | the marketplace: each mod in plugins/, at its plugin.json version |
playground/ | a browser page that draws every mod live with sample data, on each surface, through zen's bench; playground/fixtures/ holds the sample data |
stories/ | Storybook stories: each mod's scenarios, the lab and the engine experiments |
Each mod is versioned on its own in its plugin.json. A new mod also gets an entry in .claude-plugin/marketplace.json; CI fails if a mod is missing there or its version differs from its plugin.json. Any change to a mod's compiled output in plugins/<mod>/ (a new zen pin that changes the kit included) raises its version, in plugin.json and the marketplace, since installs only update when it changes; CI fails a pull request that changes a mod's output without raising its version.
bun run playground
At http://localhost:5173 (set PORT for another). Every mod's ui.render hooks, drawn with the scenarios in playground/fixtures/<mod>.ts, side by side in desktop light, desktop dark and terminal, at any pane width. On each save it also compiles and runs claude plugin validate plugins/<mod>, and shows the result in the bar.
bun run storybook
At http://localhost:6006 (set PORT for another). Each mod scenario (stories/<mod>.stories.ts, from the playground's fixtures), drawn by zen's bench. The toolbar picks the surface (desktop or terminal), the theme, the frame and the pane width. The kit's own components and the skins have their stories in zen's Storybook, under Harness/Claude Code. Storybook is for how things look; the playground is for wiring up a whole mod with the engine's checks running.
The engine reads a mod's source as it is and refuses any import from outside the mod's folder (symlinks included). build.ts copies zen's kit into each compiled mod and rewrites @kit/… to relative paths; nothing else changes, so the output is still readable source and passes claude plugin validate.
What the desktop accepts, found by probing the engine's validator (first on 2.1.293; the mods are tested on 2.1.296): whole percentages only (25.5% is refused); flexGrow up to 10000; fractional heights and margins; no Box hover. Borders: round draws rounded corners, and single, double, bold and classic draw; dashed doesn't. SVG is drawn as an image the page scales, so it blurs as the pane resizes: draw with boxes and text.
What it paints (measured on the real pane, 2.1.295): a horizontal unit is a column (~7.8px), height a row (~19px), but vertical margin, padding and row gap half a row (~9.5px); a transparent border colour draws in the text's colour; a Button draws its label alone, never its children. zen's docs/harness-claude-code.md ("What the engine and the desktop accept") has the details, and Storybook draws by the same rules.
MIT, except the forked mods, which keep their own licenses: mods/filetree is MIT, copyright Kurt Buhler (LICENSE), and mods/blast-radius is Apache-2.0, copyright Anthropic PBC (LICENSE).
hooks/register.tsx 870 lines1import { atom, read, update } from 'claude-code'
2import type { CommandRunInput, EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register } from 'claude-code'
3
4import { holdButtons } from '../kit/ui'
5
6import type { Adopted, KillConfirm, LaunchConfig, LaunchFile, Server, Status } from '../types'
7import { LOOK, commandOf, drawnServers, renderPane, urlOf } from './view'
8import type { PaneElements } from './view'
9
10const PANE = 'launcher'
11const TITLE = 'Launcher'
12/** How many lines of a server's output are kept. */
13const LOG_LINES = 200
14
15const file = atom({ plugin: 'launcher', key: 'file' } as const, null)
16const servers = atom({ plugin: 'launcher', key: 'servers' } as const, {})
17const open = atom({ plugin: 'launcher', key: 'open' } as const, [])
18const adopted = atom({ plugin: 'launcher', key: 'adopted' } as const, {})
19const confirm = atom({ plugin: 'launcher', key: 'confirm' } as const, null)
20/** Whether a wide app pane flows its rows into columns; the footer's switch flips it. */
21const columns = atom({ plugin: 'launcher', key: 'columns' } as const, true)
22
23// ---- reading .claude/launch.json ----
24
25const trim = (dir: string) => (dir.length > 1 ? dir.replace(/[\\/]+$/, '') : dir)
26const parent = (dir: string) => {
27 const cut = Math.max(dir.lastIndexOf('/'), dir.lastIndexOf('\\'))
28 return cut <= 0 ? dir.slice(0, cut + 1) || dir : dir.slice(0, cut)
29}
30/** `rel` under `root`, unless it's absolute already. */
31export const resolvePath = (root: string, rel: string) => (/^([\\/]|[A-Za-z]:)/.test(rel) ? rel : `${trim(root)}/${rel.replace(/^\.\/?/, '')}`.replace(/\/$/, ''))
32
33/** The configurations of a launch.json's text; entries missing a name, an executable or a port are left out. */
34export const parseLaunch = (text: string, root: string, at: number): LaunchFile => {
35 let json: any
36 try {
37 json = JSON.parse(text)
38 } catch {
39 return { root, configs: [], at, note: 'Unreadable .claude/launch.json (not JSON).' }
40 }
41 const configs: LaunchConfig[] = []
42 for (const c of Array.isArray(json?.configurations) ? json.configurations : []) {
43 const port = Number(c?.port)
44 if (typeof c?.name !== 'string' || !c.name || typeof c.runtimeExecutable !== 'string' || !Number.isInteger(port) || port <= 0) continue
45 if (configs.some(x => x.name === c.name)) continue
46 configs.push({
47 name: c.name,
48 runtimeExecutable: c.runtimeExecutable,
49 runtimeArgs: Array.isArray(c.runtimeArgs) ? c.runtimeArgs.map(String) : [],
50 port,
51 ...(typeof c.cwd === 'string' && c.cwd ? { cwd: c.cwd } : {}),
52 ...(c.env && typeof c.env === 'object' ? { env: Object.fromEntries(Object.entries(c.env).map(([k, v]) => [k, String(v)])) } : {}),
53 ...(c.autoPort ? { autoPort: true } : {}),
54 })
55 }
56 return { root, configs, at }
57}
58
59/** The folder holding `.claude/launch.json`: the session's, or the nearest above it up to the git root. */
60const findRoot = async ($: EngineInterface): Promise<string | null> => {
61 let dir = trim(await $.session.cwd())
62 for (let i = 0; i < 64; i++) {
63 if (await $.fs.exists(`${dir}/.claude/launch.json`).catch(() => false)) return dir
64 if (await $.fs.exists(`${dir}/.git`).catch(() => false)) return null
65 const up = parent(dir)
66 if (up === dir) return null
67 dir = up
68 }
69 return null
70}
71
72const loadFile = async ($: EngineInterface): Promise<LaunchFile> => {
73 const at = Date.now()
74 const root = await findRoot($)
75 if (!root) return { root: trim(await $.session.cwd()), configs: [], at, note: 'No .claude/launch.json in this project.' }
76 try {
77 return parseLaunch(String(await $.fs.read(`${root}/.claude/launch.json`)), root, at)
78 } catch {
79 return { root, configs: [], at, note: 'Could not read .claude/launch.json.' }
80 }
81}
82
83// ---- which ports answer ----
84
85/** The TCP ports `lsof -nP -iTCP -sTCP:LISTEN -Fn` lists as listening. */
86export const portsFromLsof = (stdout: string): Set<number> => {
87 const ports = new Set<number>()
88 for (const line of stdout.split('\n')) {
89 const m = /^n.*:(\d+)$/.exec(line.trim())
90 if (m) ports.add(Number(m[1]))
91 }
92 return ports
93}
94
95/**
96 * Every process listening on each TCP port, from `lsof -Fpn`'s `p<pid>` and
97 * `n<addr>:<port>` lines, in lsof's order: one port can have several (an IPv4
98 * and an IPv6 socket from different processes).
99 */
100export const pidsFromLsof = (stdout: string): Map<number, Set<number>> => {
101 const pids = new Map<number, Set<number>>()
102 let pid = 0
103 for (const line of stdout.split('\n')) {
104 const l = line.trim()
105 if (l.startsWith('p')) pid = Number(l.slice(1)) || 0
106 const m = /^n.*:(\d+)$/.exec(l)
107 if (!m || !pid) continue
108 const port = Number(m[1])
109 const set = pids.get(port) ?? new Set<number>()
110 set.add(pid)
111 pids.set(port, set)
112 }
113 return pids
114}
115
116/** `ps -o pid=,%cpu=,rss=` lines: each process's CPU percent and resident memory in bytes. */
117export const usageFromPs = (stdout: string): Map<number, { cpu: number; rss: number }> => {
118 const usage = new Map<number, { cpu: number; rss: number }>()
119 for (const line of stdout.split('\n')) {
120 const [pid, cpu, rss] = line.trim().split(/\s+/).map(Number)
121 if (pid && Number.isFinite(cpu) && Number.isFinite(rss)) usage.set(pid, { cpu: cpu!, rss: rss! * 1024 })
122 }
123 return usage
124}
125
126/** The last probe: every port something listens on, and the processes on each where lsof said. */
127let listening = new Set<number>()
128let holders = new Map<number, Set<number>>()
129/** The process a row names for a port: the first lsof listed on it. */
130const pidOf = (port: number): number | undefined => holders.get(port)?.values().next().value
131/** Whether a probe has run since the module loaded. */
132let probed = false
133/** Whether the last probe's pids came from lsof (rather than none, from HTTP). */
134let byLsof = false
135
136/** Which ports answer: lsof's listening sockets, or, without lsof, an HTTP request to each configuration's port. */
137const probe = async ($: EngineInterface, wanted: number[]) => {
138 try {
139 const run = await $.process.run(['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-Fpn'], { timeoutMs: 5_000 })
140 // lsof exits 1 when nothing listens at all.
141 listening = portsFromLsof(run.stdout)
142 holders = pidsFromLsof(run.stdout)
143 byLsof = true
144 } catch {
145 const answers = await Promise.all(wanted.map(p => $.http.fetch(urlOf(p)).then(() => p, () => null)))
146 listening = new Set(answers.filter((p): p is number => p !== null))
147 holders = new Map()
148 byLsof = false
149 }
150 probed = true
151}
152
153/** How many CPU samples a server keeps: two minutes of ticks. */
154const USAGE_SAMPLES = 60
155/** Each answering server's CPU samples and last memory, by configuration name. */
156const usage = new Map<string, { cpu: number[]; rss: number }>()
157
158/**
159 * One CPU and memory sample of each answering server's listening process: one
160 * `ps` for all of them, after the probe that found their pids. Servers that
161 * stopped answering lose their samples. Without lsof's pids or ps, nothing.
162 */
163const sample = async ($: EngineInterface, configs: LaunchConfig[]) => {
164 const pids = new Map<string, number>()
165 for (const c of configs) {
166 const pid = pidOf(children.get(c.name)?.port ?? c.port)
167 if (pid) pids.set(c.name, pid)
168 }
169 for (const name of [...usage.keys()]) if (!pids.has(name)) usage.delete(name)
170 if (!pids.size) return
171 let got: Map<number, { cpu: number; rss: number }>
172 try {
173 got = usageFromPs((await $.process.run(['ps', '-o', 'pid=,%cpu=,rss=', '-p', [...new Set(pids.values())].join(',')], { timeoutMs: 5_000 })).stdout)
174 } catch {
175 return
176 }
177 for (const [name, pid] of pids) {
178 const now = got.get(pid)
179 if (!now) {
180 usage.delete(name)
181 continue
182 }
183 const u = usage.get(name) ?? { cpu: [], rss: 0 }
184 u.cpu = [...u.cpu, now.cpu].slice(-USAGE_SAMPLES)
185 u.rss = now.rss
186 usage.set(name, u)
187 }
188}
189
190/** The first port from `from` up that nothing listens on. */
191const freePort = (from: number) => {
192 let port = from
193 while (listening.has(port) && port < 65_535) port++
194 return port
195}
196
197// ---- whose process holds a port ----
198
199/**
200 * A listening process as ps and lsof tell it: when it started and its command
201 * line (together they tell it from a later process given the same pid), its
202 * parents' command lines (nearest first) and its working directory.
203 */
204export type Identity = { pid: number; start: string; command: string; chain: string[]; cwd?: string }
205
206type Row = { ppid: number; start: string; command: string }
207
208/** The columns every ps read asks for, in the C locale so `lstart` reads the same each time. */
209const PS_COLUMNS = ['-o', 'pid=,ppid=,lstart=,command='] as const
210const PS_ENV = { env: { LC_ALL: 'C' }, timeoutMs: 5_000 }
211
212/** `ps -o pid=,ppid=,lstart=,command=` lines: each process's parent, start time (`Mon Oct 12 10:00:00 2026`) and command line. */
213export const tableFromPs = (stdout: string): Map<number, Row> => {
214 const table = new Map<number, Row>()
215 for (const line of stdout.split('\n')) {
216 const m = /^\s*(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\S+\s+\d+:\d+:\d+\s+\d+)\s+(.*)$/.exec(line)
217 if (m) table.set(Number(m[1]), { ppid: Number(m[2]), start: m[3]!.replace(/\s+/g, ' '), command: m[4]!.trim() })
218 }
219 return table
220}
221
222/** `lsof -a -d cwd -p <pids> -Fpn` output: each process's working directory. */
223export const cwdsFromLsof = (stdout: string): Map<number, string> => {
224 const cwds = new Map<number, string>()
225 let pid = 0
226 for (const line of stdout.split('\n')) {
227 const l = line.trim()
228 if (l.startsWith('p')) pid = Number(l.slice(1)) || 0
229 else if (l.startsWith('n') && pid && !cwds.has(pid)) cwds.set(pid, l.slice(1))
230 }
231 return cwds
232}
233
234/** How far up its parents a process's command lines are read: npm, bun or a wrapper start the real server a level or two down. */
235const CHAIN = 5
236
237/** Who each listening pid is, read once per pid (a pid's command line and folder don't change while it lives). */
238const ids = new Map<number, Identity>()
239
240/**
241 * Reads who holds each configuration's port that this session neither started
242 * nor took over: one `ps` for the process table and one `lsof` for their
243 * working directories, only for pids not read before. Without ps, nothing:
244 * the rows can't tell whose a port is, and say it answers.
245 */
246const identify = async ($: EngineInterface, ports: number[]) => {
247 const pids = [...new Set(ports.flatMap(p => [...(holders.get(p) ?? [])]))]
248 for (const pid of [...ids.keys()]) if (!pids.includes(pid)) ids.delete(pid)
249 const fresh = pids.filter(p => !ids.has(p))
250 if (!fresh.length) return
251 let table: Map<number, Row>
252 try {
253 table = tableFromPs((await $.process.run(['ps', '-A', ...PS_COLUMNS], PS_ENV)).stdout)
254 } catch {
255 return
256 }
257 let cwds = new Map<number, string>()
258 try {
259 cwds = cwdsFromLsof((await $.process.run(['lsof', '-a', '-d', 'cwd', '-p', fresh.join(','), '-Fpn'], { timeoutMs: 5_000 })).stdout)
260 } catch {
261 // Without folders, the command lines alone tell.
262 }
263 for (const pid of fresh) {
264 const own = table.get(pid)
265 if (!own) continue
266 const chain: string[] = []
267 let up = own.ppid
268 for (let i = 0; i < CHAIN && up > 1; i++) {
269 const p = table.get(up)
270 if (!p) break
271 chain.push(p.command)
272 up = p.ppid
273 }
274 const cwd = cwds.get(pid)
275 ids.set(pid, { pid, start: own.start, command: own.command, chain, ...(cwd ? { cwd: trim(cwd) } : {}) })
276 }
277}
278
279const base = (path: string) => path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
280
281/** Whether a command line runs the configuration: its executable (by name, anywhere in the line) followed by exactly its arguments, to the end. */
282export const runsConfig = (command: string, c: LaunchConfig) => {
283 const words = command.trim().split(/\s+/)
284 const exe = base(c.runtimeExecutable)
285 for (let i = 0; i + c.runtimeArgs.length < words.length; i++) {
286 if (base(words[i]!) !== exe) continue
287 const rest = words.slice(i + 1)
288 if (rest.length === c.runtimeArgs.length && rest.every((w, j) => w === c.runtimeArgs[j])) return true
289 }
290 return false
291}
292
293/** The folder a configuration's server works in: its `cwd`, then a `--cwd` or `--prefix` among its arguments (bun, npm). */
294export const workdirOf = (c: LaunchConfig, root: string) => {
295 let dir = c.cwd ? resolvePath(root, c.cwd) : trim(root)
296 const a = c.runtimeArgs
297 for (let i = 0; i < a.length; i++) {
298 const eq = /^--(?:cwd|prefix)=(.+)$/.exec(a[i]!)
299 if (eq) dir = resolvePath(dir, eq[1]!)
300 else if ((a[i] === '--cwd' || a[i] === '--prefix') && a[i + 1]) dir = resolvePath(dir, a[i + 1]!)
301 }
302 return trim(dir)
303}
304
305/**
306 * How surely a process is a configuration's server: 2 when it or a parent runs
307 * the configuration's command, 1 when it works in the configuration's folder,
308 * 0 when neither.
309 */
310export const matchScore = (id: Identity, c: LaunchConfig, root: string) => {
311 if ([id.command, ...id.chain].some(cmd => runsConfig(cmd, c))) return 2
312 if (id.cwd && id.cwd === workdirOf(c, root)) return 1
313 return 0
314}
315
316// ---- the servers this session started ----
317
318type Child = {
319 stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>
320 port: number
321 startedAt: number
322 /** Stop was asked for, so its end isn't a failure. */
323 stopping: boolean
324 /** Settles once the child is gone and its state written. */
325 done: Promise<void>
326}
327
328/**
329 * The children this module started, by configuration name. Each lives as long
330 * as its spawn loop: Stop ends the loop, and the module unloading (the session
331 * ending, a reload) kills every one.
332 */
333const children = new Map<string, Child>()
334/** Each server's output: whole lines and the line still being written. */
335const logs = new Map<string, { lines: string[]; partial: string }>()
336/** Names whose Start is under way but not yet spawned, so a second Start can't spawn twice. */
337const starting = new Set<string>()
338/** How long Stop waits for a child's loop to end before answering anyway. */
339const STOP_WAIT = 3_000
340
341const ANSI = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07]*\x07/g
342
343/** Adds output to a server's log, a line at a time, keeping the last LOG_LINES. */
344export const appendLog = (log: { lines: string[]; partial: string }, text: string) => {
345 const parts = (log.partial + text.replace(ANSI, '')).split('\n')
346 log.partial = parts.pop() ?? ''
347 for (const p of parts) log.lines.push(p.replace(/\r$/, '').replace(/^.*\r/, ''))
348 if (log.lines.length > LOG_LINES) log.lines.splice(0, log.lines.length - LOG_LINES)
349}
350
351const logOf = (name: string) => {
352 const log = logs.get(name)
353 if (!log) return undefined
354 return log.partial ? [...log.lines, log.partial].slice(-LOG_LINES) : [...log.lines]
355}
356
357/** The writes of `servers` that read it first, one at a time. */
358let chain: Promise<unknown> = Promise.resolve()
359const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
360 const p = chain.then(fn)
361 chain = p.catch(() => undefined)
362 return p
363}
364
365/** Every configuration's server as it stands: what this session runs, what answers, how the last one ended. */
366const reconcile = ($: EngineInterface) => serial(() => reconcileNow($))
367
368const reconcileNow = async ($: EngineInterface) => {
369 const f = await read($, file)
370 const old = await read($, servers)
371 const took = await read($, adopted)
372 const configs = f?.configs ?? []
373 const root = f?.root ?? ''
374 // A taken-over server whose process left its port, or whose pid is now a later process's, has ended: forget it.
375 const gone = Object.keys(took).filter(name => {
376 // Before the first probe (a reload) nothing is known to have ended.
377 if (!probed) return false
378 const a = took[name]!
379 const held = holders.get(a.port)
380 const id = ids.get(a.pid)
381 return (
382 !configs.some(c => c.name === name) ||
383 children.has(name) ||
384 !listening.has(a.port) ||
385 // Without lsof's pids (it answers to HTTP), there's no telling it left.
386 (held !== undefined && !held.has(a.pid)) ||
387 (id !== undefined && (id.start !== a.start || id.command !== a.command))
388 )
389 })
390 if (gone.length) await update($, adopted, a => Object.fromEntries(Object.entries(a).filter(([n]) => !gone.includes(n))))
391 const kept = Object.fromEntries(Object.entries(took).filter(([n]) => !gone.includes(n)))
392 // Ports a server of this session's (started or taken over) holds, and whose it is.
393 const owner = new Map<number, string>()
394 for (const [name, child] of children) owner.set(child.port, name)
395 for (const [name, a] of Object.entries(kept)) if (!owner.has(a.port)) owner.set(a.port, name)
396 // On each port held elsewhere, how surely the best configuration on it matches its process.
397 const best = new Map<number, number>()
398 for (const c of configs) {
399 const id = ids.get(pidOf(c.port) ?? 0)
400 if (id) best.set(c.port, Math.max(best.get(c.port) ?? 0, matchScore(id, c, root)))
401 }
402 const next: Record<string, Server> = {}
403 for (const c of configs) {
404 const prev = old[c.name]
405 const child = children.get(c.name)
406 const took1 = kept[c.name]
407 const holder = pidOf(c.port)
408 const id = holder ? ids.get(holder) : undefined
409 let status: Status
410 if (starting.has(c.name)) status = 'starting'
411 else if (child) status = listening.has(child.port) ? 'running' : 'starting'
412 else if (took1) status = 'running'
413 else if (listening.has(c.port)) {
414 const other = owner.get(c.port)
415 // Another configuration's server of this session's holds it; or a process
416 // that runs a configuration on this port better than this one, or none.
417 if (other !== undefined && other !== c.name) status = 'taken'
418 else if (!id) status = 'elsewhere'
419 else status = matchScore(id, c, root) > 0 && matchScore(id, c, root) === best.get(c.port) ? 'elsewhere' : 'taken'
420 } else if (prev?.status === 'failed') status = 'failed'
421 else status = 'stopped'
422 const used = status === 'running' || status === 'elsewhere' ? usage.get(c.name) : undefined
423 const held = !child && (status === 'elsewhere' || status === 'taken' || Boolean(took1))
424 const pid = took1?.pid ?? (held ? holder : undefined)
425 const command = took1?.command ?? (held ? id?.command : undefined)
426 next[c.name] = {
427 status,
428 port: child?.port ?? took1?.port ?? c.port,
429 log: logOf(c.name) ?? prev?.log ?? [],
430 code: child ? null : (prev?.code ?? null),
431 startedAt: child?.startedAt ?? 0,
432 ...(used ? { cpu: used.cpu, rss: used.rss } : {}),
433 ...(pid ? { pid } : {}),
434 ...(command ? { command } : {}),
435 ...(took1 ? { adopted: true } : {}),
436 }
437 }
438 // Only a change the pane would draw redraws: a new CPU sample that reads the same doesn't.
439 if (JSON.stringify(drawnServers(next)) !== JSON.stringify(drawnServers(old))) await update($, servers, () => next)
440}
441
442/** Starts a configuration's server; answers what happened, in a line. */
443const start = async ($: EngineInterface, name: string): Promise<string> => {
444 const f = await read($, file)
445 const c = f?.configs.find(x => x.name === name)
446 if (!f || !c) return `No configuration named "${name}"${f?.configs.length ? ` (have: ${f.configs.map(x => x.name).join(', ')})` : ''}.`
447 if (children.has(name)) return `${name} is already running from this session on ${urlOf(children.get(name)!.port)}.`
448 if (starting.has(name)) return `${name} is already starting.`
449 // Reserved before the first await, so a second Start meanwhile spawns nothing.
450 starting.add(name)
451 try {
452 return await spawnIt($, name, f, c)
453 } finally {
454 starting.delete(name)
455 await reconcile($).catch(() => undefined)
456 }
457}
458
459const spawnIt = async ($: EngineInterface, name: string, f: LaunchFile, c: LaunchConfig): Promise<string> => {
460 await reconcile($)
461 await probe($, [c.port])
462 let port = c.port
463 if (listening.has(port)) {
464 if (!c.autoPort) {
465 return `${name}: port ${port} is in use by another process (started elsewhere); not starting it twice.`
466 }
467 port = freePort(port)
468 }
469 const argv = [c.runtimeExecutable, ...c.runtimeArgs]
470 const stream = $.process.spawn({ argv, cwd: c.cwd ? resolvePath(f.root, c.cwd) : f.root, env: { ...c.env, PORT: String(port) } })
471 logs.set(name, { lines: [`$ ${commandOf(c)}`], partial: '' })
472 const child: Child = { stream, port, startedAt: Date.now(), stopping: false, done: Promise.resolve() }
473 children.set(name, child)
474 child.done = run($, name, child).catch(() => undefined)
475 return `Starting ${name} on ${urlOf(port)} (${commandOf(c)}).`
476}
477
478/** Reads the child's output until it ends, then writes how it ended. */
479const run = async ($: EngineInterface, name: string, child: Child) => {
480 const log = logs.get(name)!
481 let result: ProcessSpawnResult | undefined
482 let cannot = false
483 try {
484 for (;;) {
485 const piece = await child.stream.next()
486 if (piece.done) {
487 result = piece.value
488 break
489 }
490 appendLog(log, piece.value.text)
491 }
492 } catch (err) {
493 cannot = !child.stopping
494 if (cannot) appendLog(log, `\n${err instanceof Error ? err.message : String(err)}\n`)
495 }
496 if (children.get(name) === child) children.delete(name)
497 const failed = !child.stopping && (cannot || result?.code !== 0)
498 if (!child.stopping && result) appendLog(log, `\n${result.signal ? `ended by ${result.signal}` : `exited with code ${result.code}`}\n`)
499 const ended: Server = { status: failed ? 'failed' : 'stopped', port: child.port, log: logOf(name) ?? [], code: result?.code ?? null, startedAt: 0 }
500 // In turn with reconcile, so neither writes over the other with what it read before.
501 await serial(async () => {
502 // A newer child of the same name owns the row now (or a Restart's Start is under way).
503 if (children.has(name) || (child.stopping && starting.has(name))) return
504 await update($, servers, s => ({ ...s, [name]: ended }))
505 // A failure unfolds its row, so its output is in view.
506 if (failed) await update($, open, o => (o.includes(name) ? o : [...o, name]))
507 })
508}
509
510/** Ends a server this session started; answers what happened, in a line. */
511const stop = async ($: EngineInterface, name: string): Promise<string> => {
512 const child = children.get(name)
513 if (!child) {
514 const took = (await read($, adopted))[name]
515 if (took) {
516 const check = await recheck($, took, { sole: false, released: false })
517 if (!check.ok) {
518 // It isn't the process taken over any more (or it left its port): nothing of this session's to stop.
519 await forget($, name)
520 await settle($, took.port)
521 return `${name}: not stopped, and no longer taken over: ${check.why}.`
522 }
523 const out = await terminate($, took, { sole: false })
524 if (out.ended) await forget($, name)
525 await settle($, took.port)
526 return out.ended ? `Stopped ${name}.` : `${name}: Stop failed: ${out.why}; still taken over.`
527 }
528 const s = (await read($, servers))[name]
529 return s?.status === 'elsewhere'
530 ? `${name} on ${urlOf(s.port)} was started elsewhere (another session or the desktop app); take it over or kill it from the pane, or stop it there.`
531 : `${name} isn't running from this session.`
532 }
533 child.stopping = true
534 // Leaving the loop kills the child: return() ends the stream the loop reads.
535 void child.stream.return({ code: null, signal: 'SIGTERM' }).catch(() => undefined)
536 // A server that ignores the end mustn't hang Stop.
537 const wait = new AbortController()
538 await Promise.race([child.done, $.clock.sleep(STOP_WAIT, { signal: wait.signal }).catch(() => undefined)])
539 wait.abort()
540 if (children.get(name) === child) children.delete(name)
541 await probe($, [child.port]).catch(() => undefined)
542 await reconcile($)
543 return `Stopped ${name}.`
544}
545
546const restart = async ($: EngineInterface, name: string) => {
547 if (children.has(name) || (await read($, adopted))[name]) await stop($, name)
548 return start($, name)
549}
550
551// ---- servers started elsewhere: take over, kill ----
552
553/** How long Kill waits after SIGTERM before SIGKILL, and how often it looks meanwhile. */
554const KILL_WAIT = 2_000
555const KILL_LOOK = 250
556/** How long a first press of Kill waits for the second that confirms it. */
557export const CONFIRM_MS = 4_000
558
559/** Whether the process lives: `kill -0` succeeds. */
560const alive = async ($: EngineInterface, pid: number) => {
561 try {
562 return (await $.process.run(['kill', '-0', String(pid)], { timeoutMs: 5_000 })).exitCode === 0
563 } catch {
564 return false
565 }
566}
567
568/** A process recorded to be signalled later: the port it listened on, and who it was then. */
569type Recorded = { pid: number; port: number; start?: string; command: string }
570
571type Check = { ok: true } | { ok: false; gone: boolean; why: string }
572
573/** `process 1`, `processes 1 and 2`, `processes 1, 2 and 3`. */
574const listPids = (pids: Iterable<number>) => {
575 const a = [...pids]
576 return a.length === 1 ? `process ${a[0]}` : `processes ${a.slice(0, -1).join(', ')} and ${a[a.length - 1]}`
577}
578
579/**
580 * Whether a recorded process is still the one to signal, read afresh: ps's
581 * start time and command line for its pid must be the recorded ones (a pid
582 * freed and given to a later process mustn't be signalled), and lsof must list
583 * it on its port. `sole`: no other process listens there either (Kill ends one
584 * process a press). `released`: its port may be free (a process shutting down
585 * after SIGTERM closes its socket first). `gone` says the recorded process has
586 * ended (its pid is no one's, or a later process's).
587 */
588const recheck = async ($: EngineInterface, was: Recorded, o: { sole: boolean; released: boolean }): Promise<Check> => {
589 if (!was.start) return { ok: false, gone: false, why: `process ${was.pid}'s start time was never read, so it can't be told from a later process given its pid` }
590 await probe($, [was.port])
591 if (!byLsof) return { ok: false, gone: false, why: `lsof couldn't say which process listens on port ${was.port}` }
592 let row: Row | undefined
593 try {
594 const run = await $.process.run(['ps', ...PS_COLUMNS, '-p', String(was.pid)], PS_ENV)
595 row = tableFromPs(run.stdout).get(was.pid)
596 } catch {
597 return { ok: false, gone: false, why: `ps couldn't say who process ${was.pid} is now` }
598 }
599 if (!row) return { ok: false, gone: true, why: `process ${was.pid} has ended` }
600 if (row.start !== was.start || row.command !== was.command) {
601 return { ok: false, gone: true, why: `process ${was.pid} is now another process (started ${row.start}: ${row.command})` }
602 }
603 const held = holders.get(was.port) ?? new Set<number>()
604 if (!held.has(was.pid)) {
605 if (o.released && !held.size) return { ok: true }
606 return { ok: false, gone: false, why: held.size ? `port ${was.port} is now held by ${listPids(held)}, not process ${was.pid}` : `process ${was.pid} no longer listens on port ${was.port}` }
607 }
608 if (o.sole && held.size > 1) return { ok: false, gone: false, why: severalOn(was.port, held) }
609 return { ok: true }
610}
611
612const severalOn = (port: number, held: Set<number>) => `${held.size} processes listen on port ${port} (${listPids(held)}); Kill ends one process, so end them from a terminal`
613
614/**
615 * Ends a process this module didn't spawn, already rechecked: SIGTERM, then
616 * SIGKILL if it still lives after KILL_WAIT and is still the recorded process.
617 * The engine has no signal call of its own, so `kill` runs on the host as the
618 * session's user (it can end only that user's processes).
619 */
620const terminate = async ($: EngineInterface, was: Recorded, o: { sole: boolean }): Promise<{ ended: true } | { ended: false; why: string }> => {
621 const pid = was.pid
622 if (!(pid > 1)) return { ended: false, why: `process ${pid} can't be signalled` }
623 await $.process.run(['kill', '-TERM', String(pid)], { timeoutMs: 5_000 }).catch(() => undefined)
624 for (let waited = 0; waited < KILL_WAIT; waited += KILL_LOOK) {
625 if (!(await alive($, pid))) return { ended: true }
626 await $.clock.sleep(KILL_LOOK).catch(() => undefined)
627 }
628 if (!(await alive($, pid))) return { ended: true }
629 const check = await recheck($, was, { sole: o.sole, released: true })
630 if (!check.ok) return check.gone ? { ended: true } : { ended: false, why: `process ${pid} outlived SIGTERM and wasn't sent SIGKILL: ${check.why}` }
631 await $.process.run(['kill', '-KILL', String(pid)], { timeoutMs: 5_000 }).catch(() => undefined)
632 return (await alive($, pid)) ? { ended: false, why: `could not end process ${pid} (it may be another user's)` } : { ended: true }
633}
634
635/** Forgets a taken-over server. */
636const forget = ($: EngineInterface, name: string) => update($, adopted, a => Object.fromEntries(Object.entries(a).filter(([n]) => n !== name)))
637
638/** Reads every configuration's port and its holders afresh, then the rows. */
639const settle = async ($: EngineInterface, port: number) => {
640 const ports = [...new Set([port, ...((await read($, file))?.configs ?? []).map(c => c.port)])]
641 await probe($, ports).catch(() => undefined)
642 await identify($, ports).catch(() => undefined)
643 await reconcile($)
644}
645
646/** The process holding a configuration's port right now, read afresh, and who it is. */
647const holderOf = async ($: EngineInterface, port: number) => {
648 const ports = [...new Set([port, ...((await read($, file))?.configs ?? []).map(c => c.port)])]
649 await probe($, ports)
650 await identify($, ports)
651 const pid = pidOf(port)
652 return pid ? { pid, held: holders.get(port)!, id: ids.get(pid) } : null
653}
654
655/**
656 * Takes over the server answering on a configuration's port: records its pid,
657 * so the row reads running and Stop signals it. Its output stays where it goes
658 * (the terminal or app that started it): the engine can't read another
659 * process's standard output.
660 */
661const adopt = async ($: EngineInterface, name: string): Promise<string> => {
662 const c = (await read($, file))?.configs.find(x => x.name === name)
663 if (!c) return `No configuration named "${name}".`
664 if (children.has(name)) return `${name} is already running from this session.`
665 const holder = await holderOf($, c.port)
666 if (!holder) return `${name}: nothing found listening on port ${c.port} to take over.`
667 // Stop will signal it only if it's still this process, so who it is must be known now.
668 if (!holder.id) return `${name}: ps couldn't say who process ${holder.pid} is, so it can't be taken over.`
669 const took: Adopted = { pid: holder.pid, port: c.port, start: holder.id.start, command: holder.id.command, at: await $.clock.now() }
670 await update($, adopted, a => ({ ...a, [name]: took }))
671 await reconcile($)
672 return `Took over ${name} (process ${holder.pid}) on ${urlOf(c.port)}.`
673}
674
675/**
676 * Kill, in two presses: the first arms it on the row for CONFIRM_MS (the row
677 * asks `Kill PID 1234?`), the second within that time ends the process holding
678 * the port, if it's still the process the first press saw (see `recheck`). A
679 * press after it lapsed arms it again. A port several processes listen on
680 * isn't armed: one press signals one process.
681 */
682const killPress = async ($: EngineInterface, name: string): Promise<string> => {
683 const c = (await read($, file))?.configs.find(x => x.name === name)
684 if (!c) return `No configuration named "${name}".`
685 const s = (await read($, servers))[name]
686 if (!s?.pid) return `${name}: no process found on port ${c.port}.`
687 const now = await $.clock.now()
688 const armed = await read($, confirm)
689 if (armed && armed.name === name && armed.pid === s.pid && armed.until > now) {
690 await update($, confirm, () => null)
691 return kill($, name, armed)
692 }
693 // Arming: who holds the port, read afresh, is what the second press must find.
694 const holder = await holderOf($, s.port).catch(() => null)
695 const refuse = async (why: string) => {
696 await reconcile($)
697 return `${name}: not killed: ${why}.`
698 }
699 if (!holder) return refuse(`nothing listens on port ${s.port} now`)
700 if (holder.held.size > 1) return refuse(severalOn(s.port, holder.held))
701 if (holder.pid !== s.pid) return refuse(`port ${s.port} is now held by process ${holder.pid}; look again`)
702 if (!holder.id) return refuse(`ps couldn't say who process ${holder.pid} is`)
703 const until = now + CONFIRM_MS
704 await update($, confirm, () => ({ name, pid: holder.pid, port: s.port, start: holder.id!.start, command: holder.id!.command, until }))
705 $.clock.after(CONFIRM_MS, () => void update($, confirm, x => (x && x.name === name && x.until === until ? null : x)).catch(() => undefined))
706 return `Kill-armed ${name}.`
707}
708
709/** Ends the process the first press of Kill saw on a configuration's port (SIGTERM, then SIGKILL), after which Start works. */
710const kill = async ($: EngineInterface, name: string, armed: KillConfirm): Promise<string> => {
711 const check = await recheck($, armed, { sole: true, released: false })
712 if (!check.ok) {
713 await settle($, armed.port)
714 return `${name}: not killed: ${check.why}.`
715 }
716 const out = await terminate($, armed, { sole: true })
717 if (out.ended && (await read($, adopted))[name]) await forget($, name)
718 await settle($, armed.port)
719 return out.ended ? `Killed process ${armed.pid} on port ${armed.port}.` : `${name}: ${out.why}.`
720}
721
722// ---- reading while the pane is shown ----
723
724// Nothing runs while the pane isn't shown (but the children's own loops): a
725// timer probes the ports every tick while it is, and reads launch.json again
726// every FILE_EVERY.
727const TICK = 2_000
728const FILE_EVERY = 10_000
729let fileAt = 0
730let busy = false
731let shownAt = 0
732let shown = false
733
734const isShown = async ($: EngineInterface): Promise<boolean> => {
735 if (Date.now() - shownAt < 2_000) return shown
736 shownAt = Date.now()
737 try {
738 shown = (await $.ui.panes()).some(p => p.id === PANE && p.isShown && p.isPlaced)
739 } catch {
740 shown = false
741 }
742 return shown
743}
744
745const refresh = async ($: EngineInterface, readFile: boolean) => {
746 if (busy) return
747 busy = true
748 try {
749 if (readFile || !(await read($, file)) || Date.now() - fileAt >= FILE_EVERY) {
750 fileAt = Date.now()
751 const fresh = await loadFile($)
752 const old = await read($, file)
753 if (!old || JSON.stringify({ ...old, at: 0 }) !== JSON.stringify({ ...fresh, at: 0 })) await update($, file, () => fresh)
754 }
755 const configs = (await read($, file))?.configs ?? []
756 await probe($, configs.map(c => c.port))
757 await identify($, configs.map(c => c.port))
758 await sample($, configs)
759 await reconcile($)
760 } finally {
761 busy = false
762 }
763}
764
765const tick = async ($: EngineInterface) => {
766 if (await isShown($)) await refresh($, false)
767}
768
769// ---- the command ----
770
771const USAGE = 'Usage: /launcher (opens the pane), /launcher start <name>, /launcher stop <name>, /launcher restart <name>, /launcher list.'
772
773/** Every configuration on a line: name, state, where. */
774const listing = async ($: EngineInterface) => {
775 const f = await read($, file)
776 if (!f) return 'Reading .claude/launch.json…'
777 if (f.note) return f.note
778 const s = await read($, servers)
779 const lines = f.configs.map(c => {
780 const one = s[c.name]
781 const status = one?.status ?? 'stopped'
782 const port = one?.port ?? c.port
783 const where = status === 'running' || status === 'elsewhere' ? urlOf(port) : `:${port}`
784 return ` ${c.name} ${LOOK[status].word} ${where}`
785 })
786 return [`${f.root}/.claude/launch.json:`, ...(lines.length ? lines : [' (no configurations)'])].join('\n')
787}
788
789const command = async ($: EngineInterface, e: CommandRunInput) => {
790 const [verb = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
791 const name = rest.join(' ')
792 await refresh($, true).catch(() => undefined)
793 switch (verb.toLowerCase()) {
794 case '': {
795 // Opened by the person: read now rather than on the next tick.
796 shownAt = 0
797 await $.ui.open({ id: PANE, title: TITLE })
798 return { text: `${TITLE} pane opened.\n${await listing($)}` }
799 }
800 case 'list':
801 case 'status':
802 return { text: await listing($) }
803 case 'start':
804 case 'stop':
805 case 'restart': {
806 if (!name) return { text: `Which one? ${USAGE}` }
807 const v = verb.toLowerCase()
808 return { text: v === 'start' ? await start($, name) : v === 'stop' ? await stop($, name) : await restart($, name) }
809 }
810 default:
811 return { text: USAGE }
812 }
813}
814
815let worker: { cancel: () => void } | undefined
816
817export const register: Register = on => {
818 on('session.start', async ($, e, next) => {
819 for (const name of ['launcher', 'preview']) {
820 await $.command.register({
821 name,
822 description: "Open a pane with this project's dev servers from .claude/launch.json, or start or stop one",
823 argumentHint: '[start|stop|restart <name>]',
824 immediate: true,
825 })
826 }
827 worker?.cancel()
828 worker = $.clock.every(TICK, () => void tick($).catch(() => undefined))
829 void $.ui.open({ id: PANE, title: TITLE })
830
831 return next(e)
832 })
833
834 on('command.run', { command: 'launcher' }, ($, e) => command($, e))
835 on('command.run', { command: 'preview' }, ($, e) => command($, e))
836
837 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
838 // The row itself shows what happened; only a refusal or a failure needs a toast.
839 const quiet = /^(Starting|Stopped|Took over|Kill-armed|Killed)/
840 const say = (what: Promise<string>) => void what.then(text => (quiet.test(text) ? undefined : $.ui.toast(text))).catch(() => undefined)
841 const f = await read($, file)
842 const s = await read($, servers)
843 const o = await read($, open)
844 const armed = await read($, confirm)
845 const columnsOn = await read($, columns)
846 return renderPane(
847 // Buttons keep their press handles across redraws, so a click made just before one still lands.
848 holdButtons($.ui.resolve(e), e) as PaneElements,
849 e.surface,
850 e.props.bodyColumns,
851 f,
852 s,
853 o,
854 {
855 start: name => say(start($, name)),
856 stop: name => say(stop($, name)),
857 restart: name => say(restart($, name)),
858 adopt: name => say(adopt($, name)),
859 kill: name => say(killPress($, name)),
860 toggle: name => void update($, open, o => (o.includes(name) ? o.filter(n => n !== name) : [...o, name])).catch(() => undefined),
861 },
862 {
863 focused: e.props.isFocused,
864 confirm: armed,
865 flow: { on: columnsOn, toggle: () => void update($, columns, was => !was).catch(() => undefined) },
866 },
867 )
868 })
869}
870kit/ui.tsx 2021 lines1import type { RenderElement, RenderSurface } from 'claude-code'
2
3import { middle, pct, short } from './format'
4import { ICON_PX, glyphOf, iconSvg } from './icons'
5import { ms } from './perf'
6import type { PerfRow, PerfSnapshot } from './perf'
7import { C, FILL, GLYPH, GRAY, INK, MUTED, STATUS, TRACK, isKitColor, termInk, tint, treePrefix } from './tokens'
8import type { Duo, StatusKind } from './tokens'
9
10/**
11 * The shared components. Each draws for the surface it is given: on the app
12 * surfaces (desktop, VS Code, mobile) as boxes laid out by flexbox, crisp at
13 * any width; in the terminal as cells, `columns` wide.
14 *
15 * What the desktop accepts (2.1.293): whole percentages only, `flexGrow` up to
16 * 10000, fractional heights and margins; no `hover`. Borders `round` (rounded
17 * corners), single, double, bold and classic draw; `dashed` doesn't. SVG is
18 * drawn as an image: crisp only at its exact pixel size, so it's kept to small
19 * fixed pieces like bar ends.
20 *
21 * Units on the desktop (measured on 2.1.295): a column (widths, paddingX, marginX,
22 * gaps between columns) is ~7.8px; a row of size (`height`, `minHeight`) is ~19px,
23 * one text line; but vertical spacing (marginTop/Bottom/Y, paddingTop/Bottom/Y,
24 * rowGap) is half that, ~9.5px a unit. A `transparent` border colour is drawn in
25 * the text's colour, so the kit draws no border to round a fill.
26 */
27
28/** The element constructors the kit draws with. `Button` is needed only for collapsible sections. */
29export type Els = {
30 Box: (props: any, ...children: any[]) => RenderElement
31 Text: (props: any, ...children: any[]) => RenderElement
32 Button?: (props: any, ...children: any[]) => RenderElement
33 /** Rounds the ends of bars where the surface has it. */
34 Svg?: (props: any, ...children: any[]) => RenderElement
35}
36
37/**
38 * The Buttons one pane drew last, per scope: each by its key, with the props and
39 * children it was drawn with, the element built for them, and the latest press.
40 */
41type HeldButton = { look: string; made: unknown; element: RenderElement; frame: number; press: (e: any) => void }
42const heldButtons = new Map<string, { frame: number; buttons: Map<string, HeldButton> }>()
43
44/**
45 * The element table `els` with a `Button` that keeps each button's press handle
46 * from one drawing to the next, for the drawing `scope` names (the render event
47 * itself: its component, requestId and surface). Call it once per render, as
48 * the drawing starts (after the render's awaits), and draw every Button of that
49 * render with the table it returns (the kit's own included: pass it to `kit`).
50 *
51 * Why: the engine's JSX gives every Button it builds a new handle, and frees the
52 * old one as soon as a drawing leaves it out; a press reaches its `onPress` by
53 * that handle alone. So a redraw between drawing and click (the click focusing
54 * the pane, a timer's write) left the press on a freed handle, and it did
55 * nothing. Here a Button whose key, props and children match the last render's
56 * is that same element, its handle unchanged, its press running the latest
57 * `onPress`. One that changed is built anew (a stale press on it misses, as it
58 * should); one absent from the last render is forgotten.
59 */
60export const holdButtons = <E extends { Button?: unknown }>(els: E, scope: string | { component: string; requestId: string; surface: string }): E => {
61 const Made = els.Button as ((props: any, ...children: any[]) => RenderElement) | undefined
62 if (!Made) return els
63 const id = typeof scope === 'string' ? scope : `${scope.component}\u0000${scope.requestId}\u0000${scope.surface}`
64 const pane = heldButtons.get(id) ?? { frame: 0, buttons: new Map<string, HeldButton>() }
65 heldButtons.set(id, pane)
66 // A new render: keep only what the last one drew.
67 for (const [key, b] of pane.buttons) if (b.frame < pane.frame) pane.buttons.delete(key)
68 pane.frame += 1
69 const frame = pane.frame
70 const Button = (props: any, ...rest: any[]): RenderElement => {
71 const { children: given, onPress, ...plain } = props ?? {}
72 const children: unknown[] = rest.length ? rest : given === undefined ? [] : Array.isArray(given) ? given : [given]
73 const key = plain.key ?? plain.label ?? (children.length === 1 && typeof children[0] === 'string' ? children[0] : undefined)
74 if (typeof key !== 'string' || typeof onPress !== 'function') {
75 return (
76 <Made {...plain} onPress={onPress}>
77 {children}
78 </Made>
79 )
80 }
81 const look = JSON.stringify([plain, children])
82 const was = pane.buttons.get(key)
83 // A second Button on one key in one render is built apart: one element can't hold two presses.
84 if (was?.frame === frame) {
85 return (
86 <Made {...plain} onPress={onPress}>
87 {children}
88 </Made>
89 )
90 }
91 // The same look from the same constructor (another plugin may restyle the table's).
92 if (was && was.look === look && was.made === Made) {
93 was.frame = frame
94 was.press = onPress
95 return was.element
96 }
97 const b: HeldButton = { look, made: Made, frame, press: onPress, element: null as unknown as RenderElement }
98 b.element = (
99 <Made {...plain} onPress={(e: any) => b.press(e)}>
100 {children}
101 </Made>
102 )
103 pane.buttons.set(key, b)
104 return b.element
105 }
106 // Everything else read through to the table as it was (whatever kind of object it is).
107 // Defined, not assigned: the engine's table is frozen, and its read-only Button would refuse an assignment.
108 return Object.create(els, { Button: { value: Button, enumerable: true } }) as E
109}
110
111/**
112 * A bar part: its value, its color, and the label its legend shows. `room: true`
113 * marks room rather than use (free space, a reserve): the terminal draws it as
114 * the bar's light rail in its colour, and a `bar: true` legend keys it with `─`.
115 */
116export type Part = { value: number; color: string; label?: string; room?: boolean }
117
118const BAR = 0.45
119
120/** One row's height in CSS pixels on the desktop, measured: a 0.45-row bar is 8.5px tall. */
121const ROW_PX = 8.5 / 0.45
122/** One column's width in CSS pixels on the desktop (as the playground calibrates it): what an SVG sized in cells is drawn at. */
123const COL_PX = 7.8
124
125/*
126 * The grid. Mods use these, never raw spacing numbers:
127 *
128 * - SPACE.pair between the two halves of a pair (glyph and figure, dot and label),
129 * SPACE.item between items in a row, SPACE.group between groups of items;
130 * SPACE.block between blocks inside a section body (rows in a list sit at 0).
131 * - INSET: a band's (header, footer) padding on both sides, and a body's on the right,
132 * so header figures, body figures and the footer end on one line.
133 * - BODY_INDENT: a body's left edge, just past the header's fold arrow.
134 * - Reading columns: LABEL | gauge (grows) | FIG right-aligned | FIG2 right-aligned.
135 */
136export const SPACE = { pair: 0.75, item: 1.25, group: 2, block: 0.8 } as const
137const INSET = 1
138const BODY_INDENT = 1.6
139/**
140 * The content edges: everything that isn't a band (the header totals, section bodies)
141 * starts at `left` and ends at `right`. Alignment rules, for every row:
142 * - labels, names and titles start on the left edge;
143 * - the row's last figure or action ends on the right edge (rows are space-between);
144 * - figures are right-aligned; a row's right side is one cluster, never two.
145 */
146export const EDGE = { left: BODY_INDENT, right: INSET } as const
147const LABEL = 10
148const FIG = 5
149const FIG2 = 6
150/** The terminal's fold-arrow gutter. */
151const GUTTER = 2
152
153/*
154 * The terminal's grid. A terminal lays out in whole cells, so it has its own
155 * spacing (a fraction would round differently row to row) and draws to an exact
156 * width in cells. `k.space` and `k.edge` give the grid for the kit's surface.
157 *
158 * - CELLS: pair 1 (glyph and figure), item 1 (columns of a reading), group 2
159 * (figures in a summary, actions in a row), block 1 (a blank row).
160 * - Indent: a section body starts GUTTER (2) in, under the header's icon column;
161 * each tree level indents 2 more.
162 * - Reading columns: LABEL (10) | gauge (the rest) | FIG (5) | FIG2 (6), one cell
163 * apart; under MIN_GAUGE cells of gauge the label narrows, then the gauge goes.
164 */
165export const CELLS = { pair: 1, item: 1, group: 2, block: 1 } as const
166/** The fewest cells a terminal gauge is drawn in; narrower, the reading drops it and keeps its figures. */
167const MIN_GAUGE = 6
168/** The narrowest a terminal reading's label gets before its gauge is dropped. */
169const MIN_LABEL = 6
170/** The cells of its text a terminal list item keeps (with its ellipsis) before its right cluster is cut. */
171const ITEM_KEEPS = 6
172/**
173 * The narrowest pane the terminal draws section bodies in. Narrower, each section
174 * draws its header and figures only (a floor, as bottom's basic mode), rather
175 * than every reading collapsing to an ellipsis.
176 */
177export const MIN_COLUMNS = 30
178
179/**
180 * The terminal's meter glyphs. A gauge and a bar are both a heavy line on a light
181 * one, to half a cell (╸): a bar's parts are runs of the heavy line, each in its
182 * own solid theme colour, and its room (free space, a reserve) the light rail in
183 * its colour. No shades or dithers: they render as a checkerboard in real fonts.
184 */
185export const METER = {
186 line: '━',
187 half: '╸',
188 rail: '─',
189 /** Lower eighths, 0 to 8: for columns that rise. */
190 rise: [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'],
191} as const
192
193/**
194 * The widest a dashboard's sections are drawn on the terminal, in cells, when a
195 * mod passes `measure: MEASURE` to `kit()`: past it, more width only spreads
196 * readings apart and sends the eye across the pane.
197 */
198export const MEASURE = 72
199/** The terminal's right margin: figures stop one cell short of the pane's edge. */
200const RIGHT = 1
201/** The cells a section title's column takes, so header figures start on one column. */
202const TITLE_COL = 10
203
204/** The terminal's fold arrows: the small triangles, a cell each. */
205export const FOLD = { open: '▾', closed: '▸' } as const
206
207/**
208 * The terminal's marks for lists and footers: a selected row's gutter mark (a
209 * left half block, in `suggestion`, Claude Code's own selection colour), the key
210 * hints' separator, and the ellipsis that ends anything cut.
211 */
212export const MARK = { selected: '▌', sep: ' · ', more: '…', stale: '↻' } as const
213
214/**
215 * Braille dots for a sparkline, filled from the bottom: a cell holds two points
216 * (its left and right dot columns), four levels each. `left[n]` is the left
217 * column's bottom n dots, `right[n]` the right column's; OR them onto U+2800.
218 */
219export const SPARK = {
220 base: 0x2800,
221 left: [0, 0x40, 0x44, 0x46, 0x47],
222 right: [0, 0x80, 0xa0, 0xb0, 0xb8],
223} as const
224/**
225 * A band's (a section header's, a footer's) height on the app surfaces, in rows of
226 * size: one text row and a little room. Fixed rather than padded, so every band is
227 * as tall as every other whatever it holds: a native Button, a Link or an icon in
228 * a row of the real desktop is taller or shorter than a line of text, and a padded
229 * band grew or shrank with it (the launcher's rows came out uneven).
230 */
231export const BAND_ROWS = 1.3
232
233/**
234 * A Braille blank (U+2800): drawn as nothing, as a space is, but never trimmed or
235 * collapsed. A Button labelled with them lies over what the desktop can't draw in a
236 * Button (an icon, a coloured word), which shows through; its key is its address.
237 */
238export const BLANK = '⠀'
239/** A tile's padding above and below its lines, and the room between its lines: enough to breathe. */
240const TILE_PAD_Y = 0.6
241const TILE_ROW_GAP = 0.2
242
243/**
244 * The desktop's fold arrow: full-size triangles, since text can't be sized.
245 * U+FE0E keeps ▶ a text glyph; without it macOS can draw it as an emoji. Never
246 * on the terminal, which draws FOLD and never gets a variation selector.
247 */
248export const fold = (collapsed: boolean) => (collapsed ? '▶︎' : '▼')
249
250
251/**
252 * `text` split for two terminal lines `width` cells wide: the first holds as many
253 * whole words as fit (a word longer than the line is cut to it), the second the
254 * rest, which the caller truncates. The second is empty when it all fits on one.
255 */
256const breakLine = (text: string, width: number): [string, string] => {
257 const chars = [...text]
258 if (chars.length <= width) return [text, '']
259 // The last space at or before the line's end; none, a hard cut.
260 let cut = -1
261 for (let i = Math.min(width, chars.length - 1); i > 0; i--) {
262 if (chars[i] === ' ') {
263 cut = i
264 break
265 }
266 }
267 if (cut <= 0) return [chars.slice(0, width).join(''), chars.slice(width).join('').trimStart()]
268 return [chars.slice(0, cut).join('').trimEnd(), chars.slice(cut + 1).join('').trimStart()]
269}
270
271
272/** The debug footer's glyph for each job the mods time; all muted, a slow job's figure turns amber or red. */
273export const PERF_GLYPH: Readonly<Record<string, string>> ={ render: '▣', tick: '↻', resources: '◎', usage: '◕', git: '⎇', gh: '⇡' }
274
275/*
276 * The terminal's look ("rules", after lazygit and gh-dash, made quieter). A header
277 * is a dim arrow and icon, the title in bold default foreground, its figures on a
278 * shared column right after it (so the eye doesn't cross the pane), then a dim `─`
279 * rule out to the edge. Status colours keep their one meaning; nothing else is coloured.
280 *
281 * Two tones and one weight carry the hierarchy: dim for what a thing is (labels,
282 * units, notes), the default foreground for what it says (values, list text),
283 * bold for the title and the one figure that matters in each section (a header's
284 * first figure). No italics: real terminal fonts draw them oddly, and some not at
285 * all. Tune the look here, in one place:
286 * - title: a section's name: bold;
287 * - label: what a value is (a reading's, a prop's, a legend entry's label): dim;
288 * - value: the figure or word that answers (a reading's figure, a prop's value): plain;
289 * - strong: the one figure per section that matters (a header's first figure): bold;
290 * - secondary: text a reader still reads, after the main text (a list row's note): dim;
291 * - meta: what's scanned past (units, ages, counts in a list row): dim.
292 *
293 * The alternatives it was picked from (inspector, panel, ledger) are in commit ab3a37a.
294 */
295
296/** Text props a terminal role is drawn with. */
297type Role = { color?: string; dimColor?: boolean; bold?: boolean; italic?: boolean }
298
299export const TERM_ROLES: Readonly<Record<'title' | 'label' | 'value' | 'strong' | 'secondary' | 'meta', Role>> = {
300 title: { bold: true },
301 label: { dimColor: true },
302 value: {},
303 strong: { bold: true },
304 secondary: { dimColor: true },
305 meta: { dimColor: true },
306}
307
308/**
309 * The header icon's colour on the terminal: dim (MUTED), as the fold arrow, since
310 * grey is what icons mean. The title beside it is bold in the default foreground,
311 * so a header carries no hue that could be read as a status in the rows under
312 * it. Read when drawing, so a skin worn later is the one drawn.
313 */
314const termHeaderIcon = () => MUTED
315
316/** What the kit is told about the pane beyond its surface and width. */
317export type KitOptions = {
318 /**
319 * Whether the pane has the keyboard: pass `e.props.isFocused`. Key hints show
320 * only while it's true, and a selected list row is drawn dimmed while it's
321 * false. Left out, the pane counts as focused.
322 */
323 focused?: boolean
324 /**
325 * The widest the terminal draws sections, in cells (`MEASURE` for a dashboard
326 * of readings). Past it a section keeps this width and the rest of the pane
327 * stays empty. Left out, sections span the pane (a file tree wants every cell).
328 */
329 measure?: number
330 /**
331 * The column view's switch, as the mod holds it in its own state (key
332 * `columns`, default on): off, `flow` gives one stack at any width. With a
333 * `toggle`, a wide desktop pane's footer (`perf`, `footer`) draws the quiet
334 * action that flips it (see `flowToggle`).
335 */
336 flow?: FlowSwitch
337}
338
339/** The column view's switch: `on` as the mod's state holds it, `toggle` to flip it from a press. */
340export type FlowSwitch = { on: boolean; toggle?: () => void }
341
342/**
343 * A part of a list row's right cluster that says when it gives way: at narrow
344 * widths the part with the highest `drop` goes first (ties: the later one), then
345 * the next, until the row fits; parts without one (rank 0) are never dropped,
346 * only cut. `cells` is its width on the terminal when it's an element the kit
347 * can't measure (a string measures itself).
348 */
349export type RightPart = { part: RenderElement | string; drop?: number; cells?: number }
350
351/** One key hint: the key as the person types it, and what it does. */
352export type Hint = { key: string; label: string }
353
354const isRightPart = (x: RenderElement | string | RightPart): x is RightPart => typeof x === 'object' && x !== null && 'part' in x && !('type' in x)
355
356/**
357 * The cells a part draws in on the terminal, as far as the kit can tell: a
358 * string's characters; a Text's text; a row Box's children and gaps; a plain
359 * Button's label (and `1: ` before it with a hotkey). Undefined when it can't.
360 */
361const cellsOf = (x: unknown): number | undefined => {
362 if (x == null || x === false) return 0
363 if (typeof x === 'string' || typeof x === 'number') return [...String(x)].length
364 if (Array.isArray(x)) {
365 let n = 0
366 for (const c of x) {
367 const m = cellsOf(c)
368 if (m === undefined) return undefined
369 n += m
370 }
371 return n
372 }
373 if (typeof x !== 'object') return undefined
374 const el = x as { type?: string; props?: Record<string, any>; children?: unknown[] }
375 const p = el.props ?? {}
376 const kids = el.children ?? (p.children as unknown[] | undefined) ?? []
377 if (el.type === 'Text') return cellsOf(kids)
378 if (el.type === 'Button') {
379 const label = p.label === undefined ? cellsOf(kids) : [...String(p.label)].length
380 if (label === undefined) return undefined
381 return (p.plain ? label : label + 4) + (p.hotkey ? 3 : 0)
382 }
383 if (el.type === 'Box') {
384 const list = (Array.isArray(kids) ? kids : [kids]).flat(Infinity as 1).filter(c => c != null && c !== false)
385 const sizes = list.map(cellsOf)
386 if (sizes.some(s => s === undefined)) return undefined
387 const n = sizes as number[]
388 if (p.flexDirection === 'column') return Math.max(0, ...n)
389 const gap = Number(p.columnGap ?? p.gap ?? 0)
390 return n.reduce((a, b) => a + b, 0) + gap * Math.max(0, n.length - 1)
391 }
392 return undefined
393}
394
395/*
396 * Flow: a pane's sections in columns when the desktop gives it the width.
397 *
398 * The engine tells a render its pane's width (`e.props.bodyColumns`; on the desktop
399 * the pane's width over its code font's advance), and draws again when it changes.
400 * At a sidebar's width the sections stack as they always have; past two of
401 * FLOW_MIN (plus the gutter) they split into two columns, past three into three.
402 * The terminal always stacks: its panes dock beside the transcript and are narrow.
403 */
404
405/**
406 * The narrowest a flow column gets, in the pane's reported columns: about the
407 * widest a desktop sidebar is drawn (Storybook's 110), halved, so a section in a
408 * column reads as it does in a roomy sidebar. Two columns from 82 (about 640px), three from 124 (about 970px).
409 */
410export const FLOW_MIN = 40
411/** The most columns a flow splits into: more makes every section a sliver. */
412export const FLOW_MAX = 3
413/** The gutter between flow columns: a group's room, as between groups of items in a row. No rule or panel marks it. */
414const FLOW_GAP = SPACE.group
415
416/** How a flow is told to lay out; each defaults to the kit's FLOW_MIN, FLOW_MAX. */
417export type FlowOptions = {
418 /** The narrowest column, in reported columns. */
419 min?: number
420 /** The most columns. */
421 max?: number
422 /**
423 * Each block's height in rows, where the caller knows better than the kit's
424 * estimate (`undefined` entries are estimated). In the order the blocks are given.
425 */
426 rows?: (number | undefined)[]
427 /**
428 * The room between blocks in a column, in the desktop's vertical units, for
429 * blocks that space themselves apart in one stack (a list's tiles, each with a
430 * margin above all but the first): in columns they drop that margin and the
431 * column spaces them, so every column's first block starts on one line.
432 */
433 gap?: number
434 /** One stack at any width: the column view switched off (KitOptions' `flow`). */
435 single?: boolean
436}
437
438/** Whether a pane `columns` across on `surface` is wide enough to split into flow columns at all. */
439export const flowSplits = (surface: RenderSurface, columns: number, o: Pick<FlowOptions, 'min'> = {}) =>
440 surface !== 'terminal' && Math.floor((Math.floor(columns) + FLOW_GAP) / ((o.min ?? FLOW_MIN) + FLOW_GAP)) >= 2
441
442/**
443 * How many columns a flow of `blocks` blocks takes at `columns` across on
444 * `surface`, and each column's width in reported columns: 1 and the whole width
445 * on the terminal, at a sidebar's width, with fewer than two blocks, or `single`.
446 */
447export const flowColumns = (surface: RenderSurface, columns: number, blocks: number, o: FlowOptions = {}) => {
448 const min = o.min ?? FLOW_MIN
449 const max = o.max ?? FLOW_MAX
450 const fit = surface === 'terminal' || o.single ? 1 : Math.floor((Math.floor(columns) + FLOW_GAP) / (min + FLOW_GAP))
451 const count = Math.max(1, Math.min(fit, max, blocks))
452 return { count, width: count === 1 ? columns : Math.floor((columns - FLOW_GAP * (count - 1)) / count) }
453}
454
455/** A drawn element's own props and children, as the engine's JSX and Storybook's both build them. */
456const partsOf = (x: any): { type?: string; p: Record<string, any>; kids: unknown[] } => {
457 const p = (x?.props ?? {}) as Record<string, any>
458 const given = x?.children ?? p.children ?? []
459 return { type: x?.type, p, kids: (Array.isArray(given) ? given : [given]).flat(Infinity as 1).filter(c => c != null && c !== false) }
460}
461
462/**
463 * About how many rows tall an element draws on the desktop: a Text or a Button a
464 * row, a column Box its children stacked, a row Box its tallest child, a `height`
465 * as given; vertical margins, padding and gaps at half a row a unit, as the
466 * desktop draws them. Only good enough to balance columns, never to size them.
467 */
468export const rowsOf = (x: unknown): number => {
469 if (x == null || x === false) return 0
470 if (typeof x === 'string' || typeof x === 'number') return 1
471 if (Array.isArray(x)) return x.reduce((n: number, c) => n + rowsOf(c), 0)
472 if (typeof x !== 'object') return 0
473 const { type, p, kids } = partsOf(x)
474 if (p.display === 'none' || p.position === 'absolute') return 0
475 const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
476 // Vertical spacing in half rows (the desktop's unit for it), as rows.
477 const around =
478 (num(p.marginTop) + num(p.marginBottom) + 2 * num(p.marginY) + 2 * num(p.margin) + num(p.paddingTop) + num(p.paddingBottom) + 2 * num(p.paddingY) + 2 * num(p.padding)) / 2
479 let own: number
480 if (typeof p.height === 'number') own = p.height
481 else if (type === 'Box') {
482 const sizes = kids.map(rowsOf)
483 if (p.flexDirection === 'column') own = sizes.reduce((a, b) => a + b, 0) + (num(p.rowGap ?? p.gap) * Math.max(0, sizes.length - 1)) / 2
484 else own = Math.max(0, ...sizes)
485 } else own = 1
486 return Math.max(own, num(p.minHeight)) + around
487}
488
489/**
490 * Splits `heights` into `n` runs in order, so the tallest run is as short as it
491 * can be: blocks read top to bottom, then left to right, and the columns end
492 * about even. Returns each run's first index.
493 */
494const balance = (heights: number[], n: number): number[] => {
495 const m = heights.length
496 const sum = [0]
497 for (const h of heights) sum.push(sum[sum.length - 1]! + h)
498 // best[j][i]: the tallest column laying the first i blocks in j columns; cut[j][i] where the last column starts.
499 const best = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(Infinity))
500 const cut = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
501 best[0]![0] = 0
502 for (let j = 1; j <= n; j++) {
503 for (let i = j; i <= m; i++) {
504 // Every column holds at least one block.
505 for (let s = j - 1; s < i; s++) {
506 const tall = Math.max(best[j - 1]![s]!, sum[i]! - sum[s]!)
507 // Ties go to the later cut, so earlier columns take more and the last isn't the tall one.
508 if (tall <= best[j]![i]!) {
509 best[j]![i] = tall
510 cut[j]![i] = s
511 }
512 }
513 }
514 }
515 const starts: number[] = []
516 let i = m
517 for (let j = n; j >= 1; j--) {
518 starts.unshift(cut[j]![i]!)
519 i = cut[j]![i]!
520 }
521 return starts
522}
523
524/**
525 * Lays `blocks` (a pane's sections, a list's tiles) out in columns when the pane
526 * is wide enough (see `flowColumns`); otherwise gives them back as they were, so
527 * a narrow pane draws exactly what it drew before. Spread the result where the
528 * blocks went: `{flow(Box, e.surface, columns, 'sections', [a, b, c])}`.
529 *
530 * A block is never split across columns. They read top to bottom, then left to
531 * right, cut where the columns come out most even by `rowsOf`'s estimate (or the
532 * `rows` given). Each column is a plain column Box (key `<key>-col-<n>`), each
533 * block in it spanning the column's width; the gutter between is FLOW_GAP, with
534 * no rule, panel or outline.
535 */
536export const flow = (
537 Box: Els['Box'],
538 surface: RenderSurface,
539 columns: number,
540 key: string,
541 blocks: (RenderElement | null | false | undefined)[],
542 o: FlowOptions = {},
543): (RenderElement | null)[] => {
544 const shown = blocks.map((b, i) => ({ b, rows: o.rows?.[i] })).filter((x): x is { b: RenderElement; rows: number | undefined } => x.b != null && x.b !== false)
545 const { count } = flowColumns(surface, columns, shown.length, o)
546 if (count < 2) return blocks.map(b => b || null)
547 const starts = balance(
548 shown.map(x => x.rows ?? rowsOf(x.b)),
549 count,
550 )
551 const runs = starts.map((s, c) => shown.slice(s, starts[c + 1] ?? shown.length))
552 return [
553 <Box key={key} flexDirection="row" width="100%" columnGap={FLOW_GAP} alignItems="flex-start">
554 {runs.map((run, c) => (
555 // Columns share the row equally, growing from no width, as a legend's do (the desktop doesn't stretch a nested box).
556 <Box key={`${key}-col-${c}`} flexDirection="column" flexGrow={1} flexShrink={1} width={0} minWidth={0} {...(o.gap ? { rowGap: o.gap } : {})}>
557 {run.map((x, i) => (
558 <Box key={`${key}-${c}-${i}`} flexDirection="column" width="100%" minWidth={0}>
559 {x.b}
560 </Box>
561 ))}
562 </Box>
563 ))}
564 </Box>,
565 ]
566}
567
568export const kit = (els: Els, surface: RenderSurface, columns = 40, opts: KitOptions = {}) => {
569 const { Box, Text, Button, Svg } = els
570 const terminal = surface === 'terminal'
571 /** Whether the pane has the keyboard; unknown counts as yes. */
572 const paneFocused = opts.focused !== false
573 /** A flow's options with the column view's switch applied: off, one stack. */
574 const flowOpts = (o: FlowOptions = {}): FlowOptions => (opts.flow?.on === false ? { ...o, single: true } : o)
575 /** Under MIN_COLUMNS the terminal draws section headers only. */
576 const floor = terminal && Math.floor(columns) < MIN_COLUMNS
577 /** A text role's props on the terminal (see TERM_ROLES). */
578 const R = TERM_ROLES
579 /** Text a step below the main text: one of the three greys on the app surfaces; plain dim in the terminal, whose greys are its own. */
580 const tone = (level: keyof typeof GRAY) => (terminal ? { dimColor: true } : { color: GRAY[level] })
581 /** A token colour as Text props for this surface: on the terminal a status's theme key, a grey as dim. */
582 const ink = (color?: string): { color?: string; dimColor?: boolean } => (terminal ? termInk(color) : color ? { color } : {})
583 /** The grid for this surface: CELLS on the terminal, SPACE elsewhere. */
584 const space = terminal ? CELLS : SPACE
585 const edge = terminal ? { left: GUTTER, right: RIGHT } : EDGE
586 /** A section's width in terminal cells: the pane's, up to the measure. */
587 const span = Math.max(1, Math.min(Math.floor(columns), opts.measure ?? Infinity))
588 /** A section body's width in terminal cells: the section's less the gutter and the right margin. */
589 const body = Math.max(1, span - GUTTER - RIGHT)
590 const clamp01 = (x: number) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0)
591
592 /** A run of one glyph in one colour, as one Text. */
593 const run = (key: string, glyph: string, n: number, props: Record<string, unknown>) =>
594 n > 0 ? <Text key={key} {...props}>{glyph.repeat(n)}</Text> : null
595
596 /**
597 * A terminal gauge `width` cells wide: a heavy line to the half cell over a
598 * dim light rail. Anything above zero shows at least half a cell.
599 */
600 const lineMeter = (fraction: number, color: string, width: number): RenderElement => {
601 const w = Math.max(1, Math.floor(width))
602 let halves = Math.round(clamp01(fraction) * w * 2)
603 if (fraction > 0 && halves === 0) halves = 1
604 const full = Math.floor(halves / 2)
605 const half = halves % 2
606 return (
607 <Text>
608 {run('fill', METER.line, full, ink(color))}
609 {half ? <Text key="half" {...ink(color)}>{METER.half}</Text> : null}
610 {run('rail', METER.rail, w - full - half, { dimColor: true })}
611 </Text>
612 )
613 }
614
615 /**
616 * A terminal bar `width` cells wide, one row: each part a run of the heavy line
617 * (`━`) in its own solid colour, to the half cell; a `room` part the light rail
618 * (`─`) in its colour; what's left the dim rail. Anything above zero shows at
619 * least half a cell. A cell shared by two parts takes the first one's colour.
620 */
621 const lineBar = (parts: Part[], total: number, width: number): RenderElement => {
622 const w = Math.max(1, Math.floor(width))
623 const halves = w * 2
624 const ends: { end: number; color: string; room: boolean }[] = []
625 let at = 0
626 for (const p of parts) {
627 if (!(p.value > 0) || at >= halves) continue
628 at = Math.min(halves, at + Math.max(1, Math.round((p.value / total) * halves)))
629 ends.push({ end: at, color: p.color, room: Boolean(p.room) })
630 }
631 const partAt = (h: number) => ends.find(x => h < x.end)
632 const rail = { glyph: METER.rail, props: { dimColor: true } as Record<string, unknown> }
633 const cells: { glyph: string; props: Record<string, unknown> }[] = []
634 for (let c = 0; c < w; c++) {
635 const left = partAt(2 * c)
636 const right = partAt(2 * c + 1)
637 if (!left) cells.push(rail)
638 else if (left.room) cells.push({ glyph: METER.rail, props: ink(left.color) })
639 // A used part ending at the half: the half line, then nothing (a room part, or the rail, starts next cell).
640 else if (!right || right.room) cells.push({ glyph: METER.half, props: ink(left.color) })
641 else cells.push({ glyph: METER.line, props: ink(left.color) })
642 }
643 // Runs of the same glyph and colour, as one Text each.
644 const runs: RenderElement[] = []
645 let i = 0
646 while (i < cells.length) {
647 let j = i + 1
648 while (j < cells.length && cells[j]!.glyph === cells[i]!.glyph && JSON.stringify(cells[j]!.props) === JSON.stringify(cells[i]!.props)) j++
649 runs.push(<Text key={`r${i}`} {...cells[i]!.props}>{cells[i]!.glyph.repeat(j - i)}</Text>)
650 i = j
651 }
652 return <Text>{runs}</Text>
653 }
654
655 /*
656 * Rounding on the desktop. Boxes have no corner radius; what rounds is:
657 *
658 * - `capped`: anything thin (bars, gauges). SVG end caps drawn at an exact pixel
659 * size, so they stay crisp; the middle takes no height and stretches to the caps.
660 * - Nothing else: `rounded` boxes (bands, tiles) are square-cornered fills.
661 *
662 * What doesn't work, so nobody tries again: a `round` border in `transparent` to
663 * round a fill (the desktop doesn't know `transparent` as a border colour and draws
664 * the border in the text's colour: every band and tile became an outlined bubble),
665 * a border in a see-through fill's own colour (paints twice and draws a ring),
666 * borders on thin bars (empty, or blobs),
667 * glyph ends (◖━◗, ╺━╸ don't draw), SVG slivers a few pixels wide (don't draw), an
668 * SVG stretched to fit (blurs), a circle drawn over a rect in one SVG (a translucent
669 * colour doubles where they overlap).
670 */
671
672 /**
673 * An icon at the kit's one size (ICON_PX square): the SVG drawing where the
674 * surface has SVG and the kit has one for `key`, else its glyph as text
675 * (`glyphOf`: the terminal glyph for a named icon, never the name itself).
676 *
677 * `color` may be a file type's `Duo` (`FILE_TYPE`): light and dark values on the
678 * app surfaces, dim on the terminal, which draws file types monochrome.
679 * `alt` names the drawing for a reader that can't see it (the desktop's
680 * accessible name); `tooltip` also shows it under the pointer, drawing the SVG
681 * interactive (a sandboxed frame), so keep it to an icon that says something
682 * on its own (a branch's upstream), never one under a Button.
683 */
684 const icon = (key: string, color: string | Duo, px = ICON_PX, opts: { alt?: string; tooltip?: string } = {}): RenderElement => {
685 const source = Svg && !terminal ? iconSvg(key, color, px, opts.tooltip) : null
686 if (!Svg || !source) return <Text {...ink(typeof color === 'string' ? color : MUTED)}>{glyphOf(key)}</Text>
687 const alt = opts.alt ?? opts.tooltip ?? 'icon'
688 return opts.tooltip ? <Svg source={source} alt={alt} width={px} height={px} isInteractive /> : <Svg source={source} alt={alt} width={px} height={px} />
689 }
690
691 /**
692 * One rounded end:a square `px` tall, a half circle on the outside and a filled
693 * strip on the inside, as one shape.
694 */
695 const cap = (px: number, color: string, side: 'left' | 'right'): RenderElement | null => {
696 if (!Svg) return null
697 const r = px / 2
698 const d = side === 'left' ? `M${r} 0H${px}V${px}H${r}A${r} ${r} 0 0 1 ${r} 0Z` : `M0 0H${r}A${r} ${r} 0 0 1 ${r} ${px}H0Z`
699 // Over the track, as the bar's middle paints its parts: a see-through part's end
700 // then comes out the same shade as the rest of it.
701 const under = color === TRACK ? '' : `<path d="${d}" fill="${TRACK}"/>`
702 const source =
703 `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 ${px} ${px}">` +
704 `${under}<path d="${d}" fill="${color}"/></svg>`
705 return (
706 <Box flexShrink={0}>
707 <Svg source={source} alt="rounded end" width={px} height={px} />
708 </Box>
709 )
710 }
711
712 /**
713 * Rounds the ends of something thin: `middle` between a `left` and a `right` cap,
714 * `height` rows tall. Give `middle` no height of its own; it stretches to the caps,
715 * whose pixel size it can't drift from. Where the surface has no SVG, `middle` alone
716 * at `height`.
717 */
718 const capped = (height: number, left: string, right: string, middle: (height: number | undefined) => RenderElement): RenderElement => {
719 if (terminal || !Svg) return middle(height)
720 const px = Math.max(4, Math.round(height * ROW_PX))
721 return (
722 <Box flexDirection="row" flexGrow={1} alignItems="stretch">
723 {cap(px, left, 'left')}
724 {middle(undefined)}
725 {cap(px, right, 'right')}
726 </Box>
727 )
728 }
729
730 /**
731 * A filled box, a row or more tall: a band or a tile. Square-cornered and with no
732 * border on the app surfaces, as the design has no outlines and the desktop has no
733 * transparent border to round a fill with (see "Rounding on the desktop" above).
734 * In the terminal, the box without fill.
735 */
736 const rounded = (color: string, props: Record<string, unknown>, children: RenderElement | (RenderElement | null)[] | null) =>
737 terminal ? (
738 <Box {...props}>{children}</Box>
739 ) : (
740 <Box {...props} backgroundColor={color}>
741 {children}
742 </Box>
743 )
744
745 /**
746 * A tile: one item of a list that has several lines (a branch and its PR), in its
747 * own rounded fill a step lighter than the pane, so items read as units. A
748 * `selected` tile takes a blue-tinted fill: picked, without an outline; half
749 * as tinted while the pane isn't `focused` (the kit's `focused` by default).
750 * On the terminal, a blank row above and no fill, selected or not: a terminal
751 * list that selects draws its rows with `item`, which has a selection mark.
752 */
753 const tile = (key: string, children: (RenderElement | null)[], opts: { selected?: boolean; focused?: boolean } = {}) =>
754 terminal ? (
755 <Box key={key} flexDirection="column" marginTop={1}>
756 {children}
757 </Box>
758 ) : (
759 rounded(
760 opts.selected ? ((opts.focused ?? paneFocused) ? FILL.selected : FILL.selectedBlurred) : FILL.tile,
761 { key, flexDirection: 'column', paddingX: SPACE.item, paddingY: TILE_PAD_Y, rowGap: TILE_ROW_GAP, width: '100%' },
762 children,
763 )
764 )
765
766 /**
767 * A thin bar: parts left to right out of `max`, the rest as track. On the
768 * terminal one row `width` cells wide (a section body's width by default): the
769 * gauge's heavy line, each part in its own colour, `room` parts and the rest
770 * as the light rail (see `lineBar`).
771 */
772 const bar = (parts: Part[], max: number, opts: { height?: number; width?: number } = {}): RenderElement => {
773 const total = Math.max(max, parts.reduce((n, p) => n + Math.max(0, p.value), 0), 1)
774 if (terminal) {
775 const w = opts.width ?? body
776 return lineBar(parts, total, w)
777 }
778 // Grow factors out of 1000 (the desktop caps them at 10000): finer than a pixel at any width.
779 // Rounded by SVG end caps around a flat middle. Borders can't round it: on the desktop a
780 // border takes room inside the box, so a thin bordered track draws empty and bordered fills
781 // draw as blobs; glyph bars (◖━◗, ╺━╸) don't draw at all.
782 const height = opts.height ?? BAR
783 const shown = parts.filter(p => p.value > 0).map(p => ({ ...p, grow: (p.value / total) * 1000 }))
784 const rest = Math.max(0, 1000 - shown.reduce((n, p) => n + p.grow, 0))
785 // Not `h`: that's the JSX factory.
786 const flat = (rows: number | undefined) => (
787 <Box flexDirection="row" flexGrow={1} height={rows} backgroundColor={TRACK} overflow="hidden">
788 {/* An explicit full height: the desktop doesn't stretch a child to its parent's height. */}
789 {shown.map(p => (
790 <Box flexGrow={p.grow} flexShrink={0} width={0} height="100%" backgroundColor={p.color} />
791 ))}
792 {rest > 0 ? <Box flexGrow={rest} width={0} /> : null}
793 </Box>
794 )
795 // The caps sit inside the bar's width, so a scale under it still lines up with its ends.
796 const first = shown[0]?.color ?? TRACK
797 const last = rest > 0 ? TRACK : (shown[shown.length - 1]?.color ?? TRACK)
798 return capped(height, first, last, flat)
799 }
800
801 /**
802 * A label as GitHub draws one: a rounded chip filled with the label's colour,
803 * faint and see-through (FILL.pill), and its name in the theme's foreground, which
804 * reads on the light desktop theme and the dark one whatever the label's colour
805 * (a label without a hex colour gets the track's grey). On the
806 * terminal, a `■` swatch and the name in the default foreground: a label's
807 * colour is data (GitHub's hex) that may not read on the person's theme, so it
808 * only ever tints the one-cell swatch, never the text. A status colour (a kit
809 * token) draws as its theme key, as everywhere.
810 */
811 const pill = (key: string, text: string, color: string) => {
812 if (terminal) {
813 return (
814 <Text key={key}>
815 <Text {...(isKitColor(color) ? ink(color) : { color })}>■</Text>
816 {` ${text}`}
817 </Text>
818 )
819 }
820 return rounded(tint(color, FILL.pill) ?? TRACK, { key, flexDirection: 'row', paddingX: 0.6, flexShrink: 0 }, [<Text key="t">{text}</Text>])
821 }
822
823 /**
824 * A small mark of colour, a legend's key: a dot on the app surfaces, a square
825 * `■` on the terminal. `line: true` keys a line bar's part (`k.bar`) on the
826 * terminal with its own glyph: `━` for a part, `─` for a `room` one.
827 */
828 const swatch = (color: string, o: { line?: boolean; room?: boolean } = {}) => (
829 <Text {...ink(color)}>{terminal ? (o.line ? (o.room ? METER.rail : METER.line) : '■') : '●'}</Text>
830 )
831
832 /**
833 * A legend: each entry a key, a dim label and its figure. `note` is an optional
834 * coloured figure after it (a failure count). With `bar: true` (a legend that
835 * keys a `bar`, its items in the bar's order, `room` ones included) the
836 * terminal's keys are the bar's own glyphs, `━` and `─`.
837 *
838 * On the terminal it's a compact grid, filled row by row in the items' order:
839 * as many columns as fit with labels up to 10 cells (`maxCols`, 3 at most),
840 * 3 cells apart; then each column takes its share of the row, up to what its
841 * longest label, figure and note need, so labels widen into spare room and are
842 * cut with … only when it runs out, and a label stays close to its figure. The app surfaces
843 * share the row out in up to three columns.
844 */
845 const legend = (
846 key: string,
847 items: { label: string; color: string; figure: string; note?: string; noteColor?: string; room?: boolean }[],
848 maxCols?: number,
849 opts: { bar?: boolean } = {},
850 ) => {
851 if (terminal) {
852 const len = (s: string) => [...s].length
853 const longest = Math.max(1, ...items.map(t => len(t.label)))
854 const figW = Math.max(1, ...items.map(t => len(t.figure)))
855 const noteW = Math.max(0, ...items.map(t => (t.note ? len(t.note) + 1 : 0)))
856 const fixed = 2 + 1 + figW + noteW
857 const gap = 3
858 // How many columns fit is decided at the compact width (labels up to LABEL cells)...
859 const compact = fixed + Math.min(LABEL, longest)
860 const fit = Math.max(1, Math.floor((body + gap) / (compact + gap)))
861 const cols = Math.max(1, Math.min(maxCols ?? 3, fit, items.length))
862 const rows = Array.from({ length: Math.ceil(items.length / cols) }, (_, r) => items.slice(r * cols, r * cols + cols))
863 // ...then each column takes its share of the row, up to what its longest label needs,
864 // and the label has what's left after the key, figure and note.
865 const share = Math.floor((body - gap * (cols - 1)) / cols)
866 const width = Math.max(1, Math.min(fixed + longest, share))
867 const labelRoom = Math.max(1, width - fixed)
868 const fitLabel = (s: string) => (len(s) <= labelRoom ? s : [...s].slice(0, Math.max(0, labelRoom - 1)).join('') + MARK.more)
869 return (
870 <Box key={key} flexDirection="column">
871 {rows.map((row, r) => (
872 <Box key={`${key}-row-${r}`} flexDirection="row" gap={gap}>
873 {row.map(t => (
874 <Box key={`${key}-${t.label}`} flexDirection="row" width={width} flexShrink={0}>
875 <Box width={2} flexShrink={0}>
876 {swatch(t.color, { line: opts.bar, room: t.room })}
877 </Box>
878 <Box flexGrow={1} flexShrink={1} minWidth={0}>
879 <Text {...R.label} wrap="truncate">
880 {fitLabel(t.label)}
881 </Text>
882 </Box>
883 <Box width={figW + 1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
884 <Text {...R.value}>{t.figure}</Text>
885 </Box>
886 {noteW ? (
887 <Box width={noteW} flexShrink={0} flexDirection="row" justifyContent="flex-end">
888 {t.note ? <Text {...ink(t.noteColor)}>{t.note}</Text> : null}
889 </Box>
890 ) : null}
891 </Box>
892 ))}
893 </Box>
894 ))}
895 </Box>
896 )
897 }
898 // Few rows: two columns for up to four entries, three beyond. (The pane's reported
899 // width in columns runs smaller than what the desktop draws, so it can't decide this.)
900 const cols = Math.min(maxCols ?? 3, items.length <= 4 ? 2 : 3, Math.max(1, items.length))
901 const per = Math.ceil(items.length / cols)
902 const groups = Array.from({ length: cols }, (_, c) => items.slice(c * per, c * per + per))
903 return (
904 // The legend spans the body (the desktop doesn't stretch a nested box), and its
905 // columns share that width equally, as a bar's parts do: grow from zero width.
906 <Box key={key} flexDirection="row" width="100%" gap={space.group}>
907 {groups.map((col, c) => (
908 <Box key={`${key}-col-${c}`} flexGrow={1} width={0} flexDirection="column">
909 {col.map(t => (
910 // The full column's width (the desktop doesn't stretch it); the label gives way first.
911 <Box key={`${key}-${t.label}`} flexDirection="row" alignItems="center" gap={space.pair} width="100%">
912 {swatch(t.color)}
913 <Box flexGrow={1} flexShrink={1} minWidth={0}>
914 <Text {...tone('light')} wrap="truncate">
915 {t.label}
916 </Text>
917 </Box>
918 {t.note ? <Text {...ink(t.noteColor)}>{t.note}</Text> : null}
919 <Text>{t.figure}</Text>
920 </Box>
921 ))}
922 </Box>
923 ))}
924 </Box>
925 )
926 }
927
928 /** A caption: dim uppercase, for a chart's or a column's heading. The only uppercase besides a state word. */
929 const caption = (text: string) => <Text {...(terminal ? R.meta : tone('mid'))}>{text.toUpperCase()}</Text>
930
931 /**
932 * A header's figures, one form for every section: figures, each followed by
933 * a dim lowercase unit, SPACE.group apart. A figure is coloured only when the
934 * colour is a status. On the terminal the first figure is bold (the one that
935 * matters; put it first) and the rest plain.
936 *
937 * Its state, before the figures, so a cut never takes it:
938 * - `loading`: no figures yet (the first read): the pending mark `◌` holds
939 * their place, so a folded section still says it's coming;
940 * - `age`: how old the figures are when they're last-good figures kept through
941 * a refresh, as a dim `↻ 2m`;
942 * - `failed`: the last refresh failed and these are the last-good figures: an
943 * amber `▲` (with the age after it, dim, when given). Keep the figures; don't
944 * swap the body for an error.
945 */
946 const summary = (items: ([string, string?, string?] | null | false)[], state: { loading?: boolean; age?: string; failed?: boolean } = {}) => {
947 const shown = items.filter((x): x is [string, string?, string?] => Boolean(x))
948 const pending = state.loading && !shown.length
949 if (terminal) {
950 // One line of text, the figures CELLS.group apart: too wide for its room, it's
951 // cut wherever that falls and ends in an ellipsis, never mid-figure without one.
952 const mark = state.failed ? (
953 <Text key="state">
954 <Text {...ink(STATUS.warn.color)}>{STATUS.warn.glyph}</Text>
955 {state.age ? <Text {...R.secondary}>{` ${state.age}`}</Text> : null}
956 {shown.length ? ' '.repeat(CELLS.group) : null}
957 </Text>
958 ) : state.age ? (
959 <Text key="state" {...R.secondary}>
960 {`${MARK.stale} ${state.age}`}
961 {shown.length ? ' '.repeat(CELLS.group) : null}
962 </Text>
963 ) : null
964 return (
965 <Box flexDirection="row" minWidth={0} flexShrink={1}>
966 <Text wrap="truncate">
967 {pending ? <Text key="pending" {...ink(STATUS.pending.color)}>{STATUS.pending.glyph}</Text> : null}
968 {mark}
969 {shown.map(([figure, unit, color], i) => (
970 <Text key={`${figure}-${unit ?? ''}`}>
971 {i ? ' '.repeat(CELLS.group) : null}
972 {/* The first figure is the one that matters: bold; the rest plain. */}
973 <Text {...(i ? R.value : R.strong)} {...ink(color)}>{figure}</Text>
974 {unit ? <Text {...R.meta}>{` ${unit}`}</Text> : null}
975 </Text>
976 ))}
977 </Text>
978 </Box>
979 )
980 }
981 // Too wide for the band, the last item ends in an ellipsis and what's past it is cut.
982 return (
983 <Box flexDirection="row" gap={space.group} minWidth={0} flexShrink={1} overflow="hidden">
984 {pending ? <Text key="pending" color={STATUS.pending.color}>{STATUS.pending.glyph}</Text> : null}
985 {state.failed ? (
986 <Text key="state">
987 <Text color={STATUS.warn.color}>{STATUS.warn.glyph}</Text>
988 {state.age ? <Text {...tone('mid')}>{` ${state.age}`}</Text> : null}
989 </Text>
990 ) : state.age ? (
991 <Text key="state" {...tone('mid')}>{`${MARK.stale} ${state.age}`}</Text>
992 ) : null}
993 {shown.map(([figure, unit, color], i) => (
994 <Text key={`${figure}-${unit ?? ''}`} wrap={i === shown.length - 1 ? 'truncate' : undefined}>
995 <Text {...ink(color)}>{figure}</Text>
996 {unit ? <Text {...tone('mid')}>{` ${unit}`}</Text> : null}
997 </Text>
998 ))}
999 </Box>
1000 )
1001 }
1002
1003 /**
1004 * Labels under a chart, each centred under its place: `at` from 0 to 1 across
1005 * the chart's width (the first and last flush with its ends). On the terminal
1006 * one dim line `width` cells wide (a section body's by default; pass the
1007 * chart's): a label that would touch the one before it is left out.
1008 */
1009 const axis = (key: string, labels: { at: number; text: string }[], width?: number) => {
1010 const row: RenderElement[] = []
1011 const sorted = [...labels].sort((a, b) => a.at - b.at)
1012 if (terminal) {
1013 const w = Math.max(1, Math.floor(width ?? body))
1014 const line = Array.from({ length: w }, () => ' ')
1015 let free = 0
1016 for (const l of sorted) {
1017 const text = [...l.text]
1018 const start = Math.max(0, Math.min(w - text.length, Math.round(clamp01(l.at) * w - text.length / 2)))
1019 if (start < free || start + text.length > w) continue
1020 text.forEach((ch, i) => (line[start + i] = ch))
1021 free = start + text.length + 1
1022 }
1023 return (
1024 <Text key={key} dimColor>
1025 {line.join('').trimEnd()}
1026 </Text>
1027 )
1028 }
1029 sorted.forEach((l, i) => {
1030 // Each label owns the span halfway to its neighbours; the ends own up to the edge.
1031 const from = i === 0 ? 0 : (sorted[i - 1]!.at + l.at) / 2
1032 const to = i === sorted.length - 1 ? 1 : (l.at + sorted[i + 1]!.at) / 2
1033 const align = i === 0 && l.at === 0 ? 'flex-start' : i === sorted.length - 1 && l.at === 1 ? 'flex-end' : 'center'
1034 row.push(
1035 <Box key={`${key}-${i}`} flexGrow={Math.max(1, Math.round((to - from) * 1000))} width={0} flexDirection="row" justifyContent={align}>
1036 <Text {...tone('dark')}>{l.text}</Text>
1037 </Box>,
1038 )
1039 })
1040 return (
1041 <Box key={key} flexDirection="row">
1042 {row}
1043 </Box>
1044 )
1045 }
1046
1047 /**
1048 * An instrument's scale under a bar: ticks at 0, 25, 50, 75 and 100%, and a
1049 * marker where `mark` falls (a fraction of the scale), in `markColor`. On the
1050 * terminal a row `width` cells wide (a section body's by default, as `bar`):
1051 * dim ticks (╵) and the marker as ▴.
1052 */
1053 const scale = (mark?: number, markColor: string = C.warn, width?: number) => {
1054 if (terminal) {
1055 const w = Math.max(2, Math.floor(width ?? body))
1056 const row = Array.from({ length: w }, () => ' ')
1057 for (const at of [0, 0.25, 0.5, 0.75, 1]) row[Math.round(at * (w - 1))] = '╵'
1058 const m = mark === undefined ? -1 : Math.round(clamp01(mark) * (w - 1))
1059 if (m < 0) return <Text dimColor>{row.join('')}</Text>
1060 return (
1061 <Text>
1062 <Text dimColor>{row.slice(0, m).join('')}</Text>
1063 <Text {...ink(markColor)}>▴</Text>
1064 <Text dimColor>{row.slice(m + 1).join('')}</Text>
1065 </Text>
1066 )
1067 }
1068 // Every tick at its place on the scale, the marker among them, gaps between sized by flex-grow.
1069 const marks: Array<{ at: number; color: string; height: number }> = [0, 0.25, 0.5, 0.75, 1].map(at => ({ at, color: TRACK, height: 0.3 }))
1070 if (mark !== undefined) marks.push({ at: Math.max(0, Math.min(1, mark)), color: markColor, height: 0.45 })
1071 marks.sort((a, b) => a.at - b.at)
1072 const row: RenderElement[] = []
1073 marks.forEach((m, i) => {
1074 if (i > 0) row.push(<Box key={`g${i}`} flexGrow={Math.max(0, (m.at - marks[i - 1]!.at) * 1000)} width={0} />)
1075 row.push(<Box key={`m${i}`} width={0.12} height={m.height} backgroundColor={m.color} flexShrink={0} />)
1076 })
1077 return (
1078 <Box flexDirection="row" alignItems="flex-start" height={0.45}>
1079 {row}
1080 </Box>
1081 )
1082 }
1083
1084 /**
1085 * A section as a properties inspector draws one (Premiere's Effect Controls):
1086 * a collapsible header row (icon, title, figures, then the fold arrow last at
1087 * the right end) and its properties indented under it, the terminal's under the
1088 * title. The arrow (a Button, key `toggle-<id>`) folds it. No panel, outline or tint: rows and indentation do
1089 * the grouping. Collapsible when given `onToggle` (the mod keeps which are
1090 * folded in its state); folded, only the header row shows.
1091 */
1092 const section = (s: {
1093 id: string
1094 title: string
1095 /** An icon before the title (a GLYPH, drawn as the kit's SVG icon), always muted. */
1096 icon?: string
1097 right?: RenderElement | null
1098 collapsed?: boolean
1099 onToggle?: () => void
1100 /**
1101 * On the terminal, the right side (figures, buttons) keeps its natural width
1102 * and only the title is cut, with an ellipsis: for a header whose right side
1103 * is controls that must all show. Size the right side to fit the row yourself.
1104 * Without it, the title keeps up to 12 cells and then it and the figures give
1105 * way together. The desktop draws the same either way.
1106 */
1107 keepRight?: boolean
1108 /** No figures yet: with no `right`, the figure slot shows the pending mark (`summary([], { loading: true })`). */
1109 loading?: boolean
1110 /**
1111 * The figures restate what the body shows (the fullest of the readings under
1112 * it): the terminal draws them only while the body is hidden, folded or under
1113 * the narrow floor, so an open section doesn't say it twice.
1114 */
1115 restated?: boolean
1116 children: RenderElement | (RenderElement | null)[] | null
1117 }) => {
1118 if (s.loading && !s.right) s = { ...s, right: summary([], { loading: true }) }
1119 if (terminal) {
1120 // ◔ Title figures ─────────────── ▾
1121 // body, two cells in, under the title
1122 // A dim icon, the title bold, the figures on a shared column right after it
1123 // (TITLE_COL), a dim rule, then the dim fold arrow last, flush right. The icon
1124 // takes the GUTTER the body is indented by, so a title lines up with the
1125 // readings under it. No band or box. Every section leaves one blank row under
1126 // it, open or folded.
1127 const arrow = s.collapsed ? FOLD.closed : FOLD.open
1128 const toggle =
1129 s.onToggle && Button ? (
1130 <Button key={`toggle-${s.id}`} plain dimColor label={arrow} onPress={() => s.onToggle?.()} />
1131 ) : (
1132 <Text dimColor>{arrow}</Text>
1133 )
1134 // The arrow keeps its cell whatever else gives way; `gap` is its room when the row has no gap of its own.
1135 const arrowBox = (gap: number) => (
1136 <Box key="fold" flexShrink={0} marginLeft={gap}>
1137 {toggle}
1138 </Box>
1139 )
1140 const lead = s.icon ? GUTTER : 0
1141 const titleCells = [...s.title].length
1142 const keeps = Math.min(titleCells, 12)
1143 // Under MIN_COLUMNS, the header and its figures only: the body is left out.
1144 const open = !s.collapsed && !floor
1145 const right = s.restated && open ? null : s.right
1146 const head = [
1147 s.icon ? (
1148 <Box key="icon" width={GUTTER} flexShrink={0}>
1149 {icon(s.icon, termHeaderIcon())}
1150 </Box>
1151 ) : null,
1152 <Box key="name" flexShrink={1} minWidth={0}>
1153 <Text wrap="truncate" {...R.title}>
1154 {s.title}
1155 </Text>
1156 </Box>,
1157 ]
1158 // The rule takes only the room left: from no width, cut at its box's edge.
1159 const rule = (
1160 <Box key="rule" flexGrow={1} flexShrink={1} width={0} minWidth={0} height={1} overflow="hidden">
1161 <Text dimColor>{METER.rail.repeat(Math.max(1, Math.floor(columns)))}</Text>
1162 </Box>
1163 )
1164 const row = s.keepRight ? (
1165 // Controls on the right edge: they keep their width and only the title is cut.
1166 <Box flexDirection="row" gap={CELLS.item}>
1167 <Box key="title" flexDirection="row" flexShrink={1} minWidth={lead + 1}>
1168 {head}
1169 </Box>
1170 {rule}
1171 {right ? (
1172 <Box key="right" flexDirection="row" minWidth={0} flexShrink={0} overflow="hidden">
1173 {right}
1174 </Box>
1175 ) : null}
1176 {arrowBox(0)}
1177 </Box>
1178 ) : (
1179 // The title's column gives way first (down to 12 cells of title), then the figures; the rule
1180 // only ever takes what's left.
1181 <Box flexDirection="row">
1182 <Box
1183 key="title"
1184 flexDirection="row"
1185 // Without figures the rule follows the title; with them, they start on the shared column.
1186 width={lead + (right ? Math.max(TITLE_COL, titleCells) + CELLS.group : titleCells + CELLS.item)}
1187 flexShrink={1000}
1188 minWidth={lead + keeps + (right ? CELLS.group : CELLS.item)}
1189 >
1190 {head}
1191 </Box>
1192 {right ? (
1193 <Box key="right" flexDirection="row" minWidth={0} flexShrink={1} overflow="hidden" marginRight={CELLS.item}>
1194 {right}
1195 </Box>
1196 ) : null}
1197 {rule}
1198 {arrowBox(CELLS.item)}
1199 </Box>
1200 )hooks/view.tsx 506 lines1import type { Elements, RenderElement, RenderSurface } from 'claude-code'
2
3import { basename, bytes, middle } from '../kit/format'
4import { C, GRAY, MUTED, STATUS } from '../kit/tokens'
5import type { StatusKind } from '../kit/tokens'
6import { CELLS, EDGE, MEASURE, SPACE, kit } from '../kit/ui'
7import type { FlowSwitch } from '../kit/ui'
8
9import type { KillConfirm, LaunchConfig, LaunchFile, Server, Status } from '../types'
10
11/** What the pane draws with; the terminal's table has the same elements, Svg aside. */
12export type PaneElements = Pick<Elements['desktop'], 'Box' | 'Text' | 'Button' | 'Link'> & Partial<Pick<Elements['desktop'], 'Svg'>>
13
14/** What the pane's controls do; the hooks module runs them with its `$`. */
15export type Actions = {
16 start: (name: string) => void
17 stop: (name: string) => void
18 restart: (name: string) => void
19 /** Takes over the server another process runs on its port, so Stop works on it. */
20 adopt: (name: string) => void
21 /** Kill's press: the first arms the confirm, a second within its time ends the process on the port. */
22 kill: (name: string) => void
23 /** Unfolds a configuration's details and output, or folds them. */
24 toggle: (name: string) => void
25}
26
27/**
28 * Each state's word and colour: grey stopped, blue starting, green answering, red
29 * failed; and the kit status that draws it on the terminal (its glyph in the same colour).
30 */
31export const LOOK: Record<Status, { word: string; color: string; kind: StatusKind }> = {
32 stopped: { word: 'stopped', color: MUTED, kind: 'stopped' },
33 starting: { word: 'starting', color: C.blue, kind: 'running' },
34 running: { word: 'running', color: C.ok, kind: 'done' },
35 elsewhere: { word: 'running elsewhere', color: C.ok, kind: 'done' },
36 // Amber: it can't start on its own port until that process ends (or autoPort moves it).
37 taken: { word: 'port taken', color: C.warn, kind: 'warn' },
38 failed: { word: 'failed', color: C.bad, kind: 'failed' },
39}
40
41/** Where a server answers. */
42export const urlOf = (port: number) => `http://localhost:${port}`
43
44/** The command line a configuration runs, as one would type it. */
45export const commandOf = (c: LaunchConfig) => [c.runtimeExecutable, ...c.runtimeArgs].map(a => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')
46
47/** A configuration not yet probed reads as stopped on its own port. */
48const serverOf = (c: LaunchConfig, servers: Record<string, Server>): Server =>
49 servers[c.name] ?? { status: 'stopped', port: c.port, log: [], code: null, startedAt: 0 }
50
51/** How many output lines an unfolded row shows. */
52const TAIL = { terminal: 8, app: 14 } as const
53
54/**
55 * The cells of title a terminal row keeps before its right side gives up parts,
56 * and the header's gaps either side of its rule (which may shrink to nothing).
57 */
58const TITLE_KEEPS = 12
59const RULE_GAPS = 2
60
61/**
62 * A desktop row's measure, in columns, to tell whether its address fits: the band's
63 * inset and its fold arrow (at the row's right end), the gap between parts, and the room a
64 * native Button takes beyond its label.
65 */
66const DESK_LEAD = 4
67const DESK_GAP = 1
68const DESK_BUTTON = 1
69/** The columns a desktop prop's label and the body's indents take, left of a value. */
70const DESK_PROP_LABEL = 14
71
72/** The cells of a running server's CPU sparkline on the terminal: the last 12 samples. */
73const SPARK_CELLS = 6
74/** The CPU percent a sparkline's top stands for at least, so an idle server's noise stays low. */
75const SPARK_FLOOR = 10
76
77/**
78 * A state's word on the terminal, after its glyph: shorter than `LOOK`'s where the
79 * glyph's colour already says it (a server running elsewhere is a green ✓ and "elsewhere").
80 */
81const TERM_WORD: Record<Status, string> = {
82 stopped: 'stopped',
83 starting: 'starting',
84 running: 'running',
85 elsewhere: 'elsewhere',
86 taken: 'taken',
87 failed: 'failed',
88}
89
90/**
91 * What every terminal row's right side gives up as the pane narrows, one step at a
92 * time and the same step for every row, so their columns stay aligned: the address
93 * shortens to its port, then the CPU sparkline goes, then Take over (Kill stays),
94 * then the state's word (the glyph stays), then the address, then Restart (it
95 * stays in `/launcher restart`).
96 */
97type Fit = { address: 'full' | 'port' | null; spark: boolean; word: boolean; restart: boolean; adopt: boolean }
98const FITS: Fit[] = [
99 { address: 'full', spark: true, word: true, restart: true, adopt: true },
100 { address: 'port', spark: true, word: true, restart: true, adopt: true },
101 { address: 'port', spark: false, word: true, restart: true, adopt: true },
102 { address: 'port', spark: false, word: true, restart: true, adopt: false },
103 { address: 'port', spark: false, word: false, restart: true, adopt: false },
104 { address: null, spark: false, word: false, restart: true, adopt: false },
105 { address: null, spark: false, word: false, restart: false, adopt: false },
106]
107
108/**
109 * A server's terminal log as it reads unfolded: without the lines the props above
110 * it already say (the `$ command` this session wrote first, and `exited with code N`
111 * when the exit prop shows it), blank lines trimmed from its ends and runs of them
112 * kept to one, then its last `n` lines.
113 */
114const quietLog = (c: LaunchConfig, s: Server, n: number) => {
115 let lines = s.log.slice()
116 if (lines[0] === `$ ${commandOf(c)}`) lines = lines.slice(1)
117 const exitShown = s.status === 'failed' && s.code !== null
118 const blank = (l: string) => !l.trim()
119 while (lines.length && blank(lines[lines.length - 1]!)) lines.pop()
120 if (exitShown && lines[lines.length - 1] === `exited with code ${s.code}`) lines.pop()
121 lines = lines.filter((l, i) => !blank(l) || (i > 0 && !blank(lines[i - 1]!)))
122 while (lines.length && blank(lines[0]!)) lines.shift()
123 while (lines.length && blank(lines[lines.length - 1]!)) lines.pop()
124 return lines.slice(-n)
125}
126
127/** Notes that mean something went wrong reading the file, rather than there being none. */
128const isError = (note: string) => /^(Unreadable|Could not)/.test(note)
129
130const cells = (s: string) => [...s].length
131
132/** A CPU sample as a figure: a tenth under 10% (an idle server's 0.3%), whole above. */
133const percent = (cpu: number) => `${cpu < 10 ? cpu.toFixed(1).replace(/\.0$/, '') : Math.round(cpu)}%`
134
135/**
136 * What the pane can draw of each server: everything but its CPU and memory as
137 * kept, those as the figures and sparkline show them (the samples the line
138 * spans, its top, the last figure, the memory's). Two equal ones draw alike,
139 * so a refresh that changes neither need not redraw.
140 */
141export const drawnServers = (servers: Record<string, Server>) =>
142 Object.fromEntries(
143 Object.entries(servers).map(([name, { cpu, rss, ...s }]) => [
144 name,
145 {
146 ...s,
147 cpu: cpu ? [cpu.length >= 2, cpu.slice(-2 * SPARK_CELLS).map(percent), percent(Math.max(SPARK_FLOOR, ...cpu))] : null,
148 rss: rss ? bytes(rss) : null,
149 },
150 ]),
151 )
152
153/**
154 * The pane, on every surface: the project as a plain line (on the desktop with how
155 * many answer), then one foldable row per configuration. On the terminal each
156 * row's state, address, CPU and buttons sit in columns shared by every row:
157 *
158 * yahaha/.claude/launch.json
159 *
160 * app ──── ✓ running localhost:5173 ⣀⣤⣶⣤⣀⣀ Stop Restart ▸
161 *
162 * storybook ──── ✕ failed :6006 Start ▾
163 * command npm --prefix app run storybook
164 * exit 1
165 *
166 * > storybook dev -p 6006
167 * Error: …
168 *
169 * `focused` is whether the pane has the keyboard (`e.props.isFocused`). The
170 * launcher binds no keys of its own (its controls are buttons), so it draws no
171 * key hints.
172 */
173export const renderPane = (
174 els: PaneElements,
175 surface: RenderSurface,
176 columns: number,
177 file: LaunchFile | null,
178 servers: Record<string, Server>,
179 open: string[],
180 act: Actions,
181 opts: { focused?: boolean; confirm?: KillConfirm | null; flow?: FlowSwitch } = {},
182): RenderElement => {
183 const { Box, Text, Link } = els
184 const terminal = surface === 'terminal'
185 // On the terminal the rows keep to the measure, so a row's state and controls stay
186 // near its name however wide the pane. The column view's switch is the app surfaces' alone.
187 const k = kit(els, surface, columns, { focused: opts.focused, ...(terminal ? { measure: MEASURE } : { flow: opts.flow }) })
188 if (!file) return terminal ? k.note('reading', 'Reading .claude/launch.json…', 'loading') : <Text dimColor>Reading .claude/launch.json…</Text>
189 if (file.note) {
190 return (
191 <Box flexDirection="column" paddingLeft={terminal ? 0 : EDGE.left} paddingRight={terminal ? 0 : EDGE.right} marginTop={terminal ? 0 : 1}>
192 {terminal ? (
193 k.note('note', file.note, isError(file.note) ? 'error' : 'empty')
194 ) : (
195 <Text dimColor wrap="truncate">
196 {file.note}
197 </Text>
198 )}
199 </Box>
200 )
201 }
202
203 const all = file.configs.map(c => ({ c, s: serverOf(c, servers) }))
204 // A row's width on the desktop: the pane's, or a flow column's when the pane is wide enough for columns.
205 const lane = k.flowColumns(all.length).width
206 const answering = all.filter(x => x.s.status === 'running' || x.s.status === 'elsewhere').length
207 const failed = all.filter(x => x.s.status === 'failed').length
208
209 const isUp = (s: Server) => s.status === 'running' || s.status === 'elsewhere'
210 /**
211 * What can be done to a server: Start while it's stopped or failed; Stop and the
212 * quiet Restart while it runs from here (started or taken over); Take over and
213 * Kill while its own server runs elsewhere; Kill (and Start on the next port,
214 * with autoPort) while another process holds its port. Kill asks once more
215 * (`Kill PID 1234?`, red) before it ends anything.
216 */
217 const controls = (c: LaunchConfig, s: Server) => {
218 const mine = s.status === 'running' || s.status === 'starting'
219 const canStart = s.status === 'stopped' || s.status === 'failed' || (s.status === 'taken' && Boolean(c.autoPort))
220 const held = (s.status === 'elsewhere' || s.status === 'taken') && Boolean(s.pid)
221 const armed = Boolean(held && opts.confirm && opts.confirm.name === c.name && opts.confirm.pid === s.pid)
222 return {
223 start: canStart ? { key: `start-${c.name}`, label: 'Start', onPress: () => act.start(c.name) } : null,
224 stop: mine ? { key: `stop-${c.name}`, label: 'Stop', onPress: () => act.stop(c.name) } : null,
225 restart: mine ? { key: `restart-${c.name}`, label: 'Restart', quiet: true, onPress: () => act.restart(c.name) } : null,
226 adopt: held && s.status === 'elsewhere' ? { key: `adopt-${c.name}`, label: 'Take over', quiet: true, onPress: () => act.adopt(c.name) } : null,
227 kill: held
228 ? armed
229 ? { key: `kill-${c.name}`, label: `Kill PID ${s.pid}?`, tone: 'danger' as const, onPress: () => act.kill(c.name) }
230 : { key: `kill-${c.name}`, label: 'Kill', quiet: true, onPress: () => act.kill(c.name) }
231 : null,
232 }
233 }
234 /** A row's actions in order: the main ones, then the quiet ones, less Restart or Take over where the row has no room for them. */
235 const listOf = (a: ReturnType<typeof controls>, fit: { restart: boolean; adopt: boolean }) => [
236 a.start,
237 a.stop,
238 fit.restart ? a.restart : null,
239 fit.adopt ? a.adopt : null,
240 a.kill,
241 ]
242 /** Where a server answers on a terminal row, whole; one that doesn't answer shows its port. */
243 const addressOf = (s: Server) => (isUp(s) ? `localhost:${s.port}` : `:${s.port}`)
244 /** A server's CPU series once there's a line to draw: answering, two samples or more. */
245 const sparkOf = (s: Server) => (isUp(s) && s.cpu && s.cpu.length >= 2 ? s.cpu : null)
246
247 /**
248 * The terminal rows' shared columns: each slot's cells (the widest row's) and the
249 * one `Fit` that leaves every title its first `TITLE_KEEPS` cells and a rule.
250 */
251 const grid = (() => {
252 if (!terminal) return null
253 const max = (ns: number[]) => Math.max(0, ...ns)
254 const word = max(all.map(({ s }) => cells(TERM_WORD[s.status])))
255 const full = max(all.map(({ s }) => cells(addressOf(s))))
256 const port = max(all.map(({ s }) => cells(`:${s.port}`)))
257 const spark = all.some(({ s }) => sparkOf(s) !== null)
258 const labels = (c: LaunchConfig, s: Server, fit: Fit) =>
259 listOf(controls(c, s), fit)
260 .filter(x => x !== null)
261 .map(x => x.label)
262 const actions = (fit: Fit) =>
263 max(all.map(({ c, s }) => {
264 const l = labels(c, s, fit)
265 return l.reduce((n, x) => n + cells(x), 0) + CELLS.group * Math.max(0, l.length - 1)
266 }))
267 const state = (fit: Fit) => 1 + (fit.word ? CELLS.item + word : 0)
268 const total = (fit: Fit) => {
269 const slots = [
270 state(fit),
271 fit.address ? (fit.address === 'full' ? full : port) : 0,
272 fit.spark && spark ? SPARK_CELLS : 0,
273 actions(fit),
274 ].filter(n => n > 0)
275 return slots.reduce((a, b) => a + b, 0) + CELLS.group * (slots.length - 1)
276 }
277 const title = Math.min(max(all.map(({ c }) => cells(c.name))), TITLE_KEEPS)
278 const room = k.width - title - RULE_GAPS
279 const fit = FITS.find(f => total(f) <= room) ?? FITS[FITS.length - 1]!
280 return {
281 fit,
282 state: state(fit),
283 address: fit.address === 'full' ? full : port,
284 spark: fit.spark && spark,
285 actions,
286 }
287 })()
288
289 /**
290 * One row's right side: its state, where it answers or its port, then what can be
291 * done to it. On the terminal, slots aligned across the rows (`grid`); the section
292 * (`keepRight`) keeps them whole and cuts the title to what's left.
293 */
294 const row = (c: LaunchConfig, s: Server) => {
295 const look = LOOK[s.status]
296 const up = isUp(s)
297 const acts = controls(c, s)
298
299 const where = (label: string) =>
300 up ? (
301 <Link key={`url-${c.name}`} href={urlOf(s.port)} label={label}>
302 <Text {...k.ink(C.blue)} wrap="truncate">
303 {label}
304 </Text>
305 </Link>
306 ) : (
307 <Text key="port" {...(terminal ? { dimColor: true } : { color: GRAY.mid })} wrap="truncate">{`:${s.port}`}</Text>
308 )
309
310 if (terminal && grid) {
311 // [glyph word] [address] [sparkline] [actions], each slot as wide as the
312 // widest row's, so the rows' states, addresses and buttons line up.
313 const status = STATUS[look.kind]
314 const fit = grid.fit
315 const address = fit.address === 'full' ? addressOf(s) : `:${s.port}`
316 const cpu = sparkOf(s)
317 const shown = listOf(acts, fit)
318 const right = (
319 <Box key={`right-${c.name}`} flexDirection="row" gap={CELLS.group}>
320 <Box key={`state-${c.name}`} flexDirection="row" gap={CELLS.item} width={grid.state} flexShrink={0}>
321 <Text {...k.ink(status.color)}>{status.glyph}</Text>
322 {fit.word ? (
323 <Box key={`word-${c.name}`} flexDirection="row" minWidth={0}>
324 <Text {...k.ink(status.color)} wrap="truncate">
325 {TERM_WORD[s.status]}
326 </Text>
327 </Box>
328 ) : null}
329 </Box>
330 {fit.address ? (
331 <Box key={`where-${c.name}`} flexDirection="row" width={grid.address} flexShrink={0}>
332 {up ? (
333 <Link key={`url-${c.name}`} href={urlOf(s.port)} label={address}>
334 <Text wrap="truncate">
335 {address}
336 </Text>
337 </Link>
338 ) : (
339 <Text dimColor wrap="truncate">
340 {address}
341 </Text>
342 )}
343 </Box>
344 ) : null}
345 {grid.spark ? (
346 <Box key={`cpu-${c.name}`} flexDirection="row" width={SPARK_CELLS} flexShrink={0}>
347 {cpu ? (
348 <Box key={`spark-${c.name}`} flexDirection="row">
349 {k.spark(cpu, { width: SPARK_CELLS, max: Math.max(SPARK_FLOOR, ...cpu) })}
350 </Box>
351 ) : null}
352 </Box>
353 ) : null}
354 {grid.actions(fit) ? (
355 <Box key={`do-${c.name}`} flexDirection="row" width={grid.actions(fit)} flexShrink={0}>
356 {shown.some(Boolean) ? k.actions(`actions-${c.name}`, shown) : null}
357 </Box>
358 ) : null}
359 </Box>
360 )
361 return { title: c.name, right }
362 }
363
364 // The state's word keeps its width; the address gives way first, and drops
365 // out when the row can't hold it beside the title, the word and the actions.
366 const shown = listOf(acts, { restart: true, adopt: true }).filter(x => x !== null)
367 const address = up ? `localhost:${s.port}` : `:${s.port}`
368 const need =
369 DESK_LEAD + cells(c.name) + DESK_GAP + cells(look.word) + DESK_GAP + cells(address) + shown.reduce((n, a) => n + DESK_GAP + cells(a.label) + DESK_BUTTON, 0)
370 const right = (
371 <Box key="right" flexDirection="row" alignItems="center" gap={SPACE.item} minWidth={0} flexShrink={1}>
372 <Box key={`state-${c.name}`} flexShrink={0}>
373 <Text key="state" color={look.color}>
374 {look.word}
375 </Text>
376 </Box>
377 {need <= lane ? (
378 <Box key={`where-${c.name}`} flexDirection="row" minWidth={0} flexShrink={1}>
379 {where(address)}
380 </Box>
381 ) : null}
382 {shown.length ? k.actions(`actions-${c.name}`, shown) : null}
383 </Box>
384 )
385 return { title: c.name, right }
386 }
387
388 /** Unfolded: what it runs, where, how it ended, then the tail of its output. */
389 const details = (c: LaunchConfig, s: Server) => {
390 const tail = terminal ? quietLog(c, s, TAIL.terminal) : s.log.slice(-TAIL.app)
391 const held = s.status === 'elsewhere' || s.status === 'taken'
392 const note = s.adopted
393 ? `Taken over: its output can't be shown here, as it writes to wherever it was started, not to the launcher.`
394 : s.status === 'elsewhere'
395 ? `Port ${s.port} answers, but this session didn't start it.`
396 : s.status === 'taken'
397 ? `Port ${s.port} is held by another process, not this configuration's server.`
398 : null
399 const line = (key: string, text: string) =>
400 terminal ? (
401 k.note(key, text)
402 ) : (
403 <Text key={key} dimColor wrap="truncate">
404 {text}
405 </Text>
406 )
407 // The process holding the port, cut in the middle so its start and end both show.
408 const proc = (held || s.adopted) && s.command ? s.command : null
409 return [
410 k.prop(`command-${c.name}`, 'command', commandOf(c)),
411 c.cwd ? k.prop(`cwd-${c.name}`, 'cwd', c.cwd) : null,
412 // On the terminal, what the header's sparkline last measured, as figures.
413 terminal && s.cpu?.length ? k.prop(`cpu-${c.name}`, 'cpu', percent(s.cpu[s.cpu.length - 1]!)) : null,
414 terminal && s.cpu?.length && s.rss ? k.prop(`memory-${c.name}`, 'memory', `${bytes(s.rss)}B`) : null,
415 (held || s.adopted) && s.pid ? k.prop(`pid-${c.name}`, 'pid', String(s.pid)) : null,
416 proc
417 ? terminal
418 ? k.prop(`process-${c.name}`, 'process', proc, { path: true })
419 : k.prop(`process-${c.name}`, 'process', middle(proc, Math.max(16, lane - DESK_PROP_LABEL)))
420 : null,
421 note ? line(`note-${c.name}`, note) : null,
422 s.status === 'failed' && s.code !== null ? k.prop(`exit-${c.name}`, 'exit', <Text {...k.ink(C.bad)}>{String(s.code)}</Text>) : null,
423 // A server running elsewhere writes its output there: no empty log to show.
424 !tail.length && (held || s.adopted) ? null : tail.length ? (
425 // The output is its own block: one blank row under the props.
426 <Box key={`log-${c.name}`} flexDirection="column" marginTop={terminal ? k.space.block : SPACE.block} minWidth={0}>
427 {tail.map((line, i) =>
428 terminal ? (
429 k.note(`log-${c.name}-${i}`, line || ' ')
430 ) : (
431 <Text key={`log-${c.name}-${i}`} dimColor wrap="truncate">
432 {line || ' '}
433 </Text>
434 ),
435 )}
436 </Box>
437 ) : terminal ? (
438 <Box key={`log-${c.name}`} flexDirection="column" marginTop={k.space.block}>
439 {k.note(`log-${c.name}-none`, 'No output yet.')}
440 </Box>
441 ) : (
442 <Text key={`log-${c.name}`} dimColor>
443 No output yet.
444 </Text>
445 ),
446 ]
447 }
448
449 const rows: RenderElement[] = all.map(({ c, s }) =>
450 k.section({
451 id: c.name,
452 ...row(c, s),
453 keepRight: true,
454 collapsed: !open.includes(c.name),
455 onToggle: () => act.toggle(c.name),
456 children: details(c, s),
457 }),
458 )
459
460 const path = `${basename(file.root) || file.root}/.claude/launch.json`
461 const counts = k.summary([
462 [String(answering), 'running', answering ? C.ok : undefined],
463 failed ? [String(failed), 'failed', C.bad] : null,
464 ])
465 const empty = 'No configurations in .claude/launch.json.'
466
467 if (terminal) {
468 // The file's path as a dim head line (no counts: every row's header says its
469 // state), one blank row, then the sections from the pane's left edge. The path
470 // ends where the rows' rules do.
471 return (
472 <Box flexDirection="column">
473 <Box key="top" flexDirection="column" marginBottom={1}>
474 {k.item('head', { text: path, dim: true, path: true, width: k.edge.left + k.width })}
475 </Box>
476 {rows.length ? rows : k.note('empty', empty)}
477 </Box>
478 )
479 }
480
481 const head = (
482 <Box key="head" flexDirection="row" justifyContent="space-between" gap={SPACE.item} width="100%">
483 <Box flexShrink={1} minWidth={0}>
484 <Text color={GRAY.mid} wrap="truncate">
485 {path}
486 </Text>
487 </Box>
488 {counts}
489 </Box>
490 )
491
492 return (
493 // The sections run the pane's full width, as the overview's do; the plain line sits on the body's edges.
494 <Box flexDirection="column" marginTop={1}>
495 <Box key="top" flexDirection="column" paddingLeft={EDGE.left} paddingRight={EDGE.right}>
496 {head}
497 {rows.length ? null : <Text dimColor>{empty}</Text>}
498 </Box>
499 {/* In columns when the pane is wide; one stack at a sidebar's width. */}
500 {k.flow('sections', rows)}
501 {/* The column view's switch, on a pane wide enough for columns. */}
502 {k.footer()}
503 </Box>
504 )
505}
506kit/format.ts 95 lines1const trim = (n: number) => (n >= 100 ? String(Math.round(n)) : n.toFixed(1).replace(/\.0$/, ''))
2
3/** Token counts: 950, 1.2k, 84k, 1M. */
4export const tokens = (n: number): string => {
5 if (n >= 999_500) return `${trim(n / 1_000_000)}M`
6 if (n >= 1_000) return `${trim(n / 1_000)}k`
7 return String(Math.round(n))
8}
9
10/** A duration to the second: 12s, 3m 05s, 2h 05m. */
11export const duration = (ms: number): string => {
12 const s = Math.max(0, Math.floor(ms / 1_000))
13 if (s < 60) return `${s}s`
14 const m = Math.floor(s / 60)
15 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
16 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
17}
18
19/** A duration at a glance: 47m, 2h05, 3d4h. */
20export const short = (ms: number): string => {
21 const m = Math.max(0, Math.floor(ms / 60_000))
22 if (m < 60) return `${m}m`
23 const h = Math.floor(m / 60)
24 if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}`
25 return `${Math.floor(h / 24)}d${h % 24}h`
26}
27
28/** How long ago, coarsely, as GitHub lists things: 5m, 3h, 2d, 3w, 4mo, 2y. */
29export const ago = (ms: number): string => {
30 const m = Math.max(0, Math.floor(ms / 60_000))
31 if (m < 60) return `${m}m`
32 const h = Math.floor(m / 60)
33 if (h < 24) return `${h}h`
34 const d = Math.floor(h / 24)
35 if (d < 14) return `${d}d`
36 if (d < 60) return `${Math.floor(d / 7)}w`
37 if (d < 365) return `${Math.floor(d / 30)}mo`
38 return `${Math.floor(d / 365)}y`
39}
40
41/** US dollars: $1.50, $123. */
42/** Bytes, short: 812, 41K, 3.2M, 16G. */
43export const bytes = (n: number): string => {
44 const units = ['', 'k', 'M', 'G', 'T']
45 let i = 0
46 while (n >= 1024 && i < units.length - 1) {
47 n /= 1024
48 i++
49 }
50 return `${n >= 10 || i === 0 ? Math.round(n) : n.toFixed(1)}${units[i]}`
51}
52
53export const money =(usd: number) => (usd >= 100 ? `$${Math.round(usd)}` : `$${usd.toFixed(2)}`)
54
55/** `value` as a whole percent of `max`, clamped: the desktop takes whole percentages only. */
56export const pct = (value: number, max: number) => `${Math.round(Math.max(0, Math.min(100, (value / Math.max(max, 1)) * 100)))}%`
57
58/**
59 * `text` cut in the middle to at most `cells` characters, the end kept: a path
60 * keeps its first segment and as many whole segments from its end as fit
61 * (`src/…/hooks/view.tsx`), then `…/view.tsx`, then the end of its name; other
62 * text keeps its start and end around `…`. Text that fits comes back as is.
63 */
64export const middle = (text: string, cells: number): string => {
65 const chars = [...text]
66 const n = Math.max(0, Math.floor(cells))
67 if (chars.length <= n) return text
68 if (n <= 1) return n === 1 ? '…' : ''
69 const parts = text.split('/')
70 if (parts.length >= 2) {
71 const len = (s: string) => [...s].length
72 const head = parts[0]!
73 const tail: string[] = [parts[parts.length - 1]!]
74 // As many whole segments from the end as fit after `head/…/`.
75 for (let i = parts.length - 2; i > 0; i--) {
76 const next = [parts[i]!, ...tail].join('/')
77 if (len(`${head}/…/${next}`) > n) break
78 tail.unshift(parts[i]!)
79 }
80 const kept = tail.join('/')
81 if (len(`${head}/…/${kept}`) <= n) return `${head}/…/${kept}`
82 if (len(`…/${kept}`) <= n) return `…/${kept}`
83 // Not even the name fits: the end of the name.
84 const name = [...kept]
85 return `…${name.slice(name.length - (n - 1)).join('')}`
86 }
87 // Plain text: a little more of the start than the end.
88 const front = Math.ceil((n - 1) / 2)
89 const back = n - 1 - front
90 return `${chars.slice(0, front).join('')}…${back ? chars.slice(chars.length - back).join('') : ''}`
91}
92
93/** The last path segment. */
94export const basename = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
95kit/icons.ts 202 lines1import { GLYPH } from './tokens'
2import type { Duo } from './tokens'
3
4/*
5 * The kit's icons: stroked drawings on a 16×16 grid, drawn as SVG at one exact
6 * pixel size so every icon is the same height and width and stays crisp (an SVG
7 * the page has to scale blurs). Keyed by the glyph each one stands in for, so a
8 * component given a GLYPH draws the icon where the surface has SVG, the glyph
9 * where it doesn't (the terminal).
10 */
11
12/** The icons' drawn size in CSS pixels. */
13export const ICON_PX = 13
14
15const PATHS: Record<string, string> = {
16 // A pie a quarter cut: how full.
17 [GLYPH.context]: '<circle cx="8" cy="8" r="6"/><path d="M8 2v6h6"/>',
18 [GLYPH.time]: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/>',
19 [GLYPH.turns]: '<path d="M13 8a5 5 0 1 1-1.6-3.7"/><path d="M13 2.5v3.2H9.8"/>',
20 [GLYPH.tools]: '<path d="M10.5 2.5a3 3 0 0 0-3.3 4.1L2.6 11.2l2.2 2.2 4.6-4.6a3 3 0 0 0 4.1-3.3l-1.9 1.9-1.9-.4-.4-1.9z"/>',
21 [GLYPH.files]: '<path d="M3 13l.9-3.4L10.8 2.7l2.5 2.5-6.9 6.9z"/>',
22 [GLYPH.agents]: '<path d="M8 2l1.4 4.6L14 8l-4.6 1.4L8 14l-1.4-4.6L2 8l4.6-1.4z"/>',
23 // A gauge: a dial three-quarters round, open at the bottom, centred like the
24 // other icons (a half dial sat low in its square and read as cut off), and a needle.
25 [GLYPH.limits]: '<path d="M3.76 12.24a6 6 0 1 1 8.48 0"/><path d="M8 8l2.6-2.6"/>',
26 [GLYPH.cost]: '<circle cx="8" cy="8" r="6"/><path d="M10 6.2c-.4-.8-1.2-1.2-2-1.2-1.1 0-2 .6-2 1.5S6.9 7.8 8 8s2 .6 2 1.5-.9 1.5-2 1.5c-.8 0-1.6-.4-2-1.2M8 4v1M8 11v1"/>',
27 // Events: a bolt.
28 events: '<path d="M9 1.5L3.5 9H8l-1 5.5L12.5 7H8z"/>',
29}
30
31/**
32 * GitHub's own icons for branches and pull requests: Octicons (`@primer/octicons`
33 * 19.15.0, `*-16.svg`, MIT, © GitHub Inc.), copied unchanged. Filled shapes, not
34 * strokes, so they're drawn filled in the icon's colour.
35 */
36const FILLED: Record<string, string> = {
37 [GLYPH.branch]:
38 'M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z',
39 'pr-open':
40 'M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z',
41 'pr-merged':
42 'M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z',
43 'pr-closed':
44 'M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l.97.97.97-.97a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.97.97.97.97a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-.97-.97-.97.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l.97-.97-.97-.97a.75.75 0 0 1 0-1.06ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z',
45 'issue-opened': 'M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z',
46 comment:
47 'M1 2.75C1 1.784 1.784 1 2.75 1h10.5c.966 0 1.75.784 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.573 2.573A1.458 1.458 0 0 1 4 13.543V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z',
48 'pr-draft':
49 'M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.251 2.251 0 0 1 3.25 1Zm9.5 14a2.25 2.25 0 1 1 0-4.5 2.25 2.25 0 0 1 0 4.5ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM14 7.5a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Zm0-4.25a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Z',
50 // Files and folders, for trees of them.
51 file: 'M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z',
52 'file-code':
53 'M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0 1 14.25 15h-9a.75.75 0 0 1 0-1.5h9a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 10 4.25V1.5H5.75a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0Zm1.72 4.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.47-1.47-1.47-1.47a.75.75 0 0 1 0-1.06ZM3.28 7.78 1.81 9.25l1.47 1.47a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Zm8.22-6.218V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z',
54 folder: 'M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z',
55 'folder-open':
56 'M.513 1.513A1.75 1.75 0 0 1 1.75 1h3.5c.55 0 1.07.26 1.4.7l.9 1.2a.25.25 0 0 0 .2.1H13a1 1 0 0 1 1 1v.5H2.75a.75.75 0 0 0 0 1.5h11.978a1 1 0 0 1 .994 1.117L15 13.25A1.75 1.75 0 0 1 13.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75c0-.464.184-.91.513-1.237Z',
57 // Git and the actions on a tree or a command.
58 'git-commit': 'M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z',
59 'repo-push':
60 'M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0V1.5h-8a1 1 0 0 0-1 1v6.708A2.493 2.493 0 0 1 4.5 9h2.25a.75.75 0 0 1 0 1.5H4.5a1 1 0 0 0 0 2h4.75a.75.75 0 0 1 0 1.5H4.5A2.5 2.5 0 0 1 2 11.5Zm12.23 7.79h-.001l-1.224-1.224v6.184a.75.75 0 0 1-1.5 0V9.066L10.28 10.29a.75.75 0 0 1-1.06-1.061l2.505-2.504a.75.75 0 0 1 1.06 0L15.29 9.23a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018Z',
61 alert:
62 'M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z',
63 trash:
64 'M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z',
65 search:
66 'M10.68 11.74a6 6 0 0 1-7.922-8.982 6 6 0 0 1 8.982 7.922l3.04 3.04a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215ZM11.5 7a4.499 4.499 0 1 0-8.997 0A4.499 4.499 0 0 0 11.5 7Z',
67 sync: 'M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z',
68 'arrow-up':
69 'M3.47 7.78a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018L9 4.81v7.44a.75.75 0 0 1-1.5 0V4.81L4.53 7.78a.75.75 0 0 1-1.06 0Z',
70 home: 'M6.906.664a1.749 1.749 0 0 1 2.187 0l5.25 4.2c.415.332.657.835.657 1.367v7.019A1.75 1.75 0 0 1 13.25 15h-3.5a.75.75 0 0 1-.75-.75V9H7v5.25a.75.75 0 0 1-.75.75h-3.5A1.75 1.75 0 0 1 1 13.25V6.23c0-.531.242-1.034.657-1.366l5.25-4.2Zm1.25 1.171a.25.25 0 0 0-.312 0l-5.25 4.2a.25.25 0 0 0-.094.196v7.019c0 .138.112.25.25.25H5.5V8.25a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 .75.75v5.25h2.75a.25.25 0 0 0 .25-.25V6.23a.25.25 0 0 0-.094-.195Z',
71 eye: 'M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z',
72 // What an agent's tool call is doing: a command, an edit, the web, an MCP server, anything else.
73 terminal:
74 'M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L5.44 8 3.72 6.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z',
75 pencil:
76 'M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z',
77 globe:
78 'M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM5.78 8.75a9.64 9.64 0 0 0 1.363 4.177c.255.426.542.832.857 1.215.245-.296.551-.705.857-1.215A9.64 9.64 0 0 0 10.22 8.75Zm4.44-1.5a9.64 9.64 0 0 0-1.363-4.177c-.307-.51-.612-.919-.857-1.215a9.927 9.927 0 0 0-.857 1.215A9.64 9.64 0 0 0 5.78 7.25Zm-5.944 1.5H1.543a6.507 6.507 0 0 0 4.666 5.5c-.123-.181-.24-.365-.352-.552-.715-1.192-1.437-2.874-1.581-4.948Zm-2.733-1.5h2.733c.144-2.074.866-3.756 1.58-4.948.12-.197.237-.381.353-.552a6.507 6.507 0 0 0-4.666 5.5Zm10.181 1.5c-.144 2.074-.866 3.756-1.58 4.948-.12.197-.237.381-.353.552a6.507 6.507 0 0 0 4.666-5.5Zm2.733-1.5a6.507 6.507 0 0 0-4.666-5.5c.123.181.24.365.353.552.714 1.192 1.436 2.874 1.58 4.948Z',
79 plug: 'M4 8H2.5a1 1 0 0 0-1 1v5.25a.75.75 0 0 1-1.5 0V9a2.5 2.5 0 0 1 2.5-2.5H4V5.133a1.75 1.75 0 0 1 1.533-1.737l2.831-.353.76-.913c.332-.4.825-.63 1.344-.63h.782c.966 0 1.75.784 1.75 1.75V4h2.25a.75.75 0 0 1 0 1.5H13v4h2.25a.75.75 0 0 1 0 1.5H13v.75a1.75 1.75 0 0 1-1.75 1.75h-.782c-.519 0-1.012-.23-1.344-.63l-.761-.912-2.83-.354A1.75 1.75 0 0 1 4 9.867Zm6.276-4.91-.95 1.14a.753.753 0 0 1-.483.265l-3.124.39a.25.25 0 0 0-.219.248v4.734c0 .126.094.233.219.249l3.124.39a.752.752 0 0 1 .483.264l.95 1.14a.25.25 0 0 0 .192.09h.782a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25h-.782a.25.25 0 0 0-.192.09Z',
80 'dot-fill': 'M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8Z',
81 // Kinds of agent: one that writes code, a worker, a quick fix, a reviewer, an explorer,
82 // a planner, a general one, a guide.
83 code: 'm11.28 3.22 4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L13.94 8l-3.72-3.72a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215Zm-6.56 0a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L2.06 8l3.72 3.72a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L.47 8.53a.75.75 0 0 1 0-1.06Z',
84 tools:
85 'M5.433 2.304A4.492 4.492 0 0 0 3.5 6c0 1.598.832 3.002 2.09 3.802.518.328.929.923.902 1.64v.008l-.164 3.337a.75.75 0 1 1-1.498-.073l.163-3.33c.002-.085-.05-.216-.207-.316A5.996 5.996 0 0 1 2 6a5.993 5.993 0 0 1 2.567-4.92 1.482 1.482 0 0 1 1.673-.04c.462.296.76.827.76 1.423v2.82c0 .082.041.16.11.206l.75.51a.25.25 0 0 0 .28 0l.75-.51A.249.249 0 0 0 9 5.282V2.463c0-.596.298-1.127.76-1.423a1.482 1.482 0 0 1 1.673.04A5.993 5.993 0 0 1 14 6a5.996 5.996 0 0 1-2.786 5.068c-.157.1-.209.23-.207.315l.163 3.33a.752.752 0 0 1-1.094.714.75.75 0 0 1-.404-.64l-.164-3.345c-.027-.717.384-1.312.902-1.64A4.495 4.495 0 0 0 12.5 6a4.492 4.492 0 0 0-1.933-3.696c-.024.017-.067.067-.067.16v2.818a1.75 1.75 0 0 1-.767 1.448l-.75.51a1.75 1.75 0 0 1-1.966 0l-.75-.51A1.75 1.75 0 0 1 5.5 5.282V2.463c0-.092-.043-.142-.067-.159Z',
86 zap: 'M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004L9.503.429Zm1.047 1.074L3.286 8.571A.25.25 0 0 0 3.462 9H6.75a.75.75 0 0 1 .694 1.034l-1.713 4.188 6.982-6.793A.25.25 0 0 0 12.538 7H9.25a.75.75 0 0 1-.683-1.06l2.008-4.418.003-.006a.036.036 0 0 0-.004-.009l-.006-.006-.008-.001c-.003 0-.006.002-.009.004Z',
87 checklist:
88 'M2.5 1.75v11.5c0 .138.112.25.25.25h3.17a.75.75 0 0 1 0 1.5H2.75A1.75 1.75 0 0 1 1 13.25V1.75C1 .784 1.784 0 2.75 0h8.5C12.216 0 13 .784 13 1.75v7.736a.75.75 0 0 1-1.5 0V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13.274 9.537v-.001l-4.557 4.45a.75.75 0 0 1-1.055-.008l-1.943-1.95a.75.75 0 0 1 1.062-1.058l1.419 1.425 4.026-3.932a.75.75 0 1 1 1.048 1.074ZM4.75 4h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM4 7.75A.75.75 0 0 1 4.75 7h2a.75.75 0 0 1 0 1.5h-2A.75.75 0 0 1 4 7.75Z',
89 telescope:
90 'M14.184 1.143v-.001l1.422 2.464a1.75 1.75 0 0 1-.757 2.451L3.104 11.713a1.75 1.75 0 0 1-2.275-.702l-.447-.775a1.75 1.75 0 0 1 .53-2.32L11.682.573a1.748 1.748 0 0 1 2.502.57Zm-4.709 9.32h-.001l2.644 3.863a.75.75 0 1 1-1.238.848l-1.881-2.75v2.826a.75.75 0 0 1-1.5 0v-2.826l-1.881 2.75a.75.75 0 1 1-1.238-.848l2.049-2.992a.746.746 0 0 1 .293-.253l1.809-.87a.749.749 0 0 1 .944.252ZM9.436 3.92h-.001l-4.97 3.39.942 1.63 5.42-2.61Zm3.091-2.108h.001l-1.85 1.26 1.505 2.605 2.016-.97a.247.247 0 0 0 .13-.151.247.247 0 0 0-.022-.199l-1.422-2.464a.253.253 0 0 0-.161-.119.254.254 0 0 0-.197.038ZM1.756 9.157a.25.25 0 0 0-.075.33l.447.775a.25.25 0 0 0 .325.1l1.598-.769-.83-1.436-1.465 1Z',
91 tasklist:
92 'M2 2h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1Zm4.655 8.595a.75.75 0 0 1 0 1.06L4.03 14.28a.75.75 0 0 1-1.06 0l-1.5-1.5a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l.97.97 2.095-2.095a.75.75 0 0 1 1.06 0ZM9.75 2.5h5.5a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5Zm0 5h5.5a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5Zm0 5h5.5a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5Zm-7.25-9v3h3v-3Z',
93 hubot:
94 'M0 8a8 8 0 0 1 16 0v5.25a.75.75 0 0 1-1.5 0V8a6.5 6.5 0 1 0-13 0v5.25a.75.75 0 0 1-1.5 0Zm3-1.25C3 5.784 3.784 5 4.75 5h6.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0 1 11.25 10h-6.5A1.75 1.75 0 0 1 3 8.25Zm1.47-.53a.75.75 0 0 0 0 1.06l1.5 1.5a.75.75 0 0 0 1.06 0L8 7.81l.97.97a.75.75 0 0 0 1.06 0l1.5-1.5a.749.749 0 0 0-.326-1.275.749.749 0 0 0-.734.215l-.97.97-.97-.97a.75.75 0 0 0-1.06 0l-.97.97-.97-.97a.75.75 0 0 0-1.06 0Zm1.03 6.03a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z',
95 book: 'M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z',
96 fold: 'M10.896 2H8.75V.75a.75.75 0 0 0-1.5 0V2H5.104a.25.25 0 0 0-.177.427l2.896 2.896a.25.25 0 0 0 .354 0l2.896-2.896A.25.25 0 0 0 10.896 2ZM8.75 15.25a.75.75 0 0 1-1.5 0V14H5.104a.25.25 0 0 1-.177-.427l2.896-2.896a.25.25 0 0 1 .354 0l2.896 2.896a.25.25 0 0 1-.177.427H8.75v1.25Zm-6.5-6.5a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM6 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 6 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5ZM12 8a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1 0-1.5h.5A.75.75 0 0 1 12 8Zm2.25.75a.75.75 0 0 0 0-1.5h-.5a.75.75 0 0 0 0 1.5h.5Z',
97}
98
99/**
100 * Where a branch's upstream lives, by the remote's host: GitHub's mark and a
101 * generic remote (a cloud) are Octicons (`@primer/octicons`, `mark-github-16.svg`
102 * and `cloud-16.svg`, MIT, © GitHub Inc.); GitLab's and Forgejo's marks are from
103 * Simple Icons (`icons/gitlab.svg`, `icons/forgejo.svg`, CC0 1.0), on their
104 * 24×24 grid. All copied unchanged, drawn filled in the icon's colour.
105 */
106const HOSTS: Record<string, { d: string; grid: number }> = {
107 'mark-github': {
108 grid: 16,
109 d: 'M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656 0-.781.281-1.625.75-2.188-.203-.515-.172-1.609.063-2.062.625-.078 1.468.25 1.968.703.594-.187 1.219-.281 1.985-.281.765 0 1.39.094 1.953.265.484-.437 1.344-.765 1.969-.687.218.422.25 1.515.046 2.047.5.593.766 1.39.766 2.203 0 1.922-1.453 3.375-3.547 3.64.531.344.89 1.094.89 1.954v1.625c0 .468.391.734.86.547C13.781 14.359 16 11.53 16 8.03 16 3.61 12.406 0 7.984 0 3.563 0 0 3.61 0 8.031a7.88 7.88 0 0 0 5.172 7.422c.422.156.828-.125.828-.547v-1.25c-.219.094-.5.156-.75.156-1.031 0-1.64-.562-2.078-1.609-.172-.422-.36-.672-.719-.719-.187-.015-.25-.093-.25-.187 0-.188.313-.328.625-.328.453 0 .844.281 1.25.86.313.452.64.655 1.031.655s.641-.14 1-.5c.266-.265.47-.5.657-.656',
110 },
111 cloud: {
112 grid: 16,
113 d: 'M2 7.25A5.225 5.225 0 0 1 7.25 2a5.222 5.222 0 0 1 4.767 3.029A4.472 4.472 0 0 1 16 9.5c0 2.505-1.995 4.5-4.5 4.5h-8A3.474 3.474 0 0 1 0 10.5c0-1.41.809-2.614 2.001-3.17Zm1.54.482a.75.75 0 0 1-.556.832c-.86.22-1.484.987-1.484 1.936 0 1.124.876 2 2 2h8c1.676 0 3-1.324 3-3s-1.324-3-3-3a.75.75 0 0 1-.709-.504A3.72 3.72 0 0 0 7.25 3.5C5.16 3.5 3.5 5.16 3.5 7.25c.002.146.014.292.035.436l.004.036.001.008Z',
114 },
115 gitlab: {
116 grid: 24,
117 d: 'm23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z',
118 },
119 forgejo: {
120 grid: 24,
121 d: 'M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z',
122 },
123}
124
125/**
126 * Each named icon's glyph on the terminal: one cell wide, from Unicode blocks
127 * monospace fonts carry (geometric shapes, arrows, box drawing, a few dingbats),
128 * never one with an emoji presentation (⚠ ⚡ ◼ draw as emoji in some terminals).
129 * A GLYPH key is its own glyph.
130 */
131export const TERM_GLYPH: Readonly<Record<string, string>> = {
132 events: '↯',
133 'issue-opened': '◉',
134 comment: '≡',
135 'pr-open': GLYPH.pr,
136 'pr-merged': '◆',
137 'pr-closed': GLYPH.failed,
138 'pr-draft': GLYPH.pending,
139 file: '□',
140 'file-code': '□',
141 // A tree shows a folder by its fold: closed points right, open points down.
142 folder: '▸',
143 'folder-open': '▾',
144 'git-commit': '○',
145 'repo-push': GLYPH.up,
146 alert: '▲',
147 trash: '⌫',
148 search: '⌕',
149 sync: GLYPH.turns,
150 'arrow-up': GLYPH.up,
151 home: '⌂',
152 eye: '◎',
153 // ⇕, not ↕: ↕ has an emoji presentation.
154 fold: '⇕',
155 terminal: '$',
156 pencil: '✎',
157 globe: '◎',
158 plug: '⏚',
159 'dot-fill': '·',
160 code: '◆',
161 tools: '◇',
162 // ↯, not ⚡: ⚡ has an emoji presentation; ✓, not ✔, for the same reason.
163 zap: '↯',
164 checklist: '✓',
165 telescope: '⌕',
166 tasklist: '≡',
167 hubot: '○',
168 book: '?',
169}
170
171/**
172 * The glyph an icon draws as where there's no SVG: its terminal glyph, a
173 * one-character key as itself, else a dot (never the icon's name as text).
174 */
175export const glyphOf = (key: string): string => TERM_GLYPH[key] ?? ([...key].length === 1 ? key : '•')
176
177/** An XML text node's escapes, for a tooltip's `<title>`. */
178const xml = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
179
180/**
181 * The SVG for an icon (a GLYPH or a name), drawn in `color`; null when the kit has none.
182 *
183 * A `Duo` colour (a file type's, `FILE_TYPE` in kit/tokens.ts) draws the dark value
184 * and switches to the light one by the page's colour scheme, in a style inside the
185 * SVG: the engine tells a render hook no desktop theme. `title` is a tooltip,
186 * shown where the SVG is drawn interactive.
187 */
188export const iconSvg = (key: string, color: string | Duo, px = ICON_PX, title?: string): string | null => {
189 const host = HOSTS[key]
190 const filled = host?.d ?? FILLED[key]
191 const body = PATHS[key]
192 if (!filled && !body) return null
193 const grid = host?.grid ?? 16
194 const base = typeof color === 'string' ? color : color.dark
195 const paint = filled ? 'fill' : 'stroke'
196 const scheme = typeof color === 'string' ? '' : `<style>@media (prefers-color-scheme: light){svg{${paint}:${color.light}}}</style>`
197 const tip = title ? `<title>${xml(title)}</title>` : ''
198 const open = `<svg xmlns="http://www.w3.org/2000/svg" width="${px}" height="${px}" viewBox="0 0 ${grid} ${grid}" `
199 if (filled) return `${open}fill="${base}">${scheme}${tip}<path d="${filled}"/></svg>`
200 return `${open}fill="none" stroke="${base}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${scheme}${tip}${body}</svg>`
201}
202kit/perf.ts 101 lines1/**
2 * What a mod costs: how often each named thing ran, how long it took, and how
3 * much of the last minute it spent. Each compiled mod gets its own copy of the
4 * kit, so these are that mod's numbers. Recording never redraws: the footer
5 * shows them as of the pane's latest draw, so measuring adds no load.
6 *
7 * const done = perf.start('render'); …; done()
8 * await perf.time('git', () => run(...))
9 * perf.count('state write')
10 */
11
12const now = (): number => (globalThis as { performance?: { now(): number } }).performance?.now() ?? Date.now()
13
14const WINDOW = 60_000
15
16type Entry = { n: number; total: number; last: number; max: number; recent: Array<[at: number, ms: number]> }
17
18export type PerfRow = {
19 name: string
20 /** Runs since the mod loaded. */
21 n: number
22 /** Runs in the last minute. */
23 perMin: number
24 /** Milliseconds per run, on average; 0 for counts. */
25 avgMs: number
26 lastMs: number
27 maxMs: number
28 /** Milliseconds spent in the last minute. */
29 busyMs: number
30}
31
32export type PerfSnapshot = { sinceMs: number; rows: PerfRow[] }
33
34export const createPerf = () => {
35 const entries = new Map<string, Entry>()
36 const loadedAt = Date.now()
37
38 const record = (name: string, ms: number) => {
39 const e = entries.get(name) ?? { n: 0, total: 0, last: 0, max: 0, recent: [] }
40 const at = Date.now()
41 e.n++
42 e.total += ms
43 e.last = ms
44 e.max = Math.max(e.max, ms)
45 e.recent.push([at, ms])
46 // Keep only the window, so a long session doesn't grow this.
47 while (e.recent.length && at - e.recent[0]![0] > WINDOW) e.recent.shift()
48 entries.set(name, e)
49 }
50
51 /** Starts timing `name`; call what it returns when it's done. */
52 const start = (name: string) => {
53 const t0 = now()
54 return () => record(name, now() - t0)
55 }
56
57 /** Times `fn`, sync or async, under `name`. */
58 const time = <T>(name: string, fn: () => T): T => {
59 const done = start(name)
60 let out: T
61 try {
62 out = fn()
63 } catch (err) {
64 done()
65 throw err
66 }
67 if (out instanceof Promise) return out.finally(done) as T
68 done()
69 return out
70 }
71
72 /** Counts one occurrence of `name`, with no time. */
73 const count = (name: string) => record(name, 0)
74
75 const snapshot = (): PerfSnapshot => {
76 const at = Date.now()
77 const rows = [...entries].map(([name, e]) => {
78 const recent = e.recent.filter(([t]) => at - t <= WINDOW)
79 return {
80 name,
81 n: e.n,
82 perMin: recent.length,
83 avgMs: e.n ? e.total / e.n : 0,
84 lastMs: e.last,
85 maxMs: e.max,
86 busyMs: recent.reduce((s, [, ms]) => s + ms, 0),
87 }
88 })
89 // What cost the most in the last minute first, then what ran most.
90 rows.sort((a, b) => b.busyMs - a.busyMs || b.perMin - a.perMin)
91 return { sinceMs: at - loadedAt, rows }
92 }
93
94 return { start, time, count, snapshot }
95}
96
97export type Perf = ReturnType<typeof createPerf>
98
99/** Milliseconds, short: 0.4ms, 12ms, 1.2s. */
100export const ms = (v: number): string => (v >= 1_000 ? `${(v / 1_000).toFixed(1)}s` : v >= 10 ? `${Math.round(v)}ms` : `${v.toFixed(1)}ms`)
101kit/tokens.ts 318 lines1import type { Skin } from './skin'
2import { DEFAULT_SKIN, SKINS } from './skins'
3import type { SkinId } from './skins'
4
5/**
6 * The instrument's colours. Each hue means one thing, everywhere:
7 *
8 * - grey (MUTED): labels, icons, git marks, ordinary values
9 * - slate (INK): "how much" — bars and gauges that only measure
10 * - blue: live or in progress — running, pending, the activity series
11 * - amber: getting close to a limit — 75% and up, the compaction mark
12 * - red: failed, conflicted, 90% and up
13 * - green: finished well — checks passed, an agent done
14 * - purple: merged
15 *
16 * Cyan and orchid aren't statuses: they only tint parts within one chart.
17 *
18 * The values come from the skin the kit wears (kit/skin.ts): a zen theme pack's
19 * palette, mapped to what the app surfaces draw. The kit starts in the
20 * `claude-code` skin; `wear()` puts on another. Read them when drawing, never
21 * into a module-level constant, so a skin worn later reaches every drawing.
22 */
23export const C = {
24 ok: '',
25 warn: '',
26 bad: '',
27 blue: '',
28 purple: '',
29 cyan: '',
30 slate: '',
31 orchid: '',
32}
33
34/** Quantity ink: what a bar or gauge is drawn in when it only says how much. */
35export let INK = ''
36
37/** Shades within one chart: parts of one thing, told apart without new meanings. */
38export const RAMP = {
39 blue: ['', '', '', ''] as [string, string, string, string],
40 slate: ['', '', '', ''] as [string, string, string, string],
41}
42
43/** A bar's empty part and other quiet fills: a grey that reads on light and dark. */
44export const TRACK = 'rgba(128,128,128,0.18)'
45
46/**
47 * `color` (#rgb or #rrggbb) at `alpha` (0 to 1), as rgba(): a tint that lets the
48 * pane show through, so it reads on the light desktop theme and the dark one.
49 * Undefined for anything that isn't a hex colour.
50 */
51export const tint = (color: string, alpha: number): string | undefined => {
52 const hex = color.replace(/^#/, '')
53 const full = hex.length === 3 ? [...hex].map(x => x + x).join('') : hex
54 if (!/^[0-9a-f]{6}$/i.test(full)) return undefined
55 const [r, g, b] = [0, 2, 4].map(i => parseInt(full.slice(i, i + 2), 16))
56 return `rgba(${r},${g},${b},${alpha})`
57}
58
59/**
60 * The app surfaces' fills, behind text. The engine gives render hooks no desktop
61 * theme to read and no theme key for a fill, so each is a see-through tint over
62 * the pane: a neutral grey darkens the light pane and lightens the dark one by
63 * about the same step, and the text over it keeps the theme's own foreground.
64 * Never on the terminal, which draws no fills.
65 *
66 * - head: a section header's band, the step that marks where a section starts;
67 * - tile: one item of a list of several-line items, a step quieter than a band;
68 * - selected: the picked tile or row, tinted towards the blue that means live;
69 * - selectedBlurred: the same while the pane doesn't have the keyboard, half as strong;
70 * - pill: how strongly a label's own colour fills its chip.
71 */
72export const FILL = {
73 head: 'rgba(128,128,128,0.09)',
74 tile: 'rgba(128,128,128,0.05)',
75 selected: '',
76 selectedBlurred: '',
77 pill: 0.22,
78}
79
80/** Text, icons and marks that should recede. */
81export let MUTED = ''
82
83/**
84 * Three greys for text below the main text, each a step quieter, so a row can say
85 * what matters most without colour (tuned for the desktop's dark pane; mid and
86 * dark also read on the light one):
87 * - light: secondary text a reader still reads (a linked PR's title, a byline);
88 * - mid: metadata scanned past (ages, counts, secondary actions);
89 * - dark: the quietest marks (an icon beside a count, a heading over a list).
90 */
91export const GRAY = { light: '', mid: '', dark: '' }
92
93/** A colour with one value for the light desktop theme and one for the dark. */
94export type Duo = { readonly light: string; readonly dark: string }
95
96/**
97 * File-type icon colours. One meaning: this file's type. Only a file tree's icons
98 * take them, never text, a header or a control (those stay grey), and only on the
99 * app surfaces: the terminal draws file icons monochrome (dim), as a file type is
100 * no state a theme key means.
101 *
102 * The values are IntelliJ's New UI ("expui") icon colours, each from the fill or
103 * stroke of the icon named beside it: intellij-community
104 * `platform/icons/src/expui/fileTypes/<name>.svg` (light) and `<name>_dark.svg`
105 * (dark), and `platform/icons/src/expui/nodes/` for the folders. Apache 2.0,
106 * © JetBrains s.r.o. and contributors (each SVG's header).
107 *
108 * An icon drawn in a Duo carries both values and picks one by the page's colour
109 * scheme (`iconSvg` in kit/icons.ts); where that can't be read it takes the dark.
110 */
111export const FILE_TYPE = {
112 /** typeScript.svg */
113 typescript: { light: '#3574F0', dark: '#548AF7' },
114 /** javaScript.svg */
115 javascript: { light: '#FFAF0F', dark: '#F2C55C' },
116 /** json.svg */
117 json: { light: '#834DF0', dark: '#B589EC' },
118 /** markdown.svg */
119 markdown: { light: '#3574F0', dark: '#548AF7' },
120 /** config.svg (its accent), toml.svg, docker.svg */
121 config: { light: '#3574F0', dark: '#548AF7' },
122 /** yaml.svg */
123 yaml: { light: '#DB3B4B', dark: '#DB5C5C' },
124 /** css.svg */
125 style: { light: '#4682FA', dark: '#548AF7' },
126 /** html.svg */
127 html: { light: '#369650', dark: '#57965C' },
128 /** xml.svg */
129 xml: { light: '#E66D17', dark: '#C77D55' },
130 /** csv.svg */
131 data: { light: '#208A3C', dark: '#57965C' },
132 /** sql.svg */
133 sql: { light: '#834DF0', dark: '#B589EC' },
134 /** image.svg */
135 image: { light: '#3574F0', dark: '#548AF7' },
136 /** archive.svg */
137 archive: { light: '#3574F0', dark: '#548AF7' },
138 /** text.svg, shell.svg, unknown.svg, font.svg: any other file, lockfiles included */
139 text: { light: '#6C707E', dark: '#CED0D6' },
140 /** nodes/folder.svg, nodes/folderGithub.svg */
141 folder: { light: '#6C707E', dark: '#CED0D6' },
142 /** nodes/excludeRoot.svg: a folder git ignores (node_modules, build output) */
143 excluded: { light: '#F28C35', dark: '#C77D55' },
144} as const satisfies Record<string, Duo>
145
146export type FileType = keyof typeof FILE_TYPE
147
148/** Slate under 75%, amber from 75%, red from 90%: colour only once it matters. */
149export const levelColor = (percent: number): string => (percent >= 90 ? C.bad : percent >= 75 ? C.warn : INK)
150
151/*
152 * The terminal's colours. The terminal draws each status in the engine theme key
153 * that means the same thing, so it follows the person's terminal theme (dark,
154 * light, colour-blind, ANSI-only) instead of a fixed hex that may not read on it:
155 *
156 * - green C.ok → 'success'
157 * - red C.bad → 'error'
158 * - amber C.warn → 'warning'
159 * - blue C.blue → 'suggestion'
160 * - purple C.purple → 'merged'
161 * - the greys (MUTED, GRAY, TRACK) → the terminal's own dim, never a grey hex
162 * - quantity (INK, slate) → the terminal's default foreground
163 * - the shades within one chart (RAMP, cyan, orchid) → plain and dim in turn
164 * (TERM_TINT), the blue ramp as `suggestion` and dimmed `suggestion`
165 * - any other hex → the default foreground: no fixed hex reaches the terminal,
166 * where it can vanish on a light, an ANSI-only or a colour-blind theme.
167 */
168const themeKey: Record<string, string> = {}
169export const THEME_KEY: Readonly<Record<string, string>> = themeKey
170
171const QUIET = new Set<string>()
172
173/** Text props on the terminal: a theme key, the terminal's dim, or neither (the default foreground). */
174export type TermInk = { color?: string; dimColor?: boolean }
175
176/**
177 * The chart shades on the terminal, which has no colours to spare for them: the
178 * slate ramp (quantity) is the default foreground and dim in turn, the blue ramp
179 * `suggestion` and dimmed `suggestion` in turn, cyan plain and orchid dim. A
180 * bar's parts are all runs of the same heavy line (METER), told apart by these
181 * inks alone: no shade or dither glyphs, which render as a checkerboard.
182 */
183const termTint: Record<string, TermInk> = {}
184export const TERM_TINT: Readonly<Record<string, TermInk>> = termTint
185
186/** Whether `color` is one of the kit's tokens (a status, a grey, a chart shade), not data such as a GitHub label's hex. */
187export const isKitColor = (color: string): boolean => QUIET.has(color) || color in THEME_KEY || color in TERM_TINT
188
189/** A raw colour (hex, rgb(), hsl()) rather than a theme key's name. */
190const RAW = /^(#|rgba?\(|hsla?\()/i
191
192/**
193 * A colour's Text props on the terminal: a status as its theme key, a grey as
194 * `dimColor`, a chart shade as TERM_TINT says, a theme key's name as itself, and
195 * any other raw colour as nothing (the default foreground). Nothing for no colour.
196 */
197export const termInk = (color?: string): TermInk => {
198 if (!color) return {}
199 if (QUIET.has(color)) return { dimColor: true }
200 const key = THEME_KEY[color]
201 if (key) return { color: key }
202 const tint = TERM_TINT[color]
203 if (tint) return { ...tint }
204 return RAW.test(color) ? {} : { color }
205}
206
207/**
208 * One glyph per idea, shared so mods read alike (the SVG icons are keyed by these).
209 * Each is what the terminal draws: one cell, and never a character with an emoji
210 * presentation (Unicode's Emoji property: ⚒ ⚠ ⚡ ⏱ ↕ ◼ draw as emoji in some
211 * terminals and take two cells).
212 */
213export const GLYPH = {
214 context: '◕',
215 cost: '$',
216 time: '◷',
217 turns: '↻',
218 // The place-of-interest sign, as on a Command key: what the tools are called with.
219 tools: '⌘',
220 files: '✎',
221 agents: '✦',
222 limits: '◔',
223 layers: '▤',
224 branch: '⎇',
225 pr: '⇡',
226 running: '◐',
227 done: '✓',
228 failed: '✕',
229 stopped: '■',
230 pending: '◌',
231 up: '↑',
232 down: '↓',
233} as const
234
235/**
236 * A status's mark: one glyph and the colour that means it, the same on every
237 * surface. Draw it with `k.status(kind)`, never by hand, so a running task, a
238 * running server and a pending check all read alike.
239 */
240export const STATUS = {
241 running: { glyph: GLYPH.running, color: '' },
242 pending: { glyph: GLYPH.pending, color: '' },
243 done: { glyph: GLYPH.done, color: '' },
244 failed: { glyph: GLYPH.failed, color: '' },
245 warn: { glyph: '▲', color: '' },
246 merged: { glyph: '◆', color: '' },
247 stopped: { glyph: GLYPH.stopped, color: '' },
248 idle: { glyph: '○', color: '' },
249}
250
251export type StatusKind = keyof typeof STATUS
252
253/** The skin the kit wears now: a key of SKINS, or `custom` for a skin passed in whole. */
254export let SKIN: SkinId | 'custom' = DEFAULT_SKIN
255
256/**
257 * Puts on a skin: a zen theme pack's (a key of SKINS, kit/skins.ts) or one made
258 * in full. Every colour above takes the skin's value, and the terminal's mapping
259 * follows, so each meaning keeps its theme key. Wear one before the first
260 * drawing (at the top of the hooks module); the kit starts in DEFAULT_SKIN.
261 */
262export const wear = (skin: SkinId | Skin): void => {
263 const s = typeof skin === 'string' ? SKINS[skin] : skin
264 if (!s) throw new Error(`no skin '${String(skin)}'`)
265 SKIN = typeof skin === 'string' ? skin : 'custom'
266 Object.assign(C, { ok: s.ok, warn: s.warn, bad: s.bad, blue: s.blue, purple: s.purple, cyan: s.cyan, slate: s.slate, orchid: s.orchid })
267 INK = s.slate
268 RAMP.blue.splice(0, 4, ...s.ramp.blue)
269 RAMP.slate.splice(0, 4, ...s.ramp.slate)
270 FILL.selected = tint(s.blue, 0.13)!
271 FILL.selectedBlurred = tint(s.blue, 0.065)!
272 MUTED = s.muted
273 Object.assign(GRAY, s.gray)
274 const status: Record<StatusKind, string> = {
275 running: s.blue,
276 pending: s.blue,
277 done: s.ok,
278 failed: s.bad,
279 warn: s.warn,
280 merged: s.purple,
281 stopped: s.muted,
282 idle: s.muted,
283 }
284 for (const kind of Object.keys(status) as StatusKind[]) STATUS[kind].color = status[kind]
285 for (const key of Object.keys(themeKey)) delete themeKey[key]
286 Object.assign(themeKey, { [s.ok]: 'success', [s.bad]: 'error', [s.warn]: 'warning', [s.blue]: 'suggestion', [s.purple]: 'merged' })
287 QUIET.clear()
288 for (const grey of [s.muted, s.gray.light, s.gray.mid, s.gray.dark, TRACK]) QUIET.add(grey)
289 for (const key of Object.keys(termTint)) delete termTint[key]
290 Object.assign(termTint, {
291 [s.ramp.slate[0]]: {},
292 [s.ramp.slate[1]]: { dimColor: true },
293 [s.ramp.slate[2]]: {},
294 [s.ramp.slate[3]]: { dimColor: true },
295 [s.ramp.blue[1]]: { color: 'suggestion', dimColor: true },
296 [s.ramp.blue[2]]: { color: 'suggestion' },
297 [s.ramp.blue[3]]: { color: 'suggestion', dimColor: true },
298 [s.cyan]: {},
299 [s.orchid]: { dimColor: true },
300 })
301}
302
303wear(DEFAULT_SKIN)
304
305/**
306 * Tree connectors for nested rows (agents under the agent that spawned them,
307 * files under folders), each two cells wide so levels indent on the grid.
308 */
309export const TREE = { tee: '├─', last: '└─', pipe: '│ ', blank: ' ' } as const
310
311/**
312 * A nested row's connector: for each level above it, a pipe where that level has
313 * more rows below, else blank; then a tee, or an elbow for the last child.
314 * Empty at the top level.
315 */
316export const treePrefix = (guides: boolean[], last: boolean, depth = guides.length + 1): string =>
317 depth === 0 ? '' : `${guides.map(g => (g ? TREE.pipe : TREE.blank)).join('')}${last ? TREE.last : TREE.tee}`
318kit/skin.ts 39 lines1/**
2 * A skin: the colours the kit draws with on the app surfaces (desktop, VS Code, mobile), one per
3 * meaning (see kit/tokens.ts). Each zen theme pack gives one (kit/skins.ts, made from the pack's
4 * palette by zen's `src/harness/claude-code/skins.ts`); a mod wears one with `wear()`.
5 *
6 * The terminal never draws these values: it draws each meaning in the terminal theme key that
7 * means the same thing (THEME_KEY, TERM_TINT in kit/tokens.ts), whatever the skin, so a mod
8 * follows the person's terminal theme.
9 *
10 * Every value is #rrggbb, and every value is its meaning's own: no two meanings share a value,
11 * as the terminal finds a meaning by its value. Greys may share one another's.
12 */
13export type Skin = {
14 /** The pack's name, as zen's toolbar shows it. */
15 readonly label: string
16 /** Finished well: checks passed, an agent done. */
17 readonly ok: string
18 /** Getting close to a limit: 75% and up, the compaction mark. */
19 readonly warn: string
20 /** Failed, conflicted, 90% and up. */
21 readonly bad: string
22 /** Live or in progress: running, pending, the activity series. */
23 readonly blue: string
24 /** Merged. */
25 readonly purple: string
26 /** A tint for parts within one chart, never a status. */
27 readonly cyan: string
28 /** Quantity ink: what a bar or gauge is drawn in when it only says how much. */
29 readonly slate: string
30 /** A tint for parts within one chart, never a status. */
31 readonly orchid: string
32 /** Shades within one chart: the live colour and the quantity ink, each base first, then darker, lighter, darkest. */
33 readonly ramp: { readonly blue: readonly [string, string, string, string]; readonly slate: readonly [string, string, string, string] }
34 /** Text, icons and marks that should recede. */
35 readonly muted: string
36 /** Text below the main text, each a step quieter. */
37 readonly gray: { readonly light: string; readonly mid: string; readonly dark: string }
38}
39kit/skins.ts 261 lines1// Every zen theme pack's skin for the Claude Code kit, by pack id: made from the pack's palette by
2// zen's src/harness/claude-code/skins.ts. Generated; don't edit it, run `npx tsx scripts/harness-skins.ts` in zen.
3import type { Skin } from './skin'
4
5export const SKINS = {
6 "yahaha-elektron": {
7 label: "yahaha × Elektron",
8 ok: "#5fdc4a",
9 warn: "#ffd60a",
10 bad: "#e5534b",
11 blue: "#3b8eff",
12 purple: "#a58cff",
13 cyan: "#3ff4ff",
14 slate: "#afc4e0",
15 orchid: "#f57aff",
16 ramp: {
17 blue: [
18 "#3b8eff",
19 "#2c6bbf",
20 "#80b6ff",
21 "#204e8c"
22 ],
23 slate: [
24 "#afc4e0",
25 "#8393a8",
26 "#cbd9eb",
27 "#606c7b"
28 ]
29 },
30 muted: "#8c8c8c",
31 gray: {
32 light: "#d6d6d6",
33 mid: "#8c8c8c",
34 dark: "#4d4d4d"
35 }
36 },
37 "yahaha-classic": {
38 label: "yahaha classic",
39 ok: "#02c31e",
40 warn: "#ffeb44",
41 bad: "#fe4e66",
42 blue: "#4cc2ff",
43 purple: "#b98cff",
44 cyan: "#08b7aa",
45 slate: "#87aac2",
46 orchid: "#ff6bd0",
47 ramp: {
48 blue: [
49 "#4cc2ff",
50 "#3992bf",
51 "#8bd7ff",
52 "#2a6b8c"
53 ],
54 slate: [
55 "#87aac2",
56 "#658092",
57 "#b1c8d7",
58 "#4a5e6b"
59 ]
60 },
61 muted: "#8a929d",
62 gray: {
63 light: "#9aa2ad",
64 mid: "#8a929d",
65 dark: "#555c66"
66 }
67 },
68 "yahaha-push": {
69 label: "yahaha Push (as designed)",
70 ok: "#5fdc4a",
71 warn: "#ffd60a",
72 bad: "#e5534b",
73 blue: "#3b8eff",
74 purple: "#a58cff",
75 cyan: "#3ff4ff",
76 slate: "#afc4e0",
77 orchid: "#f57aff",
78 ramp: {
79 blue: [
80 "#3b8eff",
81 "#2c6bbf",
82 "#80b6ff",
83 "#204e8c"
84 ],
85 slate: [
86 "#afc4e0",
87 "#8393a8",
88 "#cbd9eb",
89 "#606c7b"
90 ]
91 },
92 muted: "#8c8c8c",
93 gray: {
94 light: "#d6d6d6",
95 mid: "#8c8c8c",
96 dark: "#4d4d4d"
97 }
98 },
99 visuals: {
100 label: "visual[flow] (glass)",
101 ok: "#51cd85",
102 warn: "#f0b23c",
103 bad: "#e0483f",
104 blue: "#7c86ff",
105 purple: "#c86ef0",
106 cyan: "#3ff4ff",
107 slate: "#a8abce",
108 orchid: "#f2659f",
109 ramp: {
110 blue: [
111 "#7c86ff",
112 "#5d65bf",
113 "#aab0ff",
114 "#444a8c"
115 ],
116 slate: [
117 "#a8abce",
118 "#7e809b",
119 "#c6c8df",
120 "#5c5e71"
121 ]
122 },
123 muted: "#a4a4ac",
124 gray: {
125 light: "#b7b7be",
126 mid: "#a4a4ac",
127 dark: "#5e5e66"
128 }
129 },
130 elektron: {
131 label: "Elektron",
132 ok: "#3ddc84",
133 warn: "#ff8c1a",
134 bad: "#ff4d4d",
135 blue: "#3d6bff",
136 purple: "#b48cff",
137 cyan: "#00d1ff",
138 slate: "#c2cdf5",
139 orchid: "#ff3dbb",
140 ramp: {
141 blue: [
142 "#3d6bff",
143 "#2e50bf",
144 "#819fff",
145 "#223b8c"
146 ],
147 slate: [
148 "#c2cdf5",
149 "#929ab8",
150 "#d7dff9",
151 "#6b7187"
152 ]
153 },
154 muted: "#b4b4b8",
155 gray: {
156 light: "#eeeef2",
157 mid: "#b4b4b8",
158 dark: "#6a6a6a"
159 }
160 },
161 ableton: {
162 label: "Ableton",
163 ok: "#36b958",
164 warn: "#ffe630",
165 bad: "#ff3254",
166 blue: "#4d4dff",
167 purple: "#8a8aff",
168 cyan: "#018fd2",
169 slate: "#d3d3ff",
170 orchid: "#db77ff",
171 ramp: {
172 blue: [
173 "#4d4dff",
174 "#3a3abf",
175 "#8b8bff",
176 "#2a2a8c"
177 ],
178 slate: [
179 "#d3d3ff",
180 "#9e9ebf",
181 "#e2e2ff",
182 "#74748c"
183 ]
184 },
185 muted: "#aaaaaa",
186 gray: {
187 light: "#ffffff",
188 mid: "#aaaaaa",
189 dark: "#666666"
190 }
191 },
192 linear: {
193 label: "Linear",
194 ok: "#86e08a",
195 warn: "#f4c430",
196 bad: "#eb5757",
197 blue: "#5e6ad2",
198 purple: "#bb87fc",
199 cyan: "#02b8cc",
200 slate: "#b4bbdd",
201 orchid: "#ff7ad9",
202 ramp: {
203 blue: [
204 "#5e6ad2",
205 "#47509e",
206 "#969ee2",
207 "#343a74"
208 ],
209 slate: [
210 "#b4bbdd",
211 "#878ca6",
212 "#ced3e9",
213 "#63677a"
214 ]
215 },
216 muted: "#8a8f98",
217 gray: {
218 light: "#d0d6e0",
219 mid: "#8a8f98",
220 dark: "#62666d"
221 }
222 },
223 "claude-code": {
224 label: "Claude Code",
225 ok: "#5bc08a",
226 warn: "#e3a33b",
227 bad: "#e5636f",
228 blue: "#6c8ef5",
229 purple: "#9d85e8",
230 cyan: "#4fb3c8",
231 slate: "#8c9bb5",
232 orchid: "#c58ad6",
233 ramp: {
234 blue: [
235 "#6c8ef5",
236 "#4c68c9",
237 "#a9bbf9",
238 "#38509e"
239 ],
240 slate: [
241 "#8c9bb5",
242 "#6f7c93",
243 "#a7b2c6",
244 "#5b667a"
245 ]
246 },
247 muted: "#8e8e93",
248 gray: {
249 light: "#b9b9b6",
250 mid: "#8a8a87",
251 dark: "#62625f"
252 }
253 }
254} as const satisfies Record<string, Skin>
255
256/** A pack's id: the skins a mod can wear. */
257export type SkinId = keyof typeof SKINS
258
259/** The skin the kit starts in: awesome-claude's look. */
260export const DEFAULT_SKIN: SkinId = 'claude-code'
261types/index.d.ts 108 lines1/** One entry of `.claude/launch.json`'s `configurations`, as the desktop app reads it. */
2export type LaunchConfig = {
3 name: string
4 runtimeExecutable: string
5 runtimeArgs: string[]
6 port: number
7 /** Relative to the project root (the folder holding `.claude/`); absent, the root. */
8 cwd?: string
9 env?: Record<string, string>
10 /** When the port is taken, run on the next free one instead. */
11 autoPort?: boolean
12}
13
14/** The project's `.claude/launch.json`, as last read. */
15export type LaunchFile = {
16 /** The folder holding `.claude/launch.json`; empty when none was found. */
17 root: string
18 configs: LaunchConfig[]
19 /** Why there's nothing to show (no file, unreadable JSON). */
20 note?: string
21 /** When it was read (ms since epoch). */
22 at: number
23}
24
25/**
26 * - `stopped`: nothing answers on its port;
27 * - `starting`: started from this session, its port not answering yet;
28 * - `running`: started from this session (or taken over) and answering;
29 * - `elsewhere`: something this session didn't start answers on its port, and
30 * it is this configuration's server (its command line or working directory
31 * match), or there's no telling whose it is;
32 * - `taken`: the process on its port is another one's (another configuration's
33 * on the same port, or another program);
34 * - `failed`: started from this session and exited on its own with an error.
35 */
36export type Status = 'stopped' | 'starting' | 'running' | 'elsewhere' | 'taken' | 'failed'
37
38/** A server this session didn't start but took over: Stop signals its process. */
39export type Adopted = {
40 pid: number
41 port: number
42 /**
43 * When it started and its command line, as ps said when it was taken over:
44 * Stop signals the pid only while ps still says both (not a later process
45 * given the same pid).
46 */
47 start: string
48 command: string
49 /** When it was taken over (ms since epoch). */
50 at: number
51}
52
53/** A Kill pressed once, waiting for the second press that confirms it. */
54export type KillConfirm = {
55 name: string
56 /** The process the first press found on `port`, and who it was (as in Adopted): the second press signals it only if it's still that. */
57 pid: number
58 port: number
59 start: string
60 command: string
61 /** When the confirm lapses (ms since epoch, the engine's clock). */
62 until: number
63}
64
65/** One configuration's server as the pane draws it. */
66export type Server = {
67 status: Status
68 /** The port it runs on: the configuration's, or the free one `autoPort` picked. */
69 port: number
70 /** The last lines it wrote, oldest first (at most 200). */
71 log: string[]
72 /** How it last ended: its exit code, null for a signal or while it runs. */
73 code: number | null
74 /** When this session started it (ms since epoch); 0 when it didn't. */
75 startedAt: number
76 /**
77 * While it answers: the CPU (percent of one core) of the process listening on
78 * its port, one sample a tick while the pane is shown, oldest first (at most 60).
79 * Absent when it can't be measured (no lsof or ps).
80 */
81 cpu?: number[]
82 /** While it answers: that process's resident memory, in bytes, at the last sample. */
83 rss?: number
84 /** The process listening on its port when this session didn't start it (lsof), and its command line (ps). */
85 pid?: number
86 command?: string
87 /** Running because it was taken over: its output isn't the launcher's to show. */
88 adopted?: boolean
89}
90
91declare module 'claude-code' {
92 interface PluginState {
93 launcher: {
94 file: LaunchFile | null
95 /** By configuration name. */
96 servers: Record<string, Server>
97 /** Configurations whose details and output are unfolded, by name. */
98 open: string[]
99 /** Servers taken over from elsewhere, by configuration name. */
100 adopted: Record<string, Adopted>
101 /** The row whose Kill waits for its confirming press, if any. */
102 confirm: KillConfirm | null
103 /** Whether a wide app pane flows its rows into columns (the footer's switch); on until flipped. */
104 columns: boolean
105 }
106 }
107}
108