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…

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.
/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).
| Surface | What 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 chat | A 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 code | When 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. |
/config → kane-qe:
| Setting | Default | |
|---|---|---|
| After Claude changes code | offer | offer: 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.
~/.testmuai/kaneai/sessions/active/<pid>.json, once a second; only runs whose cwd is inside this project, and only while the pid is alive.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.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.run_end.verdict (root cause, category, severity, confidence), never guessed.<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.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.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.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.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):
| Command | When | Why |
|---|---|---|
kill -0 <pid> | once a second while a run is live | to tell whether a kane-cli run is still going |
ps -o args= -p <pid> | once per run nobody started in this chat | to name the run from its command line |
tail -c +<offset> <events file> | while a run is live | to read only the new lines of kane-cli's event stream |
git check-ignore -q <path> | after Claude edits a file | to skip git-ignored files when deciding what changed |
kane-cli cover gaps --json | at session start and after each run, when the project has a .context folder | to read assurance coverage (read-only, no credits) |
kane-cli evidence serve <pack> | only when you press View evidence | kane-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:
Test my last change with the saved kane-cli test: kane-cli testmd run .testmuai/tests/<test> … --agent --headless. Then report the result.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 the assurance gaps of kane-cli use case <id> …Set up kane-cli assurance for this project …Open the evidence for the kane-cli run in <path>.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.
How the mod's code answers each point the plugin directory's policy check raises:
| The directory's note | Where in the code | What the mod does |
|---|---|---|
| Starts other programs | hooks/register.tsx: alive, readNew, processLabel, ignored, refreshAssurance, startViewer | Only 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 out | poll, readNew, savedTests; the tool.call hook | The 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 out | fill, called from act and openEvidence | The 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 hands | command.run hook for /kane | This 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 turn | tool.call, classic.Stop hooks | tool.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. |
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.
hooks/register.tsx 1239 lines1// 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 lines1// 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}
180hooks/mascot.ts 148 lines1// 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}
148hooks/model.ts 752 lines1// 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
752hooks/sources.ts 70 lines1// 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}
70types/index.d.ts 113 lines1export 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