diff --git a/.cue.profile b/.cue.profile index f5bd37c1..73eefa07 100644 --- a/.cue.profile +++ b/.cue.profile @@ -1 +1 @@ -core +core+skill-writer diff --git a/profiles/core/profile.yaml b/profiles/core/profile.yaml index cc58464f..25ed0860 100644 --- a/profiles/core/profile.yaml +++ b/profiles/core/profile.yaml @@ -151,6 +151,7 @@ skills: # ── Security ── - security/trivy-scan # /trivy-scan — pre-merge supply-chain gate (Trivy: dependency CVEs + secrets + IaC misconfig). Hard-blocks HIGH/CRITICAL. Invoked by code-review-deep Pass 0 + ship Step 9.0. # ── Browser ── + - browser/ego-browser # DEFAULT browser path. One JS heredoc drives a real Chromium (navigate, forms, clicks, snapshot, screenshots) instead of many MCP tool-call round trips — chosen for token cost. Reuses the user's real logins. Linux port lives in ~/Documents/ego-lite-linux (package/ego-linux). - browser/lightpanda # Lightpanda — fast headless browser for scraping, DOM dump, CDP. Pairs with lightpanda MCP. # ── Source-fetching ── - tools/opensrc # opensrc — fetch dependency source (npm/PyPI/crates/GitHub) so agents read implementations, not just types diff --git a/profiles/frontend-design/profile.yaml b/profiles/frontend-design/profile.yaml new file mode 100644 index 00000000..822aaacc --- /dev/null +++ b/profiles/frontend-design/profile.yaml @@ -0,0 +1,60 @@ +name: frontend-design +icon: "🖌️" +description: Distinctive frontend visual design — Anthropic's frontend-design plugin as the method, plus a browser to verify what actually rendered +inherits: web-frontend-base +recommends: + - frontend # combine to implement the design in React/Next/Svelte + - designer # combine for the heavy kit: brandkit, imagegen, taste-skills, Figma + - medusa-vite # combine to design a Medusa + Vite storefront + - medusa-next # combine to design a Medusa + Next.js storefront +playbooks: + # Inherits ship-feature, triage-bug, sprint from core. + - designer-workflow # brief → layout → build → design-review → polish +persona: | + You design frontends that could not be mistaken for anyone else's. The + `frontend-design` plugin skill is the method — load it and follow its + process (brainstorm → explore → plan → critique → build → critique again) + rather than improvising a look. + + - **Name the subject before you design.** If the brief doesn't pin down what + the product is, who it's for, and the page's single job, pin it yourself + and say so. Distinctive choices come from the subject's own world — its + materials, vernacular, artifacts — not from a palette generator. + - **The template answer is the one to beat.** A big number with a small + label, a gradient accent, 01/02/03 markers: use them only when the content + genuinely is a sequence or a statistic. Otherwise they read as defaults. + - **Typography carries the personality.** Pair display and body faces + deliberately, set an intentional scale. Not a neutral delivery vehicle. + - **Take one real aesthetic risk you can justify.** Then justify it out loud. + - **Motion is deliberate or absent.** One orchestrated moment beats scattered + effects; excess animation is itself a tell that a machine made this. + - **Match complexity to the vision.** Maximalist needs elaborate execution; + minimal needs precision in spacing, type, and detail. + - **No em-dashes in UI copy.** Copy makes a design feel templated as fast as + the layout does — write it with the same intent. + + ## Verify what rendered, not what you wrote + + A design claim is unproven until you have looked at it. `web-frontend-base` + supplies `agent-browser` and the screenshot skill — open the page, capture + it, and check the result against the brief before calling it done. Reading + your own CSS is not verification. + + ## When combined with other profiles + + - With **frontend**: you own the visual layer, that profile owns the + component/state/a11y correctness bar. + - With **designer**: reach for that profile's brandkit, image generation, and + Figma MCP when the job needs assets or an existing design file. This + profile stays lean on purpose — combine rather than duplicate. +skills: + local: + # Creation is the plugin's job (see `plugins:` below); this covers the + # plugin's own "critique again" step with a structured review pass. + - gstack/design-review +plugins: + # Anthropic's official frontend-design plugin — the aesthetic-direction + # method this profile is built around. Already shipped by designer/commerce/ + # studio/webshop; here it is the centrepiece rather than an add-on. + - frontend-design@claude-plugins-official +mcps: [] # browser automation via the agent-browser CLI (no MCP needed) diff --git a/resources/hooks/liedetector-tag-density.sh b/resources/hooks/liedetector-tag-density.sh index 7dec7710..41fa5517 100755 --- a/resources/hooks/liedetector-tag-density.sh +++ b/resources/hooks/liedetector-tag-density.sh @@ -3,13 +3,15 @@ # # The integrity protocol asks the model to mark decision-relevant claims with # confidence tags (🟢 [VERIFIED], 🟡 [INFERRED ~80%], 🟠 [GUESSED ~30%], -# 🔴 [UNKNOWN], etc.). Two failure modes degrade that signal: +# 🔴 [UNKNOWN], etc.). Three failure modes degrade that signal: # (a) a long, substantive response with ZERO tags — no confidence signal at # all where the reader most needs one; -# (b) tag-spam — a tag on nearly every clause, which trains the reader to +# (b) a yellow/orange tag with no ~N%, or an ~N% that isn't on its tier's +# ladder — the tier alone can't order claims against each other; +# (c) tag-spam — a tag on nearly every clause, which trains the reader to # ignore the tags entirely. -# This hook nudges on both. It NEVER blocks the Stop; it only prints one line -# to stderr (which Claude Code surfaces) so the model can self-correct. +# This hook nudges on all three. It NEVER blocks the Stop; it only prints one +# line to stderr (which Claude Code surfaces) so the model can self-correct. # # Honest about its limits: this is a crude heuristic. It cannot tell whether a # response was actually "decision-relevant" — it uses response length (>1500 @@ -18,7 +20,9 @@ # narrative explanation). To keep the false-positive rate low it only fires on # CLEARLY long, completely tag-free responses, and stays silent otherwise. The # density check needs at least 4 tags before it can call something "spam". -# Treat every nudge as a question ("did this response need tags?"), not a verdict. +# Treat those two nudges as a question ("did this response need tags?"), not a +# verdict. Check (b) is the exception: the protocol names the legal ~N% values, +# so a missing or off-ladder percent is a fact, not a heuristic. # # Reliability: parsing the transcript can fail for many reasons (missing file, # truncated JSONL, schema drift). Every failure path FAILS OPEN — any error @@ -55,6 +59,18 @@ TAGS = ("VERIFIED", "KNOWN", "INFERRED", "ASSUMED", "GUESSED", "STALE", "UNKNOWN", "CORRECTION") TAG_RE = re.compile(r"\[(?:%s)[^\]]*\]" % "|".join(TAGS)) +# The protocol requires a ~N% on every yellow and orange tag, drawn from that +# tier's ladder. Yellow spans ~50-85%, orange ~20-45%, so the ladders don't +# overlap each other or green (>=90%). +LADDER = { + "INFERRED": {"50", "60", "70", "80"}, + "ASSUMED": {"50", "60", "70", "80"}, + "GUESSED": {"20", "30", "40"}, + "STALE": {"20", "30", "40"}, +} +CAL_RE = re.compile(r"\[(%s)([^\]]*)\]" % "|".join(LADDER)) +PCT_RE = re.compile(r"~\s*(\d+)\s*%") + LONG_CHARS = 1500 # proxy for "substantive response" SPAM_MIN_TAGS = 4 # need real density before calling it spam SPAM_WORDS_PER_TAG = 25 # > 1 tag / 25 words = spam @@ -106,7 +122,32 @@ if n_chars > LONG_CHARS and n_tags == 0: "Skip this nudge with [skip-tag-density]." % n_chars) sys.exit(0) -# (b) tag-spam → density trains the reader to ignore the tags. +# (b) calibration format → a yellow/orange tag with no ~N%, or with one that +# isn't on its tier's ladder. Unlike the two heuristics around it this is +# an exact check: the protocol names the legal values, so a miss is a +# violation rather than a guess about intent. +missing, offladder = [], [] +for tag, rest in CAL_RE.findall(last): + pct = PCT_RE.search(rest) + if not pct: + missing.append(tag) + elif pct.group(1) not in LADDER[tag]: + offladder.append("%s ~%s%%" % (tag, pct.group(1))) + +if missing or offladder: + parts = [] + if missing: + parts.append("%d tag(s) with no ~N%% (%s)" + % (len(missing), ", ".join(sorted(set(missing))))) + if offladder: + parts.append("%d off-ladder (%s)" + % (len(offladder), ", ".join(sorted(set(offladder))))) + print("liedetector: %s. Yellow ([INFERRED]/[ASSUMED]) takes ~50/60/70/80%%, " + "orange ([GUESSED]/[STALE]) takes ~20/30/40%% — nothing else. " + "Skip with [skip-tag-density]." % "; ".join(parts)) + sys.exit(0) + +# (c) tag-spam → density trains the reader to ignore the tags. if n_tags >= SPAM_MIN_TAGS and n_words > 0: words_per_tag = n_words / n_tags if words_per_tag < SPAM_WORDS_PER_TAG: diff --git a/resources/hooks/tag-audit.sh b/resources/hooks/tag-audit.sh index 1ca576c5..9e4dbc0c 100755 --- a/resources/hooks/tag-audit.sh +++ b/resources/hooks/tag-audit.sh @@ -11,6 +11,12 @@ # - For every [KNOWN] claim that mentions a time-sensitive subject # (versions, "latest", "current"), warn — training data goes stale. # +# It also reports the turn's TAG MIX — the green/yellow/orange/red split of +# the claims, with a "% grounded" and "% guess-or-worse" readout. The audits +# above catch violations; the mix answers the plainer question the tags exist +# for: how much of this answer did the model actually check? Prints on turns +# with >=3 tags. Disable with CUE_TAG_MIX_OFF=1. +# # When mismatches are detected, the hook emits a "⚠ Tag audit" block to # stderr (which Claude Code surfaces). It never blocks; it only flags. # Suppress per-turn via [skip-tag-audit] anywhere in the assistant response. @@ -46,8 +52,10 @@ touch "$throttle" last_user_line=$(awk 'BEGIN{n=0; last=0} {n++} /"type":"user"/{last=n} END{print last}' "$transcript_path") [ "$last_user_line" = "0" ] && exit 0 -# Slice the transcript: just this turn's lines. -turn_jsonl="$CACHE_DIR/turn.jsonl" +# Slice the transcript: just this turn's lines. Session-scoped — concurrent +# Claude sessions share $CACHE_DIR, and an unscoped path lets one session's +# Stop hook overwrite another's slice mid-read. +turn_jsonl="$CACHE_DIR/turn.${session_id:-default}.jsonl" tail -n +"$last_user_line" "$transcript_path" > "$turn_jsonl" # ─── Extract assistant text + tool_use names from this turn ──────────────── @@ -73,10 +81,22 @@ if grep -qF "[skip-tag-audit]" <<< "$assistant_text"; then exit 0; fi # ─── Count tags in the response ──────────────────────────────────────────── # Match [VERIFIED], 🟢 [VERIFIED], `[VERIFIED]`, etc. Single regex with # optional brackets/backticks. -verified_count=$(grep -oE '\[VERIFIED[^]]*\]' <<< "$assistant_text" | wc -l | tr -d '\n') -known_count=$(grep -oE '\[KNOWN[^]]*\]' <<< "$assistant_text" | wc -l | tr -d '\n') -verified_count=${verified_count:-0} -known_count=${known_count:-0} +count_tag() { grep -oE "\[$1[^]]*\]" <<< "$assistant_text" | wc -l | tr -d '\n'; } + +verified_count=$(count_tag VERIFIED); verified_count=${verified_count:-0} +known_count=$(count_tag KNOWN); known_count=${known_count:-0} +inferred_count=$(count_tag INFERRED); inferred_count=${inferred_count:-0} +assumed_count=$(count_tag ASSUMED); assumed_count=${assumed_count:-0} +guessed_count=$(count_tag GUESSED); guessed_count=${guessed_count:-0} +stale_count=$(count_tag STALE); stale_count=${stale_count:-0} +unknown_count=$(count_tag UNKNOWN); unknown_count=${unknown_count:-0} +correction_count=$(count_tag CORRECTION); correction_count=${correction_count:-0} + +green_count=$((verified_count + known_count)) +yellow_count=$((inferred_count + assumed_count)) +orange_count=$((guessed_count + stale_count)) +red_count=$unknown_count +claim_count=$((green_count + yellow_count + orange_count + red_count)) # ─── Count verification tool calls ───────────────────────────────────────── # A "verification action" is one of: @@ -157,7 +177,22 @@ if [ "$stale_known" -gt 0 ]; then warnings+=("⚠ Tag audit: ${stale_known}× [KNOWN] tag on time-sensitive subject(s) (versions / 'latest' / 'current'). Training data goes stale. Downgrade to [STALE] or re-verify via web search.") fi -[ "${#warnings[@]}" -eq 0 ] && exit 0 +# ─── Tag mix: how much of this turn was grounded vs guessed ──────────────── +# Everything above detects protocol *violations*. This block answers the +# plainer question the tags exist for: how much of what I just said did I +# actually check? Prints whenever the turn carries enough tags to form a +# distribution (>=3), so one-tag asides stay quiet. Disable: CUE_TAG_MIX_OFF=1. +mix_line="" +if [ "${CUE_TAG_MIX_OFF:-}" != "1" ] && [ "$claim_count" -ge 3 ]; then + green_pct=$((green_count * 100 / claim_count)) + soft_pct=$(((orange_count + red_count) * 100 / claim_count)) + mix_line="$(printf '🕵 Tag mix (%d claims): 🟢%d 🟡%d 🟠%d 🔴%d — %d%% grounded, %d%% guess-or-worse' \ + "$claim_count" "$green_count" "$yellow_count" "$orange_count" "$red_count" \ + "$green_pct" "$soft_pct")" + [ "$correction_count" -gt 0 ] && mix_line="${mix_line} | ${correction_count}x [CORRECTION]" +fi + +[ "${#warnings[@]}" -eq 0 ] && [ -z "$mix_line" ] && exit 0 # ─── Opt-in: auto-log detected miscalibrations to the calibration scoreboard ─ # The always-on audit detects exactly the events the scoreboard wants to tally @@ -190,9 +225,12 @@ fi # ─── Emit warnings to stderr (Claude Code surfaces) ──────────────────────── { printf '\n' - for w in "${warnings[@]}"; do printf '%s\n' "$w"; done - printf ' (turn tool calls: %d verification, %d non-verification | suppress with [skip-tag-audit])\n' \ - "$verification_count" "$non_verification_count" + [ -n "$mix_line" ] && printf '%s\n' "$mix_line" + if [ "${#warnings[@]}" -gt 0 ]; then + for w in "${warnings[@]}"; do printf '%s\n' "$w"; done + printf ' (turn tool calls: %d verification, %d non-verification | suppress with [skip-tag-audit])\n' \ + "$verification_count" "$non_verification_count" + fi } >&2 exit 0 diff --git a/resources/personas/integrity-protocol-compact.md b/resources/personas/integrity-protocol-compact.md index 2235bb55..28494fe6 100644 --- a/resources/personas/integrity-protocol-compact.md +++ b/resources/personas/integrity-protocol-compact.md @@ -12,7 +12,9 @@ Applies to every response. Flag uncertainty *before* the claim, never bury hedge - 🟠 `[STALE]` — true at training cutoff; re-check current docs. - 🔴 `[UNKNOWN]` — outside reliable knowledge; say so instead of fabricating. -Pick the *most specific* tag and **downgrade when in doubt** — false confidence hurts more than false hedging. Optional decile calibration on yellow/orange (`🟡 [INFERRED ~80%]`), required when the user must rank two of your suggestions. +Pick the *most specific* tag and **downgrade when in doubt** — false confidence hurts more than false hedging. + +**Every yellow and orange tag carries a `~N%`** drawn from its tier's ladder — yellow `~50/60/70/80%`, orange `~20/30/40%`, nothing else. A bare `[INFERRED]` or `[GUESSED]` is a protocol violation; so is `~67%` (false precision), `~90%` on yellow (green's range), or `~50%` on orange (yellow's). Skip the % on green and red — the tier already says it. Can't pick a value? You're in the wrong tier: downgrade. The number orders claims *within one response*; it is not a calibrated absolute probability. **Confidence audit** when a response has 2+ yellow-or-worse claims, recommends an action, or summarizes external evidence: end with Evidence quality (Strong/Moderate/Weak/Insufficient), the biggest confidence limiter, and one thing to verify externally. diff --git a/resources/personas/integrity-protocol.md b/resources/personas/integrity-protocol.md index 20993e92..72ae0561 100644 --- a/resources/personas/integrity-protocol.md +++ b/resources/personas/integrity-protocol.md @@ -24,11 +24,14 @@ Rewritten by Claude (Opus 4.7) from your hallucination-reduction draft. Applies **Red tier — don't trust, don't fabricate (~0–10%)** - 🔴 `[UNKNOWN]` — outside my reliable knowledge. I'm saying so instead of fabricating an answer. Hand off to a search or to the user. - **Optional percentage calibration on yellow/orange tags.** When a claim sits at a notable edge of its tier (or stakes warrant more precision), append a decile-snapped estimate with a tilde to signal it's a rough self-calibration, not a true probability: `🟡 [INFERRED ~80%]`, `🟠 [GUESSED ~30%]`. Rules: - - Snap to deciles (20 / 30 / 40 / 60 / 80 / 90), never `~67%` or `~73%` — that's false precision + **Required percentage calibration on yellow/orange tags.** Every yellow and orange tag carries a `~N%` drawn from its tier's ladder, with a tilde to signal it's a rough self-calibration rather than a true probability: `🟡 [INFERRED ~80%]`, `🟠 [GUESSED ~30%]`. Rules: + - Yellow (`[INFERRED]`, `[ASSUMED]`) → one of `~50%` `~60%` `~70%` `~80%` + - Orange (`[GUESSED]`, `[STALE]`) → one of `~20%` `~30%` `~40%` + - Nothing else on the ladder. Never `~67%` or `~73%` (false precision), never `~90%` on yellow (that's green's range) or `~50%` on orange (that's yellow's) + - A bare `[INFERRED]` / `[ASSUMED]` / `[GUESSED]` / `[STALE]` is a protocol violation - Always prefix `~` so the reader knows it's an estimate - Skip the % on green and red — the tier already says it - - Required when the user has to decide between two of your suggestions and the order of confidence matters more than the tier itself + - If you can't pick a value, you're in the wrong tier — downgrade to the one where the range fits - The number is meaningful as *relative* ordering across claims in the same response, *not* as a literal calibrated probability **Picking the tag.** Choose the *most specific* fit, never grade-inflate: diff --git a/src/commands/optimizer.ts b/src/commands/optimizer.ts index aed6dab7..77fa3e64 100644 --- a/src/commands/optimizer.ts +++ b/src/commands/optimizer.ts @@ -68,7 +68,7 @@ const KNOWN_CLIS = new Set([ "release-plz", "typos", "cargo-chef", "cargo-msrv", "cargo-readme", "maturin", "napi", "uniffi-bindgen", "bindgen", "cbindgen", "probe-rs", "cargo-embed", "cargo-binutils", "chisel", - "chromium", "chrome", "google-chrome", "microsoft-edge", + "chromium", "chrome", "google-chrome", "microsoft-edge", "ego-browser", "openssl", "ssh", "ncat", "netcat", "socat", "splunk", "elastic", "kibana", "logstash", "peepdf", "pdfid", "pdf-parser", "olevba", "oletools", diff --git a/src/lib/picker/card.test.ts b/src/lib/picker/card.test.ts index b8766357..8c7a570b 100644 --- a/src/lib/picker/card.test.ts +++ b/src/lib/picker/card.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { COMPACT_ROWS, renderCardFrame, type CardSuggestion } from "./card"; +import { displayWidth } from "./render-util"; const rust: CardSuggestion = { parts: ["rust", "secops"], @@ -34,30 +35,45 @@ describe("renderCardFrame", () => { expect(frame).toContain("/home/u/proj"); }); - test("shows a counter only when there is more than one suggestion", () => { - expect(renderCardFrame({ ...base, suggestions: [rust] })).not.toContain("1/1"); + /** The card's top border, where the page indicator rides. */ + const topBorder = (frame: string) => + plain(frame).split("\n").find((l) => l.startsWith("╭")) ?? ""; + + test("page dots appear only when there is more than one suggestion", () => { + expect(topBorder(renderCardFrame({ ...base, suggestions: [rust] }))).not.toContain("○"); const many = renderCardFrame({ ...base, suggestions: [rust, python], index: 1 }); - expect(many).toContain("2/2"); + // One dot per suggestion, filled on the one being shown. + expect(topBorder(many)).toContain("○ ●"); expect(many).toContain("🐍 python"); - expect(many).toContain("↹ next suggestion"); + expect(plain(many)).toContain("↹ next suggestion"); + }); + + test("falls back to a numeric page indicator past eight suggestions", () => { + const many = Array.from({ length: 12 }, () => python); + expect(topBorder(renderCardFrame({ ...base, suggestions: many, index: 2 }))).toContain("3/12"); }); - test("states the pin decision on the launch key", () => { - expect(renderCardFrame({ ...base, suggestions: [rust] })).toContain( - "⏎ launch · pins to this directory", - ); - expect(renderCardFrame({ ...base, suggestions: [rust], pin: false })).toContain("⏎ launch · no pin"); - const disabled = renderCardFrame({ ...base, suggestions: [rust], pinDisabled: true }); + test("shows the pin decision as a switch beside the launch button", () => { + const on = plain(renderCardFrame({ ...base, suggestions: [rust] })); + expect(on).toContain("⏎ launch"); + expect(on).toContain("p ● pin to this folder"); + const off = plain(renderCardFrame({ ...base, suggestions: [rust], pin: false })); + expect(off).toContain("p ○ pin to this folder"); + const disabled = plain(renderCardFrame({ ...base, suggestions: [rust], pinDisabled: true })); expect(disabled).toContain("⏎ launch"); - expect(disabled).not.toContain("pins to this directory"); - expect(disabled).not.toContain("p pin"); + expect(disabled).not.toContain("pin to this folder"); }); - test("warns about a heavy stack, stays quiet about a light one", () => { - expect(renderCardFrame({ ...base, suggestions: [{ ...rust, alwaysOn: 32_000 }] })).toContain( - "⚠ heavy: ~32k always-on", - ); - expect(renderCardFrame({ ...base, suggestions: [rust] })).not.toContain("⚠ heavy"); + test("meters the stack weight and only calls out a heavy one", () => { + const heavy = plain(renderCardFrame({ ...base, suggestions: [{ ...rust, alwaysOn: 32_000 }] })); + expect(heavy).toContain("~32k always-on"); + expect(heavy).toContain("⚠ heavy, slows the agent"); + expect(heavy).toContain("█"); + const light = plain(renderCardFrame({ ...base, suggestions: [rust] })); + expect(light).toContain("~6.0k always-on"); + expect(light).not.toContain("⚠ heavy"); + // A light stack still gets a meter — the bar is a comparison, not a warning. + expect(light).toContain("█"); }); test("help overlay replaces the body and lists every key", () => { @@ -69,10 +85,21 @@ describe("renderCardFrame", () => { test("degrades to a usable frame when there is nothing to suggest", () => { const frame = renderCardFrame({ ...base, suggestions: [] }); - expect(frame).toContain("no profiles available"); + expect(frame).toContain("nothing to suggest"); expect(frame).toContain("a browse every profile"); }); + test("the card's right border lands in one column on every row", () => { + const frame = plain(renderCardFrame({ ...base, suggestions: [{ ...rust, alwaysOn: 32_000 }] })); + const bordered = frame + .split("\n") + .filter((l) => l.startsWith("╭") || l.startsWith("╰") || (l.startsWith("│") && l.endsWith("│") && l.length > 2)); + // Width in terminal cells, not code points — the emoji labels are 2 cells wide. + const widths = new Set(bordered.map(displayWidth)); + expect(bordered.length).toBeGreaterThan(4); + expect(widths.size).toBe(1); + }); + test("ascii mode drops the emoji icons", () => { const frame = renderCardFrame({ ...base, suggestions: [rust], ascii: true }); expect(frame).toContain("rust"); @@ -98,6 +125,15 @@ describe("renderCardFrame", () => { expect(short).toContain("⏎ launch"); }); + test("the action rows shed words instead of wrapping a narrow terminal", () => { + for (const cols of [40, 56, 60, 72, 80, 120]) { + const frame = plain(renderCardFrame({ ...base, cols, suggestions: [rust, python] })); + for (const line of frame.split("\n")) expect(displayWidth(line)).toBeLessThanOrEqual(cols); + expect(frame).toContain("⏎ launch"); + expect(frame).toContain("esc quit"); + } + }); + test("caps the reason list at three lines", () => { const frame = renderCardFrame({ ...base, diff --git a/src/lib/picker/card.ts b/src/lib/picker/card.ts index b5c80abc..14a786c9 100644 --- a/src/lib/picker/card.ts +++ b/src/lib/picker/card.ts @@ -9,8 +9,23 @@ import { Prompt, type PromptOptions } from "@clack/core"; import { styleText } from "node:util"; -import { asciiIconsEnabled, clipToWidth, displayWidth, stripIconIfAscii } from "./render-util"; -import { formatOverheadBadge } from "./tally"; +import { asciiIconsEnabled, clipToWidth, stripIconIfAscii } from "./render-util"; +import { OVERHEAD_WARN_TOKENS } from "./tally"; +import { + BAR, + button, + cardBottom, + cardInner, + cardLine, + cardTop, + cardWidth, + fitLine, + formatAlwaysOn, + keyHints, + keyTable, + meterBar, + pageDots, +} from "./ui"; /** What the user asked the card to do. */ export type CardAction = "launch" | "edit" | "search" | "all"; @@ -48,8 +63,10 @@ export interface CardState { rows?: number; } -/** Terminal height below which the card renders without blank spacer lines. */ -export const COMPACT_ROWS = 16; +/** Terminal height below which the card renders without blank spacer lines. + * Set above the card's full height (~18 rows) so the squeeze happens *before* + * the frame would overflow, not after. */ +export const COMPACT_ROWS = 20; const KEY_HELP: ReadonlyArray<[string, string]> = [ ["⏎", "launch this stack"], @@ -65,96 +82,147 @@ const KEY_HELP: ReadonlyArray<[string, string]> = [ /** * Render one frame of the suggestion card. Pure — `styleText` is a no-op when * stdout isn't a TTY, so tests assert on plain text. + * + * Layout follows the grouped-inset idiom: the answer sits inside a rounded card + * whose left border continues clack's gutter, and the actions live below it — + * one filled pill for the thing you almost always want, a quiet keycap row for + * everything else. */ export function renderCardFrame(state: CardState): string { - const BAR = styleText("gray", "│"); + const bar = BAR(); const cols = state.cols ?? process.stdout.columns ?? 80; const compact = (state.rows ?? process.stdout.rows ?? 24) < COMPACT_ROWS; const ascii = state.ascii ?? asciiIconsEnabled(); const icon = (s: string) => stripIconIfAscii(s, ascii); - // Room for the "│ " gutter plus a right margin. - const width = Math.max(20, cols - 6); + const width = cardWidth(cols); + const inner = cardInner(width); const lines: string[] = []; - const blank = () => { - if (!compact) lines.push(BAR); + /** A blank card row — dropped on a short terminal, where height is scarcer + * than calm. */ + const airInCard = () => { + if (!compact) lines.push(cardLine(width)); }; - lines.push(BAR); + lines.push(bar); lines.push( - `${BAR} ${styleText("cyan", "◆")} ${styleText("bold", "cue")} ${styleText( + `${bar} ${styleText("cyan", "◆")} ${styleText("bold", "cue")} ${styleText( "dim", - clipToWidth(state.cwd, width - 6), + clipToWidth(state.cwd, width - 10), )}`, ); + lines.push(bar); if (state.help === true) { - blank(); - for (const [key, what] of KEY_HELP) { - const k = styleText("cyan", key.padEnd(8)); - lines.push(`${BAR} ${k}${styleText("dim", what)}`); - } - lines.push(BAR); - lines.push(`${BAR} ${styleText("dim", "? close help")}`); + lines.push(cardTop(width, "keys")); + airInCard(); + for (const row of keyTable(KEY_HELP)) lines.push(cardLine(width, row)); + airInCard(); + lines.push(cardBottom(width)); + lines.push(bar); + lines.push(`${bar} ${keyHints([["?", "close help"]])}`); return lines.join("\n"); } const current = state.suggestions[state.index]; if (!current) { - blank(); - lines.push(`${BAR} ${styleText("yellow", "no profiles available")}`); - lines.push(BAR); - lines.push(`${BAR} ${styleText("dim", "a browse every profile · esc cancel")}`); + lines.push(cardTop(width, "nothing to suggest")); + airInCard(); + lines.push( + cardLine(width, styleText("dim", "no profiles are installed for this directory yet")), + ); + airInCard(); + lines.push(cardBottom(width)); + lines.push(bar); + lines.push( + `${bar} ${button("a browse every profile")} ${keyHints([["esc", "cancel"]])}`, + ); return lines.join("\n"); } - blank(); - const counter = - state.suggestions.length > 1 ? `${state.index + 1}/${state.suggestions.length}` : ""; - const heading = styleText("blueBright", styleText("bold", "suggested stack")); - const pad = counter - ? " ".repeat(Math.max(2, width - displayWidth("suggested stack") - displayWidth(counter))) - : ""; - lines.push(`${BAR} ${heading}${pad}${counter ? styleText("dim", counter) : ""}`); - blank(); + // Page dots ride the top border, iOS-style: the count is ambient rather than + // another line of text competing with the answer. + lines.push(cardTop(width, "suggested stack", pageDots(state.index, state.suggestions.length))); + airInCard(); - const stack = current.labels.map(icon).join(styleText("dim", " + ")); - lines.push(`${BAR} ${clipToWidth(stack, width - 2)}`); - blank(); + // The answer itself, given the most visual weight on the screen. + const stack = current.labels + .map((l) => styleText("bold", icon(l))) + .join(styleText("dim", " + ")); + lines.push(cardLine(width, stack)); + airInCard(); for (const reason of current.reasons.slice(0, 3)) { - lines.push(`${BAR} ${styleText("dim", clipToWidth(reason, width - 2))}`); + lines.push(cardLine(width, styleText("dim", clipToWidth(reason, inner)))); } - if (current.totals) { - lines.push(`${BAR} ${styleText("dim", clipToWidth(current.totals, width - 2))}`); + + if (current.totals || (current.alwaysOn ?? 0) > 0) { + airInCard(); + if (current.totals) lines.push(cardLine(width, styleText("dim", current.totals))); + const alwaysOn = current.alwaysOn ?? 0; + if (alwaysOn > 0) { + // Weight as a meter, not just a number — "how heavy is this" is a + // comparison, and a bar answers it without the reader doing arithmetic. + const heavy = alwaysOn > OVERHEAD_WARN_TOKENS; + const caption = heavy + ? styleText("yellow", `${formatAlwaysOn(alwaysOn)} · ⚠ heavy, slows the agent`) + : styleText("dim", formatAlwaysOn(alwaysOn)); + lines.push(cardLine(width, `${meterBar(alwaysOn)} ${caption}`)); + } } - const badge = formatOverheadBadge(current.alwaysOn ?? 0); - if (badge) lines.push(`${BAR} ${styleText("yellow", clipToWidth(badge, width - 2))}`); - lines.push(BAR); - // Lead with the one key that matters, and say exactly what it does — the pin - // decision lives here now instead of in a follow-up confirm. - const launchText = - state.pinDisabled === true - ? "⏎ launch" - : state.pin - ? "⏎ launch · pins to this directory" - : "⏎ launch · no pin"; + airInCard(); + lines.push(cardBottom(width)); + lines.push(bar); + + // One filled pill for the action you almost always want, then the two + // controls that change what it would do: which suggestion, and whether it + // sticks. The pin reads as a switch — constant label, changing dot. + // Everything below is offered in a long and a short form so a narrow + // terminal drops words instead of wrapping the row. + const budget = cols - 4; + const actionRow = (pinLabel: string, nextLabel: string): string => { + const parts: string[] = [button("⏎ launch")]; + if (state.suggestions.length > 1) parts.push(keyHints([["↹", nextLabel]])); + if (state.pinDisabled !== true) { + const dot = state.pin ? styleText("green", "●") : styleText("dim", "○"); + parts.push(`${styleText("dim", "p")} ${dot} ${styleText("dim", pinLabel)}`); + } + return parts.join(styleText("dim", " ")); + }; lines.push( - `${BAR} ${styleText("cyan", launchText)}${ - state.suggestions.length > 1 ? styleText("dim", " ↹ next suggestion") : "" - }`, + `${bar} ${fitLine( + budget, + actionRow("pin to this folder", "next suggestion"), + actionRow("pin here", "next"), + )}`, + ); + + lines.push( + `${bar} ${fitLine( + budget, + keyHints([ + ["e", "edit stack"], + ["/", "search"], + ["a", "all profiles"], + ["?", "keys"], + ["esc", "quit"], + ]), + keyHints([ + ["e", "edit"], + ["/", "search"], + ["a", "all"], + ["?", "keys"], + ["esc", "quit"], + ]), + // Last resort on a very narrow terminal: keep editing, the full + // catalogue, and the way out. `?` still lists everything else. + keyHints([ + ["e", "edit"], + ["a", "all"], + ["esc", "quit"], + ]), + )}`, ); - const secondary = [ - "e edit", - "/ search", - "a all profiles", - state.pinDisabled === true ? "" : state.pin ? "p don't pin" : "p pin", - "? help", - "esc cancel", - ] - .filter((s) => s.length > 0) - .join(" · "); - lines.push(`${BAR} ${styleText("dim", clipToWidth(secondary, width))}`); return lines.join("\n"); } @@ -256,13 +324,13 @@ export class CardPrompt extends Prompt { } renderFrame(this: CardPrompt): string { - const BAR = styleText("gray", "│"); - if (this.state === "cancel") return `${BAR} ${styleText("red", "■")} cancelled`; + const bar = BAR(); + if (this.state === "cancel") return `${bar} ${styleText("red", "■")} cancelled`; if (this.state === "submit" && this.value === "launch") { const current = this.suggestions[this.index]; const ascii = asciiIconsEnabled(); const label = (current?.labels ?? []).map((l) => stripIconIfAscii(l, ascii)).join(" + "); - return `${BAR} ${styleText("green", "◇")} ${label}`; + return `${bar} ${styleText("green", "◇")} ${label}`; } if (this.state === "submit") return ""; return renderCardFrame({ diff --git a/src/lib/picker/palette.test.ts b/src/lib/picker/palette.test.ts index f2348e72..0bafeb1d 100644 --- a/src/lib/picker/palette.test.ts +++ b/src/lib/picker/palette.test.ts @@ -13,6 +13,7 @@ import { type PaletteProfile, type PaletteRow, } from "./palette"; +import { displayWidth } from "./render-util"; import type { ProfileTally } from "./tally"; const profiles: PaletteProfile[] = [ @@ -154,35 +155,56 @@ describe("buildPaletteRows", () => { describe("renderPaletteFrame", () => { const base = { cursor: 0, query: "", cols: 80, ascii: false }; - test("shows checkbox state, section headers and the suggested marker", () => { - const frame = renderPaletteFrame({ ...base, rows: rows(), selected: ["rust"] }); - expect(frame).toContain(SUGGESTED_SECTION); - expect(plain(frame)).toContain("[x] 🦀 rust"); - expect(plain(frame)).toContain("[ ] 🔒 secops"); + test("shows selection marks and an uppercase section header", () => { + const frame = plain(renderPaletteFrame({ ...base, rows: rows(), selected: ["rust"] })); + expect(frame).toContain(SUGGESTED_SECTION.toUpperCase()); + expect(frame).toContain("● 🦀 rust"); + expect(frame).toContain("○ 🔒 secops"); + // The section header already says "suggested" — the per-row tag would only + // repeat it, so it stays off until a search flattens the sections away. + expect(frame).not.toContain(" suggested"); + }); + + test("tags a suggested row once a search has flattened the sections", () => { + const frame = plain( + renderPaletteFrame({ ...base, rows: rows(), selected: [], query: "rust" }), + ); + expect(frame).toContain(MATCHES_SECTION.toUpperCase()); expect(frame).toContain("suggested"); }); + test("reports how much is on screen next to the search field", () => { + const frame = plain(renderPaletteFrame({ ...base, rows: rows(), selected: ["rust"] })); + expect(frame).toContain("type to filter…"); + expect(frame).toContain("4 profiles"); + expect(frame).toContain("1 selected"); + const searching = plain( + renderPaletteFrame({ ...base, rows: rows(), selected: [], query: "medusa" }), + ); + expect(searching).toContain("2 matches"); + expect(searching).toContain("none selected"); + }); + test("disables a row that conflicts with the selection and says why", () => { const frame = renderPaletteFrame({ ...base, rows: rows(), selected: ["medusa-next"] }); - expect(frame).toContain("[—]"); + expect(plain(frame)).toContain("⊘"); expect(plain(frame)).toContain("conflicts with medusa-next"); }); - test("footer totals the selected stack and flags a heavy one", () => { + test("footer totals the selected stack, meters it and flags a heavy one", () => { const tallies = new Map([ ["rust", tally("rust", 20, 4000)], ["secops", tally("secops", 11, 9000)], ]); - const frame = renderPaletteFrame({ - ...base, - rows: rows(), - selected: ["rust", "secops"], - tallies, - }); - expect(frame).toContain("🦀 rust + 🔒 secops"); + const frame = plain( + renderPaletteFrame({ ...base, rows: rows(), selected: ["rust", "secops"], tallies }), + ); + expect(frame).toContain("🦀 rust + 🔒 secops"); expect(frame).toContain("31 skills"); - expect(frame).toContain("⚠ heavy: ~13k always-on"); - expect(frame).toContain("⏎ launch 2 selected"); + expect(frame).toContain("~13k always-on"); + expect(frame).toContain("⚠ heavy, slows the agent"); + expect(frame).toContain("█"); + expect(frame).toContain("⏎ launch 2"); }); test("marks totals as pending while a tally is still loading", () => { @@ -196,9 +218,21 @@ describe("renderPaletteFrame", () => { }); test("nudges when nothing is selected", () => { - const frame = renderPaletteFrame({ ...base, rows: rows(), selected: [] }); - expect(frame).toContain("nothing selected yet"); - expect(frame).toContain("⏎ pick at least one profile"); + const frame = plain(renderPaletteFrame({ ...base, rows: rows(), selected: [] })); + expect(frame).toContain("nothing selected yet — press space to add the focused profile"); + expect(frame).toContain("⏎ pick a profile"); + }); + + test("the action row sheds words instead of wrapping a narrow terminal", () => { + for (const cols of [40, 56, 60, 72, 80, 120]) { + const frame = plain( + renderPaletteFrame({ ...base, cols, rows: rows(), selected: ["rust"], maxRows: 6 }), + ); + for (const line of frame.split("\n")) expect(displayWidth(line)).toBeLessThanOrEqual(cols); + // However narrow it gets, the two keys you cannot guess still show. + expect(frame).toContain("⏎ launch 1"); + expect(frame).toContain("space add"); + } }); test("windows a long list with scroll markers", () => { @@ -222,12 +256,13 @@ describe("renderPaletteFrame", () => { const frame = renderPaletteFrame({ ...base, rows: rows(), selected: [], help: true }); expect(frame).toContain("add / remove the focused profile"); expect(frame).toContain("back to the suggestion"); - expect(plain(frame)).not.toContain("[ ]"); + expect(plain(frame)).not.toContain("○"); }); - test("ascii mode drops the emoji icons", () => { + test("ascii mode drops the emoji icons and keeps bracket checkboxes", () => { const frame = renderPaletteFrame({ ...base, rows: rows(), selected: ["rust"], ascii: true }); expect(plain(frame)).toContain("[x] rust"); + expect(plain(frame)).toContain("[ ] secops"); expect(frame).not.toContain("🦀"); }); }); diff --git a/src/lib/picker/palette.ts b/src/lib/picker/palette.ts index 99e0f8eb..7eb0a8a9 100644 --- a/src/lib/picker/palette.ts +++ b/src/lib/picker/palette.ts @@ -24,11 +24,26 @@ import { } from "./render-util"; import { EMPTY_TALLY, - formatOverheadBadge, formatStackTotals, + OVERHEAD_WARN_TOKENS, unionTallyCounts, type ProfileTally, } from "./tally"; +import { + BAR, + button, + CARD_MAX_WIDTH, + clipVisible, + fitLine, + formatAlwaysOn, + keyHints, + keyTable, + markWidth, + meterBar, + padVisible, + sectionHeader, + selectMark, +} from "./ui"; /** Section title used for the flat ranked list while a filter is active. */ export const MATCHES_SECTION = "matches"; @@ -80,6 +95,7 @@ const KEY_HELP: ReadonlyArray<[string, string]> = [ ["⏎", "launch the stack you built"], ["space", "add / remove the focused profile"], ["↑↓", "move"], + ["pgup/pgdn", "move ten rows"], ["type", "fuzzy-search every profile"], ["⌫", "delete a search character"], ["?", "toggle this help"], @@ -229,44 +245,65 @@ export function filterRows(rows: PaletteRow[], query: string): PaletteRow[] { .map(({ row }) => ({ ...row, section: MATCHES_SECTION })); } -/** Column the section-header count is aligned to. */ -export const SECTION_RULE_COL = 30; - /** * Render one frame of the palette. Pure — same state in, same string out. + * + * Reading order top to bottom: what screen is this and how much have I picked → + * the search field → the grouped list → what I've built and what it costs → the + * one key that ships it. */ export function renderPaletteFrame(state: PaletteState): string { - const BAR = styleText("gray", "│"); + const bar = BAR(); const cols = state.cols ?? process.stdout.columns ?? 80; const width = Math.max(24, cols - 6); + // Rows clip to the full terminal width (a long hint deserves the room), but + // the right-aligned header meta tracks the card's capped width so the two + // screens line up instead of drifting apart on a wide terminal. + const headWidth = Math.min(width, CARD_MAX_WIDTH); const ascii = state.ascii ?? asciiIconsEnabled(); const icon = (s: string) => stripIconIfAscii(s, ascii); const lines: string[] = []; - lines.push(BAR); - const filterTag = - state.query.length > 0 - ? styleText("cyan", ` ${state.query}▏`) - : styleText("dim", " type to search"); - lines.push(`${BAR} ${styleText("cyan", "◆")} ${styleText("bold", "build your stack")}${filterTag}`); + const visible = filterRows(state.rows, state.query); + const conflictMap = buildConflictMap(state.rows); + const effective = new Set(resolveConflicts(state.selected, conflictMap)); + + // Title bar: the screen's name on the left, how far along you are on the + // right — the two things you'd check before touching a key. + lines.push(bar); + const title = `${styleText("cyan", "◆")} ${styleText("bold", "build your stack")}`; + const picked = + effective.size > 0 + ? styleText("green", `${effective.size} selected`) + : styleText("dim", "none selected"); + lines.push(`${bar} ${padVisible(title, Math.max(0, headWidth - 14))}${picked}`); if (state.help === true) { - lines.push(BAR); - for (const [key, what] of KEY_HELP) { - lines.push(`${BAR} ${styleText("cyan", key.padEnd(8))}${styleText("dim", what)}`); - } - lines.push(BAR); - lines.push(`${BAR} ${styleText("dim", "? close help")}`); + lines.push(bar); + for (const row of keyTable(KEY_HELP, 12)) lines.push(`${bar} ${row}`); + lines.push(bar); + lines.push(`${bar} ${keyHints([["?", "close help"]])}`); return lines.join("\n"); } - const visible = filterRows(state.rows, state.query); - const conflictMap = buildConflictMap(state.rows); - const effective = new Set(resolveConflicts(state.selected, conflictMap)); + // Search field. Rendering it as its own labelled row — rather than a tag + // welded onto the title — is what makes "you can just type" discoverable. + lines.push(bar); + const field = + state.query.length > 0 + ? `${styleText("cyan", state.query)}${styleText("cyan", "▏")}` + : styleText("dim", "type to filter…"); + const scope = + state.query.length > 0 + ? styleText("dim", `${visible.length} ${visible.length === 1 ? "match" : "matches"}`) + : styleText("dim", `${state.rows.length} profiles`); + lines.push( + `${bar} ${styleText("dim", "search")} ${padVisible(field, Math.max(0, headWidth - 22))}${scope}`, + ); - lines.push(BAR); + lines.push(bar); if (visible.length === 0) { - lines.push(`${BAR} ${styleText("yellow", `nothing matches "${state.query}"`)}`); + lines.push(`${bar} ${styleText("yellow", `nothing matches "${state.query}"`)}`); } // Per-section totals (across the whole filtered list, not just the window) so @@ -281,61 +318,86 @@ export function renderPaletteFrame(state: PaletteState): string { const max = state.maxRows && state.maxRows > 0 ? state.maxRows : visible.length; const cursor = Math.max(0, Math.min(state.cursor, visible.length - 1)); const win = windowOptions(visible, cursor, max); - if (win.hiddenAbove > 0) lines.push(`${BAR} ${styleText("dim", `↑ ${win.hiddenAbove} more`)}`); + if (win.hiddenAbove > 0) lines.push(`${bar} ${styleText("dim", `↑ ${win.hiddenAbove} more`)}`); let lastSection: string | undefined; win.items.forEach((row, offset) => { const idx = win.start + offset; if (row.section !== lastSection) { - if (lastSection !== undefined) lines.push(BAR); - const count = String(sectionTotals.get(row.section) ?? 0); - const rule = styleText( - "gray", - "─".repeat(Math.max(3, SECTION_RULE_COL - displayWidth(row.section) - 2)), - ); - lines.push( - `${BAR} ${styleText("bold", styleText("blueBright", row.section))} ${rule} ${styleText("dim", count)}`, - ); + if (lastSection !== undefined) lines.push(bar); + lines.push(`${bar} ${sectionHeader(row.section, sectionTotals.get(row.section) ?? 0)}`); lastSection = row.section; } - lines.push(renderRow(row, idx === cursor, effective, conflictMap, { icon, labelCol, state, width })); + lines.push( + renderRow(row, idx === cursor, effective, conflictMap, { icon, labelCol, state, width, ascii }), + ); }); - if (win.hiddenBelow > 0) lines.push(`${BAR} ${styleText("dim", `↓ ${win.hiddenBelow} more`)}`); + if (win.hiddenBelow > 0) lines.push(`${bar} ${styleText("dim", `↓ ${win.hiddenBelow} more`)}`); // Sticky footer: what you're about to launch, and what it costs. - lines.push(BAR); + lines.push(bar); const chosen = [...effective]; if (chosen.length === 0) { - lines.push(`${BAR} ${styleText("dim", "nothing selected yet — space adds the focused profile")}`); + lines.push( + `${bar} ${styleText("dim", "nothing selected yet — press space to add the focused profile")}`, + ); } else { const labelOf = (v: string) => icon(state.rows.find((r) => r.value === v)?.label ?? v); - lines.push(`${BAR} ${styleText("green", clipToWidth(chosen.map(labelOf).join(" + "), width))}`); + lines.push( + `${bar} ${styleText("green", clipToWidth(chosen.map(labelOf).join(" + "), width))}`, + ); const tallies = state.tallies; if (tallies) { - const picked = chosen.map((v) => tallies.get(v)); - const totals = formatStackTotals(unionTallyCounts(picked.map((t) => t ?? EMPTY_TALLY))); - const pending = picked.some((t) => t === undefined) ? " …" : ""; - if (totals) lines.push(`${BAR} ${styleText("dim", `${totals}${pending}`)}`); - const badge = formatOverheadBadge( - picked.reduce((sum, t) => sum + (t?.alwaysOn ?? 0), 0), + const pickedTallies = chosen.map((v) => tallies.get(v)); + const totals = formatStackTotals( + unionTallyCounts(pickedTallies.map((t) => t ?? EMPTY_TALLY)), ); - if (badge) lines.push(`${BAR} ${styleText("yellow", clipToWidth(badge, width))}`); + const pending = pickedTallies.some((t) => t === undefined) ? " …" : ""; + if (totals) lines.push(`${bar} ${styleText("dim", `${totals}${pending}`)}`); + const alwaysOn = pickedTallies.reduce((sum, t) => sum + (t?.alwaysOn ?? 0), 0); + if (alwaysOn > 0) { + const heavy = alwaysOn > OVERHEAD_WARN_TOKENS; + const caption = heavy + ? styleText("yellow", `${formatAlwaysOn(alwaysOn)} · ⚠ heavy, slows the agent`) + : styleText("dim", formatAlwaysOn(alwaysOn)); + lines.push(`${bar} ${clipVisible(`${meterBar(alwaysOn)} ${caption}`, width)}`); + } } } - lines.push(BAR); - const enterText = - chosen.length > 0 ? `⏎ launch ${chosen.length} selected` : "⏎ pick at least one profile"; + lines.push(bar); + const ship = + chosen.length > 0 + ? button(`⏎ launch ${chosen.length}`) + : styleText("dim", " ⏎ pick a profile "); + // Three widths of the same row, so a narrow terminal drops words rather than + // wrapping — a wrapped action row reflows the list on every keystroke. + const row = (pairs: ReadonlyArray<[string, string]>) => `${ship} ${keyHints(pairs)}`; lines.push( - `${BAR} ${styleText(chosen.length > 0 ? "cyan" : "dim", enterText)}${styleText( - "dim", - " · space toggle · ↑↓ move · ? help · esc back", + `${bar} ${fitLine( + cols - 4, + row([ + ["space", "add/remove"], + ["↑↓", "move"], + ["?", "keys"], + ["esc", "back"], + ]), + row([ + ["space", "add"], + ["↑↓", "move"], + ["?", "keys"], + ["esc", "back"], + ]), + row([ + ["space", "add"], + ["esc", "back"], + ]), )}`, ); return lines.join("\n"); } -/** One list row: checkbox, label, per-row delta, conflict/recommendation tags. */ +/** One list row: selection mark, label, per-row delta, conflict/danger tags. */ function renderRow( row: PaletteRow, isCursor: boolean, @@ -346,9 +408,10 @@ function renderRow( labelCol: number; state: PaletteState; width: number; + ascii: boolean; }, ): string { - const BAR = styleText("gray", "│"); + const bar = BAR(); const isSel = effective.has(row.value); const arrow = isCursor ? styleText("cyan", "›") @@ -363,7 +426,7 @@ function renderRow( if (partners) { for (const sel of effective) { if (partners.has(sel)) { - return `${BAR} ${arrow} ${styleText("dim", "[—]")} ${styleText( + return `${bar} ${arrow} ${selectMark("blocked", ctx.ascii)} ${styleText( "dim", `${ctx.icon(row.label)} (conflicts with ${sel})`, )}`; @@ -372,28 +435,39 @@ function renderRow( } } - const box = isSel ? styleText("green", "[x]") : styleText("dim", "[ ]"); + const box = selectMark(isSel ? "on" : "off", ctx.ascii); const rawLabel = ctx.icon(row.label); - const labelStyled = isSel || isCursor ? rawLabel : styleText("dim", rawLabel); + // Three weights, so the eye lands on the focused row first and the selected + // ones second: focused is bold, selected is normal, everything else recedes. + const labelStyled = isCursor + ? styleText("bold", rawLabel) + : isSel + ? rawLabel + : styleText("dim", rawLabel); const tally = ctx.state.tallies?.get(row.value); const delta = tally && tally.skills.length > 0 ? `${tally.skills.length} skills` : ""; // The focused row's description can be a paragraph (some profiles carry a // 200-character blurb). Clip it to what's left on the line so one row can't // wrap across the screen and shove the rest of the list out of view. - const gutter = 9; // "│ › [x] " + const gutter = 6 + markWidth(ctx.ascii); // "│ › ● " const used = gutter + Math.max(displayWidth(rawLabel), ctx.labelCol) + displayWidth(delta) + 4; const hint = row.hint && isCursor ? styleText("dim", ` (${clipToWidth(row.hint, Math.max(16, ctx.width - used))})`) : ""; - const recTag = row.recommended === true ? styleText("dim", " suggested") : ""; + // The "suggested" tag earns its place only where the section header doesn't + // already say it — i.e. once a search has flattened everything into `matches`. + const recTag = + row.recommended === true && row.section !== SUGGESTED_SECTION + ? styleText("dim", " suggested") + : ""; const hasTrailer = delta !== "" || Boolean(row.danger); const pad = hasTrailer ? " ".repeat(Math.max(2, ctx.labelCol + 2 - displayWidth(rawLabel))) : ""; const deltaStr = delta ? styleText("dim", delta) : ""; const dangerTag = row.danger ? styleText("red", `${delta ? " · " : ""}${row.danger}`) : ""; - return `${BAR} ${arrow} ${box} ${labelStyled}${pad}${deltaStr}${dangerTag}${hint}${recTag}`; + return `${bar} ${arrow} ${box} ${labelStyled}${pad}${deltaStr}${dangerTag}${hint}${recTag}`; } /** @@ -528,9 +602,10 @@ export class StackPalettePrompt extends Prompt { private visibleWindow(): number { const rows = (this.output as { rows?: number } | undefined)?.rows ?? process.stdout.rows ?? 24; - // Reserve the header (3), section spacers (2), footer (4) and scroll - // markers (2); floor at 5 so a short terminal still lists something. - return Math.max(5, rows - 11); + // Reserve the header + search field (5), section spacers (2), the footer's + // stack / totals / meter / keys (5) and the scroll markers (2); floor at 5 + // so a short terminal still lists something. + return Math.max(5, rows - 14); } renderFrame(this: StackPalettePrompt): string { diff --git a/src/lib/picker/ui.test.ts b/src/lib/picker/ui.test.ts new file mode 100644 index 00000000..12cbc68d --- /dev/null +++ b/src/lib/picker/ui.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, test } from "bun:test"; + +import { displayWidth } from "./render-util"; +import { + cardBottom, + cardInner, + cardLine, + cardTop, + cardWidth, + clipVisible, + formatAlwaysOn, + keyTable, + markWidth, + meterBar, + METER_FULL_TOKENS, + padVisible, + pageDots, + sectionHeader, + selectMark, + stripAnsi, + visibleWidth, +} from "./ui"; + +const ESC = String.fromCharCode(27); +/** A cyan-wrapped string, built by hand so the test doesn't depend on whether + * the runner's stdout happens to be a TTY. */ +const styled = (s: string) => `${ESC}[36m${s}${ESC}[39m`; + +describe("width helpers", () => { + test("measures and strips styling rather than counting escape bytes", () => { + expect(stripAnsi(styled("rust"))).toBe("rust"); + expect(visibleWidth(styled("rust"))).toBe(4); + // Emoji are two cells wide even though they are one code point. + expect(visibleWidth(styled("🦀 rust"))).toBe(7); + }); + + test("pads a styled string to a cell width without disturbing the styling", () => { + const padded = padVisible(styled("rust"), 10); + expect(visibleWidth(padded)).toBe(10); + expect(padded).toContain(styled("rust")); + }); + + test("padding never truncates a string that is already too wide", () => { + expect(stripAnsi(padVisible(styled("rust-core"), 4))).toBe("rust-core"); + }); + + test("clipping keeps the styling when it fits and drops it when it cannot", () => { + expect(clipVisible(styled("rust"), 10)).toBe(styled("rust")); + const clipped = clipVisible(styled("rust-core"), 6); + expect(displayWidth(clipped)).toBeLessThanOrEqual(6); + expect(clipped).toContain("…"); + }); +}); + +describe("inset card", () => { + test("caps its width on a wide terminal and shrinks on a narrow one", () => { + expect(cardWidth(400)).toBe(74); + expect(cardWidth(60)).toBe(58); + expect(cardWidth(10)).toBe(24); + }); + + test("every row is the same number of cells wide", () => { + const w = cardWidth(80); + const frame = [ + cardTop(w, "suggested stack", pageDots(0, 3)), + cardLine(w), + cardLine(w, "🦀 rust + 🔒 secops"), + cardLine(w, styled("39 skills · 3 mcps")), + cardBottom(w), + ]; + const widths = new Set(frame.map((l) => displayWidth(stripAnsi(l)))); + expect(widths).toEqual(new Set([w])); + }); + + test("over-long content is clipped to the card, never wrapped past it", () => { + const w = cardWidth(80); + const line = cardLine(w, "x".repeat(500)); + expect(displayWidth(stripAnsi(line))).toBe(w); + expect(line).toContain("…"); + }); + + test("a title and badge still leave rule between them", () => { + const top = stripAnsi(cardTop(cardWidth(80), "keys", "1/9")); + expect(top).toContain("keys"); + expect(top).toContain("1/9"); + expect(top).toContain("─"); + expect(top.startsWith("╭")).toBe(true); + expect(top.endsWith("╮")).toBe(true); + }); + + test("inner width leaves room for both borders and the side padding", () => { + const w = cardWidth(80); + expect(cardInner(w)).toBe(w - 6); + }); +}); + +describe("pageDots", () => { + test("says nothing when there is only one page", () => { + expect(pageDots(0, 1)).toBe(""); + expect(pageDots(0, 0)).toBe(""); + }); + + test("fills the dot for the current page", () => { + expect(stripAnsi(pageDots(1, 3))).toBe("○ ● ○"); + }); + + test("switches to a numeric indicator past the dot budget", () => { + expect(stripAnsi(pageDots(2, 12))).toBe("3/12"); + expect(stripAnsi(pageDots(2, 12, 20))).toContain("○"); + }); +}); + +describe("selectMark", () => { + test("uses circles by default and brackets in ascii mode", () => { + expect(stripAnsi(selectMark("on", false))).toBe("●"); + expect(stripAnsi(selectMark("off", false))).toBe("○"); + expect(stripAnsi(selectMark("blocked", false))).toBe("⊘"); + expect(stripAnsi(selectMark("on", true))).toBe("[x]"); + expect(stripAnsi(selectMark("off", true))).toBe("[ ]"); + expect(stripAnsi(selectMark("blocked", true))).toBe("[-]"); + }); + + test("markWidth matches what selectMark actually renders", () => { + for (const ascii of [true, false]) { + for (const state of ["on", "off", "blocked"] as const) { + expect(displayWidth(stripAnsi(selectMark(state, ascii)))).toBe(markWidth(ascii)); + } + } + }); +}); + +describe("meterBar", () => { + test("is the requested width whatever the value", () => { + for (const v of [0, 1, 5_000, 20_000, 500_000]) { + expect(displayWidth(stripAnsi(meterBar(v, 12)))).toBe(12); + } + }); + + test("grows with the cost and pegs at full", () => { + const filled = (v: number) => stripAnsi(meterBar(v, 12)).replace(/░/g, "").length; + expect(filled(0)).toBe(0); + expect(filled(10_000)).toBeGreaterThan(filled(4_000)); + expect(filled(METER_FULL_TOKENS)).toBe(12); + expect(filled(METER_FULL_TOKENS * 5)).toBe(12); + }); + + test("a non-zero cost always shows at least one cell", () => { + expect(stripAnsi(meterBar(100, 12)).startsWith("█")).toBe(true); + }); +}); + +describe("formatAlwaysOn", () => { + test("keeps a decimal below 10k and drops it above", () => { + expect(formatAlwaysOn(6_000)).toBe("~6.0k always-on"); + expect(formatAlwaysOn(13_400)).toBe("~13k always-on"); + }); + + test("says nothing when the cost is unknown", () => { + expect(formatAlwaysOn(0)).toBe(""); + expect(formatAlwaysOn(-1)).toBe(""); + }); +}); + +describe("sectionHeader", () => { + test("uppercases the name and trails the count", () => { + expect(stripAnsi(sectionHeader("detected here", 3))).toBe("DETECTED HERE 3"); + expect(stripAnsi(sectionHeader("featured"))).toBe("FEATURED"); + }); +}); + +describe("keyTable", () => { + test("aligns the descriptions into one column", () => { + const out = keyTable( + [ + ["⏎", "launch"], + ["space", "toggle"], + ], + 9, + ).map(stripAnsi); + const starts = out.map((l) => l.indexOf(l.trimStart().split(" ").pop()!)); + expect(new Set(starts).size).toBe(1); + expect(out[0]).toContain("launch"); + }); +}); diff --git a/src/lib/picker/ui.ts b/src/lib/picker/ui.ts new file mode 100644 index 00000000..5f36c8a1 --- /dev/null +++ b/src/lib/picker/ui.ts @@ -0,0 +1,219 @@ +/** + * Visual primitives shared by the picker surfaces (v2 card + stack palette). + * + * The house style borrows from iOS's grouped-inset lists: one rounded card per + * idea, uppercase muted section headers instead of heavy rules, a filled pill + * for the single primary action, circular selection marks instead of ASCII + * brackets, and page dots for "there is more to see here". + * + * Everything here is pure — no I/O, no TTY. `styleText` is a no-op when stdout + * isn't a TTY, so tests assert on plain text. + */ + +import { styleText } from "node:util"; +import { clipToWidth, displayWidth } from "./render-util"; + +/** The clack gutter every picker line hangs off. */ +export const BAR = (): string => styleText("gray", "│"); + +const ANSI = /\u001b\[[0-9;]*m/g; + +/** Drop SGR escapes so a styled string can be measured and padded correctly. */ +export function stripAnsi(s: string): string { + return s.replace(ANSI, ""); +} + +/** Rendered cell width of a possibly-styled string. */ +export function visibleWidth(s: string): number { + return displayWidth(stripAnsi(s)); +} + +/** Right-pad a possibly-styled string to `width` cells (never truncates). */ +export function padVisible(s: string, width: number): string { + return s + " ".repeat(Math.max(0, width - visibleWidth(s))); +} + +/** Clip a possibly-styled string to `width` cells, keeping the styling intact + * when it already fits. Styled strings that overflow are clipped plain — an + * over-long line is a layout bug we'd rather see unstyled than wrapped. */ +export function clipVisible(s: string, width: number): string { + if (visibleWidth(s) <= width) return s; + return clipToWidth(stripAnsi(s), width); +} + +// ── inset card ───────────────────────────────────────────────────────────── +// +// The card's left border sits in the same column as clack's `│` gutter, so the +// rail appears to thicken into a card rather than doubling up next to one. + +/** Widest an inset card grows to, however wide the terminal is. Long lines are + * harder to scan than short ones; iOS caps its content width for the same + * reason. */ +export const CARD_MAX_WIDTH = 74; + +/** Outer width of the inset card for a given terminal width. */ +export function cardWidth(cols: number): number { + return Math.max(24, Math.min(CARD_MAX_WIDTH, cols - 2)); +} + +/** Inner (content) width — the outer width minus both borders and the 2-cell + * breathing room on each side. */ +export function cardInner(width: number): number { + return Math.max(8, width - 6); +} + +/** + * Top border, with an optional inline title on the left and a trailing badge + * (page dots, a count) on the right: + * + * ╭─ suggested stack ─────────────────────────────── ● ○ ○ ─╮ + */ +export function cardTop(width: number, title?: string, badge?: string): string { + if (!title && !badge) { + return styleText("gray", `╭${"─".repeat(width - 2)}╮`); + } + const head = title ? ` ${styleText("bold", title)} ` : ""; + const tail = badge ? ` ${badge} ` : ""; + const fill = Math.max(1, width - 4 - visibleWidth(head) - visibleWidth(tail)); + return ( + styleText("gray", "╭─") + + head + + styleText("gray", "─".repeat(fill)) + + tail + + styleText("gray", "─╮") + ); +} + +/** One content row inside the card, padded so the right border lines up. */ +export function cardLine(width: number, content = ""): string { + const inner = cardInner(width); + const body = padVisible(clipVisible(content, inner), inner); + const edge = styleText("gray", "│"); + return `${edge} ${body} ${edge}`; +} + +/** Bottom border. */ +export function cardBottom(width: number): string { + return styleText("gray", `╰${"─".repeat(width - 2)}╯`); +} + +// ── controls ─────────────────────────────────────────────────────────────── + +/** iOS page control: one dot per suggestion, filled for the current one. Falls + * back to `3/12` past `maxDots`, the same way iOS switches to a numeric page + * indicator rather than rendering an unreadable row of dots. */ +export function pageDots(index: number, total: number, maxDots = 8): string { + if (total <= 1) return ""; + if (total > maxDots) return styleText("dim", `${index + 1}/${total}`); + return Array.from({ length: total }, (_, i) => + i === index ? styleText("cyan", "●") : styleText("dim", "○"), + ).join(" "); +} + +/** + * A filled pill for the one action that matters on a screen — the terminal's + * closest thing to iOS's tinted primary button. Inverse video paints the whole + * label, so it reads as a solid block rather than another line of text. + */ +export function button(label: string, tone: "primary" | "muted" = "primary"): string { + const text = ` ${label} `; + return tone === "primary" + ? styleText(["inverse", "cyan"], text) + : styleText(["inverse", "gray"], text); +} + +/** + * Section header in the grouped-list idiom: uppercase, muted, with the row + * count trailing. No rule — whitespace does the separating, which reads calmer + * than a screen full of dashes. + */ +export function sectionHeader(name: string, count?: number): string { + const label = styleText("bold", styleText("blueBright", name.toUpperCase())); + return count === undefined ? label : `${label} ${styleText("dim", String(count))}`; +} + +/** Selection state of a list row. */ +export type MarkState = "on" | "off" | "blocked"; + +/** + * The selection mark. Circles read as "tap to toggle" the way iOS's selection + * circles do, and a filled dot is far easier to spot down a column than `[x]`. + * ASCII mode keeps the bracket form for fonts without the geometric shapes. + */ +export function selectMark(state: MarkState, ascii: boolean): string { + if (ascii) { + return state === "on" + ? styleText("green", "[x]") + : state === "blocked" + ? styleText("dim", "[-]") + : styleText("dim", "[ ]"); + } + return state === "on" + ? styleText("green", "●") + : state === "blocked" + ? styleText("dim", "⊘") + : styleText("dim", "○"); +} + +/** Cell width `selectMark` occupies, so callers can budget the row. */ +export function markWidth(ascii: boolean): number { + return ascii ? 3 : 1; +} + +/** Always-on token cost at which the meter reads full. Roughly the point where + * a stack has eaten a serious slice of the startup budget. */ +export const METER_FULL_TOKENS = 20_000; + +/** + * A weight meter for a stack's always-on cost — the same information the + * `⚠ heavy` badge carries, but readable at a glance and present even when the + * stack is light. Colour tracks `tokenLevelEmoji`'s bands. + */ +export function meterBar(alwaysOn: number, width = 12): string { + const ratio = Math.max(0, Math.min(1, alwaysOn / METER_FULL_TOKENS)); + const filled = Math.max(alwaysOn > 0 ? 1 : 0, Math.round(ratio * width)); + // `tokenLevelEmoji`'s four bands collapsed onto the three colours a terminal + // reliably distinguishes: 🟢 green, 🟡/🟠 yellow, 🔴 red. + const color = alwaysOn > 15_000 ? "red" : alwaysOn > 5_000 ? "yellow" : "green"; + return ( + styleText(color, "█".repeat(filled)) + styleText("gray", "░".repeat(Math.max(0, width - filled))) + ); +} + +/** "~14k always-on" — the meter's caption. Returns "" for an unknown cost. */ +export function formatAlwaysOn(alwaysOn: number): string { + if (alwaysOn <= 0) return ""; + const k = alwaysOn >= 10_000 ? String(Math.round(alwaysOn / 1000)) : (alwaysOn / 1000).toFixed(1); + return `~${k}k always-on`; +} + +/** + * A quiet keycap row: `space add · ↑↓ move · esc back`. Keys keep their normal + * weight so they stand out from the dim descriptions around them. + */ +export function keyHints(pairs: ReadonlyArray<[string, string]>): string { + // The key is left unstyled so it keeps the terminal's default (bright) + // foreground next to its dim description — the contrast is what makes the + // row scannable without adding another colour. + return pairs + .map(([key, what]) => `${key} ${styleText("dim", what)}`) + .join(styleText("dim", " · ")); +} + +/** + * Pick the first candidate that fits `width`, falling back to a clip of the + * last one. Footers degrade rather than wrap: a wrapped action row reflows the + * whole list on a keystroke, which reads as the screen flickering. + */ +export function fitLine(width: number, ...candidates: string[]): string { + for (const c of candidates) if (visibleWidth(c) <= width) return c; + return clipVisible(candidates[candidates.length - 1] ?? "", width); +} + +/** A two-column key/description list, used by the `?` help overlays. */ +export function keyTable(pairs: ReadonlyArray<[string, string]>, keyCol = 9): string[] { + return pairs.map( + ([key, what]) => + `${styleText("cyan", padVisible(key, keyCol))}${styleText("dim", what)}`, + ); +} diff --git a/src/lib/profile-merge.ts b/src/lib/profile-merge.ts index ed186e73..639eaaa7 100644 --- a/src/lib/profile-merge.ts +++ b/src/lib/profile-merge.ts @@ -39,6 +39,10 @@ function skillSetTokens(ids: string[]): number { // skill discovery — they bootstrap the rest). Matched by slug suffix. const ALWAYS_KEEP_SLUGS = new Set([ "caveman-commit", "find-skills", "smart-loader", "help", + // Default browser path. A merged profile that budgets it out doesn't skip + // the browser work, it downgrades it to MCP round-trips. Mirrors + // skill-subset's ALWAYS_KEEP, which holds it through the project loadout. + "ego-browser", ]); export type OptimizeAction = "prune" | "dedupe" | "budget" | "router"; diff --git a/src/lib/resolver-local.ts b/src/lib/resolver-local.ts index 24f80677..e7241c55 100644 --- a/src/lib/resolver-local.ts +++ b/src/lib/resolver-local.ts @@ -183,7 +183,23 @@ async function walk(root: string): Promise { let slugs: string[]; try { const entries = await readdir(catPath, { withFileTypes: true }); - slugs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + // A skill maintained in its own repo can be symlinked into the tree + // (e.g. browser/ego-browser -> ~/Documents/ego-lite-linux/skills/...). + // dirent.isDirectory() is false for the link itself, so stat through it. + const resolved = await Promise.all( + entries.map(async (e) => { + if (e.isDirectory()) return e.name; + if (!e.isSymbolicLink()) return null; + try { + return (await stat(join(catPath, e.name))).isDirectory() + ? e.name + : null; + } catch { + return null; // dangling link — treat as absent + } + }), + ); + slugs = resolved.filter((name): name is string => name !== null); } catch { // A non-directory or unreadable entry at the category level is ignored // rather than fatal — keeps walk robust against stray files. diff --git a/src/lib/runtime-materializer.test.ts b/src/lib/runtime-materializer.test.ts index c2601004..01ee593c 100644 --- a/src/lib/runtime-materializer.test.ts +++ b/src/lib/runtime-materializer.test.ts @@ -1373,3 +1373,61 @@ describe("materializeRuntime — session-telemetry gating", () => { expect(onBytes).toBeGreaterThanOrEqual(offBytes); }); }); + +/** + * The rebuild swap used to be `rm -rf runtimeDir` followed by `rename(tmp)`, + * which left CLAUDE_CONFIG_DIR nonexistent for the entire recursive delete. + * A Claude Code session already running against the profile resolves its hooks + * through that path, so every hook firing in the gap died with "No such file or + * directory" (2026-08-03: nine Stop hooks at once). + * + * The swap now moves the old tree to a `.old--` sibling first. These + * tests pin the observable half of that — the sibling is transient, and a + * leftover from a swap killed between the two renames gets swept. They do NOT + * pin the ordering itself; a revert to rm-then-rename would still pass, so keep + * the comment above the swap in runtime-materializer.ts. + */ +describe("materializeRuntime — rebuild swap leftovers", () => { + const swapArgs = (runtimeRoot: string) => ({ + profile: sampleProfile, + agent: "claude-code" as const, + runtimeRoot, + skillSourceLookup: async (id: string) => `/fake/skills/${id}`, + mcpRegistry: { "claude-mem": { command: "claude-mem", args: [] } }, + userClaudeMd: "# user CLAUDE.md\n", + }); + + const swapSiblings = async (runtimeDir: string) => { + const { readdir } = await import("node:fs/promises"); + const { dirname, basename } = await import("node:path"); + const names = await readdir(dirname(runtimeDir)); + return names.filter((n) => n.startsWith(`${basename(runtimeDir)}.old-`)); + }; + + test("a rebuild leaves no .old-* sibling behind", async () => { + const runtimeRoot = join(root, "runtime"); + const first = await materializeRuntime(swapArgs(runtimeRoot)); + // Force a real rebuild rather than the hash-unchanged fast path. + await writeFile(join(first.runtimeDir, ".cue-hash"), "0".repeat(64)); + const second = await materializeRuntime(swapArgs(runtimeRoot)); + + expect(second.rebuilt).toBe(true); + expect(await swapSiblings(second.runtimeDir)).toEqual([]); + // The runtime is intact, not a half-swapped shell. + expect(JSON.parse(await readFile(join(second.runtimeDir, "settings.json"), "utf8"))).toBeTruthy(); + }); + + test("sweeps a .old-* left by a swap that died between the two renames", async () => { + const runtimeRoot = join(root, "runtime"); + const first = await materializeRuntime(swapArgs(runtimeRoot)); + + const stale = `${first.runtimeDir}.old-99999-deadbeef`; + await mkdir(stale, { recursive: true }); + await writeFile(join(stale, "junk"), "x"); + await writeFile(join(first.runtimeDir, ".cue-hash"), "0".repeat(64)); + + const second = await materializeRuntime(swapArgs(runtimeRoot)); + + expect(await swapSiblings(second.runtimeDir)).toEqual([]); + }); +}); diff --git a/src/lib/runtime-materializer.ts b/src/lib/runtime-materializer.ts index eb37c737..77ec6f48 100644 --- a/src/lib/runtime-materializer.ts +++ b/src/lib/runtime-materializer.ts @@ -828,8 +828,33 @@ export async function materializeRuntime(input: MaterializeInput): Promise { + /* the new runtime is already live; a stale .old-* is swept below */ + }); + } + await sweepStaleSwapDirs(runtimeDir); if (agent === "claude-code") { await syncMcpsIntoClaudeJson(runtimeDir, mcpServers, effectiveInput.disabledMcpIds); @@ -838,6 +863,26 @@ export async function materializeRuntime(input: MaterializeInput): Promise.old-*` leftovers from an earlier swap that was killed + * between the two renames. Best-effort and never fatal: the runtime it belongs + * to is already live, so a leftover only wastes disk. + */ +async function sweepStaleSwapDirs(runtimeDir: string): Promise { + const parent = dirname(runtimeDir); + const prefix = `${basename(runtimeDir)}.old-`; + try { + const names = await readdir(parent); + await Promise.all( + names + .filter((name) => name.startsWith(prefix)) + .map((name) => rm(join(parent, name), { recursive: true, force: true }).catch(() => {})), + ); + } catch { + /* parent unreadable — nothing to sweep */ + } +} + /** * Read `claudeAiOauth.expiresAt` (ms epoch) from a `.credentials.json`. Returns * 0 when the file is missing, unparseable, or carries no expiry — so anything diff --git a/src/lib/skill-subset.ts b/src/lib/skill-subset.ts index 253418e8..a535b64e 100644 --- a/src/lib/skill-subset.ts +++ b/src/lib/skill-subset.ts @@ -48,6 +48,10 @@ export const ALWAYS_KEEP = new Set([ "meta/acpx", "caveman/caveman", "caveman/caveman-commit", + // The default browser path. Deferring it doesn't save a browser session — + // it sends the agent to MCP round-trips or web fetch instead, which costs + // more than this skill's frontmatter. Keep it on in every project. + "browser/ego-browser", ]); // Re-exported so callers and tests that reached for these here keep working.