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

Mods for Claude Code. A mod is a Claude Code plugin that adds interface to Claude Code itself (a pane, a slash command) through the engine's hooks API. This repository holds our own mods, forks of others' (each kept under its own license, with a link back), and is a plugin marketplace that installs them.
The panes draw in the desktop app (as a side pane, with charts) and in the terminal (a simpler pane of text cells). Tested on Claude Code 2.1.296, the version CI pins.
| Mod | What it shows | Opens with | Needs |
|---|---|---|---|
overview | the current session at a glance: context, limits, activity, agents, the branches it started and their pull requests, cost | on its own when a session starts; /overview opens it again | git for Workspaces; gh signed in for pull requests |
issues | the open GitHub issues of the session's repository, newest change first, with actions on each | on its own when a session starts; /issues opens it again | a GitHub repository; gh signed in |
launcher | the project's dev servers from .claude/launch.json (the desktop app's preview list), each with its state, URL, Start, Stop and Restart, and its output | on its own when a session starts; /launcher (or /preview) opens it again; /launcher start <name>, /launcher stop <name> | a .claude/launch.json; lsof to tell which ports answer |
lab | for mod authors: 77 experiments probing what the engine draws (SVG gradients and filters, animation, hover, Client modules, every element), with the engine's verdict on each surface in mods/lab/verdicts.json | the index on its own when a session starts; /lab, /lab <id>, /lab gallery | nothing |
Copies of other people's mods, changed here to fit these. Each keeps its original license and author; its README opens with where it came from, the upstream commit it was taken at, and what this fork changed.
| Mod | What it does | Original | License |
|---|---|---|---|
filetree | an IDE-style file tree of the session's folder: git status and lines changed per file, and a shimmer on what Claude reads, writes and commits. /filetree opens it | data-goblin/claude-code-filetree, by Kurt Buhler | MIT |
blast-radius | holds a risky shell command (rm -rf, git reset --hard, git clean, force push, migrations) and shows what it would change, with Proceed and Cancel | anthropics/claude-code-playground, by Anthropic | Apache-2.0 |
overview's Workspaces section.gh auth login), for issues and for the pull requests in overview's Workspaces. Without it overview still draws everything else, and its Workspaces section shows a note in place of pull requests (gh not installed, or gh not authenticated (run gh auth login)); issues shows the same note in place of the list.bun install, then bun build.ts). Installing from the marketplace needs nothing else./overviewOpens by itself when a session starts; /overview brings it back after you close it. Its sections, each folding open and shut:
gh every 30 seconds while any is open.A footer shows the session's share of the machine's cpu, memory and network.
/issuesOpens by itself when a session starts; /issues brings it back. Lists the open issues of the session's repository (read with gh issue list).
gh issue create).gh:refined.refined asks first whether to refine it.sprint/<date> branch, then one PR for your approval with auto-merge off.These prompts encode an opinionated way of working (worktrees, a subagent per issue, an integration branch reviewed by you). Read mods/issues/hooks/register.tsx for their exact wording before you rely on them.
/launcherThe desktop app's preview launcher, in a pane that works in the terminal too. Opens by itself when a session starts; /launcher brings it back. It lists every configuration in the project's .claude/launch.json (the session's folder, or the nearest one above it up to the git root), one row each: its name, its state, its URL once the port answers, and Start, Stop and Restart.
http://localhost:<port>); running elsewhere (green: something this session didn't start answers on the port, such as the desktop app or another session; it can't be started twice); failed (red: it exited with an error, and its row unfolds to show why).runtimeExecutable runtimeArgs… in the configuration's cwd (the project root by default) with PORT set to its port and its env over that. With autoPort, a taken port moves it to the next free one./launcher start <name>, /launcher stop <name>, /launcher restart <name>, /launcher list. Each answers in text, so they work without the pane. /preview does the same.lsof (an HTTP request to each port where lsof isn't installed), every 2 s while the pane is shown, and not at all while it isn't.npm run serve starting a node server, ending the spawn loop ends the grandchild node too, so its port was free at once.The servers it starts are children of the mod: they end when the session ends, and when the mod reloads (/reload-plugins, or a rebuild with hot reloading on). Servers started elsewhere aren't touched; Stop is only for the ones this session started.
At the prompt of a Claude Code session in a terminal:
/plugin marketplace add satoramoto/awesome-claude
/plugin install overview@awesome-claude
/plugin install issues@awesome-claude
Pick a scope when asked (user scope loads the mod in every session). Each mod is active straight away. Or from a shell: claude plugin marketplace add satoramoto/awesome-claude, then claude plugin install overview@awesome-claude.
Run these from a terminal session; a mod installed at user scope then loads in the desktop app too.
An installed mod updates only when its version changes; every release of a mod raises it. To fetch the newest versions by hand, at the prompt:
/plugin marketplace update awesome-claude
/plugin update overview@awesome-claude
or from a shell: claude plugin marketplace update awesome-claude, then claude plugin update overview@awesome-claude (and the same for each mod you installed). The new version loads after a restart or /reload-plugins.
Auto-update is off by default for marketplaces other than Anthropic's. Turn it on in /plugin → Marketplaces → awesome-claude → Enable auto-update, or with "autoUpdate": true next to source in extraKnownMarketplaces in ~/.claude/settings.json:
"extraKnownMarketplaces": {
"awesome-claude": {
"source": { "source": "github", "repo": "satoramoto/awesome-claude" },
"autoUpdate": true
}
}
Loading from a clone (--plugin-dir or CLAUDE_CODE_PLUGIN_DIRS, below) gets no marketplace updates: git pull the clone instead. To pull at every session start, a SessionStart hook in ~/.claude/settings.json:
"hooks": {
"SessionStart": [
{ "hooks": [{ "type": "command", "command": "git -C ~/Code/satoramoto/awesome-claude pull -q --ff-only" }] }
]
}
The compiled mods are committed, so a clone runs as is. A folder of plugins passed to --plugin-dir loads each one in it:
git clone https://github.com/satoramoto/awesome-claude ~/Code/satoramoto/awesome-claude
claude --plugin-dir ~/Code/satoramoto/awesome-claude/plugins
git clone https://github.com/satoramoto/awesome-claude ~/Code/satoramoto/awesome-claude
cd ~/Code/satoramoto/awesome-claude
bun install
bun build.ts
Then point every session at the compiled folder, in ~/.claude/settings.json, with the full path to your clone's plugins folder:
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/Code/satoramoto/awesome-claude/plugins",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
With bun run watch running, a save under mods/ (or in zen's kit, with ZEN set) recompiles and the session reloads the mod. These two variables are what this setup uses on Claude Code 2.1.296; they may change in later versions. Don't also install the mods from the marketplace, or each loads twice.
AGENTS.md lists the commands and checks. The project's skills, which Claude Code loads when working in this repository, hold the how-to:
.claude/skills/mod-authoring/: writing a mod in this repository.claude/skills/storybook-prototyping/: designing how it looks in Storybook.claude/skills/headless-verification/: checking it works without a person watching| Folder | What it is |
|---|---|
mods/<name>/ | a mod's source: .claude-plugin/plugin.json, hooks/, types/. Imports the kit as @kit/… |
zen's src/harness/claude-code/kit/ | the kit, from the pinned @satoramoto/zen dev dependency: components (ui.tsx), colors and glyphs (tokens.ts), number formatting (format.ts), icons (icons.ts), timing a mod's own work (perf.ts), skins (skin.ts, skins.ts) |
zen.ts | where zen's kit and bench are: node_modules/@satoramoto/zen, or the zen checkout ZEN names |
plugins/<name>/ | the compiled mods: what Claude Code loads and the marketplace installs. Generated by bun build.ts; don't edit |
.claude-plugin/marketplace.json | the marketplace: each mod in plugins/, at its plugin.json version |
playground/ | a browser page that draws every mod live with sample data, on each surface, through zen's bench; playground/fixtures/ holds the sample data |
stories/ | Storybook stories: each mod's scenarios, the lab and the engine experiments |
Each mod is versioned on its own in its plugin.json. A new mod also gets an entry in .claude-plugin/marketplace.json; CI fails if a mod is missing there or its version differs from its plugin.json. Any change to a mod's compiled output in plugins/<mod>/ (a new zen pin that changes the kit included) raises its version, in plugin.json and the marketplace, since installs only update when it changes; CI fails a pull request that changes a mod's output without raising its version.
bun run playground
At http://localhost:5173 (set PORT for another). Every mod's ui.render hooks, drawn with the scenarios in playground/fixtures/<mod>.ts, side by side in desktop light, desktop dark and terminal, at any pane width. On each save it also compiles and runs claude plugin validate plugins/<mod>, and shows the result in the bar.
bun run storybook
At http://localhost:6006 (set PORT for another). Each mod scenario (stories/<mod>.stories.ts, from the playground's fixtures), drawn by zen's bench. The toolbar picks the surface (desktop or terminal), the theme, the frame and the pane width. The kit's own components and the skins have their stories in zen's Storybook, under Harness/Claude Code. Storybook is for how things look; the playground is for wiring up a whole mod with the engine's checks running.
The engine reads a mod's source as it is and refuses any import from outside the mod's folder (symlinks included). build.ts copies zen's kit into each compiled mod and rewrites @kit/… to relative paths; nothing else changes, so the output is still readable source and passes claude plugin validate.
What the desktop accepts, found by probing the engine's validator (first on 2.1.293; the mods are tested on 2.1.296): whole percentages only (25.5% is refused); flexGrow up to 10000; fractional heights and margins; no Box hover. Borders: round draws rounded corners, and single, double, bold and classic draw; dashed doesn't. SVG is drawn as an image the page scales, so it blurs as the pane resizes: draw with boxes and text.
What it paints (measured on the real pane, 2.1.295): a horizontal unit is a column (~7.8px), height a row (~19px), but vertical margin, padding and row gap half a row (~9.5px); a transparent border colour draws in the text's colour; a Button draws its label alone, never its children. zen's docs/harness-claude-code.md ("What the engine and the desktop accept") has the details, and Storybook draws by the same rules.
MIT, except the forked mods, which keep their own licenses: mods/filetree is MIT, copyright Kurt Buhler (LICENSE), and mods/blast-radius is Apache-2.0, copyright Anthropic PBC (LICENSE).
hooks/register.tsx 306 lines1import { 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}
306hooks/view.tsx 418 lines1import 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}
418types/index.d.ts 66 lines1/** 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