SLOPSHOPPER

gh-ci-status

GitHub Actions runs of the session's repo, pinned above the prompt, with links to the PR and the run

newpanebandcommandtoastprocess
v0.2.0no licenseupdated 2026-10-05diegorv/claude-mods-diegorv/plugins/gh-ci-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gh-ci-status
› fix the failing auth test and add an audit log call ● gh-ci-status: no GitHub remote; staying quiet until the next push or merge ● gh-ci-status: no GitHub remote; staying quiet until the next push or merge ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gh-ci ⎿ gh-ci-status: No GitHub repo found yet (gh repo view); looking again now. Run /gh-ci again in a moment. Last error: no GitHu ● gh-ci-status: no GitHub remote; staying quiet until the next push or merge ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods-diegorv

Claude Code mods, packaged as a plugin marketplace. A mod is a plugin whose hooks module (TypeScript, here) runs inside the session. Mods need Claude Code 2.1.287 or later and are on by default.

PluginWhat it does
timeThe time you sent each message, drawn above it
gh-ci-statusGitHub Actions runs of the session's repo, pinned above the prompt, with links to the PR and the run
activity-logEvery event of the session, tool calls and their full results included, appended to a JSONL file per session
agent-flowA live tree of the session's subagents and teammates in a pane: status, current tool, time, calls and tokens

time

The time above a message is the one of its first drawing, which is when you sent it, and it is kept per message, so a resize or a redraw does not move it. A resumed session, or a reload of the plugin, draws the old messages with the current time.

gh-ci-status

⚙ owner/repo · Actions · 1 running · 1 finished
#167  ◐ Running    PR       0m37s  chore(ci): smoke-test PR
main  ● Success    Deploy   2m15s  Release 1.4.0
  • Finds the repo the way gh does (gh repo view, the default remote), so gh must be on your PATH and logged in. Without a GitHub remote it stays quiet. The check runs at session start; if it fails there, no network or an expired token, it runs again at the next command that wakes the band (below), so after gh auth login, or once the network is back, a push is enough. Each failure goes to the debug log (claude --debug). While gh fails, the header says so (gh error for 2m05s) and keeps the last rows.
  • Polls gh run list every 60 s, and every 15 s while a run is in flight or for 6 minutes after a push. A git push, git subtree push, gh pr merge, gh workflow run or gh run rerun in Bash wakes it.
  • #N links to the PR (matched by branch through gh pr list, or from refs/pull/N/head); the workflow name (on a narrow band, the status) links to the run.
  • Shows runs from a push, a pull request, a manual dispatch, the merge queue, a release, or a workflow another one started, and leaves cron and issue bots out. Toasts when a run starts and when runs finish: one per run that failed or needs you, one for those that passed; a cancelled or skipped run shows only on the band. A finished run stays for 5 minutes; one that failed, timed out or needs you stays until a newer run of the same workflow, branch and trigger shows up, for 30 minutes at most.
  • details on the band's header opens a pane with every run of the last list, and under each failed one its failed jobs and steps, each linking to the job: ctrl+x tab to focus the band, then d; d or Esc closes it. A mouse click reaches it only in fullscreen.
  • /gh-ci opens or closes the same pane, band or not; before the repo is found it looks again and says so.

activity-log

Writes everything the session does to ~/.claude/activity-log/<date>-<sessionId>.jsonl, one JSON object per line. The date is the local date of the session's first line; after a /clear the lines go to a new file, under the new session id.

  • Each event is two lines with the same seq: a start line with the event's input (and origin, who raised it), written before the event runs, then an end line with its result and durationMs, or an error line. Tool calls, the rows the conversation keeps, the model's requests, prompts, commands, agents and the $ calls of other plugins are all there.
  • The content is whole, nothing cut: a tool's full input and result, every message. An event that runs in a subagent has its agentId on the start line. The model's response and a plugin's spawned process stream; their end line lists the chunks, which pass through unchanged as they come.
  • The files hold everything the session saw, secrets included. The folder is made 700 and each file 600, but nothing is redacted: delete what you do not want kept.
  • Left out: ui.render and ui.resolve, which fire for each component on every redraw; prompt.edit, which fires on every keystroke with the draft so far (prompt.submit keeps the text sent); and this plugin's own calls. Telemetry is only the collector stream of telemetry.log; the anthropic stream, and telemetry.mark with it, is closed to installed plugins.
  • It is large: about 2 MB for a one-line prompt, mostly tool.describe, command.describe and the $ calls of other plugins.
  • Lines are written about once a second and at session end. After a hot reload of the plugin, seq starts again at 0 in the same file, and the last second of lines may be lost.

Reading it with jq:

# tool calls, with input and result side by side
jq -s -c 'map(select(.event == "tool.call")) | group_by(.seq)[]
  | {tool: .[0].input.tool, agentId: .[0].agentId, input: .[0].input, result: .[1].result}' FILE

# one subagent's lines (its ids: jq -r '.agentId // empty' FILE | sort -u)
jq -s -c --arg id AGENT_ID 'map(select(.agentId == $id) | .seq) as $seqs
  | .[] | select(.seq | IN($seqs[]))' FILE

# how many of each event
jq -r 'select(.phase == "start") | .event' FILE | sort | uniq -c | sort -rn

agent-flow

2 running · 1 done · 1 waiting
● main · running 1m24s · Agent 1m23s
  ● general-purpose: find the auth flow · running 1m23s · Grep 4s · 7 calls
    ✓ Explore: read the session store · completed 36s · 12 calls · 20.4k tok
  ● general-purpose: run the test suite · running 1m22s · waiting for approval: Bash · 3 calls
  • /flow opens a pane with the main loop and every subagent and teammate under the loop that spawned it; /flow again closes it. The pane does not take the keyboard. Terminal only.
  • A row: status glyph, type and name (or the task's description), status and elapsed time, the oldest tool call in flight and how long it has run, the calls ended, and once the agent ended its tokens. The header counts the agents running, those done, and the loops waiting for approval.
  • A row stands out (bold, colored) while its loop waits for approval, while a tool call has run over 30 s (an Agent call, which lasts as long as its child, does not count), or while a running agent has had no event for 2 minutes. Ended rows are dimmed.
  • The tree comes from the engine's events (agent.spawn, tool.call, turn.start, turn.complete, and classic.PermissionRequest when no settings hook decided it), checked against $.agent.list() every 2 s while something runs and the pane is open; the list has the last word on status. An agent the list named once and then left out is marked gone.
  • Tokens are uncached input plus output, summed over the agent's turns; cache reads and writes are left out.
  • "waiting for approval" stays until the tool call ends, approved or not: no event carries the person's answer. In auto or dontAsk mode, or for a background subagent, it can show briefly before an automatic deny; it clears when the call ends.
  • The engine's own forks (compaction, memory) are not shown, and events from loops no spawn or list named (a workflow's agents) are ignored.
  • /clear and /resume start the tree empty; a background agent that survives /clear comes back once the list names it (while something runs with the pane open). A reload of the plugin starts it empty too; run /flow to draw the pane again, and running agents come back once the list names them.
  • Based on the idea of claude-agent-flow by Charlie0113-T (Apache-2.0); no code is copied.

