A sci-fi creature-collection game built from your real dev work.

Space: the final commit.
A mod for Claude Code that turns your real development work into a sci-fi exploration game. Epics become star systems, issues become missions, and finishing work turns up Difflings: procedurally generated alien flora and fauna you contain, collect, and raise as companions. Optional puzzles built from your own diffs improve your odds and double as interview practice.
It runs entirely in the terminal: ASCII art, keyboard only, 40 columns minimum, playable over SSH and tmux, including from a phone.
Your companion above the prompt, from a real game: name, tier, mood and current mission beside its sprite, with the game's status line below.
Status: pre-alpha. The first playable slice works: chart an epic, run missions, meet and contain Difflings, keep a companion. Plan documents can drive it, as can GitHub Issues and Jira Cloud. Puzzles are starting: a question on your own changed code before containment. How it plays is in the Player's Guide; the design is in docs/SPEC.md.
There is no release yet. To play from a clone, see Running the mod while developing.
The commands are below. The Player's Guide explains the rest: when encounters happen, your containment odds, cells, flora and crafting, the crew, and what each part of the screen means.
| Command | What it does | ||
|---|---|---|---|
/epic NOVA-1 | Opens a form for the epic's title and description, then charts it as a star system in the background. | ||
/mission NOVA-12 | Starts a mission. Checking out a branch like feature/NOVA-12-x (in the epic's project) starts it too. | ||
/mission complete | Finishes the mission. Tests passing during it earn a Reinforced Cell, a clean Tactical review a Stasis Cell, and it harvests 1 to 3 flora samples; an encounter may follow. A plain npm test counts by its exit status; a piped, guarded or chained run (`npm test \ | tail, npm test; echo done`) counts by the runner's summary line, so keep that line in the output. | |
/mission reopen NOVA-12 | Undoes a mission completed by mistake: its latest completion leaves the log (so /mission can start it again), and the completed count, cells and flora it gave are taken back. | ||
/setup | Walks through the work sources the game reads (plan documents, GitHub, Jira) in a pane, and saves your answers as its settings. | ||
| `/contain [reinforced\ | stasis\ | singularity]` | Opens Seal the Lattice: press Space as the needle crosses the zone, Enter to throw the cell, Esc to pause. A Singularity Cell needs a Legendary flora sample, which it spends. |
| `/craft [reinforced\ | stasis]` | Crafts a cell from flora: 3 Common samples make a Reinforced Cell, 2 Rare a Stasis Cell. Alone, lists the recipes and what you hold. | |
/calibrate | Measures your key latency on this device, so timing is fair over SSH or from a phone. | ||
/bay, /bay companion N | Shows the cells and flora you hold, lists your specimens, and picks the companion shown above the prompt. | ||
/dossier | Opens your puzzle accuracy by category, grouped by puzzle type, weakest first, with a trend and the weakest category marked. It does not change which puzzles you get. | ||
/epic complete | Surveys the epic: a Singularity Cell and a guaranteed encounter, never a Common. | ||
/bridge | The bridge: the active system and mission, hull (this session's test pass rate), shields (the last lint or type-check), fuel (context window left), crew status and the latest captain's log, with your companion at the foot. Keys send the crew: e run tests, l lint, s ask a question, t security review; the report opens when the run finishes. 1, 2, 3 reopen Engineering's, Science's or Tactical's last report (saved locally, including runs Claude sent). | ||
| Crew | Three read-only subagents Claude (or you) can dispatch: final-commit:engineering runs and explains tests, final-commit:science answers questions about the code, final-commit:tactical reviews your branch for security issues before a push. A clean Tactical review after your last commit raises the mission's quality (better odds of rarer Difflings). | ||
/captains-log | Writes a short summary of the session (standup notes) and shows it in a pane. The last 20 are kept. |
A failed test run, or a failed tool call other than a shell command, during a mission raises a red alert: the status line flashes, and a toast appears at most once every five minutes.
Instead of typing /epic and /mission, you can keep a markdown plan in your project and let the game follow it. Run /setup, or add plans to workSources in /config (comma-separated, like every list setting). The game then reads every .md file in docs/plans/ (the plansFolders setting) at session start and every 12 minutes:
# NOVA-7 Billing export
Export invoices as files for the finance team.
- [x] NOVA-8 Write the exporter
- [~] NOVA-9 Schedule it
- [ ] NOVA-10 Document it
The first heading's key is the epic, and the text before the first task is its description. Each task whose text starts with a key (or has one in brackets) is a mission: [~] starts it, [x] finishes it, and all tasks done surveys the epic. Tasks and plans without a key are skipped. The first read only records where things stand, so work already done is not awarded. Checking out a task's branch or typing /mission starts it too. Mark a task [~] when you begin it: only work done while its mission is active counts, so a task that goes straight to [x] completes without rewards.
Add github to workSources, and list each repository with the prefix its keys get in githubRepos: example/nova=NOVA makes issue #12 NOVA-12. The game reads through the gh CLI and its own login, so gh auth login first; the mod never handles a token. Every issue assigned to you is a mission: being assigned starts it, and closing it finishes it (closing one that is not your active mission only records it as closed, without rewards). Its star system is its parent issue if it has one, else its milestone (NOVA-M3), else the repo's backlog (NOVA-BACKLOG), so loose tickets count too. Closing a parent or a milestone surveys its system. Name a branch with the key (feature/NOVA-12-export) and checking it out starts the mission too.
Add jira to workSources, and set jiraSite (your https://<name>.atlassian.net), jiraEmail and jiraToken (an API token from id.atlassian.com, under Security; Claude Code keeps it in secure storage, never in the repo or the game's save). Only an atlassian.net site is accepted, and the token goes only to the site you set, so check its name. Every issue assigned to you is a mission: moving it to In Progress starts it, and Done finishes it. Its star system is its epic, else its project's backlog (NOVA-BACKLOG). An epic moving to Done surveys its system. Check your employer's policy before pointing this at a work Jira.
The game is built from your own work, so some of it is sent to a model.
What is sent to the model: the epic title and description you type into the /epic form (or a plan document's first heading and the prose before its first task, or a GitHub parent issue's title and body, or a milestone's title and description, or a Jira epic's summary and description; a backlog sends only the word "Backlog"), after the privacy filter, in one request per epic (plus up to two follow-up requests that redraw creature art; those carry generated names and descriptions, not your text). The issue key is never sent. The default model is Opus; you can pick Sonnet or Haiku in /config. Puzzles can send code. When a mission rolls an encounter, the functions it changed are read for a puzzle. With puzzles set to on (the default) and privacyMode set to standard or off, one of them is sent to the puzzle model (Sonnet by default, the puzzleModel setting) in two requests: one writes a question about it, one checks the answer. The code is filtered first: only lines of plain code are kept (any line with a string, template, regular expression, comment or escape is blanked to …, and after anything the filter cannot be sure of, the rest of the file is), the value shapes below (emails, keys, IDs, names after a title, and so on) are replaced. Only TypeScript files are read (.ts, .mts, .cts): JavaScript files may hold JSX, whose text the filter cannot tell from code. Identifiers and type names are not changed. That is why the default strict mode never sends code: under it, puzzles are made on this machine only. Set puzzles to local for the same with any filter, or off for none. Trace runs your code locally. For a Trace puzzle the game runs one of the changed functions, the filtered version the puzzle shows, with node on this machine: in the system's temporary folder, under node's permission model (no file reads or writes, no child processes), started with an empty environment (none of your session's variables or tokens), in a vm sandbox that holds only a few built-ins (Math, JSON, Array and the like) and cannot load modules, with a 1 s limit per call and 10 s in all. Node's permission model does not itself block the network; the sandbox holds nothing that can reach it. Only a small, self-contained function is ever run; anything that could reach outside is refused before running. Its inputs are made up by the game. Nothing is sent to a model for Trace. /captains-log asks your session's own model to summarize the session it already holds; the mod adds only a fixed instruction, and the summary is stored locally. The crew subagents read your code the way any Claude Code subagent does, through your session's model and only when dispatched; the mod supplies only their instructions.
Where it goes: only through your own Claude Code session and account. Apart from the work sources you turn on (GitHub, through gh; your Jira Cloud site, with your API token), the mod makes no other network calls.
What stays local: the mod watches the Bash commands Claude runs in your session (to notice branch switches, commits and test runs), runs git to read the current branch, and reads TERM, TMUX and SSH_CONNECTION to tell your devices apart for calibration (stored only as a salted hash). With the plans source on, it reads the markdown files in your plan folders and keeps each task's last status. With the github source on, it runs gh api graphql to read the listed repositories' issues: that request goes to GitHub, through gh, as any gh command does. Saved game data, including your issue keys, stays in Claude Code's plugin store on this machine.
What the model sees in the transcript: slash commands and their output are part of the session the model reads. That is why /epic takes only the key and asks for the title in a form, and why command output is short and factual (Mission NOVA-12 complete.). Issue keys you type in commands are visible to the model.
No telemetry. No analytics. No data leaves your machine except as described above.
Privacy filter, on by default. Before epic text or code reaches a prompt, the filter replaces quoted text, issue keys, URLs, emails, IPs and hostnames, phone numbers, SSN-, date- and ID-shaped values, ages, ZIP codes, and names after a title (Dr.) or a role word (patient). The setting is privacyMode in /config:
strict (the default) also replaces every capitalized word that is not a common title word or technical acronym, which catches most names.standard keeps capitalized words, for better system theming.off sends the text as typed.Known limit: a name written in lowercase in plain prose ("ask jane") cannot be told from an ordinary word, in any mode. Keep names out of epic titles.
Your responsibility: check whether your employer permits sending work content through Claude Code before pointing this mod at work projects.
This repository is public, and history is permanent. Never commit secrets, real work content (employer or client names, real issue keys, ticket text), hostnames, IPs, usernames, or local paths. Tests, fixtures, and examples use invented projects with the issue key prefix NOVA-. See SPEC section 15.
Two git hooks in scripts/hooks/ block a commit before it happens:
pre-commit scans staged changes with gitleaks and checks the staged files against your denylistcommit-msg checks the commit message and branch name against your denylistCI runs the same checks, so the hooks only catch problems earlier.
gitleaks is on your PATH. The pre-commit hook refuses to commit without it.denylist.txt at the repository root. It is gitignored. Put one term per line: names of employers and clients, real project keys, hostnames, anything that must never appear here. Matching is case-insensitive and substring-based; lines starting with # are comments. # denylist.txt
examplecorp
ACME-
my-laptop
git config core.hooksPath scripts/hooks
To check everything by hand:
scripts/denylist-check.sh files # tracked files
scripts/denylist-check.sh history # every commit message
gitleaks git --redact --config .gitleaks.toml .
npm install
npm run types # copy your Claude Code API declarations (never committed)
npm run typecheck
npm test
claude --plugin-dir .
claude --plugin-dir . starts a session with the mod loaded from this folder; edits reload when the folder goes quiet. Set devMode in /config to get /encounter, which forces an encounter for playtesting. A numeric FINAL_COMMIT_SEED environment variable makes every roll reproducible (the tests use it).
Machine-specific notes go in CLAUDE.local.md (gitignored). Start from CLAUDE.local.md.example.
See SECURITY.md. Do not open a public issue.
The Final Commit is an independent project. It is not affiliated with, endorsed by, or sponsored by Anthropic or Paramount. Claude Code is a product of Anthropic.
hooks/register.tsx 741 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { BandView, CalibrationView, ChartFailures, ChartingEntry, ConfirmView, EpicFormView, LatticeView, QueuedMission, ReportView } from '../types'
4import { NO_ALERT, redAlert } from '../src/bridge/alert'
5import { NO_GAUGES } from '../src/bridge/bridge'
6import { wireBridge } from '../src/bridge/bridge-pane'
7import { wireCrew } from '../src/crew/crew-wire'
8import { CREW_SPECS, ROLES } from '../src/crew/roster'
9import { wireBand } from '../src/bridge/band'
10import { wireBay } from '../src/bridge/bay-pane'
11import { wireDossier } from '../src/bridge/dossier-pane'
12import { wireScan } from '../src/bridge/scan-pane'
13import { wireSetup } from '../src/setup/setup-pane'
14import { wrap } from '../src/bridge/text'
15import { appendLog, LOG_PROMPT, logLines, stardate } from '../src/bridge/log'
16import { CHARTING, COMPANION, GENERATION, GIT, GITHUB, PUZZLE, RED_ALERT, SYNC, type GenerationModel } from '../src/config'
17import { missionUnits } from '../src/puzzle/material'
18import { buildPuzzle } from '../src/puzzle/puzzle'
19import { runnerPath, runCalls, type Run } from '../src/puzzle/runners'
20import { buildTrace, type TraceCapability } from '../src/puzzle/trace'
21import { wireCalibration } from '../src/contain/calibrate'
22import { wireLattice } from '../src/contain/lattice'
23import { classifyBash, lintVerdict, testRunFailed, testRunPassed } from '../src/detect/git'
24import { NO_CHART_FAILURES, chartFailed, chartSucceeded, isChartHeld } from '../src/detect/chart-retry'
25import { createGithubSources, parseRepos } from '../src/detect/github'
26import { createJiraRest, createJiraSource, parseJiraSettings } from '../src/detect/jira'
27import { createPlansSource, plansId } from '../src/detect/plans'
28import { createSyncGate, resolveSources, syncSources, type Backend } from '../src/detect/sources'
29import type { WorkSource } from '../src/detect/work-source'
30import { completeEpic, completeMission, craftCell, forceEncounter, onBranch, openItems, parseCraftCell, parseEpicKey, queueTarget, recordBash, reopenMission, startEpic, startMission, type Outcome } from '../src/game'
31import type { Rng } from '../src/rng'
32import { NO_MOOD, localDay, orphanedCharts, readSettings, rngFor, sendsCode, snapshot, type Settings } from '../src/runtime'
33import { migrate } from '../src/store/migrate'
34import { createRepo, type Repo, type StoreLike } from '../src/store/repo'
35import type { Complete } from '../src/world/generate'
36
37// Wiring only: events in, src/ modules do the work. Command `text` is read by
38// the model, so it stays factual (CLAUDE.md rule 1); flavor goes in toasts,
39// panes, the band and the status line.
40
41export const EPIC_PANE = 'fc-epic'
42/** `/mission complete` asks here before completing a mission with open items. */
43export const CONFIRM_PANE = 'fc-confirm'
44/** The report pane is drawn and dismissed in src/contain/lattice.tsx, which can open containment from it. */
45export const REPORT_PANE = 'fc-report'
46const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
47const form = atom({ plugin: 'final-commit', key: 'epicForm' } as const, null as EpicFormView | null)
48const report = atom({ plugin: 'final-commit', key: 'report' } as const, null as ReportView | null)
49const confirm = atom({ plugin: 'final-commit', key: 'confirm' } as const, null as ConfirmView | null)
50const queued = atom({ plugin: 'final-commit', key: 'queuedMission' } as const, null as QueuedMission | null)
51const mood = atom({ plugin: 'final-commit', key: 'mood' } as const, NO_MOOD)
52const charting = atom({ plugin: 'final-commit', key: 'charting' } as const, [] as ChartingEntry[])
53const chartFailures = atom({ plugin: 'final-commit', key: 'chartFailures' } as const, NO_CHART_FAILURES as ChartFailures)
54const band = atom({ plugin: 'final-commit', key: 'band' } as const, null as BandView | null)
55const alert = atom({ plugin: 'final-commit', key: 'alert' } as const, NO_ALERT)
56const gauges = atom({ plugin: 'final-commit', key: 'gauges' } as const, NO_GAUGES)
57const lattice = atom({ plugin: 'final-commit', key: 'lattice' } as const, null as LatticeView | null)
58const calibration = atom({ plugin: 'final-commit', key: 'calibration' } as const, null as CalibrationView | null)
59
60const NOT_READY = { text: 'The Final Commit could not load its save; see the debug log (claude --debug).' }
61
62/**
63 * Epics this module instance is charting. The `charting` state shows them;
64 * this set says which are really running, since a hot reload ends the
65 * generation but keeps the state.
66 */
67const live = new Set<string>()
68let actions = 0
69/** True while a /captains-log summary is being written. */
70let logging = false
71/** The configured work sources, built once per session start (a backend may keep what it last read). */
72let sources: WorkSource[] = []
73/** One sync at a time (SPEC 4.2); made by the first `startSync` of this module instance and kept, so a later start cannot overlap a sync still running. */
74let syncGate: ReturnType<typeof createSyncGate> | undefined
75/** Work sources whose failure has been reported (SPEC 4.4 rule 5: once per outage). */
76let failing = new Set<string>()
77let settings: Settings = readSettings({})
78
79// The engine lets `$` reach only top-level functions of the same file: these
80// adapters are this file's own (each wiring file has its own copy).
81function storeOf($: EngineInterface): StoreLike {
82 return { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() }
83}
84
85function repoOf($: EngineInterface): Repo {
86 return createRepo(storeOf($), line => $.ui.log(line, { to: 'debug' }))
87}
88
89async function rngOf($: EngineInterface): Promise<Rng> {
90 actions += 1
91 return rngFor(await $.env.get('FINAL_COMMIT_SEED'), actions)
92}
93
94/** The model call, never rejecting: a refused request becomes a failed result, so generation falls back. */
95function completeVia($: EngineInterface, model: GenerationModel, limits: { maxTokens: number; timeoutMs: number } = GENERATION): Complete {
96 return async ({ system, prompt }) => {
97 try {
98 const r = await $.model.complete({ model, system, prompt, maxTokens: limits.maxTokens, timeoutMs: limits.timeoutMs })
99 return r.isAnswered ? { ok: true, text: r.text } : { ok: false, reason: r.reason }
100 } catch (err) {
101 return { ok: false, reason: err instanceof Error ? err.message : 'refused' }
102 }
103 }
104}
105
106async function refresh($: EngineInterface) {
107 const now = await $.clock.now()
108 const s = await snapshot(repoOf($), await read($, mood), now, await read($, charting), now < (await read($, alert)).until)
109 $.ui.status(s.status)
110 await update($, band, prev => (s.band && prev ? { ...s.band, isBlinking: prev.isBlinking } : s.band))
111}
112
113async function deps($: EngineInterface) {
114 const now = await $.clock.now()
115 return { repo: repoOf($), now, rng: await rngOf($), day: localDay(now) }
116}
117
118/** `activate: false` for a chart a work source starts: an issue's start makes its epic active (SPEC 4.2 rule 6). */
119async function chart($: EngineInterface, key: string, title: string, description: string, activate = true) {
120 live.add(key)
121 const startedAt = await $.clock.now()
122 await update($, charting, list => [...list.filter(c => c.key !== key), { key, startedAt }])
123 // The status line's spinner and seconds (SPEC 9): redrawn while the model works.
124 const spinner = $.clock.every(CHARTING.spinnerMs, () => {
125 void refresh($)
126 })
127 await refresh($)
128 try {
129 const out = await startEpic({ ...(await deps($)), epic: { key, title, description }, activate, complete: completeVia($, settings.generationModel), privacy: settings.privacyMode })
130 $.ui.toast(out.toast ?? out.text)
131 await update($, chartFailures, f => chartSucceeded(f, key))
132 await startQueued($, key, true)
133 await refresh($)
134 // A chart a source started holds back that source's mission: sync now rather than at the next poll.
135 if (!activate) syncGate?.request({ again: true })
136 } catch (err) {
137 const reason = err instanceof Error ? err.message : String(err)
138 if (activate) {
139 $.ui.toast(`Charting ${key} failed: ${reason}`)
140 } else {
141 // A source's chart is tried again after a wait, not at every poll; only the first failure in a row is told.
142 const failed = chartFailed(await read($, chartFailures), key, await $.clock.now())
143 await update($, chartFailures, () => failed.failures)
144 if (failed.isFirst) $.ui.toast(`Charting ${key} failed: ${reason}. It is tried again later.`)
145 else $.ui.log(`final-commit: charting ${key} failed again (${failed.failures[key]?.count} in a row): ${reason}`, { to: 'debug' })
146 }
147 await startQueued($, key, false)
148 } finally {
149 spinner.cancel()
150 live.delete(key)
151 await update($, charting, list => list.filter(c => c.key !== key))
152 await refresh($)
153 }
154}
155
156/** A charting finished: start the mission that was waiting for that epic, timed from when it was asked for. */
157async function startQueued($: EngineInterface, epicKey: string, isCharted: boolean) {
158 const q = await read($, queued)
159 if (!q || q.epicKey !== epicKey) return
160 await update($, queued, () => null)
161 if (!isCharted) {
162 $.ui.toast(`Mission ${q.issueKey} was not started: charting ${epicKey} failed. Run /mission ${q.issueKey} again.`)
163 return
164 }
165 const out = await startMission({ ...(await deps($)), issueKey: q.issueKey, startedAt: q.at, epicKey: q.epicKey })
166 $.ui.toast(out.text)
167}
168
169/** The form's Enter: the title first, then the description; then charting starts after the pane closes. */
170async function submitForm($: EngineInterface, value: string) {
171 const f = await read($, form)
172 if (!f) return
173 if (f.step === 'title') {
174 if (value.trim() === '') return
175 await update($, form, () => ({ ...f, step: 'description', title: value.trim() }))
176 return
177 }
178 await update($, form, () => null)
179 await $.ui.close({ id: EPIC_PANE })
180 // Generation can take minutes, so it runs after this handler returns
181 // (verified on 2.1.291: the model call is not aborted).
182 void chart($, f.key, f.title, value.trim())
183}
184
185/**
186 * Shows an outcome: its report in a pane that stays until dismissed (a toast
187 * vanishes before a long name is read), or its toast where no pane can open.
188 */
189async function announce($: EngineInterface, out: Outcome) {
190 // A level up or evolution is told by toast too: the report may be dismissed unread.
191 if (out.companionToast) $.ui.toast(out.companionToast)
192 if (out.report) {
193 const r = out.report
194 await update($, report, () => r)
195 const placed = await $.ui.open({ id: REPORT_PANE, title: r.title, focus: true, closeOnEscape: true })
196 if (placed.isPlaced) return
197 await update($, report, () => null)
198 }
199 if (out.toast) $.ui.toast(out.toast)
200}
201
202/**
203 * SPEC 4.4: the backends this build has, by the name `workSources` lists.
204 * Each closes over `$` here, as `$` reaches only this file's functions.
205 */
206function backendsOf($: EngineInterface): Record<string, Backend> {
207 const repo = repoOf($)
208 return {
209 // SPEC 4.4 rule 3: files only. Its state and sync record are per project folder, so another project's plans never read as changes.
210 plans: () => createPlansSource({
211 folders: settings.plansFolders,
212 project: async () => plansId(await $.session.cwd()),
213 exists: path => $.fs.exists(path),
214 list: dir => $.fs.list(dir),
215 read: path => $.fs.read(path),
216 load: project => repo.plans(project),
217 save: (project, s) => repo.savePlans(project, s),
218 now: () => $.clock.now(),
219 log: line => $.ui.log(line, { to: 'debug' }),
220 }),
221 // SPEC 4.4 rule 6: one source per repo, through gh and its own login; no token passes through the mod. Incomplete without a valid repo entry.
222 github: () => {
223 const { repos, invalid } = parseRepos(settings.githubRepos)
224 for (const entry of invalid) $.ui.log(`final-commit: github: ignored "${entry}" (expected owner/repo=PREFIX, each repo and prefix once)`, { to: 'debug' })
225 if (repos.length === 0) return undefined
226 return createGithubSources({ repos, run: argv => $.process.run(argv, { timeoutMs: GITHUB.timeoutMs }), log: line => $.ui.log(line, { to: 'debug' }) })
227 },
228 // SPEC 4.4 rule 7: Jira Cloud over REST with the user's API token (a secret setting). Incomplete until site, email and token are set.
229 jira: () => {
230 const { settings: jira, problem } = parseJiraSettings(settings.jira.site, settings.jira.email, settings.jira.token)
231 if (!jira) {
232 $.ui.log(`final-commit: jira: ${problem}`, { to: 'debug' })
233 return undefined
234 }
235 return createJiraSource({
236 transport: createJiraRest(jira, (url, init) => $.http.fetch(url, init)),
237 now: () => $.clock.now(),
238 log: line => $.ui.log(line, { to: 'debug' }),
239 })
240 },
241 }
242}
243
244/** One sync of every configured work source (SPEC 4.2): at session start, then each poll. */
245async function syncTracked($: EngineInterface) {
246 try {
247 if (sources.length === 0 || !(await read($, ready))) return
248 const d = await deps($)
249 const held = await read($, chartFailures)
250 const r = await syncSources({
251 ...d, sources, failing, charting: new Set(live),
252 // An epic whose last chart failed waits out its retry; its issues stay deferred meanwhile.
253 chart: e => {
254 if (isChartHeld(held, e.key, d.now)) return false
255 void chart($, e.key, e.title, e.description, false)
256 return true
257 },
258 })
259 failing = r.failing
260 for (const t of r.toasts) $.ui.toast(t)
261 await refresh($)
262 // One report pane at a time: an outcome after the first report is told by toast.
263 let isReporting = false
264 for (const out of r.outcomes) {
265 if (out.report && !isReporting) {
266 isReporting = true
267 await announce($, out)
268 } else {
269 $.ui.toast(out.toast ?? out.text)
270 if (out.companionToast) $.ui.toast(out.companionToast)
271 }
272 }
273 void preparePuzzle($)
274 } catch (err) {
275 $.ui.log(`final-commit: work sync: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
276 }
277}
278
279/** SPEC 4.2: the catch-up sync now, then a poll on the clock. Names no backend answers to are said once. */
280async function startSync($: EngineInterface) {
281 const resolved = resolveSources(settings.workSources, backendsOf($))
282 sources = resolved.sources
283 const gate = (syncGate ??= createSyncGate(() => syncTracked($)))
284 const { unknown, incomplete } = resolved
285 for (const name of unknown) $.ui.toast(`Work source ${name} is not available in this build; it is ignored.`)
286 for (const name of incomplete) $.ui.toast(`Work source ${name} is missing settings; it is ignored until they are set.`)
287 if (sources.length === 0) return
288 $.clock.every(SYNC.pollMs, () => gate.request())
289 // In the background: a slow tracker never holds up the session's start.
290 gate.request()
291}
292
293async function epic($: EngineInterface, args: string): Promise<{ text: string }> {
294 if (!(await read($, ready))) return NOT_READY
295 if (args.trim().toLowerCase() === 'complete') {
296 const out = await completeEpic(await deps($))
297 await refresh($)
298 await announce($, out)
299 return { text: out.text }
300 }
301 const key = parseEpicKey(args)
302 if (!key) return { text: 'Usage: /epic <KEY> (the title is asked for in a pane), or /epic complete.' }
303 if ((await read($, charting)).some(c => c.key === key)) return { text: `Epic ${key} is already being charted.` }
304 await update($, form, () => ({ key, step: 'title', title: '' }))
305 await $.ui.open({ id: EPIC_PANE, title: `Chart ${key}`, focus: true, closeOnEscape: true })
306 return { text: `Opened the charting form for ${key}.` }
307}
308
309async function mission($: EngineInterface, args: string): Promise<{ text: string }> {
310 if (!(await read($, ready))) return NOT_READY
311 const arg = args.trim()
312 const verb = arg.toLowerCase().split(/\s+/).join(' ')
313 if (verb === 'reopen' || verb.startsWith('reopen ')) {
314 const out = await reopenMission({ ...(await deps($)), issueKey: arg.slice('reopen'.length) })
315 await refresh($)
316 return { text: out.text }
317 }
318 if (verb !== 'complete' && verb !== 'complete anyway') {
319 const key = parseEpicKey(arg)
320 // An epic still being charted becomes active when it finishes: the mission waits for it.
321 const target = key && !(await repoOf($).activeMission()) ? queueTarget(key, (await read($, charting)).filter(c => live.has(c.key))) : undefined
322 if (key && target) {
323 const before = await read($, queued)
324 const at = await $.clock.now()
325 await update($, queued, () => ({ issueKey: key, epicKey: target, at }))
326 return { text: `Mission ${key} queued: it starts on epic ${target} when charting finishes.${before && before.issueKey !== key ? ` It replaces the queued ${before.issueKey}.` : ''}` }
327 }
328 const out = await startMission({ ...(await deps($)), issueKey: arg })
329 await refresh($)
330 await announce($, out)
331 return { text: out.text }
332 }
333 const active = await repoOf($).activeMission()
334 const waiting = await read($, queued)
335 if (!active && waiting) return { text: `No active mission: ${waiting.issueKey} is queued until epic ${waiting.epicKey} is charted.` }
336 const items = active ? openItems(active) : []
337 if (!active || items.length === 0 || verb === 'complete anyway') return { text: await finishMission($) }
338 const open = `Mission ${active.issueKey} has open items: ${items.join('; ')}.`
339 await update($, confirm, () => ({ issueKey: active.issueKey, items }))
340 const placed = await $.ui.open({ id: CONFIRM_PANE, title: `Complete ${active.issueKey}?`, focus: true, closeOnEscape: true })
341 if (placed.isPlaced) return { text: `${open} Asked for confirmation in a pane; not completed yet.` }
342 await update($, confirm, () => null)
343 return { text: `${open} Not completed. /mission complete anyway completes it regardless.` }
344}
345
346async function finishMission($: EngineInterface): Promise<string> {
347 const out = await completeMission(await deps($))
348 await refresh($)
349 await announce($, out)
350 void preparePuzzle($)
351 return out.text
352}
353
354/** The encounter a puzzle is being prepared for: one at a time, and once. */
355let preparing: string | undefined
356
357/**
358 * SPEC 8: prepares the waiting encounter's puzzle in the background, from the
359 * code its mission changed, so /contain need not wait. Once per encounter;
360 * none for a survey's. With puzzles `local`, nothing is sent to a model.
361 */
362async function preparePuzzle($: EngineInterface) {
363 try {
364 if (settings.puzzles === 'off' || !(await read($, ready))) return
365 const repo = repoOf($)
366 const pending = await repo.pending()
367 if (!pending?.missionKey || pending.puzzle !== undefined || preparing === pending.id) return
368 preparing = pending.id
369 const mission = (await repo.missionLog()).findLast(m => m.issueKey === pending.missionKey)
370 const io = {
371 git: (args: readonly string[]) => $.process.run(['git', ...args], { timeoutMs: PUZZLE.gitTimeoutMs }),
372 read: async (path: string) => {
373 try {
374 return await $.fs.read(path)
375 } catch {
376 return undefined
377 }
378 },
379 }
380 const units = mission ? await missionUnits(io, mission.startedAt, settings.privacyMode) : []
381 const trace = units.length > 0 ? await traceVia($) : undefined
382 const puzzle = await buildPuzzle({
383 units, tier: pending.tier, rng: await rngOf($),
384 ...(sendsCode(settings) ? { complete: completeVia($, settings.puzzleModel, PUZZLE) } : {}),
385 ...(trace ? { trace } : {}),
386 })
387 // Kept only if the same creature is still waiting.
388 const now = await repo.pending()
389 if (now?.id === pending.id) await repo.savePending({ ...now, puzzle: puzzle ?? null })
390 } catch (err) {
391 $.ui.log(`final-commit: puzzle: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
392 } finally {
393 preparing = undefined
394 }
395}
396
397/** SPEC 8.3 rule 3: node's absolute path if it answers, checked once per session (once per module load). */
398let nodeRunner: Promise<string | undefined> | undefined
399
400/**
401 * SPEC 8, Trace: runs a unit with node in the system's temporary folder
402 * (never the project), or undefined when node is not available. Nothing is
403 * sent to a model, so it is built with puzzles `local` and under strict too.
404 */
405async function traceVia($: EngineInterface): Promise<TraceCapability | undefined> {
406 const tmp = await $.env.get('TMPDIR')
407 const cwd = tmp !== undefined && tmp.startsWith('/') ? tmp : '/tmp'
408 const run: Run = (argv, init) => $.process.run(argv, init)
409 nodeRunner ??= runnerPath('node', run, cwd)
410 const path = await nodeRunner
411 if (!path) return undefined
412 return (units, rng) => buildTrace(units, rng, (unit, inputs) => runCalls({ name: 'node', path, run, cwd, code: unit.code, fn: unit.name, inputs }))
413}
414
415/** Enter on the confirmation: complete the mission it asked about, if that one is still active. */
416async function confirmComplete($: EngineInterface) {
417 const c = await read($, confirm)
418 // A plugin's own $.ui.close does not reach its own ui.close hook, so the view is cleared here.
419 await update($, confirm, () => null)
420 await $.ui.close({ id: CONFIRM_PANE })
421 if (!c) return
422 if ((await repoOf($).activeMission())?.issueKey !== c.issueKey) {
423 $.ui.toast(`Mission ${c.issueKey} is no longer active.`)
424 return
425 }
426 await finishMission($)
427}
428
429async function encounter($: EngineInterface): Promise<{ text: string }> {
430 if (!settings.devMode) return { text: 'Developer mode is off.' }
431 if (!(await read($, ready))) return NOT_READY
432 const out = await forceEncounter(await deps($))
433 if (out.toast) $.ui.toast(out.toast)
434 await refresh($)
435 return { text: out.text }
436}
437
438/** SPEC 7.2: `/craft <cell>`; an unknown cell lists the recipes. */
439async function craft($: EngineInterface, args: string): Promise<{ text: string }> {
440 if (!(await read($, ready))) return NOT_READY
441 const out = await craftCell({ ...(await deps($)), cell: parseCraftCell(args) })
442 return { text: out.text }
443}
444
445/**
446 * The captain's log (SPEC 9.2): a summary of this session from a fork of its
447 * own transcript, saved and shown in the report pane. Runs after the command
448 * returns, as a fork takes a while.
449 */
450async function writeLog($: EngineInterface) {
451 logging = true
452 try {
453 const reply = await $.model.fork({ prompt: LOG_PROMPT })
454 if (!reply.isAnswered) {
455 $.ui.toast(reply.reason === 'nothing-to-fork' ? "Nothing to log yet: the session has no replies." : `The captain's log could not be written (${reply.reason}).`)
456 return
457 }
458 const now = await $.clock.now()
459 const entry = { at: now, stardate: stardate(now), lines: logLines(reply.text) }
460 const repo = repoOf($)
461 await repo.saveCaptainsLog(appendLog(await repo.captainsLog(), entry))
462 // Finished in the background: never take over a pane in use (a report with an encounter, a timing game, the form).
463 const isBusy = (await read($, report)) !== null || (await read($, confirm)) !== null || (await read($, lattice)) !== null || (await read($, calibration)) !== null || (await read($, form)) !== null
464 if (isBusy) {
465 $.ui.toast(`Captain's log, stardate ${entry.stardate}, recorded.`)
466 return
467 }
468 await announce($, {
469 text: '',
470 toast: `Captain's log, stardate ${entry.stardate}, recorded.`,
471 report: { title: `Captain's log, stardate ${entry.stardate}`, lines: entry.lines, encounter: null },
472 })
473 } catch (err) {
474 $.ui.log(`final-commit: captain's log: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
475 } finally {
476 logging = false
477 }
478}
479
480async function captainsLog($: EngineInterface): Promise<{ text: string }> {
481 if (!(await read($, ready))) return NOT_READY
482 if (logging) return { text: 'A session summary is already being written.' }
483 void writeLog($)
484 return { text: 'Writing a session summary to the captain\'s log.' }
485}
486
487/**
488 * An errored call that was interrupted (Esc) rather than failed. The engine
489 * gives no flag for an errored call, only the error text the model reads, so
490 * this goes by that text.
491 */
492function wasInterrupted(ran: { result?: unknown; text?: string }): boolean {
493 const said = [ran.text, typeof ran.result === 'string' ? ran.result : undefined].join(' ')
494 return /interrupt/i.test(said) || (ran.result as { interrupted?: unknown } | null)?.interrupted === true
495}
496
497/** SPEC 9.2: a failed test run or tool call during a mission flashes the status line, and toasts outside the cooldown. */
498async function raiseAlert($: EngineInterface, tool: string, isError: boolean, testFailed: boolean) {
499 const mission = (await repoOf($).activeMission())?.issueKey
500 const raised = redAlert({ tool, isError, testFailed, mission, now: await $.clock.now(), state: await read($, alert) })
501 if (!raised) return
502 await update($, alert, () => raised.state)
503 if (raised.toast) $.ui.toast(raised.toast)
504 $.clock.after(RED_ALERT.flashMs, () => {
505 void refresh($)
506 })
507}
508
509/**
510 * The Bridge's hull and shields (SPEC 9): this session's test runs, judged as
511 * a mission judges them, and the last lint or type-check verdict.
512 */
513async function updateGauges($: EngineInterface, signals: ReturnType<typeof classifyBash>, isError: boolean, output: string) {
514 const lint = lintVerdict(signals, isError)
515 if (!signals.isTestRun && lint === undefined) return
516 const passed = testRunPassed(signals, isError, output)
517 await update($, gauges, g => ({
518 tests: signals.isTestRun ? { runs: g.tests.runs + 1, passes: g.tests.passes + (passed ? 1 : 0) } : g.tests,
519 lint: lint ?? g.lint,
520 }))
521}
522
523/** Session state from the save: run at start, and again after a /clear starts the state over. */
524async function prepareSession($: EngineInterface) {
525 const now = await $.clock.now()
526 try {
527 await migrate(storeOf($), now, line => $.ui.log(line, { to: 'debug' }))
528 await update($, ready, () => true)
529 await update($, mood, m => ({ ...m, lastActivityAt: now }))
530 // A hot reload ends any charting in flight but keeps its marker: say so, and clear it.
531 const orphans = orphanedCharts(await read($, charting), live)
532 if (orphans.length > 0) {
533 await update($, charting, list => list.filter(c => live.has(c.key)))
534 for (const o of orphans) $.ui.toast(`Charting ${o.key} was interrupted by a reload. Run /epic ${o.key} again.`)
535 const q = await read($, queued)
536 if (q && orphans.some(o => o.key === q.epicKey)) {
537 await update($, queued, () => null)
538 $.ui.toast(`Mission ${q.issueKey} is no longer queued. Run /mission ${q.issueKey} after charting ${q.epicKey}.`)
539 }
540 }
541 } catch (err) {
542 // A save from a newer build, or a store that cannot be read: the game stays off rather than overwrite it.
543 await update($, ready, () => false)
544 $.ui.log(`final-commit: ${err instanceof Error ? err.message : String(err)}`)
545 }
546 if (await read($, ready)) {
547 await refresh($)
548 // A reload cut off a puzzle in the making: start it again.
549 void preparePuzzle($)
550 }
551}
552
553/** SPEC 9.1: the crew as agent types the Agent tool can dispatch (`final-commit:<role>`). */
554async function registerCrew($: EngineInterface) {
555 for (const role of ROLES) {
556 const spec = CREW_SPECS[role]
557 try {
558 await $.agent.register({ name: spec.name, description: spec.description, prompt: spec.prompt, tools: spec.tools, maxTurns: spec.maxTurns })
559 } catch (err) {
560 $.ui.log(`final-commit: crew ${role} not registered: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
561 }
562 }
563}
564
565async function startSession($: EngineInterface) {
566 await prepareSession($)
567 await $.command.register({ name: 'epic', description: 'Chart an epic as a star system, or survey it', argumentHint: '<KEY> | complete' })
568 await $.command.register({ name: 'mission', description: 'Start or complete a mission', argumentHint: '<KEY> | complete [anyway] | reopen <KEY>' })
569 await $.command.register({ name: 'contain', description: 'Open containment for a waiting encounter', argumentHint: '[reinforced | stasis | singularity]' })
570 await $.command.register({ name: 'craft', description: 'Craft a cell from flora samples', argumentHint: '[reinforced | stasis]' })
571 await $.command.register({ name: 'calibrate', description: "Measure this device's key latency for containment" })
572 await $.command.register({ name: 'bay', description: 'Open the specimen bay', argumentHint: '[companion N]' })
573 await $.command.register({ name: 'scan', description: 'Scan a star system: the lifeforms known so far', argumentHint: '[KEY]' })
574 await $.command.register({ name: 'dossier', description: 'Open the dossier: puzzle accuracy by category' })
575 await $.command.register({ name: 'setup', description: 'Choose and set up the work sources the game reads: plans, GitHub, Jira' })
576 await $.command.register({ name: 'bridge', description: 'Open the bridge: system, mission, hull, shields, fuel' })
577 await $.command.register({ name: 'captains-log', description: 'Write a summary of this session to the captain\'s log' })
578 await registerCrew($)
579 if (settings.devMode) await $.command.register({ name: 'encounter', description: 'Force an encounter (developer mode)' })
580 await startSync($)
581 // Idle animation (SPEC 9.3): a short blink every few seconds.
582 $.clock.every(COMPANION.blinkEveryMs, () => {
583 void update($, band, v => (v ? { ...v, isBlinking: true } : v))
584 $.clock.after(COMPANION.blinkMs, () => {
585 void update($, band, v => (v ? { ...v, isBlinking: false } : v))
586 })
587 })
588}
589
590/**
591 * SPEC 4.2: what one Bash call says about the active mission. Results of a
592 * backgrounded command are ignored: they return before the command finishes.
593 */
594async function observeBash($: EngineInterface, command: string, result: unknown, isError: boolean, interrupted: boolean) {
595 if (!(await read($, ready))) return
596 const record = (result ?? {}) as {
597 backgroundTaskId?: unknown
598 stdout?: unknown
599 stderr?: unknown
600 gitOperation?: { commit?: { kind?: unknown } }
601 }
602 if (record.backgroundTaskId !== undefined) return
603 const d = await deps($)
604 const signals = classifyBash(command)
605 const event: 'fed' | 'startled' | undefined = isError ? 'startled' : signals.isTestRun ? 'fed' : undefined
606 await update($, mood, m => ({ last: event ? { event, at: d.now } : m.last, lastActivityAt: d.now }))
607 const reported = record.gitOperation?.commit?.kind === 'committed' ? 1 : 0
608 const commits = isError ? 0 : Math.max(signals.commits, reported)
609 // The end of the output is where runners print their summaries.
610 const output = [record.stdout, record.stderr].filter((x): x is string => typeof x === 'string').join('\n').slice(-GIT.outputTailChars)
611 if (commits > 0 || signals.isTestRun || signals.isLintRun) await recordBash({ ...d, signals, commits, isError, output })
612 if (!interrupted) await raiseAlert($, 'Bash', isError, testRunFailed(signals, isError, output))
613 if (!interrupted) await updateGauges($, signals, isError, output)
614 if (signals.mayChangeBranch && !isError) {
615 const head = await $.process.run(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], { timeoutMs: GIT.timeoutMs })
616 if (head.exitCode === 0) {
617 const out = await onBranch({ ...d, branch: head.stdout.trim() })
618 if (out) $.ui.toast(out.text)
619 }
620 }
621 await refresh($)
622}
623
624export const register: Register = (on, options) => {
625 settings = readSettings(options)
626
627 wireBand(on)
628 wireLattice(on)
629 wireCalibration(on)
630 wireBay(on)
631 wireScan(on)
632 wireDossier(on)
633 wireSetup(on, options)
634 wireBridge(on)
635 wireCrew(on)
636
637 on('session.start', async ($, e, next) => {
638 await startSession($)
639 return next(e)
640 })
641
642 // A /clear fires no session.start and starts the session's state over
643 // (the band, the ready flag): set it up again from the save.
644 on('classic.SessionStart', async ($, e, next) => {
645 if (e.source === 'clear') await prepareSession($)
646 return next(e)
647 })
648
649 on('command.run', { command: 'epic' }, async ($, e) => epic($, e.args))
650 on('command.run', { command: 'mission' }, async ($, e) => mission($, e.args))
651 on('command.run', { command: 'encounter' }, async $ => encounter($))
652 on('command.run', { command: 'craft' }, async ($, e) => craft($, e.args))
653 on('command.run', { command: 'captains-log' }, async $ => captainsLog($))
654
655 on('ui.close', { id: EPIC_PANE }, async ($, e, next) => {
656 await update($, form, () => null)
657 return next(e)
658 })
659
660 on('ui.close', { id: CONFIRM_PANE }, async ($, e, next) => {
661 // Esc: the mission stays active.
662 await update($, confirm, () => null)
663 return next(e)
664 })
665
666 on('ui.render', { component: 'Pane', requestId: CONFIRM_PANE }, async ($, e) => {
667 const elements = $.ui.resolve(e)
668 const { Box, Text, Button } = elements
669 const Input = 'Input' in elements ? elements.Input : undefined
670 const c = await read($, confirm)
671 const width = e.props.bodyColumns
672 if (!c) return <Text dimColor>Nothing to confirm.</Text>
673 return (
674 <Box flexDirection="column">
675 {wrap(`Mission ${c.issueKey} has open items:`, width).map(line => (
676 <Text bold>{line}</Text>
677 ))}
678 {c.items.flatMap(item => wrap(`- ${item}`, width)).map(line => (
679 <Text>{line}</Text>
680 ))}
681 {wrap('Enter: complete anyway Esc: keep working', width).map(line => (
682 <Text dimColor>{line}</Text>
683 ))}
684 {Input ? (
685 <Input key="confirm" autoFocus label="Enter" onInput={() => {}} onSubmit={() => void confirmComplete($)} />
686 ) : (
687 <Button key="confirm" label="Complete anyway" onPress={() => void confirmComplete($)} />
688 )}
689 </Box>
690 )
691 })
692
693 on('ui.render', { component: 'Pane', requestId: EPIC_PANE }, async ($, e) => {
694 const elements = $.ui.resolve(e)
695 const { Box, Text } = elements
696 const Input = 'Input' in elements ? elements.Input : undefined
697 const f = await read($, form)
698 if (!f) return <Text dimColor>No epic is being charted.</Text>
699 const asking = f.step === 'title' ? 'Epic title' : 'Description (optional)'
700 return (
701 <Box flexDirection="column">
702 <Text>{`Typed here, the text skips the transcript and is filtered (${settings.privacyMode}) before the model sees it.`}</Text>
703 {f.step === 'description' && <Text dimColor>{f.title}</Text>}
704 {Input ? (
705 <Input key={`epic-${f.step}`} autoFocus label={asking} onInput={() => {}} onSubmit={(value: string) => void submitForm($, value)} />
706 ) : (
707 <Text dimColor>This surface has no text input; chart epics from a terminal.</Text>
708 )}
709 </Box>
710 )
711 })
712
713 // SPEC 4.2: watch Bash for branch switches, commits and test runs. The tool
714 // call itself is never changed; a failure here is logged, never surfaced.
715 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
716 const ran = await next(e)
717 if (ran.deny !== undefined) return ran
718 try {
719 await observeBash($, e.command, ran.result, ran.isError === true, wasInterrupted(ran))
720 } catch (err) {
721 $.ui.log(`final-commit: Bash observer: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
722 }
723 return ran
724 })
725
726 // SPEC 9.2: any other tool failing during a mission raises a red alert (Bash is judged above).
727 on('tool.call', async ($, e, next) => {
728 const ran = await next(e)
729 if (e.tool === 'Bash' || ran.deny !== undefined || ran.isError !== true || wasInterrupted(ran)) return ran
730 try {
731 if (await read($, ready)) {
732 await raiseAlert($, e.tool, true, false)
733 await refresh($)
734 }
735 } catch (err) {
736 $.ui.log(`final-commit: alert: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
737 }
738 return ran
739 })
740}
741src/bridge/alert.ts 31 lines1import { RED_ALERT } from '../config'
2
3// SPEC 9.2 red alert. Pure: the glue keeps the state and draws it.
4
5/** `until`: the status line flashes before this time. `lastToastAt`: the last alert toast, for its cooldown. */
6export type AlertState = { until: number; lastToastAt: number | null }
7export const NO_ALERT: AlertState = { until: 0, lastToastAt: null }
8
9/**
10 * A failed test run, or a failed tool call, during a mission. A Bash call is
11 * judged as a test run only: a shell command exiting non-zero (`grep` with no
12 * match, `diff`) is routine, not a failure of the tool. Returns the new state
13 * and a toast when the cooldown allows one; undefined when nothing is raised.
14 */
15export function redAlert(s: {
16 tool: string
17 isError: boolean
18 testFailed: boolean
19 mission: string | undefined
20 now: number
21 state: AlertState
22}): { state: AlertState; toast?: string } | undefined {
23 if (!s.mission) return undefined
24 const isToolFailure = s.isError && s.tool !== 'Bash'
25 if (!s.testFailed && !isToolFailure) return undefined
26 const mayToast = s.state.lastToastAt === null || s.now - s.state.lastToastAt >= RED_ALERT.toastCooldownMs
27 const state = { until: s.now + RED_ALERT.flashMs, lastToastAt: mayToast ? s.now : s.state.lastToastAt }
28 if (!mayToast) return { state }
29 return { state, toast: s.testFailed ? `Red alert! Tests failing on ${s.mission}.` : `Red alert! ${s.tool} failed on ${s.mission}.` }
30}
31src/bridge/bridge.ts 76 lines1import { BRIDGE, COLORS, type Tier } from '../config'
2import { crewLines, type CrewState, type Role } from '../crew/roster'
3import type { LogEntry } from '../store/schema'
4
5// SPEC 9 Bridge pane, as text rows. Pure: the pane gathers the values.
6
7/** Session gauges the Bash observer keeps: test runs for the hull, the last lint verdict for the shields. */
8export type Gauges = { tests: { runs: number; passes: number }; lint: 'pass' | 'fail' | null }
9export const NO_GAUGES: Gauges = { tests: { runs: 0, passes: 0 }, lint: null }
10
11export type BridgeData = {
12 system?: { name: string; starClass: string; isSurveyed: boolean }
13 mission?: { key: string; commits: number; testRuns: number }
14 gauges: Gauges
15 /** Context window left, percent; undefined before the session's first reply. */
16 fuel?: number
17 log?: LogEntry
18 crew?: CrewState
19 /** Officers with a saved report (the number keys reopen it). */
20 reportRoles?: Role[]
21 isPending: boolean
22 /** The waiting encounter's tier, to color its line. */
23 pendingTier?: Tier
24 isAlert: boolean
25}
26
27/** A Bridge row: a status `color` (a theme key) or a `tier` the pane colors at draw time. */
28export type BridgeRow = { text: string; color?: string; tier?: Tier }
29
30const fit = (text: string, width: number) => [...text].slice(0, Math.max(0, width)).join('')
31
32/** `[######----]`: ASCII, so it is one cell per character everywhere. */
33export function gauge(fraction: number): string {
34 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * BRIDGE.gaugeCells)
35 return `[${'#'.repeat(filled)}${'-'.repeat(BRIDGE.gaugeCells - filled)}]`
36}
37
38const pct = (fraction: number) => `${Math.round(fraction * 100)}%`
39
40/** Hull color by pass rate: all passing, some, none. */
41function hullColor(rate: number): string {
42 return rate >= COLORS.hullCautionBelow ? COLORS.good : rate > 0 ? COLORS.caution : COLORS.bad
43}
44
45function fuelColor(fuel: number): string | undefined {
46 return fuel < COLORS.fuelBadBelow ? COLORS.bad : fuel < COLORS.fuelCautionBelow ? COLORS.caution : undefined
47}
48
49export function bridgeRows(d: BridgeData, width: number): BridgeRow[] {
50 const rows: BridgeRow[] = []
51 const add = (text: string, style: Omit<BridgeRow, 'text'> = {}) => rows.push({ text, ...style })
52 add(d.system ? `${d.system.name} (${d.system.starClass})${d.system.isSurveyed ? ' surveyed' : ''}` : 'No system charted: /epic <KEY>')
53 if (d.isAlert) add('! RED ALERT', { color: COLORS.bad })
54 if (d.isPending) add('Encounter waiting: /contain', d.pendingTier ? { tier: d.pendingTier } : {})
55 add('')
56 const m = d.mission
57 add(m ? `Mission ${m.key} · ${m.commits} commit${m.commits === 1 ? '' : 's'}` : 'Mission none')
58 const t = d.gauges.tests
59 if (t.runs > 0) add(`Hull ${gauge(t.passes / t.runs)} ${pct(t.passes / t.runs)}`, { color: hullColor(t.passes / t.runs) })
60 else add('Hull no test runs yet')
61 const lint = d.gauges.lint
62 add(`Shields ${lint === 'pass' ? 'up' : lint === 'fail' ? 'DOWN: lint failing' : 'no lint run yet'}`, lint ? { color: lint === 'pass' ? COLORS.good : COLORS.bad } : {})
63 if (d.fuel === undefined) add('Fuel not measured yet')
64 else {
65 const color = fuelColor(d.fuel)
66 add(`Fuel ${gauge(d.fuel / 100)} ${Math.round(d.fuel)}%`, color ? { color } : {})
67 }
68 if (d.crew) for (const line of ['Crew', ...crewLines(d.crew, d.reportRoles ?? [])]) add(line)
69 if (d.log) {
70 add('')
71 add(`Captain's log ${d.log.stardate}`)
72 for (const line of d.log.lines.slice(0, BRIDGE.logLines)) add(` ${line}`)
73 }
74 return rows.map(r => ({ ...r, text: fit(r.text, width) }))
75}
76src/bridge/bridge-pane.tsx 213 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import type { BandView } from '../../types'
4import { COMPANION } from '../config'
5import { besideLines, blink, trimSprite } from './band'
6import { NO_ALERT } from './alert'
7import type { ReportView } from '../../types'
8import { BRIDGE_KEYS, crewTask, NO_CREW, REPORT_KEYS, ROLE_LABELS, TASK_ROLE, type CrewTask, type Role } from '../crew/roster'
9import { bridgeRows, NO_GAUGES, type BridgeData } from './bridge'
10import { tierColor } from './color'
11import { createRepo, type Repo } from '../store/repo'
12
13export const BRIDGE_PANE = 'fc-bridge'
14const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
15const gauges = atom({ plugin: 'final-commit', key: 'gauges' } as const, NO_GAUGES)
16const alert = atom({ plugin: 'final-commit', key: 'alert' } as const, NO_ALERT)
17const crew = atom({ plugin: 'final-commit', key: 'crew' } as const, NO_CREW)
18const input = atom({ plugin: 'final-commit', key: 'bridgeInput' } as const, { isAsking: false, generation: 0 })
19const report = atom({ plugin: 'final-commit', key: 'report' } as const, null as ReportView | null)
20/** The report pane, drawn in src/contain/lattice.tsx. */
21const REPORT_PANE = 'fc-report'
22const isOpen = atom({ plugin: 'final-commit', key: 'bridgeOpen' } as const, false)
23const band = atom({ plugin: 'final-commit', key: 'band' } as const, null as BandView | null)
24
25function repoOf($: EngineInterface): Repo {
26 return createRepo(
27 { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() },
28 line => $.ui.log(line, { to: 'debug' }),
29 )
30}
31
32/** The fraction of the context window left, from the status line's figures. */
33async function fuelOf($: EngineInterface): Promise<number | undefined> {
34 try {
35 const percent = (await $.session.usage()).context.percent
36 return percent === undefined ? undefined : 100 - percent
37 } catch {
38 return undefined
39 }
40}
41
42/** Everything the bridge shows, read at draw time from the save and the session. */
43async function gather($: EngineInterface): Promise<BridgeData> {
44 const repo = repoOf($)
45 const meta = await repo.meta()
46 let system: BridgeData['system']
47 for (const id of await repo.systemIds()) {
48 const s = await repo.system(id)
49 if (s && s.epicKey === meta?.activeEpicKey) system = { name: s.name, starClass: s.starClass, isSurveyed: s.status === 'surveyed' }
50 }
51 const mission = await repo.activeMission()
52 const log = (await repo.captainsLog()).at(-1)
53 const fuel = await fuelOf($)
54 const reports = await repo.crewReports()
55 const pending = await repo.pending()
56 return {
57 ...(system ? { system } : {}),
58 ...(mission ? { mission: { key: mission.issueKey, commits: mission.commits, testRuns: mission.testRuns } } : {}),
59 gauges: await read($, gauges),
60 ...(fuel !== undefined ? { fuel } : {}),
61 ...(log ? { log } : {}),
62 crew: await read($, crew),
63 reportRoles: (Object.keys(reports) as Role[]).filter(r => reports[r] !== undefined),
64 isPending: pending !== undefined,
65 ...(pending ? { pendingTier: pending.tier } : {}),
66 isAlert: (await $.clock.now()) < (await read($, alert)).until,
67 }
68}
69
70/** Sends an officer from the Bridge (SPEC 9.1); its report opens when it finishes (src/crew/crew-wire.ts). */
71async function launch($: EngineInterface, taskName: CrewTask, question = '') {
72 const role = TASK_ROLE[taskName]
73 const c = await read($, crew)
74 if (Object.values(c.running).includes(role)) {
75 $.ui.toast(`${ROLE_LABELS[role]} is already on it.`)
76 return
77 }
78 const task = crewTask(taskName, question)
79 try {
80 const started = await $.agent.spawn({ subagentType: `final-commit:${role}`, prompt: task.prompt, description: task.description })
81 if (started.deny !== undefined || started.agentId === undefined) {
82 $.ui.toast(`${ROLE_LABELS[role]} could not be sent: ${started.deny ?? 'no agent started'}`)
83 return
84 }
85 const id = started.agentId
86 // The agent.spawn hook already marked it running; this marks where its report goes.
87 await update($, crew, s => ({ ...s, running: { ...s.running, [id]: role }, fromBridge: [...(s.fromBridge ?? []), id] }))
88 $.ui.toast(`${ROLE_LABELS[role]} is on it.`)
89 } catch (err) {
90 $.ui.toast(`${ROLE_LABELS[role]} could not be sent: ${err instanceof Error ? err.message : String(err)}`)
91 }
92}
93
94/** Opens an officer's last saved report in the report pane, unless that pane holds a waiting encounter. */
95async function reopen($: EngineInterface, role: Role) {
96 const saved = (await repoOf($).crewReports())[role]
97 if (!saved) {
98 $.ui.toast(`${ROLE_LABELS[role]} has no report yet.`)
99 return
100 }
101 if ((await read($, report))?.encounter) {
102 $.ui.toast('Close the encounter report first.')
103 return
104 }
105 const title = `${ROLE_LABELS[role]} report`
106 await update($, report, () => ({ title, lines: saved.lines, encounter: null }))
107 const placed = await $.ui.open({ id: REPORT_PANE, title, focus: true, closeOnEscape: true })
108 if (!placed.isPlaced) await update($, report, () => null)
109}
110
111/** A key typed into the Bridge's field: `e`, `l`, `s`, `t`, or `1` to `3` for a report. The field is cleared after each. */
112async function onKey($: EngineInterface, value: string) {
113 const last = value.slice(-1).toLowerCase()
114 const saved = REPORT_KEYS[last]
115 if (saved) {
116 await update($, input, i => ({ isAsking: false, generation: i.generation + 1 }))
117 await reopen($, saved)
118 return
119 }
120 const task = BRIDGE_KEYS[last]
121 await update($, input, i => ({ isAsking: task === 'question', generation: i.generation + 1 }))
122 if (task && task !== 'question') await launch($, task)
123}
124
125async function onAsk($: EngineInterface, question: string) {
126 await update($, input, i => ({ isAsking: false, generation: i.generation + 1 }))
127 if (question.trim() !== '') await launch($, 'question', question)
128}
129
130/** The companion at the foot of the pane, laid out as the band lays it out: facts to the left of the sprite. */
131function companionBlock(Box: any, Text: any, v: BandView, width: number, color: string) {
132 const sprite = trimSprite(v.isBlinking || v.mood === 'asleep' ? blink(v.sprite) : v.sprite)
133 const spriteColumns = Math.max(0, ...sprite.map(r => r.length))
134 const room = width - spriteColumns - COMPANION.spriteGap
135 const lines = room >= COMPANION.besideMinColumns ? besideLines(v, room) : []
136 if (sprite.length === 0 || spriteColumns > width) return <Text dimColor color={color}>{`${v.tier} ${v.name} · ${v.mood}`.slice(0, width)}</Text>
137 return (
138 <Box flexDirection="row" marginTop={1}>
139 {lines.length > 0 && (
140 <Box flexDirection="column" justifyContent="center" marginRight={COMPANION.spriteGap}>
141 {lines.map((line, n) => (n === 0 ? <Text bold color={color}>{line}</Text> : n === 1 ? <Text color={color}>{line}</Text> : <Text dimColor>{line}</Text>))}
142 </Box>
143 )}
144 <Box flexDirection="column">
145 {sprite.map(row => <Text color={color}>{row}</Text>)}
146 </Box>
147 </Box>
148 )
149}
150
151async function bridge($: EngineInterface): Promise<string> {
152 if (!(await read($, ready))) return 'The Final Commit could not load its save; see the debug log (claude --debug).'
153 await update($, input, i => ({ isAsking: false, generation: i.generation + 1 }))
154 // The companion moves into the pane while it is open (the band steps aside).
155 await update($, isOpen, () => true)
156 const placed = await $.ui.open({ id: BRIDGE_PANE, title: 'Bridge', focus: true, closeOnEscape: true })
157 if (!placed.isPlaced) {
158 await update($, isOpen, () => false)
159 return 'The bridge could not open here.'
160 }
161 return 'Opened the bridge.'
162}
163
164/** SPEC 9 Bridge pane: the state of play at a glance. It never reaches the transcript. */
165export function wireBridge(on: On): void {
166 on('command.run', { command: 'bridge' }, async ($, _e) => ({ text: await bridge($) }))
167
168 on('ui.close', { id: BRIDGE_PANE }, async ($, e, next) => {
169 await update($, input, i => ({ isAsking: false, generation: i.generation + 1 }))
170 await update($, isOpen, () => false)
171 return next(e)
172 })
173
174 on('ui.render', { component: 'Pane', requestId: BRIDGE_PANE }, async ($, e) => {
175 const elements = $.ui.resolve(e)
176 const { Box, Text, Button } = elements
177 const Input = 'Input' in elements ? elements.Input : undefined
178 // Reading the band subscribes this drawing to every refresh of the game's views (and the blink).
179 const companion = await read($, band)
180 const width = e.props.bodyColumns
181 const rows = bridgeRows(await gather($), width)
182 const now = await $.clock.now()
183 const color = companion ? tierColor(companion.tierName, now) : ''
184 const i = await read($, input)
185 const hints = ['e: run tests', 'l: lint', 's: ask Science', 't: security review', '1-3: last report', 'Esc: close']
186 const joined = hints.join(' ')
187 const hintRows = [...joined].length <= width ? [joined] : [`${hints[0]} ${hints[1]} ${hints[2]}`, `${hints[3]} ${hints[4]}`, hints[5]!]
188 return (
189 <Box flexDirection="column">
190 {rows.map((r, n) => (
191 <Text bold={n === 0} color={r.tier ? tierColor(r.tier, now) : r.color}>{r.text === '' ? ' ' : r.text}</Text>
192 ))}
193 <Text> </Text>
194 {i.isAsking ? <Text dimColor>Type a question for Science, then Enter.</Text> : hintRows.map(hint => <Text dimColor>{hint}</Text>)}
195 {companion && companionBlock(Box, Text, companion, width, color)}
196 {Input && i.isAsking && (
197 <Input key={`bridge-ask-${i.generation}`} autoFocus label="Question" onInput={() => {}} onSubmit={(q: string) => void onAsk($, q)} />
198 )}
199 {Input && !i.isAsking && (
200 <Input key={`bridge-keys-${i.generation}`} autoFocus label="Key" onInput={(v: string) => void onKey($, v)} onSubmit={() => {}} />
201 )}
202 {!Input && (
203 <Box flexDirection="row">
204 <Button key="crew-e" label="Tests" onPress={() => void launch($, 'tests')} />
205 <Button key="crew-l" label="Lint" onPress={() => void launch($, 'lint')} />
206 <Button key="crew-t" label="Security" onPress={() => void launch($, 'review')} />
207 </Box>
208 )}
209 </Box>
210 )
211 })
212}
213src/crew/crew-wire.ts 107 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import type { CalibrationView, EpicFormView, LatticeView, ReportView } from '../../types'
4import { recordLint, recordTactical } from '../game'
5import { createRepo, type Repo } from '../store/repo'
6import { NO_GAUGES } from '../bridge/bridge'
7import { NO_CREW, parseLint, parseVerdict, reportLines, roleOf, ROLE_LABELS, type Role } from './roster'
8
9/** The report pane, drawn in src/contain/lattice.tsx. */
10const REPORT_PANE = 'fc-report'
11
12// SPEC 9.1: follows the crew's subagents from spawn to finished turn. The
13// agent types are registered at session start (hooks/register.tsx, as a
14// plugin has one session.start hook).
15
16const crew = atom({ plugin: 'final-commit', key: 'crew' } as const, NO_CREW)
17const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
18const gauges = atom({ plugin: 'final-commit', key: 'gauges' } as const, NO_GAUGES)
19const report = atom({ plugin: 'final-commit', key: 'report' } as const, null as ReportView | null)
20const lattice = atom({ plugin: 'final-commit', key: 'lattice' } as const, null as LatticeView | null)
21const calibration = atom({ plugin: 'final-commit', key: 'calibration' } as const, null as CalibrationView | null)
22const form = atom({ plugin: 'final-commit', key: 'epicForm' } as const, null as EpicFormView | null)
23
24function repoOf($: EngineInterface): Repo {
25 return createRepo(
26 { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() },
27 line => $.ui.log(line, { to: 'debug' }),
28 )
29}
30
31/**
32 * A run launched from the Bridge reports in the report pane, as nothing else
33 * relays its answer. A pane already in use (a report, a timing game, the
34 * epic form) is never taken over: a toast says the run finished instead.
35 */
36async function showReport($: EngineInterface, role: Role, answer: string, isAborted: boolean) {
37 const title = `${ROLE_LABELS[role]} report`
38 const busy = (await read($, report)) !== null || (await read($, lattice)) !== null || (await read($, calibration)) !== null || (await read($, form)) !== null
39 if (busy || isAborted) {
40 $.ui.toast(isAborted ? `${ROLE_LABELS[role]} stopped before reporting.` : `${title} finished while a pane was open; it was not shown.`)
41 return
42 }
43 const r: ReportView = { title, lines: reportLines(answer), encounter: null }
44 await update($, report, () => r)
45 const placed = await $.ui.open({ id: REPORT_PANE, title, focus: true, closeOnEscape: true })
46 if (!placed.isPlaced) {
47 await update($, report, () => null)
48 $.ui.toast(`${title} is ready, but no pane could open here.`)
49 }
50}
51
52async function finished($: EngineInterface, agentId: string, answer: string, isAborted: boolean) {
53 let role: ReturnType<typeof roleOf>
54 let isFromBridge = false
55 await update($, crew, c => {
56 role = c.running[agentId]
57 const { [agentId]: _done, ...running } = c.running
58 const fromBridge = c.fromBridge ?? []
59 isFromBridge = fromBridge.includes(agentId)
60 return role ? { ...c, running, fromBridge: fromBridge.filter(id => id !== agentId) } : c
61 })
62 if (!role) return
63 // Every report is kept, whoever sent the officer, so the Bridge can show it again.
64 if (!isAborted && answer.trim() !== '') {
65 await repoOf($).saveCrewReport({ role, at: await $.clock.now(), lines: reportLines(answer) })
66 }
67 // An Engineering lint run ends with LINT: PASS or FAIL: the Bridge's shields and the mission's lint, even where an exit status was not seen.
68 const lint = role === 'engineering' && !isAborted ? parseLint(answer) : undefined
69 if (lint) {
70 await update($, gauges, g => ({ ...g, lint }))
71 if (await read($, ready)) await recordLint({ repo: repoOf($), verdict: lint })
72 }
73 if (isFromBridge) await showReport($, role, answer, isAborted)
74 const now = await $.clock.now()
75 const verdict = role === 'tactical' && !isAborted ? parseVerdict(answer) : undefined
76 const outcome = isAborted ? 'stopped' : verdict ?? 'done'
77 const r = role
78 await update($, crew, c => ({ ...c, last: { ...c.last, [r]: { outcome, at: now } } }))
79 if (!verdict) return
80 if (!(await read($, ready))) return
81 const { counted } = await recordTactical({ repo: repoOf($), verdict })
82 $.ui.toast(verdict === 'clean'
83 ? `${ROLE_LABELS.tactical}: all clear${counted ? '. Mission quality up.' : '.'}`
84 : `${ROLE_LABELS.tactical}: security issues found. See the report.`)
85}
86
87export function wireCrew(on: On): void {
88 on('agent.spawn', async ($, e, next) => {
89 const started = await next(e)
90 const role = roleOf(e.subagentType)
91 const id = started.agentId
92 if (role && id !== undefined) await update($, crew, c => ({ ...c, running: { ...c.running, [id]: role } }))
93 return started
94 })
95
96 on('turn.complete', async ($, e, next) => {
97 if (e.agentId !== undefined) {
98 try {
99 await finished($, e.agentId, e.answer, e.isAborted)
100 } catch (err) {
101 $.ui.log(`final-commit: crew: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
102 }
103 }
104 return next(e)
105 })
106}
107src/crew/roster.ts 135 lines1import { CREW } from '../config'
2
3// SPEC 9.1 crew: real subagent types. The prompts and descriptions are working
4// instructions only (CLAUDE.md rule 1): the role names are the game's, nothing
5// else about the game reaches the model.
6
7export type Role = 'engineering' | 'science' | 'tactical'
8export const ROLES: readonly Role[] = ['engineering', 'science', 'tactical']
9
10export const ROLE_LABELS: Record<Role, string> = { engineering: 'Engineering', science: 'Science', tactical: 'Tactical' }
11
12const READ_ONLY = 'Do not modify, create or delete files, and do not commit or push.'
13
14export type CrewSpec = { name: Role; description: string; prompt: string; tools: string[]; maxTurns: number }
15
16export const CREW_SPECS: Record<Role, CrewSpec> = {
17 engineering: {
18 name: 'engineering',
19 description: 'Runs the project\'s tests and explains the failures. Read-only.',
20 prompt: [
21 'You run a project\'s tests and explain the results.',
22 'Find how the project runs its tests (package.json scripts, Makefile, composer.json, pyproject, CI config) and run the narrowest command that covers the request.',
23 'Report the command you ran, the pass and fail counts, and for each failure: the test name, the assertion or error, and the most likely cause with file:line.',
24 'If the tests cannot run, say why and what is missing.',
25 READ_ONLY,
26 ].join(' '),
27 tools: ['Bash', 'Read', 'Grep', 'Glob'],
28 maxTurns: CREW.maxTurns.engineering,
29 },
30 science: {
31 name: 'science',
32 description: 'Answers questions about the codebase and its docs by reading them, citing file:line. Read-only.',
33 prompt: [
34 'You answer questions about a codebase by reading its code and documentation.',
35 'Cite file:line for every claim. Keep what you verified apart from what you infer, and say what you could not find.',
36 READ_ONLY,
37 ].join(' '),
38 tools: ['Read', 'Grep', 'Glob'],
39 maxTurns: CREW.maxTurns.science,
40 },
41 tactical: {
42 name: 'tactical',
43 description: 'Security review of the current branch\'s changes before a push; ends with a CLEAN or ISSUES verdict. Read-only.',
44 prompt: [
45 'You review code changes for security problems before they are pushed.',
46 'Unless told otherwise, the changes under review are the commits on the current branch that its upstream lacks (git log @{u}..HEAD; with no upstream, compare against the default branch) plus uncommitted changes.',
47 'Look for injection (SQL, shell, template), authentication and authorization gaps, secrets or credentials in code or config, unsafe deserialization, path traversal, server-side request forgery, missing validation at trust boundaries, sensitive data written to logs, and risky dependency changes.',
48 'Report each finding with file:line, severity and a concrete fix.',
49 READ_ONLY,
50 'End your reply with exactly one line: "VERDICT: CLEAN" when you found no security issue, or "VERDICT: ISSUES" otherwise.',
51 ].join(' '),
52 tools: ['Bash', 'Read', 'Grep', 'Glob'],
53 maxTurns: CREW.maxTurns.tactical,
54 },
55}
56
57/** `final-commit:tactical` -> 'tactical'; undefined for any other agent type. */
58export function roleOf(subagentType: string): Role | undefined {
59 const m = /^final-commit:(engineering|science|tactical)$/.exec(subagentType)
60 return m ? (m[1] as Role) : undefined
61}
62
63export type Verdict = 'clean' | 'issues'
64
65/** The Tactical verdict: the last `VERDICT:` line of the reply, or undefined without one. */
66export function parseVerdict(answer: string): Verdict | undefined {
67 const all = [...answer.matchAll(/^\s*\**VERDICT:\s*(CLEAN|ISSUES)\b/gim)]
68 const last = all.at(-1)?.[1]
69 return last === undefined ? undefined : last.toUpperCase() === 'CLEAN' ? 'clean' : 'issues'
70}
71
72/** What the crew is doing this session: running agents by id, and each officer's last result. */
73export type CrewOutcome = 'done' | 'clean' | 'issues' | 'stopped'
74/** `fromBridge`: runs launched from the Bridge pane, whose reports open in the report pane. */
75export type CrewState = { running: Record<string, Role>; last: Partial<Record<Role, { outcome: CrewOutcome; at: number }>>; fromBridge: string[] }
76export const NO_CREW: CrewState = { running: {}, last: {}, fromBridge: [] }
77
78/** What a Bridge key sends an officer to do. */
79export type CrewTask = 'tests' | 'lint' | 'question' | 'review'
80export const TASK_ROLE: Record<CrewTask, Role> = { tests: 'engineering', lint: 'engineering', question: 'science', review: 'tactical' }
81
82/** The Bridge's keys: `e` tests and `l` lint (Engineering), `s` a question (Science), `t` a security review (Tactical). */
83export const BRIDGE_KEYS: Record<string, CrewTask> = { e: 'tests', l: 'lint', s: 'question', t: 'review' }
84
85/** The prompt a Bridge key gives an officer: plain working instructions, like the agent prompts. */
86export function crewTask(task: CrewTask, question = ''): { prompt: string; description: string } {
87 switch (task) {
88 case 'tests':
89 return { prompt: 'Run the project\'s test suite and report the results.', description: 'Run the tests' }
90 case 'lint':
91 return {
92 prompt: [
93 'Run the project\'s linters and type checkers (for example its lint and typecheck scripts, eslint, tsc, phpstan, ruff) and report every problem with file:line.',
94 'Run each command on its own, not piped into another command, so its exit status is kept.',
95 'End your reply with exactly one line: "LINT: PASS" when every check passed, or "LINT: FAIL" otherwise.',
96 ].join(' '),
97 description: 'Run lint and type checks',
98 }
99 case 'review':
100 return { prompt: 'Review the changes on the current branch that are not yet pushed, plus any uncommitted changes, for security issues.', description: 'Security review' }
101 case 'question':
102 return { prompt: question.trim(), description: 'Answer a question' }
103 }
104}
105
106/** The lint verdict an Engineering report ends with: the last `LINT:` line, or undefined. */
107export function parseLint(answer: string): 'pass' | 'fail' | undefined {
108 const last = [...answer.matchAll(/^\s*\**LINT:\s*(PASS|FAIL)\b/gim)].at(-1)?.[1]
109 return last === undefined ? undefined : last.toUpperCase() === 'PASS' ? 'pass' : 'fail'
110}
111
112/** A finished run's answer as report lines: blank lines dropped, cut to CREW.reportLines with a note. */
113export function reportLines(answer: string): string[] {
114 const lines = answer.split('\n').map(l => l.trimEnd()).filter(l => l.trim() !== '')
115 if (lines.length === 0) return ['(no report)']
116 if (lines.length <= CREW.reportLines) return lines
117 return [...lines.slice(0, CREW.reportLines - 1), `(${lines.length - CREW.reportLines + 1} more lines not shown)`]
118}
119
120/** The Bridge's number keys: each reopens that officer's last report. */
121export const REPORT_KEYS: Record<string, Role> = { '1': 'engineering', '2': 'science', '3': 'tactical' }
122
123/**
124 * One line per officer for the Bridge, numbered by its report key: busy,
125 * idle, or its last result, and `(report)` when one is saved.
126 */
127export function crewLines(s: CrewState, withReport: readonly Role[] = []): string[] {
128 const busy = new Set(Object.values(s.running))
129 const word: Record<CrewOutcome, string> = { done: 'idle', clean: 'all clear', issues: 'ISSUES FOUND', stopped: 'idle (stopped)' }
130 return ROLES.map((r, i) => {
131 const status = busy.has(r) ? 'busy' : s.last[r] ? word[s.last[r]!.outcome] : 'idle'
132 return ` ${i + 1} ${ROLE_LABELS[r].padEnd(12)}${status}${withReport.includes(r) ? ' (report)' : ''}`
133 })
134}
135src/bridge/band.tsx 80 lines1import { atom, read } from 'claude-code'
2import type { On } from 'claude-code'
3import type { BandView, CalibrationView, LatticeView, ReportView } from '../../types'
4import { COMPANION, SPRITE } from '../config'
5import { tierColor } from './color'
6
7const band = atom({ plugin: 'final-commit', key: 'band' } as const, null as BandView | null)
8const lattice = atom({ plugin: 'final-commit', key: 'lattice' } as const, null as LatticeView | null)
9const calibration = atom({ plugin: 'final-commit', key: 'calibration' } as const, null as CalibrationView | null)
10const report = atom({ plugin: 'final-commit', key: 'report' } as const, null as ReportView | null)
11const bridgeOpen = atom({ plugin: 'final-commit', key: 'bridgeOpen' } as const, false)
12
13/** Eyes close for a blink frame. */
14export function blink(rows: string[]): string[] {
15 return rows.map(r => r.replace(/[oO@]/g, '-'))
16}
17
18/** The drawing without its blank margin: blank rows above and below, blank columns at either side. */
19export function trimSprite(rows: string[]): string[] {
20 const inked = rows.filter(r => r.trim().length > 0)
21 if (inked.length === 0) return []
22 const first = rows.findIndex(r => r.trim().length > 0)
23 const last = rows.length - 1 - [...rows].reverse().findIndex(r => r.trim().length > 0)
24 const kept = rows.slice(first, last + 1)
25 const left = Math.min(...inked.map(r => r.length - r.trimStart().length))
26 const right = Math.max(...inked.map(r => r.trimEnd().length))
27 return kept.map(r => r.padEnd(right, ' ').slice(left, right))
28}
29
30/** Cuts a line to `width` cells. Band text is printable ASCII and one cell per character. */
31const fit = (text: string, width: number): string => [...text].slice(0, Math.max(0, width)).join('')
32
33/** The text beside the sprite: one fact per line, each cut to the column left of it. */
34export function besideLines(v: Pick<BandView, 'name' | 'tier' | 'mood' | 'mission'>, width: number): string[] {
35 return [v.name, v.tier, v.mood, ...(v.mission ? [v.mission] : [])].map(line => fit(line, width))
36}
37
38/** The companion above the prompt (SPEC 9.3). The wiring files write the view (and the blink); this draws it. */
39export function wireBand(on: On): void {
40 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
41 const v = await read($, band)
42 // A game pane needs the rows: the band steps aside while one is open. The Bridge draws the companion itself.
43 const isPlaying = (await read($, lattice)) !== null || (await read($, calibration)) !== null || (await read($, report)) !== null || (await read($, bridgeOpen))
44 if (!v || e.props.hasSurvey || isPlaying) return next(e)
45 const { Box, Text } = $.ui.resolve(e)
46 const columns = e.props.bodyColumns
47 const color = tierColor(v.tierName, await $.clock.now())
48 // The companion keeps to the right edge; whatever else draws in the band keeps the left.
49 const beneath = await next(e)
50 const showSprite = e.props.maxRows >= COMPANION.spriteMinRows && columns >= SPRITE.cols && v.sprite.length > 0
51 if (!showSprite) {
52 const line = fit(`${v.tier} ${v.name} · ${v.mood}${v.mission ? ` · ${v.mission}` : ''}`, columns)
53 return (
54 <Box flexDirection="row">
55 <Box flexGrow={1} flexShrink={1}>{beneath}</Box>
56 <Box flexShrink={0}><Text dimColor color={color}>{line}</Text></Box>
57 </Box>
58 )
59 }
60 const sprite = trimSprite(v.isBlinking || v.mood === 'asleep' ? blink(v.sprite) : v.sprite)
61 const spriteColumns = Math.max(0, ...sprite.map(r => r.length))
62 const room = columns - spriteColumns - COMPANION.spriteGap
63 const lines = room >= COMPANION.besideMinColumns ? besideLines(v, room) : []
64 const textColumns = Math.max(0, ...lines.map(l => [...l].length))
65 return (
66 <Box flexDirection="row">
67 <Box flexGrow={1} flexShrink={1}>{beneath}</Box>
68 {lines.length > 0 && (
69 <Box flexDirection="column" justifyContent="center" flexShrink={0} width={textColumns} marginRight={COMPANION.spriteGap}>
70 {lines.map((line, i) => (i === 0 ? <Text bold color={color}>{line}</Text> : i === 1 ? <Text color={color}>{line}</Text> : <Text dimColor>{line}</Text>))}
71 </Box>
72 )}
73 <Box flexDirection="column" flexShrink={0} width={spriteColumns}>
74 {sprite.map(row => <Text color={color}>{row}</Text>)}
75 </Box>
76 </Box>
77 )
78 })
79}
80src/bridge/bay-pane.tsx 74 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import type { BandView, BayRow, ChartingEntry } from '../../types'
4import { setCompanion } from '../game'
5import { NO_MOOD, rngFor, snapshot } from '../runtime'
6import { createRepo, type Repo } from '../store/repo'
7import type { StarSystem } from '../store/schema'
8import { bayRows, holdRows } from './bay'
9import { tierColor } from './color'
10
11export const BAY_PANE = 'fc-bay'
12const rows = atom({ plugin: 'final-commit', key: 'bay' } as const, [] as BayRow[])
13const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
14const mood = atom({ plugin: 'final-commit', key: 'mood' } as const, NO_MOOD)
15const charting = atom({ plugin: 'final-commit', key: 'charting' } as const, [] as ChartingEntry[])
16const band = atom({ plugin: 'final-commit', key: 'band' } as const, null as BandView | null)
17
18function repoOf($: EngineInterface): Repo {
19 return createRepo(
20 { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() },
21 line => $.ui.log(line, { to: 'debug' }),
22 )
23}
24
25async function refresh($: EngineInterface) {
26 const s = await snapshot(repoOf($), await read($, mood), await $.clock.now(), await read($, charting))
27 $.ui.status(s.status)
28 await update($, band, prev => (s.band && prev ? { ...s.band, isBlinking: prev.isBlinking } : s.band))
29}
30
31async function bay($: EngineInterface, args: string): Promise<string> {
32 if (!(await read($, ready))) return 'The Final Commit could not load its save; see the debug log (claude --debug).'
33 const repo = repoOf($)
34 const m = /^companion\s+(\d+)$/i.exec(args.trim())
35 if (m) {
36 const chosen = (await repo.specimens())[Number(m[1]) - 1]
37 if (!chosen) return 'No such specimen. /bay lists them.'
38 const out = await setCompanion({ repo, now: await $.clock.now(), rng: rngFor(undefined, 0), specimenId: chosen.id })
39 await refresh($)
40 return out.text
41 }
42 const specimens = await repo.specimens()
43 const systems: StarSystem[] = []
44 for (const id of await repo.systemIds()) {
45 const s = await repo.system(id)
46 if (s) systems.push(s)
47 }
48 const meta = await repo.meta()
49 // Rows are cut to the pane's width when drawn.
50 const inv = await repo.inventory()
51 await update($, rows, () => [...holdRows(inv, systems), ...bayRows(specimens, systems, meta?.companionId ?? null, Number.MAX_SAFE_INTEGER)])
52 await $.ui.open({ id: BAY_PANE, title: 'Specimen Bay', focus: true, closeOnEscape: true })
53 return `Opened the specimen bay (${specimens.length} specimen${specimens.length === 1 ? '' : 's'}).`
54}
55
56/** The Specimen Bay as a pane, so creature names stay out of the transcript the model reads. */
57export function wireBay(on: On): void {
58 on('command.run', { command: 'bay' }, async ($, e) => ({ text: await bay($, e.args) }))
59
60 on('ui.render', { component: 'Pane', requestId: BAY_PANE }, async ($, e) => {
61 const { Box, Text } = $.ui.resolve(e)
62 const list = await read($, rows)
63 const now = await $.clock.now()
64 return (
65 <Box flexDirection="column">
66 {list.map(r => (
67 <Text color={r.tier ? tierColor(r.tier, now) : undefined}>{[...r.text].slice(0, e.props.bodyColumns).join('')}</Text>
68 ))}
69 <Text dimColor>/bay companion N sets your companion. /craft makes cells. Esc closes.</Text>
70 </Box>
71 )
72 })
73}
74src/bridge/dossier-pane.tsx 56 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import type { DossierStat } from '../../types'
4import { createRepo, type Repo } from '../store/repo'
5import { dossierRows } from './dossier'
6
7export const DOSSIER_PANE = 'fc-dossier'
8const stats = atom({ plugin: 'final-commit', key: 'dossier' } as const, null as DossierStat[] | null)
9const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
10
11function repoOf($: EngineInterface): Repo {
12 return createRepo(
13 { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() },
14 line => $.ui.log(line, { to: 'debug' }),
15 )
16}
17
18/** `/dossier`: puzzle accuracy by category, in a pane so category names stay out of the transcript the model reads. */
19async function dossier($: EngineInterface): Promise<string> {
20 if (!(await read($, ready))) return 'The Final Commit could not load its save; see the debug log (claude --debug).'
21 const drawn = (await repoOf($).puzzleStats()).map(({ category, type, attempts, correct, recent }) => ({ category, type, attempts, correct, recent }))
22 await update($, stats, () => drawn)
23 await $.ui.open({ id: DOSSIER_PANE, title: 'Dossier', focus: true, closeOnEscape: true })
24 return `Opened the dossier (${drawn.reduce((n, s) => n + s.attempts, 0)} answered).`
25}
26
27async function close($: EngineInterface) {
28 // A plugin's own $.ui.close does not reach its own ui.close hook, so the state is cleared here.
29 await update($, stats, () => null)
30 await $.ui.close({ id: DOSSIER_PANE })
31}
32
33export function wireDossier(on: On): void {
34 on('command.run', { command: 'dossier' }, async $ => ({ text: await dossier($) }))
35
36 on('ui.close', { id: DOSSIER_PANE }, async ($, e, next) => {
37 await update($, stats, () => null)
38 return next(e)
39 })
40
41 on('ui.render', { component: 'Pane', requestId: DOSSIER_PANE }, async ($, e) => {
42 const { Box, Text, Button } = $.ui.resolve(e)
43 const list = await read($, stats)
44 if (!list) return <Text dimColor>Nothing to show.</Text>
45 return (
46 <Box flexDirection="column">
47 {dossierRows(list, e.props.bodyColumns).map(r => (
48 <Text bold={r.style === 'bold'}>{r.text === '' ? ' ' : r.text}</Text>
49 ))}
50 <Text dimColor>Esc closes.</Text>
51 {e.surface === 'mobile' && <Button key="dossier-close" label="Close" onPress={() => void close($)} />}
52 </Box>
53 )
54 })
55}
56src/bridge/scan-pane.tsx 62 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3import type { ScanRow } from '../../types'
4import { parseSystemKey } from '../game'
5import { createRepo, type Repo } from '../store/repo'
6import { scanRows } from './scan'
7import { tierColor } from './color'
8import { wrap } from './text'
9
10export const SCAN_PANE = 'fc-scan'
11const rows = atom({ plugin: 'final-commit', key: 'scan' } as const, [] as ScanRow[])
12const ready = atom({ plugin: 'final-commit', key: 'ready' } as const, false)
13
14function repoOf($: EngineInterface): Repo {
15 return createRepo(
16 { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value), delete: key => $.store.delete(key), keys: () => $.store.keys() },
17 line => $.ui.log(line, { to: 'debug' }),
18 )
19}
20
21/** `/scan [KEY]`: the active epic's system, or the one charted for KEY. */
22async function scan($: EngineInterface, args: string): Promise<string> {
23 if (!(await read($, ready))) return 'The Final Commit could not load its save; see the debug log (claude --debug).'
24 const repo = repoOf($)
25 const key = args.trim() === '' ? (await repo.meta())?.activeEpicKey ?? undefined : parseSystemKey(args)
26 if (!key) return args.trim() === '' ? 'No active epic. /scan <KEY> scans a charted one.' : 'Usage: /scan [KEY], for example /scan NOVA-1.'
27 let system
28 for (const id of await repo.systemIds()) {
29 const s = await repo.system(id)
30 if (s?.epicKey === key) system = s
31 }
32 if (!system) return `Epic ${key} is not charted.`
33 const drawn = scanRows(system, await repo.catalog(), await repo.resolved())
34 await update($, rows, () => drawn)
35 await $.ui.open({ id: SCAN_PANE, title: `Scan ${key}`, focus: true, closeOnEscape: true })
36 return `Opened the scan of epic ${key}.`
37}
38
39/** The system scan as a pane, so species names stay out of the transcript the model reads. */
40export function wireScan(on: On): void {
41 on('command.run', { command: 'scan' }, async ($, e) => ({ text: await scan($, e.args) }))
42
43 on('ui.render', { component: 'Pane', requestId: SCAN_PANE }, async ($, e) => {
44 const { Box, Text } = $.ui.resolve(e)
45 const width = e.props.bodyColumns
46 const list = await read($, rows)
47 const now = await $.clock.now()
48 // Sprites are cut to the width; prose wraps.
49 const lines = list.flatMap(r => (r.isArt ? [{ ...r, text: [...r.text].slice(0, width).join('') }] : wrap(r.text, width).map(text => ({ ...r, text }))))
50 return (
51 <Box flexDirection="column">
52 {lines.map(r => (
53 <Text bold={r.style === 'bold'} dimColor={r.style === 'dim'} color={r.tier ? tierColor(r.tier, now) : undefined}>
54 {r.text}
55 </Text>
56 ))}
57 <Text dimColor>Esc closes.</Text>
58 </Box>
59 )
60 })
61}
62src/setup/setup-pane.tsx 141 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginOptions } from 'claude-code'
3import type { SetupValues, SetupView } from '../../types'
4import { wrap } from '../bridge/text'
5import { GIT } from '../config'
6import { answer, changesOf, questionOf, reviewLines, startSetup } from './wizard'
7
8// `/setup` (SPEC 4.6): the wizard as a pane. Answers are typed into it, so
9// they skip the transcript the model reads, and are saved as the plugin's
10// own settings through `$.config.set`, as a change in `/config` would be.
11// The Jira API token is a secret no plugin can write: the review says where
12// to set it, and whether it is set, never what it is.
13
14export const SETUP_PANE = 'fc-setup'
15const view = atom({ plugin: 'final-commit', key: 'setup' } as const, null as SetupView | null)
16
17const text = (v: unknown) => (typeof v === 'string' ? v : '')
18let current: SetupValues = { workSources: '', plansFolders: '', githubRepos: '', jiraSite: '', jiraEmail: '' }
19let hasJiraToken = false
20
21/** This project's GitHub repo (owner/name) through gh, to suggest; undefined when there is none or gh cannot tell. */
22async function repoHere($: EngineInterface): Promise<string | undefined> {
23 try {
24 const r = await $.process.run(['gh', 'repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], { timeoutMs: GIT.timeoutMs })
25 const name = r.stdout.trim()
26 return r.exitCode === 0 && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(name) ? name : undefined
27 } catch {
28 return undefined
29 }
30}
31
32async function setup($: EngineInterface): Promise<{ text: string }> {
33 const repo = await repoHere($)
34 await update($, view, () => startSetup(current, repo))
35 const placed = await $.ui.open({ id: SETUP_PANE, title: 'Set up work sources', focus: true, closeOnEscape: true })
36 if (placed.isPlaced) return { text: 'Opened the setup pane.' }
37 await update($, view, () => null)
38 return { text: 'The setup pane could not open here. Set workSources and the source settings in /config instead.' }
39}
40
41/** Enter on a question: the answer is checked and kept, or the pane says what is wrong. */
42async function submit($: EngineInterface, value: string) {
43 await update($, view, v => (v ? answer(v, value) : v))
44}
45
46/**
47 * Enter on the review: writes each changed setting, the source list last. A
48 * write reloads the plugin with the new settings; the writes still finish.
49 * The pane shows `saving` meanwhile, with no input, so a second Enter cannot
50 * write them again. Saving is not stopped halfway: if the pane was closed
51 * meanwhile, a toast says what was saved.
52 */
53async function save($: EngineInterface) {
54 const v = await read($, view)
55 if (!v || v.step !== 'review') return
56 const changes = changesOf(v)
57 if (changes.length === 0) {
58 await close($)
59 return
60 }
61 await update($, view, s => (s ? { ...s, step: 'saving' as const } : s))
62 const applied: string[] = []
63 for (const c of changes) {
64 try {
65 const r = await $.config.set({ key: `final-commit.${c.key}`, value: c.value })
66 applied.push('deny' in r && r.deny !== undefined ? `${c.key}: not saved (${r.deny})` : `${c.key}: saved`)
67 } catch (err) {
68 applied.push(`${c.key}: not saved (${err instanceof Error ? err.message : String(err)})`)
69 }
70 }
71 const after = await update($, view, s => (s ? { ...s, step: 'done' as const, applied } : s))
72 if (!after) $.ui.toast(`Setup: ${applied.join('; ')}.`)
73}
74
75async function close($: EngineInterface) {
76 // A plugin's own $.ui.close does not reach its own ui.close hook, so the view is cleared here.
77 await update($, view, () => null)
78 await $.ui.close({ id: SETUP_PANE })
79}
80
81/** Registers `/setup` and its pane. `options` are the settings this load runs with: the wizard starts from them. */
82export function wireSetup(on: On, options: PluginOptions): void {
83 current = {
84 workSources: text(options.workSources),
85 plansFolders: text(options.plansFolders),
86 githubRepos: text(options.githubRepos),
87 jiraSite: text(options.jiraSite),
88 jiraEmail: text(options.jiraEmail),
89 }
90 hasJiraToken = text(options.jiraToken).trim() !== ''
91
92 on('command.run', { command: 'setup' }, async $ => setup($))
93
94 on('ui.close', { id: SETUP_PANE }, async ($, e, next) => {
95 await update($, view, () => null)
96 return next(e)
97 })
98
99 on('ui.render', { component: 'Pane', requestId: SETUP_PANE }, async ($, e) => {
100 const elements = $.ui.resolve(e)
101 const { Box, Text } = elements
102 const Input = 'Input' in elements ? elements.Input : undefined
103 const width = e.props.bodyColumns
104 const v = await read($, view)
105 if (!v) return <Text dimColor>Nothing to set up.</Text>
106 const lines = (s: string, style?: 'bold' | 'dim' | 'error') => wrap(s, width).map(line => (
107 <Text bold={style === 'bold'} dimColor={style === 'dim'} color={style === 'error' ? 'error' : undefined}>{line}</Text>
108 ))
109 if (!Input) return <Box flexDirection="column">{lines('This surface has no text input; run /setup from a terminal.', 'dim')}</Box>
110 if (v.step === 'done') {
111 return (
112 <Box flexDirection="column">
113 {v.applied.flatMap(a => lines(a))}
114 {lines('Settings take effect at once. Enter or Esc closes.', 'dim')}
115 <Input key="setup-done" autoFocus label="Enter" onInput={() => {}} onSubmit={() => void close($)} />
116 </Box>
117 )
118 }
119 if (v.step === 'saving') return <Box flexDirection="column">{lines('Saving...', 'dim')}</Box>
120 if (v.step === 'review') {
121 return (
122 <Box flexDirection="column">
123 {lines('Review', 'bold')}
124 {reviewLines(v, hasJiraToken).flatMap(l => lines(l, l.startsWith('!') ? 'error' : undefined))}
125 <Input key="setup-review" autoFocus label="Enter" onInput={() => {}} onSubmit={() => void save($)} />
126 </Box>
127 )
128 }
129 const q = questionOf(v)
130 if (!q) return <Text dimColor>Nothing to set up.</Text>
131 return (
132 <Box flexDirection="column">
133 {lines(q.help, 'dim')}
134 {v.error ? lines(v.error, 'error') : null}
135 <Input key={`setup-${v.step}`} autoFocus label={q.label} value={q.value} onInput={() => {}} onSubmit={(value: string) => void submit($, value)} />
136 {lines('Enter: next Esc: cancel, nothing saved', 'dim')}
137 </Box>
138 )
139 })
140}
141src/bridge/text.ts 23 lines1// Pure text layout for panes.
2
3function cut(text: string, width: number): string {
4 const chars = [...text]
5 return chars.length <= width ? text : `${chars.slice(0, Math.max(1, width - 1)).join('')}~`
6}
7
8/** `text` broken at spaces into lines of at most `width` (a longer word is cut). Continuations indent two cells. */
9export function wrap(text: string, width: number): string[] {
10 const out: string[] = []
11 let line = ''
12 for (const word of text.split(' ')) {
13 const candidate = line === '' ? word : `${line} ${word}`
14 if ([...candidate].length <= width || line.trim() === '') line = candidate
15 else {
16 out.push(line)
17 line = ` ${word}`
18 }
19 }
20 out.push(line)
21 return out.map(l => cut(l, width))
22}
23