From ca42a8ea53237ffa4c5cc8f7e623d6b696a4aaa3 Mon Sep 17 00:00:00 2001 From: Favour Ohans Date: Wed, 16 Sep 2026 23:44:27 +0100 Subject: [PATCH 1/2] The picker, redesigned: wordmark, coach line, highlight bar, one detail line, collapsed exclusions, review as the bundle tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped travel step showed everything at once and taught nothing: every sensible item preselected, so the natural first key accepted the defaults and read as skipping the user's input. This is the signed-off redesign, rendering only — keys, defaults, consent rules, plain mode, flag echo and assemble-never-execute are untouched. Launch opens with a half-block AGENT SYNC wordmark (bold text in ASCII or narrow terminals) and a found-on-this-machine card that simply omits absent agents. The multiselect gets a picked-count chip, a warn-colored coach line — space picks, enter continues — that retires after the first toggle, a full-row highlight bar instead of a pointer glyph, plain group headers with a blank rail line between groups, per-item hints demoted to one detail line describing the highlighted row, the locked section collapsed to a counted line with v to expand, and a three-entry footer with ? revealing the full bindings. The destination step is gone: the review IS the final screen, and it is the product promise on screen — the actual bundle tree with per-skill file counts and sizes, config files naming the keys they carry, the manifest line, and an accounting of consented hooks, plugins and exclusions — ending in Pack it? with the destination as a named default: y packs, n leaves nothing written, d opens the destination choice, esc cancels. Plain mode reads the same tree and answers one y/N; destination changes there ride the scripted flags the echo teaches. The receipt closes with the scripted spelling and the apply-on-the-other-side line. Tests: wordmark rows and fallbacks, the review-tree builder, review n and d key paths end to end, coach retirement, v and ? toggles, the match-count filter line, forced re-collapse on tight budgets, and the ascii design snapshot rewritten to the new frame. 177 tests. --- src/commands/guided.ts | 225 ++++++++++++++++++++++++++++++++++------- src/main.ts | 3 +- src/tui/components.ts | 148 +++++++++++++++++---------- src/tui/theme.ts | 6 ++ src/tui/wordmark.ts | 53 ++++++++++ test/guided.test.mjs | 87 +++++++++++++++- test/tui.test.mjs | 54 ++++++---- 7 files changed, 465 insertions(+), 111 deletions(-) create mode 100644 src/tui/wordmark.ts diff --git a/src/commands/guided.ts b/src/commands/guided.ts index 19b7ab3..a676971 100644 --- a/src/commands/guided.ts +++ b/src/commands/guided.ts @@ -25,8 +25,11 @@ import { createTheme } from "../tui/theme.js"; import { Screen } from "../tui/terminal.js"; import { createFlow, type Flow, type MultiGroup } from "../tui/components.js"; import { Plain } from "../tui/plain.js"; +import { wordmarkLines } from "../tui/wordmark.js"; import { buildTar, writeBundleDirectory } from "./export.js"; +declare const __PKG_VERSION__: string; + export type GuidedMode = "picker" | "plain"; export interface EntryContext { @@ -161,9 +164,10 @@ function factsLines(reports: ScanReport[]): string[] { codex: "Codex", opencode: "OpenCode", }; - return reports.map((report) => { + return reports.flatMap((report) => { + // Absent agents are simply not mentioned; the card lists what IS here. + if (!report.present) return []; const title = titles[report.agent].padEnd(13); - if (!report.present) return `${title} not installed`; const userItems = report.items.filter((item) => item.scope === "user"); const counts: string[] = []; for (const [kind, label] of [ @@ -180,7 +184,7 @@ function factsLines(reports: ScanReport[]): string[] { if (total === 0) continue; counts.push(kind === "settings" || kind === "memory" ? label : `${total} ${label}`); } - return `${title} ${counts.length > 0 ? counts.join(" · ") : "nothing to sync"}`; + return [`${title} ${counts.length > 0 ? counts.join(" · ") : "nothing to sync"}`]; }); } @@ -221,9 +225,9 @@ export async function runGuided(io: CommandIo, mode: GuidedMode, overrides: Guid return 0; } - const ui = mode === "picker" ? pickerUi(overrides) : plainUi(io, overrides); + const ui = mode === "picker" ? pickerUi({ ...overrides, wordmark: true }) : plainUi(io, overrides); try { - ui.intro("agent-sync", "guided export", factsLines(reports)); + ui.intro("found on this machine", `carry your agent setup anywhere ${"·"} v${__PKG_VERSION__}`, factsLines(reports)); const travel = await ui.groupMultiselect("What should travel?", groups); if (travel === null) return 2; @@ -238,14 +242,15 @@ export async function runGuided(io: CommandIo, mode: GuidedMode, overrides: Guid let hooks: string[] = []; if (hookGroup !== null) { - const picked = await ui.groupMultiselect("Which hooks may travel?", [hookGroup]); + const picked = await ui.groupMultiselect( + "Which hooks may travel?", + [hookGroup], + "hooks run shell commands on the target; none travel unless you pick them", + ); if (picked === null) return 2; hooks = picked; } - const dest = await ui.select("Where should the bundle go?", DESTINATIONS); - if (dest === null) return 2; - const collectOptions: Parameters[0] = { confirmedHooks: hooks, selectedPlugins: plugins, @@ -291,21 +296,24 @@ export async function runGuided(io: CommandIo, mode: GuidedMode, overrides: Guid } const totalBytes = plan.entries.reduce((sum, entry) => sum + entry.content.length, 0); - // The review is everything that leaves the machine, so consented hooks and - // plugins are named here, not just counted into the file total. - const review = [ - `Would pack ${plan.entries.length} files (${formatBytes(totalBytes)}) + manifest.json`, - ...hooks.map((name) => `hook confirmed: ${name}`), - ...plugins.map((name) => `plugin reference: ${name}`), - ...plan.skipped.map((skip) => `skipped: ${skip.path} — ${skip.reason}`), - ]; - ui.note(review); - - const confirmed = await ui.confirm(`Write ${dest === "agent-sync-bundle" ? "agent-sync-bundle/" : dest}?`); - if (confirmed === null) return 2; - if (!confirmed) { - ui.outro("Nothing was written."); - return 0; + // The review IS the product promise: the actual tree of what leaves the + // machine, read before anything is written. The destination is a named + // default inside the confirm, not a step of its own. + let dest = "setup.tgz"; + for (;;) { + const choice = await ui.review(buildReviewLines(plan, hooks, plugins, report.excluded.length), destLabel(dest)); + if (choice === null) return 2; + if (choice === "skip") { + ui.outro("Nothing was written."); + return 0; + } + if (choice === "dest") { + const picked = await ui.select("Where should the bundle go?", DESTINATIONS); + if (picked === null) return 2; + dest = picked; + continue; + } + break; } const destPath = join(overrides.destDir ?? process.cwd(), dest); @@ -313,8 +321,9 @@ export async function runGuided(io: CommandIo, mode: GuidedMode, overrides: Guid const selections: Selections = { skips, plugins, hooks, allowSecrets, dest }; ui.outro( - `Packed ${plan.entries.length} files (${formatBytes(totalBytes)}) to ${dest === "agent-sync-bundle" ? "agent-sync-bundle/" : dest}`, - `Next time, non-interactively: ${flagEcho(selections)}`, + `Packed ${plan.entries.length} files (${formatBytes(totalBytes)}) -> ${destLabel(dest)}`, + `Next time, scripted: ${flagEcho(selections)}`, + `Apply on the other side: npx @coldtea/agent-sync@latest apply ${destLabel(dest)}`, ); return 0; } finally { @@ -322,6 +331,82 @@ export async function runGuided(io: CommandIo, mode: GuidedMode, overrides: Guid } } +function destLabel(dest: string): string { + return dest === "agent-sync-bundle" ? "agent-sync-bundle/" : dest; +} + +// The bundle as a readable tree: skill directories aggregate to a count and +// size, config files name the keys they carry, and the closing line accounts +// for consents and exclusions, so the screen is the manifest in prose. +export function buildReviewLines( + plan: ExportPlan, + hooks: string[], + plugins: string[], + excludedCount: number, +): string[] { + const lines: string[] = []; + const seenDirGroups = new Set(); + const annotate = (path: string, content: Buffer): string => { + const name = path.split("/").pop() ?? path; + if (name === "CLAUDE.md" || name === "AGENTS.md") return "memory"; + if (name.endsWith(".json") || name.endsWith(".toml")) { + try { + const keys = + name.endsWith(".json") + ? Object.keys(JSON.parse(content.toString("utf8")) as Record) + : content + .toString("utf8") + .split("\n") + .map((line) => line.split("=")[0]?.trim() ?? "") + .filter((key) => key.length > 0); + if (keys.length > 0) return keys.sort().join(", "); + } catch { + // Annotation only; an unparseable file just goes unannotated. + } + } + return ""; + }; + + const pad = (name: string, note: string, indent: string): string => + note.length > 0 ? `${indent}${name.padEnd(Math.max(22 - indent.length + 2, name.length + 2))}${note}` : `${indent}${name}`; + + for (const entry of plan.entries) { + const parts = entry.path.split("/"); + if (parts.length === 1) { + lines.push(pad(entry.path, annotate(entry.path, entry.content), "")); + continue; + } + // skills//** and codex/skills//** aggregate per skill dir; + // every other nested path lists as a file under its top-level dir. + const isSkillTree = parts[0] === "skills" || (parts[0] === "codex" && parts[1] === "skills"); + const groupKey = isSkillTree ? parts.slice(0, parts[0] === "codex" ? 3 : 2).join("/") : null; + const header = `${parts[0]}/`; + if (!seenDirGroups.has(header)) { + seenDirGroups.add(header); + lines.push(header); + } + if (groupKey !== null) { + if (seenDirGroups.has(groupKey)) continue; + seenDirGroups.add(groupKey); + const members = plan.entries.filter((candidate) => candidate.path.startsWith(`${groupKey}/`)); + const bytes = members.reduce((sum, member) => sum + member.content.length, 0); + const label = `${groupKey.split("/").pop() ?? groupKey}/`; + lines.push(pad(label, `${members.length} files, ${formatBytes(bytes)}`, " ")); + } else { + const rest = parts.slice(1).join("/"); + lines.push(pad(rest, annotate(entry.path, entry.content), " ")); + } + } + lines.push(pad("manifest.json", "hashes for every file above", "")); + lines.push(""); + lines.push( + `hooks: ${hooks.length > 0 ? hooks.join(", ") : "none"} | plugins: ${ + plugins.length > 0 ? plugins.join(", ") : "none" + } | ${excludedCount} excluded item${excludedCount === 1 ? "" : "s"} stayed behind`, + ); + return lines; +} + function writeDestination(destPath: string, dest: string, plan: ExportPlan): void { const manifestBytes = Buffer.from(`${JSON.stringify(plan.manifest, null, 2)}\n`, "utf8"); if (dest.endsWith(".tgz")) writeFileSync(destPath, gzipSync(buildTar(plan, manifestBytes), { level: 9 })); @@ -529,33 +614,45 @@ export async function runGuidedApply( } } -// Both modes speak the same five verbs; null means the user cancelled. +// Both modes speak the same verbs; null means the user cancelled. interface GuidedUi { intro(title: string, subtitle: string, facts: string[]): void; note(lines: string[]): void; - groupMultiselect(message: string, groups: MultiGroup[]): Promise; + groupMultiselect(message: string, groups: MultiGroup[], coach?: string): Promise; select(message: string, items: typeof DESTINATIONS): Promise; confirm(message: string, initial?: boolean): Promise; + review(lines: string[], dest: string): Promise<"pack" | "skip" | "dest" | null>; outro(...lines: string[]): void; close(): void; } -function pickerUi(overrides: GuidedOverrides): GuidedUi { +function pickerUi(overrides: GuidedOverrides & { wordmark?: boolean }): GuidedUi { const theme = createTheme(overrides.env === undefined ? {} : { env: overrides.env }); const screen = overrides.screen ?? new Screen({ ellipsis: theme.glyphs.ellipsis }); const flow: Flow = createFlow(screen, theme); + const g = theme.glyphs; + const bar = theme.paint("accent", g.bar); let open = false; return { intro(title, subtitle, facts) { - flow.intro(title, subtitle); - open = true; + if (overrides.wordmark === true) { + screen.open(); + open = true; + screen.commit(["", ...wordmarkLines(theme, screen.columns), theme.paint("dim", subtitle), ""]); + flow.intro(title); + } else { + flow.intro(title, subtitle); + open = true; + } if (facts.length > 0) flow.note(facts); }, note(lines) { flow.note(lines); }, - async groupMultiselect(message, groups) { - const result = await flow.groupMultiselect(message, groups); + async groupMultiselect(message, groups, coach) { + const options: { coach?: string } = {}; + if (coach !== undefined) options.coach = coach; + const result = await flow.groupMultiselect(message, groups, options); if (result.cancelled) open = false; return result.cancelled ? null : result.value; }, @@ -569,9 +666,53 @@ function pickerUi(overrides: GuidedOverrides): GuidedUi { if (result.cancelled) open = false; return result.cancelled ? null : result.value; }, + // The review screen: the bundle tree, then "Pack it?" with the + // destination as a named default. y packs, n leaves, d changes the + // destination, esc cancels. The picker still owns no writes. + async review(lines, dest) { + const message = "This is what leaves the machine"; + for (;;) { + screen.renderLive([ + `${theme.paint("accent", g.stepActive)} ${theme.paint("bright", message)}`, + bar, + ...lines.map((line) => `${bar} ${line.length > 0 ? line : ""}`), + bar, + `${bar} ${theme.paint("bright", "Pack it?")} ${theme.paint("ok", dest)} ${theme.paint("dim", "(y / n / d changes destination)")}`, + `${theme.paint("accent", g.railEnd)} ${theme.paint("dim", ["y pack", "d destination", "esc cancel"].join(` ${g.sep} `))}`, + ]); + const key = await screen.waitKey(); + if (key.name === "cancel" || key.name === "escape") { + screen.commit([ + `${theme.paint("bad", g.stepError)} ${theme.paint("strike", message)}`, + `${theme.paint("accent", g.railEnd)} ${theme.paint("bright", "Cancelled. Nothing was written.")}`, + ]); + screen.close(); + open = false; + return null; + } + const lower = key.char?.toLowerCase(); + if (lower === "y" || key.name === "return" || key.name === "enter") { + screen.clearLive(); + screen.commit([ + `${theme.paint("ok", g.stepDone)} ${message} ${theme.paint("dim", `${g.sep} ${dest}`)}`, + bar, + ]); + return "pack"; + } + if (lower === "n") { + screen.clearLive(); + screen.commit([`${theme.paint("ok", g.stepDone)} ${message} ${theme.paint("dim", `${g.sep} not packed`)}`, bar]); + return "skip"; + } + if (lower === "d") { + screen.clearLive(); + return "dest"; + } + } + }, outro(...lines) { // Lines print in order; the last one lands on the rail end, so the - // flag-echo teaching line closes the transcript. + // teaching lines close the transcript. if (lines.length > 1) flow.note(lines.slice(0, -1)); flow.outro(lines[lines.length - 1] ?? ""); open = false; @@ -594,7 +735,8 @@ function plainUi(io: CommandIo, overrides: GuidedOverrides): GuidedUi { note(lines) { for (const line of lines) plain.say(line); }, - async groupMultiselect(message, groups) { + async groupMultiselect(message, groups, coach) { + if (coach !== undefined) plain.say(coach); const result = await plain.groupMultiselect(message, groups); if (result.cancelled) io.err("Cancelled. Nothing was written."); return result.cancelled ? null : result.value; @@ -609,6 +751,19 @@ function plainUi(io: CommandIo, overrides: GuidedOverrides): GuidedUi { if (result.cancelled) io.err("Cancelled. Nothing was written."); return result.cancelled ? null : result.value; }, + // Plain mode reads the same tree and answers one y/N; changing the + // destination in plain mode is the scripted flags' job, which the echo + // teaches at the end of every run. + async review(lines, dest) { + plain.say("This is what leaves the machine:"); + for (const line of lines) plain.say(line); + const result = await plain.confirm(`Pack to ${dest}?`, true); + if (result.cancelled) { + io.err("Cancelled. Nothing was written."); + return null; + } + return result.value ? "pack" : "skip"; + }, outro(...lines) { for (const line of lines) plain.say(line); }, diff --git a/src/main.ts b/src/main.ts index f3d8b4f..e739e3d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,7 +19,8 @@ export { Plain, plainModeRequested } from "./tui/plain.js"; export type { ScanReport, ScanItem } from "./scan/types.js"; export { collectExport, skipToken, MANIFEST_SCHEMA_VERSION } from "./export/collect.js"; export type { Manifest, ManifestPlugin, ExportPlan } from "./export/collect.js"; -export { chooseEntry, runGuided, runGuidedApply, flagEcho, applyFlagEcho, buildTravelGroups, buildHookGroup } from "./commands/guided.js"; +export { chooseEntry, runGuided, runGuidedApply, flagEcho, applyFlagEcho, buildTravelGroups, buildHookGroup, buildReviewLines } from "./commands/guided.js"; +export { wordmarkLines, wordmarkWidth } from "./tui/wordmark.js"; export { scanContentForSecrets } from "./export/secrets.js"; export { createTar } from "./export/tar.js"; export { parseTar, validateArchivePath } from "./apply/untar.js"; diff --git a/src/tui/components.ts b/src/tui/components.ts index cc1e631..dada259 100644 --- a/src/tui/components.ts +++ b/src/tui/components.ts @@ -31,7 +31,7 @@ export interface Flow { outro(text: string): void; confirm(message: string, initial?: boolean): Promise>; select(message: string, items: MultiItem[]): Promise>; - groupMultiselect(message: string, groups: MultiGroup[], options?: { required?: boolean }): Promise>; + groupMultiselect(message: string, groups: MultiGroup[], options?: { required?: boolean; coach?: string }): Promise>; } interface Row { @@ -50,12 +50,19 @@ interface MultiState { filtering: boolean; error: string | null; required: boolean; + // The coach line teaches space-vs-enter until the first toggle proves the + // lesson landed (null = the default wording, rendered with theme glyphs); + // the two view toggles belong to the state so a repaint cannot lose them. + coach: string | null; + touched: boolean; + lockedExpanded: boolean; + helpExpanded: boolean; } export function buildMultiState( message: string, groups: MultiGroup[], - options: { required?: boolean } = {}, + options: { required?: boolean; coach?: string } = {}, ): MultiState { const items: MultiState["items"] = []; let id = 0; @@ -73,6 +80,10 @@ export function buildMultiState( filtering: false, error: null, required: options.required ?? false, + coach: options.coach ?? null, + touched: false, + lockedExpanded: false, + helpExpanded: false, }; } @@ -137,13 +148,23 @@ export function reduceMulti(state: MultiState, key: Key): MultiOutcome return { kind: "continue" }; case key.name === "space": { if (current) toggle(state, current.id); + state.touched = true; return { kind: "continue" }; } case key.name === "tab": { if (current) toggle(state, current.id); + state.touched = true; state.cursor = visible.length === 0 ? 0 : (state.cursor + 1) % visible.length; return { kind: "continue" }; } + case key.char === "v": { + state.lockedExpanded = !state.lockedExpanded; + return { kind: "continue" }; + } + case key.char === "?": { + state.helpExpanded = !state.helpExpanded; + return { kind: "continue" }; + } case key.char === "a": { if (!current) return { kind: "continue" }; // With a filter active, bulk operations touch only what the user can @@ -157,6 +178,7 @@ export function reduceMulti(state: MultiState, key: Key): MultiOutcome if (allOn) state.selected.delete(item.id); else state.selected.add(item.id); } + state.touched = true; return { kind: "continue" }; } case key.char === "i": { @@ -165,6 +187,7 @@ export function reduceMulti(state: MultiState, key: Key): MultiOutcome if (state.selected.has(item.id)) state.selected.delete(item.id); else state.selected.add(item.id); } + state.touched = true; return { kind: "continue" }; } case key.char === "/": @@ -175,99 +198,112 @@ export function reduceMulti(state: MultiState, key: Key): MultiOutcome } } + +function lockedSummary(count: number): string { + return count === 1 ? "1 item never leaves this machine" : `${count} items never leave this machine`; +} + function toggle(state: MultiState, id: number): void { if (state.selected.has(id)) state.selected.delete(id); else state.selected.add(id); } -const RULE_WIDTH = 44; - -export function renderMulti(state: MultiState, theme: Theme, maxRows: number): string[] { +// The drawing the owner signed off: chip in the header, coach line until the +// first toggle, plain group headers with air between groups, full-row +// highlight on the active item, hints demoted to one detail line, the locked +// section a single counted line unless v expands it, and a three-entry footer +// with ? for the rest. +export function renderMulti(state: MultiState, theme: Theme, maxRows: number, columns = 80): string[] { const g = theme.glyphs; const bar = theme.paint("accent", g.bar); const visible = visibleItems(state); const lines: string[] = []; - const counts = - state.query.length > 0 - ? `${visible.length}/${state.items.length} (${state.selected.size} selected)` - : `${state.selected.size} of ${state.items.length} selected`; - lines.push(`${theme.paint("accent", g.stepActive)} ${theme.paint("bright", state.message)} ${theme.paint("dim", counts)}`); + const chip = theme.paint("inverse", ` ${state.selected.size} of ${state.items.length} picked `); + lines.push(`${theme.paint("accent", g.stepActive)} ${theme.paint("bright", state.message)} ${chip}`); if (state.filtering || state.query.length > 0) { - lines.push(`${bar} ${theme.paint("accent", "/")} ${state.query}${state.filtering ? theme.paint("inverse", " ") : ""}`); + lines.push( + `${bar} ${theme.paint("accent", "/")} ${state.query}${state.filtering ? theme.paint("inverse", " ") : ""} ${theme.paint("dim", `${visible.length}/${state.items.length} match`)}`, + ); + } else if (!state.touched) { + const coach = state.coach ?? `space picks ${g.sep} enter continues with what is checked`; + lines.push(`${bar} ${theme.paint("warn", coach)}`); } + lines.push(bar); const rows: string[] = []; + let activeRow = 0; let lastGroup = -1; + const labelWidth = Math.max(10, columns - 6); visible.forEach((item, index) => { if (item.groupIndex !== lastGroup) { + if (lastGroup !== -1) rows.push(bar); lastGroup = item.groupIndex; const group = state.groups[item.groupIndex]; - if (group) { - const title = ` ${group.title} `; - rows.push(`${bar} ${theme.paint("dim", `${g.rule.repeat(2)}${title}${g.rule.repeat(Math.max(0, RULE_WIDTH - title.length))}`)}`); - } + if (group) rows.push(`${bar} ${theme.paint("dim", group.title)}`); } const active = index === state.cursor; - const mark = state.selected.has(item.id) ? theme.paint("ok", g.checkOn) : g.checkOff; - const label = active ? theme.paint("bright", item.label) : item.label; - const hint = item.hint ? ` ${theme.paint("dim", item.hint)}` : ""; - rows.push(`${bar} ${active ? theme.paint("accent", g.pointer) : " "} ${mark} ${label}${hint}`); + if (active) activeRow = rows.length; + const mark = state.selected.has(item.id) ? g.pickOn : g.pickOff; + if (active) { + const content = ` ${mark} ${item.label}`.padEnd(labelWidth).slice(0, labelWidth); + rows.push(`${bar} ${theme.paint("inverse", content)}`); + } else { + const painted = state.selected.has(item.id) ? theme.paint("ok", mark) : theme.paint("dim", mark); + rows.push(`${bar} ${painted} ${item.label}`); + } }); if (visible.length === 0) rows.push(`${bar} ${theme.paint("dim", "No matches.")}`); + const lockedItems = state.groups.filter((group) => group.locked === true).flatMap((group) => group.items); let lockedLines: string[] = []; - let lockedCount = 0; - state.groups.forEach((group) => { - if (!group.locked) return; - lockedCount += group.items.length; - const title = ` ${group.title} `; - lockedLines.push(`${bar} ${theme.paint("dim", `${g.rule.repeat(2)}${title}${g.rule.repeat(Math.max(0, RULE_WIDTH - title.length))}`)}`); - for (const item of group.items) { - lockedLines.push(`${bar} ${theme.paint("dim", `${g.lockedMark} ${item.label}${item.hint ? ` ${item.hint}` : ""}`)}`); + if (lockedItems.length > 0) { + if (state.lockedExpanded) { + lockedLines.push( + `${bar} ${theme.paint("dim", `${g.lockedMark} ${lockedSummary(lockedItems.length)} ${g.sep} v to hide`)}`, + ); + for (const item of lockedItems) { + lockedLines.push(`${bar} ${theme.paint("dim", `${g.lockedMark} ${item.label}${item.hint ? ` ${item.hint}` : ""}`)}`); + } + } else { + lockedLines.push( + `${bar} ${theme.paint("dim", `${g.lockedMark} ${lockedSummary(lockedItems.length)} ${g.sep} v to view`)}`, + ); } - if (group.lockedReason) lockedLines.push(`${bar} ${theme.paint("dim", ` ${group.lockedReason}`)}`); - }); + } const sep = ` ${g.sep} `; + const activeItem = visible[state.cursor]; + const detail = + activeItem?.hint !== undefined && activeItem.hint.length > 0 + ? [`${bar} ${theme.paint("dim", `${activeItem.label} ${g.sep} ${activeItem.hint}`)}`] + : []; + const footer: string[] = []; if (state.error) footer.push(`${theme.paint("warn", g.stepError)} ${theme.paint("warn", state.error)}`); footer.push( `${theme.paint("accent", g.railEnd)} ${theme.paint( "dim", - [`${g.navUpDown} move`, "space select", "tab next", "a group", "i invert", "/ filter", "enter confirm"].join(sep), + state.helpExpanded + ? [`${g.navUpDown} move`, "space pick", "tab next", "a group", "i invert", "/ filter", "v locked", "enter continue"].join(sep) + : ["space pick", "enter continue", "? keys"].join(sep), )}`, ); - // Short terminals: locked lines collapse to one before the list shrinks - // below usable, keeping the footer and error visible down to ~8 rows. - // Below that, renderLive's top-first slice cuts the footer — the accepted - // ceiling; no interactive terminal is 7 rows tall. - let overhead = lines.length + lockedLines.length + footer.length; - if (lockedCount > 0 && overhead + 3 > maxRows) { + // Short terminals: the locked section forces back to its one-line form + // before the list shrinks below usable, keeping footer and error visible + // down to ~8 rows. Below that, renderLive's top-first slice cuts the + // footer — the accepted ceiling; no interactive terminal is 7 rows tall. + let overhead = lines.length + lockedLines.length + detail.length + footer.length + 1; + if (lockedLines.length > 1 && overhead + 3 > maxRows) { lockedLines = [ - `${bar} ${theme.paint("dim", `${g.lockedMark} ${lockedCount} item(s) never leave this machine`)}`, + `${bar} ${theme.paint("dim", `${g.lockedMark} ${lockedSummary(lockedItems.length)}`)}`, ]; - overhead = lines.length + lockedLines.length + footer.length; + overhead = lines.length + lockedLines.length + detail.length + footer.length + 1; } const budget = Math.max(3, maxRows - overhead); - const windowed = windowRows(rows, cursorRowIndex(state, visible), budget, theme); - return [...lines, ...windowed, ...lockedLines, ...footer]; -} - -function cursorRowIndex(state: MultiState, visible: MultiState["items"]): number { - let row = 0; - let lastGroup = -1; - for (let index = 0; index < visible.length; index += 1) { - const item = visible[index]; - if (item && item.groupIndex !== lastGroup) { - lastGroup = item.groupIndex; - row += 1; - } - if (index === state.cursor) return row; - row += 1; - } - return row; + const windowed = windowRows(rows, activeRow, budget, theme); + return [...lines, ...windowed, bar, ...lockedLines, ...detail, ...footer]; } function windowRows(rows: string[], cursorRow: number, budget: number, theme: Theme): string[] { @@ -387,7 +423,7 @@ export function createFlow(screen: Screen, theme: Theme): Flow { async groupMultiselect(message, groups, options = {}) { const state = buildMultiState(message, groups, options); for (;;) { - screen.renderLive(renderMulti(state, theme, Math.max(6, screen.rows - 2))); + screen.renderLive(renderMulti(state, theme, Math.max(6, screen.rows - 2), screen.columns)); const outcome = reduceMulti(state, await screen.waitKey()); if (outcome.kind === "cancel") { cancelCommit(message); diff --git a/src/tui/theme.ts b/src/tui/theme.ts index 54d9fa1..da2a819 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -18,6 +18,8 @@ export interface Glyphs { sep: string; ellipsis: string; rule: string; + pickOn: string; + pickOff: string; } const UNICODE: Glyphs = { @@ -40,6 +42,8 @@ const UNICODE: Glyphs = { sep: "·", ellipsis: "…", rule: "─", + pickOn: "●", + pickOff: "○", }; const ASCII: Glyphs = { @@ -62,6 +66,8 @@ const ASCII: Glyphs = { sep: "-", ellipsis: "...", rule: "-", + pickOn: "[x]", + pickOff: "[ ]", }; export interface ThemeEnvironment { diff --git a/src/tui/wordmark.ts b/src/tui/wordmark.ts new file mode 100644 index 0000000..07d2e6d --- /dev/null +++ b/src/tui/wordmark.ts @@ -0,0 +1,53 @@ +import type { Theme } from "./theme.js"; + +// The launch wordmark: AGENT SYNC in a 4x5 pixel font, drawn as three +// terminal rows of half-block cells so the aspect reads as tiles rather than +// stretched columns. ASCII mode gets plain bold text; a narrow terminal gets +// the same, because a clipped wordmark is worse than none. +const FONT: Record = { + A: ["0110", "1001", "1111", "1001", "1001"], + G: ["0111", "1000", "1011", "1001", "0111"], + E: ["1111", "1000", "1110", "1000", "1111"], + N: ["1001", "1101", "1111", "1011", "1001"], + T: ["1111", "0100", "0100", "0100", "0100"], + S: ["0111", "1000", "0110", "0001", "1110"], + Y: ["1001", "1001", "0110", "0100", "0100"], + C: ["0111", "1000", "1000", "1000", "0111"], + " ": ["00", "00", "00", "00", "00"], +}; + +const WORD = "AGENT SYNC"; + +export function wordmarkWidth(): number { + let width = 0; + for (const character of WORD) width += (FONT[character]?.[0]?.length ?? 0) + 1; + return width - 1; +} + +export function wordmarkLines(theme: Theme, columns: number): string[] { + if (!theme.unicode || columns < wordmarkWidth() + 4) { + return [theme.paint("bright", WORD)]; + } + const pixelRows: string[] = ["", "", "", "", ""]; + for (const character of WORD) { + const glyph = FONT[character]; + if (glyph === undefined) continue; + for (let row = 0; row < 5; row += 1) pixelRows[row] += `${glyph[row]}0`; + } + const lines: string[] = []; + for (const [top, bottom] of [ + [0, 1], + [2, 3], + [4, -1], + ] as const) { + let line = ""; + const width = pixelRows[0]?.length ?? 0; + for (let column = 0; column < width; column += 1) { + const upper = pixelRows[top]?.[column] === "1"; + const lower = bottom >= 0 && pixelRows[bottom]?.[column] === "1"; + line += upper && lower ? "█" : upper ? "▀" : lower ? "▄" : " "; + } + lines.push(theme.paint("bright", line.replace(/\s+$/, ""))); + } + return lines; +} diff --git a/test/guided.test.mjs b/test/guided.test.mjs index 682c2ae..e6053a3 100644 --- a/test/guided.test.mjs +++ b/test/guided.test.mjs @@ -356,7 +356,8 @@ test("guided picker flow: accepting every default writes setup.tgz and echoes th assert.ok(!("hooks" in settings), "hooks are opt-in and none were selected"); const rendered = fake.chunks.join(""); - assert.match(rendered, /Never leaves this machine/); + assert.match(rendered, /never leaves? this machine/); + assert.match(rendered, /This is what leaves the machine/); assert.match(rendered, /agent-sync export setup\.tgz/); }); @@ -394,6 +395,90 @@ test("guided flow cancel writes nothing and exits 2", async () => { assert.ok(!existsSync(join(destDir, "setup.tgz"))); }); +test("wordmark renders three half-block rows in unicode and bold text in ascii or narrow terminals", async () => { + const { createTheme, wordmarkLines } = await import("../dist/main.js"); + const unicode = createTheme({ env: { TERM: "xterm-256color" }, platform: "darwin", isTTY: false }); + const rows = wordmarkLines(unicode, 120); + assert.equal(rows.length, 3); + assert.ok(rows.every((row) => /^[█▀▄ ]+$/.test(row)), "half-block cells only"); + const widths = new Set(rows.map((row) => row.replace(/\s+$/, "").length <= rows[0].length)); + assert.ok(widths.has(true)); + + const narrow = wordmarkLines(unicode, 30); + assert.deepEqual(narrow, ["AGENT SYNC"]); + const ascii = createTheme({ env: { TERM: "linux" }, platform: "linux", isTTY: false }); + assert.deepEqual(wordmarkLines(ascii, 120), ["AGENT SYNC"]); +}); + +test("the review tree aggregates skill dirs, names config keys, and accounts for consents", async () => { + const { buildReviewLines } = await import("../dist/main.js"); + const entry = (path, content) => ({ path, content: Buffer.from(content), executable: false }); + const plan = { + entries: [ + entry("CLAUDE.md", "memory\n"), + entry("settings.json", JSON.stringify({ model: "opus", theme: "dark" })), + entry("skills/boxd-cli/SKILL.md", "# a\n"), + entry("skills/boxd-cli/guide.md", "guide\n"), + entry("codex/config.toml", 'model = "gpt"\n'), + entry("codex/skills/hermes/SKILL.md", "# h\n"), + ], + manifest: {}, + skipped: [], + secretFindings: [], + diagnostics: [], + }; + const lines = buildReviewLines(plan, ["hooks.PostToolUse"], [], 5); + const text = lines.join("\n"); + assert.match(text, /CLAUDE\.md\s+memory/); + assert.match(text, /settings\.json\s+model, theme/); + assert.match(text, /boxd-cli\/\s+2 files/); + assert.match(text, /config\.toml\s+model/); + assert.match(text, /hermes\/\s+1 files/); + assert.match(text, /manifest\.json\s+hashes for every file above/); + assert.match(text, /hooks: hooks\.PostToolUse \| plugins: none \| 5 excluded items stayed behind/); +}); + +test("review keys: n leaves nothing written, d changes the destination", async () => { + const destDir = join(root, "review-keys-out"); + mkdirSync(destDir, { recursive: true }); + const io = { out: () => {}, err: () => {} }; + const overrides = { + userDir, + claudeJsonPath, + codexHome: join(fakeHome, ".codex"), + codexAgentsDir: join(fakeHome, ".agents"), + opencodeConfigDir: join(fakeHome, ".config", "opencode"), + destDir, + env: {}, + }; + + const first = fakeScreenIo(); + const declining = runGuided(io, "picker", { ...overrides, screen: new Screen({ input: first.input, output: first.output }) }); + setImmediate(() => { + const press = (char, name) => first.input.emit("keypress", char, { name, sequence: char ?? "\r" }); + press(undefined, "return"); // travel + press(undefined, "return"); // hooks + press("n", "n"); // review: do not pack + }); + assert.equal(await declining, 0); + assert.ok(!existsSync(join(destDir, "setup.tgz")), "n must write nothing"); + + const second = fakeScreenIo(); + const switching = runGuided(io, "picker", { ...overrides, screen: new Screen({ input: second.input, output: second.output }) }); + setImmediate(() => { + const press = (char, name) => second.input.emit("keypress", char, { name, sequence: char ?? "\r" }); + press(undefined, "return"); // travel + press(undefined, "return"); // hooks + press("d", "d"); // review: change destination + press(undefined, "down"); + press(undefined, "return"); // pick setup.tar + press("y", "y"); // review again: pack + }); + assert.equal(await switching, 0); + assert.ok(existsSync(join(destDir, "setup.tar")), "d then select must retarget the write"); + assert.ok(!existsSync(join(destDir, "setup.tgz"))); +}); + test("bare non-TTY invocation keeps the static usage surface", () => { const output = execFileSync(process.execPath, ["bin/agent-sync.mjs"], { cwd: REPO_ROOT, diff --git a/test/tui.test.mjs b/test/tui.test.mjs index c714769..eb56549 100644 --- a/test/tui.test.mjs +++ b/test/tui.test.mjs @@ -110,16 +110,30 @@ test("required blocks empty submit with an error", () => { assert.match(state.error, /at least one/); }); -test("renderMulti shows counts, locked section, footer, and windows long lists", () => { +test("renderMulti shows chip, coach, collapsed locked line, short footer, and windows long lists", () => { const theme = asciiTheme(); const state = buildMultiState("What should travel?", GROUPS); const frame = renderMulti(state, theme, 24).join("\n"); assert.match(frame, /What should travel\?/); - assert.match(frame, /0 of 3 selected/); - assert.match(frame, /Never leaves this machine/); - assert.match(frame, /x \.credentials\.json/); - assert.match(frame, /Credentials never sync\./); - assert.match(frame, /enter confirm/); + assert.match(frame, /0 of 3 picked/); + assert.match(frame, /space picks - enter continues with what is checked/); + assert.match(frame, /1 item never leaves this machine - v to view/); + assert.ok(!frame.includes(".credentials.json"), "locked items hide until v expands them"); + assert.match(frame, /space pick - enter continue - \? keys/); + assert.ok(!frame.includes("i invert"), "the long bindings hide behind ?"); + + reduceMulti(state, press("v")); + const expanded = renderMulti(state, theme, 24).join("\n"); + assert.match(expanded, /x \.credentials\.json/); + assert.match(expanded, /v to hide/); + + reduceMulti(state, press("?")); + assert.match(renderMulti(state, theme, 24).join("\n"), /i invert/); + + reduceMulti(state, key("space")); + const touched = renderMulti(state, theme, 24).join("\n"); + assert.ok(!touched.includes("space picks - enter continues"), "the coach line retires after the first toggle"); + assert.match(touched, /1 of 3 picked/); const many = [{ title: "Big", items: Array.from({ length: 40 }, (_, i) => ({ value: i, label: `item-${i}` })) }]; const bigState = buildMultiState("big", many); @@ -285,14 +299,15 @@ test("filtered bulk operations touch only visible items", () => { assert.equal(zero.selected.size, 0, "invert with zero matches is a no-op"); }); -test("group headers survive filtering", () => { +test("group headers survive filtering, and the filter line shows the match count", () => { const theme = asciiTheme(); const state = buildMultiState("pick", GROUPS); reduceMulti(state, press("/")); reduceMulti(state, press("p")); const frame = renderMulti(state, theme, 24).join("\n"); - assert.match(frame, /-- Plugins /); + assert.match(frame, /Plugins/); assert.match(frame, /ponytail/); + assert.match(frame, /1\/3 match/); }); test("locked section collapses to one line before controls are cut", () => { @@ -306,11 +321,13 @@ test("locked section collapses to one line before controls are cut", () => { }, ]; const state = buildMultiState("pick", bigLocked); + reduceMulti(state, press("v")); const frame = renderMulti(state, theme, 10); assert.ok(frame.length <= 10 + 3, "frame respects the budget with slack for the min list"); const text = frame.join("\n"); - assert.match(text, /10 item\(s\) never leave this machine/); - assert.match(text, /enter confirm/, "footer survived"); + assert.match(text, /10 items never leave this machine/); + assert.ok(!text.includes("secret-0"), "a tight budget forces the locked section back to one line"); + assert.match(text, /enter continue/, "footer survived"); }); test("keypress decoding from raw escape bytes via the real decoder", async () => { @@ -355,14 +372,15 @@ test("the design-page picker frame renders as specified (ascii snapshot)", () => items: [{ value: "x", label: ".credentials.json", hint: "credentials never sync" }], }, ]); - assert.deepEqual(renderMulti(state, theme, 24), [ - "* What should travel? 1 of 1 selected", - "| -- Skills ------------------------------------", - "| > [x] boxd-cli", - "| -- Never leaves this machine -----------------", - "| x .credentials.json credentials never sync", - "| Credentials never sync.", - "+ up/down move - space select - tab next - a group - i invert - / filter - enter confirm", + assert.deepEqual(renderMulti(state, theme, 24, 40), [ + "* What should travel? 1 of 1 picked ", + "| space picks - enter continues with what is checked", + "|", + "| Skills", + `| ${" [x] boxd-cli".padEnd(34)}`, + "|", + "| x 1 item never leaves this machine - v to view", + "+ space pick - enter continue - ? keys", ]); }); From 17d0c930dbd2152463c667597ff5b211b85029f8 Mon Sep 17 00:00:00 2001 From: Favour Ohans Date: Wed, 16 Sep 2026 23:55:24 +0100 Subject: [PATCH 2/2] review round 1: TOML keys only, the review yields before its prompt, separators go through the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildReviewLines' TOML annotation now accepts only lines shaped like a bare-key assignment: section headers, multi-line array elements and closing brackets are content, and content never reaches the review screen — the reviewer reproduced a literal token value printed by the previous line-splitting. A planted-value test pins it. The review screen fits itself before rendering: head of the tree, an elision count, and always the closing consent-accounting line, so a tall bundle can never scroll the Pack it? prompt and its keys off screen (renderLive slices top-first). fitReviewLines is exported and unit-tested. Prose separators are spelled as the mid-dot once and normalized by the UI that renders them — the theme separator in the picker, a hyphen in plain mode — closing the stray hardcoded dot in the intro subtitle and making the hooks coach line match the drawing. The skip path now also echoes the scripted spelling, so a plain-mode user who declined precisely because they wanted a different destination leaves with the flags in hand. And v/? during filtering type into the query, pinned by a reducer test. --- src/commands/guided.ts | 41 ++++++++++++++++++++++++++++++++--------- src/main.ts | 2 +- test/guided.test.mjs | 35 +++++++++++++++++++++++++++++++++++ test/tui.test.mjs | 10 ++++++++++ 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/src/commands/guided.ts b/src/commands/guided.ts index a676971..4c99875 100644 --- a/src/commands/guided.ts +++ b/src/commands/guided.ts @@ -245,7 +245,7 @@ export async function runGuided(io: CommandIo, mode: GuidedMode, overrides: Guid const picked = await ui.groupMultiselect( "Which hooks may travel?", [hookGroup], - "hooks run shell commands on the target; none travel unless you pick them", + "hooks run shell commands on the target \u00b7 none travel unless you pick them", ); if (picked === null) return 2; hooks = picked; @@ -304,7 +304,10 @@ export async function runGuided(io: CommandIo, mode: GuidedMode, overrides: Guid const choice = await ui.review(buildReviewLines(plan, hooks, plugins, report.excluded.length), destLabel(dest)); if (choice === null) return 2; if (choice === "skip") { - ui.outro("Nothing was written."); + ui.outro( + "Nothing was written.", + `To pack a different way, scripted: ${flagEcho({ skips, plugins, hooks, allowSecrets, dest })}`, + ); return 0; } if (choice === "dest") { @@ -335,6 +338,18 @@ function destLabel(dest: string): string { return dest === "agent-sync-bundle" ? "agent-sync-bundle/" : dest; } +// The review screen must never scroll its own prompt away: renderLive slices +// top-first, so a tree taller than the terminal would cut "Pack it?". The +// tree yields instead — head lines, an elision count, and always the closing +// consent-accounting line. +export function fitReviewLines(lines: string[], budget: number): string[] { + if (lines.length <= budget || budget < 4) return lines.slice(0, Math.max(budget, 4)); + const tail = lines.slice(-2); + const head = lines.slice(0, budget - 3); + const elided = lines.length - head.length - tail.length; + return [...head, `... ${elided} more`, ...tail]; +} + // The bundle as a readable tree: skill directories aggregate to a count and // size, config files name the keys they carry, and the closing line accounts // for consents and exclusions, so the screen is the manifest in prose. @@ -351,13 +366,16 @@ export function buildReviewLines( if (name === "CLAUDE.md" || name === "AGENTS.md") return "memory"; if (name.endsWith(".json") || name.endsWith(".toml")) { try { + // TOML lines must look like a bare-key assignment to count as a key: + // anything else (section headers, multi-line array elements, closing + // brackets) is CONTENT and must never reach the screen. const keys = name.endsWith(".json") ? Object.keys(JSON.parse(content.toString("utf8")) as Record) : content .toString("utf8") .split("\n") - .map((line) => line.split("=")[0]?.trim() ?? "") + .map((line) => /^\s*([A-Za-z0-9_.-]+)\s*=/.exec(line)?.[1] ?? "") .filter((key) => key.length > 0); if (keys.length > 0) return keys.sort().join(", "); } catch { @@ -633,12 +651,15 @@ function pickerUi(overrides: GuidedOverrides & { wordmark?: boolean }): GuidedUi const g = theme.glyphs; const bar = theme.paint("accent", g.bar); let open = false; + // Prose from the flow layer spells separators as the unicode mid-dot; the + // UI owns rendering, so it swaps in the theme separator (ascii "-"). + const fmt = (text: string): string => text.replaceAll("\u00b7", g.sep); return { intro(title, subtitle, facts) { if (overrides.wordmark === true) { screen.open(); open = true; - screen.commit(["", ...wordmarkLines(theme, screen.columns), theme.paint("dim", subtitle), ""]); + screen.commit(["", ...wordmarkLines(theme, screen.columns), theme.paint("dim", fmt(subtitle)), ""]); flow.intro(title); } else { flow.intro(title, subtitle); @@ -651,7 +672,7 @@ function pickerUi(overrides: GuidedOverrides & { wordmark?: boolean }): GuidedUi }, async groupMultiselect(message, groups, coach) { const options: { coach?: string } = {}; - if (coach !== undefined) options.coach = coach; + if (coach !== undefined) options.coach = fmt(coach); const result = await flow.groupMultiselect(message, groups, options); if (result.cancelled) open = false; return result.cancelled ? null : result.value; @@ -672,10 +693,11 @@ function pickerUi(overrides: GuidedOverrides & { wordmark?: boolean }): GuidedUi async review(lines, dest) { const message = "This is what leaves the machine"; for (;;) { + const fitted = fitReviewLines(lines, Math.max(4, screen.rows - 6)); screen.renderLive([ `${theme.paint("accent", g.stepActive)} ${theme.paint("bright", message)}`, bar, - ...lines.map((line) => `${bar} ${line.length > 0 ? line : ""}`), + ...fitted.map((line) => `${bar} ${line.length > 0 ? line : ""}`), bar, `${bar} ${theme.paint("bright", "Pack it?")} ${theme.paint("ok", dest)} ${theme.paint("dim", "(y / n / d changes destination)")}`, `${theme.paint("accent", g.railEnd)} ${theme.paint("dim", ["y pack", "d destination", "esc cancel"].join(` ${g.sep} `))}`, @@ -726,9 +748,10 @@ function pickerUi(overrides: GuidedOverrides & { wordmark?: boolean }): GuidedUi function plainUi(io: CommandIo, overrides: GuidedOverrides): GuidedUi { const plain = overrides.plain ?? new Plain(); + const fmt = (text: string): string => text.replaceAll("\u00b7", "-"); return { intro(title, subtitle, facts) { - plain.say(`${title} — ${subtitle} (plain mode)`); + plain.say(`${title} - ${fmt(subtitle)} (plain mode)`); for (const fact of facts) plain.say(fact); plain.say(""); }, @@ -736,7 +759,7 @@ function plainUi(io: CommandIo, overrides: GuidedOverrides): GuidedUi { for (const line of lines) plain.say(line); }, async groupMultiselect(message, groups, coach) { - if (coach !== undefined) plain.say(coach); + if (coach !== undefined) plain.say(fmt(coach)); const result = await plain.groupMultiselect(message, groups); if (result.cancelled) io.err("Cancelled. Nothing was written."); return result.cancelled ? null : result.value; @@ -753,7 +776,7 @@ function plainUi(io: CommandIo, overrides: GuidedOverrides): GuidedUi { }, // Plain mode reads the same tree and answers one y/N; changing the // destination in plain mode is the scripted flags' job, which the echo - // teaches at the end of every run. + // teaches on pack and on skip alike. async review(lines, dest) { plain.say("This is what leaves the machine:"); for (const line of lines) plain.say(line); diff --git a/src/main.ts b/src/main.ts index e739e3d..180660f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -19,7 +19,7 @@ export { Plain, plainModeRequested } from "./tui/plain.js"; export type { ScanReport, ScanItem } from "./scan/types.js"; export { collectExport, skipToken, MANIFEST_SCHEMA_VERSION } from "./export/collect.js"; export type { Manifest, ManifestPlugin, ExportPlan } from "./export/collect.js"; -export { chooseEntry, runGuided, runGuidedApply, flagEcho, applyFlagEcho, buildTravelGroups, buildHookGroup, buildReviewLines } from "./commands/guided.js"; +export { chooseEntry, runGuided, runGuidedApply, flagEcho, applyFlagEcho, buildTravelGroups, buildHookGroup, buildReviewLines, fitReviewLines } from "./commands/guided.js"; export { wordmarkLines, wordmarkWidth } from "./tui/wordmark.js"; export { scanContentForSecrets } from "./export/secrets.js"; export { createTar } from "./export/tar.js"; diff --git a/test/guided.test.mjs b/test/guided.test.mjs index e6053a3..66d3386 100644 --- a/test/guided.test.mjs +++ b/test/guided.test.mjs @@ -438,6 +438,41 @@ test("the review tree aggregates skill dirs, names config keys, and accounts for assert.match(text, /hooks: hooks\.PostToolUse \| plugins: none \| 5 excluded items stayed behind/); }); +test("the review tree never prints TOML values, section headers, or array elements", async () => { + const { buildReviewLines } = await import("../dist/main.js"); + const toml = [ + 'model = "gpt"', + "[mcp_servers.secretsvc]", + 'command = "npx"', + "args = [", + ' "--token",', + ' "sk-live-planted-value-123",', + "]", + ].join("\n"); + const plan = { + entries: [{ path: "codex/config.toml", content: Buffer.from(toml), executable: false }], + manifest: {}, + skipped: [], + secretFindings: [], + diagnostics: [], + }; + const text = buildReviewLines(plan, [], [], 0).join("\n"); + assert.ok(!text.includes("sk-live-planted-value-123"), "a TOML value reached the review screen"); + assert.ok(!text.includes("secretsvc"), "a TOML section header reached the review screen"); + assert.ok(!text.includes("]"), "array structure reached the review screen"); + assert.match(text, /config\.toml\s+args, command, model/); +}); + +test("a tall review tree yields to keep the prompt on screen", async () => { + const { fitReviewLines } = await import("../dist/main.js"); + const lines = [...Array.from({ length: 30 }, (_, i) => `file-${i}`), "", "hooks: none | plugins: none | 0 excluded items stayed behind"]; + const fitted = fitReviewLines(lines, 12); + assert.equal(fitted.length, 12); + assert.equal(fitted[fitted.length - 1], "hooks: none | plugins: none | 0 excluded items stayed behind"); + assert.match(fitted[fitted.length - 3], /\.\.\. \d+ more/); + assert.deepEqual(fitReviewLines(["a", "b"], 12), ["a", "b"], "short trees pass through untouched"); +}); + test("review keys: n leaves nothing written, d changes the destination", async () => { const destDir = join(root, "review-keys-out"); mkdirSync(destDir, { recursive: true }); diff --git a/test/tui.test.mjs b/test/tui.test.mjs index eb56549..f01e83b 100644 --- a/test/tui.test.mjs +++ b/test/tui.test.mjs @@ -299,6 +299,16 @@ test("filtered bulk operations touch only visible items", () => { assert.equal(zero.selected.size, 0, "invert with zero matches is a no-op"); }); +test("while filtering, v and ? type into the query instead of toggling views", () => { + const state = buildMultiState("pick", GROUPS); + reduceMulti(state, press("/")); + reduceMulti(state, press("v")); + reduceMulti(state, press("?")); + assert.equal(state.query, "v?"); + assert.equal(state.lockedExpanded, false); + assert.equal(state.helpExpanded, false); +}); + test("group headers survive filtering, and the filter line shows the match count", () => { const theme = asciiTheme(); const state = buildMultiState("pick", GROUPS);