Use

# mods need Claude Code 2.1.287 or later
claude --plugin-dir /path/to/claude-mods-diegorv/plugins

That folder loads activity-log too, which writes everything the session sees to disk; to leave it out, point --plugin-dir at single plugins instead (--plugin-dir plugins/time).

Under --plugin-dir, edits to a plugin's files reload it without restarting the session.

Or install them from the marketplace on GitHub:

/plugin marketplace add diegorv/claude-mods-diegorv
/plugin install activity-log@claude-mods-diegorv
/plugin install agent-flow@claude-mods-diegorv
/plugin install gh-ci-status@claude-mods-diegorv
/plugin install time@claude-mods-diegorv

Or from a local clone, by the path of its root (the folder with .claude-plugin/marketplace.json, not plugins/):

/plugin marketplace add /path/to/claude-mods-diegorv
/plugin install activity-log@claude-mods-diegorv
/plugin install agent-flow@claude-mods-diegorv
/plugin install gh-ci-status@claude-mods-diegorv
/plugin install time@claude-mods-diegorv

A marketplace added from a local path loads its plugins in place, so edits need no version bump or reinstall; they take effect at the next session start or /reload-plugins, not on save as under --plugin-dir. Both marketplaces are named claude-mods-diegorv, and only one marketplace per name can be registered, so remove one before adding the other.

The marketplace was renamed, and the install ids with it. If you installed under the old name, run /plugin marketplace remove claude-function-hooks, which also uninstalls its plugins, then one of the two marketplace blocks above.

Tested with Claude Code 2.1.289; the API may still change between releases. /plugin names the mods the session loaded, in a line such as 1 mod active · time. The old CLAUDE_CODE_ENABLE_FUNCTION_HOOKS flag is ignored; remove it.

To turn one off, disable its plugin in the Installed tab of /plugin. To turn off every installed mod, start the session with --safe-mode, which also disables your other customizations, or, for every session, set "disableAllHooks": true in ~/.claude/settings.json, which also stops your settings hooks and custom status line.

Develop

npm test           # node --test, no dependencies
npm run typecheck  # tsc
npm run format     # prettier
npm run validate   # claude plugin validate

claude plugin test runs every *.test.ts in a plugin with the claude-code/testing kit; these use node:test, so it is not used here.

The API's type declarations are not in git. Claude Code writes them to plugins/<name>/.claude-plugin/types/ each time it loads the plugins with --plugin-dir (a one-prompt headless run, claude -p --plugin-dir plugins "ok", will do). Before npm run typecheck, load the plugins once, and again after updating Claude Code.

Plugin layout

plugins/gh-ci-status/
  .claude-plugin/plugin.json   manifest
  hooks/hooks.json             points at the entry module
  src/core/                    the run model and the text derived from it, no I/O
  src/utils/                   generic helpers (text)
  src/app/                     use cases (the poller, the start-up), dependencies injected
  src/infra/                   external clients (GitHub through gh)
  src/components/              the band's view model and its JSX
  src/hooks/                   the wiring to the engine
  *.test.ts                    next to the file it tests, run with node:test
