SLOPSHOPPER

issues

A pane listing the open GitHub issues of the session's repository

newpaneguardcommandtoastprocess
v0.3.5MITupdated 2026-10-15satoramoto/awesome-claude/plugins/issues
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · issues
│ ┃ Issues ✕ › fix the failing auth test and add an audit log call │ ┃ Issues ─ │ ┃ ─ ⏺ Read(src/auth.ts) │ ┃ ─ ⎿ Read 6 lines │ ┃ ─ ⏺ Update(src/auth.ts) │ ┃ ─ ⎿ Added 2 lines, removed 1 line │ ┃ ─ ⏺ Bash(bun test) │ ┃ ─ ⎿ 3 pass, 1 fail │ ┃ ─ │ ┃ ─ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ─ │ ┃ ─ ✻ Worked for 42s · done 4:20 PM │ ┃ ─ │ ┃ ─ › /issues │ ┃ ─ ⎿ issues: Issues pane opened. │ ┃ ─ │ ┃ ─ │ ┃ ─ │ ┃ ─ │ ┃ ─ │ ┃ ─ │ ┃ ─ │ ┃ ─ │ ┃ ─ │ ┃ ─ │ ┃ ─ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Issues
Issues ─ ▾ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ✕ Unreadable gh output
README

awesome-claude

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.

The mods

ModWhat it showsOpens withNeeds
overviewthe current session at a glance: context, limits, activity, agents, the branches it started and their pull requests, coston its own when a session starts; /overview opens it againgit for Workspaces; gh signed in for pull requests
issuesthe open GitHub issues of the session's repository, newest change first, with actions on eachon its own when a session starts; /issues opens it againa GitHub repository; gh signed in
launcherthe 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 outputon 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
labfor 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.jsonthe index on its own when a session starts; /lab, /lab <id>, /lab gallerynothing

Forked mods

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.

ModWhat it doesOriginalLicense
filetreean 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 itdata-goblin/claude-code-filetree, by Kurt BuhlerMIT
blast-radiusholds a risky shell command (rm -rf, git reset --hard, git clean, force push, migrations) and shows what it would change, with Proceed and Cancelanthropics/claude-code-playground, by AnthropicApache-2.0

Requirements

  • Claude Code: tested on 2.1.296.
  • git, for overview's Workspaces section.
  • The GitHub CLI, signed in (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, only to build from source (bun install, then bun build.ts). Installing from the marketplace needs nothing else.

What the panes do

/overview

Opens by itself when a session starts; /overview brings it back after you close it. Its sections, each folding open and shut:

  • Context: how full the context window is, by kind, and the room left before compaction.
  • Limits: your plan's usage limits and when they reset.
  • Activity: tool calls per turn, sliced by tool, with failures.
  • Agents: the session's subagents, each with its status, tokens and time.
  • Workspaces: the branches the session started, each with its pull request's state, checks and lines changed. Pull requests are read with gh every 30 seconds while any is open.
  • Accounting: the session's cost and model.

A footer shows the session's share of the machine's cpu, memory and network.

/issues

Opens by itself when a session starts; /issues brings it back. Lists the open issues of the session's repository (read with gh issue list).

  • Quick add creates an issue from a title alone, with an empty body (gh issue create).
  • Refine, Start and Sprint send a prompt to the session, as if you'd typed it, and the session does the work with gh:
  • Refine: the session reads the issue, asks you about what's unclear, rewrites its body and labels it refined.
  • Start: one subagent takes the issue in its own worktree and branch off the default branch, and reports back when it's pushed. An issue not yet labelled refined asks first whether to refine it.
  • Sprint (select several issues): one subagent per issue in parallel, each in its own worktree, integrated on one 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.

/launcher

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

  • States: stopped (grey); starting (blue: started here, its port not answering yet); running (green, with 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).
  • Start runs 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.
  • Unfold a row for its command, its exit code, and the tail of its output (the last 200 lines are kept, colours stripped).
  • From the prompt: /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.
  • Which ports answer comes from 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.
  • Stop frees the port: checked with 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.

Install

From the marketplace

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.

Getting updates

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" }] }
  ]
}

Try it from a clone, for one session

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

From source, while changing the mods

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.

Making a mod

AGENTS.md lists the commands and checks. The project's skills, which Claude Code loads when working in this repository, hold the how-to:

FolderWhat 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.tswhere 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.jsonthe 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.

The playground

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.

Storybook

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.

Why a compiler

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.

Drawing for the desktop

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.

License

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

