SLOPSHOPPER

ci-board

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

newpaneguardcommandtoolprocess
v0.1.0MITupdated 2026-10-06tschallacka/ai-skills/mods/ci-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ci-board
│ ┃ CI failures ✕ › fix the failing auth test and add an audit log call │ ┃ CI failures [ Close ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ back to report ] [ refresh runs ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Filter ⎿ Added 2 lines, removed 1 line │ ┃ ● [ success ] ● [ failed ] ● [ cancelled ] ● ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ No runs listed. Check that gh is signed in │ ┃ for this repo. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ci-board │ ⎿ ci-board: CI board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · CI failures
CI failures [ Close ] [ back to report ] [ refresh runs ] Filter ● [ success ] ● [ failed ] ● [ cancelled ] ● [ in progress ] No runs listed. Check that gh is signed in for this repo.
README

AI Skills

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

Install

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.

Skills

SkillPurposeDocumentation
PlanningDurable, 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 reportA 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
TodoA 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
BrainstormShapes 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 reviewAfter-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 deviationsRecords confirmed project behavior and environment quirks in per-project notes that future agents load instead of re-debugging.docs
Resource-limited testingRuns heavyweight commands (suites, builds, analyzers, browsers) under platform-appropriate CPU/memory caps, with honest degradation when a platform has no cap mechanism.docs
ChatRFC-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 shellOperates 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 worktreesParallel 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 resolvingConflicts 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 etiquetteDescriptions 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 etiquetteShorthand 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 etiquetteNumbered 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 editorServer-owned agent editor tabs with explicit search, revision-aware edits, undo/redo, raw-byte and hex modes, SQLite metadata, and Unix/TCP transport.docs
wwwA 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 failuresWhat 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
DecisionsA 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
rjqParses, filters and searches JSON with the shipped rjq binary, a jq-compatible tool, on machines without jq.docs
TailpipeA 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.

Supported platforms

"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
Linuxany distribution, bash 4 or 5, GNU userland
macOS11+ with the stock /bin/bash 3.2, BSD userland; Homebrew bash not required
WindowsGit 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.

One-command installer

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:

DestinationAgent or standard
~/.agents/skillsUniversal Agent Skills root; recommended shared destination
~/.codex/skillsCodex CLI
~/.claude/skillsClaude Code
~/.config/opencode/skillsOpenCode
~/.openclaw/skillsOpenClaw managed skills
~/.cline/skillsCline

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.

npm installation

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.

Updating an existing install

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.

Installing or updating one skill

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.

Headless and CI usage

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.

Runtime dependencies

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:

StrengthEffect
hardThe 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.
softThe 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.

Exit codes

CodeMeaning
0Everything requested was installed. Soft warnings do not change this.
1A requested skill was blocked by a hard requirement, or any other error.
2install-skill only: approval declined, nothing was written.
3install-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.

Full-screen installer UI

Running the bare one-liner with no arguments, or installer interactive directly, opens a full-screen skill picker instead of the numbered menu:

KeyAction
↑/k, ↓/j, PageUp, PageDown, Home, Endmove the cursor
Enter / Spacetoggle the skill under the cursor
Tab / Shift-Tabswitch focus between the skill list and the info pane
a / nselect all / select none
d, r, m (info pane focused)show dependency hints, re-verify requirements, cycle a skill's integration mode
iconfirm and install the current selection
q / Escapequit 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.

Supported agent documentation

Development checkout

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

Notes

  • The planning-skill benchmark harness is agent-agnostic. 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.
  • Skills are instructions, not standalone applications. They add no dependencies unless a skill explicitly documents one.
  • resource-limited-testing's platform behaviour is described under Supported platforms; its SKILL.md documents each fallback in detail.
  • Read and review third-party skills before enabling them in an agent with access to sensitive files, credentials, or external systems.

License

Distributed under the MIT License.

Source 3 files
hooks/register.tsx 496 lines
1import 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}
496
hooks/loading.tsx 281 lines
1// 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}
281
types/index.d.ts 15 lines
1// 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