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

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.
| Plugin | What it does |
|---|---|
| time | The time you sent each message, drawn above it |
| gh-ci-status | GitHub Actions runs of the session's repo, pinned above the prompt, with links to the PR and the run |
| activity-log | Every event of the session, tool calls and their full results included, appended to a JSONL file per session |
| agent-flow | A live tree of the session's subagents and teammates in a pane: status, current tool, time, calls and tokens |
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.
⚙ owner/repo · Actions · 1 running · 1 finished
#167 ◐ Running PR 0m37s chore(ci): smoke-test PR
main ● Success Deploy 2m15s Release 1.4.0
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.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.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.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.
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.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.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.tool.describe, command.describe and the $ calls of other plugins.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
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.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.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./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.# 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.
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.
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:testsrc/hooks/register.tsx 209 lines1// 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};
209src/infra/github.ts 53 lines1// 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}
53src/app/poller.ts 143 lines1// 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}
143src/app/starter.ts 35 lines1// 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}
35src/app/details.ts 43 lines1// 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}
43src/core/trigger-commands.ts 16 lines1// 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));
16src/components/band.tsx 92 lines1// 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}
92src/components/band-model.ts 127 lines1// 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}
127src/components/pane.tsx 46 lines1// 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}
46src/components/pane-model.ts 76 lines1// 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;
76src/core/run-labels.ts 79 lines1// 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}
79src/utils/text.ts 56 lines1// 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