SLOPSHOPPER

issues

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

newpaneguardcommandtimer
v0.3.5MITupdated 2026-10-14satoramoto/awesome-claude/mods/issues
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · issues
› fix the failing auth test and add an audit log call ⏺ 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
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 3 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
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
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