SLOPSHOPPER

kane-qe

Watches kane-cli (KaneAI) runs in Claude Code: a band above the prompt with the kane mascot, a pane for runs, steps, failures and assurance coverage, and a…

panebandrowsguardcommandtoast
v2.0.0MITupdated 2026-10-26LambdaTest/kane-claude-plugin/kane-qe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kane-qe
│ ┃ Kane ✕ › fix the failing auth test and add an audit log call │ ┃ ▄▀▄▄▀▄ kane │ ┃ ▀▀▀▀▀▀▀▀ idle ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀ [ Runs ] [ Assurance ] [ History ] ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No kane-cli run in this session yet. Ask ⎿ Added 2 lines, removed 1 line │ ┃ Claude to test something, or run kane-cli in ⏺ Bash(bun test) │ ┃ a terminal here: it shows up by itself. ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /kane │ ⎿ kane-qe: Kane pane opened. It follows every kane-cli run in this │ │ ▄▀▄▄▀▄ idle ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ assurance not set up [ Set up now ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▄▀▄▄▀▄ idle ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ assurance not set up [ Set up now ]
Pane · Kane
▄▀▄▄▀▄ kane ▀▀▀▀▀▀▀▀ idle ▀▀▀▀▀▀▀▀ [ Runs ] [ Assurance ] [ History ] No kane-cli run in this session yet. Ask Claude to test something, or run kane-cli in a terminal here: it shows up by itself.
README

kane-qe v2: watch kane-cli from Claude Code

A Claude Code mod that shows what kane-cli (KaneAI) is doing, without being asked. Claude runs kane-cli; the mod reads what kane-cli leaves on disk and shows it. The mod never starts a test by itself.

Install

/plugin install kane-qe --marketplace LambdaTest/kane-claude-plugin

The documentation covers everything below step by step. It needs kane-cli installed and logged in (npm install -g @testmuai/kane-cli, then kane-cli login --oauth).

What you see

SurfaceWhat it shows
Band (three rows above the prompt, under one blank row, the kane mascot on the left)Row 1: idle with this session's passed/failed counts (each test once, by its latest result, so a failure that has since passed is gone), running with the test's name and time, N runs with one coloured cell per run, or suite with one cell per test and 5 of 12 tests done · 3 running · 4 left. A suite sent to the remote grid (testrun run --remote) reads 12 tests on the grid · sent 2m 05s ago until the job ends, because the grid reports its tests only then. Row 2: the step a single run is on, behind a spinner; now and each running test with its time, behind the same spinner; a failure the moment it happens (✗ checkout_guest failed on Fill the shipping address); last run ✓ login · 2h ago from earlier sessions; or ⚠ 2 files changed, untested [ Test this change ]. Row 3: assurance, 47% proven · 4 use cases, or not set up [ Set up now ]. The mascot's eyes move while a test runs (one step every 120 ms), and the spinner steps with them.
Pane (/kane or [ Open ])Runs: a card per run (failed first, red border), or one row per test for a suite. Press a name for its steps: done steps with a tick and time, the current or failing step expanded to think, act and each check, the failure's why, kind (kane's category, severity, confidence) and where, then Open evidence. For a remote suite the pane names the grid, the job and its link. Assurance: designed and proven bars, failing/blocked counts and when it last ran, a card per use case with what it still owes, or a card that offers to set it up with Claude. Press a use case for its detail: its title and risk, then each thing it owes under To design and To run, with kane-cli's reason and its own next command; Close these gaps with Claude hands that to Claude. History: the finished runs kept for this project, newest first, each with when it ran, how long it took, and where it failed or a suite's passed/failed counts.
Card in the chatA kane-cli test Claude runs (run, testmd run, testrun run) is drawn in the conversation as a card, in place of its shell row: one line while it runs (a spinner, name, time, step), then a bordered card with the mascot, the result (✓ passed · 41s · 12.4 credits), a failure's step and kane's reason on one line (its kind is in the pane), or a suite's cells, counts and up to three failures. A suite that ended passed with every test passed says ✓ all 3 passed in place of its counts. The card is kept short, with no blank rows, and a failed test's card is as short as a passed one (three rows with buttons): every finished card closes with a Verified with KaneAI badge on its last row, to the right of the buttons: a solid chip in kane's colour behind a shield (⛨). The border is red when the run failed, green when it passed. View steps / View tests opens the pane on that run; View evidence starts kane-cli's evidence viewer for the run's pack, and the button becomes an Open evidence viewer ↗ link you click to open it in your browser. Where the chat is printed text (the terminal outside fullscreen) the card ends with the evidence path instead of buttons. A command that never became a run (kane-cli refused or failed to start) keeps its normal row, and so does every other command. The chat folds shell calls into one count line; a group holding one run stays folded with the run's card beneath that line, so the other calls are not spread out. A group holding several runs, or a run call the host gives no id, is unfolded so each row draws its own card.
After Claude changes codeWhen a turn ends with edited files (by the edit tools or a shell command; not docs, hidden tool folders or git-ignored files) and no kane-cli run since, the band warns and offers Test this change. The card lists the changed files and saved tests that mention the same feature, with their last result and Run it; equal matches put the test that last failed first, then the one run most recently. Test with a new objective fills the prompt box with a request for Claude to read the change's git diff, write one test objective and run it with the app's start URL; you read it and press Enter to send it. A kane-cli run that starts after the last edit clears the warning.

Settings

/config → kane-qe:

SettingDefault
After Claude changes codeofferoffer: the band offers a test. auto: Claude is held once at the end of its turn to test the change. off: nothing

/kane auto, /kane ask, /kane off change it for this session. Colours follow Claude Code's theme (the design's palette on dark, a darker one on light), and every band row fits its width: names are cut first, then items drop from the right, [ Open ] stays. /kane assurance opens the Assurance tab and /kane history the History tab.

How it reads kane-cli

  • Live runs: ~/.testmuai/kaneai/sessions/active/<pid>.json, once a second; only runs whose cwd is inside this project, and only while the pid is alive.
  • Each run's events.ndjson (and each suite member's own stream, read once more when it ends), from where it last stopped. Structured lines only, never kane-cli's human output. Unknown events and fields are skipped; lines that are not JSON are skipped and counted; a stream with a newer wire version shows its name and time and says so.
  • Names come from the command Claude ran (kane-cli testmd run login_test.md → login_test.md); for a run nobody started in this chat, from its own process's command line (ps -o args= -p <pid>); then kane-cli's own recording_state, then the objective.
  • Failure facts come from run_end.verdict (root cause, category, severity, confidence), never guessed.
  • A run's card is its shell call's row: the mod notes the call's id when Claude runs a kane-cli test and keeps it on the run that starts.
  • Evidence: kane-cli seals a run's pack as it exits, at <session dir>/evidence/<id>.evidence; a suite's is <project>/.testmuai/evidence/<execution id>.evidence. The mod looks for it for half a minute after the run ends. View evidence starts kane-cli evidence serve <pack>, reads the viewer link it prints and draws it as a link you click; the viewer serves until the session ends. With no pack or no viewer, the prompt box is filled with a request for Claude to open it instead.
  • A remote suite: remote_start (the grid) and remote_dispatched (the job and its link) while the job runs; its tests arrive after the fact, stamped with the grid's own time. A job that fails before any test reports (remote_error) gives every test that reason.
  • Assurance: kane-cli cover gaps --json at session start and after each run, when the project has a .context folder. Each use case keeps up to 20 of its pending rows (title, why, risk, stage, ready_command) for its detail view; its counts cover them all.
  • The last 30 results per project are kept in the mod's store (recentByProject, apart from v1's history) for last run … and the History tab. Entries kept by earlier versions (label, status and time only) still read.

What this mod runs and sends

The mod makes no network requests of its own. It reads files on this machine, starts the local programs below, and hands text to Claude in this conversation.