Source 13 files
src/hooks/register.tsx 209 lines
1// Wires the pieces to the engine: its hooks, no logic of its own.
2//
3//   session.start        finds the repo through its remote and starts the poller
4//   classic.PostToolUse  a push or merge in Bash wakes the poller, or finds the repo again
5//                        when that failed at session start
6//   ui.render            draws the band above the prompt from the poller's state, and the
7//                        details pane its "details" Button opens
8//   ui.close             notes that the pane is gone, so the next toggle opens it
9//   command.run          /gh-ci opens or closes that pane, band or not; /gh-ci refresh polls
10//                        now, /gh-ci status answers with the band's counts
11import type { EngineInterface, Register } from "claude-code";
12import { createGitHubClient } from "../infra/github.ts";
13import { createPoller, needsRedraw, type Poller } from "../app/poller.ts";
14import { createStarter, type Starter } from "../app/starter.ts";
15import { createDetails, type DetailsCache } from "../app/details.ts";
16import { triggersWorkflow } from "../core/trigger-commands.ts";
17import { Band } from "../components/band.tsx";
18import { bandModel } from "../components/band-model.ts";
19import { Pane } from "../components/pane.tsx";
20import { paneModel, shouldClose } from "../components/pane-model.ts";
21import { counts } from "../core/run-labels.ts";
22import { cut, elapsed } from "../utils/text.ts";
23
24const TICK_MS = 1000; // the band's clocks move between polls
25const PANE_ID = "ci";
26const LAST_ERROR_CELLS = 120; // the gh failure /gh-ci shows while no repo is found
27
28type Toggled = { kind: "opened" } | { kind: "closed" } | { kind: "unplaced"; reason: string };
29
30// What /gh-ci says after it toggled the pane; opening needs no words, the pane is the answer.
31function commandReply(toggled: Toggled): string | undefined {
32  if (toggled.kind === "closed") return "Closed the CI pane.";
33  if (toggled.kind === "unplaced") return `The CI pane did not open: ${toggled.reason}`;
34  return undefined;
35}
36
37// Opens the details pane, or closes it when it is the one shown and this module
38// drew it (shouldClose). Open but behind another tab, or listed after a reload
39// with nothing drawing it, it is opened again, which raises it.
40async function togglePane($: EngineInterface, details: DetailsCache, drawnHere: boolean): Promise<Toggled> {
41  const listed = (await $.ui.panes()).find((pane) => pane.id === PANE_ID);
42  if (shouldClose(listed?.isShown === true, drawnHere)) {
43    await $.ui.close({ id: PANE_ID });
44    return { kind: "closed" };
45  }
46  // Listed but not drawn here is what a reload leaves, and opening that again does not draw it: close it first.
47  if (listed && !drawnHere) await $.ui.close({ id: PANE_ID });
48  details.retryFailed();
49  // Asked for (a press or a command), so the surface places it at any width; toasts stay on, it is not a dialog.
50  const opened = await $.ui.open({ id: PANE_ID, title: "CI", focus: true, closeOnEscape: true });
51  return opened.isPlaced ? { kind: "opened" } : { kind: "unplaced", reason: opened.reason };
52}
53
54// For a press's promise, which nothing awaits.
55const logFailure = ($: EngineInterface) => (error: unknown) =>
56  $.ui.log(error instanceof Error ? error.message : String(error), { to: "debug" });
57
58export const register: Register = (on) => {
59  // Set once the repo is found; null until then, or when there is no GitHub repo.
60  let watch: { repo: string; poller: Poller; details: DetailsCache } | null = null;
61  // Set by session.start; finds the repo and starts the watch, again on a push while it is not found.
62  let startWatch: Starter | null = null;
63  // A push or merge seen before the watch started: the poller wakes on it once it does.
64  let pendingWake = false;
65  // The poller reads the time synchronously and $.clock.now() is async, so this copy is
66  // refreshed before the poller starts, on every tick and on every drawing.
67  let now = 0;
68  // This module drew the pane since it last opened; cleared when it closes, false after a reload.
69  let paneDrawn = false;
70
71  on("session.start", ($, event, next) => {
72    if (!event.isInteractive) return next(event); // -p and the SDK draw nowhere
73    const github = createGitHubClient((argv, init) => $.process.run(argv, init), event.cwd);
74    void $.command
75      .register({
76        name: "gh-ci",
77        description: "Show or hide this repo's GitHub Actions runs and their failed jobs",
78        argumentHint: "[refresh|status]",
79      })
80      .catch((error: unknown) =>
81        $.ui.log(`/gh-ci: ${error instanceof Error ? error.message : String(error)}`, { to: "debug" }),
82      );
83
84    // Finding the repo takes a gh call; the session must not wait for it.
85    startWatch = createStarter(
86      () => github.repoName(),
87      async (repo) => {
88        now = await $.clock.now();
89        const poller = createPoller(repo, {
90          listRuns: github.listRuns,
91          listPrs: github.listPrs,
92          now: () => now,
93          after: (ms, callback) => $.clock.after(ms, callback),
94          onChange: () => $.ui.invalidate("ui.render"),
95          toast: (text, timeoutMs) => $.ui.toast(text, timeoutMs ? { timeoutMs } : undefined),
96          log: (text) => $.ui.log(text, { to: "debug" }), // the band's header shows the outage
97        });
98        // No second poller: what can throw runs before this line, and once `watch` is set a push wakes it.
99        watch = { repo, poller, details: createDetails(github.runJobs, () => $.ui.invalidate("ui.render")) };
100        poller.start();
101        if (pendingWake) {
102          pendingWake = false;
103          poller.wake();
104        }
105        // A timer's callback is synchronous; a refresh that fails is retried on the next tick.
106        let lastShown = 0; // rows on the band at the last tick
107        $.clock.every(TICK_MS, () => {
108          void $.clock.now().then(
109            (time) => {
110              now = time;
111              const counting = poller.live() || poller.staleSince() !== null;
112              const shown = poller.rows().length;
113              if (needsRedraw(counting, shown, lastShown)) $.ui.invalidate("ui.render");
114              lastShown = shown;
115            },
116            () => {},
117          );
118        });
119      },
120      (text) => $.ui.log(text, { to: "debug" }),
121    );
122    startWatch();
123    return next(event);
124  });
125
126  // classic.PostToolUse fires after the tool succeeded, so a denied or failed push never gets here.
127  on("classic.PostToolUse", { tool_name: "Bash" }, ($, event, next) => {
128    const command = (event.tool_input as { command?: unknown }).command;
129    if (typeof command === "string" && triggersWorkflow(command)) {
130      if (watch) watch.poller.wake();
131      else {
132        pendingWake = true;
133        startWatch?.(); // the repo was not found at session start: a push is a good moment to look again
134      }
135    }
136    return next(event);
137  });
138
139  on("ui.render", { component: "AbovePrompt", surface: "terminal" }, async ($, event, next) => {
140    if (event.props.hasSurvey || !watch) return next(event);
141    const { repo, poller } = watch;
142    now = await $.clock.now();
143    const model = bandModel({
144      repo,
145      rows: poller.rows(),
146      waitingSince: poller.waitingSince(),
147      staleSince: poller.staleSince(),
148      now,
149      maxRows: event.props.maxRows,
150      columns: event.props.bodyColumns,
151    });
152    const { details } = watch;
153    return model
154      ? Band($.ui.resolve(event), model, () => void togglePane($, details, paneDrawn).catch(logFailure($)))
155      : next(event);
156  });
157
158  // Registered only in an interactive session (session.start), where the pane can draw.
159  on("command.run", { command: "gh-ci" }, async ($, event) => {
160    const args = event.args.trim();
161    if (args !== "" && args !== "refresh" && args !== "status") return { text: "Usage: /gh-ci [refresh|status]" };
162    if (!watch) {
163      startWatch?.();
164      const lastError = startWatch?.lastError();
165      const reason = lastError ? ` Last error: ${cut(lastError, LAST_ERROR_CELLS)}` : "";
166      return {
167        text: `No GitHub repo found yet (gh repo view); looking again now. Run /gh-ci again in a moment.${reason}`,
168      };
169    }
170    const { repo, poller } = watch;
171    if (args === "refresh") {
172      poller.refresh();
173      return { text: "Refreshing CI runs." };
174    }
175    if (args === "status") {
176      const staleSince = poller.staleSince();
177      const stale = staleSince !== null ? ` · gh error for ${elapsed((await $.clock.now()) - staleSince)}` : "";
178      return { text: `${repo}${stale} · ${counts(poller.rows()) || "no runs on the band"}` };
179    }
180    const text = commandReply(await togglePane($, watch.details, paneDrawn));
181    return text ? { text } : {};
182  });
183
184  on("ui.render", { component: "Pane", surface: "terminal" }, async ($, event, next) => {
185    if (event.requestId !== PANE_ID || !watch) return next(event);
186    const { repo, poller, details } = watch;
187    now = await $.clock.now();
188    const runs = poller.fetched();
189    details.keepOnly(new Set((runs ?? []).map((run) => run.databaseId)));
190    const model = paneModel({
191      repo,
192      runs,
193      staleSince: poller.staleSince(),
194      now,
195      columns: event.props.bodyColumns,
196      details: details.of,
197    });
198    paneDrawn = true;
199    return Pane($.ui.resolve(event), model, () => void $.ui.close({ id: PANE_ID }).catch(logFailure($)));
200  });
201
202  // The person's close (mark, Esc) or ours ends the drawing; the close goes on. An unload runs no
203  // hook of ours, and the reloaded module starts with the flag false.
204  on("ui.close", { id: PANE_ID }, ($, event, next) => {
205    paneDrawn = false;
206    return next(event);
207  });
208};
209
src/infra/github.ts 53 lines
1// GitHub client on top of the `gh` CLI. Takes the function that runs processes
2// instead of `$`, so the hook passes `$.process.run` and tests pass a fake.
3import type { Job, Pr, Run } from "../core/workflow-run.ts";
4import { cut } from "../utils/text.ts";
5
6export type ProcessResult = { exitCode: number; stdout: string; stderr: string };
7type RunProcess = (argv: readonly string[], init?: { cwd?: string; timeoutMs?: number }) => Promise<ProcessResult>;
8
9type GitHubLimits = { runs: number; prs: number };
10const DEFAULT_LIMITS: GitHubLimits = { runs: 15, prs: 100 };
11
12const RUN_FIELDS =
13  "databaseId,status,conclusion,event,workflowDatabaseId,workflowName,headBranch,displayTitle,createdAt,startedAt,updatedAt,url";
14const PR_FIELDS = "number,headRefName,isCrossRepository";
15
16type GitHubClient = {
17  repoName: () => Promise<string>;
18  listRuns: () => Promise<Run[]>;
19  listPrs: () => Promise<Pr[]>;
20  runJobs: (runId: number) => Promise<Job[]>;
21};
22
23export function createGitHubClient(
24  runProcess: RunProcess,
25  cwd: string,
26  limits: GitHubLimits = DEFAULT_LIMITS,
27): GitHubClient {
28  const failure = (result: ProcessResult, fallback: string) =>
29    new Error(cut(result.stderr, 120) || cut(result.stdout, 120) || fallback);
30
31  const runGh = async (args: string[], timeoutMs: number): Promise<ProcessResult> => {
32    const result = await runProcess(["gh", ...args], { cwd, timeoutMs });
33    if (result.exitCode !== 0) throw failure(result, `gh exited ${result.exitCode}`);
34    return result;
35  };
36
37  const runGhJson = async <Parsed>(args: string[]): Promise<Parsed> =>
38    JSON.parse((await runGh(args, 25_000)).stdout) as Parsed;
39
40  return {
41    async repoName() {
42      const result = await runGh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], 15_000);
43      const name = result.stdout.trim();
44      if (!name) throw failure(result, "no GitHub remote");
45      return name;
46    },
47    listRuns: () => runGhJson<Run[]>(["run", "list", "--limit", String(limits.runs), "--json", RUN_FIELDS]),
48    listPrs: () =>
49      runGhJson<Pr[]>(["pr", "list", "--state", "all", "--limit", String(limits.prs), "--json", PR_FIELDS]),
50    runJobs: async (runId) => (await runGhJson<{ jobs: Job[] }>(["run", "view", String(runId), "--json", "jobs"])).jobs,
51  };
52}
53
src/app/poller.ts 143 lines
1// The poll loop: when to ask, what to keep, when to notify. Everything that
2// touches the engine comes in through `deps`, so tests run it on a fake clock.
3import {
4  attemptStartedAt,
5  inFlight,
6  startedByPerson,
7  transitions,
8  visible,
9  withPrs,
10  type Holds,
11  type Pr,
12  type Run,
13} from "../core/workflow-run.ts";
14import { finishedToasts, startedToast } from "../core/run-labels.ts";
15
16type PollerConfig = Holds & {
17  activeMs: number; // interval while a run is in flight or a push is waiting
18  idleMs: number; // interval while nothing is happening
19  watchMs: number; // how long a push keeps the active pace
20};
21
22export const DEFAULT_CONFIG: PollerConfig = {
23  activeMs: 15_000,
24  idleMs: 60_000,
25  holdMs: 5 * 60_000,
26  failedHoldMs: 30 * 60_000,
27  watchMs: 6 * 60_000,
28};
29
30const CLOCK_SKEW_MS = 10_000; // the local clock against GitHub's
31
32type Timer = { cancel: () => void };
33
34export type PollerDeps = {
35  listRuns: () => Promise<Run[]>;
36  listPrs: () => Promise<Pr[]>; // may fail: runs then show no #N
37  now: () => number;
38  after: (ms: number, callback: () => void) => Timer;
39  onChange: () => void; // the band needs a redraw
40  toast: (text: string, timeoutMs?: number) => void;
41  log: (text: string) => void;
42};
43
44export type Poller = {
45  start: () => void;
46  wake: () => void; // a push happened: look now and keep the active pace
47  refresh: () => void; // look now, at the pace the result calls for; no push, so no waiting
48  rows: () => Run[];
49  waitingSince: () => number | null; // when the push happened, while no run has shown up
50  live: () => boolean; // something on the band is counting up: a run in flight, or a push being waited on
51  staleSince: () => number | null; // when gh started failing, while it still does: the rows are from before
52  fetched: () => Run[] | null; // every run the last good poll listed, holds or not; null before the first
53};
54
55// Between polls only an expiring hold changes the band without counting up.
56export const needsRedraw = (counting: boolean, shown: number, lastShown: number): boolean =>
57  counting || shown !== lastShown;
58
59export function createPoller(repo: string, deps: PollerDeps, config: PollerConfig = DEFAULT_CONFIG): Poller {
60  let rows: Run[] = [];
61  let fetched: Run[] | null = null;
62  let pushedAt: number | null = null;
63  let seen: ReadonlySet<number> = new Set();
64  let firstPoll = true; // the first poll only learns what is already running, without notifying
65  let staleSince: number | null = null; // the first failed poll of the current outage
66  let timer: Timer | null = null;
67  let polling = false; // one poll at a time: a wake mid-poll must not start a second chain of timers
68
69  const waitingSince = (): number | null =>
70    pushedAt !== null && deps.now() - pushedAt < config.watchMs ? pushedAt : null;
71  const waiting = () => waitingSince() !== null;
72  const live = () => rows.some(inFlight) || waiting();
73
74  const fetchRuns = async (): Promise<Run[] | null> => {
75    try {
76      const [runs, prs] = await Promise.all([deps.listRuns(), deps.listPrs().catch(() => [] as Pr[])]);
77      staleSince = null;
78      return withPrs(runs.filter(startedByPerson), prs);
79    } catch (error) {
80      if (staleSince === null) deps.log(error instanceof Error ? error.message : String(error)); // one line per outage
81      staleSince ??= deps.now();
82      return null;
83    }
84  };
85
86  const announce = (started: Run[], finished: Run[]) => {
87    if (!firstPoll) for (const run of started) deps.toast(startedToast(repo, run));
88    for (const toast of finishedToasts(repo, finished, deps.now())) deps.toast(toast.text, toast.timeoutMs);
89    firstPoll = false;
90  };
91
92  const poll = async (): Promise<boolean> => {
93    const runs = await fetchRuns();
94    if (runs === null) return waiting();
95    fetched = runs;
96
97    const changes = transitions(seen, runs);
98    seen = changes.seen;
99    const since = pushedAt; // narrowed copy: TS resets `let` narrowing inside the callback
100    if (since !== null && runs.some((run) => attemptStartedAt(run) >= since - CLOCK_SKEW_MS)) pushedAt = null; // the push's run, or rerun, is here
101    announce(changes.started, changes.finished);
102    rows = visible(runs, deps.now(), config);
103    return live();
104  };
105
106  const loop = async () => {
107    if (polling) return; // a wake during a poll: the running poll already sees pushedAt and keeps the active pace
108    polling = true;
109    const active = await poll()
110      .then((pace) => {
111        deps.onChange();
112        return pace;
113      })
114      .catch((error: unknown) => {
115        deps.log(error instanceof Error ? error.message : String(error));
116        return waiting();
117      })
118      .finally(() => (polling = false));
119    timer = deps.after(active ? config.activeMs : config.idleMs, () => void loop());
120  };
121
122  const pollNow = () => {
123    timer?.cancel();
124    timer = null;
125    void loop();
126  };
127
128  return {
129    start: () => void loop(),
130    wake: () => {
131      pushedAt = deps.now();
132      deps.onChange();
133      pollNow();
134    },
135    refresh: pollNow,
136    rows: () => visible(rows, deps.now(), config), // the same rule on what the last poll kept, as time passes
137    waitingSince,
138    live,
139    staleSince: () => staleSince,
140    fetched: () => fetched,
141  };
142}
143
src/app/starter.ts 35 lines
1// Starting the watch: find the repo, then start on it. A find that fails is
2// not retried on a timer; the next call tries again, so a session started
3// before `gh auth login` or offline recovers at the next push.
4// `lastError` is why the last try failed, kept through the next try; null
5// before any failure and once the start went through.
6export type Starter = (() => void) & { lastError: () => string | null };
7
8// A call while a find is running, or once the start went through, does nothing.
9export function createStarter(
10  find: () => Promise<string>,
11  start: (repo: string) => Promise<void>,
12  log: (text: string) => void,
13): Starter {
14  let state: "idle" | "finding" | "started" = "idle";
15  let lastError: string | null = null;
16  const startWatch = () => {
17    if (state !== "idle") return;
18    state = "finding";
19    void find()
20      .then(start)
21      .then(
22        () => {
23          state = "started";
24          lastError = null;
25        },
26        (error: unknown) => {
27          state = "idle";
28          lastError = error instanceof Error ? error.message : String(error);
29          log(`${lastError}; staying quiet until the next push or merge`);
30        },
31      );
32  };
33  return Object.assign(startWatch, { lastError: () => lastError });
34}
35
src/app/details.ts 43 lines
1// The failed jobs of the runs the details pane lists: fetched on the first ask
2// for a run, once per run and version (its id and updatedAt), so a rerun is
3// fetched again and a redraw never is; onChange redraws when one lands.
4import { jobFailures, type Job, type JobFailure, type Run } from "../core/workflow-run.ts";
5
6export type Details =
7  | { state: "loading" }
8  | { state: "failed"; message: string } // kept until the pane opens again or the run changes
9  | { state: "loaded"; failures: JobFailure[] };
10
11export type DetailsCache = {
12  of: (run: Run) => Details;
13  retryFailed: () => void; // the pane opened: a fetch that failed is tried again on its next draw
14  keepOnly: (runIds: ReadonlySet<number>) => void; // drops the runs the last poll no longer lists
15};
16
17export function createDetails(fetchJobs: (runId: number) => Promise<Job[]>, onChange: () => void): DetailsCache {
18  const cache = new Map<string, { runId: number; details: Details }>();
19  const drop = (keep: (entry: { runId: number; details: Details }) => boolean) => {
20    for (const [key, entry] of cache) if (!keep(entry)) cache.delete(key);
21  };
22  return {
23    of(run) {
24      const key = `${run.databaseId}:${run.updatedAt}`;
25      const known = cache.get(key);
26      if (known) return known.details;
27      const loading: Details = { state: "loading" };
28      const settle = (details: Details) => cache.get(key) && cache.set(key, { runId: run.databaseId, details });
29      cache.set(key, { runId: run.databaseId, details: loading });
30      void fetchJobs(run.databaseId)
31        .then(
32          (jobs) => settle({ state: "loaded", failures: jobFailures(run, jobs) }),
33          (error: unknown) =>
34            settle({ state: "failed", message: error instanceof Error ? error.message : String(error) }),
35        )
36        .then(onChange);
37      return loading;
38    },
39    retryFailed: () => drop((entry) => entry.details.state !== "failed"),
40    keepOnly: (runIds) => drop((entry) => runIds.has(entry.runId)),
41  };
42}
43
src/core/trigger-commands.ts 16 lines
1// Shell lines that probably trigger a workflow, so the poller looks right
2// after they finish: a push, a merge, a workflow run, a rerun. `git push`
3// carries its own flags, so the alternation walks past them. Each segment of
4// the line counts on its own, so a dry run only turns off the push it is in.
5// Quoted text is dropped first, so a commit message that talks about a push
6// does not count as one.
7const TRIGGERS =
8  /(^|[^\w./-])(git(\s+-\S+(\s+\S+)?)*\s+(subtree\s+)?push(\s|$)|gh\s+pr\s+merge(\s|$)|gh\s+workflow\s+run(\s|$)|gh\s+run\s+rerun(\s|$))/;
9const DRY_RUN = /push\b.*(--dry-run|\s-n\b)/;
10
11export const triggersWorkflow = (command: string): boolean =>
12  command
13    .replace(/'[^']*'|"[^"]*"/g, "")
14    .split(/&&|\||;/)
15    .some((segment) => TRIGGERS.test(segment) && !DRY_RUN.test(segment));
16
src/components/band.tsx 92 lines
1// Maps the band's view model to elements. No decisions here: see band-model.ts.
2//
3// A .map() array goes inside a Fragment, and a bare Fragment lays out as a row
4// Box, so it lives inside a column Box. A line that is not drawn is a null
5// child, which the element factory drops (RenderChildren), so it takes no row.
6import type { Elements as EngineElements, RenderChildren } from "claude-code";
7import type { BandModel, Cell, RowModel } from "./band-model.ts";
8
9// The surface's element table; the band draws on the terminal.
10type RowElements = Pick<EngineElements["terminal"], "Box" | "Text" | "Link">;
11type Elements = RowElements & Pick<EngineElements["terminal"], "Button">;
12
13const anchorOf =
14  ({ Text, Link }: RowElements) =>
15  (cell: Cell) =>
16    cell.href ? <Link href={cell.href}>{cell.text}</Link> : <Text dimColor>{cell.text}</Text>;
17
18// One run's row: the band's and the details pane's.
19export function RunRow(elements: RowElements, row: RowModel) {
20  const { Box, Text, Link } = elements;
21  const anchor = anchorOf(elements);
22  const column = (child: RenderChildren) => <Box flexShrink={0}>{child}</Box>;
23  return (
24    <Box gap={2} flexWrap="nowrap">
25      {column(
26        <Text>
27          {anchor(row.ref)}
28          {row.ref.pad}
29        </Text>,
30      )}
31      {column(
32        <Text color={row.phase.color}>
33          {row.phase.href ? (
34            <Link href={row.phase.href}>{`${row.phase.dot} ${row.phase.label}`}</Link>
35          ) : (
36            `${row.phase.dot} ${row.phase.label}`
37          )}
38        </Text>,
39      )}
40      {row.workflow
41        ? column(
42            <Text dimColor>
43              {anchor(row.workflow)}
44              {row.workflow.pad}
45            </Text>,
46          )
47        : null}
48      {column(<Text>{row.clock}</Text>)}
49      {row.title ? (
50        <Text dimColor wrap="truncate-end">
51          {row.title.href ? anchor(row.title) : row.title.text}
52        </Text>
53      ) : null}
54    </Box>
55  );
56}
57
58// `onDetails` runs when the header's "details" Button is pressed (a click, Enter, or d while the band
59// has the focus); it opens the details pane, or closes it when open. Only the header text truncates.
60export function Band(elements: Elements, model: BandModel, onDetails: () => void) {
61  const { Box, Text, Button } = elements;
62  const anchor = anchorOf(elements);
63
64  return (
65    <Box flexDirection="column">
66      <Box flexDirection="row" height={1}>
67        <Text dimColor wrap="truncate-end">
68          {"⚙ "}
69          {anchor(model.repo)}
70          {" · "}
71          {anchor(model.actions)}
72          {model.stale ? ` · ${model.stale}` : ""}
73          {model.counts ? ` · ${model.counts}` : ""}
74        </Text>
75        <Box flexGrow={1} />
76        <Box flexShrink={0}>
77          <Button key="details" plain hotkey="d" dimColor onPress={onDetails}>
78            details
79          </Button>
80        </Box>
81      </Box>
82      {model.waitingFor !== null ? (
83        <Text dimColor wrap="truncate-end">{`◌ waiting for a run   ${model.waitingFor}`}</Text>
84      ) : null}
85      <Box flexDirection="column">
86        <>{model.rows.map((row) => RunRow(elements, row))}</>
87      </Box>
88      {model.hiddenCount > 0 ? <Text dimColor wrap="truncate-end">{`  … and ${model.hiddenCount} more`}</Text> : null}
89    </Box>
90  );
91}
92
src/components/band-model.ts 127 lines
1// The band's view model: every rendering decision, as plain data. band.tsx
2// maps this to elements and decides nothing, so this is where the drawing is
3// tested.
4import { byAttention, inFlight, phase, prNumber, LABEL_WIDTH, type Phase, type Run } from "../core/workflow-run.ts";
5import { branchLabel, clock, counts, linkOf, REF_MAX, titleOf, workflowLabel } from "../core/run-labels.ts";
6import { cells, cut, elapsed } from "../utils/text.ts";
7
8export type BandInput = {
9  repo: string;
10  rows: Run[];
11  waitingSince: number | null; // a recent push with no run yet, or null
12  staleSince: number | null; // when gh started failing, while it still does
13  now: number;
14  maxRows: number; // rows the band may take, every line of it included
15  columns: number; // cells a row may take: the AbovePrompt's bodyColumns
16};
17
18// One text cell: a link when `href` is set, plain text otherwise. `pad` is the
19// whitespace that aligns the column, kept outside the link.
20export type Cell = { text: string; href: string | null; pad: string };
21
22export type RowModel = {
23  ref: Cell; // #N linking to the PR, or the dim branch name
24  phase: Phase & { href: string | null }; // links to the run when the workflow is dropped
25  workflow: Cell | null; // links to the run; null when the band is too narrow
26  clock: string;
27  title: Cell | null; // links to the run only when the row has no PR
28};
29
30export type BandModel = {
31  repo: Cell;
32  actions: Cell;
33  counts: string;
34  waitingFor: string | null; // elapsed since the push, while no run has shown up
35  stale: string | null; // "gh error for 2m05s", while gh fails and the rows are from before
36  rows: RowModel[];
37  hiddenCount: number;
38};
39
40const MAX_ROWS = 6; // runs shown at most; the rest go in the "… and N more" line
41const WORKFLOW_MAX = 20;
42const GAP = 2; // between the row's columns (band.tsx)
43const PHASE_CELLS = 2 + LABEL_WIDTH; // the dot, a space, the label
44const REF_FLOOR = 6; // "#12345"
45const WORKFLOW_FLOOR = 8;
46const TITLE_FLOOR = 10;
47const STALE_ALONE_MS = 2 * 60_000; // an outage with nothing else to show draws the band only past a blip
48
49const spaces = (count: number) => " ".repeat(Math.max(0, count));
50const cell = (text: string, href: string | null, width = cells(text)): Cell => ({
51  text,
52  href,
53  pad: spaces(width - cells(text)),
54});
55
56type Widths = { ref: number; workflow: number; clock: number; title: number };
57
58// Cells for each column of a row `columns` wide, given what the widest of each
59// needs. The ref, the phase and the clock always show; the workflow gets what
60// is left, then the title, each dropped (0) when that is under its floor. When
61// even the three do not fit, the ref is cut, down to its floor, and the
62// workflow and the title are dropped.
63export function columnWidths(needed: Widths, columns: number): Widths {
64  let left = columns - needed.ref - GAP - PHASE_CELLS - GAP - needed.clock;
65  const ref = left >= 0 ? needed.ref : Math.max(Math.min(needed.ref, REF_FLOOR), needed.ref + left);
66  const fit = (want: number, floor: number) => {
67    const room = Math.min(want, left - GAP);
68    if (want === 0 || room < Math.min(want, floor)) return 0;
69    left -= GAP + room;
70    return room;
71  };
72  const workflow = fit(needed.workflow, WORKFLOW_FLOOR);
73  return { ref, workflow, clock: needed.clock, title: fit(needed.title, TITLE_FLOOR) };
74}
75
76const widest = (texts: string[]) => Math.max(0, ...texts.map(cells));
77
78// One row per run, its columns sized together to `columns` cells; the band and the details pane draw these.
79export function runRows(repo: string, runs: Run[], now: number, columns: number): RowModel[] {
80  const refs = runs.map((run) => cut(branchLabel(run), REF_MAX));
81  const workflows = runs.map((run) => cut(workflowLabel(run), WORKFLOW_MAX));
82  const clocks = runs.map((run) => clock(run, now));
83  const titles = runs.map((run) => cut(titleOf(run), Infinity));
84  const width = columnWidths(
85    { ref: widest(refs), workflow: widest(workflows), clock: widest(clocks), title: widest(titles) },
86    columns,
87  );
88  return runs.map((run, index): RowModel => {
89    const hasPr = prNumber(run) !== null;
90    const p = phase(run);
91    return {
92      ref: cell(cut(refs[index], width.ref), hasPr ? linkOf(repo, run) : null, width.ref),
93      phase: { ...p, label: cut(p.label, LABEL_WIDTH).padEnd(LABEL_WIDTH), href: width.workflow ? null : run.url },
94      workflow: width.workflow ? cell(cut(workflows[index], width.workflow), run.url, width.workflow) : null,
95      clock: clocks[index].padStart(width.clock),
96      title: width.title && titles[index] ? cell(cut(titles[index], width.title), hasPr ? null : run.url) : null,
97    };
98  });
99}
100
101// Null when there is nothing to draw: no rows, no push being waited on, and no gh outage past a blip.
102export function bandModel(input: BandInput): BandModel | null {
103  const waitingFor =
104    input.waitingSince !== null && !input.rows.some(inFlight) ? elapsed(input.now - input.waitingSince) : null;
105  const staleFor = input.staleSince !== null ? input.now - input.staleSince : null;
106  const stale = staleFor !== null ? `gh error for ${elapsed(staleFor)}` : null;
107  const staleAlone = staleFor !== null && staleFor >= STALE_ALONE_MS;
108  if (input.rows.length === 0 && waitingFor === null && !staleAlone) return null;
109
110  // The runs get what the header and the waiting line leave; when they don't
111  // all fit, one row of that goes to the "more" line. From 3 rows up the tree
112  // never grows past maxRows; below that it can, and the engine scrolls it.
113  const room = input.maxRows - 1 - (waitingFor !== null ? 1 : 0);
114  const fits = input.rows.length <= Math.min(MAX_ROWS, room);
115  // Sorted before the cut, so a failure is never the row the cap hides.
116  const shown = byAttention(input.rows).slice(0, fits ? input.rows.length : Math.max(0, Math.min(MAX_ROWS, room - 1)));
117  return {
118    repo: cell(input.repo, `https://github.com/${input.repo}`),
119    actions: cell("Actions", `https://github.com/${input.repo}/actions`),
120    counts: counts(input.rows),
121    waitingFor,
122    stale,
123    rows: runRows(input.repo, shown, input.now, input.columns),
124    hiddenCount: input.rows.length - shown.length,
125  };
126}
127
src/components/pane.tsx 46 lines
1// Maps the details pane's view model to elements. No decisions here: see pane-model.ts.
2import type { Elements as EngineElements } from "claude-code";
3import type { PaneModel } from "./pane-model.ts";
4import { RunRow } from "./band.tsx";
5
6type Elements = Pick<EngineElements["terminal"], "Box" | "Text" | "Link" | "Button">;
7
8// `onClose` runs on the header's "close" Button: d while the pane has the focus, as d on the band opened it.
9export function Pane(elements: Elements, model: PaneModel, onClose: () => void) {
10  const { Box, Text, Link, Button } = elements;
11  return (
12    <Box flexDirection="column">
13      <Box flexDirection="row" height={1}>
14        <Text dimColor wrap="truncate-end">
15          {model.header}
16        </Text>
17        <Box flexGrow={1} />
18        <Box flexShrink={0}>
19          <Button key="close" plain hotkey="d" dimColor onPress={onClose}>
20            close
21          </Button>
22        </Box>
23      </Box>
24      {model.note ? <Text dimColor>{model.note}</Text> : null}
25      <Box flexDirection="column">
26        <>
27          {model.rows.map(({ row, lines }) => (
28            <Box flexDirection="column">
29              {RunRow(elements, row)}
30              <Box flexDirection="column">
31                <>
32                  {lines.map((line) => (
33                    <Text dimColor wrap="truncate-end">
34                      {line.href ? <Link href={line.href}>{line.text}</Link> : line.text}
35                    </Text>
36                  ))}
37                </>
38              </Box>
39            </Box>
40          ))}
41        </>
42      </Box>
43    </Box>
44  );
45}
46
src/components/pane-model.ts 76 lines
1// The details pane's view model: every run the last poll listed, with the
2// band's columns at the pane's width, and under each run that needs attention
3// its failed jobs and steps. pane.tsx maps it to elements and decides nothing.
4import { byAttention, needsAttention, type Run } from "../core/workflow-run.ts";
5import { counts } from "../core/run-labels.ts";
6import { cut, elapsed } from "../utils/text.ts";
7import type { Details } from "../app/details.ts";
8import { runRows, type RowModel } from "./band-model.ts";
9
10export type PaneInput = {
11  repo: string;
12  runs: Run[] | null; // null before the first good poll
13  staleSince: number | null;
14  now: number;
15  columns: number; // the Pane's bodyColumns
16  details: (run: Run) => Details; // asked only for the runs that failed or timed out
17};
18
19export type PaneLine = { text: string; href: string | null }; // under a run: a failure, or why there is none
20
21export type PaneModel = {
22  header: string;
23  note: string | null; // instead of rows: still loading, or nothing to list
24  rows: { row: RowModel; lines: PaneLine[] }[];
25};
26
27const INDENT = "    ";
28
29const lineOf = (text: string, href: string | null, columns: number): PaneLine => ({
30  text: INDENT + cut(text, Math.max(1, columns - INDENT.length)), // cut trims, so the indent goes on after
31  href,
32});
33
34function linesOf(details: Details, columns: number): PaneLine[] {
35  const line = (text: string, href: string | null = null) => lineOf(text, href, columns);
36  if (details.state === "loading") return [line("loading the jobs…")];
37  if (details.state === "failed") return [line(`couldn't read the jobs: ${details.message}`)];
38  if (details.failures.length === 0) return [line("no failed job listed")];
39  return details.failures.map((failure) =>
40    line(`✗ ${failure.step ? `${failure.step} · ${failure.job}` : failure.job}`, failure.url),
41  );
42}
43
44// A run waiting for approval has no failed job to show, so it points at the run, where it is approved.
45function linesFor(run: Run, input: PaneInput): PaneLine[] {
46  if (!needsAttention(run)) return [];
47  if (run.conclusion === "action_required")
48    return [lineOf("waiting for approval: open the run", run.url, input.columns)];
49  return linesOf(input.details(run), input.columns);
50}
51
52export function paneModel(input: PaneInput): PaneModel {
53  const stale = input.staleSince !== null ? ` · gh error for ${elapsed(input.now - input.staleSince)}` : "";
54  const runs = input.runs ?? [];
55  const tally = counts(runs);
56  const header = `${input.repo}${stale}${tally ? ` · ${tally}` : ""}`;
57  if (input.runs === null) return { header, note: "Loading the runs…", rows: [] };
58  if (runs.length === 0) return { header, note: "No runs from a push, a PR or a dispatch in the last list.", rows: [] };
59  const sorted = byAttention(runs);
60  const rows = runRows(input.repo, sorted, input.now, input.columns);
61  return {
62    header,
63    note: null,
64    rows: sorted.map((run, index) => ({
65      row: rows[index],
66      lines: linesFor(run, input),
67    })),
68  };
69}
70
71// Whether a toggle closes the pane: only when the engine lists it as the one
72// shown and this module drew it since it opened. Right after a reload the
73// engine can still list a pane nothing draws; closing that would close a pane
74// the person cannot see, so the toggle opens it instead.
75export const shouldClose = (listedShown: boolean, drawnHere: boolean): boolean => listedShown && drawnHere;
76
src/core/run-labels.ts 79 lines
1// Text derived from a workflow run: what each band column and toast says.
2import { attemptStartedAt, inFlight, needsAttention, phase, prNumber, type Run } from "./workflow-run.ts";
3import { cut, elapsed } from "../utils/text.ts";
4
5// The time column: how long the run has been going, or how long it took.
6export function clock(run: Run, now: number): string {
7  const startedAt = attemptStartedAt(run);
8  const endedAt = inFlight(run) ? now : Date.parse(run.updatedAt);
9  return elapsed(endedAt - startedAt);
10}
11
12export const workflowLabel = (run: Run): string => run.workflowName || "workflow"; // runs of organization rulesets come without a name
13
14export const REF_MAX = 24; // the ref column's width, on the band and in a toast
15
16// `#167` when the run has a PR; otherwise the branch name.
17export function branchLabel(run: Run): string {
18  const number = prNumber(run);
19  return number === null ? run.headBranch : `#${number}`;
20}
21
22// The row's title, or empty when it only repeats the #N of the first column
23// (a `run-name: PR #N` in the workflow does that).
24export function titleOf(run: Run): string {
25  const number = prNumber(run);
26  const titleWithoutPrefix = run.displayTitle.trim().replace(/^PR\s*/i, "");
27  return number !== null && titleWithoutPrefix === `#${number}` ? "" : run.displayTitle;
28}
29
30// Where the row leads: the PR when known, otherwise the run.
31export function linkOf(repo: string, run: Run): string {
32  const number = prNumber(run);
33  return number === null ? run.url : `https://github.com/${repo}/pull/${number}`;
34}
35
36// "1 running · 2 finished · 1 failed", non-zero counts only; empty when there is nothing.
37// Failed counts the finished runs that show a red ✗ (not "Needs you"), so it is a part of finished.
38export function counts(runs: Run[]): string {
39  const running = runs.filter(inFlight).length;
40  const finished = runs.length - running;
41  const failed = runs.filter((run) => phase(run).dot === "✗").length;
42  return [running ? `${running} running` : "", finished ? `${finished} finished` : "", failed ? `${failed} failed` : ""]
43    .filter(Boolean)
44    .join(" · ");
45}
46
47export type Toast = { text: string; timeoutMs?: number }; // no timeoutMs: the engine's default (4 s)
48
49const FAILURE_TOAST_MS = 10_000;
50const name = (run: Run) => cut(workflowLabel(run), 60);
51const ref = (run: Run) => cut(branchLabel(run), REF_MAX);
52
53export const startedToast = (repo: string, run: Run): string => `⚙ ${repo}: ${name(run)} started (${ref(run)})`;
54
55// What one poll saw finish: the successes in one toast, first, so on a
56// one-line notification bar a failure is what stays; then each run that needs
57// attention on its own, longer; cancelled, skipped and the like in none, since
58// a person or a newer push usually caused them and the band still shows them.
59export function finishedToasts(repo: string, runs: Run[], now: number): Toast[] {
60  const toasts: Toast[] = [];
61  const passed = runs.filter((run) => run.conclusion === "success");
62  if (passed.length === 1) {
63    const [run] = passed;
64    toasts.push({ text: `⚙ ${repo}: ${name(run)} passed after ${clock(run, now)} (${ref(run)})` });
65  } else if (passed.length > 1) {
66    toasts.push({ text: `⚙ ${repo}: ${passed.length} runs passed` });
67  }
68  for (const run of runs.filter(needsAttention)) {
69    toasts.push({
70      text:
71        run.conclusion === "action_required"
72          ? `⚙ ${repo}: ${name(run)} needs you (${ref(run)})`
73          : `⚙ ${repo}: ${name(run)} ${phase(run).label.toLowerCase()} after ${clock(run, now)} (${ref(run)})`,
74      timeoutMs: FAILURE_TOAST_MS,
75    });
76  }
77  return toasts;
78}
79
src/utils/text.ts 56 lines
1// Generic text helpers; nothing here knows what a run is.
2
3// Fixed width below one hour (`0m10s`, `3m09s`) so columns line up.
4export function elapsed(ms: number): string {
5  const totalSeconds = Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 1000)) : 0;
6  const totalMinutes = Math.floor(totalSeconds / 60);
7  const hours = Math.floor(totalMinutes / 60);
8  const twoDigits = (value: number) => String(value).padStart(2, "0");
9  if (hours > 0) return `${hours}h${twoDigits(totalMinutes % 60)}m`;
10  return `${totalMinutes}m${twoDigits(totalSeconds % 60)}s`;
11}
12
13const ZERO_WIDTH = /[\p{Mn}\p{Me}\p{Cf}]/u; // combining marks, joiners, variation selectors
14const WIDE =
15  /[\p{Emoji_Presentation}\u1100-\u115f\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}]/u;
16
17// The cells one character adds after one that took `previous`: VS16 (U+FE0F)
18// turns a one-cell base into a two-cell emoji, so it adds one there.
19const charCells = (char: string, previous: number) =>
20  char === "\ufe0f" ? (previous === 1 ? 1 : 0) : ZERO_WIDTH.test(char) ? 0 : WIDE.test(char) ? 2 : 1;
21
22// The terminal cells a line takes: two for an emoji or an East Asian wide
23// character, none for a combining mark or a joiner, one for the rest. The
24// ambiguous-width glyphs (◐ ● ⊘) count one, as terminals draw them outside
25// East Asian locales.
26export function cells(text: string): number {
27  let width = 0;
28  let previous = 0;
29  for (const char of text) {
30    previous = charCells(char, previous);
31    width += previous;
32  }
33  return width;
34}
35
36// The first line, cut to `maxCells` terminal cells with an ellipsis, without
37// the control characters the engine rejects or the bidi overrides that reorder
38// what follows them.
39export function cut(text: string, maxCells: number): string {
40  const firstLine = text
41    .split("\n")[0]
42    .replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, "")
43    .trim();
44  if (cells(firstLine) <= maxCells) return firstLine;
45  let kept = "";
46  let width = 0;
47  let previous = 0;
48  for (const char of firstLine) {
49    previous = charCells(char, previous);
50    if (width + previous + 1 > maxCells) break;
51    kept += char;
52    width += previous;
53  }
54  return `${kept}…`;
55}
56