From 45195ccb2a47e3b798fee277d2f401f47e77ee50 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Sun, 11 Oct 2026 02:38:47 +0530 Subject: [PATCH 01/66] feat(tui): palette for the 2026-10 screen language Step 1 of the TUI redesign handoff: add the redesign's colour roles to HUES and expose them through paint() and brandAnsi(), so no caller ever hard-codes an escape. - ink3 (#767f8b, labels, heading meta, key hints) and track (#3e434c, the empty part of a progress bar) are the designed hex at 24-bit and the SGR 2 dim attribute below it. - err (#f07178) is the red for failed lines: 203 at the 256 tier, 31 at the basic tier. - ink2 is the identity: descriptions keep the terminal's own foreground. Deviations from the handoff, as agreed in the review (D12): - The handoff suggested fixed 256 greys (248/243/238) and a basic-tier bright black (90). A fixed grey fights every theme that is not ours, and 90 is the background colour in Solarized Dark, so ink3 and track stay the dim attribute below 24-bit, as dim already does. - The designed ink2 #a7adb6 is tuned for dark terminals and measures about 2.2:1 on a light one. Nothing detects the background, so descriptions are never painted. - err uses 203 instead of the suggested 204: 204 is nearest by distance but differs from the brand pink (168) only in its red channel and reads as pink at that tier. Audit cache keys are unchanged (engineVersion 10c367f637740d67, detectorVersion 895c2fedece85e93): the roles are properties on existing objects, so the bundle gains no top-level name. --- CHANGELOG.md | 6 +++++ __tests__/hooks/tui-kit.test.ts | 48 +++++++++++++++++++++++++++++++++ src/hooks/tui.ts | 34 +++++++++++++++++++---- 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1576b070..51707823b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.11-beta.0 — 2026-10-11 + +### Features + +- New terminal look for the CLI, step one: the palette gains the redesign's label grey, progress-track grey and a red for failures. Descriptions keep the terminal's own colour, and the greys fall back to the dim attribute below 24-bit, so every screen stays readable on light themes (#PR) + ## 1.0.10 — 2026-10-05 Action needed if you use `instruct` policies with Hermes: an `instruct` no longer stops the call it reminds about. The call runs and the reminder is placed first in its result, so a policy written to prevent an action (for example, a direct API write that should go through an approved script) must become a `deny` before you upgrade. Hermes 0.20.0 is supported again: on 1.0.9 the plugin loaded there with no hooks and every tool call ran unchecked. After upgrading, run `failproofai update` and restart each Hermes gateway profile by name. Collects 1.0.10-beta.0 to beta.1 below. diff --git a/__tests__/hooks/tui-kit.test.ts b/__tests__/hooks/tui-kit.test.ts index 1d16893bc..c30e805da 100644 --- a/__tests__/hooks/tui-kit.test.ts +++ b/__tests__/hooks/tui-kit.test.ts @@ -164,6 +164,54 @@ describe("colour tiers", () => { }); }); +describe("the 2026-10 roles", () => { + const INK3_24 = "38;2;118;127;139"; // #767f8b + const TRACK_24 = "38;2;62;67;76"; // #3e434c + const ERR_24 = "38;2;240;113;120"; // #f07178 + + it("paints ink3 and track in their designed greys at 24-bit", () => { + expect(withEnv(TRUECOLOR, () => paint(true).ink3("x"))).toBe(`\x1B[${INK3_24}mx\x1B[0m`); + expect(withEnv(TRUECOLOR, () => paint(true).track("x"))).toBe(`\x1B[${TRACK_24}mx\x1B[0m`); + }); + + it("keeps both greys as the dim ATTRIBUTE below 24-bit, never a fixed colour", () => { + // A cube grey fights every theme that is not ours, and the basic tier's + // bright black (90) IS the background in Solarized Dark. + for (const env of [ANSI256, BASIC]) { + expect(withEnv(env, () => paint(true).ink3("x"))).toBe("\x1B[2mx\x1B[0m"); + expect(withEnv(env, () => paint(true).track("x"))).toBe("\x1B[2mx\x1B[0m"); + } + }); + + it("leaves ink2 in the terminal's own foreground at every tier", () => { + // The designed #a7adb6 is about 2.2:1 on a light ground and nothing detects + // which ground we are on, so descriptions are never painted. + for (const env of [TRUECOLOR, ANSI256, BASIC]) { + expect(withEnv(env, () => paint(true).ink2("Block sudo"))).toBe("Block sudo"); + } + }); + + it("paints err as a red at every tier, and not the brand pink's neighbour at 256", () => { + expect(withEnv(TRUECOLOR, () => paint(true).err("x"))).toBe(`\x1B[${ERR_24}mx\x1B[0m`); + // 204 is nearest by distance but differs from pink's 168 only in red. + expect(withEnv(ANSI256, () => paint(true).err("x"))).toBe("\x1B[38;5;203mx\x1B[0m"); + expect(withEnv(ANSI256, () => paint(true).err("x"))).not.toContain("38;5;204"); + expect(withEnv(BASIC, () => paint(true).err("x"))).toBe("\x1B[31mx\x1B[0m"); + }); + + it("routes the new roles through brandAnsi too, so no caller hard-codes them", () => { + expect(withEnv(TRUECOLOR, () => brandAnsi("err"))).toBe(`\x1B[${ERR_24}m`); + expect(withEnv(ANSI256, () => brandAnsi("ink3"))).toBe("\x1B[2m"); + expect(withEnv(BASIC, () => brandAnsi("track"))).toBe("\x1B[2m"); + }); + + it("emits ZERO escapes for the new roles when colour is off", () => { + const c = withEnv(TRUECOLOR, () => paint(false)); + const painted = [c.ink2("a"), c.ink3("b"), c.track("c"), c.err("d")].join(""); + expect(painted).toBe("abcd"); + }); +}); + describe("the logomark follows the tier", () => { const tty = { isTTY: true, columns: 80, write: vi.fn(() => true) } as unknown as TTYOut; diff --git a/src/hooks/tui.ts b/src/hooks/tui.ts index bbe7fa103..897a63ec6 100644 --- a/src/hooks/tui.ts +++ b/src/hooks/tui.ts @@ -126,11 +126,11 @@ interface Hue { * xterm-256 index for the middle tier, verified as the nearest cube entry by * Euclidean distance rather than eyeballed. * - * Optional because `dim` has no honest answer: its `basic` is SGR 2, an - * ATTRIBUTE that steps down whatever foreground the user's theme is already - * using, and the cube's nearest grey (243, #767676) is a fixed colour that - * fights every theme that is not ours. So dim keeps the attribute at all - * three tiers and this stays undefined. + * Optional because the greys (`dim`, `ink3`, `track`) have no honest answer: + * their `basic` is SGR 2, an ATTRIBUTE that steps down whatever foreground the + * user's theme is already using, and the cube's nearest grey (243, #767676) + * is a fixed colour that fights every theme that is not ours. So a grey keeps + * the attribute below 24-bit and this stays undefined. */ c256?: number; basic: string; @@ -149,6 +149,23 @@ const HUES = { pink: { rgb: [228, 88, 125], c256: 168, basic: "95" }, // #e4587d — selection, enabled, the mark, the brand warn: { rgb: [227, 179, 65], c256: 179, basic: "33" }, dim: { rgb: [107, 118, 132], basic: "2" }, + // The 2026-10 screen language's two greys. Both are the designed hex at 24-bit + // and the SGR 2 ATTRIBUTE below it, for the reason `dim` gives above: a fixed + // cube grey fights every theme that is not ours, and the basic tier's "bright + // black" (90) is the BACKGROUND colour in Solarized Dark, so a label drawn in + // it would vanish. + // + // There is no `ink2`: descriptions use the terminal's own foreground at every + // tier. The designed #a7adb6 is tuned for a dark ground, nothing here can tell + // which ground it is on, and on a light one it measures about 2.2:1 — half of + // what today's dim descriptions get. `paint().ink2` is therefore the identity. + ink3: { rgb: [118, 127, 139], basic: "2" }, // #767f8b — kv labels, heading meta, key hints + track: { rgb: [62, 67, 76], basic: "2" }, // #3e434c — the empty part of a progress bar, never text + // A state, never branding: the ✕ on a failed line. 203 rather than 204, which + // is the nearest cube entry by distance but differs from the brand pink's 168 + // only in its red channel and reads as pink at that tier — the one deliberate, + // eyeballed exception to the nearest-entry rule above. + err: { rgb: [240, 113, 120], c256: 203, basic: "31" }, // #f07178 } satisfies Record; export function colorsEnabled(out: TTYOut): boolean { @@ -237,6 +254,13 @@ export function paint(on: boolean) { // to `pink` there, at which point it is deleted. softPink: mk(HUES.pink), warn: mk(HUES.warn), + // The 2026-10 roles. `ink2` is the terminal's own foreground on purpose — + // see the note on HUES — so it is the identity at every tier, and exists + // so a call site says which role it means rather than leaving text bare. + ink2: (s: string): string => s, + ink3: mk(HUES.ink3), + track: mk(HUES.track), + err: mk(HUES.err), }; } From 157670fb80a20f9c5b67f3b389a202abfe30df6b Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Sun, 11 Oct 2026 02:46:44 +0530 Subject: [PATCH 02/66] feat(tui): building blocks for the 2026-10 screen language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of the TUI redesign handoff: header, head, rows, kv, cmd, the ●/○ state glyphs, ok/caution/fail, keys, bar, logo and helpPage, which match reference/screens.js FP.primitives. They are pure builders over RenderOpts, colour on or off, so every screen built from them can be asserted at any width without a pty. Deviations from the handoff, as agreed in the review: - Nothing copyable is ever cut (D9). Commands, URLs, paths, ids and kv values are left whole. Only prose (row descriptions) is shortened, with `…`, and only when a caller sets `fit` for a terminal; piped output is never cut. - Columns are derived from the block, with a guaranteed two-space gap, instead of the reference's fixed widths. The longest real policy name (require-no-conflicts-before-stop) is exactly the 32 columns the design fixes, so a fixed column runs it into its description. - The logomark returns nothing below its minimum width. The old ▮▮ one-liner is retired with that mark. - Without colour, a progress bar's empty part is blank rather than a run of ━, which would show no progress at all. - Everything lives inside one exported factory, screenKit(). A new top-level name that matches a local in a builtin policy or an audit detector (cmd, row, head, …) makes the bundler rename that local and rescans every user's audit history. The bundle gains one name, and engineVersion 10c367f637740d67 / detectorVersion 895c2fedece85e93 are unchanged. --- __tests__/hooks/tui-kit.test.ts | 169 ++++++++++++++++++++++++++++++++ src/hooks/tui.ts | 139 ++++++++++++++++++++++++++ 2 files changed, 308 insertions(+) diff --git a/__tests__/hooks/tui-kit.test.ts b/__tests__/hooks/tui-kit.test.ts index c30e805da..db4f12cce 100644 --- a/__tests__/hooks/tui-kit.test.ts +++ b/__tests__/hooks/tui-kit.test.ts @@ -24,6 +24,7 @@ import { renderBrandLogo, rows, rule, + screenKit, stack, table, title, @@ -897,3 +898,171 @@ describe("rule — the one accent every sectioned surface carries", () => { expect(visibleWidth(plain)).toBe(80 - INDENT.length); }); }); + + +describe("screenKit — the 2026-10 building blocks", () => { + const strip = (s: string) => s.replace(/\x1B\[[0-9;]*m/g, ""); + const plain = screenKit({ version: "1.0.11", cols: 104, color: false }); + + it("opens every screen with the wordmark, version and context", () => { + expect(plain.header("Policies")).toBe("failproof ai v1.0.11 · Policies"); + expect(plain.header()).toBe("failproof ai v1.0.11"); + }); + + it("paints the wordmark's `il` pink and bold, and the version grey", () => { + const k = withEnv(TRUECOLOR, () => screenKit({ version: "1.0.11", color: true })); + const h = k.header("Status"); + expect(h).toContain(`\x1B[1;${PINK_24}mil\x1B[0m`); + expect(h).toContain("\x1B[38;2;118;127;139mv1.0.11\x1B[0m"); + expect(strip(h)).toBe("failproof ai v1.0.11 · Status"); + }); + + it("uppercases headings and keeps their meta grey and as written", () => { + expect(plain.head("Dashboard")).toBe("DASHBOARD"); + expect(plain.head("Which agents should failproofai trace?", "8 of 9 selected")).toBe( + "WHICH AGENTS SHOULD FAILPROOFAI TRACE? 8 of 9 selected", + ); + }); + + it("lines up kv values where the reference does", () => { + // Copied from the reference's launch screen, plain. + expect( + plain.kv([ + ["url", "http://127.0.0.1:8020 ● live"], + ["policies", "10 on from FailproofAI/policies@06b802b"], + ["agents", "9 traced: Claude Code, Codex, Copilot, Cursor and 5 more"], + ["cloud", "Connected as chetanraghuvanshi85"], + ]), + ).toEqual([ + " url http://127.0.0.1:8020 ● live", + " policies 10 on from FailproofAI/policies@06b802b", + " agents 9 traced: Claude Code, Codex, Copilot, Cursor and 5 more", + " cloud Connected as chetanraghuvanshi85", + ]); + }); + + it("widens the kv column for a long label instead of running it into its value", () => { + const [line] = plain.kv([["would block", "7: block-env-files 4"]]); + expect(line).toBe(" would block 7: block-env-files 4"); + }); + + it("never cuts a kv value, however narrow the terminal", () => { + const url = "https://app.befailproof.ai/settings/machines/0b1c2d3e-0000-4000-8000-000000000001"; + const k = screenKit({ cols: 40, color: false, fit: true }); + expect(k.kv([["dashboard", url]])[0]).toContain(url); + }); + + it("renders a `command description` row at the reference's column", () => { + expect(plain.rows([["command", "What it does, in a short sentence"]], 14)).toEqual([ + " command What it does, in a short sentence", + ]); + }); + + it("always leaves two spaces after the widest name — the longest real policy name is 32", () => { + const name = "require-no-conflicts-before-stop"; + expect(name).toHaveLength(32); + const [line] = plain.rows([[name, "Require no merge conflicts before stopping"]], 32); + expect(line).toBe(` ${name} Require no merge conflicts before stopping`); + }); + + it("shortens a description to fit the terminal, but never the name", () => { + const k = screenKit({ cols: 40, color: false, fit: true }); + const [line] = k.rows([["failproofai policies show ", "See what a pack holds before installing it"]]); + expect(line.startsWith(" failproofai policies show ")).toBe(true); + const [short] = k.rows([["--wait", "Wait until everything is delivered to the cloud dashboard"]]); + expect(visibleWidth(short)).toBeLessThanOrEqual(40); + expect(short.endsWith("…")).toBe(true); + }); + + it("never shortens anything when fitting is off — piped output is never cut", () => { + const k = screenKit({ cols: 40, color: false }); + const [line] = k.rows([["--wait", "Wait until everything is delivered to the cloud dashboard"]]); + expect(line).toBe(" --wait Wait until everything is delivered to the cloud dashboard"); + }); + + it("states results, cautions and failures with one glyph and an inline fix", () => { + expect(plain.ok("Done, said in the past tense")).toBe("✓ Done, said in the past tense"); + expect(plain.ok("Published acme/guards", "in 2.1s")).toBe("✓ Published acme/guards in 2.1s"); + expect(plain.caution("Needs attention", "the fix")).toBe("▲ Needs attention · the fix"); + expect(plain.fail("Failed, said plainly", "the fix")).toBe("✕ Failed, said plainly · the fix"); + }); + + it("paints the state glyphs by role and every fix as a command", () => { + const k = withEnv(TRUECOLOR, () => screenKit({ color: true })); + expect(k.ok("x")).toContain(`\x1B[${MINT_24}m✓`); + expect(k.caution("x", "failproofai config")).toContain(`\x1B[${PINK_24}mfailproofai config`); + expect(k.fail("x")).toContain("\x1B[38;2;240;113;120m✕"); + }); + + it("joins key hints with a spaced dot", () => { + expect(plain.keys(["↑↓ move", "space toggle", "enter confirm"])).toBe( + "↑↓ move · space toggle · enter confirm", + ); + }); + + it("draws a progress bar: pink fill and a grey track in colour, a blank track without it", () => { + expect(plain.bar(10, 0.6)).toBe("━━━━━━ "); + const k = withEnv(TRUECOLOR, () => screenKit({ color: true })); + const painted = k.bar(10, 0.6); + expect(painted).toContain(`\x1B[${PINK_24}m━━━━━━\x1B[0m`); + expect(painted).toContain("\x1B[38;2;62;67;76m━━━━\x1B[0m"); + // No empty colour span at either end. + expect(k.bar(10, 0)).not.toContain(`\x1B[${PINK_24}m\x1B[0m`); + expect(k.bar(10, 1)).not.toContain("38;2;62;67;76"); + }); + + it("shows the logomark only where it fits, and never the retired ▮▮ one-liner", () => { + const art = screenKit({ cols: 80, color: false }).logo(); + expect(art).toHaveLength(10); + for (const line of art) expect(line.startsWith(" ")).toBe(true); + expect(art.join("")).not.toContain("\x1B"); + expect(screenKit({ cols: 21, color: false }).logo()).toEqual([]); + expect(art.join("")).not.toContain("▮"); + }); + + it("builds a command's --help from usage, options and examples, and nothing else", () => { + const page = plain.helpPage({ + name: "flush", + usage: [["failproofai flush [options]", "Send queued events to cloud now"]], + options: [ + ["--wait", "Wait until everything is delivered"], + ["--timeout ", "How long to wait (default 60)"], + ], + optionsCol: 20, + }); + expect(page).toEqual([ + "failproof ai v1.0.11 · flush", + "", + "USAGE", + " failproofai flush [options] Send queued events to cloud now", + "", + "OPTIONS", + // The reference's option column, exactly. + " --wait Wait until everything is delivered", + " --timeout How long to wait (default 60)", + ]); + }); + + it("emits no escape at all when colour is off", () => { + const all = [ + plain.header("x"), + plain.head("x", "m"), + ...plain.kv([["a", "b"]]), + ...plain.rows([["a", "b"]]), + plain.ok("x", "d"), + plain.caution("x", "f"), + plain.fail("x", "f"), + plain.keys(["a"]), + plain.bar(5, 0.5), + plain.on, + plain.off, + plain.cmd("failproofai config"), + ].join("\n"); + expect(all).not.toContain("\x1B"); + }); + + it("defaults the header's version to this build's", async () => { + const { version } = await import("../../package.json"); + expect(screenKit().header()).toBe(`failproof ai v${version}`); + }); +}); diff --git a/src/hooks/tui.ts b/src/hooks/tui.ts index 897a63ec6..95a36c2f2 100644 --- a/src/hooks/tui.ts +++ b/src/hooks/tui.ts @@ -18,6 +18,7 @@ * 24-bit, the xterm-256 cube, or the 16 basic ANSI hues. */ import * as readline from "node:readline"; +import { version as cliVersion } from "../../package.json"; export type TTYIn = NodeJS.ReadableStream & { isTTY?: boolean; @@ -1755,3 +1756,141 @@ export function helpScreen(spec: HelpScreenSpec, opts?: RenderOpts): string[] { if (spec.footer?.length) groups.push(spec.footer.map((l) => `${INDENT}${c.dim(l)}`)); return stack(...groups); } + +// ── the 2026-10 screen language ───────────────────────────────────────────── +/** + * The redesign's building blocks: open text on the terminal — the wordmark + * header, UPPERCASE headings at column 0, rows indented two, colour by role, and + * no boxes or rules. The byte-level target is `reference/screens.js` → + * `FP.primitives` in the design handoff; the bytes differ only where the tiers + * above say how a colour is spelled. + * + * Commands, URLs, paths and ids are never cut: they are the part a person + * copies, and half of one runs the wrong thing. Prose may be shortened with `…` + * when `fit` is on, which a caller sets only for a terminal — piped output is + * never cut. + * + * ONE exported factory rather than a dozen functions, on purpose. The CLI ships + * as a single scope-hoisted bundle, and a new top-level name that matches a + * local inside a builtin policy or an audit detector (`cmd`, `row`, `head`, …) + * makes the bundler rename that local. That changes the function source the + * audit cache keys on (`engineVersion` / `detectorVersion`), and every user's + * history is rescanned from zero at upgrade. Everything here lives inside the + * factory, so the bundle gains exactly one name. + */ +export interface ScreenKitOpts extends RenderOpts { + /** The version the header shows. Defaults to this build's. */ + version?: string; + /** Shorten prose that does not fit `cols`, ending on `…`. Terminal only. */ + fit?: boolean; +} + +export function screenKit(opts: ScreenKitOpts = {}) { + const { cols, c } = ctx(opts); + const colour = opts.color ?? false; + const version = opts.version ?? cliVersion; + const sep = c.ink3(" · "); + // Never paint nothing: an empty span is two escapes with no text between. + const tint = (paintFn: (s: string) => string, s: string): string => (s ? paintFn(s) : ""); + + /** Typeable: every command on a screen is pink. */ + const cmd = (s: string): string => c.pink(s); + const on = c.guide(RADIO_ON); + const off = c.ink3(RADIO_OFF); + + /** Prose shortened to `room` columns when fitting is on; untouched otherwise. */ + const prose = (s: string, room: number): string => + opts.fit && visibleWidth(s) > room ? fit(s, Math.max(1, room)) : s; + + /** `failproof ai v1.0.11 · Context` — the line every screen opens with. */ + const header = (context?: string): string => + `${c.bold("fa")}${c.pinkBold("il")}${c.bold("proof ai")} ${c.ink3(`v${version}`)}` + + (context ? `${sep}${context}` : ""); + + /** An UPPERCASE heading, with optional grey meta after it. */ + const head = (text: string, meta?: string): string => + c.bold(text.toUpperCase()) + (meta ? ` ${c.ink3(meta)}` : ""); + + /** + * `name description` rows on one column for the block: at least `minCol` + * wide, and always two spaces past the widest name. The design fixes its + * columns by hand, and the longest real policy name is exactly the 32 it + * fixes, so a fixed column runs that name into its description. Names are + * never cut; descriptions are prose and give up the room. + */ + const rows = (items: Array<[string, string?]>, minCol = 0): string[] => { + const width = Math.max(minCol, ...items.map(([name, desc]) => (desc ? visibleWidth(name) + 2 : 0))); + return items.map(([name, desc]) => { + if (!desc) return `${INDENT}${name}`; + const lead = `${INDENT}${pad(name, width)}`; + return lead + prose(c.ink2(desc), cols - visibleWidth(lead)); + }); + }; + + /** + * Grey labels and their values, on one column at least nine wide — where the + * reference starts its values. Values are never cut: they are URLs, ids and + * counts, and a line that outgrows the terminal is left for it to wrap. + */ + const kv = (items: Array<[string, string]>, minLabel = 9): string[] => { + const width = Math.max(minLabel, ...items.map(([label]) => visibleWidth(label))) + 2; + return items.map(([label, value]) => + value ? `${INDENT}${pad(c.ink3(label), width)}${value}` : `${INDENT}${c.ink3(label)}`, + ); + }; + + /** `✓ Done, said in the past tense.` with optional grey detail. */ + const ok = (text: string, detail?: string): string => + `${c.guide("✓")} ${text}${detail ? ` ${c.ink3(detail)}` : ""}`; + + /** `▲ Needs attention. · the fix` */ + const caution = (text: string, fix?: string): string => + `${c.warn("▲")} ${text}${fix ? `${sep}${cmd(fix)}` : ""}`; + + /** `✕ Failed, said plainly. · the fix` */ + const fail = (text: string, fix?: string): string => + `${c.err("✕")} ${text}${fix ? `${sep}${cmd(fix)}` : ""}`; + + /** Lowercase key hints at the foot of an interactive screen. */ + const keys = (list: string[]): string => c.ink3(list.join(" · ")); + + /** + * A progress bar `width` cells wide and `fraction` full: the filled part pink, + * the rest the track grey. With colour off the rest is blank, because one + * unbroken run of `━` would show no progress at all. + */ + const bar = (width: number, fraction: number): string => { + const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width); + const rest = Math.max(0, width - filled); + return tint(c.pink, "━".repeat(filled)) + (colour ? tint(c.track, "━".repeat(rest)) : " ".repeat(rest)); + }; + + /** + * The logomark with a two-space margin, or nothing on a terminal too narrow + * to draw it — the old `▮▮` one-liner is retired with the rest of that mark. + * Callers show it only on a TTY, and only on bare `failproofai` and at the + * start of a `config` run. + */ + const logo = (): string[] => { + if (cols < LOGO_MIN_COLS) return []; + const tier: ColorTier = colour ? colorTier() : "basic"; + return renderLogo(tier).map((line) => `${INDENT}${line}`); + }; + + /** A command's `--help`: usage, options and examples. Nothing else. */ + const helpPage = (spec: { + name: string; + usage: Array<[string, string?]>; + options?: Array<[string, string]>; + examples?: string[]; + /** The options column, when the design fixes a wider one than the data needs. */ + optionsCol?: number; + }): string[] => { + const out = [header(spec.name), "", head("Usage"), ...rows(spec.usage)]; + if (spec.options?.length) out.push("", head("Options"), ...rows(spec.options, spec.optionsCol ?? 0)); + if (spec.examples?.length) out.push("", head("Examples"), ...spec.examples.map((e) => `${INDENT}${cmd(e)}`)); + return out; + }; + + return { cols, sep, cmd, on, off, header, head, rows, kv, ok, caution, fail, keys, bar, logo, helpPage }; +} From fe691049171167c0866115b20cb71e0ce892cae7 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Sun, 11 Oct 2026 02:57:15 +0530 Subject: [PATCH 03/66] feat(config): remember which agents failproofai traces Step 6a of the TUI redesign handoff. config.json gains an optional `agents: { selected, seen }`: the remembered answer to the new setup's "which agents should failproofai trace?". `selected` will drive hooks, collection and backfill. `seen` is what was detected at the last setup, so a newly installed agent can be offered as new. - Absent means today's behaviour, every agent. writeConfig emits nothing in that case, so an untouched config file keeps its exact bytes, and a garbage value reads as absent. A broken file can never quietly switch tracing off. - updateConfig carries the selection forward unless a patch replaces it (`null` clears it). Every mutation path goes through it, the wizard's own connect step among them, and an unrelated write that dropped the key would silently turn every unticked agent back on. - The ids are kept as written, minus non-strings and repeats. The daemon owns the list of real agents and warns about an unknown one. - isAgentTraced(id) is the one predicate per-agent surfaces ask. Audit cache keys unchanged (engineVersion 10c367f637740d67, detectorVersion 895c2fedece85e93). --- __tests__/hooks/config-round-trip.test.ts | 60 ++++++++++++++++++++++ src/hooks/fp-config.ts | 62 +++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/__tests__/hooks/config-round-trip.test.ts b/__tests__/hooks/config-round-trip.test.ts index d76648f9a..5880dc904 100644 --- a/__tests__/hooks/config-round-trip.test.ts +++ b/__tests__/hooks/config-round-trip.test.ts @@ -28,6 +28,7 @@ import { readCredentialsRaw, writeCredentials, DEFAULT_CONFIG, + isAgentTraced, } from "../../src/hooks/fp-config"; let home: string; @@ -288,3 +289,62 @@ describe("credentials.json preserves keys this build does not own", () => { expect(credentials.cloud?.token).toBe("t"); }); }); + + +describe("config.json agents: the remembered answer to \"which agents should failproofai trace?\"", () => { + it("is absent on a machine that never chose, and writes no key — absent means every agent", () => { + writeConfig(structuredClone(DEFAULT_CONFIG), {}); + expect(readFile(configFile()).agents).toBeUndefined(); + expect(readConfig().agents).toBeUndefined(); + expect(isAgentTraced("goose")).toBe(true); + }); + + it("round-trips selected and seen", () => { + writeConfig({ ...structuredClone(DEFAULT_CONFIG), agents: { selected: ["claude", "codex"], seen: ["claude", "codex", "goose"] } }, {}); + expect(readFile(configFile()).agents).toEqual({ selected: ["claude", "codex"], seen: ["claude", "codex", "goose"] }); + expect(readConfig().agents).toEqual({ selected: ["claude", "codex"], seen: ["claude", "codex", "goose"] }); + }); + + it("survives every unrelated update — the wizard's own connect step writes twice after it", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: ["claude", "goose"] } }); + updateConfig({ mode: "cloud" }); + updateConfig({ collector: { sessions: true } }); + expect(readFile(configFile()).agents).toEqual({ selected: ["claude"], seen: ["claude", "goose"] }); + }); + + it("is replaced by a patch that names it and removed by null, pruning the table", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: ["claude"] } }); + updateConfig({ agents: { selected: ["codex"], seen: ["claude", "codex"] } }); + expect(readFile(configFile()).agents).toEqual({ selected: ["codex"], seen: ["claude", "codex"] }); + updateConfig({ agents: null }); + expect(readFile(configFile()).agents).toBeUndefined(); + expect(readConfig().agents).toBeUndefined(); + }); + + it("keeps an unknown sibling a newer build wrote inside the agents table", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: [], future_knob: 1 } }); + updateConfig({ agents: { selected: ["claude", "codex"], seen: ["claude", "codex"] } }); + expect(readFile(configFile()).agents.future_knob).toBe(1); + }); + + it("reads a garbage value as no selection, so a broken file never switches tracing off", () => { + for (const agents of ["all", ["claude"], { selected: "claude" }, { seen: ["claude"] }, null]) { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents }); + expect(readConfig().agents).toBeUndefined(); + expect(isAgentTraced("claude")).toBe(true); + } + }); + + it("keeps the names as written — the daemon is the one that warns about an unknown id — minus non-strings and repeats", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude", 3, "claude", "", "future-agent"], seen: "x" } }); + expect(readConfig().agents).toEqual({ selected: ["claude", "future-agent"], seen: [] }); + }); + + it("answers isAgentTraced from the selection once there is one", () => { + seedConfig({ mode: { kind: "oss" }, audit: { auto: false, interval_days: 7 }, agents: { selected: ["claude"], seen: ["claude", "goose"] } }); + expect(isAgentTraced("claude")).toBe(true); + expect(isAgentTraced("goose")).toBe(false); + // An empty selection is a real choice, not "no selection". + expect(isAgentTraced("claude", { ...readConfig(), agents: { selected: [], seen: [] } })).toBe(false); + }); +}); diff --git a/src/hooks/fp-config.ts b/src/hooks/fp-config.ts index d9ffc9e5a..41ec050a4 100644 --- a/src/hooks/fp-config.ts +++ b/src/hooks/fp-config.ts @@ -368,6 +368,23 @@ export interface FpConfig { /** Days between scheduled runs. Wall clock, so it survives suspend. */ intervalDays: number; }; + /** + * Which agents failproofai traces: the remembered answer to `failproofai + * config`'s "which agents should failproofai trace?". + * + * `selected` drives every per-agent thing at once — which CLIs get hooks, + * which harnesses the daemon's collector reads, and which `backfill` re-sends. + * `seen` is every agent that was detected at the last setup, which is how a + * newly installed agent is recognised and offered as "new on this machine". + * Ids are the integration ids (`claude`, `codex`, …). + * + * ABSENT means today's behaviour, everything, and that is what every machine + * set up before this existed has. So `writeConfig` emits nothing in that case, + * and the daemon — which reads `agents.selected` from this same file — keeps + * collecting every harness. The direction matters: an unreadable or missing + * selection must never quietly switch tracing OFF. + */ + agents?: { selected: string[]; seen: string[] }; } /** @@ -443,6 +460,26 @@ function readSources(raw: unknown): FpConfig["collector"]["sources"] { return Object.keys(out).length > 0 ? out : undefined; } +/** + * `agents` → the in-memory shape. Kept as written (strings only, de-duplicated + * in order): the daemon owns the list of real agent ids and warns about an + * unknown one, so dropping it here would hide that warning behind a value the + * CLI had already rewritten — the same reasoning as {@link readSources}. + * + * `undefined` unless `selected` is an array, because absent means everything + * and a garbage value must fail in that direction too. + */ +function readAgents(raw: unknown): FpConfig["agents"] { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const ids = (v: unknown): string[] => + Array.isArray(v) + ? [...new Set(v.filter((e): e is string => typeof e === "string" && e.trim() !== ""))] + : []; + const table = raw as Record; + if (!Array.isArray(table.selected)) return undefined; + return { selected: ids(table.selected), seen: ids(table.seen) }; +} + /** * {@link readConfig}, plus the raw object it was projected from. * @@ -552,6 +589,7 @@ export function projectConfig(parsed: Record): FpConfig { ? audit.reports_consented_at : undefined, }, + agents: readAgents(parsed.agents), // Same shape as `audit.auto` above and for the same reason: only an // explicit `true` opts in. Anything else — absent, misspelled, `"yes"` — // reads as off, because the failure direction is a machine that starts @@ -598,6 +636,8 @@ const OWNED_CONFIG_KEYS: readonly (readonly string[])[] = [ ["audit", "auto"], ["audit", "interval_days"], ["audit", "reports_consented_at"], + ["agents", "selected"], + ["agents", "seen"], ]; const isPlainObject = (v: unknown): v is Record => @@ -708,6 +748,12 @@ export function writeConfig(config: FpConfig, raw?: Record): vo ? {} : { reports_consented_at: config.audit.reportsConsentedAt }), }, + // Written ONLY when a selection exists — absent means "every agent", and a + // machine that never chose keeps a file byte-identical to what this + // function wrote before the field existed. + ...(config.agents + ? { agents: { selected: config.agents.selected, seen: config.agents.seen } } + : {}), }; // Start from the previous bytes, strip the keys this build owns — so an // omission above really removes — then lay the projection on top. What is left @@ -726,6 +772,8 @@ export function updateConfig(patch: { collector?: Partial; telemetry?: Partial; audit?: Partial; + /** Replace the agent selection; `null` removes it (back to "every agent"). */ + agents?: FpConfig["agents"] | null; }): FpConfig { // `readConfigRaw`, not `readConfig` — this is the function EVERY mutation path // goes through (the wizard, `harness add-path`, `healDaemonFlag`, the telemetry @@ -738,11 +786,25 @@ export function updateConfig(patch: { collector: { ...current.collector, ...patch.collector }, telemetry: { ...current.telemetry, ...patch.telemetry }, audit: { ...current.audit, ...patch.audit }, + // Carried forward when the patch does not name it. Every other mutation + // path goes through here — the wizard's own connect step among them — and + // a selection dropped by an unrelated write would silently turn tracing + // back on for every agent the user switched off. + agents: patch.agents === undefined ? current.agents : (patch.agents ?? undefined), }; writeConfig(next, raw); return next; } +/** + * Whether failproofai traces this agent: hooked, collected and backfilled. + * True for every agent when no selection has been saved, which is today's + * behaviour and what every machine set up before the selection existed has. + */ +export function isAgentTraced(id: string, config: FpConfig = readConfig()): boolean { + return !config.agents || config.agents.selected.includes(id); +} + // ── credentials.json ───────────────────────────────────────────────────────── export interface FpCredentials { From 78825ee4b184ec3b3d65e0e686a1a8b99119e99b Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Sun, 11 Oct 2026 03:02:05 +0530 Subject: [PATCH 04/66] feat(policies): one shared answer to "is this machine enforcing?" Groundwork for the redesigned policies and launch screens, which both warn when policies are not enforcing. One predicate, notEnforcingReason(), answers for both so they can never disagree. It returns "no-hooks" (no agent calls failproofai), "no-policies" (wired, nothing on, counting policies switched off after install) or "observe-only", and null when something enforces. It reads files only, so it is cheap enough for the launch path: installed packs, cloud-managed policies, configured custom policy files, convention files on disk (counted, never imported) and the legacy migration shim. Deliberately not causes, because each needs its own words: - A refused pack and a down daemon on a daemon-configured machine fail closed and DENY calls. "Not enforcing" would say the opposite of what is happening. - A session pause belongs to one session, not to the machine. Audit cache keys unchanged. --- __tests__/hooks/not-enforcing.test.ts | 125 ++++++++++++++++++++++++++ src/hooks/manager.ts | 68 ++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 __tests__/hooks/not-enforcing.test.ts diff --git a/__tests__/hooks/not-enforcing.test.ts b/__tests__/hooks/not-enforcing.test.ts new file mode 100644 index 000000000..e7a86cfad --- /dev/null +++ b/__tests__/hooks/not-enforcing.test.ts @@ -0,0 +1,125 @@ +// @vitest-environment node +/** + * `notEnforcingReason` — the one answer to "are the policies on this machine + * actually enforcing?", shared by the policies listing and the launch screen so + * the two can never disagree. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ARTIFACT = "export const hooks = [];\n"; +const DIGEST = createHash("sha256").update(ARTIFACT).digest("hex"); + +let home: string; +let project: string; +let packRoot: string; +let saved: Record; + +function wireClaude(): void { + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync( + join(home, ".claude", "settings.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: "*", hooks: [{ type: "command", command: "npx -y failproofai --hook PreToolUse", __failproofai_hook__: true }] }, + ], + }, + }), + ); +} + +function installPack(over: Record = {}): void { + writeFileSync( + join(packRoot, "installed.json"), + JSON.stringify({ + schemaVersion: 1, + packs: [ + { + id: "acme/finance", + version: "1.2.0", + source: "github:acme/finance@v1.2.0", + entry: `artifacts/${DIGEST}.mjs`, + sha256: DIGEST, + policies: [ + { name: "block-big-refund", description: "Block big refunds", category: "Finance", defaultEnabled: true, match: {} }, + { name: "require-note", description: "Require a note", category: "Finance", defaultEnabled: true, match: {} }, + ], + ...over, + }, + ], + }), + ); +} + +async function reason() { + const { notEnforcingReason } = await import("@/src/hooks/manager"); + return notEnforcingReason(project); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "fpai-enforcing-home-")); + project = mkdtempSync(join(tmpdir(), "fpai-enforcing-proj-")); + packRoot = mkdtempSync(join(tmpdir(), "fpai-enforcing-packs-")); + mkdirSync(join(packRoot, "artifacts"), { recursive: true }); + writeFileSync(join(packRoot, "artifacts", `${DIGEST}.mjs`), ARTIFACT); + saved = { FAILPROOFAI_HOME: process.env.FAILPROOFAI_HOME, FAILPROOFAI_PACK_DIR: process.env.FAILPROOFAI_PACK_DIR }; + process.env.FAILPROOFAI_HOME = home; + process.env.FAILPROOFAI_PACK_DIR = packRoot; + // Agent settings resolve from the OS home, not FAILPROOFAI_HOME. + vi.stubEnv("HOME", home); + vi.stubEnv("USERPROFILE", home); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + for (const dir of [home, project, packRoot]) rmSync(dir, { recursive: true, force: true }); +}); + +describe("notEnforcingReason", () => { + it("says no agent calls failproofai when nothing is wired, whatever is installed", async () => { + installPack(); + expect(await reason()).toBe("no-hooks"); + }); + + it("says no policies when an agent is wired but nothing is switched on", async () => { + wireClaude(); + expect(await reason()).toBe("no-policies"); + }); + + it("is null once a wired machine has a pack policy on", async () => { + wireClaude(); + installPack(); + expect(await reason()).toBeNull(); + }); + + it("calls an observe-only pack observe-only, never enforcing", async () => { + wireClaude(); + installPack({ effect: "observe" }); + expect(await reason()).toBe("observe-only"); + }); + + it("counts a policy the user switched off after install as off", async () => { + wireClaude(); + installPack({ enabled: ["block-big-refund"] }); + writeFileSync( + join(home, "policies-config.json"), + JSON.stringify({ enabledPolicies: [], disabledCustomPolicies: ["pack:acme/finance@1.2.0:block-big-refund"] }), + ); + expect(await reason()).toBe("no-policies"); + }); + + it("counts convention policy files on disk without importing them", async () => { + wireClaude(); + mkdirSync(join(project, ".failproofai", "policies"), { recursive: true }); + writeFileSync(join(project, ".failproofai", "policies", "team-policies.mjs"), "throw new Error('must not be imported');\n"); + expect(await reason()).toBeNull(); + }); +}); diff --git a/src/hooks/manager.ts b/src/hooks/manager.ts index 322975be3..e2d4baff8 100644 --- a/src/hooks/manager.ts +++ b/src/hooks/manager.ts @@ -319,6 +319,74 @@ export function integrationsInstalledAt(scope: HookScope, cwd?: string): Integra }); } +/** + * Why the policies on this machine are not enforcing, or `null` when they are. + * + * ONE answer for every screen that warns about it — `failproofai policies`, the + * dashboard launch screen — so two screens can never disagree about whether + * this machine is protected. The redesign shows one generic warning whatever + * the cause (decision D18); the cause is returned anyway, for tests and so a + * screen that wants the specific fix can have it. + * + * Cheap enough for the launch path on purpose: it reads files and imports no + * policy file and probes no daemon. + * + * Deliberately NOT causes, because each needs its own words: + * - A refused pack and a down daemon on a daemon-configured machine. Both + * FAIL CLOSED: they DENY tool calls rather than let them through, so "not + * enforcing" would tell the user the opposite of what is happening. + * - A session pause. It belongs to one session, not to the machine, and + * `config --status` lists it. + */ +export type NotEnforcingReason = "no-hooks" | "no-policies" | "observe-only"; + +export function notEnforcingReason(cwd?: string): NotEnforcingReason | null { + const wired = deduplicateScopes(HOOK_SCOPES, cwd).some((scope) => hooksInstalledInSettings(scope, cwd)); + if (!wired) return "no-hooks"; + + const config = readMergedHooksConfig(cwd); + const disabled = new Set(config.disabledCustomPolicies ?? []); + let observing = false; + + // Installed packs: a policy enforces when its pack is not observe-only, it + // was taken at install, and nobody switched it off afterwards. + try { + for (const pack of readInstalledPacks().packs) { + const taken = pack.enabled ?? pack.policies.map((policy) => policy.name); + const on = taken.filter((name) => !disabled.has(`pack:${pack.id}@${pack.version}:${name}`)); + if (on.length === 0) continue; + if (pack.effect === "observe") observing = true; + else return null; + } + } catch { + // An unreadable manifest is reported by the listing itself. + } + + // Cloud-managed policies, pushed from a deployment. + try { + for (const artifact of readActiveCloudManagedPolicies()) { + if (artifact.effect === "observe") observing = true; + else return null; + } + } catch { + // No deployment, or an unreadable one: nothing enforces from there. + } + + // A configured custom policy file, or convention files on disk. Loading them + // to ask what they hold would import user code on every launch, so their + // presence counts: the listing below reports a file that fails to load. + if (configuredCustomPolicyPaths(config).length > 0) return null; + const projectDir = resolve(findProjectConfigDir(cwd ?? process.cwd()), ".failproofai", "policies"); + if (discoverPolicyFiles(projectDir).length > 0 || discoverPolicyFiles(customPoliciesDir()).length > 0) { + return null; + } + + // A machine still on the pre-pack migration shim runs its legacy builtins. + if (config.enabledPolicies.length > 0 && !hasInstalledRegexPacks()) return null; + + return observing ? "observe-only" : "no-policies"; +} + function safeSettingsPath( integration: Integration, scope: HookScope, From f3fc4191d3a63064e953ee0552e21d7197a8f8c7 Mon Sep 17 00:00:00 2001 From: chhhee10 Date: Sun, 11 Oct 2026 03:15:00 +0530 Subject: [PATCH 05/66] feat(tui): pickers in the 2026-10 screen language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 5 of the TUI redesign handoff. selectOne and multiSelect are drawn like the design's policiesAdd and agentPicker screens: - an UPPERCASE heading with grey meta, by default a live "n of m selected" that updates on every toggle - a pink `›` cursor gutter and a bold cursor row; ■ ticked and □ not - bold section names, `↑/↓ n more` when the list scrolls - the key hints at the foot Answered prompts collapse to the heading and `✓ `, or to the caller's own lines. The spine glyphs are gone from both. - The window is 12 rows, not 8. Twelve reproduces every frame in the design; eight cannot show the nine agents of its setup screen. The WINDOW identifier itself is kept, because the audit cache keys on bundled names. - Plain `a` toggles all, as the design's hints say. ctrl+a stays as an alias. - New opt-in options for the config flow's agents step: a meta function, per-row tags recomputed on every toggle, the ✕ message for minSelected, and a caller-defined collapsed form. selectOne gains `meta`, `collapsed` and `spaceSelects`. - The `policies -i` / `--install` / `--uninstall` agent menu was install-prompt.ts's own raw-mode loop, with a two-write (non-atomic) repaint and a colour gate that ignored pipes. It now uses selectOne. It keeps its "not installed" section of forward installs, Space still picks and Esc still exits 130. Its aggregate row reads "All N agents", as the design spells it. - The two raw yellow warnings in the same file are now ▲ lines through the palette, so they respect NO_COLOR and pipes, and they drop a stale six-agent list. - install-prompt.ts's searchable policy picker had no caller since `--install` stopped choosing policies. It is deleted with its tests (approved), and the manager tests keep their real assertion: setup writes the policy selection back unchanged. - The pack pickers in pack-cli.ts take the heading meta instead of hand-written hint strings. The pack picker is preceded by the design's `failproof ai v… · Add ` header, the per-pack agent picker shows display names, and Jev checks are still stated before the list. Audit cache keys unchanged (engineVersion 10c367f637740d67, detectorVersion 895c2fedece85e93). Full unit suite green. --- CHANGELOG.md | 1 + __tests__/hooks/install-prompt.test.ts | 82 +--- __tests__/hooks/manager.test.ts | 10 - __tests__/hooks/new-telemetry.test.ts | 10 - __tests__/hooks/tui-kit.test.ts | 123 +++++- src/hooks/install-prompt.ts | 515 ++----------------------- src/hooks/pack-cli.ts | 28 +- src/hooks/tui.ts | 153 +++++--- 8 files changed, 286 insertions(+), 636 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51707823b..b1c4cde0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Pickers in the new look: an UPPERCASE heading with a live "n of m selected", a `›` cursor and `■ □` boxes, twelve rows before scrolling, plain `a` to toggle all, and the key hints at the foot. The `policies -i` agent menu now uses the shared picker (#PR) - New terminal look for the CLI, step one: the palette gains the redesign's label grey, progress-track grey and a red for failures. Descriptions keep the terminal's own colour, and the greys fall back to the dim attribute below 24-bit, so every screen stays readable on light themes (#PR) ## 1.0.10 — 2026-10-05 diff --git a/__tests__/hooks/install-prompt.test.ts b/__tests__/hooks/install-prompt.test.ts index 1559a6bab..efc5dec1f 100644 --- a/__tests__/hooks/install-prompt.test.ts +++ b/__tests__/hooks/install-prompt.test.ts @@ -13,80 +13,6 @@ describe("hooks/install-prompt", () => { vi.restoreAllMocks(); }); - /** - * `manager.ts` writes whatever this returns straight into `enabledPolicies`, - * and then prints only what survived — so anything this function drops is - * configuration destroyed with nothing on screen to say so. - */ - describe("never drops a configured policy name it does not recognise", () => { - const nonTty = () => - Object.defineProperty(process.stdin, "isTTY", { value: false, writable: true, configurable: true }); - - it("carries qualified, beta and pack names through the non-TTY path", async () => { - nonTty(); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - - const configured = [ - "block-sudo", - // A form the ENFORCEMENT path explicitly accepts — - // `registerBuiltinPolicies` canonicalizes both spellings — yet the - // catalog is keyed by the bare name, so an intersection deleted it. - "failproofai/block-sudo", - "pack/acme/finance@1.2.0/block-refunds", - "some-policy-this-build-has-never-heard-of", - ]; - - expect(await promptPolicySelection(configured)).toEqual(configured); - }); - - it("still returns the defaults when nothing was configured", async () => { - // The path every fresh install takes must be unchanged. - nonTty(); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - const { BUILTIN_POLICIES } = await import("../../src/hooks/builtin-policies"); - - const expected = BUILTIN_POLICIES.filter((p) => p.defaultEnabled && !p.beta).map((p) => p.name); - expect(await promptPolicySelection()).toEqual(expected); - }); - }); - - it("returns default-enabled policies when stdin is not a TTY", async () => { - Object.defineProperty(process.stdin, "isTTY", { - value: false, - writable: true, - configurable: true, - }); - - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - const selected = await promptPolicySelection(); - - expect(selected).toContain("sanitize-jwt"); - expect(selected).toContain("protect-env-vars"); - expect(selected).toContain("block-env-files"); - expect(selected).toContain("block-sudo"); - expect(selected).toContain("block-curl-pipe-sh"); - expect(selected).toContain("block-push-master"); - expect(selected).toContain("block-failproofai-commands"); - expect(selected).not.toContain("block-rm-rf"); - expect(selected).not.toContain("block-force-push"); - expect(selected).not.toContain("block-secrets-write"); - // 12 before `block-self-pause` merged into `block-failproofai-commands`. - expect(selected).toHaveLength(11); - }); - - it("returns preSelected when stdin is not a TTY and preSelected is provided", async () => { - Object.defineProperty(process.stdin, "isTTY", { - value: false, - writable: true, - configurable: true, - }); - - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); - const selected = await promptPolicySelection(["block-sudo", "block-rm-rf"]); - - expect(selected).toEqual(["block-sudo", "block-rm-rf"]); - }); - describe("resolveTargetClis", () => { it("returns explicit cli list as-is regardless of action", async () => { const { resolveTargetClis } = await import("../../src/hooks/install-prompt"); @@ -184,7 +110,7 @@ describe("hooks/install-prompt", () => { expect(undetected).toEqual(["copilot", "cursor", "opencode", "pi", "hermes", "openclaw", "factory", "devin", "antigravity", "goose"]); expect(options[0]).toMatchObject({ isAll: true, detected: true, value: ["claude", "codex"] }); - expect(options[0].label).toBe("Install for all 2 detected"); + expect(options[0].label).toBe("All 2 agents"); // Detected rows preserve order and carry detected=true expect(options.slice(1, 3)).toEqual([ @@ -209,7 +135,7 @@ describe("hooks/install-prompt", () => { ]); }); - it("uninstall action: only detected rows, no undetected (and verb is 'Remove from')", async () => { + it("uninstall action: only detected rows, no undetected", async () => { const { buildCliMenuOptions } = await import("../../src/hooks/install-prompt"); const { options, undetected } = buildCliMenuOptions( ["claude", "codex", "copilot"], @@ -218,7 +144,7 @@ describe("hooks/install-prompt", () => { expect(undetected).toEqual([]); expect(options).toHaveLength(4); // 1 aggregate + 3 detected - expect(options[0].label).toBe("Remove from all 3 detected"); + expect(options[0].label).toBe("All 3 agents"); expect(options.every((o) => o.detected)).toBe(true); }); @@ -231,7 +157,7 @@ describe("hooks/install-prompt", () => { expect(undetected).toEqual([]); expect(options).toHaveLength(13); // aggregate + 12 detected - expect(options[0].label).toBe("Install for all 12 detected"); + expect(options[0].label).toBe("All 12 agents"); }); it("install with 1 detected + many undetected: skips aggregate row (1 ≯ 1)", async () => { diff --git a/__tests__/hooks/manager.test.ts b/__tests__/hooks/manager.test.ts index 2139903d3..87b3ae864 100644 --- a/__tests__/hooks/manager.test.ts +++ b/__tests__/hooks/manager.test.ts @@ -33,12 +33,6 @@ vi.mock("../../src/hooks/safe-config-write", async () => { }; }); -vi.mock("../../src/hooks/install-prompt", () => ({ - promptPolicySelection: vi.fn(() => - Promise.resolve(["block-sudo", "block-env-files", "sanitize-jwt"]), - ), -})); - vi.mock("../../src/hooks/hooks-config", () => ({ readHooksConfig: vi.fn(() => ({ enabledPolicies: [] })), // listHooks resolves the project root by walking up to the nearest @@ -194,12 +188,10 @@ describe("hooks/manager", () => { vi.mocked(readScopedHooksConfig).mockReturnValue({ enabledPolicies: ["block-sudo"] }); const { installHooks } = await import("../../src/hooks/manager"); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); const { writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); await installHooks(); - expect(promptPolicySelection).not.toHaveBeenCalled(); // Written back UNCHANGED — not re-derived, not defaulted, not widened. expect(writeScopedHooksConfig).toHaveBeenCalledWith( { enabledPolicies: ["block-sudo"] }, @@ -214,11 +206,9 @@ describe("hooks/manager", () => { const { installHooks } = await import("../../src/hooks/manager"); const { writeScopedHooksConfig } = await import("../../src/hooks/hooks-config"); - const { promptPolicySelection } = await import("../../src/hooks/install-prompt"); await installHooks(["all"]); - expect(promptPolicySelection).not.toHaveBeenCalled(); expect(writeScopedHooksConfig).toHaveBeenCalledWith( { enabledPolicies: expect.arrayContaining(["block-sudo", "block-rm-rf", "sanitize-jwt"]) }, "user", diff --git a/__tests__/hooks/new-telemetry.test.ts b/__tests__/hooks/new-telemetry.test.ts index 96742e6e3..8a4ba1b4c 100644 --- a/__tests__/hooks/new-telemetry.test.ts +++ b/__tests__/hooks/new-telemetry.test.ts @@ -28,16 +28,6 @@ vi.mock("node:child_process", () => ({ execSync: vi.fn(), })); -vi.mock("../../src/hooks/install-prompt", async () => { - const actual = await vi.importActual( - "../../src/hooks/install-prompt", - ); - return { - ...actual, - promptPolicySelection: vi.fn(() => Promise.resolve(["block-sudo"])), - }; -}); - vi.mock("../../src/hooks/integrations", () => ({ detectInstalledClis: vi.fn(() => ["claude"]), getIntegration: vi.fn((id: string) => ({ diff --git a/__tests__/hooks/tui-kit.test.ts b/__tests__/hooks/tui-kit.test.ts index db4f12cce..d434f1e55 100644 --- a/__tests__/hooks/tui-kit.test.ts +++ b/__tests__/hooks/tui-kit.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest"; import { type TTYIn, multiSelect, + selectOne, INDENT, CHIP_WIDTH, brandAnsi, @@ -755,8 +756,9 @@ describe("a repaint is one atomic frame", () => { const written = await drawTwice(); // The cursor-up-and-clear must never arrive on its own — that lone write is // precisely the blank frame. + // Case-insensitive: the redesign prints the question as an UPPERCASE heading. const clearOnly = written.filter( - (c) => /\u001b\[\d+A\u001b\[J/.test(c) && !c.includes("pick"), + (c) => /\u001b\[\d+A\u001b\[J/.test(c) && !/pick/i.test(c), ); expect(clearOnly).toEqual([]); }); @@ -1066,3 +1068,122 @@ describe("screenKit — the 2026-10 building blocks", () => { expect(screenKit().header()).toBe(`failproof ai v${version}`); }); }); + + +describe("pickers in the 2026-10 language", () => { + const plainText = (s: string) => s.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, ""); + + /** Drive a prompt through a real PassThrough, sending `keys` after the first frame. */ + const drive = async (start: (io: { stdin: TTYIn; stdout: TTYOut }) => Promise, keys: string[], columns = 100) => { + const written: string[] = []; + const stdout = { isTTY: true, columns, write: (chunk: string) => { written.push(chunk); return true; } } as unknown as TTYOut; + const stdin = new PassThrough() as unknown as TTYIn & PassThrough; + (stdin as unknown as { isTTY: boolean }).isTTY = true; + (stdin as unknown as { setRawMode: (on: boolean) => void }).setRawMode = () => {}; + const pending = start({ stdin: stdin as unknown as TTYIn, stdout }); + for (const k of keys) stdin.write(k); + const result = await pending; + // Cursor show/hide go out as writes of their own, which strip to nothing. + const frames = written.map(plainText).filter((f) => f.trim() !== ""); + return { result, frames, last: frames[frames.length - 1] ?? "", all: frames.join("") }; + }; + + const POLICIES = ["block-sudo", "block-rm-rf", "block-env-files"].map((name, i) => ({ + label: name, + value: name, + hint: `Description ${i}`, + checked: i === 0, + })); + + it("draws the heading in UPPERCASE with a live count, a › cursor and ■ □ boxes", async () => { + const { frames } = await drive( + (io) => multiSelect({ message: "Choose policies", choices: POLICIES, ...io }), + ["\u001b"], + ); + const first = frames[0]; + expect(first).toContain("CHOOSE POLICIES 1 of 3 selected"); + expect(first).toContain(" › ■ block-sudo"); + expect(first).toContain(" □ block-rm-rf"); + expect(first).toContain("↑↓ move · space toggle · a all · enter confirm · esc cancel"); + // No spine glyphs from the old flow. + expect(first).not.toMatch(/[│◆◇❯◼◻]/); + }); + + it("toggles everything with a plain `a`, and keeps ctrl+a as an alias", async () => { + const plainA = await drive((io) => multiSelect({ message: "pick", choices: POLICIES, ...io }), ["a", "\r"]); + expect(plainA.result).toEqual(["block-sudo", "block-rm-rf", "block-env-files"]); + const ctrlA = await drive((io) => multiSelect({ message: "pick", choices: POLICIES, ...io }), ["\u0001", "\r"]); + expect(ctrlA.result).toEqual(["block-sudo", "block-rm-rf", "block-env-files"]); + }); + + it("says the caller's minimum with ✕ and stays put", async () => { + const none = POLICIES.map((p) => ({ ...p, checked: false })); + const { all } = await drive( + (io) => multiSelect({ message: "Which agents should failproofai trace?", choices: none, minSelected: 1, minMessage: "pick at least one agent", ...io }), + ["\r", "\u001b"], + ); + expect(all).toContain("✕ pick at least one agent"); + }); + + it("recomputes a row's tag on every toggle", async () => { + const { frames } = await drive( + (io) => + multiSelect({ + message: "pick", + choices: POLICIES, + tag: (value, on) => (value === "block-rm-rf" && on ? "will be added" : undefined), + ...io, + }), + ["\u001b[B", " ", "\u001b"], + ); + expect(frames[0]).not.toContain("will be added"); + expect(frames.some((f) => f.includes("block-rm-rf") && f.includes("will be added"))).toBe(true); + }); + + it("collapses to `✓ ` under the heading, or to the caller's own lines", async () => { + const plain = await drive((io) => multiSelect({ message: "Choose policies", choices: POLICIES, ...io }), ["\r"]); + expect(plain.last).toContain("CHOOSE POLICIES"); + expect(plain.last).toContain("✓ block-sudo"); + const custom = await drive( + (io) => multiSelect({ message: "Choose policies", choices: POLICIES, collapsed: (v) => ["AGENTS", `✓ Tracing ${v.length} agents`], ...io }), + ["\r"], + ); + expect(custom.last).toContain("✓ Tracing 1 agents"); + }); + + it("shows twelve rows before it scrolls, and says how many more are below", async () => { + const many = Array.from({ length: 15 }, (_, i) => ({ label: `policy-${i}`, value: `p${i}` })); + const { frames } = await drive((io) => multiSelect({ message: "pick", choices: many, ...io }), ["\u001b"]); + expect(frames[0]).toContain("policy-11"); + expect(frames[0]).not.toContain("policy-12"); + expect(frames[0]).toContain("↓ 3 more"); + }); + + it("selectOne: grey meta after the heading, a bold cursor row, and the redesign's key hints", async () => { + const { frames, result } = await drive( + (io) => + selectOne({ + message: "Choose agents", + meta: "9 found on this machine", + choices: [ + { label: "All 9 agents", value: "all" }, + { label: "Claude Code", value: "claude" }, + ], + ...io, + }), + ["\u001b[B", "\r"], + ); + expect(frames[0]).toContain("CHOOSE AGENTS 9 found on this machine"); + expect(frames[0]).toContain(" › All 9 agents"); + expect(frames[0]).toContain("↑↓ move · enter select · esc cancel"); + expect(result).toBe("claude"); + }); + + it("selectOne picks on space only when the caller asks for it", async () => { + const choices = [{ label: "A", value: "a" }, { label: "B", value: "b" }]; + const asked = await drive((io) => selectOne({ message: "pick", choices, spaceSelects: true, ...io }), [" "]); + expect(asked.result).toBe("a"); + const notAsked = await drive((io) => selectOne({ message: "pick", choices, ...io }), [" ", "\u001b"]); + expect(notAsked.result).toBeNull(); + }); +}); diff --git a/src/hooks/install-prompt.ts b/src/hooks/install-prompt.ts index b5ed4a8e1..9afd4181b 100644 --- a/src/hooks/install-prompt.ts +++ b/src/hooks/install-prompt.ts @@ -1,48 +1,19 @@ /** - * Interactive searchable multi-select prompt for choosing hook policies. - * Uses raw mode stdin with node:readline for keypress handling. - * No external dependencies. + * Which agent CLIs a `policies --install / --uninstall` acts on, asked with the + * shared `selectOne` when more than one is detected. * - * Rendering strategy: track line count, use cursor-up + clear-to-end-of-screen - * (\x1B[NA\x1B[J) to avoid flickering. Lines are truncated to terminal width to - * ensure lastLineCount stays accurate when the terminal is narrow. - * - * Keybindings: ↑↓ navigate · Space toggle · Ctrl+A all · Ctrl+S save · Esc clear search + * This file used to carry two hand-rolled raw-mode menus of its own: this one, + * with a two-write (non-atomic) repaint and an escape-gate that ignored pipes, + * and a searchable policy picker that nothing had called since `--install` + * stopped choosing policies. The picker is gone and the menu is `selectOne`, + * so it draws, windows and repaints like every other prompt in the CLI. */ -import * as readline from "node:readline"; -// Brand palette, ANSI-aware truncation, and glyphs shared with the configure -// wizard (tui.ts) so every failproofai menu reads as one system. -import { - paint, - truncate as truncateLine, - BAR, - STEP_ACTIVE as STEP, - STEP_DONE as DONE, - CHECK_ON as BOX_ON, - CHECK_OFF as BOX_OFF, -} from "./tui"; -import { BUILTIN_POLICIES } from "./builtin-policies"; +import { colorsEnabled, optsFor, screenKit, selectOne, type SelectChoice } from "./tui"; import { detectInstalledClis, getIntegration, listInstallableIds } from "./integrations"; import { type IntegrationType } from "./types"; import { trackHookEvent } from "./hook-telemetry"; import { getInstanceId } from "../../lib/telemetry-id"; -interface SelectItem { - name: string; - description: string; - category: string; - selected: boolean; - beta: boolean; -} - -type DisplayRow = - | { kind: "header"; category: string; enabledCount: number; totalCount: number } - | { kind: "item"; item: SelectItem; filteredIndex: number }; - -export interface PromptOptions { - includeBeta?: boolean; -} - /** Whether the prompt is being shown for an install or an uninstall flow. * Drives heading + hint text so `policies --uninstall` no longer says * "Install Hooks". */ @@ -93,15 +64,17 @@ export async function resolveTargetClis( // Uninstall flow: no agent CLIs detected — nothing to remove from. Default to // claude so removeHooks operates over Claude's scopes (no-op if no settings file). console.log( - "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent, opencode, pi)." + - "Defaulting to Claude Code; nothing will be removed if no settings file exists.\x1B[0m", + screenKit({ color: colorsEnabled(process.stdout) }).caution( + "No agent CLI was found on PATH, so this acts on Claude Code. Nothing is removed if it has no settings file.", + ), ); fireDetectionEvent(["claude"], "defaulted_to_claude"); return ["claude"]; } console.log( - "\x1B[33mWarning: no agent CLI binary found in PATH (claude, codex, copilot, cursor-agent, opencode, pi)." + - "Defaulting to Claude Code; hooks will activate when an agent is installed.\x1B[0m", + screenKit({ color: colorsEnabled(process.stdout) }).caution( + "No agent CLI was found on PATH, so hooks go into Claude Code. They take effect once it is installed.", + ), ); fireDetectionEvent(["claude"], "defaulted_to_claude"); return ["claude"]; @@ -159,9 +132,8 @@ export function buildCliMenuOptions( const options: CliMenuOption[] = []; if (detected.length > 1) { - const verb = action === "uninstall" ? "Remove from" : "Install for"; options.push({ - label: `${verb} all ${detected.length} detected`, + label: `All ${detected.length} agents`, value: detected, detected: true, isAll: true, @@ -187,446 +159,35 @@ export function buildCliMenuOptions( } /** - * Interactive arrow-key single-select for "install/remove for which CLI?" when - * multiple agent CLIs are detected. + * "Install / remove hooks for which agent?" when more than one is detected. * - * Layout: - * • DETECTED section: an "Install for all detected" option (only when >1 - * detected) followed by each detected CLI individually. - * • NOT INSTALLED section (install action only): each undetected CLI as a - * forward-install option, so users can prep hooks before adding the CLI. - * - * Cursor skips section headers — it only lands on selectable item rows. + * Detected agents first — with an "All N agents" row when there are several — + * then, for an install, every undetected agent as a forward install, so hooks + * can be in place before the agent is. Esc exits 130, as it always has: the + * caller has no "cancelled" path, and an install that quietly picked something + * would be worse. */ async function promptCliTargetSelection( detected: IntegrationType[], action: CliPromptAction = "install", ): Promise { - const { options, undetected } = buildCliMenuOptions(detected, action); - - type DisplayRow = - | { kind: "header"; title: string; hint?: string } - | { kind: "blank" } - | { kind: "item"; option: CliMenuOption; itemIndex: number }; - - function buildDisplayRows(): DisplayRow[] { - const rows: DisplayRow[] = []; - let itemIndex = 0; - - rows.push({ - kind: "header", - title: `Detected (${detected.length})`, - }); - for (const opt of options) { - if (opt.detected) { - rows.push({ kind: "item", option: opt, itemIndex: itemIndex++ }); - } - } - - if (undetected.length > 0) { - rows.push({ kind: "blank" }); - rows.push({ - kind: "header", - title: `Not installed (${undetected.length})`, - hint: "install hooks ahead of time", - }); - for (const opt of options) { - if (!opt.detected) { - rows.push({ kind: "item", option: opt, itemIndex: itemIndex++ }); - } - } - } - - return rows; - } - - let cursor = 0; - let lastLineCount = 0; - let cursorHidden = false; - - function hideCursor(): void { - if (!cursorHidden) { - process.stdout.write("\x1B[?25l"); - cursorHidden = true; - } - } - function showCursor(): void { - if (cursorHidden) { - process.stdout.write("\x1B[?25h"); - cursorHidden = false; - } - } - - // Shared brand painter from tui.ts (NO_COLOR-gated like the rest of this file). - const { dim, bold, guide: teal, pink, pinkBold } = paint(!process.env.NO_COLOR); - - const heading = - action === "uninstall" ? "Remove failproofai hooks" : "Install failproofai hooks"; - - function render(): void { - const cols = process.stdout.columns || 100; - hideCursor(); - - const g = dim(BAR); - const lines: string[] = [g, `${teal(STEP)} ${bold(heading)}`]; - - for (const row of buildDisplayRows()) { - if (row.kind === "blank") { - lines.push(g); - continue; - } - if (row.kind === "header") { - const hint = row.hint ? ` ${dim("· " + row.hint)}` : ""; - lines.push(`${g} ${dim(row.title)}${hint}`); - continue; - } - - const opt = row.option; - const isActive = row.itemIndex === cursor; - const caret = isActive ? pink("❯") : " "; - const marker = opt.isAll ? teal("★") : opt.detected ? pink("●") : dim("○"); - const label = isActive - ? pinkBold(opt.label) - : opt.detected - ? opt.label - : dim(opt.label); - lines.push(`${g} ${caret} ${marker} ${label}`); - } - - lines.push(g); - lines.push(`${g} ${dim("↑/↓ move · ↵ select · esc cancel")}`); - - if (lastLineCount > 0) { - process.stdout.write(`\x1B[${lastLineCount}A\x1B[J`); - } - process.stdout.write(lines.map((l) => truncateLine(l, cols)).join("\n") + "\n"); - lastLineCount = lines.length; - } - - const itemCount = options.length; - - return new Promise((resolve) => { - render(); - readline.emitKeypressEvents(process.stdin); - const wasRaw = process.stdin.isRaw; - if (process.stdin.setRawMode) process.stdin.setRawMode(true); - process.stdin.resume(); - - function cleanup(): void { - showCursor(); - process.stdin.removeListener("keypress", onKey); - if (process.stdin.setRawMode) process.stdin.setRawMode(wasRaw ?? false); - process.stdin.pause(); - } - - function onKey(_str: string | undefined, key: readline.Key): void { - if (!key) return; - if ((key.ctrl && (key.name === "c" || key.name === "d")) || key.name === "escape") { - cleanup(); - process.stdout.write("\n"); - process.exit(130); // SIGINT-equivalent - } - if (key.name === "up") { - cursor = cursor > 0 ? cursor - 1 : itemCount - 1; - render(); - } else if (key.name === "down") { - cursor = cursor < itemCount - 1 ? cursor + 1 : 0; - render(); - } else if (key.name === "return" || key.name === "space") { - cleanup(); - process.stdout.write("\n"); - resolve(options[cursor].value); - } - } - - process.stdin.on("keypress", onKey); + const { options } = buildCliMenuOptions(detected, action); + const kit = screenKit(optsFor(process.stdout)); + process.stdout.write(`\n${kit.header(action === "uninstall" ? "Remove hooks" : "Install hooks")}\n\n`); + const choices: SelectChoice[] = options.map((option) => ({ + label: option.label, + value: option.value, + section: option.detected ? "found on this machine" : "not installed, set up ahead of time", + })); + const picked = await selectOne({ + message: "Choose agents", + meta: `${detected.length} found on this machine`, + choices, + spaceSelects: true, }); -} - -/** - * Show interactive searchable policy selector. - * @param preSelected — policy names to pre-check (e.g. from existing config). - * When omitted, uses each policy's defaultEnabled flag. - * @param options — prompt options (e.g. includeBeta) - */ -export async function promptPolicySelection( - preSelected?: string[], - options: PromptOptions = {}, -): Promise { - const { includeBeta = false } = options; - - // If stdin is not a TTY (piped/CI), return defaults - if (!process.stdin.isTTY) { - const available = BUILTIN_POLICIES.filter((p) => includeBeta || !p.beta); - // Returned UNCHANGED, deliberately. This used to intersect with the builtin - // catalog, and manager.ts writes the result straight back — so a piped - // `policies --install` silently deleted every enabled name the catalog did - // not contain: a `failproofai/`-qualified name (a form the enforcement path - // explicitly accepts, `registerBuiltinPolicies` canonicalizes both), a beta - // policy enabled with `--beta`, and now any pack policy. The line that - // reports what was enabled prints only the survivors, so the loss was - // invisible. Carrying an unknown name costs nothing — registration looks it - // up in a Set and finds nothing — while dropping one destroys configuration. - if (preSelected) return [...preSelected]; - return available.filter((p) => p.defaultEnabled).map((p) => p.name); - } - - const preSelectedSet = preSelected ? new Set(preSelected) : null; - - const items: SelectItem[] = BUILTIN_POLICIES - .filter((p) => includeBeta || !p.beta) - .map((p) => ({ - name: p.name, - description: p.description, - category: p.category, - selected: preSelectedSet ? preSelectedSet.has(p.name) : p.defaultEnabled, - beta: !!p.beta, - })); - - // Everything the user has enabled that this picker cannot show a row for, for - // the same reason as the non-TTY branch above: the picker's universe is the - // builtin catalog, and resolving from it alone deletes anything else. Kept in - // their original order and appended to whatever the user picks. - const carried = preSelected - ? preSelected.filter((name) => !items.some((i) => i.name === name)) - : []; - - const total = items.length; - const WINDOW_SIZE = 10; - - // Shared brand painter from tui.ts, bound to role names so a reader never has - // to decode which hue a role happens to use today (glyphs imported above). - const useColor = !process.env.NO_COLOR; - const c = paint(useColor); - const { dim, bold } = c; - const stepMark = c.guide; // ◆ step marker + live selected-count - const activeName = c.pinkBold; // row under the cursor - const selectedMark = c.pink; // checked boxes / selected names - const betaTag = c.softPink; // "beta" pill - // Fixed name column so descriptions align into a clean, scannable second column. - const NAME_COL = Math.min(34, Math.max(10, ...items.map((i) => i.name.length))); - - let cursor = 0; - let search = ""; - let lastLineCount = 0; - let cursorHidden = false; - - function hideCursor(): void { - if (!cursorHidden) { - process.stdout.write("\x1B[?25l"); - cursorHidden = true; - } - } - - function showCursor(): void { - if (cursorHidden) { - process.stdout.write("\x1B[?25h"); - cursorHidden = false; - } - } - - function getFiltered(): SelectItem[] { - if (!search) return items; - const q = search.toLowerCase(); - return items.filter( - (i) => i.name.toLowerCase().includes(q) || i.description.toLowerCase().includes(q), - ); - } - - // Build display rows: category header rows interspersed with item rows. - // Categories appear in the order they first appear in BUILTIN_POLICIES. - function buildDisplayRows(filtered: SelectItem[]): DisplayRow[] { - // Single pass: compute category order, enabled counts, and total counts together. - const categoryOrder: string[] = []; - const categoryEnabledCount = new Map(); - const categoryTotalCount = new Map(); - for (const p of items) { - if (!categoryEnabledCount.has(p.category)) { - categoryOrder.push(p.category); - categoryEnabledCount.set(p.category, 0); - categoryTotalCount.set(p.category, 0); - } - categoryTotalCount.set(p.category, categoryTotalCount.get(p.category)! + 1); - if (p.selected) categoryEnabledCount.set(p.category, categoryEnabledCount.get(p.category)! + 1); - } - - const filteredByCategory = new Map(); - for (const item of filtered) { - const bucket = filteredByCategory.get(item.category) ?? []; - bucket.push(item); - filteredByCategory.set(item.category, bucket); - } - - const rows: DisplayRow[] = []; - let idx = 0; - for (const cat of categoryOrder) { - const catFiltered = filteredByCategory.get(cat); - if (!catFiltered || catFiltered.length === 0) continue; - rows.push({ kind: "header", category: cat, enabledCount: categoryEnabledCount.get(cat)!, totalCount: categoryTotalCount.get(cat)! }); - for (const item of catFiltered) { - rows.push({ kind: "item", item, filteredIndex: idx++ }); - } - } - return rows; + if (picked === null) { + process.stdout.write("\n"); + process.exit(130); // SIGINT-equivalent } - - function render(): void { - const cols = process.stdout.columns || 100; - hideCursor(); - - const filtered = getFiltered(); - const shown = filtered.length; - if (shown > 0 && cursor >= shown) cursor = shown - 1; - const selectedCount = items.filter((i) => i.selected).length; - - const g = dim(BAR); - const lines: string[] = []; - - // header - lines.push(g); - lines.push( - `${stepMark(STEP)} ${bold("Choose the policies to enable")} ${dim("·")} ` + - `${stepMark(String(selectedCount))} ${dim("selected")}`, - ); - - // search - const blockCursor = useColor ? "\x1B[7m \x1B[0m" : "_"; - const countLabel = search ? dim(`${shown}/${total} shown`) : dim(`${total} policies`); - lines.push(`${g} ${dim("filter ›")} ${search}${blockCursor} ${countLabel}`); - lines.push(g); - - if (shown === 0) { - lines.push(`${g} ${dim(`no policies match “${search}”`)}`); - for (let i = 0; i < WINDOW_SIZE + 1; i++) lines.push(g); - } else { - const displayRows = buildDisplayRows(filtered); - - let cursorDisplayRow = 0; - for (let i = 0; i < displayRows.length; i++) { - const row = displayRows[i]; - if (row.kind === "item" && row.filteredIndex === cursor) { - cursorDisplayRow = i; - break; - } - } - let windowStart = cursorDisplayRow - Math.floor(WINDOW_SIZE / 2); - windowStart = Math.max(0, windowStart); - windowStart = Math.min(windowStart, Math.max(0, displayRows.length - WINDOW_SIZE)); - const windowEnd = Math.min(displayRows.length, windowStart + WINDOW_SIZE); - - const aboveItems = displayRows.slice(0, windowStart).filter((r) => r.kind === "item").length; - lines.push(aboveItems > 0 ? `${g} ${dim(`↑ ${aboveItems} more`)}` : g); - - for (let i = windowStart; i < windowEnd; i++) { - const row = displayRows[i]; - if (row.kind === "header") { - lines.push(`${g} ${dim(row.category.toUpperCase())} ${dim(`${row.enabledCount}/${row.totalCount}`)}`); - } else { - const item = row.item; - const active = row.filteredIndex === cursor; - const box = item.selected ? selectedMark(BOX_ON) : dim(BOX_OFF); - const rawName = item.name.padEnd(NAME_COL); - const name = active ? activeName(rawName) : item.selected ? rawName : dim(rawName); - const beta = item.beta ? ` ${betaTag("beta")}` : ""; - const descWidth = Math.max(8, cols - NAME_COL - 8 - (item.beta ? 5 : 0)); - const desc = dim(truncateLine(item.description, descWidth)); - lines.push(`${g} ${box} ${name}${beta} ${desc}`); - } - } - for (let i = windowEnd - windowStart; i < WINDOW_SIZE; i++) lines.push(g); - - const belowItems = displayRows.slice(windowEnd).filter((r) => r.kind === "item").length; - lines.push(belowItems > 0 ? `${g} ${dim(`↓ ${belowItems} more`)}` : g); - } - - // footer - lines.push(g); - lines.push(`${g} ${dim("↑/↓ move · space select · ctrl+a all · ↵ save · type to filter · esc clear")}`); - - if (lastLineCount > 0) process.stdout.write(`\x1B[${lastLineCount}A\x1B[J`); - process.stdout.write(lines.map((l) => truncateLine(l, cols)).join("\n") + "\n"); - lastLineCount = lines.length; - } - - return new Promise((resolve) => { - render(); - - process.stdin.setRawMode(true); - process.stdin.resume(); - // Use a single data→keypress pipeline with no readline.Interface. - // readline.createInterface would register its own competing data listener - // and its close() call would unexpectedly pause stdin, breaking arrow keys. - readline.emitKeypressEvents(process.stdin); - - function keypressHandler(_str: string | undefined, key: readline.Key): void { - if (!key) return; - - if (key.ctrl && key.name === "c") { - cleanup(); - process.exit(0); - } - - const filtered = getFiltered(); - - if (key.name === "up") { - if (filtered.length > 0) { - cursor = cursor > 0 ? cursor - 1 : filtered.length - 1; - } - render(); - } else if (key.name === "down") { - if (filtered.length > 0) { - cursor = cursor < filtered.length - 1 ? cursor + 1 : 0; - } - render(); - } else if (key.name === "space") { - const item = filtered[cursor]; - if (item) item.selected = !item.selected; - render(); - } else if (key.name === "return" || (key.ctrl && key.name === "s")) { - // Save — collapse the picker to a one-line summary, then resolve. - cleanup(); - const selected = items.filter((i) => i.selected).map((i) => i.name); - if (lastLineCount > 0) process.stdout.write(`\x1B[${lastLineCount}A\x1B[J`); - // Carried entries are NAMED, not counted. "3 kept" reads as fine right - // up until someone needs to know which three. - const summary = carried.length > 0 - ? `${selected.length} selected, kept ${carried.join(", ")}` - : `${selected.length} selected`; - process.stdout.write( - `${dim(BAR)}\n${dim(DONE)} Policies\n${dim(BAR)} ${dim(summary)}\n`, - ); - resolve([...selected, ...carried]); - } else if (key.name === "escape") { - // Clear search filter - search = ""; - cursor = 0; - render(); - } else if (key.ctrl && key.name === "a") { - // Toggle all visible items - const allSelected = filtered.length > 0 && filtered.every((i) => i.selected); - for (const item of filtered) item.selected = !allSelected; - render(); - } else if (key.name === "backspace" || key.name === "delete") { - if (search.length > 0) { - search = search.slice(0, -1); - cursor = 0; - render(); - } - } else if (_str && _str.length === 1 && !key.ctrl && !key.meta) { - // All printable characters (including 'a', 's') go to search - search += _str; - cursor = 0; - render(); - } - } - - function cleanup(): void { - showCursor(); - process.stdin.removeListener("keypress", keypressHandler); - process.stdin.setRawMode(false); - process.stdin.pause(); - } - - process.stdin.on("keypress", keypressHandler); - }); + return picked; } diff --git a/src/hooks/pack-cli.ts b/src/hooks/pack-cli.ts index 2cb7cae10..aeeb6c0d3 100644 --- a/src/hooks/pack-cli.ts +++ b/src/hooks/pack-cli.ts @@ -12,7 +12,7 @@ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdir import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { INTEGRATION_TYPES } from "./types"; import { PACK_COMMIT_RE, PACK_VERSION_RE } from "./pack-manifest"; -import { detectInstalledClis } from "./integrations"; +import { detectInstalledClis, getIntegration } from "./integrations"; import { packParamsProblem, parsePackIdentity, @@ -59,6 +59,7 @@ import { optsFor, rows as kitRows, rule, + screenKit, stack, table, title, @@ -777,10 +778,9 @@ export async function runPolicyPicker( const before = new Set(rows.filter((r) => r.checked).map((r) => r.value)); const picked = await multiSelect({ - message: "Which policies should be on?", + message: "Choose policies", choices: rows, summaryNoun: "policies", - hint: "space toggles · ctrl+a all · ↵ confirm · what is on now is ticked", stdin, stdout, }); @@ -2612,7 +2612,7 @@ async function pickClis( ): Promise { const detected = new Set(detectInstalledClis()); const choices: MultiChoice[] = INTEGRATION_TYPES.map((id) => ({ - label: id, + label: getIntegration(id).displayName, value: id, checked: true, hint: detected.has(id) ? "installed here" : "", @@ -2621,8 +2621,9 @@ async function pickClis( message: "Which agents should this pack guard?", choices, minSelected: 1, + minMessage: "Pick at least one agent.", summaryNoun: "agents", - hint: `space toggles · ctrl+a all · ↵ confirm · ${detected.size} detected on this machine`, + meta: (checked) => `${checked.length} of ${choices.length} selected · ${detected.size} found on this machine`, stdin: io.stdin, stdout: io.stdout, }); @@ -2710,21 +2711,18 @@ async function pickFromSource( }); } } - const on = preview.policies.filter((p) => p.defaultEnabled).length; - // Said BEFORE the list, because the list cannot say it: a pack's Jev checks are - // not rows here, and they arrive whole. Somebody ticking boxes should know - // that is part of what they are agreeing to. + const kit = screenKit(optsFor(io.stdout)); + const lead = ["", kit.header(`Add ${preview.id}@${preview.version}`), ""]; + // Said BEFORE the list because the list cannot say it: Jev checks are not + // rows here, and they arrive whole with the pack. if (preview.semantic.length > 0) { - io.stdout.write( - `\n This pack also brings ${semanticPhrase(preview.semantic.length)} (not selectable), ` + - `${howJevAsksChecks(preview)}.\n See: failproofai policies show ${source}\n\n`, - ); + lead.push(`Also brings ${semanticPhrase(preview.semantic.length)}, ${howJevAsksChecks(preview)}.`, ""); } + io.stdout.write(lead.join("\n") + "\n"); const picked = await multiSelect({ - message: `${preview.id}@${preview.version} — which of these should be on?`, + message: "Choose policies", choices: rows, summaryNoun: "policies", - hint: `space toggles · ctrl+a all · ↵ confirm · ${on} of ${rows.length} are the publisher's defaults`, stdin: io.stdin, stdout: io.stdout, }); diff --git a/src/hooks/tui.ts b/src/hooks/tui.ts index 95a36c2f2..ac2a57044 100644 --- a/src/hooks/tui.ts +++ b/src/hooks/tui.ts @@ -41,6 +41,12 @@ export interface SelectOneOptions { choices: SelectChoice[]; /** Static info lines rendered under the question (e.g. a review summary). */ body?: string[]; + /** Grey text after the heading — what the list is, e.g. "9 found on this machine". */ + meta?: string; + /** The lines the prompt collapses to once answered, instead of `✓