Live GitHub Actions pane beside the transcript while workflow runs are active: run, jobs and steps with status icons and live durations, drawn like GitHub's…

A Claude Code mod (function-hook plugin) that shows your GitHub Actions runs in a pane beside the transcript while they are running, drawn like GitHub's run view: each run with its jobs, the running job's steps, status icons, and durations that tick live.
One line on the footer hint line below the prompt, beside the permission mode, says what is running (⟳ 2 running · ~3m left, or the longest elapsed time when there is no estimate) and, after a failure, which run failed (✗ CI #482 failed) for the linger time. The pane is opt-in: /actions opens it, or turn on Open automatically and it opens when a run starts on your branch. Finished runs stay for a short linger, and an automatically opened pane closes itself once nothing is left to show. A failed job collapses to the step that failed.

In the main-screen layout (CLAUDE_CODE_NO_FLICKER=0) the pane sits above the prompt:

Where the pane cannot seat, the status line says what is running:

gh, logged in (gh auth login), and a checkout with a GitHub remote.From this marketplace:
/plugin marketplace add blackpaw-studio/claude-plugins
/plugin install github-actions-pane@blackpaw-plugins
Push, or start a workflow, and the footer line appears on the next poll. Run /actions to open the pane.
| Command | |
|---|---|
/actions | Open the pane by hand, or close it. Opened by hand, it stays until you close it, and with nothing running it shows the latest run. |
/actions commit | Watch the runs for HEAD only, for this session. |
/actions branch | Watch the runs on the current branch, for this session. |
/actions repo | Watch every active run in the repository, for this session. |
Click a run's name to open it on github.com (gh run view --web).
If you close the pane, it stays closed for the runs already in flight, and a new run opens it again.
These are the plugin's options. Set them when you enable it, or in settings.json under pluginConfigs["github-actions-pane"].options (scope, lingerSeconds, activePollSeconds, idlePollSeconds, autoOpen).
| Setting | Default | |
|---|---|---|
| Scope | branch | commit (HEAD), branch (the branch's runs plus any run on HEAD's commit, so a tag-push release run shows; falls back to the commit on a detached HEAD) or repo |
| Linger seconds | 30 | How long finished runs stay before the pane closes; 0 closes at once |
| Active poll seconds | 10 | Poll interval while a run is active; at least 5 |
| Idle poll seconds | 60 | Poll interval while nothing is active; at least 15 |
| Open automatically | off | On: the pane opens by itself when a run starts. Off: the footer line below the prompt and the status line show what is running, and /actions opens the pane |
gh run list (20 runs, filtered to the scope; two under branch, by branch and by HEAD's commit, merged), plus one gh run view --json jobs for each active run shown. When those 20 are all newer than a run still going, two more lists (--status in_progress, --status queued) find it. A run that is waiting, requested or pending and older than the newest 20 is not found until it moves to queued or in progress. A finished run's jobs are read once and then cached. A git push, gh workflow run, gh pr create or gh run rerun through Claude's Bash tool polls at once, then at the active rate for two minutes.git rev-parse reads the remote-tracking ref of the branch in scope (its push target, else origin/<branch>, re-read on every poll and on every look while it is only guessed, so a first git push -u <remote> is seen; under repo scope the current branch's). When it moves, the same kick happens: a poll at once, then the active rate for two minutes. No GitHub calls are made while idle, and the watch is off when there is no repository or the HEAD is detached.3m 12s · ~2m left, <1m left, or over est. once past it). The estimate is the median duration of the workflow's last 10 successful runs on any branch or event, less the time the run has been going. It needs at least 3 of them, so a new workflow shows none, and a queued run shows none. It costs one gh run list --workflow <id> --status success per workflow, the first time one of its runs is active and again after one finishes; none is made while rate limited, and a failed one is tried again on the next poll. On a narrow card the estimate drops first.◐ 2 running · ✗ 1 failed instead. A pane you open with /actions seats at any width. In the main-screen layout it draws inline above the prompt, with the body capped at 96 columns.gh drops polling to the idle rate, and the header shows rate limited until a poll succeeds. When the data is more than two polls old, the header shows stale · 40s.gh, logged out of gh, or with no GitHub remote, nothing opens and gh is not polled. /actions says which one applies. A Bash cd into a repository starts watching again.gh says completed, so a run's header can keep ticking for up to one poll after its last job finished.git push through Claude's Bash, tags included, polls at once.gh's own pick (which prefers upstream over origin in a fork clone): the remote gh repo set-default chose, else the branch's push remote (pushRemote, remote.pushDefault, or the remote it tracks), else origin. Only github.com remotes are read this way; a GitHub Enterprise host, or an ssh alias for github.com, falls back to gh repo view.gh run view --web rather than a link.claude plugin validate plugins/github-actions-pane
claude plugin test plugins/github-actions-pane
CI (.github/workflows/ci.yml) runs both, plus the marketplace validation, on every pull request. It skips tsc, because the declarations below need a logged-in session to be written.
Type-checking needs the declarations the engine lays into .claude-plugin/types/ when it loads the mod from a folder you own (a --plugin-dir or the session's mods folder). Run tsc -p on the plugin folder.
gh fixtures in src/testing/gh-fixtures.ts are real gh output from cli/cli. dev/capture-fixtures.sh refreshes them with read-only calls.
The screenshots and GIF come from a real Claude Code session. That session watches a scripted timeline that a fake gh (dev/demo/bin/gh) replays: CI #482 with four jobs, and Deploy #77 failing at "Upload artifacts". Nothing reaches GitHub. The demo polls at the floors (5 s active, 15 s idle).
dev/demo/record.sh full150 fullscreen 150 40 100 # also: main, other sizes
dev/demo/render.py still full150 42 docs/failure.png --crop 86,0,150,30
dev/demo/render.py gif full150 -12 92 docs/demo.gif --crop 86,0,150,35 --speed 2
record.sh runs Claude in a throwaway tmux session inside a scratch repository, records it with asciinema, and saves tmux capture-pane -e frames each second under dev/demo/out/<name>/. render.py turns a recording into stills and a GIF with agg and ffmpeg.
hooks/register.tsx 157 lines1// github-actions-pane: wiring only. The poller, snapshot, lifecycle and layout live
2// in src/. `$` is only ever spelled at its call sites here; src/ gets
3// closures (Ports).
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6import { bandAt } from '../src/band'
7import { createFooterGate, fitsTail, type FooterGate } from '../src/footer'
8import { isKickCommand } from '../src/kick'
9import { layoutPane } from '../src/layout'
10import { restoredData } from '../src/model'
11import { bodyWidth, paneTree } from '../src/pane'
12import { createRuntime, type Ports, type Runtime, TICK_MS } from '../src/runtime'
13import { isScope, parseSettings } from '../src/settings'
14import { buildSnapshot } from '../src/snapshot'
15
16const PANE = 'actions'
17const TITLE = 'Actions'
18/** Body rows asked for when seated inline above the prompt (the frame still fits the tree). */
19const INLINE_ROWS = 24
20/** Between the engine's footer line (or another hook's tail) and the actions text. */
21const TAIL_SEPARATOR = ' '
22
23// The $.state values (contract: types/index.d.ts). Written here, beside their
24// readers, so the engine's scan can read every reference.
25const dataAtom = atom({ plugin: 'github-actions-pane', key: 'data' } as const, null)
26const nowAtom = atom({ plugin: 'github-actions-pane', key: 'now' } as const, 0)
27const scopeAtom = atom({ plugin: 'github-actions-pane', key: 'scope' } as const, null)
28const manualAtom = atom({ plugin: 'github-actions-pane', key: 'isManual' } as const, false)
29
30const describeError = (error: unknown): string => (error instanceof Error ? error.message : String(error))
31
32/** The runtime's view of the engine: every `$` call it makes, as closures. */
33function portsOf($: EngineInterface, footer: FooterGate): Ports {
34 return {
35 run: (argv, init) => $.process.run(argv, init),
36 cwd: () => $.session.cwd(),
37 now: () => $.clock.now(),
38 after: (ms, fn) => $.clock.after(ms, fn),
39 every: (ms, fn) => $.clock.every(ms, fn),
40 data: { get: () => read($, dataAtom), set: value => update($, dataAtom, () => value).then(() => undefined) },
41 clock: { set: value => update($, nowAtom, () => value).then(() => undefined) },
42 manual: { get: () => read($, manualAtom), set: value => update($, manualAtom, () => value).then(() => undefined) },
43 scope: { get: () => read($, scopeAtom), set: value => update($, scopeAtom, () => value).then(() => undefined) },
44 open: () => $.ui.open({ id: PANE, title: TITLE, rows: INLINE_ROWS }),
45 close: () => $.ui.close({ id: PANE }),
46 pane: async () => {
47 const pane = (await $.ui.panes()).find(one => one.id === PANE)
48 return { isOpen: pane !== undefined, isPlaced: pane?.isPlaced ?? false }
49 },
50 status: text => $.ui.status(text),
51 isFooterShowing: footer.isShowing,
52 log: text => $.ui.log(text, { to: 'debug' }),
53 }
54}
55
56/** Declares /actions for the session (each one: a /clear starts another). */
57function registerCommand($: EngineInterface) {
58 return $.command.register({
59 name: 'actions',
60 description: 'Toggle the GitHub Actions pane; with commit, branch or repo, set what it watches this session',
61 argumentHint: '[commit|branch|repo]',
62 immediate: true,
63 })
64}
65
66export const register: Register = (on, options) => {
67 const settings = parseSettings(options)
68 let runtime: Runtime | null = null
69 const footer = createFooterGate()
70
71 /** The runtime over these ports, started once per module load. */
72 const attach = (ports: Ports): Runtime => {
73 if (runtime !== null) return runtime
74 const started = createRuntime(ports, settings)
75 runtime = started
76 started.start().catch(error => ports.log(`github-actions-pane: start: ${describeError(error)}`))
77 return started
78 }
79
80 on('session.start', async ($, e, next) => {
81 const started = await next(e)
82 await registerCommand($)
83 attach(portsOf($, footer))
84 return started
85 })
86
87 // A /clear empties the session's state and no session.start follows.
88 on('session.end', async ($, e, next) => {
89 const ended = await next(e)
90 if (e.reason === 'clear') {
91 const log = (error: unknown) => $.ui.log(`github-actions-pane: clear: ${describeError(error)}`, { to: 'debug' })
92 // Commands are the session's: the one going on after a /clear needs /actions again.
93 registerCommand($).catch(log)
94 runtime?.republish().catch(log)
95 }
96 return ended
97 })
98
99 // After a push (or a run started from gh), poll at once; any other Bash call may have moved the cwd.
100 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
101 const ran = await next(e)
102 const watcher = attach(portsOf($, footer))
103 const work = isKickCommand(e.command) ? watcher.kick() : watcher.cwdMaybeChanged()
104 work.catch(error => $.ui.log(`github-actions-pane: bash: ${describeError(error)}`, { to: 'debug' }))
105 return ran
106 }).catch(($, e, next) => next(e))
107
108 on('command.run', { command: 'actions' }, async ($, e) => {
109 const watcher = attach(portsOf($, footer))
110 const arg = e.args.trim()
111 if (arg === '') {
112 // Opened here, in the person's command, so it seats at any width.
113 const reply = await watcher.toggle(() => $.ui.open({ id: PANE, title: TITLE, rows: INLINE_ROWS }))
114 return reply.isQuiet ? {} : { text: reply.text }
115 }
116 if (!isScope(arg)) return { text: 'Usage: /actions [commit|branch|repo]' }
117 return { text: (await watcher.setScope(arg)).text }
118 })
119
120 on('ui.close', { id: PANE }, async ($, e, next) => {
121 const closed = await next(e)
122 if (e.origin.kind === 'person') runtime?.closedByPerson().catch(error => $.ui.log(`github-actions-pane: close: ${describeError(error)}`, { to: 'debug' }))
123 return closed
124 }).catch(($, e, next) => next(e))
125
126 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
127 if (e.surface !== 'terminal') return next(e)
128 const [data, now, scope, isManual] = await Promise.all([read($, dataAtom), read($, nowAtom), read($, scopeAtom), read($, manualAtom)])
129 const snapshot = buildSnapshot({ data: restoredData(data), now, settings, scope: scope ?? settings.scope, isManual })
130 const lines = layoutPane(snapshot, { width: bodyWidth(e.props.bodyColumns), rows: e.props.scroll.bodyRows, frame: Math.floor(now / TICK_MS) })
131 const repoArgs = data?.context?.repo === undefined ? [] : ['--repo', data.context.repo]
132 const open = (runId: number) =>
133 void $.process
134 .run(['gh', 'run', 'view', String(runId), ...repoArgs, '--web'], { env: { GH_PROMPT_DISABLED: '1' }, timeoutMs: 10_000 })
135 .catch(error => $.ui.log(`github-actions-pane: open: ${describeError(error)}`, { to: 'debug' }))
136 return paneTree($.ui.resolve(e), lines, open)
137 })
138 // The footer text shares the poller's data and clock: it draws, and the runtime ticks, with the pane closed.
139 // Only `tail` is set, so the engine's line (live pills, permission mode) stays whole. The terminal alone draws
140 // it, and only when it fits; the footer gate tells the runtime, which re-reads it on its next tick.
141 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
142 if (e.surface !== 'terminal') {
143 footer.report(false)
144 return next(e)
145 }
146 const [data, now, scope] = await Promise.all([read($, dataAtom), read($, nowAtom), read($, scopeAtom)])
147 const text = bandAt({ data: restoredData(data), now, settings, scope: scope ?? settings.scope })
148 if (text === null) {
149 footer.report(false)
150 return next(e)
151 }
152 const { hint, tail } = e.props
153 footer.report(fitsTail({ columns: e.viewport?.columns, hint, tail, separator: TAIL_SEPARATOR }))
154 return next({ ...e, props: { ...e.props, tail: `${tail ?? ''}${TAIL_SEPARATOR}${text}` } })
155 })
156}
157src/band.ts 37 lines1// The one-line text on the footer hint line below the prompt: what is running, or what just failed.
2// Drawn from the same snapshot as the pane, whose linger already decides how
3// long a finished run stays. Pure.
4import { etaLabel } from './eta'
5import { formatDuration } from './format'
6import { buildSnapshot, type Card, type Snapshot, type SnapshotInputs } from './snapshot'
7
8const RUNNING_GLYPH = '⟳'
9const FAILED_GLYPH = '✗'
10
11const maxOf = (values: readonly number[]): number | null => (values.length === 0 ? null : Math.max(...values))
12
13const runningText = (running: readonly Card[]): string => {
14 const left = maxOf(running.flatMap(card => card.remainingMs ?? []))
15 const time = left !== null ? etaLabel(left) : formatDuration(maxOf(running.flatMap(card => card.durationMs ?? [])))
16 return `${RUNNING_GLYPH} ${running.length} running${time === '' ? '' : ` · ${time}`}`
17}
18
19const failedText = (failed: readonly Card[]): string =>
20 failed.length === 1 ? `${FAILED_GLYPH} ${failed[0]?.name} failed` : `${FAILED_GLYPH} ${failed.length} failed`
21
22/** The band's text, or null when nothing is worth a line (a rate-limited or idle poller says nothing). */
23export const bandText = (snapshot: Snapshot): string | null => {
24 if (snapshot.note === 'rate limited' || snapshot.cards.length === 0) return null
25 const running = snapshot.cards.filter(card => card.isActive)
26 if (running.length > 0) return runningText(running)
27 const failed = snapshot.cards.filter(card => card.status === 'failure')
28 return failed.length > 0 ? failedText(failed) : null
29}
30
31/** Whether the band's text is the running state (the failure state is not). */
32export const isRunningBand = (text: string | null): boolean => text?.startsWith(RUNNING_GLYPH) === true
33
34/** The band for these inputs: never the pane's manual "latest run" fallback. */
35export const bandAt = (inputs: Omit<SnapshotInputs, 'isManual'>): string | null =>
36 bandText(buildSnapshot({ ...inputs, isManual: false }))
37src/footer.ts 38 lines1// Whether the footer tail is on screen. The engine draws PromptHint's `tail` on
2// the terminal only, pads the line two columns each side, and leaves the tail
3// out when under four columns of it would show. A render hook may not write
4// $.state, so the hook reports here and the runtime reads it when deciding
5// whether the status line still has to say what runs.
6
7/** Columns the engine pads around the hint line (two each side). */
8const PADDING = 4
9/** The least of the tail the engine will draw. */
10const MIN_TAIL_COLUMNS = 4
11
12type TailRoom = {
13 /** The viewport's width; unknown means no promise. */
14 columns: number | undefined
15 hint: string
16 /** A tail another hook set: it sits before ours. */
17 tail?: string
18 separator: string
19}
20
21const columnsOf = (text: string): number => [...text].length
22
23/** Conservatively: does at least a few columns of our text fit after the hint? */
24export const fitsTail = ({ columns, hint, tail = '', separator }: TailRoom): boolean =>
25 columns !== undefined && columns - PADDING - columnsOf(hint) - columnsOf(tail) - columnsOf(separator) >= MIN_TAIL_COLUMNS
26
27export type FooterGate = {
28 /** Called by every PromptHint render: whether the tail was added and fits. */
29 report: (isShowing: boolean) => void
30 isShowing: () => boolean
31}
32
33/** Nothing reported yet means not showing. */
34export const createFooterGate = (): FooterGate => {
35 let isShowing = false
36 return { report: next => void (isShowing = next), isShowing: () => isShowing }
37}
38src/kick.ts 31 lines1// Which shell commands start workflow runs, so the poller speeds up right
2// after one instead of waiting out the idle interval. Pure.
3
4const SEPARATORS = /&&|\|\||[;|&\n]/
5const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
6/** git's global options that take the next word as their value. */
7const GIT_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'])
8const GH_KICKS = new Set(['workflow run', 'pr create', 'run rerun'])
9
10const wordsOf = (segment: string): string[] => {
11 const words = segment.trim().replace(/^\(+/, '').split(/\s+/).filter(word => word !== '')
12 const start = words.findIndex(word => !ENV_ASSIGNMENT.test(word))
13 return start === -1 ? [] : words.slice(start)
14}
15
16/** git's subcommand: the first word after git that is not a global option or its value. */
17const gitSubcommand = (args: readonly string[]): string | undefined => {
18 const index = args.findIndex((word, i) => !word.startsWith('-') && !GIT_VALUE_OPTIONS.has(args[i - 1] ?? ''))
19 return index === -1 ? undefined : args[index]
20}
21
22const isKickSegment = (segment: string): boolean => {
23 const [command, ...args] = wordsOf(segment)
24 if (command === 'git') return gitSubcommand(args) === 'push'
25 if (command === 'gh') return GH_KICKS.has(args.slice(0, 2).join(' '))
26 return false
27}
28
29/** True when any command in the line is `git push`, `gh workflow run`, `gh pr create` or `gh run rerun`. */
30export const isKickCommand = (command: string): boolean => command.split(SEPARATORS).some(isKickSegment)
31src/layout.ts 137 lines1// The snapshot as lines of spans at a width and height: the run view's
2// look (glyphs, indents, right-aligned durations) and its fit to the pane. Pure.
3import { etaLabel } from './eta'
4import { formatDuration, glyphOf, type RowLevel, type RowStatus, truncate, cellsOf } from './format'
5import { BLANK, justify, type Line, span, type Span, widthOf } from './line'
6import type { Card, Row, Snapshot } from './snapshot'
7import { rowsAt, type Window, WINDOWS } from './window'
8
9export type Frame = {
10 /** Cells across the pane body. */
11 width: number
12 /** Rows the pane body holds. */
13 rows: number
14 /** The spinner's frame: one step per clock tick. */
15 frame: number
16}
17
18const TITLE = 'GitHub Actions'
19const INDENT = ' '
20const HEADER_ROWS = 2
21
22const glyphSpan = (status: RowStatus, level: RowLevel, frame: number): Span => {
23 const { glyph, ...tone } = glyphOf(status, level, frame)
24 return span(glyph, tone)
25}
26
27/** A row: indent, glyph, the name cut to fit, the duration (if any) at the right edge. */
28const rowLine = (depth: number, glyph: Span, name: (room: number) => Line, right: Line, width: number): Line => {
29 const lead = INDENT.repeat(depth)
30 const rightCells = right.length === 0 ? 0 : widthOf(right) + 1
31 const room = Math.max(1, width - cellsOf(lead) - 2 - rightCells)
32 const left: Line = [...(lead === '' ? [] : [span(lead)]), glyph, span(' '), ...name(room)]
33 return justify(left, right, width)
34}
35
36const durationLine = (ms: number | null, isDim: boolean): Line => {
37 const text = formatDuration(ms)
38 return text === '' ? [] : [span(text, isDim ? { dim: true } : {})]
39}
40
41/** The elapsed time, then ` · ~2m left` when the card has an estimate and the name still fits beside both. */
42const cardTimes = (card: Card, width: number): Line => {
43 const elapsed = durationLine(card.durationMs, false)
44 if (elapsed.length === 0 || card.remainingMs === undefined) return elapsed
45 const withEta: Line = [...elapsed, span(` · ${etaLabel(card.remainingMs)}`, { dim: true })]
46 return 2 + cellsOf(card.name) + 1 + widthOf(withEta) <= width ? withEta : elapsed
47}
48
49/** `CI #482 · push`, the event dropped first when the row is tight; the name opens the run. */
50const runName = (card: Card) => (room: number): Line => {
51 const event = card.event === '' ? '' : ` · ${card.event}`
52 const hasEvent = cellsOf(card.name) + cellsOf(event) <= room
53 return [
54 span(truncate(card.name, room), { bold: true, opens: card.id }),
55 ...(hasEvent && event !== '' ? [span(event, { dim: true })] : []),
56 ]
57}
58
59/** Steps and summaries read quieter than jobs; the step that is running or failed stands out. */
60const isQuiet = (row: Row): boolean => row.isSummary === true || (row.depth === 1 && row.status !== 'running' && row.status !== 'failure')
61
62const jobLine = (row: Row, width: number, frame: number): Line =>
63 rowLine(
64 row.depth + 1,
65 glyphSpan(row.status, row.depth === 0 ? 'job' : 'step', frame),
66 room => [span(truncate(row.name, room), isQuiet(row) ? { dim: true } : {})],
67 durationLine(row.durationMs, true),
68 width,
69 )
70
71const cardLines = (card: Card, window: Window, width: number, frame: number): Line[] => [
72 rowLine(0, glyphSpan(card.status, 'run', frame), runName(card), cardTimes(card, width), width),
73 ...(card.subtitle === '' ? [] : [[span(INDENT), span(truncate(card.subtitle, width - cellsOf(INDENT)), { dim: true })]]),
74 ...rowsAt(card.jobs, window).map(row => jobLine(row, width, frame)),
75]
76
77const headerLine = ({ label, note }: Snapshot, width: number): Line => {
78 const title = label === '' ? TITLE : `${TITLE} · ${label}`
79 const right: Line = note === null ? [] : [span(note, note === 'rate limited' ? { color: 'warning' } : { dim: true })]
80 const room = Math.max(1, width - (note === null ? 0 : cellsOf(note) + 1))
81 return justify([span(truncate(title, room), { dim: true })], right, width)
82}
83
84const emptyLine = ({ label }: Snapshot, width: number): Line => [span(truncate(`No workflow runs for ${label}`, width), { dim: true })]
85
86/** Card blocks joined by blank lines. */
87const stack = (blocks: readonly Line[][]): Line[] => blocks.flatMap((block, i) => (i === 0 ? block : [BLANK, ...block]))
88
89type Draw = (card: Card, window: Window) => Line[]
90
91const moreLine = (text: string): Line => [span(text, { dim: true })]
92
93/** The card sets to try: all, then less the finished ones, oldest (last) first. */
94const keptSets = (cards: readonly Card[]): Card[][] => {
95 const finished = cards.filter(card => !card.isActive).reverse()
96 return finished.reduce<Card[][]>((sets, drop) => [...sets, (sets[sets.length - 1] ?? []).filter(card => card !== drop)], [[...cards]])
97}
98
99const STEP_WINDOWS = WINDOWS.filter(window => window !== 'none')
100
101/** The roomiest window at which these cards stack within `room` rows. */
102const fitting = (cards: readonly Card[], draw: Draw, room: number): Line[] | undefined =>
103 STEP_WINDOWS.map(window => stack(cards.map(card => draw(card, window)))).find(lines => lines.length <= room)
104
105/**
106 * The cards in `room` rows: running steps windowed tighter first, then the
107 * oldest finished cards dropped, then running steps hidden; past that, the
108 * whole cards that fit and a `+N more` line (rows cut from a lone card).
109 */
110const fitCards = (cards: readonly Card[], draw: Draw, room: number): Line[] => {
111 const sets = keptSets(cards)
112 for (const kept of sets) {
113 const lines = fitting(kept, draw, room)
114 if (lines !== undefined) return lines
115 }
116 const kept = sets[sets.length - 1] ?? []
117 const bare = stack(kept.map(card => draw(card, 'none')))
118 if (bare.length <= room) return bare
119 const whole = kept.filter((_, n) => stack(kept.slice(0, n + 1).map(card => draw(card, 'none'))).length <= room - 1).length
120 if (whole > 0) {
121 const lines = fitting(kept.slice(0, whole), draw, room - 1) ?? stack(kept.slice(0, whole).map(card => draw(card, 'none')))
122 return [...lines, moreLine(`+${cards.length - whole} more`)]
123 }
124 const first = kept[0] === undefined ? [] : draw(kept[0], 'none')
125 const shown = first.slice(0, Math.max(0, room - 1))
126 const hiddenCards = cards.length - 1
127 return [...shown, moreLine(hiddenCards > 0 ? `+${hiddenCards} more` : `+${first.length - shown.length} more rows`)]
128}
129
130export const layoutPane = (snapshot: Snapshot, { width, rows, frame }: Frame): Line[] => {
131 const header = headerLine(snapshot, width)
132 if (snapshot.label === '' && snapshot.cards.length === 0) return [header]
133 if (snapshot.cards.length === 0) return [header, BLANK, emptyLine(snapshot, width)]
134 const draw: Draw = (card, window) => cardLines(card, window, width, frame)
135 return [header, BLANK, ...fitCards(snapshot.cards, draw, Math.max(1, rows - HEADER_ROWS))]
136}
137src/model.ts 78 lines1// How one poll's answers fold into what the poller holds. Pure.
2import type { ActionsData, ActionsJob, ActionsRun } from '../types'
3import { isActiveStatus } from './format'
4
5export const EMPTY_DATA: ActionsData = {
6 context: null,
7 runs: [],
8 jobs: {},
9 watched: {},
10 history: {},
11 fetchedAt: null,
12 pollMs: 0,
13 isRateLimited: false,
14 disabled: null,
15}
16
17/**
18 * State read back from a session: one saved before `history` or `workflowId`
19 * existed (0.1.2) gets them empty, so nothing downstream meets a missing field.
20 */
21export const restoredData = (saved: ActionsData | null): ActionsData =>
22 saved === null ? EMPTY_DATA : { ...saved, history: saved.history ?? {}, runs: saved.runs.map(run => ({ ...run, workflowId: run.workflowId ?? null })) }
23
24/**
25 * The history kept for the workflows among `runs`, less those with a run seen
26 * finishing in this list (it is a new sample: read again when next needed).
27 */
28const historyAfter = (data: ActionsData, runs: readonly ActionsRun[]): ActionsData['history'] => {
29 const finishing = new Set(runs.filter(run => !isActiveStatus(run.status) && data.watched[String(run.id)] === null).map(run => run.workflowId))
30 const inView = new Set(runs.map(run => run.workflowId))
31 return Object.fromEntries(Object.entries(data.history).filter(([id]) => inView.has(Number(id)) && !finishing.has(Number(id))))
32}
33
34/**
35 * A fresh list: active runs are watched (null), a watched run seen finished
36 * for the first time gets its completion time, and runs no longer listed are
37 * forgotten along with their jobs. Workflow history follows the runs in view.
38 */
39export const withRuns = (data: ActionsData, runs: readonly ActionsRun[], now: number): ActionsData => {
40 const watched = Object.fromEntries(
41 runs.flatMap((run): [string, number | null][] => {
42 const key = String(run.id)
43 if (isActiveStatus(run.status)) return [[key, null]]
44 const seen = data.watched[key]
45 return seen === undefined ? [] : [[key, seen ?? now]]
46 }),
47 )
48 const listed = new Set(runs.map(run => String(run.id)))
49 const jobs = Object.fromEntries(Object.entries(data.jobs).filter(([id]) => listed.has(id)))
50 return { ...data, runs: [...runs], watched, jobs, history: historyAfter(data, runs), fetchedAt: now, isRateLimited: false }
51}
52
53/**
54 * The runs whose jobs to ask for: shown and active, or shown, finished and
55 * not yet read since finishing (`final` holds those that were).
56 */
57export const runsToDetail = (data: ActionsData, shownIds: readonly number[], final: ReadonlySet<number>): number[] =>
58 shownIds.filter(id => {
59 const run = data.runs.find(one => one.id === id)
60 return run !== undefined && (isActiveStatus(run.status) || !final.has(id))
61 })
62
63export const withJobs = (data: ActionsData, answers: ReadonlyMap<number, readonly ActionsJob[]>): ActionsData =>
64 answers.size === 0
65 ? data
66 : { ...data, jobs: { ...data.jobs, ...Object.fromEntries([...answers].map(([id, jobs]) => [String(id), [...jobs]])) } }
67
68/** The workflows to read past durations for: those of active runs, not read yet. */
69export const workflowsToRead = (data: ActionsData): number[] => {
70 const ids = data.runs.flatMap(run => (isActiveStatus(run.status) && run.workflowId !== null && data.history[String(run.workflowId)] === undefined ? [run.workflowId] : []))
71 return ids.filter((id, index) => ids.indexOf(id) === index)
72}
73
74export const withHistory = (data: ActionsData, answers: ReadonlyMap<number, readonly number[]>): ActionsData =>
75 answers.size === 0
76 ? data
77 : { ...data, history: { ...data.history, ...Object.fromEntries([...answers].map(([id, durations]) => [String(id), [...durations]])) } }
78src/pane.tsx 51 lines1// Lines of spans to the pane's elements: one truncating Text per row; the run
2// name is a plain Button that opens the run in the browser. (A Link would
3// print its URL beside the name wherever the terminal lacks OSC 8, as inside
4// tmux.) Theme colour keys and dimColor only, never hex.
5import type { Elements } from 'claude-code'
6import type { Line, Span } from './line'
7
8export type PaneElements = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
9
10/** Cells of padding each side of the pane body. */
11export const PANE_PADDING = 1
12/** The widest the run view lays out: past this, durations drift too far from their names to read. */
13const MAX_BODY = 96
14
15/** The width the layout fills in a pane body this wide. */
16export const bodyWidth = (bodyColumns: number): number => Math.max(1, Math.min(MAX_BODY, bodyColumns - 2 * PANE_PADDING))
17
18const styleOf = ({ color, dim, bold }: Span) => ({
19 ...(color === undefined ? {} : { color }),
20 ...(dim === true ? { dimColor: true } : {}),
21 ...(bold === true ? { bold } : {}),
22})
23
24const isPlain = (part: Span): boolean => part.color === undefined && part.dim !== true && part.bold !== true
25
26const spanNode = (Text: PaneElements['Text'], part: Span) => (isPlain(part) ? part.text : <Text {...styleOf(part)}>{part.text}</Text>)
27
28const textNode = (Text: PaneElements['Text'], parts: readonly Span[]) => <Text wrap="truncate">{parts.map(part => spanNode(Text, part))}</Text>
29
30const lineNode = ({ Box, Text, Button }: PaneElements, line: Line, onOpen: (runId: number) => void) => {
31 const at = line.findIndex(part => part.opens !== undefined)
32 const control = line[at]
33 if (control?.opens === undefined) return textNode(Text, line)
34 const runId = control.opens
35 return (
36 <Box key={`run:${runId}`} flexDirection="row">
37 {textNode(Text, line.slice(0, at))}
38 <Button plain key={`open:${runId}`} hover={{ underline: true }} onPress={() => onOpen(runId)}>
39 <Text {...styleOf(control)}>{control.text}</Text>
40 </Button>
41 {textNode(Text, line.slice(at + 1))}
42 </Box>
43 )
44}
45
46export const paneTree = (elements: PaneElements, lines: readonly Line[], onOpen: (runId: number) => void) => (
47 <elements.Box flexDirection="column" paddingX={PANE_PADDING}>
48 {lines.map(line => lineNode(elements, line, onOpen))}
49 </elements.Box>
50)
51src/runtime.ts 385 lines1// The poller: one serialized poll at a time, rescheduled per the schedule,
2// a 1s tick while the pane is open, and the lifecycle's effects carried out.
3// Everything outside reaches it through Ports, so tests drive it on a fake
4// clock; hooks/register.tsx wires the ports to `$`.
5import type { ActionsData, ActionsJob, ActionsScope } from '../types'
6import { bandAt, isRunningBand } from './band'
7import { collectHead } from './collect/git'
8import { type GhFailure, type GhResult, listDurations, listRuns, repoName, viewJobs } from './collect/gh'
9import { readRef, trackingRefOf } from './collect/remote-ref'
10import { repoFromRemotes } from './collect/repo'
11import type { Run } from './collect/run'
12import { isActiveStatus } from './format'
13import { CLOSED, decide, type Effect, type Lifecycle, type LifecycleEvent, statusText } from './lifecycle'
14import { EMPTY_DATA, restoredData, runsToDetail, withHistory, withJobs, withRuns, workflowsToRead } from './model'
15import { KICK_MS, nextPollMs } from './schedule'
16import { listFilter } from './scope'
17import type { Settings } from './settings'
18import { buildSnapshot, type Snapshot } from './snapshot'
19
20export const TICK_MS = 1000
21/** How often the local remote-tracking ref is read: a push from any terminal moves it. Local git only. */
22export const REMOTE_WATCH_MS = 5000
23/** A ref that moves this soon after a kick is the push that was kicked. */
24const KICK_DEDUPE_MS = 2 * REMOTE_WATCH_MS
25
26export type Timer = { cancel: () => void }
27
28export type PaneState = { isOpen: boolean; isPlaced: boolean }
29
30export type Ports = {
31 run: Run
32 cwd: () => Promise<string>
33 now: () => Promise<number>
34 after: (ms: number, fn: () => void) => Timer
35 every: (ms: number, fn: () => void) => Timer
36 /** The $.state values the pane draws from. */
37 data: { get: () => Promise<ActionsData | null>; set: (value: ActionsData) => Promise<void> }
38 clock: { set: (now: number) => Promise<void> }
39 manual: { get: () => Promise<boolean>; set: (isManual: boolean) => Promise<void> }
40 scope: { get: () => Promise<ActionsScope | null>; set: (scope: ActionsScope) => Promise<void> }
41 /** Opens the pane unasked; resolves whether a surface draws it. */
42 open: () => Promise<{ isPlaced: boolean }>
43 close: () => Promise<void>
44 pane: () => Promise<PaneState>
45 status: (text: string | undefined) => void
46 /** Whether the footer tail (the band's text) is on screen right now. */
47 isFooterShowing: () => boolean
48 log: (text: string) => void
49}
50
51/** The answer to the person's `/actions`; a quiet one needs no transcript row. */
52export type Reply = { text: string; isQuiet?: true }
53
54const describe = (error: unknown): string => (error instanceof Error ? error.message : String(error))
55
56export const createRuntime = (ports: Ports, settings: Settings) => {
57 let data: ActionsData = EMPTY_DATA
58 let lifecycle: Lifecycle = CLOSED
59 let isPlaced = true
60 let lastCwd: string | null = null
61 let ghRepo: { cwd: string; repo: string } | null = null
62 let kickUntil = 0
63 let lastKickAt = Number.NEGATIVE_INFINITY
64 let pollTimer: Timer | null = null
65 let tickTimer: Timer | null = null
66 let remoteTimer: Timer | null = null
67 let isReadingRemote = false
68 /** The tracking ref being watched, for this branch in this cwd, and the sha it last held. */
69 let tracked: { key: string; ref: string; isConfigured: boolean; sha: string | null } | null = null
70 let polling: Promise<void> | null = null
71 let isPollQueued = false
72 /** Finished runs whose jobs were read after they finished: never asked again. */
73 let final = new Set<number>()
74
75 const fail = (where: string) => (error: unknown) => ports.log(`github-actions-pane: ${where}: ${describe(error)}`)
76
77 const scopeNow = async (): Promise<ActionsScope> => (await ports.scope.get()) ?? settings.scope
78
79 const snapshotAt = async (now: number): Promise<Snapshot> =>
80 buildSnapshot({ data, now, settings, scope: await scopeNow(), isManual: lifecycle.mode === 'manual' })
81
82 const publish = async (next: ActionsData): Promise<void> => {
83 data = next
84 await ports.data.set(next)
85 }
86
87 const showStatus = (snapshot: Snapshot, isRunningInBand: boolean): void => {
88 const isPaneShowing = lifecycle.mode !== 'closed' && isPlaced
89 // The footer line says what runs, when it is on screen; the status line keeps what it does not (failures).
90 const counts = isRunningInBand && ports.isFooterShowing() ? { ...snapshot.counts, running: 0, passed: 0 } : snapshot.counts
91 ports.status(isPaneShowing ? undefined : statusText(counts))
92 }
93
94 const bandTextAt = async (now: number): Promise<string | null> => bandAt({ data, now, settings, scope: await scopeNow() })
95
96 const startTick = (): void => {
97 tickTimer ??= ports.every(TICK_MS, () => void tick().catch(fail('tick')))
98 }
99
100 const stopTick = (): void => {
101 tickTimer?.cancel()
102 tickTimer = null
103 }
104
105 const apply = async (effect: Effect, now: number, open: () => Promise<{ isPlaced: boolean }> = ports.open): Promise<void> => {
106 if (effect === 'open') {
107 // The drawn clock stands still while closed: bring it up before the first frame.
108 await ports.clock.set(now)
109 isPlaced = (await open()).isPlaced
110 startTick()
111 }
112 if (effect === 'close') {
113 stopTick()
114 await ports.close()
115 isPlaced = true
116 }
117 await ports.manual.set(lifecycle.mode === 'manual')
118 }
119
120 /** The tick runs while the pane is open or the footer line shows; the clock is brought up when it starts. */
121 const syncTick = async (now: number, isBandShowing: boolean): Promise<void> => {
122 if (lifecycle.mode === 'closed' && !isBandShowing) return stopTick()
123 if (tickTimer !== null) return
124 await ports.clock.set(now)
125 startTick()
126 }
127
128 /** The status line, and the tick. */
129 const refresh = async (now: number): Promise<void> => {
130 const band = await bandTextAt(now)
131 showStatus(await snapshotAt(now), isRunningBand(band))
132 await syncTick(now, band !== null)
133 }
134
135 const step = async (event: (snapshot: Snapshot) => LifecycleEvent, now: number): Promise<void> => {
136 const snapshot = await snapshotAt(now)
137 const decision = decide(lifecycle, event(snapshot))
138 lifecycle = decision.state
139 await apply(decision.effect, now)
140 await refresh(now)
141 }
142
143 const viewOf = (snapshot: Snapshot) => ({ activeIds: snapshot.activeIds, shownIds: snapshot.shownIds })
144
145 const intervalAt = (now: number): number =>
146 nextPollMs({ settings, isActive: data.runs.some(run => run.status !== 'completed'), isRateLimited: data.isRateLimited, kickUntil, now })
147
148 const schedule = (now: number): void => {
149 pollTimer?.cancel()
150 pollTimer = null
151 if (data.disabled === null) pollTimer = ports.after(intervalAt(now), () => void poll().catch(fail('poll')))
152 }
153
154 /**
155 * One look at the tracking ref of the scoped branch. The first look at a
156 * branch is a baseline; a later change is a push from outside Claude. The
157 * push target is read again on each poll, and on every look while it is
158 * only guessed (origin's), so a first `git push -u <remote>` is seen.
159 */
160 const watchRemote = async (isPoll: boolean): Promise<void> => {
161 const context = data.context
162 if (context === null || context.branch === null || isReadingRemote) return
163 isReadingRemote = true
164 try {
165 const key = `${context.cwd}\0${context.branch}`
166 const known = tracked?.key === key ? tracked : null
167 const target = known !== null && known.isConfigured && !isPoll ? known : await trackingRefOf(ports.run, context.cwd, context.branch)
168 const read = await readRef(ports.run, context.cwd, target.ref)
169 if (read.kind === 'unknown') return
170 tracked = { key, ...target, sha: read.sha }
171 if (known === null) return
172 // Moved to a ref: a push if it moved, or if the push target just became known and holds the branch.
173 const isPush = known.ref === target.ref ? known.sha !== read.sha : !known.isConfigured && read.sha !== null
174 // A push through Claude's Bash was kicked the moment it ran: the ref moving is the same push.
175 if (isPush && (await ports.now()) - lastKickAt > KICK_DEDUPE_MS) await kick()
176 } finally {
177 isReadingRemote = false
178 }
179 }
180
181 /** The watch runs while a branch of a repository is in scope; one timer, never two. */
182 const syncRemoteWatch = (): void => {
183 const isWatchable = data.disabled === null && data.context?.branch != null
184 if (!isWatchable) {
185 remoteTimer?.cancel()
186 remoteTimer = null
187 tracked = null
188 return
189 }
190 remoteTimer ??= ports.every(REMOTE_WATCH_MS, () => void watchRemote(false).catch(fail('remote')))
191 // The baseline for this poll's branch, and its push target read afresh.
192 void watchRemote(true).catch(fail('remote'))
193 }
194
195 const disable = async (reason: string): Promise<void> => {
196 final = new Set()
197 await publish({ ...EMPTY_DATA, disabled: reason })
198 }
199
200 const onFailure = async (failure: GhFailure): Promise<void> => {
201 if (failure.kind === 'fatal') return disable(failure.reason)
202 if (failure.kind === 'rate-limited') return publish({ ...data, isRateLimited: true })
203 ports.log(`github-actions-pane: gh: ${failure.reason}`)
204 }
205
206 /** gh's own pick when git names no GitHub repo: it costs a request, so kept for the cwd until it moves. */
207 const repoViaGh = async (cwd: string): Promise<GhResult<string>> => {
208 if (ghRepo?.cwd === cwd) return { kind: 'ok', value: ghRepo.repo }
209 const asked = await repoName(ports.run, cwd)
210 if (asked.kind === 'ok') ghRepo = { cwd, repo: asked.value }
211 return asked
212 }
213
214 /** Reads the context; null when the poll stops here (now disabled, or a transient failure logged). */
215 const readContext = async (cwd: string): Promise<ActionsData['context']> => {
216 const head = await collectHead(ports.run, cwd)
217 if (head.kind === 'fatal') {
218 await disable(head.reason)
219 return null
220 }
221 if (head.kind === 'transient') {
222 ports.log(`github-actions-pane: git: ${head.reason}`)
223 return null
224 }
225 // Git's remotes are read every poll (cheap, local), so a branch switch to another push remote moves the pane.
226 const fromGit = await repoFromRemotes(ports.run, cwd, head.branch)
227 const repo: GhResult<string> = fromGit !== null ? { kind: 'ok', value: fromGit } : await repoViaGh(cwd)
228 if (repo.kind !== 'ok') {
229 await onFailure(repo)
230 return null
231 }
232 return { cwd, branch: head.branch, sha: head.sha, repo: repo.value }
233 }
234
235 const detail = async (cwd: string, repo: string, now: number): Promise<void> => {
236 // A re-run makes a finished run active again: read it afresh when it finishes again.
237 const active = new Set(data.runs.filter(run => isActiveStatus(run.status)).map(run => run.id))
238 final = new Set([...final].filter(id => !active.has(id)))
239 const wanted = runsToDetail(data, (await snapshotAt(now)).shownIds, final)
240 const answers = await Promise.all(wanted.map(async id => [id, await viewJobs(ports.run, cwd, repo, id)] as const))
241 const read = new Map<number, ActionsJob[]>()
242 for (const [id, answer] of answers) {
243 if (answer.kind !== 'ok') {
244 await onFailure(answer)
245 continue
246 }
247 read.set(id, answer.value)
248 if (data.runs.find(run => run.id === id)?.status === 'completed') final.add(id)
249 }
250 await publish(withJobs(data, read))
251 }
252
253 /** Past durations of the active runs' workflows, for the ETA: not while rate limited; a failed read is retried next poll. */
254 const readHistory = async (cwd: string, repo: string): Promise<void> => {
255 if (data.isRateLimited) return
256 const answers = await Promise.all(workflowsToRead(data).map(async id => [id, await listDurations(ports.run, cwd, repo, id)] as const))
257 const read = new Map<number, number[]>()
258 for (const [id, answer] of answers) {
259 if (answer.kind === 'ok') read.set(id, answer.value)
260 else await onFailure(answer)
261 }
262 await publish(withHistory(data, read))
263 }
264
265 /** A push (or a command that starts runs): poll now and at the active rate for a while. */
266 const kick = async (): Promise<void> => {
267 lastKickAt = await ports.now()
268 kickUntil = lastKickAt + KICK_MS
269 await poll()
270 }
271
272 const pollOnce = async (): Promise<void> => {
273 const cwd = await ports.cwd()
274 const now = await ports.now()
275 if (cwd !== lastCwd) final = new Set()
276 lastCwd = cwd
277 const context = await readContext(cwd)
278 if (context !== null) {
279 const filter = listFilter(await scopeNow(), context)
280 const listed: GhResult<ActionsData['runs']> = filter === null ? { kind: 'ok', value: [] } : await listRuns(ports.run, cwd, context.repo, filter)
281 // Another repository: nothing of the last one carries over, whatever the list says.
282 const isNewRepo = data.context !== null && data.context.repo !== context.repo
283 if (isNewRepo) final = new Set()
284 await publish({ ...(isNewRepo ? EMPTY_DATA : data), context, disabled: null })
285 if (listed.kind === 'ok') await publish(withRuns(data, listed.value, now))
286 else await onFailure(listed)
287 // The footer line shows from here, while history and job reads are still out: tick now.
288 if (tickTimer === null && data.runs.some(run => isActiveStatus(run.status))) await syncTick(await ports.now(), true)
289 await publish({ ...data, pollMs: intervalAt(now) })
290 if (data.disabled === null) await readHistory(cwd, context.repo)
291 // A rate limit met by the history read stops this poll's job reads too.
292 if (data.disabled === null && !data.isRateLimited) await detail(cwd, context.repo, now)
293 }
294 const settled = await ports.now()
295 await step(snapshot => ({ kind: 'poll', view: viewOf(snapshot), autoOpen: settings.autoOpen }), settled)
296 schedule(settled)
297 syncRemoteWatch()
298 }
299
300 /** One poll at a time; a poll asked for meanwhile runs once, right after. */
301 const poll = async (): Promise<void> => {
302 if (polling !== null) {
303 isPollQueued = true
304 return polling
305 }
306 polling = pollOnce().finally(() => {
307 polling = null
308 })
309 await polling
310 if (isPollQueued) {
311 isPollQueued = false
312 await poll()
313 }
314 }
315
316 const tick = async (): Promise<void> => {
317 const now = await ports.now()
318 await ports.clock.set(now)
319 isPlaced = (await ports.pane()).isPlaced
320 await step(snapshot => ({ kind: 'tick', view: viewOf(snapshot), autoOpen: settings.autoOpen }), now)
321 }
322
323 return {
324 /** Starts watching: restores what a reload left in state, then polls. */
325 start: async (): Promise<void> => {
326 data = restoredData(await ports.data.get())
327 const pane = await ports.pane()
328 if (pane.isOpen) {
329 lifecycle = { mode: (await ports.manual.get()) ? 'manual' : 'auto', dismissed: [] }
330 isPlaced = pane.isPlaced
331 startTick()
332 }
333 await poll()
334 },
335 poll,
336 /** A command that starts runs ran: poll now and at the active rate for a while. */
337 kick,
338 /** The session's cwd may have moved (a Bash call): a new place is checked afresh. */
339 cwdMaybeChanged: async (): Promise<void> => {
340 if ((await ports.cwd()) !== lastCwd) await poll()
341 },
342 /** `/actions`: opens (asked, through `open`) or closes; idle, says why. */
343 toggle: async (open: () => Promise<{ isPlaced: boolean }>): Promise<Reply> => {
344 if (lifecycle.mode === 'closed' && data.disabled !== null) await poll()
345 if (lifecycle.mode === 'closed' && data.disabled !== null) return { text: `Actions pane: ${data.disabled}.` }
346 const now = await ports.now()
347 isPlaced = lifecycle.mode === 'closed' || (await ports.pane()).isPlaced
348 const decision = decide(lifecycle, { kind: 'toggle', view: viewOf(await snapshotAt(now)), isPlaced })
349 lifecycle = decision.state
350 await apply(decision.effect, now, open)
351 await refresh(now)
352 if (decision.effect === 'open') void poll().catch(fail('poll'))
353 return { text: decision.effect === 'open' ? 'Actions pane opened.' : 'Actions pane closed.', isQuiet: true }
354 },
355 /** `/actions <scope>`: this session only. */
356 setScope: async (scope: ActionsScope): Promise<Reply> => {
357 await ports.scope.set(scope)
358 final = new Set()
359 await poll()
360 return { text: `Actions pane scope: ${scope} (this session).` }
361 },
362 /** The person closed the pane by hand. */
363 closedByPerson: async (): Promise<void> => {
364 const now = await ports.now()
365 const decision = decide(lifecycle, { kind: 'closedByPerson', view: viewOf(await snapshotAt(now)) })
366 lifecycle = decision.state
367 await ports.manual.set(false)
368 await refresh(now)
369 },
370 /** A /clear emptied the session's state: put back what the pane draws from. */
371 republish: async (): Promise<void> => {
372 await ports.data.set(data)
373 await ports.manual.set(lifecycle.mode === 'manual')
374 await ports.clock.set(await ports.now())
375 },
376 stop: (): void => {
377 pollTimer?.cancel()
378 remoteTimer?.cancel()
379 stopTick()
380 },
381 }
382}
383
384export type Runtime = ReturnType<typeof createRuntime>
385src/settings.ts 37 lines1// The mod's userConfig options to settings, with the poll floors held. Pure.
2import type { ActionsScope } from '../types'
3
4export type Settings = {
5 scope: ActionsScope
6 lingerMs: number
7 activePollMs: number
8 idlePollMs: number
9 autoOpen: boolean
10}
11
12const SECOND = 1000
13const ACTIVE_FLOOR_MS = 5 * SECOND
14const IDLE_FLOOR_MS = 15 * SECOND
15export const SCOPES: readonly ActionsScope[] = ['commit', 'branch', 'repo']
16
17export const DEFAULT_SETTINGS: Settings = {
18 scope: 'branch',
19 lingerMs: 30 * SECOND,
20 activePollMs: 10 * SECOND,
21 idlePollMs: 60 * SECOND,
22 autoOpen: false,
23}
24
25export const isScope = (value: unknown): value is ActionsScope => SCOPES.includes(value as ActionsScope)
26
27const secondsOf = (value: unknown, fallbackMs: number, floorMs: number): number =>
28 typeof value === 'number' && Number.isFinite(value) ? Math.max(floorMs, Math.round(value * SECOND)) : fallbackMs
29
30export const parseSettings = (options: Readonly<Record<string, unknown>>): Settings => ({
31 scope: isScope(options.scope) ? options.scope : DEFAULT_SETTINGS.scope,
32 lingerMs: secondsOf(options.lingerSeconds, DEFAULT_SETTINGS.lingerMs, 0),
33 activePollMs: secondsOf(options.activePollSeconds, DEFAULT_SETTINGS.activePollMs, ACTIVE_FLOOR_MS),
34 idlePollMs: secondsOf(options.idlePollSeconds, DEFAULT_SETTINGS.idlePollMs, IDLE_FLOOR_MS),
35 autoOpen: typeof options.autoOpen === 'boolean' ? options.autoOpen : DEFAULT_SETTINGS.autoOpen,
36})
37src/snapshot.ts 119 lines1// What the pane draws, from what the poller holds: the runs in scope that are
2// active or lingering, each as a card of jobs and steps, plus the header.
3// Every decision but the fit to the pane's size is here. Pure.
4import type { ActionsData, ActionsRun, ActionsScope } from '../types'
5import { estimateEta } from './eta'
6import { formatDuration, isActiveStatus, type RowStatus, statusOf } from './format'
7import { type JobBlock, jobsToBlocks } from './jobs'
8import { effectiveScope, isInScope, scopeLabel, shortSha } from './scope'
9import type { Settings } from './settings'
10
11export type { JobBlock, Row } from './jobs'
12
13export type Card = {
14 id: number
15 status: RowStatus
16 /** `CI #482`. */
17 name: string
18 event: string
19 /** The commit or PR title, then the short sha (the branch in repo scope). */
20 subtitle: string
21 durationMs: number | null
22 /** Estimated time left (negative once over the estimate); set only on running cards with enough history. */
23 remainingMs?: number
24 isActive: boolean
25 /** Jobs and the steps they show, newest-run-first cards; the layout windows the steps. */
26 jobs: JobBlock[]
27}
28
29export type Counts = { running: number; failed: number; passed: number }
30
31export type Snapshot = {
32 /** The branch, short sha or owner/repo the pane watches; '' while disabled. */
33 label: string
34 /** `rate limited`, `stale · 40s`, or why the mod is idle; null when all is well. */
35 note: string | null
36 cards: Card[]
37 counts: Counts
38 activeIds: number[]
39 shownIds: number[]
40}
41
42export type SnapshotInputs = {
43 data: ActionsData
44 now: number
45 settings: Settings
46 /** The scope in force: the session's `/actions` choice, else the setting. */
47 scope: ActionsScope
48 /** Opened by `/actions`: shows the latest run when nothing is active. */
49 isManual: boolean
50}
51
52/** Data older than this many poll intervals is called stale. */
53const STALE_POLLS = 2
54
55const isLingering = (run: ActionsRun, data: ActionsData, now: number, lingerMs: number): boolean => {
56 const doneAt = data.watched[String(run.id)]
57 return typeof doneAt === 'number' && now - doneAt < lingerMs
58}
59
60/** Time left on a running run, from how long its workflow's past successes took; null when it can't be said. */
61const remainingOf = (run: ActionsRun, elapsedMs: number, data: ActionsData): number | null =>
62 run.status !== 'in_progress' || run.workflowId === null ? null : estimateEta(data.history[String(run.workflowId)] ?? [], elapsedMs)
63
64const cardOf = (run: ActionsRun, { data, now, scope }: SnapshotInputs): Card => {
65 const isActive = isActiveStatus(run.status)
66 const start = run.startedAt ?? run.createdAt
67 const durationMs = (isActive ? now : run.updatedAt) - start
68 const remainingMs = remainingOf(run, durationMs, data)
69 const where = data.context !== null && effectiveScope(scope, data.context) === 'repo' ? run.branch : shortSha(run.sha)
70 return {
71 id: run.id,
72 status: statusOf(run.status, run.conclusion),
73 name: `${run.workflow} #${run.number}`,
74 event: run.event,
75 subtitle: [run.title, where].filter(part => part !== '').join(' · '),
76 durationMs,
77 ...(remainingMs === null ? {} : { remainingMs }),
78 isActive,
79 jobs: jobsToBlocks(data.jobs[String(run.id)] ?? [], now),
80 }
81}
82
83const noteOf = ({ data, now }: SnapshotInputs): string | null => {
84 if (data.disabled !== null) return data.disabled
85 if (data.isRateLimited) return 'rate limited'
86 const age = data.fetchedAt === null ? 0 : now - data.fetchedAt
87 return age > STALE_POLLS * data.pollMs ? `stale · ${formatDuration(age)}` : null
88}
89
90const countsOf = (cards: readonly Card[]): Counts => ({
91 running: cards.filter(card => card.isActive).length,
92 failed: cards.filter(card => card.status === 'failure').length,
93 passed: cards.filter(card => card.status === 'success').length,
94})
95
96/** The runs to draw: active or lingering in scope; by hand with none, the latest. */
97const shownRuns = (inputs: SnapshotInputs): ActionsRun[] => {
98 const { data, now, settings, scope, isManual } = inputs
99 const context = data.context
100 if (context === null || data.disabled !== null) return []
101 const inScope = [...data.runs].filter(run => isInScope(run, scope, context)).sort((a, b) => b.createdAt - a.createdAt)
102 const shown = inScope.filter(run => isActiveStatus(run.status) || isLingering(run, data, now, settings.lingerMs))
103 return shown.length === 0 && isManual ? inScope.slice(0, 1) : shown
104}
105
106export const buildSnapshot = (inputs: SnapshotInputs): Snapshot => {
107 const { data, scope } = inputs
108 const runs = shownRuns(inputs)
109 const cards = runs.map(run => cardOf(run, inputs))
110 return {
111 label: data.context === null || data.disabled !== null ? '' : scopeLabel(scope, data.context),
112 note: noteOf(inputs),
113 cards,
114 counts: countsOf(cards),
115 activeIds: cards.filter(card => card.isActive).map(card => card.id),
116 shownIds: cards.map(card => card.id),
117 }
118}
119src/eta.ts 29 lines1// A run's time remaining, estimated from how long the workflow's past
2// successful runs took. Pure.
3import { formatDuration } from './format'
4
5const SECOND = 1000
6const MINUTE = 60 * SECOND
7const HOUR = 60 * MINUTE
8
9/** Fewer past runs than this say too little about how long the next takes. */
10export const MIN_SAMPLES = 3
11
12const medianOf = (values: readonly number[]): number => {
13 const sorted = [...values].sort((a, b) => a - b)
14 const middle = Math.floor(sorted.length / 2)
15 return sorted.length % 2 === 1 ? (sorted[middle] ?? 0) : ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
16}
17
18/** The median of past durations less the elapsed time (negative once over); null with too few samples. */
19export const estimateEta = (samples: readonly number[], elapsedMs: number): number | null =>
20 samples.length < MIN_SAMPLES ? null : medianOf(samples) - elapsedMs
21
22/** `~2m left` (minutes rounded up), `~1h 04m left`, `<1m left`, or `over est.`. */
23export const etaLabel = (remainingMs: number): string => {
24 if (remainingMs <= 0) return 'over est.'
25 if (remainingMs < MINUTE) return '<1m left'
26 const minutes = Math.ceil(remainingMs / MINUTE)
27 return minutes * MINUTE >= HOUR ? `~${formatDuration(minutes * MINUTE)} left` : `~${minutes}m left`
28}
29src/format.ts 75 lines1// Durations, truncation and the status glyphs the pane draws. Pure.
2
3const SECOND = 1000
4const MINUTE = 60 * SECOND
5const HOUR = 60 * MINUTE
6const ELLIPSIS = '…'
7
8/** What a row's status draws as, folded from GitHub's status and conclusion. */
9export type RowStatus = 'success' | 'failure' | 'running' | 'queued' | 'cancelled' | 'action'
10
11/** Which level a row sits at: the run and its jobs spin as ◐, a step as the spinner. */
12export type RowLevel = 'run' | 'job' | 'step'
13
14/** A glyph and its tint: a theme colour key, or dimmed. */
15export type Glyph = { glyph: string; color?: string; dim?: true }
16
17export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
18
19const pad2 = (n: number): string => String(n).padStart(2, '0')
20
21/** `0s`, `42s`, `1m 04s`, `1h 04m`; null is no duration (''), negative skew is 0s. */
22export const formatDuration = (ms: number | null): string => {
23 if (ms === null) return ''
24 const clamped = Math.max(0, ms)
25 if (clamped < MINUTE) return `${Math.floor(clamped / SECOND)}s`
26 if (clamped < HOUR) return `${Math.floor(clamped / MINUTE)}m ${pad2(Math.floor((clamped % MINUTE) / SECOND))}s`
27 return `${Math.floor(clamped / HOUR)}h ${pad2(Math.floor((clamped % HOUR) / MINUTE))}m`
28}
29
30/** Cells a text takes: one per code point (the pane draws no wide glyphs in names). */
31export const cellsOf = (text: string): number => [...text].length
32
33/** The text cut to `width` cells, ending in an ellipsis (no space before it); as is when it fits. */
34export const truncate = (text: string, width: number): string => {
35 const cells = [...text]
36 if (cells.length <= width) return text
37 if (width <= 0) return ''
38 return cells.slice(0, width - 1).join('').trimEnd() + ELLIPSIS
39}
40
41const FAILED = new Set(['failure', 'timed_out', 'startup_failure'])
42const QUIET = new Set(['cancelled', 'skipped', 'neutral', 'stale'])
43
44export const statusOf = (status: string, conclusion: string | null): RowStatus => {
45 if (status === 'in_progress') return 'running'
46 if (status !== 'completed') return 'queued'
47 if (conclusion === 'success') return 'success'
48 if (conclusion !== null && FAILED.has(conclusion)) return 'failure'
49 if (conclusion === 'action_required') return 'action'
50 if (conclusion !== null && QUIET.has(conclusion)) return 'cancelled'
51 return 'queued'
52}
53
54export const isActiveStatus = (status: string): boolean => status !== 'completed'
55
56const spinnerAt = (frame: number): string => SPINNER[((frame % SPINNER.length) + SPINNER.length) % SPINNER.length] ?? SPINNER[0]
57
58/** The glyph a row of this status draws at this level, on this spinner frame. */
59export const glyphOf = (status: RowStatus, level: RowLevel, frame: number): Glyph => {
60 switch (status) {
61 case 'success':
62 return { glyph: '✓', color: 'success' }
63 case 'failure':
64 return { glyph: '✗', color: 'error' }
65 case 'running':
66 return { glyph: level === 'step' ? spinnerAt(frame) : '◐', color: 'warning' }
67 case 'action':
68 return { glyph: '!', color: 'warning' }
69 case 'cancelled':
70 return { glyph: '⊘', dim: true }
71 case 'queued':
72 return { glyph: '○', dim: true }
73 }
74}
75