From e752f782fb80cb51d351fdd35c2d494d4dff137d Mon Sep 17 00:00:00 2001 From: Favour Ohans Date: Thu, 17 Sep 2026 00:31:45 +0100 Subject: [PATCH] Picker polish from first real use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question is now literal: What goes in the bundle?, and the hooks step matches (Which hooks go in the bundle?, coach line included) — travel read as weird in front of the real screen. Facts counts pluralize (1 plugin, not 1 plugins). The exclusions line leaves the picker entirely: the review's closing accounting line is where the leaves-the-machine story lives, so the picker shows only choices. And the active row and count chip stop using inverse — on 256-color terminals they get a quiet dark band (48;5;236), elsewhere bold, never the white glare block. --- src/commands/guided.ts | 42 +++++++++++++++++------------------------- src/tui/components.ts | 4 ++-- src/tui/theme.ts | 15 +++++++++++++-- test/guided.test.mjs | 6 ++++-- test/tui.test.mjs | 3 +++ 5 files changed, 39 insertions(+), 31 deletions(-) diff --git a/src/commands/guided.ts b/src/commands/guided.ts index 4c99875..704545d 100644 --- a/src/commands/guided.ts +++ b/src/commands/guided.ts @@ -131,17 +131,8 @@ export function buildTravelGroups(report: ScanReport, others: ScanReport[] = []) }), }); } - if (report.excluded.length > 0) { - groups.push({ - title: "Never leaves this machine", - locked: true, - items: report.excluded.map((entry) => ({ - value: entry.path, - label: entry.path.split("/").pop() ?? entry.path, - hint: entry.reason, - })), - }); - } + // Exclusions no longer occupy the picker (the owner's call): the review's + // closing line accounts for them where the leaves-the-machine story lives. return groups; } @@ -170,19 +161,20 @@ function factsLines(reports: ScanReport[]): string[] { const title = titles[report.agent].padEnd(13); const userItems = report.items.filter((item) => item.scope === "user"); const counts: string[] = []; - for (const [kind, label] of [ - ["skill", "skills"], - ["subagent", "subagents"], - ["command", "commands"], - ["memory", "memory"], - ["settings", "settings"], - ["hook", "hooks"], - ["plugin", "plugins"], - ["mcp_server", "MCP servers"], + for (const [kind, singular, plural] of [ + ["skill", "skill", "skills"], + ["subagent", "subagent", "subagents"], + ["command", "command", "commands"], + ["memory", "memory", "memory"], + ["settings", "settings", "settings"], + ["hook", "hook", "hooks"], + ["plugin", "plugin", "plugins"], + ["mcp_server", "MCP server", "MCP servers"], ] as const) { const total = userItems.filter((item) => item.kind === kind).length; if (total === 0) continue; - counts.push(kind === "settings" || kind === "memory" ? label : `${total} ${label}`); + if (kind === "settings" || kind === "memory") counts.push(singular); + else counts.push(`${total} ${total === 1 ? singular : plural}`); } return [`${title} ${counts.length > 0 ? counts.join(" · ") : "nothing to sync"}`]; }); @@ -220,7 +212,7 @@ export async function runGuided(io: CommandIo, mode: GuidedMode, overrides: Guid const groups = buildTravelGroups(report, reports.slice(1)); const hookGroup = buildHookGroup(report); - if (groups.every((group) => group.locked === true) && hookGroup === null) { + if (groups.length === 0 && hookGroup === null) { io.err("Nothing to sync yet. Run `agent-sync scan` to see what agent-sync looks for."); return 0; } @@ -229,7 +221,7 @@ export async function runGuided(io: CommandIo, mode: GuidedMode, overrides: Guid try { 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); + const travel = await ui.groupMultiselect("What goes in the bundle?", groups); if (travel === null) return 2; const chosen = new Set(travel); // Plugins are opt-in (--plugin), everything else is opt-out (--skip), so an @@ -243,9 +235,9 @@ 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?", + "Which hooks go in the bundle?", [hookGroup], - "hooks run shell commands on the target \u00b7 none travel unless you pick them", + "hooks run shell commands on the target \u00b7 none go unless you pick them", ); if (picked === null) return 2; hooks = picked; diff --git a/src/tui/components.ts b/src/tui/components.ts index dada259..547496c 100644 --- a/src/tui/components.ts +++ b/src/tui/components.ts @@ -219,7 +219,7 @@ export function renderMulti(state: MultiState, theme: Theme, maxRows: numb const visible = visibleItems(state); const lines: string[] = []; - const chip = theme.paint("inverse", ` ${state.selected.size} of ${state.items.length} picked `); + const chip = theme.paint("highlight", ` ${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( @@ -247,7 +247,7 @@ export function renderMulti(state: MultiState, theme: Theme, maxRows: numb 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)}`); + rows.push(`${bar} ${theme.paint("highlight", content)}`); } else { const painted = state.selected.has(item.id) ? theme.paint("ok", mark) : theme.paint("dim", mark); rows.push(`${bar} ${painted} ${item.label}`); diff --git a/src/tui/theme.ts b/src/tui/theme.ts index da2a819..e642545 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -104,7 +104,7 @@ export function colorEnabled(options: ThemeEnvironment = {}): boolean { return options.isTTY ?? process.stderr.isTTY === true; } -export type Style = "accent" | "dim" | "bright" | "warn" | "bad" | "ok" | "strike" | "inverse"; +export type Style = "accent" | "dim" | "bright" | "warn" | "bad" | "ok" | "strike" | "inverse" | "highlight"; const CODES: Record = { accent: ["\x1b[36m", "\x1b[39m"], @@ -115,8 +115,18 @@ const CODES: Record = { ok: ["\x1b[32m", "\x1b[39m"], strike: ["\x1b[9m", "\x1b[29m"], inverse: ["\x1b[7m", "\x1b[27m"], + highlight: ["\x1b[48;5;236m", "\x1b[49m"], }; +// A washed-out inverse block reads as glare on dark terminals; where the +// terminal advertises 256 colors the highlight is a quiet dark-gray band, +// and elsewhere it falls back to bold rather than inverse. +export function supports256Colors(options: ThemeEnvironment = {}): boolean { + const env = options.env ?? process.env; + if (env.COLORTERM !== undefined && env.COLORTERM !== "") return true; + return (env.TERM ?? "").includes("256color"); +} + export interface Theme { glyphs: Glyphs; unicode: boolean; @@ -127,13 +137,14 @@ export interface Theme { export function createTheme(options: ThemeEnvironment = {}): Theme { const unicode = unicodeSupported(options); const color = colorEnabled(options); + const deep = supports256Colors(options); return { glyphs: unicode ? UNICODE : ASCII, unicode, color, paint(style, text) { if (!color || text.length === 0) return text; - const [open, close] = CODES[style]; + const [open, close] = style === "highlight" && !deep ? CODES.bright : CODES[style]; return `${open}${text}${close}`; }, }; diff --git a/test/guided.test.mjs b/test/guided.test.mjs index 66d3386..b899938 100644 --- a/test/guided.test.mjs +++ b/test/guided.test.mjs @@ -356,8 +356,10 @@ 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.ok(!/never leaves? this machine/.test(rendered), "exclusions left the picker by design"); + assert.match(rendered, /What goes in the bundle\?/); assert.match(rendered, /This is what leaves the machine/); + assert.match(rendered, /excluded item(s)? stayed behind/); assert.match(rendered, /agent-sync export setup\.tgz/); }); @@ -376,7 +378,7 @@ test("guided plain flow: scripted answers write the bundle and close stdin", asy assert.ok(existsSync(join(destDir, "setup.tgz"))); const transcript = said.join(""); assert.match(transcript, /plain mode/); - assert.match(transcript, /never leave|never included/i); + assert.match(transcript, /excluded item(s)? stayed behind/); assert.match(transcript, /agent-sync export setup\.tgz/); }); diff --git a/test/tui.test.mjs b/test/tui.test.mjs index f01e83b..c82b717 100644 --- a/test/tui.test.mjs +++ b/test/tui.test.mjs @@ -48,6 +48,9 @@ test("theme: unicode detection and color ladder", () => { assert.equal(ascii.paint("accent", "hi"), "hi"); const colored = createTheme({ env: { FORCE_COLOR: "1" }, platform: "darwin", isTTY: true }); assert.match(colored.paint("accent", "hi"), /\x1b\[36mhi\x1b\[39m/); + const deep = createTheme({ env: { FORCE_COLOR: "1", TERM: "xterm-256color" }, platform: "darwin", isTTY: true }); + assert.match(deep.paint("highlight", "row"), /\x1b\[48;5;236mrow\x1b\[49m/, "256-color terminals get the quiet band"); + assert.match(colored.paint("highlight", "row"), /\x1b\[1mrow\x1b\[22m/, "basic color falls back to bold, never inverse"); }); test("visual width and truncation never let a line exceed budget", () => {