Programs it starts (each with a fixed argument list; <pid>, <offset>, <path> and <pack> are filled in from kane-cli's own files):

CommandWhenWhy
kill -0 <pid>once a second while a run is liveto tell whether a kane-cli run is still going
ps -o args= -p <pid>once per run nobody started in this chatto name the run from its command line
tail -c +<offset> <events file>while a run is liveto read only the new lines of kane-cli's event stream
git check-ignore -q <path>after Claude edits a fileto skip git-ignored files when deciding what changed
kane-cli cover gaps --jsonat session start and after each run, when the project has a .context folderto read assurance coverage (read-only, no credits)
kane-cli evidence serve <pack>only when you press View evidencekane-cli's own evidence viewer, served on this machine

Every command above is written out in full in the code: the program is always the one named, and only <pid>, <offset>, <path> and <pack> are filled in. It opens no browser itself: links are drawn for you to click. kane-cli is found on your PATH. What kane-cli itself does is kane-cli's, as its own documentation describes.

What it reads: kane-cli's live-run pointers and event streams under ~/.testmuai/kaneai/sessions/, the project's .testmuai/ folder (saved tests, variables, evidence packs), Claude Code's theme setting, HOME. It keeps the last 30 results per project in its own store, on this machine.

What it hands to Claude, and so to the model this Claude Code session uses:

  • Prompts it fills in on a button press. The mod never sends a prompt: it puts the text in the prompt box, and you read it and press Enter to send it, or edit or clear it:
  • Run it: Test my last change with the saved kane-cli test: kane-cli testmd run .testmuai/tests/<test> … --agent --headless. Then report the result.
  • Test with a new objective: Test my last change with kane-cli: read \git diff -- <changed files>\, write one test objective …, then run kane-cli run "<objective>" … --agent --headless. Then report the result.
  • Close these gaps with Claude: Close the assurance gaps of kane-cli use case <id> …
  • Set it up with Claude: Set up kane-cli assurance for this project …
  • View evidence, when no viewer could be started: Open the evidence for the kane-cli run in <path>.
  • In auto mode only: when Claude's turn ends with untested changes, the mod asks Claude once to test them, naming the changed files and the saved tests that match.

What it never does: start a test by itself, send a prompt, call the model itself, open a browser, change Claude Code's settings or permissions, or answer a permission prompt. It hooks no setting: it reads Claude Code's theme every two seconds to match its colours.

For directory reviewers

How the mod's code answers each point the plugin directory's policy check raises:

The directory's noteWhere in the codeWhat the mod does
Starts other programshooks/register.tsx: alive, readNew, processLabel, ignored, refreshAssurance, startViewerOnly the six commands in the table above, each a fixed argument list. None is built from the conversation, a setting or a file's contents; only process ids, byte offsets, file paths and evidence pack paths are filled in.
Reads local data / the conversation, and can send data outpoll, readNew, savedTests; the tool.call hookThe mod makes no network requests. It reads kane-cli's own files and Claude's tool calls: the commands Claude runs, to recognise kane-cli commands and name their runs, and the paths of files Claude edits, to know what changed. Text reaches the model only when you send a prompt the mod filled in, after reading it in the prompt box, or through the opt-in auto mode's one Stop-hook request.
Submits prompts that can carry text outfill, called from act and openEvidenceThe mod submits no prompts. A button fills the prompt box ($.prompt.fill), and you decide whether to send it. Their full text is listed above.
Takes a permission decision out of the user's handscommand.run hook for /kaneThis hook answers only the mod's own /kane command, registered by that literal name; claude plugin validate reports it as "answers its own command", not as a gate. It opens the pane's tabs and sets this session's offer / auto / off mode. It never changes a permission, a setting, Remote Control or an agent's permission mode.
Hooks tool calls and the end of a turntool.call, classic.Stop hookstool.call always passes the call on unchanged with next(e); it only notes kane-cli commands and the files a call changed. classic.Stop keeps any block another hook made, and adds one only in opt-in auto mode, once per change.

Develop

claude plugin validate kane-qe
claude plugin test kane-qe          # adapter table, band states at 60/80/120 columns, recorded streams, pane and band on terminal and desktop, change tracking, stale pointers, assurance and use-case detail, remote suites, history, the card in the chat
node kane-qe/tests/fixtures/build.mjs   # after adding a recorded stream to tests/fixtures/*.ndjson

Layout: hooks/adapter.ts (wire line → internal event, the only place raw field names appear), hooks/model.ts (pure: events → runs, runs → band rows and pane text), hooks/sources.ts (pointer and byte-offset helpers), hooks/register.tsx (polling, hooks and drawing), hooks/mascot.ts (the mascot's 8×6 pixel poses: a cell grid in terminals, an SVG of the same pixels elsewhere).

v1 (the cockpit that starts runs, with Insights, Tests, History and Setup tabs and model tools) is on the main branch.

Source 6 files
hooks/register.tsx 1239 lines
1// kane-qe v2: watches kane-cli and shows it: a band of three rows above the prompt (with the mascot),
2// a pane for runs and assurance, and a nudge to test what Claude just changed.
3// The mod never starts a test: Claude runs kane-cli, the mod reads what kane-cli leaves on disk.
4import { atom, read, update } from 'claude-code'
5import type { Elements, EngineInterface, Register, RenderSurface, TextProps } from 'claude-code'
6import type { AfterChange, Assurance, Change, HistoryEntry, Kind, Offer, Run, Status, Step, UseCase, View } from '../types'
7import { adapt, splitLines } from './adapter'
8import type { Ev } from './adapter'
9import { RANK } from './adapter'
10import { PICTURE, RASTER, STEP_MS, mascotSvg, pixelFrame, poseAt, rasterCells, spinnerCells, spinnerGlyph } from './mascot'
11import {
12  C,
13  HISTORY_MAX,
14  ORDER,
15  band,
16  bar,
17  cardFailures,
18  cardFooter,
19  cardHeader,
20  currentStep,
21  cells,
22  cut,
23  debt,
24  elapsed,
25  firstLine,
26  fmt,
27  fold,
28  foldMember,
29  gapsPrompt,
30  gridSince,
31  historyDetail,
32  historyEntry,
33  isRunCommand,
34  isTrackedEdit,
35  kindLine,
36  latest,
37  labelFromCommand,
38  leaves,
39  newRun,
40  nm,
41  nowText,
42  onGrid,
43  parseCoverGaps,
44  ago,
45  savedTestsFor,
46  startUrl,
47  summary,
48  tally,
49  usePalette,
50} from './model'
51import type { Item, Row, Span } from './model'
52import { MAX_READ, afterBytes, inside, parsePointer, utf8Len } from './sources'
53import type { Pointer } from './sources'
54
55const PANE = 'kane-qe'
56
57const runsAtom = atom({ plugin: 'kane-qe', key: 'runs' } as const, [] as Run[])
58const viewAtom = atom({ plugin: 'kane-qe', key: 'view' } as const, { tab: 'runs', open: '' } as View)
59const tickAtom = atom({ plugin: 'kane-qe', key: 'tick' } as const, 0)
60const assuranceAtom = atom({ plugin: 'kane-qe', key: 'assurance' } as const, { state: 'unknown' } as Assurance)
61const changeAtom = atom({ plugin: 'kane-qe', key: 'change' } as const, null as Change | null)
62const offerAtom = atom({ plugin: 'kane-qe', key: 'offer' } as const, null as Offer | null)
63const modeAtom = atom({ plugin: 'kane-qe', key: 'mode' } as const, '' as '' | AfterChange)
64const lastAtom = atom({ plugin: 'kane-qe', key: 'last' } as const, null as HistoryEntry | null)
65const historyAtom = atom({ plugin: 'kane-qe', key: 'history' } as const, [] as HistoryEntry[])
66/** The link each evidence viewer this session started printed, by pack: drawn as a link the person clicks. */
67const viewerAtom = atom({ plugin: 'kane-qe', key: 'viewers' } as const, {} as Record<string, string>)
68
69type Kit = Pick<Elements['mobile'], 'Box' | 'Text' | 'Button'>
70type Table = Elements[RenderSurface]
71type Press = (action: string) => void
72
73function glyphOf(s: Status): [string, string] {
74  const all: Record<Status, [string, string]> = { running: ['◉', C.cyan], passed: ['✓', C.mint], failed: ['✗', C.coral], pending: ['○', C.dim] }
75  return all[s]
76}
77
78// ── module state: it starts over on a reload; what must last is in `$.state` / `$.store` ──
79
80let setting: AfterChange = 'offer'
81let projectDir = ''
82let activeDir = ''
83/** One per file being followed: a run's own stream, or a suite member's. */
84type Watch = { id: string; path: string; pointer?: string; pid?: number; bytes: number; rest: string; done: boolean; member?: string; parent?: string; closing?: boolean }
85const watches = new Map<string, Watch>()
86const seenPointers = new Set<string>()
87/** kane-cli commands Claude ran, to name the runs they start. */
88const commands: { label: string; surface: Kind; at: number; used: boolean; toolUseId?: string }[] = []
89/** Evidence viewers this session started, by pack: the link each one printed. */
90const viewers = new Map<string, string>()
91/** Ended runs whose evidence pack is still being looked for, by run id: when to stop looking. */
92const seeking = new Map<string, number>()
93let polling = false
94const sites = new Set<string>()
95/** Sites that draw the running-step spinner: repainted in place with the mascot. */
96const spinSites = new Set<string>()
97let frame = 0
98/** The palette drawn now, and how often Claude Code's theme is read again. */
99let palette: 'light' | 'dark' | undefined
100const THEME_MS = 2000
101let thinking = false
102let remote = false
103
104// ── drawing ─────────────────────────────────────────────────────────────
105
106function text(ui: Kit, s: Span) {
107  const p: TextProps = {}
108  if (s.c !== undefined) p.color = s.c
109  if (s.bg !== undefined) p.backgroundColor = s.bg
110  if (s.b) p.bold = true
111  if (s.d) p.dimColor = true
112  // A chip: the fill is on a box as well, for surfaces that paint no background behind text.
113  if (s.bg !== undefined) return <ui.Box backgroundColor={s.bg}>{<ui.Text {...p}>{s.t}</ui.Text>}</ui.Box>
114  return <ui.Text {...p}>{s.t}</ui.Text>
115}
116
117const item = (ui: Kit, i: Item) => (Array.isArray(i) ? <ui.Box flexDirection="row">{i.filter(s => s.t !== '').map(s => text(ui, s))}</ui.Box> : text(ui, i))
118
119// A step while its test runs: a spinner in front, so it reads as work in progress and not as a sentence.
120// One cell repainted in place on a terminal; a glyph that steps with each redraw elsewhere.
121function spinner(table: Table, surface: RenderSurface, n: number) {
122  if (surface === 'terminal' && 'Raster' in table) return <table.Raster key="spin" columns={1} rows={1} cells={spinnerCells(n, C.cyan)} />
123  return <table.Text color={C.cyan}>{spinnerGlyph(n)}</table.Text>
124}
125
126function line(ui: Kit, press: Press, r: Row, lead?: ReturnType<typeof spinner>) {
127  if (r.items.length === 0 && !r.button) return <ui.Text> </ui.Text>
128  return (
129    <ui.Box flexDirection="row" gap={1}>
130      {r.spin && lead ? lead : null}
131      {r.items.map(i => item(ui, i))}
132      {r.button ? <ui.Button key={r.button.key} label={r.button.label} variant="primary" onPress={() => press(r.button?.act ?? '')} /> : null}
133    </ui.Box>
134  )
135}
136
137// The surface decides, not the table: a surface with no cell grid still answers `Raster`, as an empty box.
138function mascot(table: Table, surface: RenderSurface, think: boolean, n: number) {
139  if (surface === 'terminal' && 'Raster' in table) return <table.Raster key="mascot" columns={RASTER.columns} rows={RASTER.rows} cells={rasterCells(pixelFrame(poseAt(think, n)))} />
140  // A plain image, redrawn per frame: an animated SVG needs a framed document, which paints its own backdrop.
141  if ('Svg' in table) return <table.Svg source={mascotSvg(poseAt(think, n))} alt="kane mascot" width={PICTURE.width} height={PICTURE.height} />
142  return <table.Text color={C.purple}>kane</table.Text>
143}
144
145const glyph = (ui: Kit, s: Status) => <ui.Text color={glyphOf(s)[1]}>{glyphOf(s)[0]}</ui.Text>
146
147/** A label and its text side by side, so a wrapped text keeps its column. */
148function fact(ui: Kit, label: string, value: string, color?: string) {
149  return (
150    <ui.Box flexDirection="row" gap={1}>
151      <ui.Box width={7} flexShrink={0}>
152        <ui.Text dimColor>{label}</ui.Text>
153      </ui.Box>
154      <ui.Box flexGrow={1} flexShrink={1}>
155        {color ? <ui.Text color={color}>{value}</ui.Text> : <ui.Text>{value}</ui.Text>}
156      </ui.Box>
157    </ui.Box>
158  )
159}
160
161const meta = (r: Run) => [r.kind, r.target].filter(Boolean).join(' · ')
162
163function runCard(ui: Kit, press: Press, r: Run, now: number, width: number) {
164  const isSuite = r.kind === 'testrun' && r.members
165  const c = isSuite ? tally(r.members ?? []) : undefined
166  const second = c ? (onGrid(r) ? `${c.total} test${c.total === 1 ? '' : 's'} on the grid · ${gridSince(r, now)}` : `${c.passed + c.failed} of ${c.total} tests done${c.running ? ` · ${c.running} running` : ''}${c.failed ? ` · ${c.failed} failed` : ''}`) : summary(r)
167  return (
168    <ui.Box key={`card-${r.id}`} flexDirection="column" borderStyle="round" borderColor={r.status === 'failed' ? C.coral : C.dim} paddingX={1}>
169      <ui.Box flexDirection="row" gap={1}>
170        {glyph(ui, r.status)}
171        <ui.Button key={`run-${r.id}`} label={cut(r.label, Math.max(16, width - 16))} plain onPress={() => press(`run:${r.id}`)} />
172        <ui.Text dimColor>{r.status === 'pending' ? '' : fmt(elapsed(r, now))}</ui.Text>
173      </ui.Box>
174      <ui.Text>{cut(second, Math.max(20, width * 2))}</ui.Text>
175      {meta(r) ? <ui.Text dimColor>{meta(r)}</ui.Text> : null}
176    </ui.Box>
177  )
178}
179
180function memberRow(ui: Kit, press: Press, r: Run, now: number, width: number) {
181  const nameRoom = Math.max(12, Math.floor(width * 0.4))
182  const stepRoom = Math.max(10, width - nameRoom - 14)
183  const tail = r.status === 'pending' ? '' : r.status === 'running' ? `${cut(nowText(r), stepRoom)} · ${fmt(elapsed(r, now))}` : fmt(elapsed(r, now))
184  return (
185    <ui.Box key={`member-${r.id}`} flexDirection="row" gap={1}>
186      {glyph(ui, r.status)}
187      <ui.Button key={`run-${r.id}`} label={cut(nm(r), nameRoom)} plain onPress={() => press(`run:${r.id}`)} />
188      <ui.Text dimColor>{tail}</ui.Text>
189    </ui.Box>
190  )
191}
192
193const sortRuns = (list: readonly Run[]) => [...list].sort((a, b) => ORDER[a.status] - ORDER[b.status] || b.startedAt - a.startedAt)
194/** A suite's tests by state, each state in the plan's order. */
195const sortMembers = (list: readonly Run[]) => list.map((m, i) => ({ m, i })).sort((a, b) => ORDER[a.m.status] - ORDER[b.m.status] || a.i - b.i).map(x => x.m)
196
197/** Where a remote suite is running: the grid, its job, and why nothing moves until it ends. */
198function gridFacts(ui: Kit, s: Run) {
199  const r = s.remote ?? {}
200  return (
201    <ui.Box flexDirection="column">
202      {fact(ui, 'grid', [r.backend ?? 'remote', r.jobId ? `job ${r.jobId}` : ''].filter(Boolean).join(' · '))}
203      {r.jobUrl ? fact(ui, 'link', r.jobUrl) : null}
204      {onGrid(s) ? <ui.Text dimColor>The grid reports each test when the job ends. Open the link to watch it there.</ui.Text> : null}
205    </ui.Box>
206  )
207}
208
209function suiteView(ui: Kit, press: Press, s: Run, now: number, width: number, back: boolean) {
210  const c = tally(s.members ?? [])
211  return (
212    <ui.Box flexDirection="column">
213      <ui.Box flexDirection="row" gap={1}>
214        {back ? <ui.Button key="back" label="‹ Runs" plain dimColor onPress={() => press('back')} /> : null}
215        <ui.Text bold>{cut(s.label, width - 10)}</ui.Text>
216      </ui.Box>
217      <ui.Text dimColor>{onGrid(s) ? `${c.total ? `${c.total} test${c.total === 1 ? '' : 's'} on the grid · ` : ''}${gridSince(s, now)}` : c.total ? `${c.passed + c.failed} of ${c.total} done · ${c.running} running · ${c.pending} waiting` : 'planning the suite'}</ui.Text>
218      {s.remote ? gridFacts(ui, s) : null}
219      <ui.Text> </ui.Text>
220      {sortMembers(s.members ?? []).map(m => memberRow(ui, press, m, now, width))}
221    </ui.Box>
222  )
223}
224
225function runsView(ui: Kit, press: Press, runs: readonly Run[], now: number, width: number, last: HistoryEntry | null) {
226  if (runs.length === 0) {
227    return (
228      <ui.Box flexDirection="column">
229        <ui.Text dimColor>No kane-cli run in this session yet. Ask Claude to test something, or run kane-cli in a terminal here: it shows up by itself.</ui.Text>
230        {last ? <ui.Text dimColor>{`last run ${last.status === 'passed' ? '✓' : '✗'} ${nm(last)} · ${ago(now - last.at)}`}</ui.Text> : null}
231      </ui.Box>
232    )
233  }
234  const only = runs.length === 1 ? runs[0] : undefined
235  if (only?.kind === 'testrun' && only.members) return suiteView(ui, press, only, now, width, false)
236  return <ui.Box flexDirection="column">{sortRuns(runs).map(r => runCard(ui, press, r, now, width))}</ui.Box>
237}
238
239function stepTime(s: Step, now: number): string {
240  if (!s.startedAt) return ''
241  if (s.status === 'running') return fmt((now - s.startedAt) / 1000)
242  return s.endedAt ? fmt((s.endedAt - s.startedAt) / 1000) : ''
243}
244
245/** The evidence viewer's link: the person clicks it, and their terminal or app opens it. */
246function evidenceLink(ui: Kit, key: string, href: string) {
247  return (
248    <ui.Text key={key} color={C.cyan}>
249      <ui.Link href={href}>Open evidence viewer ↗</ui.Link>
250    </ui.Text>
251  )
252}
253
254function runDetail(ui: Kit, press: Press, r: Run, now: number, width: number, viewer?: string) {
255  const tone = r.status === 'failed' ? C.coral : r.status === 'passed' ? C.mint : C.purple
256  const ended = r.status === 'passed' || r.status === 'failed'
257  const status = [fmt(elapsed(r, now)), r.kind, r.target, ended && r.credits !== undefined ? `${Math.round(r.credits * 10) / 10} credits` : ''].filter(Boolean).join(' · ')
258  const failing = r.status === 'failed' ? currentStep(r) : undefined
259  const kind = kindLine(r)
260  return (
261    <ui.Box flexDirection="column">
262      <ui.Box flexDirection="row" gap={1}>
263        <ui.Button key="back" label="‹ Runs" plain dimColor onPress={() => press('back')} />
264        <ui.Text bold>{cut(r.label, width - 10)}</ui.Text>
265      </ui.Box>
266      <ui.Box flexDirection="row" gap={1}>
267        <ui.Text color={tone} bold>
268          {r.status === 'pending' ? 'waiting' : r.status}
269        </ui.Text>
270        <ui.Text dimColor>{r.status === 'pending' ? '' : status}</ui.Text>
271      </ui.Box>
272      {r.newer ? <ui.Text dimColor>this kane-cli is newer than the mod: step detail is not read</ui.Text> : null}
273      {r.waiting ? fact(ui, 'asks', r.waiting, C.orange) : null}
274      {r.skipped ? <ui.Text dimColor>{`${r.skipped} line${r.skipped === 1 ? '' : 's'} of its stream could not be read and ${r.skipped === 1 ? 'was' : 'were'} skipped`}</ui.Text> : null}
275      <ui.Text> </ui.Text>
276      {r.status === 'failed' && r.failure?.why ? fact(ui, 'why', r.failure.why) : null}
277      {r.status === 'failed' && kind ? fact(ui, 'kind', kind) : null}
278      {r.status === 'failed' && r.failure?.where ? fact(ui, 'where', r.failure.where) : null}
279      {r.status === 'failed' ? <ui.Text> </ui.Text> : null}
280      {r.steps.map((s, i) => {
281        if (r.kind === 'run' && s.status === 'pending') return null
282        const open = s.status === 'running' || s === failing
283        return (
284          <ui.Box key={`step-${i}`} flexDirection="column">
285            <ui.Box flexDirection="row" gap={1}>
286              {glyph(ui, s.status)}
287              <ui.Box flexShrink={1}>
288                <ui.Text bold={open} dimColor={s.status === 'pending'}>
289                  {s.text || (s.status === 'running' ? 'working…' : `step ${i + 1}`)}
290                </ui.Text>
291              </ui.Box>
292              <ui.Text dimColor>{stepTime(s, now)}</ui.Text>
293            </ui.Box>
294            {open ? (
295              <ui.Box flexDirection="column" paddingLeft={4}>
296                {s.think ? fact(ui, 'think', s.think) : null}
297                {s.act ? fact(ui, 'act', s.act) : null}
298                {(s.checks ?? []).map(c => fact(ui, 'check', `${c.ok ? '✓' : '✗'} ${c.text}`, c.ok ? C.mint : C.coral))}
299              </ui.Box>
300            ) : null}
301          </ui.Box>
302        )
303      })}
304      {ended && r.sessionDir ? <ui.Text> </ui.Text> : null}
305      {ended && r.sessionDir ? (viewer ? evidenceLink(ui, 'evidence-link', viewer) : <ui.Button key="evidence" label="Open evidence" variant="primary" onPress={() => press(`evidence:${r.id}`)} />) : null}
306    </ui.Box>
307  )
308}
309
310function offerView(ui: Kit, press: Press, o: Offer | null, change: Change | null, now: number, width: number) {
311  const files = o?.files ?? change?.files ?? []
312  const saved = o?.saved ?? []
313  return (
314    <ui.Box flexDirection="column">
315      <ui.Box flexDirection="row" gap={1}>
316        <ui.Button key="back" label="‹ Runs" plain dimColor onPress={() => press('back')} />
317        <ui.Text bold>Test this change</ui.Text>
318      </ui.Box>
319      <ui.Text color={C.orange}>{change ? '⚠ This change has not been tested' : 'A kane-cli run has covered this change'}</ui.Text>
320      <ui.Text> </ui.Text>
321      {files.slice(0, 8).map((f, i) => fact(ui, i === 0 ? 'changed' : '', f.startsWith(`${projectDir}/`) ? f.slice(projectDir.length + 1) : f))}
322      {files.length > 8 ? <ui.Text dimColor>{`        +${files.length - 8} more`}</ui.Text> : null}
323      <ui.Text> </ui.Text>
324      {saved.length ? (
325        <ui.Box flexDirection="column">
326          <ui.Text bold>Saved tests that touch this</ui.Text>
327          {saved.slice(0, 4).map(t => {
328            const last = o?.last?.[t]
329            return (
330              <ui.Box key={`saved-${t}`} flexDirection="row" gap={1}>
331                <ui.Box flexShrink={1}>
332                  <ui.Text dimColor>{(() => {
333                    const tail = last ? ` · last ${last.status === 'passed' ? '✓' : '✗'} ${ago(now - last.at)}` : ''
334                    return `${cut(t.replace(/_test\.md$/, ''), Math.max(12, width - tail.length - 12))}${tail}`
335                  })()}</ui.Text>
336                </ui.Box>
337                <ui.Button key={`saved-run-${t}`} label="Run it" onPress={() => press(`saved:${t}`)} />
338              </ui.Box>
339            )
340          })}
341          <ui.Text> </ui.Text>
342        </ui.Box>
343      ) : null}
344      <ui.Box flexDirection="row" gap={1}>
345        <ui.Button key="offer-new" label="Test with a new objective" variant={saved.length ? undefined : 'primary'} onPress={() => press('offer-new')} />
346        <ui.Button key="offer-skip" label="Not now" onPress={() => press('offer-skip')} />
347      </ui.Box>
348    </ui.Box>
349  )
350}
351
352/** One use case: its bars, then what it still owes, to design first and to run after, each with kane-cli's own next command. */
353function useCaseView(ui: Kit, press: Press, u: UseCase) {
354  const pct = (n: number | undefined) => (n === undefined ? '   –' : `${String(n).padStart(3)}%`)
355  const listed = u.pending.length
356  const owed = u.toDesign + u.toCover
357  const group = (stage: 'design' | 'cover', title: string) => {
358    const rows = u.pending.filter(p => p.stage === stage)
359    if (!rows.length) return null
360    // Rows that share one reason and one next command say them once, under the list.
361    const shared = rows.length > 1 && rows.every(p => p.why === rows[0]!.why && p.command === rows[0]!.command)
362    return (
363      <ui.Box key={`owed-${stage}`} flexDirection="column">
364        <ui.Text bold>{title}</ui.Text>
365        {rows.map((p, i) => (
366          <ui.Box key={`owed-${stage}-${i}`} flexDirection="column">
367            <ui.Box flexDirection="row" gap={1}>
368              <ui.Text color={p.risk === 'high' ? C.coral : C.dim}>{p.risk === 'high' ? '▲' : '·'}</ui.Text>
369              <ui.Box flexShrink={1}>
370                <ui.Text>{p.title}</ui.Text>
371              </ui.Box>
372            </ui.Box>
373            {shared ? null : (
374              <ui.Box flexDirection="column" paddingLeft={2}>
375                {p.why ? fact(ui, 'why', p.why) : null}
376                {p.command ? fact(ui, 'next', p.command, C.purple) : null}
377              </ui.Box>
378            )}
379          </ui.Box>
380        ))}
381        {shared && rows[0]!.why ? fact(ui, 'why', rows[0]!.why) : null}
382        {shared && rows[0]!.command ? fact(ui, 'next', rows[0]!.command, C.purple) : null}
383        <ui.Text> </ui.Text>
384      </ui.Box>
385    )
386  }
387  return (
388    <ui.Box flexDirection="column">
389      <ui.Box flexDirection="row" gap={1}>
390        <ui.Button key="uc-back" label="‹ Assurance" plain dimColor onPress={() => press('uc-back')} />
391        <ui.Text bold>{u.id}</ui.Text>
392      </ui.Box>
393      {u.title ? <ui.Text>{u.title}</ui.Text> : null}
394      <ui.Box flexDirection="row" gap={1}>
395        {u.risk ? <ui.Text color={u.risk === 'high' ? C.coral : C.dim}>{`${u.risk} risk`}</ui.Text> : null}
396        {debt(u).map(s => text(ui, s))}
397      </ui.Box>
398      <ui.Box flexDirection="row" gap={1}>
399        <ui.Text dimColor>designed</ui.Text>
400        {item(ui, bar(12, u.designed / 100, C.yellow))}
401        <ui.Text>{pct(u.designed)}</ui.Text>
402      </ui.Box>
403      <ui.Box flexDirection="row" gap={1}>
404        <ui.Text dimColor>proven  </ui.Text>
405        {item(ui, bar(12, (u.proven ?? 0) / 100, C.yellow))}
406        <ui.Text>{pct(u.proven)}</ui.Text>
407      </ui.Box>
408      <ui.Text> </ui.Text>
409      {owed === 0 ? <ui.Text dimColor>Nothing owed: every criterion has a test{u.proven === 100 ? ' and a passing run' : ''}.</ui.Text> : null}
410      {group('design', 'To design')}
411      {group('cover', 'To run')}
412      {owed > listed ? <ui.Text dimColor>{`+${owed - listed} more: kane-cli cover gaps ${u.id}`}</ui.Text> : null}
413      {owed > 0 ? <ui.Button key="uc-close" label="Close these gaps with Claude" variant="primary" onPress={() => press(`uc-close:${u.id}`)} /> : null}
414    </ui.Box>
415  )
416}
417
418/** Finished runs the mod has seen in this project, newest first. */
419function historyView(ui: Kit, list: readonly HistoryEntry[], now: number, width: number) {
420  if (list.length === 0) return <ui.Text dimColor>No finished kane-cli run seen in this project yet. Each run is kept here when it ends.</ui.Text>
421  const c = { passed: list.filter(x => x.status === 'passed').length, failed: list.filter(x => x.status === 'failed').length }
422  return (
423    <ui.Box flexDirection="column">
424      <ui.Text dimColor>{`last ${list.length} run${list.length === 1 ? '' : 's'} here · ${c.passed} passed · ${c.failed} failed`}</ui.Text>
425      <ui.Text> </ui.Text>
426      {list.map((entry, i) => (
427        <ui.Box key={`history-${i}`} flexDirection="column">
428          <ui.Box flexDirection="row" gap={1}>
429            {glyph(ui, entry.status)}
430            <ui.Box flexShrink={1}>
431              <ui.Text bold={entry.status === 'failed'}>{cut(nm(entry), Math.max(16, width - 14))}</ui.Text>
432            </ui.Box>
433            <ui.Text dimColor>{ago(now - entry.at)}</ui.Text>
434          </ui.Box>
435          <ui.Box paddingLeft={2}>
436            <ui.Text dimColor>{cut([entry.kind, historyDetail(entry)].filter(Boolean).join(' · '), Math.max(20, width * 2 - 4))}</ui.Text>
437          </ui.Box>
438        </ui.Box>
439      ))}
440    </ui.Box>
441  )
442}
443
444function assureView(ui: Kit, press: Press, a: Assurance, now: number, open?: string) {
445  if (a.state === 'unknown') return <ui.Text dimColor>Reading the requirement store…</ui.Text>
446  const opened = a.state === 'ready' && open ? a.useCases.find(u => u.id === open) : undefined
447  if (opened) return useCaseView(ui, press, opened)
448  if (a.state === 'none') {
449    return (
450      <ui.Box flexDirection="column" borderStyle="round" borderColor={C.yellow} paddingX={1}>
451        <ui.Text color={C.yellow} bold>
452          Assurance is not set up
453        </ui.Text>
454        <ui.Text dimColor>Assurance turns a requirement into use cases, designs tests for them, and tracks which ones a real run has proven.</ui.Text>
455        <ui.Text> </ui.Text>
456        <ui.Text dimColor>○ ingest ── ○ extract ── ○ design ── ○ prove</ui.Text>
457        <ui.Text> </ui.Text>
458        <ui.Button key="setup" label="Set it up with Claude" variant="primary" onPress={() => press('setup')} />
459      </ui.Box>
460    )
461  }
462  const pct = (n: number | undefined) => (n === undefined ? '   –' : `${String(n).padStart(3)}%`)
463  const debtLine = [a.failing ? `${a.failing} failing` : '', a.blocked ? `${a.blocked} blocked` : '', a.lastRunAt ? `run ${ago(now - a.lastRunAt)}` : a.provenPct === undefined ? 'nothing run yet' : ''].filter(Boolean).join(' · ')
464  return (
465    <ui.Box flexDirection="column">
466      <ui.Box flexDirection="row" gap={1}>
467        <ui.Text dimColor>designed</ui.Text>
468        {item(ui, bar(24, a.designedPct / 100, C.yellow))}
469        <ui.Text color={C.yellow} bold>{`${a.designedPct}%`}</ui.Text>
470      </ui.Box>
471      <ui.Box flexDirection="row" gap={1}>
472        <ui.Text dimColor>proven  </ui.Text>
473        {item(ui, bar(24, (a.provenPct ?? 0) / 100, C.yellow))}
474        <ui.Text color={C.yellow} bold>
475          {a.provenPct === undefined ? '–' : `${a.provenPct}%`}
476        </ui.Text>
477      </ui.Box>
478      {debtLine ? <ui.Text dimColor>{debtLine}</ui.Text> : null}
479      <ui.Text> </ui.Text>
480      {a.useCases.map(u => (
481        <ui.Box key={`uc-${u.id}`} flexDirection="column" borderStyle="round" borderColor={C.dim} paddingX={1}>
482          <ui.Box flexDirection="row" gap={1}>
483            <ui.Button key={`uc-${u.id}`} label={u.id} plain onPress={() => press(`uc:${u.id}`)} />
484            {u.title ? (
485              <ui.Box flexShrink={1}>
486                <ui.Text>{u.title}</ui.Text>
487              </ui.Box>
488            ) : null}
489            {debt(u).map(s => text(ui, s))}
490          </ui.Box>
491          <ui.Box flexDirection="row" gap={1}>
492            <ui.Text dimColor>designed</ui.Text>
493            {item(ui, bar(6, u.designed / 100, C.yellow))}
494            <ui.Text>{pct(u.designed)}</ui.Text>
495            <ui.Text dimColor>proven</ui.Text>
496            {item(ui, bar(6, (u.proven ?? 0) / 100, C.yellow))}
497            <ui.Text>{pct(u.proven)}</ui.Text>
498          </ui.Box>
499        </ui.Box>
500      ))}
501    </ui.Box>
502  )
503}
504
505/** A run Claude started, as a card in the chat in place of its shell row: one line while it runs, then its result. */
506function chatCard(table: Table, surface: RenderSurface, press: Press, r: Run, now: number, columns: number, clickable: boolean, viewer?: string) {
507  const ui: Kit = table
508  if (r.status === 'running' || r.status === 'pending') {
509    const suite = r.kind === 'testrun' && r.members ? cardHeader(r, now, columns).result : undefined
510    return (
511      <ui.Box flexDirection="row" gap={1} marginTop={1}>
512        {spinner(table, surface, frame)}
513        <ui.Text color={C.purple} bold>
514          kane
515        </ui.Text>
516        <ui.Text dimColor>·</ui.Text>
517        <ui.Text bold>{cut(nm(r), Math.max(12, Math.floor(columns / 3)))}</ui.Text>
518        {suite ? suite.map(s => text(ui, s)) : <ui.Text dimColor>{fmt(elapsed(r, now))}</ui.Text>}
519        {suite ? null : <ui.Text dimColor>·</ui.Text>}
520        {suite ? null : (
521          <ui.Box flexShrink={1}>
522            <ui.Text>{cut(nowText(r), Math.max(10, columns - Math.floor(columns / 3) - 22))}</ui.Text>
523          </ui.Box>
524        )}
525      </ui.Box>
526    )
527  }
528  const width = Math.max(48, Math.min(88, columns - 2))
529  const inner = width - RASTER.columns - 6
530  const head = cardHeader(r, now, inner)
531  const isSuite = r.kind === 'testrun' && !!r.members
532  const failures = isSuite ? cardFailures(r) : undefined
533  const steps = r.steps.filter(s => s.text || s.status !== 'pending').length
534  return (
535    <ui.Box marginTop={1} width={width} flexDirection="row" gap={2} borderStyle="round" borderColor={r.status === 'failed' ? C.coral : C.mint} paddingX={1}>
536      {mascot(table, surface, false, 0)}
537      <ui.Box flexDirection="column" flexGrow={1} flexShrink={1}>
538        <ui.Box flexDirection="row" justifyContent="space-between" gap={2}>
539          <ui.Box flexDirection="row" gap={1} flexShrink={1}>
540            <ui.Text color={C.purple} bold>
541              kane
542            </ui.Text>
543            <ui.Text dimColor>·</ui.Text>
544            <ui.Text bold>{head.name}</ui.Text>
545          </ui.Box>
546          <ui.Box flexDirection="row" gap={1} flexShrink={0}>
547            {head.result.map((s, i) => (i === 0 ? text(ui, s) : <ui.Box flexDirection="row" gap={1}>{[<ui.Text dimColor>·</ui.Text>, text(ui, s)]}</ui.Box>))}
548          </ui.Box>
549        </ui.Box>
550        {isSuite ? item(ui, cells((r.members ?? []).map(m => m.status))) : null}
551        {failures?.rows.map(m => (
552          <ui.Box key={`card-fail-${m.id}`} flexDirection="row" gap={1}>
553            <ui.Text color={C.coral}>{`✗ ${cut(nm(m), 24)}`}</ui.Text>
554            <ui.Text dimColor>{m.failure?.where ? 'failed on' : 'failed'}</ui.Text>
555            <ui.Box flexShrink={1}>
556              <ui.Text>{cut(m.failure?.where ?? m.failure?.why ?? '', Math.max(10, inner - 38))}</ui.Text>
557            </ui.Box>
558          </ui.Box>
559        ))}
560        {failures && failures.more ? <ui.Text dimColor>{`+${failures.more} more`}</ui.Text> : null}
561        {!isSuite && r.status === 'failed' && (r.failure?.where || r.failure?.why) ? (
562          <ui.Box key="card-failure" flexDirection="row" gap={1}>
563            {r.failure.where ? <ui.Text dimColor>on</ui.Text> : null}
564            {r.failure.where ? (
565              <ui.Box flexShrink={0}>
566                <ui.Text>{cut(firstLine(r.failure.where), Math.max(16, Math.floor(inner / 2)))}</ui.Text>
567              </ui.Box>
568            ) : null}
569            {r.failure.where && r.failure.why ? <ui.Text dimColor>·</ui.Text> : null}
570            {r.failure.why ? (
571              <ui.Box flexShrink={1}>
572                <ui.Text color={C.coral} wrap="truncate-end">
573                  {firstLine(r.failure.why)}
574                </ui.Text>
575              </ui.Box>
576            ) : null}
577          </ui.Box>
578        ) : null}
579        {!isSuite && r.status === 'passed' && steps ? <ui.Text dimColor>{`${steps} step${steps === 1 ? '' : 's'}`}</ui.Text> : null}
580        {!clickable && r.evidence ? fact(ui, 'evidence', r.evidence) : null}
581        <ui.Box flexDirection="row" justifyContent="space-between" gap={2}>
582          <ui.Box flexDirection="row" gap={1}>
583            {clickable && (isSuite || r.status === 'failed') ? <ui.Button key="card-steps" label={isSuite ? 'View tests' : 'View steps'} onPress={() => press(`steps:${r.id}`)} /> : null}
584            {clickable && r.evidence ? (viewer ? evidenceLink(ui, 'card-evidence-link', viewer) : <ui.Button key="card-evidence" label="View evidence" variant="primary" onPress={() => press(`evidence:${r.id}`)} />) : null}
585          </ui.Box>
586          <ui.Box key="card-verified" flexDirection="row" gap={1} flexShrink={0}>
587            {cardFooter().map(s => text(ui, s))}
588          </ui.Box>
589        </ui.Box>
590      </ui.Box>
591    </ui.Box>
592  )
593}
594
595// ── reading kane-cli ─────────────────────────────────────────────────────
596
597/** Opened only by something the person did, and brought to the front: Claude Code opens its Diff pane in the
598 *  same dock after an edit, and re-opening a pane that is already open does not raise it, so a hidden one is
599 *  closed and opened again (what it shows lives in the atoms, so nothing is lost). */
600async function openPane($: EngineInterface) {
601  const mine = (await $.ui.panes().catch(() => [])).find(p => p.id === PANE)
602  if (mine && !mine.isShown) await $.ui.close({ id: PANE }).catch(() => undefined)
603  return $.ui.open({ id: PANE, title: 'Kane', columns: 58, rows: 28, focus: true })
604}
605
606const modeOf = (m: '' | AfterChange): AfterChange => (m === '' ? setting : m)
607
608async function alive($: EngineInterface, pid: number | undefined): Promise<boolean> {
609  if (!pid) return false
610  const r = await $.process.run(['kill', '-0', String(pid)], { timeoutMs: 3000 }).catch(() => undefined)
611  return r?.exitCode === 0
612}
613
614/** The text a file gained since the last read, as whole lines. */
615async function readNew($: EngineInterface, w: Watch): Promise<string[]> {
616  const st = await $.fs.stat(w.path).catch(() => undefined)
617  if (!st || st.size <= w.bytes) return []
618  let chunk: string
619  if (st.size > MAX_READ) {
620    const r = await $.process.run(['tail', '-c', `+${w.bytes + 1}`, w.path], { timeoutMs: 10_000 }).catch(() => undefined)
621    if (!r || r.exitCode !== 0) return []
622    chunk = r.stdout
623  } else {
624    const all = await $.fs.read(w.path).catch(() => undefined)
625    if (all === undefined) return []
626    chunk = afterBytes(all, w.bytes)
627  }
628  w.bytes += utf8Len(chunk)
629  const { lines, rest } = splitLines(w.rest + chunk)
630  w.rest = rest
631  return lines
632}
633
634/** The label Claude's own command gives a run that started just now in this project. */
635function claimCommand(surface: Kind, started: number): { label: string; toolUseId?: string } | undefined {
636  const c = [...commands].reverse().find(x => !x.used && x.surface === surface && started >= x.at - 5_000 && started - x.at < 120_000)
637  if (!c) return undefined
638  c.used = true
639  return { label: c.label, toolUseId: c.toolUseId }
640}
641
642/** Applies one file's new lines to the runs; reports the runs that just ended. */
643function applyLines(runs: Run[], w: Watch, lines: readonly string[], now: number): { runs: Run[]; ended: Run[] } {
644  const ended: Run[] = []
645  const id = w.parent ?? w.id
646  let i = runs.findIndex(r => r.id === id)
647  if (i === -1) return { runs, ended }
648  let updated = [...runs]
649  for (const l of lines) {
650    const a = adapt(l, now)
651    if (a.bad) {
652      const at = updated.findIndex(r => r.id === id)
653      if (at >= 0 && !w.member) updated[at] = { ...updated[at]!, skipped: (updated[at]!.skipped ?? 0) + 1 }
654    }
655    for (const ev of a.events) {
656      const before = updated[i]!
657      const after = w.member ? foldMember(before, w.member, ev) : fold(before, ev)
658      updated[i] = after
659      if (!w.member && ev.k === 'memberStart' && ev.logPath && !watches.has(ev.logPath)) {
660        watches.set(ev.logPath, { id: ev.logPath, path: ev.logPath, bytes: 0, rest: '', done: false, member: ev.path, parent: id })
661      }
662      // A member that ended is read once more (its last lines may hold the failure), then left.
663      if (!w.member && ev.k === 'memberEnd' && ev.logPath) {
664        const m = watches.get(ev.logPath)
665        if (m) m.closing = true
666        else watches.set(ev.logPath, { id: ev.logPath, path: ev.logPath, bytes: 0, rest: '', done: false, member: ev.path, parent: id, closing: true })
667      }
668      if (terminal(before, ev)) {
669        w.done = true
670        ended.push(after)
671      }
672    }
673  }
674  i = updated.findIndex(r => r.id === id)
675  return { runs: updated, ended }
676}
677
678function terminal(run: Run, ev: Ev): boolean {
679  if (ev.k === 'suiteDone' || ev.k === 'mdDone') return true
680  return ev.k === 'runEnd' && run.kind === 'run'
681}
682
683/** Once a second: new live runs from their pointers, new lines from every file still being followed. */
684async function poll($: EngineInterface) {
685  if (polling || !activeDir || !projectDir) return
686  polling = true
687  try {
688    const now = await $.clock.now()
689    let runs = await read($, runsAtom)
690    let changed = false
691    const ended: Run[] = []
692    const listing = await $.fs.list(activeDir).catch(() => [])
693    const present = new Set(listing.map(entry => entry.name))
694    for (const entry of listing) {
695      if (!entry.name.endsWith('.json') || seenPointers.has(entry.name)) continue
696      seenPointers.add(entry.name)
697      const p = parsePointer((await $.fs.read(`${activeDir}/${entry.name}`).catch(() => '')) ?? '', now)
698      if (!p || !inside(p.cwd, projectDir) || !(await alive($, p.pid))) continue
699      runs = startWatch(runs, p, entry.name, await processLabel($, p.pid))
700      changed = true
701    }
702    // Members first, so a suite's end reads its members' last lines; a member a suite names now is read now too.
703    const visited = new Set<Watch>()
704    const pending = () => [...watches.values()].filter(w => !w.done && !visited.has(w)).sort((a, b) => Number(!a.member) - Number(!b.member))
705    for (let w = pending()[0]; w; w = pending()[0]) {
706      visited.add(w)
707      const lines = await readNew($, w)
708      if (lines.length) {
709        const r = applyLines(runs, w, lines, now)
710        runs = r.runs
711        ended.push(...r.ended)
712        changed = true
713      }
714      if (w.closing) w.done = true
715      // A killed kane-cli leaves no terminal line: once its pointer is gone and its pid with it, the run is over.
716      if (!w.done && !w.member && w.pointer && !present.has(w.pointer) && !(await alive($, w.pid))) {
717        w.done = true
718        runs = runs.map(r => (r.id === w.id && (r.status === 'running' || r.status === 'pending') ? { ...r, status: 'failed' as const, endedAt: now, failure: r.failure ?? { why: 'kane-cli exited before it reported a result' } } : r))
719        const r = runs.find(x => x.id === w.id)
720        if (r) ended.push(r)
721        changed = true
722      }
723    }
724    for (const [k, w] of watches) if (w.done) watches.delete(k)
725    if (changed) await update($, runsAtom, () => runs)
726    for (const r of ended) await finished($, r)
727    for (const r of ended) seeking.set(r.id, now + 30_000)
728    if (seeking.size) await seekEvidence($, now)
729    if (runs.some(r => r.status === 'running' || r.status === 'pending')) await update($, tickAtom, n => n + 1)
730  } finally {
731    polling = false
732  }
733}
734
735/** kane-cli seals a run's evidence pack as it exits: `<session dir>/evidence/<id>.evidence`, and a suite's in the project's store under its execution id. */
736async function findEvidence($: EngineInterface, r: Run): Promise<string | undefined> {
737  if (r.executionId && r.cwd) {
738    const stored = `${r.cwd}/.testmuai/evidence/${r.executionId}.evidence`
739    if (await $.fs.exists(stored).catch(() => false)) return stored
740  }
741  const dir = `${r.sessionDir}/evidence`
742  const packs = (await $.fs.list(dir).catch(() => [])).filter(e => e.kind === 'file' && e.name.endsWith('.evidence'))
743  return packs.length === 1 ? `${dir}/${packs[0]!.name}` : undefined
744}
745
746/** Looks for the pack of each run that just ended, for half a minute at most. */
747async function seekEvidence($: EngineInterface, now: number) {
748  const runs = await read($, runsAtom)
749  const found = new Map<string, string>()
750  for (const [id, until] of seeking) {
751    const r = runs.find(x => x.id === id)
752    const pack = r && !r.evidence ? await findEvidence($, r) : undefined
753    if (pack) found.set(id, pack)
754    if (pack || !r || r.evidence || now > until) seeking.delete(id)
755  }
756  if (found.size) await update($, runsAtom, list => list.map(r => (found.has(r.id) ? { ...r, evidence: found.get(r.id) } : r)))
757}
758
759/** Starts kane-cli's local evidence viewer for a pack and answers the link it prints; the viewer lives until the session ends. */
760function startViewer($: EngineInterface, pack: string): Promise<string | undefined> {
761  return new Promise(resolve => {
762    let said = false
763    const say = (url: string | undefined) => {
764      if (said) return
765      said = true
766      resolve(url)
767    }
768    void $.clock.sleep(15_000).then(() => say(undefined), () => say(undefined))
769    void (async () => {
770      let text = ''
771      try {
772        for await (const piece of $.process.spawn({ argv: ['kane-cli', 'evidence', 'serve', pack], cwd: projectDir })) {
773          text = (text + piece.text).slice(-4000)
774          const link = text.match(/viewer\s+(https?:\/\/\S+)/)
775          if (link && !viewers.has(pack)) {
776            viewers.set(pack, link[1]!)
777            await update($, viewerAtom, v => ({ ...v, [pack]: link[1]! }))
778            say(link[1])
779          }
780        }
781      } catch {
782        // the viewer could not start: the caller falls back
783      }
784      viewers.delete(pack)
785      await update($, viewerAtom, v => Object.fromEntries(Object.entries(v).filter(([k]) => k !== pack))).catch(() => undefined)
786      say(undefined)
787    })()
788  })
789}
790
791/** A run's evidence: kane-cli's viewer for its pack, shown as a link the person clicks. Without a pack or a viewer, a prompt is filled for Claude instead. */
792async function openEvidence($: EngineInterface, id: string) {
793  const all = await read($, runsAtom)
794  const r = all.find(x => x.id === id) ?? leaves(all).find(x => x.id === id)
795  const pack = r?.evidence
796  const url = pack ? (viewers.get(pack) ?? (await startViewer($, pack))) : undefined
797  if (url) {
798    $.ui.toast('Evidence viewer ready: click Open evidence viewer')
799    return
800  }
801  await fill($, `Open the evidence for the kane-cli run in ${pack ?? r?.sessionDir ?? id}.`)
802}
803
804/** Puts a prompt in the prompt box for the person to read and send: the mod never sends one itself. */
805async function fill($: EngineInterface, text: string) {
806  const r = await $.prompt.fill({ text }).catch(() => undefined)
807  $.ui.toast(r?.isFilled ? 'Prompt ready: press Enter to send it to Claude' : 'Could not fill the prompt box')
808}
809
810/** A run nobody started in this chat is named from its own process: the command line kane-cli was started with. */
811async function processLabel($: EngineInterface, pid: number): Promise<string | undefined> {
812  const r = await $.process.run(['ps', '-o', 'args=', '-p', String(pid)], { timeoutMs: 3000 }).catch(() => undefined)
813  return r?.exitCode === 0 ? labelFromCommand(r.stdout)?.label : undefined
814}
815
816function startWatch(runs: Run[], p: Pointer, pointer: string, fromProcess?: string): Run[] {
817  const id = p.sessionDir
818  const claimed = claimCommand(p.surface, p.started) ?? (fromProcess ? { label: fromProcess } : undefined)
819  const run = newRun(id, p.surface, p.sessionDir, p.started, { pid: p.pid, cwd: p.cwd, ...(claimed ? { label: claimed.label, labelRank: RANK.command } : {}), ...(claimed?.toolUseId ? { toolUseId: claimed.toolUseId } : {}) })
820  // A reload follows the file again from its start, replacing what the last module folded.
821  watches.set(id, { id, path: `${p.sessionDir}/events.ndjson`, pointer, pid: p.pid, bytes: 0, rest: '', done: false })
822  const keep = runs.find(r => r.id === id)
823  const fresh = keep ? { ...run, toolUseId: run.toolUseId ?? keep.toolUseId, label: keep.labelRank && keep.labelRank >= RANK.command ? keep.label : run.label, labelRank: Math.max(keep.labelRank ?? 0, run.labelRank ?? 0) } : run
824  return [...runs.filter(r => r.id !== id), fresh]
825}
826
827/** A run reached its end: remember it, refresh assurance, and clear a change it covered. */
828async function finished($: EngineInterface, r: Run) {
829  if (r.status !== 'passed' && r.status !== 'failed') return
830  const entry = historyEntry(r)
831  if (!entry) return
832  await update($, lastAtom, () => entry)
833  const all = await recent($)
834  const list = [entry, ...(all[projectDir] ?? [])].slice(0, HISTORY_MAX)
835  all[projectDir] = list
836  await update($, historyAtom, () => list)
837  await $.store.set(RECENT, all).catch(() => undefined)
838  const change = await read($, changeAtom)
839  if (change && r.startedAt > change.lastAt) {
840    await update($, changeAtom, () => null)
841    await update($, offerAtom, () => null)
842  }
843  await refreshAssurance($)
844}
845
846async function refreshAssurance($: EngineInterface) {
847  if (!projectDir) return
848  const has = await $.fs.exists(`${projectDir}/.context`).catch(() => false)
849  if (!has) {
850    await update($, assuranceAtom, () => ({ state: 'none' }) as Assurance)
851    return
852  }
853  const r = await $.process.run(['kane-cli', 'cover', 'gaps', '--json'], { cwd: projectDir, timeoutMs: 30_000 }).catch(() => undefined)
854  if (!r) return
855  if (r.exitCode !== 0 && /no context store/i.test(`${r.stderr}${r.stdout}`)) {
856    await update($, assuranceAtom, () => ({ state: 'none' }) as Assurance)
857    return
858  }
859  const a = parseCoverGaps(r.stdout)
860  if (a.state === 'ready') await update($, assuranceAtom, () => a)
861}
862
863/** The last results per project. Its own key: v1 of this plugin kept a list under `history` in the same store. */
864const RECENT = 'recentByProject'
865
866async function recent($: EngineInterface): Promise<Record<string, HistoryEntry[]>> {
867  const v = await $.store.get(RECENT).catch(() => undefined)
868  return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, HistoryEntry[]>) : {}
869}
870
871/** Reads Claude Code's theme, and redraws when it moved between light and dark. */
872async function readTheme($: EngineInterface) {
873  const row = (await $.config.list()).find(r => r.key === 'theme')
874  const theme = usePalette(row?.value)
875  if (theme === palette) return
876  palette = theme
877  await update($, tickAtom, n => n + 1)
878}
879
880async function loadLast($: EngineInterface) {
881  const list = (await recent($))[projectDir] ?? []
882  if (list.length) await update($, historyAtom, () => list)
883  const last = list[0]
884  if (last) await update($, lastAtom, () => last)
885}
886
887// ── after Claude changes code ────────────────────────────────────────────
888
889async function ignored($: EngineInterface, path: string): Promise<boolean> {
890  const r = await $.process.run(['git', 'check-ignore', '-q', path], { cwd: projectDir, timeoutMs: 3000 }).catch(() => undefined)
891  return r?.exitCode === 0
892}
893
894async function noteEdit($: EngineInterface, path: string) {
895  if (!path || !inside(path, projectDir) || !isTrackedEdit(path.slice(projectDir.length + 1)) || (await ignored($, path))) return
896  const now = await $.clock.now()
897  await update($, changeAtom, c => (c ? { ...c, files: c.files.includes(path) ? c.files : [...c.files, path], lastAt: now } : { files: [path], firstAt: now, lastAt: now, shown: false }))
898}
899
900/** The files a Bash command changed, as Claude Code reports them beside its output. */
901function bashChanges(result: unknown): string[] {
902  const diff = (result as { bashEditDiff?: { changedFiles?: unknown; files?: unknown } } | undefined)?.bashEditDiff
903  if (!diff) return []
904  if (Array.isArray(diff.changedFiles)) return diff.changedFiles.filter((f): f is string => typeof f === 'string')
905  return Array.isArray(diff.files) ? diff.files.map(f => (f as { filePath?: unknown }).filePath).filter((f): f is string => typeof f === 'string') : []
906}
907
908async function savedTests($: EngineInterface): Promise<{ name: string; text: string }[]> {
909  const dir = `${projectDir}/.testmuai/tests`
910  const list = await $.fs.list(dir).catch(() => [])
911  const out: { name: string; text: string }[] = []
912  for (const test of list.filter(x => x.name.endsWith('_test.md')).slice(0, 40)) {
913    out.push({ name: test.name, text: (await $.fs.read(`${dir}/${test.name}`).catch(() => '')) ?? '' })
914  }
915  return out
916}
917
918async function openOffer($: EngineInterface) {
919  const change = await read($, changeAtom)
920  if (!change) return
921  const files = change.files
922  await update($, viewAtom, v => ({ ...v, tab: 'runs' as const, open: 'offer' }))
923  await openPane($)
924  const tests = await savedTests($)
925  const history = (await recent($))[projectDir] ?? []
926  const last: Record<string, HistoryEntry> = {}
927  for (const t of tests) {
928    const hit = history.find(entry => entry.label === t.name || entry.label.endsWith(`/${t.name}`))
929    if (hit) last[t.name] = hit
930  }
931  const saved = savedTestsFor(files, tests, last)
932  await update($, offerAtom, () => ({ files, saved, url: startUrl(tests), last }))
933}
934
935/** The prompt for a new test of the change: Claude reads the diff itself, writes the objective and runs it. */
936function newTestPrompt(files: readonly string[], url?: string): string {
937  const rel = files.map(f => (f.startsWith(`${projectDir}/`) ? f.slice(projectDir.length + 1) : f))
938  const start = url ? ` --url ${url}` : ''
939  const hint = url ? '' : ' kane-cli needs a start page: pass --url with this app\'s URL unless `kane-cli config show` has a default_url.'
940  return `Test my last change with kane-cli: read \`git diff -- ${rel.join(' ')}\`, write one test objective (what a user does and what must be true at the end), then run kane-cli run "<objective>"${start} --agent --headless.${hint} Then report the result.`
941}
942
943function autoInstruction(files: readonly string[], saved: readonly string[], url?: string): string {
944  const rel = files.map(f => (f.startsWith(`${projectDir}/`) ? f.slice(projectDir.length + 1) : f))
945  const reuse = saved.length ? ` A saved test may already cover it: ${saved.slice(0, 3).map(s => `.testmuai/tests/${s}`).join(', ')} (kane-cli testmd run <file>${url ? ` --url ${url}` : ''} --agent --headless).` : ''
946  const start = url ? ` --url ${url}` : ' --url <the app\'s start page>'
947  return `kane-qe: you changed ${rel.join(', ')} and no kane-cli run has tested it yet. Before you finish, test the change in a browser with kane-cli (kane-cli run "<what a user does and what must be true>"${start} --agent --headless).${reuse} Then report the result.`
948}
949
950/** A kane-cli command Claude is about to run names the run it starts. */
951async function beforeTool($: EngineInterface, e: { tool: string; command?: unknown; tool_use_id?: string }) {
952  if (e.tool !== 'Bash') return
953  const named = labelFromCommand(String(e.command ?? ''))
954  if (!named) return
955  commands.push({ ...named, at: await $.clock.now(), used: false, toolUseId: e.tool_use_id })
956  if (commands.length > 20) commands.shift()
957}
958
959/** Files Claude changed, by the edit tools or from the shell (the Bash result lists what it changed). */
960async function afterTool($: EngineInterface, e: { tool: string }, result: { deny?: unknown; isError?: unknown; result?: unknown }) {
961  if (result.deny || result.isError) return
962  if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
963    const input = e as unknown as { file_path?: unknown; notebook_path?: unknown }
964    await noteEdit($, String(input.file_path ?? input.notebook_path ?? ''))
965  }
966  // A Bash result lists every file that changed while it ran, whoever wrote it: a kane-cli run takes long enough
967  // for other tools to write theirs, so its list is not Claude's edits.
968  if (e.tool === 'Bash' && !labelFromCommand(String((e as { command?: unknown }).command ?? ''))) {
969    for (const path of bashChanges(result.result)) await noteEdit($, path)
970  }
971}
972
973/** At the end of a turn: the reason to hold Claude (auto mode), or nothing; in offer mode the band's warning shows. */
974async function holdForChange($: EngineInterface, e: { stop_hook_active?: boolean }): Promise<string | undefined> {
975  const change = await read($, changeAtom)
976  const mode = modeOf(await read($, modeAtom))
977  if (!change || mode === 'off') return undefined
978  if (mode === 'offer') {
979    if (!change.shown) await update($, changeAtom, c => (c ? { ...c, shown: true } : c))
980    return undefined
981  }
982  // Held once already and the turn still ends untested: the band offers the test instead.
983  if (e.stop_hook_active || change.heldAt === change.lastAt) {
984    if (!change.shown) await update($, changeAtom, c => (c ? { ...c, shown: true } : c))
985    return undefined
986  }
987  await update($, changeAtom, c => (c ? { ...c, heldAt: c.lastAt } : c))
988  const tests = await savedTests($).catch(() => [])
989  return autoInstruction(change.files, savedTestsFor(change.files, tests), startUrl(tests))
990}
991
992// ── actions ──────────────────────────────────────────────────────────────
993
994async function act($: EngineInterface, a: string) {
995  const at = a.indexOf(':')
996  const k = at < 0 ? a : a.slice(0, at)
997  const arg = at < 0 ? '' : a.slice(at + 1)
998  if (k === 'open') await openPane($)
999  else if (k === 'tab') {
1000    await update($, viewAtom, v => ({ ...v, tab: arg === 'assure' ? ('assure' as const) : arg === 'history' ? ('history' as const) : ('runs' as const), open: '', uc: '' }))
1001    await openPane($)
1002  } else if (k === 'uc') await update($, viewAtom, v => ({ ...v, tab: 'assure' as const, uc: arg }))
1003  else if (k === 'uc-back') await update($, viewAtom, v => ({ ...v, uc: '' }))
1004  else if (k === 'uc-close') {
1005    const a = await read($, assuranceAtom)
1006    const u = a.state === 'ready' ? a.useCases.find(x => x.id === arg) : undefined
1007    if (u) await fill($, gapsPrompt(u))
1008  } else if (k === 'run') await update($, viewAtom, v => ({ ...v, open: arg, tab: 'runs' as const }))
1009  else if (k === 'back') await update($, viewAtom, v => ({ ...v, open: '' }))
1010  else if (k === 'offer') await openOffer($)
1011  else if (k === 'offer-new') {
1012    const o = await read($, offerAtom)
1013    if (!o) return
1014    await update($, viewAtom, v => ({ ...v, open: '' }))
1015    await update($, offerAtom, () => null)
1016    await fill($, newTestPrompt(o.files, o.url))
1017  } else if (k === 'saved') {
1018    const o = await read($, offerAtom)
1019    await update($, viewAtom, v => ({ ...v, open: '' }))
1020    await update($, offerAtom, () => null)
1021    const url = o?.url ? ` --url ${o.url}` : ''
1022    await fill($, `Test my last change with the saved kane-cli test: kane-cli testmd run .testmuai/tests/${arg}${url} --agent --headless. Then report the result.`)
1023  } else if (k === 'offer-skip') {
1024    await update($, offerAtom, () => null)
1025    await update($, changeAtom, c => (c ? { ...c, shown: false } : c))
1026    await update($, viewAtom, v => ({ ...v, open: '' }))
1027  } else if (k === 'evidence') await openEvidence($, arg)
1028  else if (k === 'steps') {
1029    await update($, viewAtom, v => ({ ...v, tab: 'runs' as const, open: arg, uc: '' }))
1030    await openPane($)
1031  } else if (k === 'setup') {
1032    await fill($, 'Set up kane-cli assurance for this project: ask me for the requirement (a PRD file, a Jira, Confluence or Linear link, or a web page), ingest it with kane-cli, extract the use cases and design tests (use --mode agent), then show me the coverage with kane-cli cover gaps.')
1033  }
1034}
1035
1036// ── the mascot's animation ───────────────────────────────────────────────
1037
1038/** Where the mascot is an image, step its frame by redrawing the sites that show it. */
1039async function pulse($: EngineInterface) {
1040  if (!thinking || !remote) return
1041  frame += 1
1042  await update($, tickAtom, n => n + 1)
1043}
1044
1045function paint($: EngineInterface) {
1046  if (!thinking || sites.size === 0) return
1047  if (!remote) frame += 1
1048  const cells = rasterCells(pixelFrame(poseAt(true, frame)))
1049  for (const requestId of sites) void $.ui.blit({ requestId, key: 'mascot', cells }).catch(() => sites.delete(requestId))
1050  const spin = spinnerCells(frame, C.cyan)
1051  for (const requestId of spinSites) void $.ui.blit({ requestId, key: 'spin', cells: spin }).catch(() => spinSites.delete(requestId))
1052}
1053
1054const commandOf = (input: unknown): string => String((input as { command?: unknown } | undefined)?.command ?? '')
1055
1056/** A run's card at a chat site: a shell row, or beneath a folded group of them. */
1057async function chatCardAt($: EngineInterface, e: { requestId: string; surface: RenderSurface; viewport?: { columns: number; isFullscreen?: boolean } }, r: Run) {
1058  await read($, tickAtom)
1059  const press: Press = a => void act($, a).catch(() => undefined)
1060  // Printed text takes no clicks: only the desktop and the fullscreen terminal draw buttons.
1061  const clickable = e.surface !== 'terminal' || e.viewport?.isFullscreen === true
1062  if (e.surface === 'terminal' && (r.status === 'running' || r.status === 'pending')) spinSites.add(e.requestId)
1063  else spinSites.delete(e.requestId)
1064  const viewer = r.evidence ? (await read($, viewerAtom))[r.evidence] : undefined
1065  return chatCard($.ui.resolve(e as never), e.surface, press, r, await $.clock.now(), e.viewport?.columns ?? 90, clickable, viewer)
1066}
1067
1068// ── the mod ──────────────────────────────────────────────────────────────
1069
1070export const register: Register = (on, options) => {
1071  const o = options as Record<string, unknown>
1072  setting = o.afterChange === 'auto' || o.afterChange === 'off' ? o.afterChange : 'offer'
1073
1074  on('session.start', async ($, e, next) => {
1075    const started = await next(e)
1076    projectDir = String((e as { cwd?: unknown }).cwd ?? '').replace(/\/$/, '')
1077    palette = undefined
1078    const home = await $.env.get('HOME').catch(() => undefined)
1079    if (home) activeDir = `${home}/.testmuai/kaneai/sessions/active`
1080    await $.command.register({ name: 'kane', description: 'Kane: open the runs pane; /kane assurance; /kane history; /kane auto | ask | off for changes', argumentHint: '[assurance | history | auto | ask | off]' })
1081    $.clock.every(1000, () => void poll($).catch(() => undefined))
1082    $.clock.every(STEP_MS, () => paint($))
1083    $.clock.every(STEP_MS, () => void pulse($).catch(() => undefined))
1084    void loadLast($).catch(() => undefined)
1085    void refreshAssurance($).catch(() => undefined)
1086    // The palette follows Claude Code's theme, read every two seconds: the design's colours are for dark backgrounds.
1087    $.clock.every(THEME_MS, () => void readTheme($).catch(() => undefined))
1088    void readTheme($).catch(() => undefined)
1089    return started
1090  })
1091
1092  on('command.run', { command: 'kane' }, async ($kane, cmd) => {
1093    const arg = cmd.args.trim().toLowerCase()
1094    if (arg === '' || arg === 'runs') {
1095      await act($kane, 'tab:runs')
1096      return { text: 'Kane pane opened. It follows every kane-cli run in this folder by itself.' }
1097    }
1098    if (arg === 'assurance' || arg === 'assure') {
1099      await refreshAssurance($kane)
1100      await act($kane, 'tab:assure')
1101      return { text: 'Kane: assurance for this project.' }
1102    }
1103    if (arg === 'history') {
1104      await act($kane, 'tab:history')
1105      return { text: 'Kane: finished runs in this project.' }
1106    }
1107    if (arg === 'auto' || arg === 'ask' || arg === 'off') {
1108      const mode: AfterChange = arg === 'ask' ? 'offer' : arg
1109      await update($kane, modeAtom, () => mode)
1110      return { text: `Kane: after Claude changes code, ${mode === 'auto' ? 'Claude tests it before it finishes' : mode === 'offer' ? 'the band offers to test it' : 'nothing happens'} (this session).` }
1111    }
1112    return { text: 'Kane: /kane opens the pane · /kane assurance · /kane history · /kane auto | ask | off sets what happens after Claude changes code.' }
1113  })
1114
1115  // These three sit in the path of what the person and Claude do: the mod's own work is caught, the action always goes on.
1116  on('tool.call', async ($, e, next) => {
1117    await beforeTool($, e).catch(() => undefined)
1118    const result = await next(e)
1119    await afterTool($, e, result).catch(() => undefined)
1120    return result
1121  })
1122
1123  // The turn is ending: an untested change is offered (offer), or Claude is held once to test it (auto).
1124  on('classic.Stop', async ($, e, next) => {
1125    const result = await next(e)
1126    if (result.block) return result
1127    const block = await holdForChange($, e).catch(() => undefined)
1128    return block ? { ...result, block } : result
1129  })
1130
1131  // A kane-cli run Claude started is drawn as a card in the chat, in place of its shell row.
1132  // The chat folds shell calls into one count line. A group holding one run stays folded, with that run's card beneath its line;
1133  // a group holding several runs, or a run call without its id, is unfolded so each row draws its own card.
1134  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
1135    if (e.props.isExpanded) return next(e)
1136    const calls = e.props.calls.filter(c => c.tool === 'Bash' && isRunCommand(commandOf(c.input)))
1137    if (!calls.length) return next(e)
1138    if (calls.length > 1 || calls[0]!.tool_use_id === undefined) return next({ ...e, props: { ...e.props, isExpanded: true } })
1139    const r = (await read($, runsAtom)).find(x => x.toolUseId === calls[0]!.tool_use_id)
1140    if (!r) return next(e)
1141    const ui: Kit = $.ui.resolve(e)
1142    const line = await next(e)
1143    const card = await chatCardAt($, e, r)
1144    return (
1145      <ui.Box flexDirection="column">
1146        {line}
1147        {card}
1148      </ui.Box>
1149    )
1150  })
1151
1152  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
1153    if (e.props.tool !== 'Bash' || !isRunCommand(commandOf(e.props.input))) return next(e)
1154    const r = (await read($, runsAtom)).find(x => x.toolUseId === e.props.tool_use_id)
1155    // No run came of the command (kane-cli refused or failed to start): its own row says why.
1156    if (!r) return next(e)
1157    return chatCardAt($, e, r)
1158  })
1159
1160  // The card stands for the whole call: once the run has a result, kane-cli's raw output is not drawn beneath it.
1161  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
1162    if (e.props.tool !== 'Bash') return next(e)
1163    const r = (await read($, runsAtom)).find(x => x.toolUseId === e.props.tool_use_id)
1164    if (!r || (r.status !== 'passed' && r.status !== 'failed')) return next(e)
1165    const ui: Kit = $.ui.resolve(e)
1166    return <ui.Box />
1167  })
1168
1169  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1170    if (e.props.hasSurvey) return next(e)
1171    const runs = await read($, runsAtom)
1172    await read($, tickAtom)
1173    const change = await read($, changeAtom)
1174    const mode = modeOf(await read($, modeAtom))
1175    const now = await $.clock.now()
1176    // Auto promises Claude will test it until a held stop has been spent on this change; then the button.
1177    const promised = mode === 'auto' && change?.heldAt !== change?.lastAt
1178    const shownChange = change && mode !== 'off' && (promised || change.shown) ? { files: change.files.length, mode: promised ? ('auto' as const) : ('offer' as const) } : undefined
1179    const model = band(runs, { assurance: await read($, assuranceAtom), change: shownChange, last: await read($, lastAtom), now, columns: e.props.bodyColumns - RASTER.columns - 1 })
1180    thinking = model.think
1181    const table = $.ui.resolve(e)
1182    if (e.surface === 'terminal') sites.add(e.requestId)
1183    else remote = true
1184    if (e.surface === 'terminal' && model.rows.some(r => r.spin)) spinSites.add(e.requestId)
1185    else spinSites.delete(e.requestId)
1186    const press: Press = a => void act($, a).catch(() => undefined)
1187    return (
1188      <table.Box flexDirection="row" gap={1} marginTop={1}>
1189        {mascot(table, e.surface, model.think, frame)}
1190        <table.Box flexDirection="column">{model.rows.map(r => line(table, press, r, spinner(table, e.surface, frame)))}</table.Box>
1191      </table.Box>
1192    )
1193  })
1194
1195  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1196    const v = await read($, viewAtom)
1197    await read($, tickAtom)
1198    const runs = await read($, runsAtom)
1199    const now = await $.clock.now()
1200    const all = leaves(runs)
hooks/adapter.ts 180 lines
1// kane-cli's NDJSON wire → small internal events. The only file where raw wire field names appear.
2// The contract only ever adds: unknown types and fields are skipped, a line that is not JSON is counted.
3
4import type { Kind } from '../types'
5
6export type Ev =
7  | { k: 'start'; surface?: Kind; cli?: string; pid?: number; at: number }
8  | { k: 'newer'; at: number }
9  | { k: 'label'; text: string; rank: number; at: number }
10  | { k: 'step'; n: number; status: 'running' | 'done' | 'failed' | 'stopped'; text: string; at: number }
11  | { k: 'ask'; question: string; at: number }
12  | { k: 'error'; message: string; at: number }
13  | { k: 'runEnd'; status: 'passed' | 'failed'; credits?: number; why?: string; category?: string; severity?: string; confidence?: number; at: number }
14  | { k: 'mdStepStart'; index: number; heading: string; at: number }
15  | { k: 'runStart'; objective?: string; target?: string; at: number }
16  | { k: 'detail'; kind: 'think' | 'act' | 'check'; text: string; ok?: boolean; at: number }
17  | { k: 'mdStepEnd'; index: number; status: string; at: number }
18  | { k: 'mdDone'; status: string; at: number }
19  | { k: 'plan'; members: string[]; at: number }
20  | { k: 'memberStart'; path: string; logPath?: string; at: number }
21  | { k: 'progress'; running: string[]; pending: number; done: number; total: number; at: number }
22  | { k: 'memberEnd'; path: string; status: string; durationS?: number; logPath?: string; why?: string; at: number }
23  | { k: 'suiteDone'; status: string; executionId?: string; at: number }
24  | { k: 'remote'; backend?: string; jobId?: string; jobUrl?: string; dispatched: boolean; at: number }
25  | { k: 'postHoc' }
26
27/** Label sources, weakest first: a stronger one replaces a weaker one. */
28export const RANK = { firstStep: 1, objective: 2, recording: 3, command: 4 } as const
29
30type Json = Record<string, unknown>
31const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
32const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
33const time = (o: Json, fallback: number): number => {
34  const t = Date.parse(String(o.ts ?? ''))
35  return Number.isFinite(t) ? t : fallback
36}
37
38/** Splits text into whole lines; a tail with no newline yet is handed back to wait for the rest. */
39export function splitLines(text: string): { lines: string[]; rest: string } {
40  const end = text.lastIndexOf('\n')
41  if (end === -1) return { lines: [], rest: text }
42  return { lines: text.slice(0, end).split('\n').filter(l => l.trim() !== ''), rest: text.slice(end + 1) }
43}
44
45/** One wire line → its events (most lines give one, unknown ones none). `bad` is set for a line that is not JSON. */
46export function adapt(line: string, now = 0): { events: Ev[]; bad?: true } {
47  let o: Json
48  try {
49    const parsed: unknown = JSON.parse(line)
50    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { events: [], bad: true }
51    o = parsed as Json
52  } catch {
53    return { events: [], bad: true }
54  }
55  const at = time(o, now)
56  if (o.v !== undefined && o.v !== 1) return { events: [{ k: 'newer', at }] }
57  const out = one(o, at)
58  if (o.post_hoc === true) out.unshift({ k: 'postHoc' })
59  return { events: out }
60}
61
62function surfaceOf(v: unknown): Kind | undefined {
63  return v === 'run' || v === 'testmd' || v === 'testrun' ? v : undefined
64}
65
66function one(o: Json, at: number): Ev[] {
67  const type = str(o.type)
68  // Surface `run` step lines carry no type.
69  if (type === undefined) {
70    const n = num(o.step)
71    if (n === undefined || o.child_id !== undefined) return []
72    const s = o.status
73    const status = s === 'running' || s === 'done' || s === 'failed' || s === 'stopped' ? s : undefined
74    if (!status) return []
75    return [{ k: 'step', n, status, text: str(o.remark) ?? '', at }]
76  }
77  switch (type) {
78    case 'stream_start':
79      return [{ k: 'start', surface: surfaceOf(o.surface), cli: str(o.cli_version), pid: num(o.pid), at }]
80    case 'recording_state': {
81      const path = str(o.test_path)
82      const text = path ? (path.split('/').pop() ?? path) : str(o.session_name)
83      return text ? [{ k: 'label', text, rank: RANK.recording, at }] : []
84    }
85    case 'bifurcation': {
86      // "Navigate to <url> then <objective>": the objective is the best name the run surface gives.
87      const flows = Array.isArray(o.flows) ? o.flows : []
88      const flow = str(flows[0])
89      if (!flow) return []
90      const objective = flow.replace(/^Navigate to \S+ then /i, '').split('\n')[0]!.trim()
91      return objective ? [{ k: 'label', text: objective, rank: RANK.objective, at }] : []
92    }
93    case 'ask_user': {
94      const q = str(o.question)
95      return q ? [{ k: 'ask', question: q, at }] : []
96    }
97    case 'error': {
98      const m = str(o.message)
99      return m ? [{ k: 'error', message: m, at }] : []
100    }
101    case 'run_end': {
102      const status = o.status === 'passed' ? 'passed' : o.status === 'failed' ? 'failed' : undefined
103      if (!status) return []
104      const v = o.verdict && typeof o.verdict === 'object' ? (o.verdict as Json) : {}
105      const why = str(v.root_cause) ?? str(v.one_liner) ?? str(o.one_liner) ?? str(o.reason)
106      return [
107        {
108          k: 'runEnd',
109          status,
110          credits: num(o.credits_consumed),
111          why,
112          category: str(v.category),
113          severity: str(v.severity),
114          confidence: num(v.confidence),
115          at,
116        },
117      ]
118    }
119    case 'test_md_step_start': {
120      const index = num(o.step_index)
121      return index === undefined ? [] : [{ k: 'mdStepStart', index, heading: str(o.heading) ?? '', at }]
122    }
123    case 'run_start': {
124      const env = o.environment && typeof o.environment === 'object' ? (o.environment as Json) : {}
125      const device = env.device && typeof env.device === 'object' ? (env.device as Json) : {}
126      const model = str(device.model)
127      const os = str(device.os_version)
128      const target = model ? [model, os].filter(Boolean).join(' · ') : undefined
129      return [{ k: 'runStart', objective: str(o.objective), target, at }]
130    }
131    case 'step_event': {
132      if (o.child_id !== undefined) return []
133      const detail = str(o.detail) ?? ''
134      if (o.event === 'reasoning') return detail ? [{ k: 'detail', kind: 'think', text: detail, at }] : []
135      if (o.event === 'action') {
136        const kind = str(o.action_type)
137        return detail ? [{ k: 'detail', kind: 'act', text: kind ? `${kind}: ${detail}` : detail, at }] : []
138      }
139      if (o.event === 'assertion') return detail ? [{ k: 'detail', kind: 'check', text: detail, ok: o.passed !== false, at }] : []
140      return []
141    }
142    case 'test_md_step_end': {
143      const index = num(o.step_index)
144      return index === undefined ? [] : [{ k: 'mdStepEnd', index, status: str(o.status) ?? 'passed', at }]
145    }
146    case 'test_md_done':
147      return [{ k: 'mdDone', status: str(o.overall_status) ?? 'failed', at }]
148    case 'testrun_plan': {
149      const members = Array.isArray(o.members) ? o.members.map(m => str((m as Json | null)?.path)).filter((p): p is string => !!p) : []
150      return [{ k: 'plan', members, at }]
151    }
152    case 'testrun_member_start': {
153      const path = str(o.path)
154      return path ? [{ k: 'memberStart', path, logPath: str(o.log_path), at }] : []
155    }
156    case 'testrun_progress': {
157      const running = Array.isArray(o.running) ? o.running.map(str).filter((p): p is string => !!p) : []
158      return [{ k: 'progress', running, pending: num(o.pending) ?? 0, done: num(o.done) ?? 0, total: num(o.total) ?? 0, at }]
159    }
160    case 'testrun_member_end': {
161      const path = str(o.path)
162      if (!path) return []
163      const failure = o.failure && typeof o.failure === 'object' ? (o.failure as Json) : {}
164      return [{ k: 'memberEnd', path, status: str(o.status) ?? 'failed', durationS: num(o.duration_s), logPath: str(o.log_path), why: str(failure.message), at }]
165    }
166    case 'testrun_done':
167      return [{ k: 'suiteDone', status: str(o.overall_status) ?? 'failed', executionId: str(o.execution_id), at }]
168    case 'remote_start':
169      return [{ k: 'remote', backend: str(o.backend), dispatched: false, at }]
170    case 'remote_dispatched':
171      return [{ k: 'remote', jobId: str(o.job_id), jobUrl: str(o.job_url), dispatched: true, at }]
172    case 'remote_error': {
173      const m = str(o.detail) ?? str(o.code)
174      return m ? [{ k: 'error', message: m, at }] : []
175    }
176    default:
177      return []
178  }
179}
180
hooks/mascot.ts 148 lines
1// The kane mascot, in the design of LambdaTest/kane-cli#202: the helmet whose eyes glance left, then right, then blink.
2//
3// One drawing, an 8 by 6 pixel version of each pose (PIXELS), shown two ways:
4//  - a terminal's cell grid (one cell holds two pixel rows, so three rows tall);
5//  - an SVG of one square per pixel, for surfaces that show images.
6
7export type Pose = 'c' | 'lm' | 'l' | 'rm' | 'r' | 'xh' | 'x'
8export type Frame = readonly string[]
9
10/** The length of one step of the loop, in ms. */
11export const STEP_MS = 120
12/** The loop follows the GIF: rest, glance left, glance right, rest, blink, rest. */
13const LOOP: readonly Pose[] = ['c', 'c', 'c', 'c', 'lm', 'l', 'l', 'l', 'lm', 'rm', 'r', 'r', 'r', 'r', 'rm', 'c', 'c', 'c', 'x', 'c', 'c', 'c']
14
15/** The pose to show: resting, or step `n` of the loop. */
16export function poseAt(think: boolean, n: number): Pose {
17  return think ? (LOOP[n % LOOP.length] ?? 'c') : 'c'
18}
19
20export const MASCOT = {
21  width: 8,
22  height: 6,
23  palette: {"1": "#cdb8ff", "2": "#ae96ff", "3": "#8d7af6", "4": "#6d64ea", "5": "#4f50d4", "v": "#0c0e19", "w": "#1d2236", "g": "#5a6c9c", "h": "#a6e4ff", "k": "#ffffff"} as Record<string, string>,
24} as const
25
26const PIXELS: Record<'c' | 'l' | 'r' | 'x', Frame> = {
27  "c": [
28    "..1..3..",
29    ".122233.",
30    "2vvvvvv4",
31    "2vkvvkv4",
32    "33444455",
33    ".344555."
34  ],
35  "l": [
36    "..1..3..",
37    ".122233.",
38    "2vvvvvv4",
39    "2kvvkvv4",
40    "33444455",
41    ".344555."
42  ],
43  "r": [
44    "..1..3..",
45    ".122233.",
46    "2vvvvvv4",
47    "2vvkvvk4",
48    "33444455",
49    ".344555."
50  ],
51  "x": [
52    "..1..3..",
53    ".122233.",
54    "2vvvvvv4",
55    "2vgvvgv4",
56    "33444455",
57    ".344555."
58  ]
59}
60
61/** The pixel frame for a pose; the in-between poses use the nearest full one. */
62export function pixelFrame(pose: Pose): Frame {
63  return PIXELS[pose === 'lm' ? 'l' : pose === 'rm' ? 'r' : pose === 'xh' ? 'x' : pose]
64}
65
66const DEFAULT = 0x01000000
67const UPPER = 0x2580
68const LOWER = 0x2584
69const SPACE = 0x20
70
71function rgb(key: string | undefined): number | undefined {
72  const hex = key === undefined ? undefined : MASCOT.palette[key]
73  return hex === undefined ? undefined : Number.parseInt(hex.slice(1), 16)
74}
75
76function base64(bytes: Uint8Array): string {
77  const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
78  let out = ''
79  for (let i = 0; i < bytes.length; i += 3) {
80    const a = bytes[i] ?? 0
81    const b = bytes[i + 1]
82    const c = bytes[i + 2]
83    const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
84    out += A[(n >> 18) & 63]! + A[(n >> 12) & 63]!
85    out += b === undefined ? '=' : A[(n >> 6) & 63]!
86    out += c === undefined ? '=' : A[n & 63]!
87  }
88  return out
89}
90
91/** A frame as a terminal Raster's cells: two pixel rows per cell, drawn with half blocks. */
92export function rasterCells(frame: Frame): string {
93  const words: number[] = []
94  for (let y = 0; y < MASCOT.height; y += 2) {
95    for (let x = 0; x < MASCOT.width; x++) {
96      const top = rgb(frame[y]?.[x])
97      const bottom = rgb(frame[y + 1]?.[x])
98      if (top !== undefined && bottom !== undefined) words.push(UPPER, top, bottom)
99      else if (top !== undefined) words.push(UPPER, top, DEFAULT)
100      else if (bottom !== undefined) words.push(LOWER, bottom, DEFAULT)
101      else words.push(SPACE, DEFAULT, DEFAULT)
102    }
103  }
104  return pack(words)
105}
106
107/** Cell words (code point, foreground, background) as a Raster takes them: little-endian u32s, base64. */
108function pack(words: readonly number[]): string {
109  const bytes = new Uint8Array(words.length * 4)
110  words.forEach((w, i) => {
111    bytes[i * 4] = w & 255
112    bytes[i * 4 + 1] = (w >> 8) & 255
113    bytes[i * 4 + 2] = (w >> 16) & 255
114    bytes[i * 4 + 3] = (w >> 24) & 255
115  })
116  return base64(bytes)
117}
118
119/** The spinner drawn before a step while its test runs: one cell, stepping with the mascot. */
120const SPIN = [...'⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏']
121export const spinnerGlyph = (n: number): string => SPIN[((n % SPIN.length) + SPIN.length) % SPIN.length]!
122/** That spinner as a one-cell terminal Raster, in `color` (`#rrggbb`). */
123export function spinnerCells(n: number, color: string): string {
124  return pack([spinnerGlyph(n).codePointAt(0)!, Number.parseInt(color.slice(1), 16), DEFAULT])
125}
126
127export const RASTER = { columns: MASCOT.width, rows: MASCOT.height / 2 } as const
128
129/** The mascot's size where it is drawn as a picture: its 8 by 6 pixels, six units each. */
130export const PICTURE = { width: MASCOT.width * 6, height: MASCOT.height * 6 } as const
131
132/** The mascot for surfaces that show images: the pose's pixel frame, one square per pixel, as an SVG. */
133export function mascotSvg(pose: Pose): string {
134  const frame = pixelFrame(pose)
135  const cells: string[] = []
136  frame.forEach((row, y) => {
137    ;[...row].forEach((key, x) => {
138      const fill = MASCOT.palette[key]
139      if (fill !== undefined) cells.push(`<rect x="${x}" y="${y}" width="1" height="1" fill="${fill}"/>`)
140    })
141  })
142  return (
143    `<svg xmlns="http://www.w3.org/2000/svg" width="${PICTURE.width}" height="${PICTURE.height}" viewBox="0 0 ${MASCOT.width} ${MASCOT.height}" shape-rendering="crispEdges">` +
144    cells.join('') +
145    `</svg>`
146  )
147}
148
hooks/model.ts 752 lines
1// Pure logic: internal events → runs, and runs + clock + settings → the band's rows and the pane's text.
2// No `$` here, so tests drive it directly.
3
4import type { Ev } from './adapter'
5import { RANK } from './adapter'
6import type { Assurance, Check, HistoryEntry, Kind, Pending, Run, Status, Step, UseCase } from '../types'
7
8/** The design's palette, made for dark terminals. */
9const DARK = {
10  purple: '#b388ff',
11  lilac: '#cdb8ff',
12  cyan: '#9fdcff',
13  mint: '#7fe3b5',
14  coral: '#ff8b94',
15  orange: '#ffa45c',
16  yellow: '#ffd66b',
17  dim: '#8480a3',
18  track: '#3a3658',
19  chipRun: '#2c2350',
20  chipIdle: '#1c1a2b',
21}
22
23/** The same hues, dark enough to read on a light background. */
24const LIGHT: typeof DARK = {
25  purple: '#6a3fd1',
26  lilac: '#7d5ce0',
27  cyan: '#1f6fa8',
28  mint: '#17845a',
29  coral: '#c8323f',
30  orange: '#b85400',
31  yellow: '#9a7200',
32  dim: '#6b6789',
33  track: '#cfcbe3',
34  chipRun: '#e7defd',
35  chipIdle: '#efedf5',
36}
37
38/** The colours everything draws with; `usePalette` switches them with Claude Code's theme. */
39export const C = { ...DARK }
40
41/** Light palettes for Claude Code's light themes (`light`, `light-daltonized`, `light-ansi`); dark otherwise. */
42export function usePalette(theme: unknown): 'light' | 'dark' {
43  const light = typeof theme === 'string' && theme.startsWith('light')
44  Object.assign(C, light ? LIGHT : DARK)
45  return light ? 'light' : 'dark'
46}
47
48// ── small text helpers ───────────────────────────────────────────────────
49
50/** Seconds → "41s" or "1m 06s". */
51export const fmt = (s: number): string => {
52  const n = Math.max(0, Math.round(s))
53  return n < 60 ? `${n}s` : `${Math.floor(n / 60)}m ${String(n % 60).padStart(2, '0')}s`
54}
55export const cut = (t: string, n: number): string => (t.length > n ? `${t.slice(0, Math.max(1, n - 1))}…` : t)
56/** A test's short name: a saved test without its `_test.md`. */
57export const nm = (r: Pick<Run, 'label'>): string => r.label.replace(/_test\.md$/, '')
58export const ago = (ms: number): string => {
59  const m = Math.round(ms / 60000)
60  if (m < 1) return 'just now'
61  if (m < 60) return `${m}m ago`
62  const h = Math.round(m / 60)
63  return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
64}
65const base = (p: string): string => p.split('/').pop() ?? p
66const PLACEHOLDER = /^Step \d+$/
67
68/** Planner noise a reader does not need: "click: PRIMARY: x; role=…" → "x". */
69export function tidyStep(text: string): string {
70  let t = text.replace(/^\w+:\s+(?=PRIMARY:|Clicking|Typing|Selecting|Opening|Scrolling|Navigate|Type |Press)/, '')
71  t = t.replace(/^PRIMARY:\s*/, '').replace(/\s*\|\s*HINTS:.*$/, '').replace(/;\s*role=.*$/, '')
72  t = t.replace(/^queued plan \(\d+ steps?\):\s*/, '')
73  return t.trim()
74}
75
76// ── runs ─────────────────────────────────────────────────────────────────
77
78export function newRun(id: string, kind: Kind, sessionDir: string, startedAt: number, extra: Partial<Run> = {}): Run {
79  return { id, label: kind === 'testrun' ? 'suite' : base(sessionDir), kind, status: 'running', startedAt, steps: [], sessionDir, labelRank: 0, ...extra }
80}
81
82const isLive = (s: Status) => s === 'running' || s === 'pending'
83
84function setStep(steps: Step[], i: number, patch: Partial<Step>): Step[] {
85  const next = [...steps]
86  while (next.length <= i) next.push({ text: '', status: 'pending' })
87  next[i] = { ...next[i]!, ...patch }
88  return next
89}
90
91/** The step being worked on: the last running one. */
92const currentIndex = (steps: readonly Step[]): number => {
93  for (let i = steps.length - 1; i >= 0; i--) if (steps[i]!.status === 'running') return i
94  return -1
95}
96
97function relabel(run: Run, text: string, rank: number): Run {
98  if (!text || rank < (run.labelRank ?? 0)) return run
99  return { ...run, label: text, labelRank: rank }
100}
101
102/** Ends every step still running, as the run ended. */
103function closeSteps(steps: readonly Step[], status: Status, at: number): Step[] {
104  return steps.map(s => (s.status === 'running' ? { ...s, status, endedAt: at } : s))
105}
106
107const lastFailed = (steps: readonly Step[]): Step | undefined => [...steps].reverse().find(s => s.status === 'failed')
108
109/** Folds one internal event into a run (a single run, a test file, or a suite). */
110export function fold(run: Run, ev: Ev): Run {
111  switch (ev.k) {
112    case 'start':
113      return { ...run, kind: ev.surface ?? run.kind, pid: ev.pid ?? run.pid }
114    case 'newer':
115      return { ...run, newer: true }
116    case 'postHoc':
117      return run.postHoc ? run : { ...run, postHoc: true }
118    case 'label':
119      return relabel(run, ev.text, ev.rank)
120    case 'ask':
121      return { ...run, waiting: ev.question }
122    case 'error':
123      return run.failure ? run : { ...run, failure: { why: ev.message } }
124    case 'step': {
125      const i = ev.n - 1
126      const text = tidyStep(ev.text)
127      if (ev.status === 'running') {
128        const steps = setStep(closeSteps(run.steps, 'passed', ev.at), i, { status: 'running', startedAt: ev.at, text: PLACEHOLDER.test(text) ? '' : text })
129        return { ...run, steps, waiting: undefined }
130      }
131      const prev = run.steps[i]
132      const done: Partial<Step> = { status: ev.status === 'done' ? 'passed' : 'failed', endedAt: ev.at, text: text || prev?.text || '(no action recorded)' }
133      if (!prev?.startedAt) done.startedAt = ev.at
134      let next: Run = { ...run, steps: setStep(run.steps, i, done), waiting: undefined }
135      if (text && (run.labelRank ?? 0) < RANK.firstStep) next = relabel(next, text, RANK.firstStep)
136      return next
137    }
138    case 'runEnd': {
139      const credits = ev.credits === undefined ? run.credits : (run.kind === 'testmd' ? (run.credits ?? 0) : 0) + ev.credits
140      const facts = ev.status === 'failed' ? { why: ev.why ?? run.failure?.why ?? '', category: ev.category, severity: ev.severity, confidence: ev.confidence } : undefined
141      // A test file's run_end closes one of its steps, not the test. kane's investigated verdict (it has a
142      // category) is kept over a later failure that has none, such as a retry refused for want of a URL.
143      if (run.kind === 'testmd') {
144        const keep = run.failure?.category && !facts?.category
145        return { ...run, credits, failure: facts && facts.why && !keep ? { ...run.failure, ...facts } : run.failure }
146      }
147      const steps = closeSteps(run.steps, ev.status, ev.at)
148      const failure = facts ? { ...facts, why: facts.why || 'kane-cli reported a failure', where: lastFailed(steps)?.text || [...steps].reverse().find(s => s.text)?.text } : undefined
149      return { ...run, status: ev.status, endedAt: ev.at, steps, credits, failure, waiting: undefined }
150    }
151    case 'mdStepStart': {
152      const heading = PLACEHOLDER.test(ev.heading) ? '' : ev.heading
153      return { ...run, steps: setStep(run.steps, ev.index - 1, { text: heading, status: 'running', startedAt: ev.at }) }
154    }
155    case 'runStart': {
156      let next: Run = ev.target ? { ...run, target: ev.target } : run
157      const i = currentIndex(next.steps)
158      if (ev.objective && i >= 0 && !next.steps[i]!.text) next = { ...next, steps: setStep(next.steps, i, { text: ev.objective.replace(/\.$/, '') }) }
159      if (ev.objective) next = relabel(next, ev.objective, RANK.objective)
160      return next
161    }
162    case 'detail': {
163      const i = currentIndex(run.steps)
164      if (i < 0) return run
165      const s = run.steps[i]!
166      if (ev.kind === 'think') return { ...run, steps: setStep(run.steps, i, { think: ev.text }) }
167      if (ev.kind === 'act') return { ...run, steps: setStep(run.steps, i, { act: ev.text }) }
168      const checks: Check[] = [...(s.checks ?? []), { ok: ev.ok !== false, text: ev.text }]
169      return { ...run, steps: setStep(run.steps, i, { checks }) }
170    }
171    case 'mdStepEnd':
172      return { ...run, steps: setStep(run.steps, ev.index - 1, { status: ev.status === 'passed' ? 'passed' : 'failed', endedAt: ev.at }) }
173    case 'mdDone': {
174      const status: Status = ev.status === 'passed' ? 'passed' : 'failed'
175      const steps = closeSteps(run.steps, status, ev.at)
176      const failure = status === 'failed' ? { why: run.failure?.why || 'the test failed', ...run.failure, where: lastFailed(steps)?.text } : undefined
177      return { ...run, status, endedAt: ev.at, steps, failure, waiting: undefined }
178    }
179    case 'plan': {
180      const members = ev.members.map(p => newRun(p, 'testmd', '', ev.at, { label: base(p), status: 'pending', labelRank: RANK.command }))
181      return { ...run, kind: 'testrun', members }
182    }
183    case 'memberStart':
184      return withMember(run, ev.path, m => ({ ...m, status: 'running', startedAt: ev.at, sessionDir: ev.logPath ? ev.logPath.replace(/\/[^/]+$/, '') : m.sessionDir }))
185    case 'progress': {
186      let next = run
187      for (const p of ev.running) next = withMember(next, p, m => (m.status === 'pending' ? { ...m, status: 'running', startedAt: ev.at } : m))
188      return next
189    }
190    case 'memberEnd':
191      return withMember(run, ev.path, m => {
192        const status: Status = ev.status === 'passed' ? 'passed' : 'failed'
193        const startedAt = m.status === 'pending' && ev.durationS !== undefined ? ev.at - ev.durationS * 1000 : m.startedAt
194        const steps = closeSteps(m.steps, status, ev.at)
195        const failure = status === 'failed' ? { why: m.failure?.why || ev.why || `kane-cli reported it ${ev.status}, with no reason`, ...m.failure, where: m.failure?.where ?? lastFailed(steps)?.text } : undefined
196        return { ...m, status, startedAt, endedAt: ev.at, steps, failure }
197      })
198    case 'suiteDone': {
199      const status: Status = ev.status === 'passed' ? 'passed' : 'failed'
200      // A grid job that failed before any test reported: every test shares the job's reason.
201      const lost = status === 'failed' && !!run.remote && !(run.members ?? []).some(m => m.status === 'passed' || m.status === 'failed')
202      const members = (run.members ?? []).map(m => {
203        if (m.status === 'running') return { ...m, status: 'failed' as const, endedAt: ev.at, failure: { why: 'the suite ended before this test finished' } }
204        if (lost && m.status === 'pending') return { ...m, status: 'failed' as const, startedAt: ev.at, endedAt: ev.at, failure: { why: run.failure?.why || 'the grid job ended with no result for this test' } }
205        return m
206      })
207      return { ...run, status, endedAt: ev.at, members, ...(ev.executionId ? { executionId: ev.executionId } : {}) }
208    }
209    case 'remote': {
210      const remote = { ...run.remote }
211      if (ev.backend) remote.backend = ev.backend
212      if (ev.jobId) remote.jobId = ev.jobId
213      if (ev.jobUrl) remote.jobUrl = ev.jobUrl
214      if (ev.dispatched) remote.dispatchedAt = ev.at
215      return { ...run, kind: 'testrun', remote }
216    }
217  }
218}
219
220/** Plan paths are absolute; progress lines may be relative to where the suite ran. */
221export const samePath = (a: string, b: string): boolean => a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`)
222
223function withMember(run: Run, path: string, fn: (m: Run) => Run): Run {
224  const members = run.members ?? []
225  const i = members.findIndex(m => samePath(m.id, path))
226  if (i === -1) return { ...run, kind: 'testrun', members: [...members, fn(newRun(path, 'testmd', '', run.startedAt, { label: base(path), status: 'pending', labelRank: RANK.command }))] }
227  const next = [...members]
228  next[i] = fn(members[i]!)
229  return { ...run, members: next }
230}
231
232/** Folds a member's own stream (a test file) into the suite. Its end is the suite's to report. */
233export function foldMember(run: Run, path: string, ev: Ev): Run {
234  if (ev.k === 'label' || ev.k === 'start') return run
235  return withMember(run, path, m => {
236    const next = fold(m, ev)
237    // The suite's member_end decides the member's result; its own stream only adds steps and facts.
238    return ev.k === 'mdDone' && m.status === 'running' ? { ...next, status: m.status, endedAt: m.endedAt } : next
239  })
240}
241
242/** Folds a whole recorded stream (tests and fixtures). */
243export function replay(run: Run, events: readonly Ev[]): Run {
244  return events.reduce(fold, run)
245}
246
247// ── counting ─────────────────────────────────────────────────────────────
248
249/** The tests a run stands for: a suite's members, or the run itself. */
250export const tests = (r: Run): Run[] => (r.kind === 'testrun' && r.members ? r.members : [r])
251export const leaves = (runs: readonly Run[]): Run[] => runs.flatMap(tests)
252
253export type Tally = { passed: number; failed: number; running: number; pending: number; total: number }
254export function tally(list: readonly Run[]): Tally {
255  const out: Tally = { passed: 0, failed: 0, running: 0, pending: 0, total: list.length }
256  for (const r of list) out[r.status] += 1
257  return out
258}
259
260/** Each test by its latest result: a test that failed and then passed counts once, as passed. */
261export function latest(list: readonly Run[]): Run[] {
262  const newest = new Map<string, Run>()
263  for (const r of list) {
264    const seen = newest.get(r.label)
265    if (!seen || (r.endedAt ?? r.startedAt) >= (seen.endedAt ?? seen.startedAt)) newest.set(r.label, r)
266  }
267  return list.filter(r => newest.get(r.label) === r)
268}
269
270/** Seconds a run has taken, or has been going. */
271export const elapsed = (r: Run, now: number): number => ((r.endedAt ?? now) - r.startedAt) / 1000
272
273/** The step a run is on (or failed on). */
274export function currentStep(r: Run): Step | undefined {
275  const i = currentIndex(r.steps)
276  if (i >= 0) return r.steps[i]
277  return lastFailed(r.steps)
278}
279
280/** What a run is doing now, in words. Surface `run` names a step only when it ends. */
281export function nowText(r: Run): string {
282  if (r.waiting) return `waiting for an answer: ${r.waiting}`
283  const s = currentStep(r)
284  if (s?.text) return s.text
285  const done = [...r.steps].reverse().find(x => x.status !== 'running' && x.text)
286  if (done) return `✓ ${done.text} · next step…`
287  return 'starting the browser'
288}
289
290/** True while a suite's tests are on the remote grid and none has reported: the grid reports them when the job ends. */
291export function onGrid(r: Run): boolean {
292  if (!r.remote || !isLive(r.status)) return false
293  return !(r.members ?? []).some(m => m.status !== 'pending')
294}
295
296/** "sent 2m 05s ago", or that the job is still being sent. */
297export const gridSince = (r: Run, now: number): string => (r.remote?.dispatchedAt ? `sent ${fmt((now - r.remote.dispatchedAt) / 1000)} ago` : 'sending the job')
298
299// ── the band ────────────────────────────────────────────────────────────
300
301export type Span = { t: string; c?: string; bg?: string; b?: boolean; d?: boolean }
302/** One piece of a row; an array is drawn with no gap between its spans. */
303export type Item = Span | Span[]
304export type RowButton = { key: string; label: string; act: string }
305/** `spin`: the row is what a running test is doing now, drawn behind a spinner. */
306export type Row = { items: Item[]; button?: RowButton; spin?: boolean }
307export type Band = { think: boolean; rows: [Row, Row, Row] }
308export type BandOptions = {
309  assurance: Assurance
310  /** Files changed and untested: shown with a button (offer) or as Claude's next job (auto). */
311  change?: { files: number; mode: 'offer' | 'auto' }
312  last?: HistoryEntry | null
313  now: number
314  columns: number
315}
316
317const dim = (t: string): Span => ({ t, d: true })
318const chip = (t: string, live: boolean): Span => ({ t: ` ${t} `, bg: live ? C.chipRun : C.chipIdle, c: live ? C.purple : C.dim, b: live })
319const tone = (s: Status): string => ({ passed: C.mint, failed: C.coral, running: C.cyan, pending: C.dim })[s]
320
321/** One coloured cell per test; neighbours of one colour share a span. Past 40 tests, a proportional bar. */
322export function cells(states: readonly Status[], width = 40): Span[] {
323  if (states.length > 40) {
324    const order: Status[] = ['passed', 'failed', 'running', 'pending']
325    const out: Span[] = []
326    let used = 0
327    for (const s of order) {
328      const n = states.filter(x => x === s).length
329      if (!n) continue
330      const w = Math.max(1, Math.round((n / states.length) * width))
331      out.push({ t: (s === 'pending' ? '░' : '█').repeat(Math.min(w, width - used)), c: tone(s) })
332      used += w
333    }
334    return out
335  }
336  const out: Span[] = []
337  for (const s of states) {
338    const glyph = s === 'pending' ? '□' : '■'
339    const last = out[out.length - 1]
340    if (last && last.c === tone(s)) last.t += glyph
341    else out.push({ t: glyph, c: tone(s) })
342  }
343  return out
344}
345
346export function bar(width: number, fraction: number, color: string): Span[] {
347  const on = Math.round(width * Math.min(1, Math.max(0, fraction)))
348  return [
349    { t: '━'.repeat(on), c: color },
350    { t: '─'.repeat(width - on), c: C.track },
351  ]
352}
353
354export function assuranceRow(a: Assurance): Row {
355  const head: Span = { t: 'assurance', c: C.yellow }
356  if (a.state === 'none') return { items: [head, dim('not set up')], button: { key: 'setup', label: 'Set up now', act: 'tab:assure' } }
357  if (a.state === 'unknown') return { items: [head] }
358  const cases = `${a.useCases.length} use case${a.useCases.length === 1 ? '' : 's'}`
359  if (a.provenPct === undefined) return { items: [head, bar(16, a.designedPct / 100, C.yellow), { t: `${a.designedPct}%`, c: C.yellow, b: true }, dim('designed · nothing run yet')] }
360  return { items: [head, bar(16, a.provenPct / 100, C.yellow), { t: `${a.provenPct}%`, c: C.yellow, b: true }, dim(`proven · ${cases}`)] }
361}
362
363/** "now a 30s · b 28s · +2 more", as many as fit in `room` cells. */
364function nowRow(live: readonly Run[], now: number, room: number): Row {
365  const items: Item[] = [dim('now')]
366  const nameRoom = Math.max(8, Math.min(28, Math.floor(room / 3) - 6))
367  let used = 4
368  let shown = 0
369  for (const r of live) {
370    const name = cut(nm(r), nameRoom)
371    const time = fmt(elapsed(r, now))
372    const need = name.length + time.length + 4
373    if (shown > 0 && used + need > room - 9) break
374    if (shown > 0) items.push(dim('·'))
375    items.push({ t: name }, dim(time))
376    used += need
377    shown += 1
378  }
379  if (shown < live.length) items.push(dim(`· +${live.length - shown} more`))
380  return { items, spin: live.length > 0 }
381}
382
383function failRow(r: Run, room: number): Row {
384  const name = cut(nm(r), Math.max(10, Math.floor(room / 3)))
385  const where = r.failure?.where ?? currentStep(r)?.text
386  const rest = Math.max(10, room - name.length - 14)
387  if (where) return { items: [{ t: `✗ ${name}`, c: C.coral }, dim('failed on'), { t: cut(where, rest) }] }
388  // No step to point at (kane-cli refused or stopped early): its own reason says why.
389  const why = r.failure?.why
390  return { items: [{ t: `✗ ${name}`, c: C.coral }, dim(why ? 'failed:' : 'failed'), ...(why ? [{ t: cut(why, rest) }] : [])] }
391}
392
393const newestFailed = (list: readonly Run[]): Run | undefined =>
394  [...list].filter(r => r.status === 'failed').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))[0]
395
396/** Runs that share the screen with what is live now: live ones and those that ended since the first of them started. */
397function batch(runs: readonly Run[]): Run[] {
398  const live = runs.filter(r => isLive(r.status))
399  if (live.length === 0) return []
400  const from = Math.min(...live.map(r => r.startedAt))
401  return runs.filter(r => isLive(r.status) || (r.endedAt ?? 0) >= from)
402}
403
404const itemWidth = (i: Item): number => (Array.isArray(i) ? i.reduce((n, s) => n + [...s.t].length, 0) : [...i.t].length)
405
406/** Cells a row takes as drawn: items one cell apart, a button as `[ label ]`. */
407export function rowWidth(r: Row): number {
408  const parts = (r.spin ? [1] : []).concat(r.items.map(itemWidth), r.button ? [r.button.label.length + 4] : [])
409  return parts.reduce((a, b) => a + b, 0) + Math.max(0, parts.length - 1)
410}
411
412/** Names are already cut to fit; what still does not fit is dropped from the right, the button kept. */
413export function fitRow(r: Row, room: number): Row {
414  let row = r
415  while (row.items.length > 1 && rowWidth(row) > room) row = { ...row, items: row.items.slice(0, -1) }
416  return row
417}
418
419/** The three rows above the prompt. The first state that matches wins. */
420export function band(runs: readonly Run[], o: BandOptions): Band {
421  const b = pickBand(runs, o)
422  const room = Math.max(30, o.columns)
423  return { think: b.think, rows: [fitRow(b.rows[0], room), fitRow(b.rows[1], room), fitRow(b.rows[2], room)] }
424}
425
426function pickBand(runs: readonly Run[], o: BandOptions): Band {
427  const open: RowButton = { key: 'open', label: 'Open', act: 'open' }
428  const room = Math.max(30, o.columns)
429  const live = runs.filter(r => isLive(r.status))
430  const only = live.length === 1 ? live[0] : undefined
431  const think = live.some(r => !r.postHoc)
432
433  if (only?.kind === 'testrun' && only.members) {
434    const c = tally(only.members)
435    const running = only.members.filter(m => m.status === 'running')
436    const failed = newestFailed(only.members)
437    const counts = c.total > 0 ? [{ t: `${c.passed + c.failed} of ${c.total} tests done`, b: true }, dim(`· ${c.running} running · ${c.pending} left`)] : [dim('planning')]
438    if (onGrid(only)) {
439      const tests = c.total > 0 ? [{ t: `${c.total} test${c.total === 1 ? '' : 's'} on the grid`, b: true }, dim(`· ${gridSince(only, o.now)}`)] : [dim(gridSince(only, o.now))]
440      return {
441        think,
442        rows: [
443          { items: [chip('suite', true), cells(only.members.map(m => m.status)), ...tests], button: open },
444          { items: [dim('grid'), { t: only.remote?.backend ?? 'remote' }, dim('· each test is reported when the job ends')] },
445          assuranceRow(o.assurance),
446        ],
447      }
448    }
449    return {
450      think,
451      rows: [
452        { items: [chip('suite', true), cells(only.members.map(m => m.status)), ...counts], button: open },
453        failed ? failRow(failed, room) : nowRow(running, o.now, room),
454        assuranceRow(o.assurance),
455      ],
456    }
457  }
458
459  if (only) {
460    if (only.newer) return { think, rows: [{ items: [chip('running', true), { t: cut(nm(only), Math.max(12, room - 24)), b: true }, dim(fmt(elapsed(only, o.now)))], button: open }, { items: [dim('this kane-cli is newer than the mod')] }, assuranceRow(o.assurance)] }
461    const step = nowText(only)
462    return {
463      think,
464      rows: [
465        { items: [chip('running', true), { t: cut(nm(only), Math.max(12, room - 24)), b: true }, dim(fmt(elapsed(only, o.now)))], button: open },
466        only.waiting ? { items: [{ t: cut(step, room), c: C.orange }] } : { items: [{ t: cut(step, room - 2) }], spin: true },
467        assuranceRow(o.assurance),
468      ],
469    }
470  }
471
472  if (live.length > 1) {
473    const all = leaves(batch(runs))
474    const c = tally(all)
475    const counts = [c.passed ? `${c.passed} passed` : '', c.failed ? `${c.failed} failed` : '', `${c.running} running`, c.pending ? `${c.pending} waiting` : ''].filter(Boolean).join(' · ')
476    const now = nowRow(all.filter(r => r.status === 'running'), o.now, room)
477    const failed = newestFailed(all)
478    return {
479      think,
480      rows: [{ items: [chip(`${all.length} runs`, true), cells(all.map(r => r.status)), dim(counts)], button: open }, failed ? failRow(failed, room) : now, assuranceRow(o.assurance)],
481    }
482  }
483
484  // idle: counts cover the tests seen in this session, each by its latest result
485  const all = latest(leaves(runs))
486  const c = tally(all)
487  const first: Item[] = [chip('idle', false)]
488  if (c.passed) first.push({ t: `✓ ${c.passed} passed`, c: C.mint })
489  if (c.passed && c.failed) first.push(dim('·'))
490  if (c.failed) first.push({ t: `✗ ${c.failed} failed`, c: C.coral })
491  let second: Row = { items: [] }
492  const failed = newestFailed(all)
493  if (o.change && o.change.mode === 'auto') {
494    const warn = `⚠ ${files(o.change.files)} changed, untested`
495    // Too narrow for the whole note: its short form, rather than nothing.
496    second = { items: [{ t: warn, c: C.orange }, dim(warn.length + 44 <= room ? '· Claude will test this before it finishes' : '· Claude will test it')] }
497  }
498  else if (o.change) second = { items: [{ t: `⚠ ${files(o.change.files)} changed, untested`, c: C.orange }], button: { key: 'test', label: 'Test this change', act: 'offer' } }
499  else if (failed) second = failRow(failed, room)
500  else if (all.length === 0 && o.last) second = { items: [dim('last run'), o.last.status === 'passed' ? { t: '✓', c: C.mint } : { t: '✗', c: C.coral }, dim(`${cut(nm(o.last), 30)} · ${ago(o.now - o.last.at)}`)] }
501  return { think: false, rows: [{ items: first, button: open }, second, assuranceRow(o.assurance)] }
502}
503
504const files = (n: number) => `${n} file${n === 1 ? '' : 's'}`
505
506/** A row as plain text, for tests and for surfaces that draw no colour. */
507export const rowText = (r: Row): string =>
508  r.items
509    .map(i => (Array.isArray(i) ? i.map(s => s.t).join('') : i.t))
510    .concat(r.button ? [`[${r.button.label}]`] : [])
511    .join(' ')
512
513// ── the pane ─────────────────────────────────────────────────────────────
514
515export const ORDER: Record<Status, number> = { failed: 0, running: 1, passed: 2, pending: 3 }
516
517/** A card's second line. */
518export function summary(r: Run): string {
519  if (r.status === 'running') return nowText(r)
520  if (r.status === 'passed') return ['passed', `${r.steps.filter(s => s.text || s.status !== 'pending').length} steps`, r.credits !== undefined ? `${Math.round(r.credits * 10) / 10} credits` : ''].filter(Boolean).join(' · ')
521  if (r.status === 'failed') return r.failure?.where ? `failed · ${r.failure.where}` : 'failed'
522  return 'waiting to start'
523}
524
525/** "product bug · major · 90% sure", from the verdict's own fields only. */
526export function kindLine(r: Run): string | undefined {
527  const f = r.failure
528  if (!f) return undefined
529  const parts = [f.category?.replace(/_/g, ' '), f.severity, f.confidence !== undefined ? `${Math.round(f.confidence * 100)}% sure` : undefined].filter(Boolean)
530  return parts.length ? parts.join(' · ') : undefined
531}
532
533// ── the card in the chat ─────────────────────────────────────────────────
534
535/** True when a run ended passed with nothing but passes: the run itself, or a suite and every one of its tests. */
536export function allPassed(r: Run): boolean {
537  if (r.status !== 'passed') return false
538  if (r.kind === 'testrun' && r.members) return r.members.length > 0 && r.members.every(m => m.status === 'passed')
539  return true
540}
541
542/** The badge that closes a finished card: who ran the check, as a solid chip in kane's colour behind a shield. */
543export function cardFooter(): Span[] {
544  return [{ t: ' ⛨ Verified with KaneAI ', bg: C.purple, c: C.chipIdle, b: true }]
545}
546
547/** A chat card's right-hand result: "✓ passed · 41s · 12.4 credits", a suite's counts, or "✓ all 3 passed" when none failed. Each part only if kane-cli reported it. */
548export function cardResult(r: Run, now: number): Span[] {
549  const time = fmt(elapsed(r, now))
550  if (r.kind === 'testrun' && r.members) {
551    const c = tally(r.members)
552    if (allPassed(r)) return [{ t: c.total === 1 ? '✓ passed' : `✓ all ${c.total} passed`, c: C.mint, b: true }, { t: time, d: true }]
553    const out: Span[] = []
554    if (c.passed) out.push({ t: `${c.passed} passed`, c: C.mint })
555    if (c.failed) out.push({ t: `${c.failed} failed`, c: C.coral })
556    if (c.running) out.push({ t: `${c.running} running`, c: C.cyan })
557    if (c.pending && isLive(r.status)) out.push({ t: `${c.pending} left`, d: true })
558    out.push({ t: time, d: true })
559    return out
560  }
561  const out: Span[] = [{ t: r.status === 'passed' ? '✓ passed' : '✗ failed', c: tone(r.status), b: true }, { t: time, d: true }]
562  if (r.credits !== undefined) out.push({ t: `${Math.round(r.credits * 10) / 10} credits`, d: true })
563  return out
564}
565
566/** The header fitted to `room` cells: the name is cut first, then credits drop, then the time; the status stays. */
567export function cardHeader(r: Run, now: number, room: number): { name: string; result: Span[] } {
568  let result = cardResult(r, now)
569  const width = (list: Span[]) => list.reduce((n, s) => n + [...s.t].length + 3, 0)
570  const min = 12
571  while (result.length > 1 && width(result) + min + 8 > room) result = result.slice(0, -1)
572  return { name: cut(r.label, Math.max(min, room - width(result) - 8)), result }
573}
574
575/** The first line of a step's text that is not blank: a designed test's step can run to a paragraph. */
576export const firstLine = (t: string): string => t.split('\n').map(l => l.trim()).find(Boolean) ?? ''
577
578/** A suite card's failures: three at most, then how many more. */
579export function cardFailures(r: Run): { rows: Run[]; more: number } {
580  const failed = (r.members ?? []).filter(m => m.status === 'failed').sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
581  return { rows: failed.slice(0, 3), more: Math.max(0, failed.length - 3) }
582}
583
584/** True when a shell command is a kane-cli test run: the only commands that get a card. */
585export const isRunCommand = (command: string): boolean => labelFromCommand(command) !== undefined
586
587// ── assurance ────────────────────────────────────────────────────────────
588
589/** The most a use case's detail lists; its counts still cover every row. */
590const MAX_PENDING = 20
591
592/** `kane-cli cover gaps --json` → what the band and the pane show. */
593export function parseCoverGaps(text: string): Assurance {
594  const start = text.indexOf('{')
595  if (start === -1) return { state: 'unknown' }
596  let o: Record<string, any>
597  try {
598    o = JSON.parse(text.slice(start))
599  } catch {
600    return { state: 'unknown' }
601  }
602  const pct = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : undefined)
603  const proven = o.proven && typeof o.proven === 'object' ? o.proven : undefined
604  const started = Date.parse(String(proven?.latest_run?.started_at ?? ''))
605  const useCases: UseCase[] = (Array.isArray(o.usecases) ? o.usecases : []).map((u: Record<string, any>) => {
606    const pending: Record<string, any>[] = Array.isArray(u.pending) ? u.pending : []
607    const text = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
608    const owed: Pending[] = pending
609      .filter(p => p && (p.stage === 'design' || p.stage === 'cover'))
610      .slice(0, MAX_PENDING)
611      .map(p => ({ title: text(p.title) ?? text(p.id) ?? text(p.ref) ?? text(p.kind) ?? 'gap', why: text(p.why), risk: text(p.risk), stage: p.stage, command: text(p.ready_command) }))
612    return {
613      id: String(u.id ?? u.title ?? 'use case'),
614      title: text(u.id) ? text(u.title) : undefined,
615      risk: text(u.risk),
616      designed: pct(u.design_completeness?.pct) ?? 0,
617      proven: pct(u.proven?.pct),
618      stale: typeof u.stale_acs === 'number' ? u.stale_acs : 0,
619      toDesign: pending.filter(p => p.stage === 'design').length,
620      toCover: pending.filter(p => p.stage === 'cover').length,
621      pending: owed,
622    }
623  })
624  return {
625    state: 'ready',
626    designedPct: pct(o.design_completeness?.pct) ?? 0,
627    provenPct: pct(proven?.pct),
628    failing: typeof proven?.failing === 'number' ? proven.failing : undefined,
629    blocked: typeof proven?.blocked === 'number' ? proven.blocked : undefined,
630    lastRunAt: Number.isFinite(started) ? started : undefined,
631    useCases,
632  }
633}
634
635/** A use case's debt, each with its colour: "1 stale", "2 to design", "3 to run". */
636export function debt(u: UseCase): Span[] {
637  const out: Span[] = []
638  if (u.stale) out.push({ t: `${u.stale} stale`, c: C.orange })
639  if (u.toDesign) out.push({ t: `${u.toDesign} to design`, c: C.dim })
640  if (u.toCover) out.push({ t: `${u.toCover} to run`, c: C.dim })
641  if (!out.length && u.proven === 100) out.push({ t: 'proven', c: C.mint })
642  return out
643}
644
645/** What Claude is asked when the person wants a use case's gaps closed. */
646export function gapsPrompt(u: UseCase): string {
647  const first = u.pending.find(p => p.command)
648  const start = first?.command ? ` Start with \`${first.command}\` (${first.title}).` : ''
649  return `Close the assurance gaps of kane-cli use case ${u.id}: \`kane-cli cover gaps ${u.id}\` lists what it still owes.${start} Use --mode agent on kane-cli's design and maintain commands, and tell me what changed.`
650}
651
652// ── history ──────────────────────────────────────────────────────────────
653
654/** How many finished runs the mod keeps per project. */
655export const HISTORY_MAX = 30
656
657/** What the mod remembers of a finished run. */
658export function historyEntry(r: Run): HistoryEntry | undefined {
659  if (r.status !== 'passed' && r.status !== 'failed') return undefined
660  const at = r.endedAt ?? r.startedAt
661  const e: HistoryEntry = { label: r.label, status: r.status, at, kind: r.kind, seconds: Math.max(0, Math.round((at - r.startedAt) / 1000)) }
662  if (r.failure?.where) e.where = r.failure.where
663  if (r.kind === 'testrun' && r.members) {
664    const c = tally(r.members)
665    e.tests = { passed: c.passed, failed: c.failed }
666  }
667  return e
668}
669
670/** A history row's second line: a suite's counts, where a run failed, or that it passed. */
671export function historyDetail(h: HistoryEntry): string {
672  const took = h.seconds ? ` · ${fmt(h.seconds)}` : ''
673  if (h.tests) return `${h.tests.passed} passed · ${h.tests.failed} failed${took}`
674  if (h.status === 'failed') return `${h.where ? `failed on ${h.where}` : 'failed'}${took}`
675  return `passed${took}`
676}
677
678// ── what Claude ran ──────────────────────────────────────────────────────
679
680/** The run a Bash command starts, named from its own words: the best label kane-cli's runs can get. */
681export function labelFromCommand(command: string): { label: string; surface: Kind } | undefined {
682  const at = command.search(/\bkane-cli\b/)
683  if (at === -1) return undefined
684  const rest = command.slice(at)
685  const md = rest.match(/\btestmd\s+run\s+(["']?)([^"'\s]+)\1/)
686  if (md) return { label: base(md[2]!), surface: 'testmd' }
687  const suite = rest.match(/\btestrun\s+run\b([^|;&\n]*)/)
688  if (suite) {
689    const flags = (suite[1] ?? '').match(/--(tags|from-context|filter)\s+(\S+)/)
690    return { label: flags ? `suite · --${flags[1]} ${flags[2]}` : 'suite', surface: 'testrun' }
691  }
692  const run = rest.match(/\brun\s+(["'])([\s\S]*?)\1/)
693  if (run && !/\b(testmd|testrun)\b/.test(rest.slice(0, run.index))) return { label: run[2]!.trim(), surface: 'run' }
694  return undefined
695}
696
697// ── after Claude changes code ────────────────────────────────────────────
698
699/** Edits that never need a browser test: docs, and tool folders (any hidden one: .testmuai, .context, .claude, .omc, .vscode…).
700 *  `path` is relative to the project. */
701export function isTrackedEdit(path: string): boolean {
702  if (/\.(md|mdx|markdown|txt|rst|adoc)$/i.test(path)) return false
703  if (/(^|\/)(docs?|node_modules)\//.test(path)) return false
704  if (path.split('/').slice(0, -1).some(seg => seg.startsWith('.'))) return false
705  return true
706}
707
708const GENERIC = new Set(['src', 'app', 'lib', 'index', 'main', 'utils', 'util', 'components', 'component', 'pages', 'page', 'test', 'tests', 'spec', 'hooks', 'types', 'tsx', 'jsx', 'mjs', 'cjs', 'css', 'scss', 'html', 'json', 'the', 'and', 'for', 'with', 'helpers', 'helper', 'common', 'shared', 'core'])
709
710/** Feature words in a path: "src/checkout/AddressForm.tsx" → checkout, address, form. */
711export function featureWords(path: string): string[] {
712  const words = path
713    .replace(/([a-z])([A-Z])/g, '$1 $2')
714    .toLowerCase()
715    .split(/[^a-z0-9]+/)
716    .filter(w => w.length >= 4 && !GENERIC.has(w) && !/^\d+$/.test(w))
717  return [...new Set(words)]
718}
719
720/** Saved tests that mention a changed file's feature, best match first. Equal matches: one that last failed, then the
721 *  one run most recently, then by name. */
722export function savedTestsFor(changed: readonly string[], saved: readonly { name: string; text: string }[], last: Readonly<Record<string, Pick<HistoryEntry, 'status' | 'at'>>> = {}): string[] {
723  const words = [...new Set(changed.flatMap(featureWords))]
724  if (!words.length) return []
725  return saved
726    .map(t => {
727      // A test named for the feature outranks one that only mentions it.
728      const name = t.name.toLowerCase().replace(/[_-]/g, ' ')
729      const body = t.text.toLowerCase()
730      const hits = words.reduce((n, w) => n + (new RegExp(`\\b${w}`).test(name) ? 2 : 0) + (new RegExp(`\\b${w}`).test(body) ? 1 : 0), 0)
731      return { name: t.name, hits }
732    })
733    .filter(t => t.hits > 0)
734    .sort((a, b) => {
735      if (b.hits !== a.hits) return b.hits - a.hits
736      const [x, y] = [last[a.name], last[b.name]]
737      const failed = Number(y?.status === 'failed') - Number(x?.status === 'failed')
738      if (failed) return failed
739      if ((y?.at ?? 0) !== (x?.at ?? 0)) return (y?.at ?? 0) - (x?.at ?? 0)
740      return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
741    })
742    .map(t => t.name)
743}
744
745/** The start page the saved tests use most: kane-cli needs one when it has no default URL. */
746export function startUrl(saved: readonly { text: string }[]): string | undefined {
747  const count = new Map<string, number>()
748  for (const t of saved) for (const m of t.text.matchAll(/https?:\/\/[^\s)"'`<>]+/g)) count.set(m[0], (count.get(m[0]) ?? 0) + 1)
749  return [...count.entries()].sort((a, b) => b[1] - a[1])[0]?.[0]
750}
751
752
hooks/sources.ts 70 lines
1// What kane-cli leaves on disk, as plain values: the live-run pointer and byte offsets into event files.
2// The reads themselves (`$.fs`, `$.process`) live in register.tsx; this file only shapes what they return.
3
4import type { Kind } from '../types'
5
6/** `~/.testmuai/kaneai/sessions/active/<pid>.json`, kept while a kane-cli process runs. */
7export type Pointer = { pid: number; cwd: string; surface: Kind; sessionDir: string; started: number; cli?: string }
8
9/** `$.fs.read` refuses files above this; past it the tail is read with `tail -c`. */
10export const MAX_READ = 4 * 1024 * 1024
11
12export function parsePointer(text: string, now = 0): Pointer | undefined {
13  let o: Record<string, unknown>
14  try {
15    o = JSON.parse(text) as Record<string, unknown>
16  } catch {
17    return undefined
18  }
19  if (!o || typeof o.pid !== 'number' || typeof o.session_dir !== 'string') return undefined
20  const surface = o.surface === 'testmd' || o.surface === 'testrun' ? o.surface : 'run'
21  const started = Date.parse(String(o.started ?? ''))
22  return {
23    pid: o.pid,
24    cwd: typeof o.cwd === 'string' ? o.cwd.replace(/\/$/, '') : '',
25    surface,
26    sessionDir: o.session_dir.replace(/\/$/, ''),
27    started: Number.isFinite(started) ? started : now,
28    cli: typeof o.cli_version === 'string' ? o.cli_version : undefined,
29  }
30}
31
32/** True when `dir` is `root` or inside it: another project's runs stay out. */
33export function inside(dir: string, root: string): boolean {
34  if (!dir || !root) return false
35  const r = root.replace(/\/$/, '')
36  return dir === r || dir.startsWith(`${r}/`)
37}
38
39/** UTF-8 length of a string: event files hold “curly quotes” and other multi-byte text. */
40export function utf8Len(s: string): number {
41  let n = 0
42  for (let i = 0; i < s.length; i++) {
43    const c = s.charCodeAt(i)
44    if (c < 0x80) n += 1
45    else if (c < 0x800) n += 2
46    else if (c >= 0xd800 && c < 0xdc00) {
47      n += 4
48      i += 1
49    } else n += 3
50  }
51  return n
52}
53
54/** The text after its first `bytes` UTF-8 bytes. */
55export function afterBytes(s: string, bytes: number): string {
56  let n = 0
57  let i = 0
58  while (i < s.length && n < bytes) {
59    const c = s.charCodeAt(i)
60    if (c < 0x80) n += 1
61    else if (c < 0x800) n += 2
62    else if (c >= 0xd800 && c < 0xdc00) {
63      n += 4
64      i += 1
65    } else n += 3
66    i += 1
67  }
68  return s.slice(i)
69}
70
types/index.d.ts 113 lines
1export type Status = 'pending' | 'running' | 'passed' | 'failed'
2export type Kind = 'run' | 'testmd' | 'testrun'
3
4export type Check = { ok: boolean; text: string }
5
6export type Step = {
7  text: string
8  status: Status
9  startedAt?: number
10  endedAt?: number
11  think?: string
12  act?: string
13  checks?: Check[]
14}
15
16/** Failure facts, each taken from what kane-cli wrote (never guessed). */
17export type Failure = { why: string; where?: string; category?: string; severity?: string; confidence?: number }
18
19export type Run = {
20  /** The session dir, or the member path inside a suite. */
21  id: string
22  label: string
23  kind: Kind
24  target?: string
25  status: Status
26  startedAt: number
27  endedAt?: number
28  steps: Step[]
29  credits?: number
30  failure?: Failure
31  members?: Run[]
32  sessionDir: string
33  pid?: number
34  cwd?: string
35  /** A question kane-cli is waiting on (ask_user). */
36  waiting?: string
37  /** The stream's wire version is newer than this mod reads. */
38  newer?: boolean
39  /** Replayed after the fact (remote grid): not animated. */
40  postHoc?: boolean
41  /** Lines of its stream that were not JSON: skipped, and counted. */
42  skipped?: number
43  /** Where the label came from; a better source replaces a weaker one. */
44  labelRank?: number
45  /** A suite sent to the remote grid: the grid reports its tests only when the job ends. */
46  remote?: Remote
47  /** The chat row of the shell call that started it, when Claude ran it: where its card is drawn. */
48  toolUseId?: string
49  /** A suite's execution id, which names its evidence pack. */
50  executionId?: string
51  /** Its sealed evidence pack on disk, once kane-cli has written it. */
52  evidence?: string
53}
54
55export type Remote = { backend?: string; jobId?: string; jobUrl?: string; dispatchedAt?: number }
56
57/** One thing a use case still owes, as `cover gaps` lists it. */
58export type Pending = { title: string; why?: string; risk?: string; stage: 'design' | 'cover'; command?: string }
59
60export type UseCase = { id: string; title?: string; risk?: string; designed: number; proven?: number; stale: number; toDesign: number; toCover: number; pending: Pending[] }
61
62export type Assurance =
63  | { state: 'unknown' }
64  | { state: 'none' }
65  | {
66      state: 'ready'
67      designedPct: number
68      provenPct?: number
69      failing?: number
70      blocked?: number
71      lastRunAt?: number
72      useCases: UseCase[]
73    }
74
75export type AfterChange = 'offer' | 'auto' | 'off'
76
77/** Files Claude edited that no kane-cli run has covered since. */
78/** `heldAt`: the edit time a held stop already asked Claude to test (auto holds once per change). */
79export type Change = { files: string[]; firstAt: number; lastAt: number; shown: boolean; heldAt?: number }
80
81/** `url`: the app's start page, as the project's saved tests name it. */
82export type Offer = { files: string[]; saved: string[]; url?: string; last?: Record<string, HistoryEntry> }
83
84export type View = {
85  tab: 'runs' | 'assure' | 'history'
86  /** The run shown in detail, `offer` for the offer card, or empty for the list. */
87  open: string
88  /** The use case shown in detail on the Assurance tab, or empty for the list. */
89  uc?: string
90}
91
92/** One finished run, as the mod keeps it per project. `tests` is set for a suite. */
93export type HistoryEntry = { label: string; status: 'passed' | 'failed'; at: number; where?: string; kind?: Kind; seconds?: number; tests?: { passed: number; failed: number } }
94
95declare module 'claude-code' {
96  interface PluginState {
97    'kane-qe': {
98      runs: Run[]
99      view: View
100      tick: number
101      assurance: Assurance
102      change: Change | null
103      offer: Offer | null
104      /** `/kane auto|ask|off` for this session; empty uses the setting. */
105      mode: '' | AfterChange
106      last: HistoryEntry | null
107      history: HistoryEntry[]
108      /** Evidence viewer links this session, by pack. */
109      viewers: Record<string, string>
110    }
111  }
112}
113