Extending prevAIl
June 4, 2026 · View on GitHub
How to add new pieces without breaking existing ones. Each section ends at the boundary where the rest of the code picks the change up for free.
1. Adding a new framework
Frameworks shape the structure of the answer (BLUF, SCQA, OODA, ...). One file, one append:
// src/framework.ts
export type FrameworkId =
| "bluf" | "win" | "scqa" | "sbar" | "ooda" | "proscons" | "steelman"
| "my-new-id"; // ← add to the union
export const FRAMEWORKS: readonly Framework[] = [
// ...existing entries...
{
id: "my-new-id",
label: "MY-LABEL", // <=10 chars for the chip
blurb: "One-line description for tooltips",
instruction:
"Apply <X>. Structure your response as ... . No preamble.",
},
];
That's it. The ConfigBar chip cycles [null, ...FRAMEWORKS] so the new id
is picked up automatically. /framework <id> validates against
isFrameworkId() which also reads FRAMEWORKS. buildFrameworkPreamble()
wraps the instruction in [FRAMEWORK: ...] and runChatTurn() prepends it
to every CLI call.
Keep the instruction terse — codex tends to echo long preambles into the reply bubble.
2. Adding a new lens
Lenses are angles of attack on the problem (first principles, contrarian,
executor, ...) — used by the council fanout when lens=all. Same shape as
frameworks:
// src/lens.ts
export type LensId =
| "first-principles" | "outsider" | "contrarian" | "expansionist"
| "executor" | "alien" | "mom" | "dad"
| "my-new-lens"; // ← add to the union
export const LENSES: readonly Lens[] = [
// ...existing entries...
{
id: "my-new-lens",
label: "MY LENS", // <=14 chars for the chip
blurb: "Angle of attack in one line",
instruction:
"Approach this problem as <X>. <directive in one paragraph>.",
},
];
Then update README's lens list so users discover it. The ConfigBar chip
cycle reads LENSES directly; /council lens <id> validates through
isLensId(); expandLensSelection("all") picks the new lens up
automatically — lens=all becomes panelists × (N+1) calls, so be aware
of the cost cap.
3. Adding a new CLI bridge
Adding a new subprocess CLI (say mistral-cli) means touching one file —
src/cli-bridge.ts — in five places:
// 1. The union
export type CliKind = "claude" | "codex" | "gemini" | "ollama" | "mistral";
// 2. Detection
const CANDIDATES = [
{ kind: "claude", bins: ["claude"], label: "Claude" },
// ...
{ kind: "mistral", bins: ["mistral-cli", "mistral"], label: "Mistral" },
];
// 3. Quick-pick model versions
const MISTRAL_VERSIONS = ["mistral-large-2", "mistral-medium", "mistral-small"];
const CLI_DEFAULT_MODELS: Record<CliKind, string> = {
// ...
mistral: MISTRAL_VERSIONS[0]!,
};
export const MODEL_QUICKPICKS_FALLBACK: Record<CliKind, string[]> = {
// ...
mistral: MISTRAL_VERSIONS,
};
// 4. The hint shown in the model picker
export const CLI_MODEL_HINT: Record<CliKind, string> = {
// ...
mistral: "e.g. mistral-large-2, mistral-medium",
};
// 5. The run path in runChatTurn
if (cli.kind === "mistral") {
const args = m ? ["-m", m, framedPrompt] : [framedPrompt];
return runCapture(cli.bin, args, cwd, signal, onChunk);
}
If the CLI emits a noisy envelope (like codex/gemini), add an
extractMistralReply() helper and call it on the raw output. If it has a
real system-prompt channel like claude --append-system-prompt, wire the
operating manual in too — otherwise leave the manual gating alone.
4. Adding a new ConfigBar chip
ConfigBar chips live in src/workspace-config-bar.tsx. The pattern:
<box
flexDirection="row"
paddingLeft={1}
paddingRight={1}
onMouseDown={cycleMyChip}
>
<text fg={labelFg}>{"◆ MyChip:"}</text>
<text fg={myValFg} attributes={myOn ? 1 : 0}>{` ${myLabel}`}</text>
</box>
{sep}
Two non-negotiable rules:
- Two adjacent
<text>cells inside one<box>— opentui clips a single<text>that mixes literal segments with JSX interpolation. The label cell is one<text>; the value cell is another. Same row. - NBSP-prefix the value cell — opentui strips leading whitespace from
text cells. The value cell starts with a U+00A0 non-breaking space so
the rendered output has a visible gap between label and value. The
template literal
` ${myLabel}`is using NBSP, not regular space.
For the backing state, follow the readX / setX pattern in src/config.ts
with a domainKey?: string parameter so the chip can flip between global
and per-domain scope. Read with resolveX(domainKey) when the chip should
show the effective value, including fallthrough to the global default.
5. Adding a new slash command
Three steps, all in two files:
// src/chat-pane.tsx
export type ChatCommand =
// ...existing variants...
| { kind: "my-cmd"; arg: string };
function parseSlashCommand(text: string): ChatCommand {
// ...existing matches...
if (m = /^\/my-cmd\s+(.+)$/.exec(text)) {
return { kind: "my-cmd", arg: m[1]!.trim() };
}
// ...
}
// src/app.tsx — handleChatCommand
function handleChatCommand(key: string, cmd: ChatCommand) {
switch (cmd.kind) {
// ...
case "my-cmd":
// do the thing. Use writeTurnSummary if it produced something the
// user would want in _log; otherwise just feed UI state.
break;
}
}
If the command needs autocomplete, register it in the slash-suggestion
list in chat-pane.tsx so it surfaces while the user types /my-.
6. Adding a new vault subfolder convention
Two auto-writers already exist and are the model for any new one:
writeTurnSummary()insrc/auto-summary.ts— appends to_log/.distillTurnToJournal()insrc/journal.ts— distills to_journal/.
To add a new auto-written folder (e.g. _briefs/ for daily briefings):
- Write a writer module that accepts the same shape as
TurnSummaryArgs(domainPath, ts, content) andmkdir -pthe folder if missing. - Never throw — file errors swallowed, the chat path must not block on a side-effect writer.
- Wire it from
src/app.tsxnext to the existingwriteTurnSummary()/distillTurnToJournal()callsites for both the single-chat path (~line 1545) and the council path (~line 1875). - If the folder needs tamper-evidence, append
<entry-id> <sha256>to a sibling.shasumand teachprevail vault verifyabout the new path.
7. Style notes
- TypeScript strict mode. No implicit any, no unused locals. The
build script runs
tsc --noEmitbefore bundling. - No
shell: true. Everyspawn()call uses an argv array. Prompt content is never interpolated into a shell string. - No
console.login TUI paths. opentui renders into the same TTY the cockpit owns — anything written to stdout corrupts the frame. Use the debug log writer instead and tail the file from another shell. - opentui rendering gotchas:
- Trailing AND leading whitespace inside a
<text>cell is stripped. Use U+00A0 (NBSP) for any spacing you want preserved. - A
<text>cell that mixes literal segments with JSX interpolation clips. Split into two adjacent<text>nodes inside one<box>. height={1}rows assume one terminal row; multi-line<text>inside a fixed-height row gets clipped to one row of glyphs.
- Trailing AND leading whitespace inside a
- Side-effect writers are best-effort.
writeTurnSummary()anddistillTurnToJournal()swallow their own errors. The chat path is the contract; the vault layer is the index.