SLOPSHOPPER

github-actions-pane

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…

newpanespinnerguardcommandstatus
★ 1v0.3.1Apache-2.0updated 2026-10-12blackpaw-studio/claude-plugins/plugins/github-actions-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · github-actions-pane
│ ┃ Actions ✕ › fix the failing auth test and add an audit log call │ ┃ GitHub Actions │ ● github-actions-pane: github-actions-pane: gh: gh returned output th │ ● github-actions-pane: github-actions-pane: gh: gh returned output th │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /actions │ ● github-actions-pane: github-actions-pane: gh: gh returned output th │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Actions
GitHub Actions
README

github-actions-pane

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.

Screenshots

Two runs in flight A failed deploy beside a running CI A run from start to finish This repository's own CI run, captured from a real session

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

Inline above the prompt

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

Status line fallback

Requirements

  • Claude Code 2.1.296 or later (the build it was tested on).
  • Function-hook plugins (mods) are early access, and the API may change between releases.
  • gh, logged in (gh auth login), and a checkout with a GitHub remote.

Install

From this marketplace:

/plugin marketplace add blackpaw-studio/claude-plugins
/plugin install github-actions-pane@blackpaw-plugins

Use

Push, or start a workflow, and the footer line appears on the next poll. Run /actions to open the pane.

Command
/actionsOpen 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 commitWatch the runs for HEAD only, for this session.
/actions branchWatch the runs on the current branch, for this session.
/actions repoWatch 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.

Settings

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

SettingDefault
Scopebranchcommit (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 seconds30How long finished runs stay before the pane closes; 0 closes at once
Active poll seconds10Poll interval while a run is active; at least 5
Idle poll seconds60Poll interval while nothing is active; at least 15
Open automaticallyoffOn: 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

Behaviour

  • Polling: each poll makes one 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.
  • Pushes from any terminal: every 5 seconds a local 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.
  • What shows: runs that are active, plus the runs this session saw finish, for the linger window. A run that had already finished when it was first seen does not open the pane.
  • Jobs and steps: passed jobs take one line. A running job lists its steps, and a failed job lists only its failed steps. A run with more than eight jobs folds the passed ones into "N jobs passed". When the pane is short, the finished steps of running jobs fold into "N steps" around the current one. Then older finished runs drop out, and anything still left over ends in "+N more".
  • Time remaining: a running run's card shows an estimate after its elapsed time (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.
  • Where it draws: a pane that opens by itself seats from 144 columns, or from 110 once you have opened it there yourself. Narrower, it waits and the status line shows ◐ 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.
  • Rate limits: a rate-limited 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.
  • Off quietly: outside a git repository, without 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.
  • Terminal only: the pane's body is drawn only in the terminal.

Limitations

  • A run stays active until gh says completed, so a run's header can keep ticking for up to one poll after its last job finished.
  • A push is detected within about 5 seconds from any terminal, by the remote-tracking ref moving. A push that leaves the ref alone, to a remote other than the branch's push target, is only seen on the next idle poll. The same goes for a tag pushed from outside Claude: it moves no branch ref, so its run shows on the next idle poll. A git push through Claude's Bash, tags included, polls at once.
  • The repository comes from git remotes, not 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.
  • The linger and the stale note use your local clock, measured from when a poll saw the change, not from GitHub's timestamps.
  • Some terminal setups (inside tmux, for one) do not support OSC 8 hyperlinks, so the run name is a button that runs gh run view --web rather than a link.

Development

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 demo

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.

Source 24 files
hooks/register.tsx 157 lines
1// 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}
157
src/band.ts 37 lines
1// 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 }))
37
src/footer.ts 38 lines
1// 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}
38
src/kick.ts 31 lines
1// 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)
31
src/layout.ts 137 lines
1// 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}
137
src/model.ts 78 lines
1// 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]])) } }
78
src/pane.tsx 51 lines
1// 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)
51
src/runtime.ts 385 lines
1// 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>
385
src/settings.ts 37 lines
1// 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})
37
src/snapshot.ts 119 lines
1// 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}
119
src/eta.ts 29 lines
1// 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}
29
src/format.ts 75 lines
1// 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