A pane with the failing jobs of the branch's latest CI run, from the ci-failures tool, refreshed on demand.

A small collection of reusable SKILL.md instructions for coding agents. The skills are plain Markdown, version-controlled, and portable across compatible agent tools.
This README is the reference: what the skills are, which platforms they support, and how to install them. The [wiki][wiki] answers the other question — why the project is built this way: why a Markdown repository contains Rust crates, why the dev shell compiles bash 3.2 from source, which design choices were made and what they cost, how to verify any of it yourself, and what does not work yet.
[wiki]: https://github.com/tschallacka/ai-skills/wiki
Pick whichever fits how you work. Any of these opens the same interactive installer, where you choose the skills and the agent destination(s).
npx — installs and runs in one step, nothing left on PATH afterward:
npx --yes --package @tschallacka/ai-skills ai-skills-install
Linux / macOS — the one-command installer, no npm required:
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh
Windows — the identical command, run inside Git Bash or WSL2 (both provide the POSIX sh it needs; there is no separate PowerShell/cmd installer):
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh
See "One-command installer" below for the full list of install destinations, and "npm installation" for a persistent ai-skills-install command on PATH instead of npx.
| Skill | Purpose | Documentation |
|---|---|---|
| Planning | Durable, resumable plans: goals, ordered steps, verification, progress trackers, handoff notes — plus a single-file HTML overview of any plan, and a live served version that updates in place. Steps complete with git-diff atomicity evidence, not decorative checkboxes. | docs |
| Bug report | A defect register in one JSON file: every entry carries its reproduction, observed vs expected, the mechanism, and the verification that fails without the fix. bugs add/bugs update write through shared validation; a closure without proof is refused. | docs |
| Todo | A work queue that outlives the conversation, in one JSON file: nested tasks, every closed item carries its evidence. todo add/todo update keep the register sound; read recipes print user-ready output. | docs |
| Brainstorm | Shapes an under-specified idea into a recorded, agreed picture (brainstorm.md) before planning, with an adversarial completion pass and a plan-vs-implement gate. | docs |
| Post-implementation review | After-the-fact review of built code with concrete proposed fixes, in three passes: implementer self-analysis, an independent solutions agent, and a critical-feedback agent that ranks every fix. | docs |
| Project-specific deviations | Records confirmed project behavior and environment quirks in per-project notes that future agents load instead of re-debugging. | docs |
| Resource-limited testing | Runs heavyweight commands (suites, builds, analyzers, browsers) under platform-appropriate CPU/memory caps, with honest degradation when a platform has no cap mechanism. | docs |
| Chat | RFC-1459 IRC-over-TLS message bus for agents: a rust server a standard TLS IRC client can join, a rust client with UDP discovery and TOFU cert pinning, channels, and additive history/delta reads. | docs |
| Interactive shell | Operates a full-screen terminal program an agent has never seen - nano, mc, lynx, a pager, a menu: a rust PTY wrapper publishing each screen change as one JSONL event, compact row views and deltas to keep context small, element discovery, and a unix-socket client for keys, combos, pastes, mouse and resize. POSIX only. | docs |
| Git worktrees | Parallel agents in one repository: isolated worktree verification, per-agent trees, and merging back in a conflict-aware order without trampling the main checkout. | docs |
| Git merge resolving | Conflicts resolved by what each side changed rather than by ours/theirs: reading intent from history, unions that look like choices, regenerated output, and attributing post-merge failures to the side that caused them. | docs |
| Merge request etiquette | Descriptions a reviewer can act on, in the author's voice: a one-paragraph TLDR, the defect/cause/change body, derived from the branch's commits, and the one case where a collapsible section earns its place. | docs |
| Text etiquette | Shorthand and a clipped register for an agent's prose - chat, dev talk, and its own thinking: facts first, a shared shorthand with an ask-don't-guess rule, praise capped at gj, and the people-please prose banned. Plain english on request. | docs |
| Question etiquette | Numbered questions, lettered options, never a bullet: a reply like Q7b is unambiguous, a partial answer names exactly which numbers are still open, and a lettered list always ends with "none of these, I'll say it myself." | docs |
| AI text editor | Server-owned agent editor tabs with explicit search, revision-aware edits, undo/redo, raw-byte and hex modes, SQLite metadata, and Unix/TCP transport. | docs |
| www | A brake the human can pull, and one the agent pulls on itself when it is thrashing: stop, answer what do we have / what are the values / what are we trying to achieve, in order, then continue with one reasoned step or a numbered question. | docs |
| CI failures | What actually failed in a CI run or pipeline, from a run/pipeline id, a PR/MR number, or a branch: GitHub and GitLab detected from the git remote, named rather than chosen silently, with just the failing lines extracted per job. | docs |
| Decisions | A register of non-blocking questions raised mid-work: Q# ids, lettered options, priority and the branch they came from as context, so a question can be stubbed and left open without blocking a turn. | docs |
| rjq | Parses, filters and searches JSON with the shipped rjq binary, a jq-compatible tool, on machines without jq. | docs |
| Tailpipe | A less/tail for agents: pipe a command's output into a named, server-held stream with chat-style message ids; a reader lists/reads/searches/tails it from anywhere, an MCP adapter offers the same as typed tools, and a board mod shows it to a human. Idle streams are gzip-snapshotted after 15 minutes. | docs |
Use a skill only when its frontmatter trigger matches the task or when the user explicitly requests it. Each skill documents when not to activate.
"Portable" elsewhere in this repository means portable across agent tools (Claude Code, Codex, OpenCode, OpenClaw, Cline). Operating-system support is separate and stated here:
Those tools do not identify the calling agent the same way, which matters to every per-agent feature here. src/agent-session-key/HARNESS-IDENTITY.md records what each one provides, how it was measured, and the procedure for contributing a harness that is not yet listed.
| Supported | |
|---|---|
| Linux | any distribution, bash 4 or 5, GNU userland |
| macOS | 11+ with the stock /bin/bash 3.2, BSD userland; Homebrew bash not required |
| Windows | Git for Windows' bash with its bundled coreutils, checked by CI legs, or WSL2, which is a Linux install. The evidence and the conventions are in .agents/MAINTAINER.md 1.16, which is in a full git checkout and not part of the installed package |
The one-command install needs a POSIX sh (bootstrap.sh has no bash-only constructs), plus curl, tar, awk, and standard coreutils; the installed skills' own helper scripts need bash, POSIX coreutils, awk, sed, grep, and git. The planning skill additionally needs rjq, and on macOS resource-limited-testing needs memlimit; the installer checks for both up front and prints a per-platform install hint rather than failing partway through. Those two are the only extra runtime dependencies any skill has — in particular python3 is not required by anything that gets installed, only by this repository's own benchmark harness. CODE-STYLE.md is the contract these scripts are held to; what CI proves on each platform is mapped in .agents/MAINTAINER.md section 3 (a full git checkout has both; the installed package has neither).
One skill is genuinely OS-scoped: resource-limited-testing enforces a hard RAM cap only on Linux, via a transient systemd --user cgroup v2 scope. On macOS it uses memlimit (MIT, by Jelle Besseling), which refuses allocations past the cap instead of killing the process — a best-effort cap on real resident memory over the process tree, not a cgroup-equivalent guarantee; SKILL.md lists what it does and does not promise. On Apple Silicon macOS the installer declares memlimit a soft requirement: without it the skill still installs and the run warns that the RAM cap is not enforced. The wrapper then degrades to nice plus cpulimit (CPU throttling only); on a Linux session without a user systemd instance it falls back to ulimit -v. memlimit is not asked for on Intel Macs at all — it does not support them. Every other skill behaves identically on both.
Run this command and choose the skills and agent destination interactively:
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh | sh
The installer can install all the skills or one skill, and supports these global skill roots:
| Destination | Agent or standard |
|---|---|
~/.agents/skills | Universal Agent Skills root; recommended shared destination |
~/.codex/skills | Codex CLI |
~/.claude/skills | Claude Code |
~/.config/opencode/skills | OpenCode |
~/.openclaw/skills | OpenClaw managed skills |
~/.cline/skills | Cline |
The universal root is also discovered by OpenCode and OpenClaw. Installing the same skill into multiple roots can create duplicate definitions or precedence conflicts, so choose only the roots you need.
The interactive installer checks which supported agents are present and omits roots for agents it cannot detect. Custom roots are saved in ~/.config/tsch-ai-skills/custom-locations and are offered again when they still exist.
Install the package globally to expose the installer command. The npm package keeps the skills in this repository and links ai-skills-install directly to installer/bootstrap.sh, which fetches the matching compiled installer release for your platform on first run:
npm install -g @tschallacka/ai-skills
ai-skills-install
For a one-off run without a global install:
npx --yes --package @tschallacka/ai-skills ai-skills-install
The npm package does not install skills automatically as an npm lifecycle side-effect; run the installer command when you are ready to choose a target.
Run the installer again against the same root. It compares every managed file with the repository copy and reports one of three outcomes per destination: Up to date (nothing differed), Installed (files were written), or Skipped (you declined, or the destination needs manual review).
Each installed skill contains a .version marker identifying its tag, branch, and commit. If an installed file differs from the repository version, the installer asks before replacing it. For a managed version transition that the user approves — the .version marker differs, so the change came from a new release rather than from you — old files are replaced without backups; the previous version can be restored by running the installer against its tag with AI_SKILLS_REF. Unmanaged changes still receive <file>.bak backups (.bak.1, .bak.2, … if one already exists). A symlinked skill is skipped for manual review rather than following the link and modifying an unexpected location.
Interactively, choose that one skill at the menu. Headless, name it:
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
| sh -s -- install --skill planning --target "$HOME/.codex/skills"
--skill may be given more than once, and each value may itself be a comma-separated list, so --skill planning --skill brainstorm and --skill planning,brainstorm install the same two. Repeats are collapsed, menu numbers work (--skill 1,4), and all selects everything wherever it appears.
Use --all, --skill, --target and --yes when the choices are already known. --target takes a single root, so installing into two roots is two runs.
# Install all skills into the shared Agent Skills root
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
| sh -s -- install --all --target "$HOME/.agents/skills"
# Unattended replacement: managed version transitions replace without backups,
# unmanaged changed files are still backed up as <file>.bak
curl -fsSL https://raw.githubusercontent.com/tschallacka/ai-skills/master/installer/bootstrap.sh \
| sh -s -- install --all --target "$HOME/.agents/skills" --yes
Every run ends with a summary block on stdout saying what was installed, what was not, and why; the progress and diagnostics go to stderr, so installer install … > summary.txt keeps the outcome and 2>/dev/null keeps it readable. A blocked skill is reported once — not once per root — with the commands that finish the job. This is what an --all run on a machine without rjq prints:
== Summary ==
Installed: /home/u/.agents/skills/project-specifics
Installed: /home/u/.agents/skills/resource-limited-testing
Installed: /home/u/.agents/skills/brainstorm
Installed: /home/u/.agents/skills/post-implementation-review
Skipped: planning — a hard requirement is missing, nothing was written
To install planning once its requirements are met:
1. install rjq:
sudo apt-get install -y rjq
2. replay this run:
installer install --skill planning --target /home/u/.agents/skills --yes
The install step is chosen for the detected platform and package manager, and the replay line carries the same target and flags as the run that printed it, naming the installer binary bootstrap.sh downloaded to run it. The exit status is non-zero, because four of five skills is a partial install and CI must not read it as success.
Dependencies are declared per skill, so one unsatisfiable dependency never stops the other skills from installing. Each skill ships a requires.tsv naming what it needs, on which platform and architecture, and how badly:
| Strength | Effect |
|---|---|
hard | The skill does not work without the tool. It is not installed, the run explains why and prints the replay commands, and the exit status is non-zero. |
soft | The skill works in a degraded form. It is installed, with a warning naming the tool and the capability that is lost. The exit status is unaffected. |
Currently:
planning requires rjq (hard, every platform) — without it validate-plan.sh refuses to run and the plan gates stop firing.resource-limited-testing names memlimit (soft, Apple Silicon macOS only) — see Supported platforms above for what the degraded path still does.No other skill has a runtime dependency.
| Code | Meaning |
|---|---|
| 0 | Everything requested was installed. Soft warnings do not change this. |
| 1 | A requested skill was blocked by a hard requirement, or any other error. |
| 2 | install-skill only: approval declined, nothing was written. |
| 3 | install-skill only: an unsafe collision (an existing file that is not a managed version upgrade, or a symlink). |
Codes 2 and 3 belong to the machine-facing install-skill subcommand that the planning skill's own tooling uses; the interactive, install --all, and install --skill paths only ever return 0 or 1.
Running the bare one-liner with no arguments, or installer interactive directly, opens a full-screen skill picker instead of the numbered menu:
| Key | Action |
|---|---|
↑/k, ↓/j, PageUp, PageDown, Home, End | move the cursor |
Enter / Space | toggle the skill under the cursor |
Tab / Shift-Tab | switch focus between the skill list and the info pane |
a / n | select all / select none |
d, r, m (info pane focused) | show dependency hints, re-verify requirements, cycle a skill's integration mode |
i | confirm and install the current selection |
q / Escape | quit without installing |
With neither --target nor --agent given, it also prompts to choose an auto-detected agent root, a saved custom directory, a new custom directory, or a for every listed root.
Review the installer before running it if you do not trust the source. Skills are instructions that may guide agents to run commands or access files.
install.sh retired in favor of a compiled Rust installer (src/installer/); installer/bootstrap.sh is the small, pure-POSIX-sh entry point (#!/usr/bin/env sh, no bash-only constructs) that detects the platform, downloads the matching release, and hands off to it — see CONTRIBUTING.md before editing either.
installer/bootstrap.sh always downloads a release archive, so it is not how a checkout installs its own local files. Build the installer and point it at the checkout with --source instead:
cargo build --release -p installer
./target/release/installer interactive --source .
--package dev (also read from $PACKAGE_SELECTION) ships the MODE: DEV files too — tests, maintainer docs — instead of filtering them out, for installing a working development copy rather than the prod set.
installer/bootstrap.sh itself accepts AI_SKILLS_REPO_URL (a different owner/repo to resolve GitHub's "latest release" redirect against) and AI_SKILLS_RELEASE_URL (an exact archive URL, bypassing that redirect entirely — how RELEASE.md verifies one specific tag).
benchmark/planning/runtime/ makes the worker/reviewer/analyzer launch, session-id extraction, and token telemetry pluggable per CLI: the active agent defaults to codex and is selected with BENCHMARK_AGENT (opencode, claude), with a shared launcher (lib-agent.sh) owning all setsid/timeout/process-group control. See benchmark/planning/runtime/README.md for the contract and first-time setup.resource-limited-testing's platform behaviour is described under Supported platforms; its SKILL.md documents each fallback in detail.Distributed under the MIT License.
hooks/register.tsx 496 lines1import type { Register } from 'claude-code'
2import { update } from 'claude-code'
3
4import { LOADING_FRAMES, LOADING_FRAME_MS, loadingArt, pickSlogan } from './loading'
5
6// The failing jobs of the branch's latest CI run, as the ci-failures tool reports
7// them, or the list of the repo's recent runs: a run picked from the list shows its
8// own report. The tool talks to the network, so it runs when the pane opens, when
9// the person presses [refresh] or a run, not on a timer. The one timer is the job
10// count of a listed run still going, re-read every 30 seconds so its bar moves. The last report, the
11// browsing state, the run list and the picked run are kept in $.state so a redraw
12// keeps them. Toggled by `enabled` in settings.json pluginConfigs["ci-board"].options.
13
14const PANE = 'ci-board'
15const TOOL = 'show_ci_board'
16const READ = 'read_ci_board'
17const report = { plugin: 'ci-board', key: 'report' } as const
18const browsing = { plugin: 'ci-board', key: 'browsing' } as const
19const runs = { plugin: 'ci-board', key: 'runs' } as const
20const picked = { plugin: 'ci-board', key: 'run' } as const
21const narrow = { plugin: 'ci-board', key: 'narrow' } as const
22const busy = { plugin: 'ci-board', key: 'busy' } as const
23const progress = { plugin: 'ci-board', key: 'progress' } as const
24
25// The loading screen's animation frame and slogan, which the timer moves while a fetch runs.
26let frame = 0
27let line = ''
28let ticker: { cancel: () => void } | undefined
29
30// The run list's filter buttons: the label and the test a run must pass to be listed.
31const FILTERS: { label: string; tone: string; keeps: (conclusion: string) => boolean }[] = [
32 { label: 'success', tone: 'green', keeps: conclusion => conclusion === 'success' },
33 { label: 'failed', tone: 'red', keeps: conclusion => conclusion === 'failure' || conclusion === 'timed_out' },
34 { label: 'cancelled', tone: 'gray', keeps: conclusion => conclusion === 'cancelled' },
35 { label: 'in progress', tone: 'yellow', keeps: conclusion => !['success', 'failure', 'timed_out', 'cancelled', 'skipped', 'neutral', 'action_required', 'stale'].includes(conclusion) },
36]
37
38// A run in the browse list: its id, what it was for, and how it ended.
39type RunRow = { id: string; title: string; branch: string; conclusion: string; when: string; age: string; took: string; live: boolean; jobs?: JobCounts }
40
41// How many of a run's jobs have finished, out of how many there are.
42type JobCounts = { done: number; total: number }
43
44// The jobs of a run gh has listed, as finished and total. An unreadable answer is none.
45function jobCountsOf(stdout: string): JobCounts | undefined {
46 try {
47 const jobs = (JSON.parse(stdout).jobs ?? []) as { status: string }[]
48 return { done: jobs.filter(job => job.status === 'completed').length, total: jobs.length }
49 } catch {
50 return undefined
51 }
52}
53
54// A run's jobs as they stand now, from the jobs API: gh run view refuses a run still going.
55async function jobCountsFor($: Parameters<typeof update>[0], id: string): Promise<JobCounts | undefined> {
56 const listed = await $.process.run(['gh', 'api', `repos/{owner}/{repo}/actions/runs/${id}/jobs?per_page=100`])
57 return listed.exitCode === 0 ? jobCountsOf(listed.stdout) : undefined
58}
59
60// A bar of the jobs finished so far, as "[########------------] 8 of 20 jobs".
61function jobBar({ done, total }: JobCounts, width = 20): string {
62 const filled = total === 0 ? 0 : Math.round((width * done) / total)
63 return `[${'#'.repeat(filled)}${'-'.repeat(width - filled)}] ${done} of ${total} jobs`
64}
65
66// The job counts of runs still going are re-read this often while the list is open,
67// so their bars move without a press. The timer stops once no listed run is still going.
68const JOB_REFRESH_MS = 30_000
69let jobRefresh: { cancel: () => void } | undefined
70
71// Re-reads the jobs of each listed run still going, and of the open run when it is one,
72// then redraws. Quiet, with no loading screen.
73async function refreshLiveJobs($: Parameters<typeof update>[0]): Promise<void> {
74 const { value: listed } = await $.state.get(runs)
75 const current = (listed ?? []) as RunRow[]
76 const next: RunRow[] = []
77 for (const row of current) {
78 next.push(row.live ? { ...row, jobs: (await jobCountsFor($, row.id)) ?? row.jobs } : row)
79 }
80 await update($, runs, () => next)
81 const { value: open } = await $.state.get(picked)
82 const liveOpen = next.find(row => row.live && open && row.id === open.id)
83 if (liveOpen) await update($, picked, () => ({ ...open, jobs: liveOpen.jobs }))
84 if (!next.some(row => row.live)) {
85 jobRefresh?.cancel()
86 jobRefresh = undefined
87 }
88}
89
90// Starts the 30-second re-read of live runs, unless it is already running.
91function followLiveRuns($: Parameters<typeof update>[0]): void {
92 if (jobRefresh) return
93 jobRefresh = $.clock.every(JOB_REFRESH_MS, () => {
94 void refreshLiveJobs($)
95 })
96}
97
98// How long a run took, from when it started to when it finished, as "4m 12s".
99function tookOf(startedAt: string, finishedAt: string): string {
100 const seconds = Math.round((Date.parse(finishedAt) - Date.parse(startedAt)) / 1000)
101 if (!Number.isFinite(seconds) || seconds < 0) return 'unknown'
102 if (seconds < 60) return `${seconds}s`
103 const minutes = Math.floor(seconds / 60)
104 if (minutes < 60) return `${minutes}m ${seconds % 60}s`
105 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
106}
107
108// How long ago a run started, in the largest whole unit, as "1 hour ago". The clock is
109// read from the system (`date`) when the list is fetched, not from the plugin runtime.
110function ago(createdAt: string, nowMs: number): string {
111 const seconds = Math.max(0, Math.round((nowMs - Date.parse(createdAt)) / 1000))
112 const units: [number, string][] = [
113 [31536000, 'year'],
114 [2592000, 'month'],
115 [604800, 'week'],
116 [86400, 'day'],
117 [3600, 'hour'],
118 [60, 'minute'],
119 ]
120 for (const [size, name] of units) {
121 if (seconds >= size) {
122 const count = Math.floor(seconds / size)
123 return `${count} ${name}${count === 1 ? '' : 's'} ago`
124 }
125 }
126 return 'just now'
127}
128
129// What the tool said: its report on standard output, or its message on standard
130// error when it has no report (no runs for the branch, for example).
131function reportOf(run: { exitCode: number; stdout: string; stderr: string }): string {
132 const out = run.stdout.trim()
133 if (out !== '') return out
134 const err = run.stderr.trim()
135 return err !== '' ? err : `ci-failures exited ${run.exitCode} with no output.`
136}
137
138// The tool's own binary, in the shared bin the installer puts it in.
139function toolPath(home: string, xdg: string): string {
140 const bin = xdg ? `${xdg}/tsch-ai-skills/bin` : `${home}/.config/tsch-ai-skills/bin`
141 return `${bin}/ci-failures`
142}
143
144// The repo's recent runs, read into the pane's state, every run gh reports for
145// the first `limit` (gh's own default is 20). Quiet, with no loading screen, so
146// opening the board shows them at once, runs in progress included. "load more"
147// asks again with a larger limit.
148async function loadRunList($: Parameters<typeof update>[0], limit = 20): Promise<void> {
149 const clock = await $.process.run(['date', '+%s'])
150 const nowMs = Number(clock.stdout.trim()) * 1000
151 const result = await $.process.run(['gh', 'run', 'list', '--limit', String(limit), '--json', 'databaseId,displayTitle,headBranch,conclusion,status,createdAt,startedAt,updatedAt'])
152 const rows = result.exitCode === 0 && Number.isFinite(nowMs) ? runsOf(result.stdout, nowMs) : []
153 // A run still going gets its job count, one jobs-API call each, so its bar shows.
154 for (const row of rows) {
155 if (row.live) row.jobs = await jobCountsFor($, row.id)
156 }
157 await update($, runs, () => rows)
158 if (rows.some(row => row.live)) followLiveRuns($)
159 await update($, picked, () => null)
160 await update($, browsing, () => true)
161}
162
163// The repo's recent runs, from gh's JSON; an unreadable answer is no runs.
164function runsOf(stdout: string, nowMs: number): RunRow[] {
165 try {
166 const rows = JSON.parse(stdout) as {
167 databaseId: number
168 displayTitle: string
169 headBranch: string
170 conclusion: string
171 status: string
172 createdAt: string
173 startedAt: string
174 updatedAt: string
175 }[]
176 return rows.map(row => ({
177 id: String(row.databaseId),
178 title: row.displayTitle,
179 branch: row.headBranch,
180 conclusion: row.conclusion || row.status,
181 when: row.createdAt.slice(0, 16).replace('T', ' '),
182 age: ago(row.createdAt, nowMs),
183 took: tookOf(row.startedAt, row.updatedAt),
184 live: row.status !== 'completed',
185 }))
186 } catch {
187 return []
188 }
189}
190
191export const register: Register = (on, options) => {
192 if (options.enabled === false) return
193
194 on('session.start', async ($, e, next) => {
195 await $.command.register({
196 name: 'ci-board',
197 description: 'Show the failing jobs of the latest CI run in a pane; `/ci-board close` hides it',
198 })
199 await $.tool.register({
200 name: TOOL,
201 description:
202 'Show the failing CI jobs of the current branch to the person, in a pane, and return the report as text for the chat.',
203 inputSchema: { type: 'object', properties: {} },
204 })
205 await $.tool.register({
206 name: READ,
207 description: 'Read the CI report the person has in the CI board, as text, without running the check or opening the pane.',
208 inputSchema: { type: 'object', properties: {} },
209 })
210 return next(e)
211 })
212
213 on('command.run', { command: 'ci-board' }, async ($, e) => {
214 if (e.args.trim().toLowerCase() === 'close') {
215 await $.ui.close({ id: PANE })
216 return { text: 'CI board closed.' }
217 }
218 await $.ui.open({ id: PANE, title: 'CI failures' })
219 await loadRunList($)
220 return { text: 'CI board opened.' }
221 })
222
223 on('tool.call', { tool: `mcp__ci-board__${TOOL}` }, async $ => {
224 const home = (await $.env.get('HOME')) ?? ''
225 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
226 const run = await $.process.run([toolPath(home, xdg)])
227 const text = reportOf(run)
228 await update($, report, () => text)
229 await $.ui.open({ id: PANE, title: 'CI failures' })
230
231 return { result: text }
232 })
233
234 // The agent's read of the report the pane holds. It does not run the check and
235 // does not open the pane.
236 on('tool.call', { tool: `mcp__ci-board__${READ}` }, async $ => {
237 const { value: last } = await $.state.get(report)
238 const { value: run } = await $.state.get(picked)
239 const { value: isBrowsing } = await $.state.get(browsing)
240 const live = await $.ui.selection().catch(() => undefined)
241 const shown = last ?? 'No CI report yet. The board has not been refreshed.'
242 const lines = [
243 run && isBrowsing
244 ? `Run in the board: ${run.id} (${run.title})`
245 : isBrowsing
246 ? 'Run in the board: none picked (the run list is shown)'
247 : 'Run in the board: none (the branch report is shown)',
248 ]
249 if (run && isBrowsing) lines.push(run.text)
250 else lines.push(shown)
251 lines.push(`Highlighted in the board: ${live?.text ?? 'none'}`)
252 return { result: lines.join('\n') }
253 })
254
255 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
256 const { Box, Button, Code, Link, Text } = $.ui.resolve(e)
257 const { value: last } = await $.state.get(report)
258 const { value: isBrowsing } = await $.state.get(browsing)
259 const { value: list } = await $.state.get(runs)
260 const { value: run } = await $.state.get(picked)
261 const { value: filterLabel } = await $.state.get(narrow)
262 const active = FILTERS.find(item => item.label === filterLabel)
263 const shown = (list ?? []).filter(row => (active ? active.keeps(row.conclusion) : true))
264 const lines = (last ?? 'Not checked yet. Press [refresh] to read the latest CI run.').split('\n')
265
266 // Shows the loading screen while the steps run, one after another, with a progress bar
267 // that fills as each step finishes, to 100% when the last one is done.
268 const busyWhile = async (label: string, steps: (() => Promise<void>)[]) => {
269 frame = 0
270 line = pickSlogan()
271 ticker?.cancel()
272 ticker = $.clock.every(LOADING_FRAME_MS, () => {
273 frame += 1
274 $.ui.invalidate('ui.render')
275 })
276 await update($, busy, () => label)
277 await update($, progress, () => ({ done: 0, total: steps.length }))
278 try {
279 for (const [index, step] of steps.entries()) {
280 await step()
281 await update($, progress, () => ({ done: index + 1, total: steps.length }))
282 }
283 } finally {
284 ticker?.cancel()
285 ticker = undefined
286 await update($, busy, () => null)
287 await update($, progress, () => null)
288 }
289 }
290
291 // The run list: each row is a button; a press reads that run's own report.
292 const openRun = async (row: RunRow) => {
293 const home = (await $.env.get('HOME')) ?? ''
294 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
295 let text = ''
296 let url = ''
297 let jobs: JobCounts | undefined
298 await busyWhile(`run ${row.id}`, [
299 async () => {
300 text = reportOf(await $.process.run([toolPath(home, xdg), row.id]))
301 },
302 async () => {
303 // The run's page on GitHub, for the link in its details; empty when gh cannot say.
304 const page = await $.process.run(['gh', 'run', 'view', row.id, '--json', 'url'])
305 url = ''
306 if (page.exitCode === 0) {
307 try {
308 url = JSON.parse(page.stdout).url ?? ''
309 } catch {
310 url = ''
311 }
312 }
313 },
314 async () => {
315 // A run still going: its jobs as they stand now, so the bar is current.
316 if (row.live) jobs = await jobCountsFor($, row.id)
317 },
318 async () => {
319 await update($, picked, () => ({ id: row.id, title: row.title, text, url, took: row.took, conclusion: row.conclusion, jobs }))
320 },
321 ])
322 }
323 const browse = () => busyWhile('the run list', [async () => loadRunList($)])
324 // The next page of runs: as many again as are listed, plus the default page.
325 const loadMore = (listed: number) => busyWhile('the run list', [async () => loadRunList($, listed + 20)])
326
327 const { value: fetching } = await $.state.get(busy)
328 const { value: task } = await $.state.get(progress)
329 let body
330 if (fetching) {
331 const dots = '.'.repeat((frame % 3) + 1)
332 const art = loadingArt(frame % LOADING_FRAMES, 'LOADING', line)
333 const done = task?.done ?? 0
334 const total = Math.max(task?.total ?? 1, 1)
335 const percent = Math.round((100 * done) / total)
336 const width = 30
337 const filled = Math.round((width * done) / total)
338 body = (
339 <Box flexDirection="column">
340 <Text bold color="cyan">{`Fetching ${fetching}${dots}`}</Text>
341 <Text> </Text>
342 <Code source={art.join('\n')} />
343 <Text color="cyan">{`[${'#'.repeat(filled)}${'-'.repeat(width - filled)}] ${percent}%`}</Text>
344 <Text dimColor>{`step ${done} of ${total}`}</Text>
345 </Box>
346 )
347 } else if (isBrowsing && run) {
348 body = (
349 <Box flexDirection="column">
350 <Button variant="primary" onPress={() => update($, picked, () => null)}>
351 back to runs
352 </Button>
353 <Text bold>{`Run ${run.id}: ${run.title}`}</Text>
354 {run.url && (
355 <Box flexDirection="row">
356 <Button onPress={() => $.ui.copy({ text: run.url })}>copy link</Button>
357 <Text> </Text>
358 <Text dimColor>{run.url}</Text>
359 </Box>
360 )}
361 <Text>{`${run.conclusion === 'success' ? 'Passed' : run.conclusion} · took ${run.took ?? 'unknown'}`}</Text>
362 {run.live && <Text color="yellow">{run.jobs ? jobBar(run.jobs) : 'waiting for jobs'}</Text>}
363 {run.url ? (
364 <Button
365 onPress={async () => {
366 // The desktop's own opener, tried in turn: xdg-open, then gio (GNOME), then open (macOS).
367 for (const opener of [['xdg-open', run.url], ['gio', 'open', run.url], ['open', run.url]]) {
368 const opened = await $.process.run(opener).catch(() => ({ exitCode: 1 }))
369 if (opened.exitCode === 0) break
370 }
371 }}
372 >
373 view in browser
374 </Button>
375 ) : (
376 <Text dimColor>view in browser: GitHub did not give this run's link</Text>
377 )}
378 <Text> </Text>
379 {run.text.split('\n').map((line, index) => (
380 <Text key={index} color={/fail|red|✘/i.test(line) ? 'red' : undefined}>
381 {line}
382 </Text>
383 ))}
384 </Box>
385 )
386 } else if (isBrowsing) {
387 body = (
388 <Box flexDirection="column">
389 <Box flexDirection="row">
390 <Button variant="primary" onPress={() => update($, browsing, () => false)}>
391 back to report
392 </Button>
393 <Text> </Text>
394 <Button onPress={browse}>refresh runs</Button>
395 </Box>
396 <Text> </Text>
397 <Text bold underline color="cyan">
398 Filter
399 </Text>
400 <Box flexDirection="row">
401 {FILTERS.map(item => (
402 <Box key={item.label} flexDirection="row">
403 <Box minWidth={2}>
404 <Text color={item.tone}>●</Text>
405 </Box>
406 <Button
407 variant={active?.label === item.label ? 'primary' : undefined}
408 onPress={() => update($, narrow, () => (active?.label === item.label ? null : item.label))}
409 >
410 {item.label}
411 </Button>
412 {active?.label === item.label && <Text inverse bold>{' FILTER ON '}</Text>}
413 <Text> </Text>
414 </Box>
415 ))}
416 </Box>
417 <Text> </Text>
418 {(list ?? []).length === 0 && <Text dimColor>No runs listed. Check that gh is signed in for this repo.</Text>}
419 {shown.map(row => {
420 // Green for a success, red for a failure, grey for a cancel, yellow while it is still running.
421 const tone =
422 row.conclusion === 'success' ? 'green'
423 : ['failure', 'timed_out'].includes(row.conclusion) ? 'red'
424 : row.conclusion === 'cancelled' ? 'gray'
425 : 'yellow'
426 // A success needs no word: its colour says it. Anything else names its state.
427 const word = row.conclusion === 'success' ? '' : `${row.conclusion} `
428 return (
429 <Box key={row.id} flexDirection="column">
430 <Box flexDirection="row">
431 <Box minWidth={2}>
432 <Text color={tone}>●</Text>
433 </Box>
434 <Button onPress={() => openRun(row)}>{`${word}${row.title}`}</Button>
435 </Box>
436 <Text italic>{` ${row.when} · ${row.age ?? 'press refresh runs for its age'} · took ${row.took ?? 'unknown'} · on ${row.branch}`}</Text>
437 {row.live && <Text color="yellow">{` ${row.jobs ? jobBar(row.jobs) : 'waiting for jobs'}`}</Text>}
438 </Box>
439 )
440 })}
441 {(list ?? []).length >= 20 && (
442 <Box flexDirection="row">
443 <Button onPress={() => loadMore((list ?? []).length)}>load more</Button>
444 </Box>
445 )}
446 </Box>
447 )
448 } else {
449 body = (
450 <Box flexDirection="column">
451 <Box flexDirection="row">
452 <Button
453 variant="primary"
454 onPress={() =>
455 busyWhile('the branch report', [
456 async () => {
457 const home = (await $.env.get('HOME')) ?? ''
458 const xdg = (await $.env.get('XDG_CONFIG_HOME')) ?? ''
459 const result = await $.process.run([toolPath(home, xdg)])
460 await update($, report, () => reportOf(result))
461 },
462 ])
463 }
464 >
465 refresh
466 </Button>
467 <Text> </Text>
468 <Button onPress={browse}>browse runs</Button>
469 </Box>
470 <Text> </Text>
471 {lines.map((line, index) => (
472 <Text key={index} color={/fail|red|✘/i.test(line) ? 'yellow' : undefined}>
473 {line}
474 </Text>
475 ))}
476 </Box>
477 )
478 }
479
480 return (
481 <Box flexDirection="column">
482 <Box flexDirection="row" justifyContent="space-between">
483 <Text bold inverse>
484 CI failures
485 </Text>
486 <Button role="dismiss" onPress={() => $.ui.close({ id: PANE })}>
487 Close
488 </Button>
489 </Box>
490 <Text> </Text>
491 {body}
492 </Box>
493 )
494 })
495}
496hooks/loading.tsx 281 lines1// The loading animation: a runner with a stack of papers in his arms, drawn on a small
2// canvas in front of a scrolling office. A sign on the wall above the desk shows a word
3// in big letters, with a slogan under it. The office enters from the right edge and
4// leaves on the left; the legs cycle with bent knees and their stride matches the floor's
5// speed; a sheet is thrown up from the stack every quarter of the loop and drifts behind.
6//
7// This file is the one source of the animation. Each mod that uses it gets a copy at its
8// own hooks/loading.tsx, made by mods/loading/sync.sh; edit it here, then run the script.
9// Use it from a mod with:
10// import { LOADING_FRAMES, LOADING_FRAME_MS, loadingArt, pickSlogan } from './loading'
11// and draw loadingArt(frame % LOADING_FRAMES, word, line).join('\n') in a Code element.
12
13export const LOADING_FRAME_MS = 70
14
15const CANVAS_W = 46
16const CANVAS_H = 17
17const FLOOR_ROW = CANVAS_H - 1
18const RUNNER_X = 14
19const OFFICE_HEIGHT = 16
20
21// The door is as tall as the canvas, so its top stands well above the runner's head.
22const DOOR = [
23 '____________',
24 ...Array.from({ length: 14 }, (_, i) => (i === 8 ? '| o |' : '| |')),
25 '|__________|',
26]
27// The sign and desk prop is one width whatever the word or slogan, so the loop does not change.
28const SIGN_WIDTH = 39
29// Big letters for the sign, five rows each, four columns wide. A letter not listed is blank.
30const LETTERS: Record<string, string[]> = {
31 L: ['# ', '# ', '# ', '# ', '####'],
32 O: ['####', '# #', '# #', '# #', '####'],
33 A: ['####', '# #', '####', '# #', '# #'],
34 D: ['### ', '# #', '# #', '# #', '### '],
35 I: ['####', ' ## ', ' ## ', ' ## ', '####'],
36 N: ['# #', '## #', '# ##', '# #', '# #'],
37 G: ['####', '# ', '# ##', '# #', '####'],
38}
39const BLANK_LETTER = [' ', ' ', ' ', ' ', ' ']
40
41const SLOGANS = [
42 'Hamsters are running as fast as they can',
43 'Reticulating splines',
44 'Asking the intern nicely',
45 'Consulting the office oracle',
46 'Filing the paperwork in triplicate',
47 'The coffee machine is thinking',
48 'Almost there, pinky promise',
49 'Do not feed the paper shredder',
50 'Unjamming the printer, again',
51 "It's not a bug, it's a feature",
52 'Please hold, your call is important',
53 'Productivity at 110 percent, loading',
54]
55// A slogan picked at random, for a caller that wants one each time the screen starts.
56export function pickSlogan(): string {
57 return SLOGANS[Math.floor(Math.random() * SLOGANS.length)] ?? SLOGANS[0]!
58}
59
60const centred = (text: string, width: number) => {
61 const cut = text.slice(0, width)
62 const left = Math.floor((width - cut.length) / 2)
63 return ' '.repeat(left) + cut + ' '.repeat(width - cut.length - left)
64}
65
66// The sign board on the wall, its word in big letters, the slogan under it, and the
67// desk beneath, all as one prop of OFFICE_HEIGHT rows.
68function signDesk(word: string, line: string): string[] {
69 const letters = word.toUpperCase().split('').map(ch => LETTERS[ch] ?? BLANK_LETTER)
70 const big = Array.from({ length: 5 }, (_, r) => letters.map(g => g[r]).join(' '))
71 const inner = SIGN_WIDTH - 2
72 const board = [
73 '+' + '-'.repeat(inner) + '+',
74 ...big.map(row => '|' + centred(row, inner) + '|'),
75 '+' + '-'.repeat(inner) + '+',
76 ]
77 const desk = [
78 centred('_'.repeat(27), SIGN_WIDTH),
79 centred('|' + '_'.repeat(25) + '|', SIGN_WIDTH),
80 centred('| |' + ' '.repeat(21) + '| |', SIGN_WIDTH),
81 centred('| |' + ' '.repeat(21) + '| |', SIGN_WIDTH),
82 ]
83 const rows = [...board, centred(line, SIGN_WIDTH), ...desk]
84 return [...Array.from({ length: OFFICE_HEIGHT - rows.length }, () => ' '.repeat(SIGN_WIDTH)), ...rows]
85}
86
87// The office props in order: the sign above the desk first, then a plant, a door, and a
88// set of drawers.
89function officeProps(word: string, line: string): string[][] {
90 return [
91 signDesk(word, line),
92 [' \\|/ ', ' \\|||/', ' \\|/ ', ' [_] '],
93 DOOR,
94 ['+-----+', '|=====|', '+-----+', '|=====|', '+-----+'],
95 ]
96}
97
98// The gap is chosen so the loop is a whole number of strides (192 = 8 x 24 frames).
99const OFFICE_GAP = 32
100// The office's length in columns. The animation is this plus the canvas width, so the
101// office enters from the right and leaves on the left with nothing on screen at the seam.
102const OFFICE_LOOP = officeProps('', '').reduce((sum, prop) => sum + prop[0]!.length + OFFICE_GAP, 0)
103
104// One text row per office row, the props side by side with their gaps between them. A
105// shorter prop is padded at the top so its feet sit on the floor.
106function officeRows(word: string, line: string): string[] {
107 const padded = officeProps(word, line).map(prop => [
108 ...Array.from({ length: OFFICE_HEIGHT - prop.length }, () => ' '.repeat(prop[0]!.length)),
109 ...prop,
110 ])
111 return Array.from({ length: OFFICE_HEIGHT }, (_, row) =>
112 padded.map(prop => prop[row]! + ' '.repeat(OFFICE_GAP)).join(''),
113 )
114}
115
116const RUNNER_TOP = [
117 ' ____ ',
118 ' /o o\\ ',
119 ' \\_..-/ ',
120 ' /| ',
121 ' /|[=====]',
122 ' < |[=====]',
123 ' \\|______|',
124]
125
126// The legs are two bones each, a thigh and a shin of equal length, solved from the hip
127// to a foot that moves in a loop: forward and lifted while it swings, back on the floor
128// while it pushes. The knee always bends forward. Units are rows; a column is half a
129// unit, because a character is about twice as tall as it is wide.
130const HIP = { u: 2.5, v: 7 }
131const FEET_ROW = 12
132const BONE = 3
133// One stride, both legs, takes GAIT_FRAMES frames. A foot moves 2 * STRIDE units (four
134// columns per unit of stride) across the floor while it pushes, half the stride. The
135// background moves one column a frame, so the stride is GAIT_FRAMES / 8 units: the floor
136// slides under the feet at the speed the feet travel.
137const GAIT_FRAMES = 24
138const STRIDE = GAIT_FRAMES / 8
139const LIFT = 2.5
140type Pt = { u: number; v: number }
141
142// The two legs at one phase of the gait, as [hip, knee, foot] for each leg. The legs are
143// half a cycle apart.
144function legsAt(phase: number): [Pt, Pt, Pt][] {
145 return [0, Math.PI].map(shift => {
146 const p = phase + shift
147 // The foot swings forward in the air (sin below zero) and pushes back on the floor.
148 const foot: Pt = { u: HIP.u + STRIDE * Math.cos(p), v: FEET_ROW - LIFT * Math.max(0, -Math.sin(p)) }
149 const dx = foot.u - HIP.u
150 const dy = foot.v - HIP.v
151 const d = Math.min(Math.hypot(dx, dy), BONE * 2 - 0.01)
152 const mid: Pt = { u: (HIP.u + foot.u) / 2, v: (HIP.v + foot.v) / 2 }
153 const h = Math.sqrt(BONE * BONE - (d / 2) * (d / 2))
154 const nu = -dy / Math.hypot(dx, dy)
155 const nv = dx / Math.hypot(dx, dy)
156 const a: Pt = { u: mid.u + nu * h, v: mid.v + nv * h }
157 const b: Pt = { u: mid.u - nu * h, v: mid.v - nv * h }
158 const knee = a.u > b.u ? a : b
159 return [HIP, knee, foot]
160 })
161}
162
163// The character for one bone's direction: a floor line, an upright, or a diagonal.
164function boneChar(from: Pt, to: Pt): string {
165 const dc = (to.u - from.u) * 2
166 const dr = to.v - from.v
167 if (Math.abs(dr) < 0.6) return '_'
168 if (Math.abs(dc) < 0.6 * Math.abs(dr)) return '|'
169 return dc * dr > 0 ? '\\' : '/'
170}
171
172// The cells a leg covers: each bone sampled along its length, as [row, column, char].
173// The knee is drawn as a '>' pointing forward, not as the upright of the bone above it.
174function legCells(top: number, phase: number): [number, number, string][] {
175 const cells: [number, number, string][] = []
176 for (const leg of legsAt(phase)) {
177 for (let bone = 0; bone < 2; bone++) {
178 const from = leg[bone]!
179 const to = leg[bone + 1]!
180 const steps = Math.ceil(Math.hypot((to.u - from.u) * 2, to.v - from.v) * 2) || 1
181 const ch = boneChar(from, to)
182 for (let i = bone === 0 ? 0 : 1; i <= steps; i++) {
183 const u = from.u + ((to.u - from.u) * i) / steps
184 const v = from.v + ((to.v - from.v) * i) / steps
185 const isKnee = bone === 0 && i === steps
186 cells.push([top + Math.round(v), RUNNER_X + Math.round(u * 2), isKnee ? '>' : ch])
187 }
188 }
189 }
190 return cells
191}
192
193// The sheet seen face-on, tilting, edge-on, and tilting back: a flip, one state a frame.
194const SHEET_STATES: string[][] = [
195 ['+---+', '|===|', '+---+'],
196 ['/---\\', '|===|', '\\---/'],
197 [' | ', ' | ', ' | '],
198 ['/---\\', '|...|', '\\---/'],
199]
200// A sheet flies for this many frames. It stays put in the air while the office scrolls
201// past under it, so it is left behind as the guy moves forward: one column back a frame,
202// rising, then sinking.
203const SHEET_FLIGHT = 40
204const SHEET_LOOP = OFFICE_LOOP + CANVAS_W + 2
205const SHEET_THROW_EVERY = SHEET_LOOP / 4
206const SHEET_START_ROW = 9
207const SHEET_START_COL = 26
208
209// Where the sheets in the air are on this frame, as [row, column, state]. A sheet rises
210// from the stack for the first part of its flight and sinks for the rest.
211function sheetsAt(index: number): [number, number, number][] {
212 const found: [number, number, number][] = []
213 for (let k = 0; k < 4; k++) {
214 const t = (((index - k * SHEET_THROW_EVERY) % SHEET_LOOP) + SHEET_LOOP) % SHEET_LOOP
215 if (t > SHEET_FLIGHT) continue
216 const row = Math.round(SHEET_START_ROW - 10 * Math.sin((Math.PI * t) / SHEET_FLIGHT) + t * 0.15)
217 const col = SHEET_START_COL - t
218 found.push([row, col, (index + k) % SHEET_STATES.length])
219 }
220 return found
221}
222
223// The number of frames before the animation repeats. Use it as the modulus for a frame count.
224export const LOADING_FRAMES = SHEET_LOOP
225
226// One animation frame as rows of text, ready to join with newlines for a Code element.
227// The word is set in big letters on the sign; the line is the slogan under it.
228export function loadingArt(index: number, word = 'LOADING', line = ''): string[] {
229 const canvas: string[][] = Array.from({ length: CANVAS_H }, () => Array(CANVAS_W).fill(' '))
230 const put = (row: number, col: number, text: string) => {
231 for (let i = 0; i < text.length; i++) {
232 const c = col + i
233 if (row >= 0 && row < CANVAS_H && c >= 0 && c < CANVAS_W && text[i] !== ' ') canvas[row]![c] = text[i]!
234 }
235 }
236 // A dotted ceiling on the top row, drawn first so the office covers it. It keeps the
237 // top row non-blank in every frame, so the Code block never shrinks when the door leaves.
238 for (let col = 0; col < CANVAS_W; col += 2) canvas[0]![col] = '.'
239 // The office: at frame 0 its first column is just past the right edge, and it moves one
240 // column left a frame.
241 officeRows(word, line).forEach((text, row) => {
242 for (let col = 0; col < CANVAS_W; col++) {
243 const at = col + index - CANVAS_W
244 if (at < 0 || at >= OFFICE_LOOP) continue
245 const ch = text[at]!
246 if (ch !== ' ') canvas[FLOOR_ROW - OFFICE_HEIGHT + row]![col] = ch
247 }
248 })
249 for (let col = 0; col < CANVAS_W; col++) canvas[FLOOR_ROW]![col] = '_'
250 // The runner, feet on the floor. Where his figure is, it marks the canvas, so a sheet
251 // passing behind him is hidden by him.
252 const top = FLOOR_ROW - 1 - FEET_ROW
253 const legs = legCells(top, ((index % GAIT_FRAMES) / GAIT_FRAMES) * 2 * Math.PI)
254 const solid = Array.from({ length: CANVAS_H }, () => Array(CANVAS_W).fill(false))
255 RUNNER_TOP.forEach((text, i) => {
256 for (let c = 0; c < text.length; c++) {
257 const row = top + i
258 const col = RUNNER_X + c
259 if (text[c] !== ' ' && row >= 0 && row < CANVAS_H && col >= 0 && col < CANVAS_W) solid[row]![col] = true
260 }
261 })
262 for (const [row, col] of legs) {
263 if (row >= 0 && row < CANVAS_H && col >= 0 && col < CANVAS_W) solid[row]![col] = true
264 }
265 // The sheets in the air, drawn behind the runner: a sheet cell is skipped where he is.
266 for (const [row, col, state] of sheetsAt(index)) {
267 SHEET_STATES[state]!.forEach((line, i) => {
268 for (let c = 0; c < line.length; c++) {
269 const r = row + i
270 const x = col + c
271 if (line[c] === ' ' || r < 0 || r >= CANVAS_H || x < 0 || x >= CANVAS_W || solid[r]![x]) continue
272 canvas[r]![x] = line[c]!
273 }
274 })
275 }
276 RUNNER_TOP.forEach((text, i) => put(top + i, RUNNER_X, text))
277 for (const [row, col, ch] of legs) put(row, col, ch)
278 // Every row keeps its full width, so no row shrinks and the block keeps its shape.
279 return canvas.map(row => row.join(''))
280}
281types/index.d.ts 15 lines1// The last CI report the board drew: the tool's text, or null before the first
2// refresh.
3declare module 'claude-code' {
4 interface PluginState {
5 'ci-board': {
6 report: string | null
7 browsing: boolean
8 runs: { id: string; title: string; branch: string; conclusion: string; when: string; age: string }[] | null
9 run: { id: string; title: string; text: string; url: string; took: string; conclusion: string } | null
10 narrow: string | null
11 busy: string | null
12 progress: { done: number; total: number } | null
13 } }
14}
15