Source 10 files
hooks/register.tsx 306 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { holdButtons } from '../kit/ui'
5
6import type { Issue, IssueList } from '../types'
7import { REFINED, cursorOf, renderApp, renderTerminal } from './view'
8import type { AppElements, TermElements } from './view'
9
10const PANE = 'issues'
11/** How many open issues are read; past this the count shows a "+". */
12const LIMIT = 100
13const FIELDS = 'number,title,url,author,labels,assignees,comments,updatedAt,closedByPullRequestsReferences'
14
15const list = atom({ plugin: 'issues', key: 'list' } as const, null)
16const selected = atom({ plugin: 'issues', key: 'selected' } as const, [])
17const adding = atom({ plugin: 'issues', key: 'adding' } as const, null)
18const reading = atom({ plugin: 'issues', key: 'reading' } as const, false)
19const cursor = atom({ plugin: 'issues', key: 'cursor' } as const, null)
20/** Whether a wide app pane flows the tiles into columns; the footer's switch flips it. */
21const columns = atom({ plugin: 'issues', key: 'columns' } as const, true)
22/** Bumped after each add, so the quick-add field is drawn afresh, empty. */
23let formKey = 0
24
25const ref = (i: Issue) => `#${i.number} (${i.url})`
26
27/** Refine: the session reads the issue, asks about whatever is unclear, then writes it up and labels it. */
28export const refinePrompt = (i: Issue) =>
29  [
30    `Refine GitHub issue ${ref(i)}: "${i.title}".`,
31    `Read it with \`gh issue view ${i.number} --comments\`, then ask me about anything ambiguous or underspecified, one round of questions at a time, until the scope is clear.`,
32    `Then rewrite the issue body with the clarified scope and acceptance criteria (\`gh issue edit ${i.number} --body-file <file>\`) and add the label \`${REFINED}\` (\`gh label create ${REFINED} --color 0e8a16\` first if the repository doesn't have it; \`gh issue edit ${i.number} --add-label ${REFINED}\`).`,
33  ].join('\n\n')
34
35/** Start: one subagent takes the issue; unrefined, the session asks first whether to refine it. */
36export const startPrompt = (i: Issue) => {
37  const work = `start on it with a subagent: brief one agent (a lane lead, if this setup has one) with the issue as an outcome, working in its own worktree and branch off the default branch, and report back when it's pushed.`
38  const refined = i.labels.some(l => l.name === REFINED)
39  return refined
40    ? `Start GitHub issue ${ref(i)}: "${i.title}". Read it with \`gh issue view ${i.number} --comments\`, then ${work}`
41    : `Start GitHub issue ${ref(i)}: "${i.title}". It isn't labelled \`${REFINED}\` yet: first ask me whether to refine it (clarify what's ambiguous, rewrite it, label it \`${REFINED}\`) or start it as it is. Then do what I pick, and ${work}`
42}
43
44/** Sprint: every selected issue at once, in parallel, integrated on one branch for approval. */
45export const sprintPrompt = (issues: Issue[], at: number) => {
46  const name = `sprint/${new Date(at).toISOString().slice(0, 10)}`
47  const unrefined = issues.filter(i => !i.labels.some(l => l.name === REFINED))
48  return [
49    `Run a sprint on these GitHub issues, in parallel:`,
50    issues.map(i => `- ${ref(i)}: "${i.title}"`).join('\n'),
51    unrefined.length
52      ? `Not labelled \`${REFINED}\` yet: ${unrefined.map(i => `#${i.number}`).join(', ')}. Before starting, ask me whether to refine those first (clarify, rewrite, label \`${REFINED}\`) or take them as they are.`
53      : null,
54    `Read each with \`gh issue view <n> --comments\`. Then plan them as lanes that own disjoint files, and start one subagent per issue at the same time, each in its own worktree and branch off the default branch, briefed with its issue as an outcome.`,
55    `Integrate as they land: keep one integration branch, \`${name}\`, that is the default branch merged with every issue branch, and run the checks there. Fixes go to the issue's own branch, then merge again.`,
56    `When every issue is in and the checks pass, push \`${name}\` and open one PR from it for my approval, listing each issue (with "Closes #<n>") and what was decided. Don't turn on auto-merge for it: I'll review and merge it myself.`,
57  ]
58    .filter(Boolean)
59    .join('\n\n')
60}
61
62/** Hands a request to the session as the person's own prompt; it runs as a turn once the session is free. */
63const ask = ($: EngineInterface, text: string, what: string) =>
64  void $.prompt.submit({ text, asUser: true }).catch(() => $.ui.toast(`${what}: could not send the request to the session`))
65
66/** Quick add: `gh issue create` with the title, then the list read again. */
67const addIssue = async ($: EngineInterface, title: string) => {
68  const clean = title.trim()
69  if (!clean || (await read($, adding))) return
70  await update($, adding, () => clean)
71  try {
72    const cwd = await $.session.cwd()
73    const run = await $.process.run(['gh', 'issue', 'create', '--title', clean, '--body', ''], { cwd, timeoutMs: 30_000 })
74    if (run.exitCode !== 0) await $.ui.toast(`New issue: ${ghNote(run.stderr)}`)
75  } catch {
76    await $.ui.toast('New issue: gh not installed')
77  } finally {
78    formKey++
79    await update($, adding, () => null)
80  }
81  await refresh($)
82}
83
84/** One issue from `gh issue list --json`, as plain data. */
85export const toIssue = (json: any): Issue => ({
86  number: Number(json.number),
87  title: String(json.title ?? ''),
88  url: String(json.url ?? ''),
89  author: String(json.author?.login ?? ''),
90  labels: (json.labels ?? []).map((l: any) => ({ name: String(l.name ?? ''), color: `#${String(l.color ?? '8e8e93')}` })),
91  assignees: (json.assignees ?? []).map((a: any) => String(a.login ?? '')),
92  comments: Array.isArray(json.comments) ? json.comments.length : Number(json.comments) || 0,
93  updatedAt: Date.parse(json.updatedAt) || 0,
94  // Filled in from `gh pr list` once every issue is read (the references carry only a number and URL).
95  prs: (json.closedByPullRequestsReferences ?? []).map((r: any) => ({
96    number: Number(r.number),
97    title: '',
98    url: String(r.url ?? ''),
99    state: '',
100    isDraft: false,
101    conflicting: false,
102    failing: 0,
103    pending: 0,
104  })),
105})
106
107const FAILED = new Set(['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE', 'ERROR'])
108
109/** How many of a PR's checks fail and how many still run, from gh's `statusCheckRollup`. */
110export const checksOf = (rollup: any[] | undefined) => {
111  let failing = 0
112  let pending = 0
113  for (const c of rollup ?? []) {
114    const verdict = String(c.conclusion ?? c.state ?? '')
115    if (FAILED.has(verdict)) failing++
116    else if ((c.status && c.status !== 'COMPLETED') || verdict === 'PENDING' || verdict === 'EXPECTED') pending++
117  }
118  return { failing, pending }
119}
120
121/** Fills each linked PR's title and state from one `gh pr list`, by number. */
122const withPrs = async (issues: Issue[], gh: (args: string[]) => Promise<{ exitCode: number; stdout: string }>) => {
123  if (!issues.some(i => i.prs.length)) return issues
124  const run = await gh(['pr', 'list', '--state', 'all', '--limit', '100', '--json', 'number,title,url,state,isDraft,mergeable,statusCheckRollup']).catch(() => null)
125  if (!run || run.exitCode !== 0) return issues
126  let byNumber: Map<number, any>
127  try {
128    byNumber = new Map((JSON.parse(run.stdout) as any[]).map(p => [Number(p.number), p]))
129  } catch {
130    return issues
131  }
132  return issues.map(i => ({
133    ...i,
134    prs: i.prs.map(pr => {
135      const p = byNumber.get(pr.number)
136      // A PR in another repository isn't in this list: it keeps its number and URL alone.
137      if (!p || p.url !== pr.url) return pr
138      return { ...pr, title: String(p.title ?? ''), state: String(p.state ?? ''), isDraft: Boolean(p.isDraft), conflicting: p.mergeable === 'CONFLICTING', ...checksOf(p.statusCheckRollup) }
139    }),
140  }))
141}
142
143/** `owner/name` from an issue's URL. */
144export const repoOf = (url: string) => /github\.com\/([^/]+\/[^/]+)\//.exec(url)?.[1] ?? ''
145
146/** A one-line reason from gh's stderr. */
147export const ghNote = (stderr: string): string => {
148  if (/auth login|not logged|authenticat/i.test(stderr)) return 'gh not authenticated (run gh auth login)'
149  if (/no git remote|none of the git remotes|not a git/i.test(stderr)) return 'No GitHub repository for this session'
150  if (/disabled issues|has issues disabled/i.test(stderr)) return 'Issues are turned off for this repository'
151  return (stderr.trim().split('\n')[0] || 'gh failed').slice(0, 120)
152}
153
154/** The session's repository's open issues, newest change first. */
155const loadIssues = async ($: EngineInterface): Promise<IssueList> => {
156  // The engine's clock, as the render hook's: the figures' age reads against the same time.
157  const at = await $.clock.now()
158  const cwd = await $.session.cwd()
159  const gh = (args: string[]) => $.process.run(['gh', ...args], { cwd, timeoutMs: 20_000 })
160  let run
161  try {
162    run = await gh(['issue', 'list', '--state', 'open', '--limit', String(LIMIT + 1), '--json', FIELDS])
163  } catch {
164    return { repo: '', issues: [], more: false, at, note: 'gh not installed' }
165  }
166  if (run.exitCode !== 0) return { repo: '', issues: [], more: false, at, note: ghNote(run.stderr) }
167  let issues: Issue[]
168  try {
169    issues = (JSON.parse(run.stdout) as any[]).map(toIssue).sort((a, b) => b.updatedAt - a.updatedAt)
170  } catch {
171    return { repo: '', issues: [], more: false, at, note: 'Unreadable gh output' }
172  }
173  // With no issue to read it from, the repository's name takes a call of its own.
174  let repo = repoOf(issues[0]?.url ?? '')
175  if (!repo) {
176    const view = await gh(['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']).catch(() => null)
177    repo = view?.exitCode === 0 ? view.stdout.trim() : ''
178  }
179  return { repo, issues: await withPrs(issues.slice(0, LIMIT), gh), more: issues.length > LIMIT, at }
180}
181
182// Nothing runs while the pane isn't shown: a timer reads gh again while it is,
183// every READ_EVERY, or within AT_MOST once something marked the list stale.
184const READ_EVERY = 120_000
185const AT_MOST = 20_000
186let stale = true
187let lastRead = 0
188let busy = false
189let shownAt = 0
190let shown = false
191
192/** Whether the issues pane is the one shown and placed; asked at most every 2 s. */
193const isShown = async ($: EngineInterface): Promise<boolean> => {
194  if (Date.now() - shownAt < 2_000) return shown
195  shownAt = Date.now()
196  try {
197    shown = (await $.ui.panes()).some(p => p.id === PANE && p.isShown && p.isPlaced)
198  } catch {
199    shown = false
200  }
201  return shown
202}
203
204const refresh = async ($: EngineInterface) => {
205  if (busy) return
206  busy = true
207  try {
208    stale = false
209    lastRead = Date.now()
210    // The header says a read is under way (and how old the figures it keeps are).
211    await update($, reading, () => true)
212    const fresh = await loadIssues($)
213    const old = await read($, list)
214    // A failed read keeps the last-good issues, marked with why; only the first read's failure is a note.
215    const next = fresh.note && old && !old.note ? { ...old, failed: fresh.note } : fresh
216    await update($, list, () => next)
217  } finally {
218    busy = false
219    await update($, reading, () => false).catch(() => undefined)
220  }
221}
222
223const tick = async ($: EngineInterface) => {
224  if (!(await isShown($))) return
225  if (Date.now() - lastRead >= (stale ? AT_MOST : READ_EVERY)) await refresh($)
226}
227
228let worker: { cancel: () => void } | undefined
229
230export const register: Register = on => {
231  on('session.start', async ($, e, next) => {
232    await $.command.register({ name: 'issues', description: "Open a pane with the open GitHub issues of this session's repository" })
233    worker?.cancel()
234    worker = $.clock.every(5_000, () => void tick($).catch(() => undefined))
235    void $.ui.open({ id: PANE, title: 'Issues' })
236
237    return next(e)
238  })
239
240  on('command.run', { command: 'issues' }, async $ => {
241    // Opened by the person: read now rather than on the next tick. The pane is
242    // open (drawing "Reading issues…") while gh runs; awaited, so the command
243    // settles with the list in state.
244    shownAt = 0
245    await $.ui.open({ id: PANE, title: 'Issues' })
246    await refresh($).catch(() => undefined)
247
248    return { text: 'Issues pane opened.' }
249  })
250
251  // A gh issue command (opening, closing, commenting) changes the list: read it again soon.
252  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
253    const ran = await next(e)
254    if (/\bgh issue\b/.test(e.command)) stale = true
255
256    return ran
257    // Only watching: should it fail, the call goes on as if it weren't here.
258  }).catch(($, e, next) => next(e))
259
260  // The terminal's cursor is the issue whose row holds the focus ring: the
261  // arrows and Tab walk the rows' marks, and the row under the ring is drawn selected.
262  on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
263    const result = await next(e)
264    const n = cursorOf(e.element)
265    if (n !== null) await update($, cursor, () => n).catch(() => undefined)
266    return result
267  })
268
269  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
270    const data = await read($, list)
271    // The engine's clock, so ages read the same time a test holds still.
272    const at = await $.clock.now()
273    if (e.surface === 'terminal') {
274      const shown = { reading: await read($, reading), cursor: await read($, cursor) }
275      // Buttons keep their press handles across redraws, so a click made just before one still lands.
276      return renderTerminal(holdButtons($.ui.resolve(e), e) as TermElements, data, e.props.bodyColumns, at, {
277        focused: e.props.isFocused,
278        ...shown,
279        start: issue => ask($, startPrompt(issue), 'Start'),
280      })
281    }
282    const picked = await read($, selected)
283    const form = await read($, adding)
284    const columnsOn = await read($, columns)
285    const pick = (n: number) => update($, selected, s => (s.includes(n) ? s.filter(x => x !== n) : [...s, n]))
286    return renderApp(holdButtons($.ui.resolve(e), e) as AppElements, data, e.props.bodyColumns, at, {
287      add: title => void addIssue($, title).catch(() => undefined),
288      refine: issue => ask($, refinePrompt(issue), 'Refine'),
289      start: issue => ask($, startPrompt(issue), 'Start'),
290      toggle: issue => void pick(issue.number).catch(() => undefined),
291      clear: () => void update($, selected, () => []).catch(() => undefined),
292      sprint: () =>
293        void (async () => {
294          const chosen = (data?.issues ?? []).filter(i => picked.includes(i.number))
295          if (!chosen.length) return
296          ask($, sprintPrompt(chosen, Date.now()), 'Sprint')
297          await update($, selected, () => [])
298        })().catch(() => undefined),
299      selected: picked,
300      adding: form,
301      formKey,
302      flow: { on: columnsOn, toggle: () => void update($, columns, was => !was).catch(() => undefined) },
303    })
304  })
305}
306
kit/ui.tsx 2021 lines
1import 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 418 lines
1import type { Elements, RenderElement } from 'claude-code'
2
3import { ago } from '../kit/format'
4import { C, GRAY, MUTED, treePrefix } from '../kit/tokens'
5import { BLANK, EDGE, SPACE, kit } from '../kit/ui'
6import type { FlowSwitch, RightPart } from '../kit/ui'
7
8import type { Issue, IssueList, LinkedPr } from '../types'
9
10/** The app surfaces' elements; `Input` only where the surface draws fields (not mobile). */
11export type AppElements = Pick<Elements['desktop'], 'Box' | 'Text' | 'Link' | 'Button' | 'Svg'> & Partial<Pick<Elements['desktop'], 'Input'>>
12export type TermElements = Pick<Elements['terminal'], 'Box' | 'Text'> & Partial<Pick<Elements['terminal'], 'Button'>>
13
14/** Open issues are green, as GitHub draws them. */
15const OPEN = C.ok
16
17/** The header's figures: how many are open, and how many of those changed in the last day. */
18const counts = (list: IssueList, at: number) => {
19  const fresh = list.issues.filter(i => at - i.updatedAt < 86_400_000).length
20  return { open: `${list.issues.length}${list.more ? '+' : ''}`, fresh }
21}
22
23/** The label an issue gets once refined; Start asks to refine first without it. */
24export const REFINED = 'refined'
25
26/** What the pane's controls do; the hooks module runs them with its `$`. */
27export type Actions = {
28  /** Opens an issue with this title. */
29  add: (title: string) => void
30  refine: (issue: Issue) => void
31  start: (issue: Issue) => void
32  /** Adds an issue to the sprint selection, or takes it out. */
33  toggle: (issue: Issue) => void
34  clear: () => void
35  /** Hands every selected issue to the session as one sprint. */
36  sprint: () => void
37  /** The issues selected for a sprint, by number. */
38  selected: number[]
39  /** The title being added, while gh runs. */
40  adding: string | null
41  /** Changes after each add, so the field comes back empty. */
42  formKey: number
43  /** The column view's switch: on, a wide pane flows the tiles into columns. */
44  flow?: FlowSwitch
45}
46
47/**
48 * The app surfaces: the repository as a plain line, a field to add an issue,
49 * then a tile per issue.
50 *
51 *   [issue icon]  Title of the issue…           (bug) (refined)  #12
52 *   [pr icon] #31 Title of a linked PR                    1 failing
53 *   by someone                 [💬] 3  2d   Refine  Start  Select
54 */
55export const renderApp = (els: AppElements, list: IssueList | null, columns: number, at: number, act: Actions): RenderElement => {
56  const { Box, Text, Link, Button, Input } = els
57  const k = kit(els, 'desktop', columns, { flow: act.flow })
58  if (!list) return <Text dimColor>Reading issues…</Text>
59  const { open, fresh } = counts(list, at)
60
61  const row = (key: string, l: (RenderElement | null)[], r: (RenderElement | null)[]) => (
62    <Box key={key} flexDirection="row" justifyContent="space-between" gap={SPACE.item} width="100%">
63      <Box flexDirection="row" alignItems="center" gap={SPACE.pair} flexShrink={1} minWidth={0} overflow="hidden">
64        {l}
65      </Box>
66      <Box flexDirection="row" alignItems="center" gap={SPACE.item} flexShrink={0}>
67        {r}
68      </Box>
69    </Box>
70  )
71
72  // Every line of a tile starts with the same icon column, as a section header's does,
73  // so the title and each linked PR start their text at one x.
74  const lead = (key: string, icon: RenderElement | null) => (
75    <Box key={key} width={2} flexShrink={0} flexDirection="row" alignItems="center">
76      {icon}
77    </Box>
78  )
79
80  /** A link that reads by its blue alone, no underline: its text handed to the Link as a child. */
81  const link = (key: string, href: string, text: string) => (
82    <Link key={key} href={href} label={text}>
83      <Text color={C.blue}>{text}</Text>
84    </Link>
85  )
86
87  /**
88   * A secondary action: dim at rest (`dimColor` on the Button), as the desktop draws
89   * a Button's label alone and never a child Text, so a grey child never showed there.
90   */
91  const quiet = (key: string, label: string, onPress: () => void) => k.action({ key, label, onPress, quiet: true })
92
93  /**
94   * An action whose word is coloured: the word as a coloured Text and over it a Button
95   * labelled with as many Braille blanks (drawn as nothing), since the desktop draws a
96   * Button's label uncoloured and never its children. A press on the word is the Button's.
97   */
98  const tinted = (key: string, label: string, color: string, onPress: () => void) => (
99    <Box key={`${key}-tint`} position="relative" flexShrink={0} flexDirection="row">
100      <Text color={color}>{label}</Text>
101      <Box position="absolute" top={0} left={0}>
102        <Button key={key} plain label={BLANK.repeat([...label].length)} onPress={onPress} />
103      </Box>
104    </Box>
105  )
106
107  const picked = (issue: Issue) => act.selected.includes(issue.number)
108  // In columns when the pane is wide: the columns space the tiles, so none keeps a margin of its own.
109  const flowing = list.issues.length > 0 && k.flowColumns(list.issues.length).count > 1
110  const tile = (issue: Issue, i: number) => {
111    const refined = issue.labels.some(l => l.name === REFINED)
112    return (
113      <Box key={`issue-${issue.number}`} width="100%" marginTop={i === 0 || flowing ? 0 : SPACE.block}>
114        {k.tile(`tile-${issue.number}`, [
115          // The title line, then a little room before the rest of the tile.
116          <Box key="l1" width="100%" marginBottom={SPACE.block / 2}>
117            {row(
118              'title',
119              [
120                lead('i', k.icon('issue-opened', OPEN)),
121                <Text key="t" wrap="truncate">
122                  {issue.title}
123                </Text>,
124              ],
125              // Its labels as GitHub pills, then its number; the title gives way to them.
126              [...issue.labels.map(l => k.pill(`label-${l.name}`, l.name, l.color)), link('n', issue.url, `#${issue.number}`)],
127            )}
128          </Box>,
129          // Each PR linked to close it, as the overview's workspaces draw one: its state in the icon's glyph and colour.
130          ...issue.prs.map(pr =>
131            row(
132              `pr-${pr.number}`,
133              [
134                lead('i', k.icon(prIcon(pr), prColor(pr))),
135                link('n', pr.url, `#${pr.number}`),
136                pr.title ? (
137                  <Text key="t" color={GRAY.light} wrap="truncate">
138                    {pr.title}
139                  </Text>
140                ) : null,
141              ],
142              [prNote(Text, pr)],
143            ),
144          ),
145          // The footer, with the same room above it as the title has below, when PRs sit between.
146          <Box key="l3" width="100%" marginTop={issue.prs.length ? SPACE.block / 2 : 0}>
147          {row(
148            'footer',
149            [
150              // The byline sits flush left, under the icons rather than the titles.
151              <Text key="by" color={GRAY.mid} wrap="truncate">
152                {issue.assignees.length ? `assigned to ${issue.assignees.join(', ')}` : `by ${issue.author}`}
153              </Text>,
154            ],
155            [
156              issue.comments ? (
157                <Box key="c" flexDirection="row" alignItems="center" gap={SPACE.pair}>
158                  {k.icon('comment', GRAY.dark)}
159                  <Text color={GRAY.mid}>{String(issue.comments)}</Text>
160                </Box>
161              ) : null,
162              <Text key="age" color={GRAY.dark}>
163                {ago(at - issue.updatedAt)}
164              </Text>,
165              // Refine until it's refined; Start always (it asks to refine first when it isn't).
166              refined ? null : quiet(`refine-${issue.number}`, 'Refine', () => act.refine(issue)),
167              <Button key={`start-${issue.number}`} plain label="Start" onPress={() => act.start(issue)} />,
168              // Picks it for a sprint: the word turns blue and the tile takes the selected fill.
169              picked(issue)
170                ? tinted(`pick-${issue.number}`, 'Selected', C.blue, () => act.toggle(issue))
171                : quiet(`pick-${issue.number}`, 'Select', () => act.toggle(issue)),
172            ],
173          )}
174          </Box>,
175        ], { selected: picked(issue) })}
176      </Box>
177    )
178  }
179
180  // Quick add: a title and Enter opens the issue; the field comes back empty.
181  const form = Input ? (
182    <Box key="add" width="100%" marginBottom={SPACE.block}>
183      {act.adding ? (
184        <Text dimColor wrap="truncate">{`Adding “${act.adding}”…`}</Text>
185      ) : (
186        <Input key={`new-issue-${act.formKey}`} placeholder="New issue title" submitLabel="add" onSubmit={(title: string) => act.add(title)} />
187      )}
188    </Box>
189  ) : null
190
191  // Once anything is selected: how many, Clear, and Sprint, which hands them all over at once.
192  const chosen = list.issues.filter(picked).length
193  const bar =
194    chosen ? (
195      <Box key="sprint" width="100%" marginBottom={SPACE.block}>
196        {row(
197          'sprint-row',
198          [<Text key="n">{`${chosen} selected`}</Text>],
199          [quiet('clear', 'Clear', () => act.clear()), <Button key="go" plain label={`Sprint (${chosen})`} onPress={() => act.sprint()} />],
200        )}
201      </Box>
202    ) : null
203
204  const body: (RenderElement | null)[] = list.note
205    ? [<Text key="note" dimColor>{list.note}</Text>]
206    : [form, bar, ...(list.issues.length ? k.flow('issues', list.issues.map(tile), { gap: SPACE.block }) : [<Text key="none" dimColor>No open issues.</Text>])]
207
208  // One repository, so no foldable header: its name as a plain line, the counts on the right.
209  const head = row('repo', [<Text key="r" color={GRAY.mid} wrap="truncate">{list.repo || 'Issues'}</Text>], [list.note ? null : k.summary([[open, 'open', OPEN], fresh ? [String(fresh), 'today'] : null])])
210  // The column view's switch, on a pane wide enough for columns: a footer band across the pane, under the body's edges.
211  const footer = k.footer()
212  const page = (
213    <Box key="page" flexDirection="column" paddingLeft={EDGE.left} paddingRight={EDGE.right} marginTop={SPACE.block}>
214      <Box key="head" width="100%" marginBottom={SPACE.block}>
215        {head}
216      </Box>
217      {body}
218    </Box>
219  )
220  return footer ? (
221    <Box flexDirection="column">
222      {page}
223      {footer}
224    </Box>
225  ) : (
226    page
227  )
228}
229
230/** A linked PR's icon: GitHub's for its state; unread (another repository), the open one. */
231const prIcon = (pr: LinkedPr) => (pr.state === 'MERGED' ? 'pr-merged' : pr.state === 'CLOSED' ? 'pr-closed' : pr.isDraft ? 'pr-draft' : 'pr-open')
232
233/** A linked PR's colour, as the overview's workspaces colour one: merged purple, closed or draft grey; open green, red on conflicts, amber while CI fails. */
234const prColor = (pr: LinkedPr) =>
235  pr.state === 'MERGED' ? C.purple : pr.state === 'CLOSED' || pr.isDraft || !pr.state ? MUTED : pr.conflicting ? C.bad : pr.failing ? C.warn : C.ok
236
237/** What an open PR's checks say, on the right of its line, in the surface's ink for the colour. */
238const prNote = (Text: AppElements['Text'], pr: LinkedPr, ink: (color: string) => object = color => ({ color })): RenderElement | null => {
239  if (pr.state !== 'OPEN') return null
240  if (pr.conflicting) return <Text key="ci" {...ink(C.bad)}>conflicts</Text>
241  if (pr.failing) return <Text key="ci" {...ink(C.warn)}>{`${pr.failing} failing`}</Text>
242  if (pr.pending) return <Text key="ci" {...ink(C.blue)}>{`${pr.pending} pending`}</Text>
243  return null
244}
245
246/** Cells an issue's title keeps before its labels give way, on the terminal (half the body when that's more). */
247const TITLE_MIN = 16
248
249/** How the terminal pane stands and what its rows do; the hooks module fills it. */
250export type TermState = {
251  /** Whether the pane has the keyboard (`e.props.isFocused`): key hints show, the cursor is drawn at full strength. */
252  focused?: boolean
253  /** Whether gh is being read right now. */
254  reading?: boolean
255  /** The issue the cursor is on, by number. */
256  cursor?: number | null
257  /**
258   * Enter on a row's mark: hands the issue to the session to start. Given, each
259   * row leads with a focusable mark the arrows walk, and the list draws a cursor.
260   */
261  start?: (issue: Issue) => void
262}
263
264/** The key of the focusable mark that leads an issue's row on the terminal. */
265const markKey = (n: number) => `mark-${n}`
266
267/** The issue a focused element's key names (a row's mark), or null. */
268export const cursorOf = (key: string | undefined): number | null => {
269  const m = /^mark-(\d+)$/.exec(key ?? '')
270  return m ? Number(m[1]) : null
271}
272
273/** What the terminal's keys do while the pane has them: the most used first. */
274const HINTS = [
275  { key: '↑↓', label: 'move' },
276  { key: 'enter', label: 'start' },
277]
278
279/**
280 * The terminal: one section, the repository's name as its title and the open
281 * count after it; an item per issue, its first label as a pill where it fits
282 * (`+1` for the rest), its number and age on the right; each linked PR on a dim
283 * tree row under it, its checks in their status colour. An issue with PRs is a
284 * block: a blank row parts it from its neighbours. With `start`, each row leads
285 * with a focusable mark: the row under the focus is the cursor's (`▌` in the
286 * gutter), Enter starts it, and the keys show under the list, lined up with
287 * the rows' marks.
288 *
289 *   awesome-claude  5 open ─────────────────────── ▾
290 *     ◉ Workspaces: show which checks…  ■ overview  #21  3d
291 *
292 *    ▌◉ Snapshot tests of what…            ■ tests  #24  2h
293 *     └─⇡ #31  Snapshot tests for…           1 failing
294 *
295 *     ↑↓ move · enter start
296 *
297 * Each fact once: the ages are on the rows, so the header doesn't count the
298 * day's changes; every issue here is open, so its mark is dim rather than green.
299 * A read under way shows the figures' age (`↻ 2m`); a failed one an amber `▲`
300 * and the age, the last-good issues kept. Only a first read that fails, with
301 * nothing to keep, is an error note.
302 */
303export const renderTerminal = (els: TermElements, list: IssueList | null, columns: number, at: number, st: TermState = {}): RenderElement => {
304  const { Box, Text, Button } = els
305  const k = kit(els, 'terminal', columns, { focused: st.focused })
306  const pane = (title: string, right: RenderElement | null, body: (RenderElement | null)[], loading = false) =>
307    k.section({ id: 'issues', title, right: right ?? undefined, loading, children: body })
308
309  // The repository's name without its owner: the owner rarely tells, and the full
310  // `owner/name` ran into the figures in a 40-column pane.
311  const title = list?.repo ? list.repo.split('/').pop() || list.repo : 'Issues'
312  if (!list) return pane('Issues', null, [k.note('loading', 'Reading issues…', 'loading')], true)
313  if (list.note) return pane(title, null, [k.note('error', list.note, 'error')], Boolean(st.reading))
314
315  const { open } = counts(list, at)
316  // Rows take the keyboard when Enter has something to do: every row then says whether it's the cursor's.
317  const keys = Boolean(st.start && Button)
318  const chosen = (n: number) => (keys ? st.cursor === n : undefined)
319  const gutter = keys ? 1 : 0
320  const ages = list.issues.map(i => ago(at - i.updatedAt))
321  // Numbers and ages right-aligned in columns of their own, so they line up row to row.
322  const ageWidth = Math.max(0, ...ages.map(a => a.length))
323  const numberWidth = Math.max(0, ...list.issues.map(i => `#${i.number}`.length))
324  const figure = (key: string, text: string, width: number) => (
325    <Box key={key} width={width} flexShrink={0} flexDirection="row" justifyContent="flex-end">
326      <Text dimColor>{text}</Text>
327    </Box>
328  )
329
330  // The number's column has a cell more on its left, so a label's name and the number sit two apart.
331  const numberCells = numberWidth + 1
332
333  const issueRow = (issue: Issue, age: string, i: number) => {
334    // The lead's two cells, the gap before the right cluster, the number and the age.
335    const keep = Math.max(TITLE_MIN, Math.floor(k.width / 2))
336    const used = gutter + 2 + k.space.item + numberCells + k.space.item + ageWidth + Math.min([...issue.title].length, keep)
337    // One label, GitHub's first, while it fits beside the title's minimum (its swatch, a space, its
338    // name, the gap before it); the rest say only how many they are. Should the row still run short,
339    // the count goes first, then the label, then the age, then the number.
340    const labels: RightPart[] = []
341    const first = issue.labels[0]
342    if (first) {
343      const cells = 2 + [...first.name].length
344      if (used + cells + k.space.item <= k.width) {
345        labels.push({ part: k.pill(`label-${first.name}`, first.name, first.color), drop: 3, cells })
346        const rest = issue.labels.length - 1
347        const more = `+${rest}`
348        if (rest > 0 && used + cells + 2 * k.space.item + more.length <= k.width) labels.push({ part: more, drop: 4 })
349      }
350    }
351    // Each linked PR on a dim row: what it is reads from its icon and its checks' status colour.
352    const prs = issue.prs.map((pr, j) =>
353      k.item(`pr-${issue.number}-${pr.number}`, {
354        tree: treePrefix([], j === issue.prs.length - 1),
355        lead: prIcon(pr),
356        leadColor: prColor(pr),
357        text: `#${pr.number}`,
358        dim: true,
359        note: pr.title || undefined,
360        right: [prNote(Text, pr, k.ink)],
361        // In the gutter's column too, so the tree lines up under its issue.
362        selected: keys ? false : undefined,
363      }),
364    )
365    // The open-issue mark, dim: every issue here is open. With keys, a Button the focus ring lands on,
366    // Enter on it starting the issue.
367    const lead =
368      keys && Button ? (
369        <Button key={markKey(issue.number)} plain label={`#${issue.number}`} onPress={() => st.start!(issue)}>
370          {k.icon('issue-opened', MUTED)}
371        </Button>
372      ) : (
373        'issue-opened'
374      )
375    // An issue with PRs under it is a block: one blank row parts it from the issue before.
376    const prev = list.issues[i - 1]
377    const apart = prev && (prev.prs.length > 0 || issue.prs.length > 0)
378    return (
379      <Box key={`issue-${issue.number}`} flexDirection="column" marginTop={apart ? k.space.block : 0}>
380        {k.item(`row-${issue.number}`, {
381          lead,
382          leadColor: MUTED,
383          text: issue.title,
384          right: [
385            ...labels,
386            { part: figure('n', `#${issue.number}`, numberCells), drop: 1, cells: numberCells },
387            { part: figure('age', age, ageWidth), drop: 2, cells: ageWidth },
388          ],
389          selected: chosen(issue.number),
390        })}
391        {prs}
392      </Box>
393    )
394  }
395
396  // Last-good figures say how old they are while a read is under way, or once one failed.
397  const age = st.reading || list.failed ? ago(Math.max(0, at - list.at)) : undefined
398  // The key hints, a block of their own under the list, starting in the rows' mark column (past the
399  // cursor's gutter) rather than a cell left of them.
400  const hints = keys ? k.hints('hints', HINTS, { width: k.width - gutter }) : null
401  return pane(
402    title,
403    // The open count, bold as the figure that matters, in the default foreground: an issue being
404    // open is no status here. The ages on the rows say what changed today, so the header doesn't.
405    k.summary([[open, 'open']], { loading: st.reading, age, failed: Boolean(list.failed) }),
406    list.issues.length
407      ? [
408          ...list.issues.map((issue, i) => issueRow(issue, ages[i]!, i)),
409          hints ? (
410            <Box key="keys" marginTop={k.space.block} paddingLeft={gutter}>
411              {hints}
412            </Box>
413          ) : null,
414        ]
415      : [k.note('empty', 'No open issues.')],
416  )
417}
418
kit/format.ts 95 lines
1const 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
95
kit/icons.ts 202 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
202
kit/perf.ts 101 lines
1/**
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`)
101
kit/tokens.ts 318 lines
1import 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}`
318
kit/skin.ts 39 lines
1/**
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}
39
kit/skins.ts 261 lines
1// 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'
261
types/index.d.ts 66 lines
1/** A label as GitHub colours it. */
2export type Label = { name: string; color: string }
3
4/** A pull request linked to close an issue ("Closes #n"), with its state. */
5export type LinkedPr = {
6  number: number
7  title: string
8  url: string
9  /** `OPEN`, `MERGED` or `CLOSED`; empty when it lives in another repository and wasn't read. */
10  state: string
11  isDraft: boolean
12  /** GitHub can't merge it for conflicts with its base. */
13  conflicting: boolean
14  /** Checks failing, and still running. */
15  failing: number
16  pending: number
17}
18
19/** One open issue, slimmed to what the pane draws. */
20export type Issue = {
21  number: number
22  title: string
23  url: string
24  author: string
25  labels: Label[]
26  assignees: string[]
27  comments: number
28  /** When it last changed (ms since epoch). */
29  updatedAt: number
30  /** The pull requests linked to close it. */
31  prs: LinkedPr[]
32}
33
34/** The last `gh issue list`: the repository's open issues, newest change first. */
35export type IssueList = {
36  /** `owner/name`; empty when gh couldn't tell. */
37  repo: string
38  issues: Issue[]
39  /** Whether there are more open issues than were read. */
40  more: boolean
41  /** When it was last read well (ms since epoch). */
42  at: number
43  /** Why there's nothing to show, when gh failed on the first read. */
44  note?: string
45  /** Why the last refresh failed, when these are the last-good issues kept through it. */
46  failed?: string
47}
48
49declare module 'claude-code' {
50  interface PluginState {
51    issues: {
52      list: IssueList | null
53      /** Issues selected for a sprint, by number. */
54      selected: number[]
55      /** The title of the issue being added, while gh runs. */
56      adding: string | null
57      /** Whether gh is being read right now. */
58      reading: boolean
59      /** The issue the terminal's cursor is on (the one whose row holds the focus), by number. */
60      cursor: number | null
61      /** Whether a wide app pane flows the tiles into columns (the footer's switch); on until flipped. */
62      columns: boolean
63    }
64  }
65}
66