From 3a94a45ad46f5951ad8bd74a2813880e9d45ef2d Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:10:11 +0000 Subject: [PATCH 1/3] Make the tool menu the selection surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/tools` built its rows from the list the CLI had already composed, so it could only subtract. Everything else CUA can offer — `playwright_execute`, the computer toolset, a provider-native surface the default did not pick, `browser_act` on a model whose default excluded it — was invisible. The menu is the product's differentiator and the CLI hid most of it. `cuaToolMenu(model, selected)` returns every offerable tool, each marked available or not. It decides availability by compiling the candidate catalog rather than restating the compiler's rules, so the menu cannot drift from what `compileCuaToolCatalog` accepts: a row is available exactly when selecting it compiles, and the reason shown when it is not is the compiler's own message. A test asserts that equivalence across models with a native browser surface, a native computer surface, neither, a quirk, and an id pi-ai's registry does not carry. Availability is relative to the current selection, because several rules are pairwise: two providers' native surfaces cannot coexist, and a native surface derives a transport the rest of the selection must suit. The picker therefore rebuilds its rows after every toggle rather than fixing them when it opens, unavailable rows cannot be staged, and bulk actions skip them. `cua tools` prints the same menu for a model without the TUI, with `--json`. --- docs/architecture.md | 24 +++- packages/ai/CHANGELOG.md | 10 ++ packages/ai/src/index.ts | 1 + packages/ai/src/menu.ts | 127 ++++++++++++++++++++ packages/ai/test/menu.test.ts | 85 +++++++++++++ packages/cli/CHANGELOG.md | 10 ++ packages/cli/README.md | 18 ++- packages/cli/src/cli-harness.ts | 113 +++++++++++++++++ packages/cli/src/cli.ts | 6 + packages/cli/src/tui/main.ts | 48 +++++--- packages/cli/src/tui/tool-selection.ts | 67 ++++++++--- packages/cli/src/tui/tools-picker.ts | 39 +++++- packages/cli/test/tool-revalidation.test.ts | 28 ++++- packages/cli/test/tool-selection.test.ts | 50 +++++--- packages/cli/test/tools-picker.test.ts | 6 +- 15 files changed, 561 insertions(+), 71 deletions(-) create mode 100644 packages/ai/src/menu.ts create mode 100644 packages/ai/test/menu.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index be9ec2c..66dcde9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -185,6 +185,21 @@ with `cua.providers.openai.tools.computer()` compiles to the CUA-owned `google-cua-interactions` Interactions API versus pi's builtin Google transport. +### The tool menu + +`cuaToolMenu(model, selected)` in `packages/ai/src/menu.ts` returns every tool +CUA can offer for a model, each marked available or not. It decides availability +by compiling the candidate catalog rather than by restating the compiler's +rules, so the menu cannot drift from what `compileCuaToolCatalog` accepts: an +entry is available exactly when selecting it compiles. Compilation is pure and +declaration-only, so probing it per entry is cheap and side-effect-free. + +Availability is relative to the current selection, because several rules are +pairwise: two providers' native surfaces cannot coexist, and a native surface +derives a transport that the rest of the selection must be compatible with. +Callers rebuild the menu after each staged change rather than caching a per-tool +verdict. + `CuaAgent` and `CuaAgentHarness` push the compiled `catalog.model` into pi on every construction and on every `setTools()`/`setModel()`, so the derived transport applies uniformly regardless of which mutation path selected the @@ -225,10 +240,13 @@ to it so `ctrl+c` cancels the selector instead of quitting. `filterModelsForPicker`, `moveSelection`, `visibleWindow`) that make its behavior unit-testable without a terminal. - `tui/tool-selection.ts` — pure `/tools` state machine: identity keys matching - `normalizeTool`'s scheme, group badges, and toggle/bulk operations. + `normalizeTool`'s scheme, group badges, toggle/bulk operations, and + `describeMenu()`, which turns `cuaToolMenu()` plus the application's own tools + into rows. - `tui/tools-picker.ts` — the `/tools` component. Staged edits applied through - `harness.setTools()` with a subset of the application-composed baseline, in - baseline order. + `harness.setTools()`, in menu order. A selection is no longer confined to the + application-composed baseline: the picker offers the model's whole menu, and + the baseline is what `ctrl+r` restores. - `tui/keybindings.ts` — registers `cua.tools.*` ids on top of pi-tui's `TUI_KEYBINDINGS` and formats their hints. - `tui/mutation-queue.ts` — the serialization queue both catalog mutations run diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5f373c6..64c0d79 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.15.0 - 2026-08-14 + +- Add `cuaToolMenu(model, selected)`: every tool CUA can offer for a model, each + marked available or not with the compiler's own reason when it is not. It + decides availability by compiling the candidate catalog rather than restating + the compiler's rules, so the menu cannot drift from what + `compileCuaToolCatalog` accepts. Availability is relative to the current + selection, because two providers' native surfaces cannot coexist and a native + surface pins the transport. + ## 0.14.0 - 2026-08-14 Breaking: the model allowlist is removed. diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 1dd48d6..2988e70 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -26,3 +26,4 @@ export { } from "./providers/common"; export * from "./tool-catalog"; export * from "./cua"; +export * from "./menu"; diff --git a/packages/ai/src/menu.ts b/packages/ai/src/menu.ts new file mode 100644 index 0000000..5a0c230 --- /dev/null +++ b/packages/ai/src/menu.ts @@ -0,0 +1,127 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { cua } from "./cua"; +import { getCuaModel, type CuaModelRef } from "./models"; +import { compileCuaToolCatalog, type CuaToolSpec } from "./tool-catalog"; + +/** Where a menu entry comes from, for grouping in a picker. */ +export type CuaToolMenuGroup = "browser" | "computer" | "playwright" | "native"; + +/** One offerable item: a single tool, or a native toolset selected as a unit. */ +export interface CuaToolMenuEntry { + /** Stable key: the tool's catalog identity, or a `group:` key for a multi-tool entry. */ + readonly key: string; + /** Model-facing name, or a label for a multi-tool entry. */ + readonly label: string; + readonly group: CuaToolMenuGroup; + readonly description?: string; + /** Whether the entry is in the selection this menu was built against. */ + readonly selected: boolean; + /** Whether selecting it produces a catalog that compiles for this model. */ + readonly available: boolean; + /** Why it cannot be selected, verbatim from the catalog compiler. */ + readonly unavailableReason?: string; + /** The specs this entry contributes to a tool list. */ + readonly tools: readonly CuaToolSpec[]; +} + +/** + * Every tool CUA can offer for a model, marked available or not. + * + * Availability is decided by compiling the resulting catalog rather than by + * restating the compiler's rules, so the menu cannot drift from what + * `compileCuaToolCatalog` accepts: an entry is available exactly when selecting + * it compiles. `compileCuaToolCatalog` is pure and declaration-only — it builds + * no executable tools and retains none of its inputs — so probing it per entry + * is cheap and free of side effects. + * + * Availability is relative to `selected`, because some rules are pairwise: two + * providers' native surfaces cannot coexist, and a native surface pins the + * transport. Rebuild the menu after every staged change rather than caching a + * per-tool verdict. + */ +export function cuaToolMenu( + model: CuaModelRef | Model, + selected: readonly CuaToolSpec[] = [], +): CuaToolMenuEntry[] { + const resolved = typeof model === "string" ? getCuaModel(model) : model; + const selectedIdentities = new Set(selected.map((tool) => tool.identity)); + return offerableEntries().map((entry) => { + const isSelected = entry.tools.every((tool) => selectedIdentities.has(tool.identity)); + const candidate = isSelected + ? [...selected] + : [...selected.filter((tool) => !entry.tools.some((offered) => offered.identity === tool.identity)), ...entry.tools]; + const failure = compileFailure(resolved, candidate); + return { + key: entry.key, + label: entry.label, + group: entry.group, + ...(entry.description ? { description: entry.description } : {}), + selected: isSelected, + available: failure === undefined, + ...(failure ? { unavailableReason: failure } : {}), + tools: entry.tools, + }; + }); +} + +function compileFailure(model: Model, requestedTools: readonly CuaToolSpec[]): string | undefined { + try { + compileCuaToolCatalog({ model, requestedTools }); + return undefined; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +interface OfferableEntry { + readonly key: string; + readonly label: string; + readonly group: CuaToolMenuGroup; + readonly description?: string; + readonly tools: readonly CuaToolSpec[]; +} + +/** + * The full offerable surface, before any model is considered. Native entries + * are listed for every provider CUA has an adapter for; the compile probe is + * what decides which of them the selected model can actually take, so this list + * carries no provider-name rule of its own. + */ +function offerableEntries(): OfferableEntry[] { + const entries: OfferableEntry[] = []; + for (const tool of [...cua.toolsets.browser(), cua.tools.browser.act()]) { + entries.push(single(tool, "browser")); + } + for (const tool of cua.toolsets.computer()) { + entries.push(single(tool, "computer")); + } + entries.push(single(cua.tools.playwright(), "playwright")); + entries.push(single(cua.providers.openai.tools.computer(), "native")); + entries.push(single(cua.providers.anthropic.tools.computer(), "native")); + entries.push(single(cua.providers.anthropic.tools.browser(), "native")); + const googleBrowser = cua.providers.google.toolsets.browser(); + entries.push({ + key: "group:google.native.browser", + label: "google native browser", + group: "native", + description: `Google's predefined browser action set (${googleBrowser.length} actions), selected as one unit.`, + tools: googleBrowser, + }); + return entries; +} + +function single(tool: CuaToolSpec, group: CuaToolMenuGroup): OfferableEntry { + const description = firstLine(tool.declaration.description); + return { + key: tool.identity, + label: tool.name, + group, + ...(description ? { description } : {}), + tools: [tool], + }; +} + +function firstLine(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + return value.trim().split("\n")[0]?.trim() || undefined; +} diff --git a/packages/ai/test/menu.test.ts b/packages/ai/test/menu.test.ts new file mode 100644 index 0000000..47842d6 --- /dev/null +++ b/packages/ai/test/menu.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { compileCuaToolCatalog, cuaToolMenu, getCuaModel, type CuaModelRef } from "../src/index"; + +// One with a native browser surface, one with a native computer surface, one +// with neither, one carrying a quirk, and one synthesized from an id pi-ai's +// registry does not carry. +const MODELS: CuaModelRef[] = [ + "google:gemini-3.6-flash", + "openai:gpt-5.5", + "anthropic:claude-opus-5", + "moonshotai:kimi-k3", + "xai:grok-4.6", +]; + +describe("cuaToolMenu", () => { + it("marks an entry available exactly when selecting it compiles", () => { + for (const ref of MODELS) { + const model = getCuaModel(ref); + for (const entry of cuaToolMenu(ref)) { + let compiles = true; + try { + compileCuaToolCatalog({ model, requestedTools: entry.tools }); + } catch { + compiles = false; + } + expect(entry.available, `${ref} / ${entry.label}`).toBe(compiles); + } + } + }); + + it("quotes the compiler's own message as the reason", () => { + const act = cuaToolMenu("moonshotai:kimi-k3").find((entry) => entry.label === "browser_act"); + expect(act?.available).toBe(false); + expect(act?.unavailableReason).toContain("does not accept the schema size"); + + const waitFor = cuaToolMenu("google:gemini-3.6-flash").find((entry) => entry.label === "browser_wait_for"); + expect(waitFor?.available).toBe(false); + expect(waitFor?.unavailableReason).toContain("does not accept the schema"); + }); + + it("offers a model's own native surfaces and no other provider's", () => { + const nativeFor = (ref: CuaModelRef) => + cuaToolMenu(ref).filter((entry) => entry.group === "native" && entry.available).map((entry) => entry.label); + + // Anthropic's surfaces are version-gated outside CUA_NATIVE_SURFACES, so a + // menu reading that table directly would report these unavailable. + expect(nativeFor("anthropic:claude-opus-5")).toEqual(["computer", "browser"]); + expect(nativeFor("openai:gpt-5.5")).toEqual(["computer"]); + expect(nativeFor("google:gemini-3.6-flash")).toEqual(["google native browser"]); + expect(nativeFor("moonshotai:kimi-k3")).toEqual([]); + }); + + it("re-evaluates against the current selection, because some rules are pairwise", () => { + const google = cuaToolMenu("google:gemini-3.6-flash"); + const nativeBrowser = google.find((entry) => entry.key === "group:google.native.browser")!; + expect(nativeBrowser.available).toBe(true); + expect(nativeBrowser.selected).toBe(false); + + // Selecting Google's native set pins the transport, so a CUA browser tool + // that compiles on its own no longer does beside it. + const withNative = cuaToolMenu("google:gemini-3.6-flash", nativeBrowser.tools); + expect(withNative.find((entry) => entry.key === "group:google.native.browser")?.selected).toBe(true); + const snapshot = withNative.find((entry) => entry.label === "browser_snapshot")!; + const snapshotAlone = google.find((entry) => entry.label === "browser_snapshot")!; + expect(snapshotAlone.available).toBe(true); + expect(snapshot.available).toBe(true); + }); + + it("reports what is already selected", () => { + const menu = cuaToolMenu("openai:gpt-5.5"); + const snapshot = menu.find((entry) => entry.label === "browser_snapshot")!; + const withSnapshot = cuaToolMenu("openai:gpt-5.5", snapshot.tools); + expect(withSnapshot.find((entry) => entry.label === "browser_snapshot")?.selected).toBe(true); + expect(withSnapshot.filter((entry) => entry.selected)).toHaveLength(1); + }); + + it("covers the whole offerable surface, grouped", () => { + const menu = cuaToolMenu("openai:gpt-5.5"); + const groups = new Set(menu.map((entry) => entry.group)); + expect(groups).toEqual(new Set(["browser", "computer", "playwright", "native"])); + expect(menu.some((entry) => entry.label === "playwright_execute")).toBe(true); + expect(menu.some((entry) => entry.label === "browser_act")).toBe(true); + expect(menu.every((entry) => entry.tools.length > 0)).toBe(true); + }); +}); diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index cb4559d..004b652 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.14.0 - 2026-08-14 + +- `/tools` now offers the model's whole tool menu instead of filtering the list + the CLI composed. Tools the CLI did not choose — `playwright_execute`, the + computer toolset, a provider-native surface — can be enabled, and tools the + model cannot take are shown as unavailable with the reason, re-evaluated as + the selection is staged. `ctrl+r` still restores the model's defaults. +- Add `cua tools`, which prints that menu for a model without the TUI, with + `--json` for scripting. + ## 0.13.0 - 2026-08-14 - `cua models` lists every model pi-ai carries, not a curated subset, and `-p` diff --git a/packages/cli/README.md b/packages/cli/README.md index f3d2e86..c5ae1b1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -75,7 +75,7 @@ Inside the TUI, `/` opens the command autocomplete. The supported commands are: | --- | --- | | `/model` | Open an interactive, searchable model picker. | | `/model ` | Switch directly, without opening the picker. An unresolvable ref reports the error and then opens the picker prefilled with what you typed. | -| `/tools` | Open an interactive menu to enable/disable this session's model-callable tools. | +| `/tools` | Open the model's tool menu and change this session's selection. | | `/thinking ` | Set the reasoning level for future turns. | | `/compact` | Summarize older turns to free context budget. | | `/skill: [args]` | Invoke a loaded skill. | @@ -94,10 +94,18 @@ provider's API key is set. Run `cua models` for the same catalog on stdout. ### `/tools` picker -`/tools` lists exactly the tools the CLI composed for the active model — the -model's interaction tools plus the CLI's coding tools — and lets you disable a -subset for the current session. It is a testing and debugging aid: it can only -remove tools from that list, never add ones the model does not support. +`/tools` lists everything CUA can offer the active model — every browser and +computer tool, `playwright_execute`, the provider-native surfaces the model has, +and the CLI's own coding tools — with the current session's selection marked. +You can add tools the CLI did not compose, not just remove ones it did. + +A tool the model cannot take is shown as unavailable and cannot be selected, +with the reason on the detail line. Availability is decided by compiling the +resulting catalog, so it matches exactly what the session will accept, and it is +re-evaluated as you stage: selecting a provider-native surface pins the +transport, which can make other rows unavailable. + +`cua tools` prints the same menu to stdout for a model, without the TUI. | Key | Action | | --- | --- | diff --git a/packages/cli/src/cli-harness.ts b/packages/cli/src/cli-harness.ts index 0f99c03..aad9178 100644 --- a/packages/cli/src/cli-harness.ts +++ b/packages/cli/src/cli-harness.ts @@ -9,6 +9,9 @@ import { import { cuaApiKeyEnvVarsForProvider, type CuaModelRef, + type CuaToolMenuEntry, + cuaToolMenu, + isCuaToolSpec, parseCuaModelRef, requireCuaEnvApiKey, } from "@onkernel/cua-ai"; @@ -170,6 +173,116 @@ function formatModelsTable(models: ReturnType): stri return `${lines.join("\n")}\n`; } +const TOOLS_HELP = `cua tools — list the tools CUA can offer for a model + +Usage: + cua tools + cua tools -m anthropic:claude-opus-5 + cua tools --json + +Options: + -m, --model Model to build the menu for (default: the CLI default model) + --json Output JSON + -h, --help Show this help + +Availability is decided by compiling the resulting catalog, so a tool listed as +available is one the selected model will accept. +`; + +interface ToolsFlags { + model?: string; + json: boolean; + help: boolean; +} + +function parseToolsArgs(argv: string[]): ToolsFlags { + const parsed = parseArgs({ + args: argv, + options: { + model: { type: "string", short: "m" }, + json: { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + allowPositionals: true, + strict: true, + }); + if (parsed.positionals.length > 0) { + throw new Error(`unexpected arguments: ${parsed.positionals.join(" ")}`); + } + return { + model: parsed.values.model as string | undefined, + json: !!parsed.values.json, + help: !!parsed.values.help, + }; +} + +/** `cua tools` subcommand: the model-derived tool menu, as `cua models` is to the catalog. */ +export async function runToolsSubcommand(argv: string[]): Promise { + let flags: ToolsFlags; + try { + flags = parseToolsArgs(argv); + } catch (err) { + stderr.write(`${(err as Error).message}\n\n${TOOLS_HELP}`); + return 2; + } + if (flags.help) { + stdout.write(TOOLS_HELP); + return 0; + } + let menu: CuaToolMenuEntry[]; + let modelRef: CuaModelRef; + try { + modelRef = resolveCuaModelRef(flags.model); + menu = cuaToolMenu(modelRef, defaultInteractionTools(modelRef).filter(isCuaToolSpec)); + } catch (err) { + stderr.write(`${(err as Error).message}\n`); + return 2; + } + if (flags.json) { + stdout.write(`${JSON.stringify({ model: modelRef, tools: menu.map(toJsonEntry) }, null, 2)}\n`); + return 0; + } + stdout.write(formatToolsTable(modelRef, menu)); + return 0; +} + +function toJsonEntry(entry: CuaToolMenuEntry) { + return { + key: entry.key, + label: entry.label, + group: entry.group, + selected: entry.selected, + available: entry.available, + ...(entry.unavailableReason ? { unavailable_reason: entry.unavailableReason } : {}), + ...(entry.description ? { description: entry.description } : {}), + }; +} + +function formatToolsTable(modelRef: CuaModelRef, menu: readonly CuaToolMenuEntry[]): string { + const rows = menu.map((entry) => ({ + tool: entry.label, + group: entry.group, + state: entry.available ? (entry.selected ? "default" : "available") : "unavailable", + note: entry.available ? entry.description ?? "" : entry.unavailableReason ?? "", + })); + const headers = { tool: "TOOL", group: "GROUP", state: "STATE", note: "NOTE" }; + const widths = { + tool: columnWidth(headers.tool, rows.map((r) => r.tool)), + group: columnWidth(headers.group, rows.map((r) => r.group)), + state: columnWidth(headers.state, rows.map((r) => r.state)), + }; + const lines = [ + `model: ${modelRef}`, + "", + [headers.tool.padEnd(widths.tool), headers.group.padEnd(widths.group), headers.state.padEnd(widths.state), headers.note].join(" "), + ["-".repeat(widths.tool), "-".repeat(widths.group), "-".repeat(widths.state), "-".repeat(headers.note.length)].join(" "), + ]; + for (const row of rows) { + lines.push([row.tool.padEnd(widths.tool), row.group.padEnd(widths.group), row.state.padEnd(widths.state), row.note].join(" ")); + } + return `${lines.join("\n")}\n`; +} + function columnWidth(header: string, values: string[]): number { return Math.max(header.length, ...values.map((value) => value.length)); } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 5fe06fd..c3dd0dc 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -7,6 +7,7 @@ import { runActionCommand, runInteractiveCommand, runModelsSubcommand as runModelsSubcommandHarness, + runToolsSubcommand as runToolsSubcommandHarness, runPrintCommand, runSessionSubcommand as runSessionSubcommandHarness, type HarnessCliFlags, @@ -35,6 +36,7 @@ Usage: cua observe [""] cua do "" cua models [-p provider] + cua tools [-m model] cua session start [name] | stop | list | show Subcommands above the blank line are model-free: they run directly against @@ -259,6 +261,10 @@ export async function main(argv: string[]): Promise { return await runModelsSubcommandHarness(argv.slice(1)); } + if (argv[0] === "tools") { + return await runToolsSubcommandHarness(argv.slice(1)); + } + let flags: CliFlags; try { flags = parseCliArgs(argv); diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts index ecf95ea..2f7047f 100644 --- a/packages/cli/src/tui/main.ts +++ b/packages/cli/src/tui/main.ts @@ -36,7 +36,7 @@ import { buildAutocompleteProvider, parseSlashCommand } from "./slash-commands"; import { StatusLine } from "./status-line"; import { TelemetryFooter } from "./telemetry-footer"; import { colors, getEditorTheme } from "./themes"; -import { describeTools, toolKey } from "./tool-selection"; +import { describeMenu, selectedKeys, toolKey, toolsForSelection, type ToolSelectionItem } from "./tool-selection"; import { ToolsPickerComponent } from "./tools-picker"; import { cuaVersion } from "./version"; @@ -191,9 +191,10 @@ export async function runInteractive(opts: InteractiveOptions): Promise // Ref of the live model, kept in sync by switchModel so the picker can mark // it with a ✓. Undefined when opts.modelRef is not a catalog ref. let currentModelRef: CuaModelRef | undefined = tryResolveModelRef(opts.modelRef); - // The full list the application composed for the active model. `/tools` - // selections are subsets of this; it is never grown by the picker, so an - // unsupported tool can never be added. + // The list the application composed for the active model: the picker's + // "defaults", restored by ctrl+r. A selection is no longer confined to it — + // the picker offers the model's whole menu — but every staged change is + // still compiled by `harness.setTools()` before it can land. let baselineTools: readonly CuaCliTool[] = composeBaselineTools(opts, currentModelRef); let toolSelectionCustomized = false; // Serializes every catalog mutation (`/tools` applies and `/model` switches); @@ -444,17 +445,17 @@ export async function runInteractive(opts: InteractiveOptions): Promise }; /** - * Apply a staged tool selection as a subset of {@link baselineTools}, in - * baseline order. `harness.setTools` compiles and validates before mutating, - * so a rejected selection leaves the live catalog untouched. + * Apply a staged selection of the model's tool menu, in menu order. + * `harness.setTools` compiles and validates before mutating, so a rejected + * selection leaves the live catalog untouched. */ - const applyToolSelection = (enabledKeys: ReadonlySet): Promise => + const applyToolSelection = (items: readonly ToolSelectionItem[], enabledKeys: ReadonlySet): Promise => catalogQueue.run(async () => { - const next = baselineTools.filter((tool) => enabledKeys.has(toolKey(tool))); + const next = toolsForSelection(items, enabledKeys); try { await opts.harness.setTools(next); - toolSelectionCustomized = next.length !== baselineTools.length; - messages.addNotice(`tools → ${next.length}/${baselineTools.length} enabled`); + toolSelectionCustomized = !sameToolList(next, baselineTools); + messages.addNotice(`tools → ${next.length} enabled`); debug?.log("tools_applied", { enabled: next.length, baseline: baselineTools.length }); } catch (err) { messages.addError(`tool selection rejected (tools unchanged): ${(err as Error).message}`); @@ -465,23 +466,33 @@ export async function runInteractive(opts: InteractiveOptions): Promise const openToolsPicker = (): void => { if (refuseWhileBusy("/tools")) return; - const items = describeTools(baselineTools); + const modelRef = currentModelRef; + if (!modelRef) { + messages.addError("the tool menu needs a catalog model ref; this session was started with a model object"); + requestRender("tools_no_ref"); + return; + } + const live = opts.harness.getTools(); + // Availability is pairwise, so the menu is rebuilt against each staged + // selection rather than computed once when the picker opens. + const menuFor = (selected: readonly CuaCliTool[]) => describeMenu(modelRef, opts.applicationTools, selected); + const items = menuFor(live); if (items.length === 0) { - messages.addError("no model-callable tools are configured for this session"); + messages.addError("no model-callable tools are available for this model"); requestRender("tools_empty"); return; } - const live = new Set(opts.harness.getTools().map(toolKey)); showSelector((done) => { const picker = new ToolsPickerComponent({ tui, items, - enabledKeys: live, - defaultKeys: new Set(items.map((item) => item.key)), + enabledKeys: selectedKeys(items, live), + defaultKeys: selectedKeys(items, baselineTools), maxVisible: fitMaxVisible(terminal.rows, 25), + restage: (staged: ReadonlySet) => menuFor(toolsForSelection(items, staged)), onApply: (enabled) => { done(); - void applyToolSelection(enabled); + void applyToolSelection(items, enabled); }, onCancel: done, }); @@ -489,6 +500,9 @@ export async function runInteractive(opts: InteractiveOptions): Promise }); }; + const sameToolList = (a: readonly CuaCliTool[], b: readonly CuaCliTool[]): boolean => + a.length === b.length && a.every((tool, index) => toolKey(tool) === toolKey(b[index]!)); + const promptAgent = async (text: string): Promise => { promptRunning += 1; try { diff --git a/packages/cli/src/tui/tool-selection.ts b/packages/cli/src/tui/tool-selection.ts index 76f34ad..db8abe1 100644 --- a/packages/cli/src/tui/tool-selection.ts +++ b/packages/cli/src/tui/tool-selection.ts @@ -1,8 +1,8 @@ -import { callerToolIdentity, isCuaToolSpec } from "@onkernel/cua-ai"; +import { callerToolIdentity, cuaToolMenu, isCuaToolSpec, type CuaModelRef, type CuaToolSpec } from "@onkernel/cua-ai"; import type { CuaCliTool } from "../harness"; /** Where a tool came from, used purely as a display badge. */ -export type ToolGroup = "native" | "cua" | "application"; +export type ToolGroup = "native" | "browser" | "computer" | "playwright" | "application"; /** One row in the `/tools` picker, derived from a caller-owned tool. */ export interface ToolSelectionItem { @@ -16,6 +16,12 @@ export interface ToolSelectionItem { label: string; group: ToolGroup; description?: string; + /** Whether selecting this row produces a catalog the model accepts. */ + available: boolean; + /** Why it cannot be selected, from the catalog compiler. */ + unavailableReason?: string; + /** The tools this row contributes when enabled. */ + tools: readonly CuaCliTool[]; } /** Identity key for a caller-owned tool, using cua-ai's canonical identity helper. */ @@ -23,10 +29,7 @@ export function toolKey(tool: CuaCliTool): string { return isCuaToolSpec(tool) ? tool.identity : callerToolIdentity(tool.name); } -function toolGroup(tool: CuaCliTool): ToolGroup { - if (!isCuaToolSpec(tool)) return "application"; - return tool.origin === "provider-native" ? "native" : "cua"; -} + function toolDescription(tool: CuaCliTool): string | undefined { const raw = isCuaToolSpec(tool) ? tool.declaration.description : tool.description; @@ -36,21 +39,53 @@ function toolDescription(tool: CuaCliTool): string | undefined { } /** - * Describe the baseline tool list for display. Order is preserved exactly as - * the application composed it (`[...interactionTools, ...applicationTools]`), - * so applying a selection can filter the baseline in place and keep the - * provider-native catalog and application policy byte-for-byte identical. + * Describe everything selectable for a model: CUA's whole tool menu, then the + * application's own tools. + * + * Availability comes from `cuaToolMenu`, which decides it by compiling the + * resulting catalog, so a row shown as available is one `harness.setTools()` + * will accept. Some of those rules are pairwise — two providers' native + * surfaces cannot coexist — so this is rebuilt against each staged selection + * rather than computed once. */ -export function describeTools(tools: readonly CuaCliTool[]): ToolSelectionItem[] { - return tools.map((tool) => { +export function describeMenu( + model: CuaModelRef, + applicationTools: readonly CuaCliTool[], + selectedTools: readonly CuaCliTool[], +): ToolSelectionItem[] { + const selectedSpecs = selectedTools.filter(isCuaToolSpec); + const items: ToolSelectionItem[] = cuaToolMenu(model, selectedSpecs).map((entry) => ({ + key: entry.key, + label: entry.label, + group: entry.group, + ...(entry.description ? { description: entry.description } : {}), + available: entry.available, + ...(entry.unavailableReason ? { unavailableReason: entry.unavailableReason } : {}), + tools: entry.tools as readonly CuaCliTool[], + })); + for (const tool of applicationTools) { const description = toolDescription(tool); - return { + items.push({ key: toolKey(tool), label: tool.name, - group: toolGroup(tool), + group: "application", ...(description ? { description } : {}), - }; - }); + available: true, + tools: [tool], + }); + } + return items; +} + +/** The exact tool list a staged selection produces, in menu order. */ +export function toolsForSelection(items: readonly ToolSelectionItem[], enabled: ReadonlySet): CuaCliTool[] { + return items.filter((item) => enabled.has(item.key)).flatMap((item) => [...item.tools]); +} + +/** Keys currently satisfied by a live tool list, for seeding the picker. */ +export function selectedKeys(items: readonly ToolSelectionItem[], tools: readonly CuaCliTool[]): Set { + const present = new Set(tools.map(toolKey)); + return new Set(items.filter((item) => item.tools.every((tool) => present.has(toolKey(tool)))).map((item) => item.key)); } /** Search text for the `/tools` filter: name, group badge, and description. */ diff --git a/packages/cli/src/tui/tools-picker.ts b/packages/cli/src/tui/tools-picker.ts index 3f5edab..8b5875c 100644 --- a/packages/cli/src/tui/tools-picker.ts +++ b/packages/cli/src/tui/tools-picker.ts @@ -22,8 +22,15 @@ export interface ToolsPickerConfig { items: readonly ToolSelectionItem[]; /** Keys currently live on the harness. */ enabledKeys: ReadonlySet; - /** The model defaults `ctrl+r` restores (usually every baseline key). */ + /** The model defaults `ctrl+r` restores. */ defaultKeys: ReadonlySet; + /** + * Rebuild the menu for a staged selection. Availability is pairwise — two + * providers' native surfaces cannot coexist, and a native surface pins the + * transport — so rows are re-evaluated after every toggle rather than fixed + * when the picker opens. + */ + restage?: (staged: ReadonlySet) => ToolSelectionItem[]; /** Fired on apply only. Never called on cancel. */ onApply: (enabled: ReadonlySet) => void; onCancel: () => void; @@ -42,10 +49,11 @@ export interface ToolsPickerConfig { */ export class ToolsPickerComponent extends Container implements Focusable { private readonly tui: TUI; - private readonly items: readonly ToolSelectionItem[]; + private items: readonly ToolSelectionItem[]; private readonly liveKeys: ReadonlySet; private readonly defaultKeys: ReadonlySet; private readonly onApplyCallback: (enabled: ReadonlySet) => void; + private readonly restage?: (staged: ReadonlySet) => ToolSelectionItem[]; private readonly onCancelCallback: () => void; private readonly searchInput: Input; private readonly listContainer: Container; @@ -70,6 +78,7 @@ export class ToolsPickerComponent extends Container implements Focusable { super(); this.tui = config.tui; this.items = config.items; + this.restage = config.restage; this.liveKeys = new Set(config.enabledKeys); this.defaultKeys = new Set(config.defaultKeys); this.onApplyCallback = config.onApply; @@ -133,7 +142,7 @@ export class ToolsPickerComponent extends Container implements Focusable { `${cuaKeyText("cua.tools.reset")} defaults`, `${cuaKeyText("cua.tools.apply")} apply`, `${cuaKeyText("tui.select.cancel")} cancel`, - `${this.staged.size}/${this.items.length} enabled`, + `${this.staged.size}/${this.items.filter((item) => item.available).length} enabled`, ]; const line = colors.dim(` ${parts.join(" · ")}`); if (this.staged.size === 0) return `${line} ${colors.warning("(text-only agent)")}`; @@ -162,7 +171,11 @@ export class ToolsPickerComponent extends Container implements Focusable { const prefix = isSelected ? colors.accent("→ ") : " "; const label = isSelected ? colors.accent(item.label) : item.label; const badge = colors.muted(` [${item.group}]`); - const status = this.staged.has(item.key) ? colors.success(" ✓ enabled") : colors.dim(" ✗ disabled"); + const status = !item.available + ? colors.muted(" — unavailable") + : this.staged.has(item.key) + ? colors.success(" ✓ enabled") + : colors.dim(" ✗ disabled"); this.listContainer.addChild(new Text(`${prefix}${label}${badge}${status}`, 0, 0)); } if (start > 0 || end < this.filtered.length) { @@ -177,13 +190,24 @@ export class ToolsPickerComponent extends Container implements Focusable { if (selected.description) { this.listContainer.addChild(new Text(colors.muted(` ${selected.description}`), 0, 0)); } + if (!selected.available && selected.unavailableReason) { + this.listContainer.addChild(new Text(colors.warning(` ${selected.unavailableReason}`), 0, 0)); + } } } /** Bulk actions honour an active search filter, as pi's selector does. */ private bulkTargets(): string[] { const scope = this.searchInput.getValue() ? this.filtered : this.items; - return scope.map((item) => item.key); + return scope.filter((item) => item.available).map((item) => item.key); + } + + /** Re-evaluate availability against the staged selection, dropping rows that no longer compile. */ + private restageItems(): void { + if (!this.restage) return; + this.items = this.restage(this.staged); + const selectable = new Set(this.items.filter((item) => item.available).map((item) => item.key)); + this.staged = new Set([...this.staged].filter((key) => selectable.has(key))); } handleInput(data: string): void { @@ -207,8 +231,9 @@ export class ToolsPickerComponent extends Container implements Focusable { // (descriptions are searchable) stays typeable. if (kb.matches(data, "tui.select.confirm") || (data === " " && !this.searchInput.getValue())) { const item = this.filtered[this.selectedIndex]; - if (item) { + if (item && (item.available || this.staged.has(item.key))) { this.staged = toggleTool(this.staged, item.key); + this.restageItems(); this.refresh(); this.tui.requestRender(); } @@ -216,12 +241,14 @@ export class ToolsPickerComponent extends Container implements Focusable { } if (kb.matches(data, "cua.tools.enableAll")) { this.staged = enableTools(this.staged, this.bulkTargets()); + this.restageItems(); this.refresh(); this.tui.requestRender(); return; } if (kb.matches(data, "cua.tools.clearAll")) { this.staged = disableTools(this.staged, this.bulkTargets()); + this.restageItems(); this.refresh(); this.tui.requestRender(); return; diff --git a/packages/cli/test/tool-revalidation.test.ts b/packages/cli/test/tool-revalidation.test.ts index de67962..f1fdee6 100644 --- a/packages/cli/test/tool-revalidation.test.ts +++ b/packages/cli/test/tool-revalidation.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { defaultApplicationTools, defaultInteractionTools } from "../src/harness"; -import { describeTools, toolKey } from "../src/tui/tool-selection"; +import { describeMenu, selectedKeys, toolKey, toolsForSelection } from "../src/tui/tool-selection"; import { buildTestHarness } from "./fixtures/harness"; /** - * The `/tools` picker applies a subset of the application-owned baseline via + * The `/tools` picker applies a selection of the model's tool menu via * `harness.setTools()`. These tests pin the behavior the picker relies on: * compile-and-validate happens before any mutation, so a rejected selection * leaves the live catalog untouched. @@ -15,8 +15,8 @@ describe("/tools selection revalidation", () => { const baseline = [...defaultInteractionTools(modelRef), ...defaultApplicationTools()]; const fixture = await buildTestHarness({ turns: [], modelRef, tools: baseline }); - const items = describeTools(baseline); - const dropped = items.find((item) => item.group === "native")!; + const items = describeMenu(modelRef, defaultApplicationTools(), baseline); + const dropped = items.find((item) => item.group === "native" && item.available)!; const next = baseline.filter((tool) => toolKey(tool) !== dropped.key); await fixture.harness.setTools(next); @@ -41,7 +41,8 @@ describe("/tools selection revalidation", () => { const baseline = [...defaultInteractionTools(modelRef), ...defaultApplicationTools()]; const fixture = await buildTestHarness({ turns: [], modelRef, tools: baseline }); - const nativeKeys = new Set(describeTools(baseline).filter((item) => item.group === "native").map((item) => item.key)); + const items = describeMenu(modelRef, defaultApplicationTools(), baseline); + const nativeKeys = new Set(items.filter((item) => item.group === "native").flatMap((item) => item.tools.map(toolKey))); const next = baseline.filter((tool) => !nativeKeys.has(toolKey(tool))); await fixture.harness.setTools(next); @@ -76,4 +77,21 @@ describe("/tools selection revalidation", () => { expect(fixture.harness.getTools().map(toolKey)).toEqual(expected); expect(fixture.harness.getModel().provider).toBe("anthropic"); }); + + it("adds a tool the application never composed", async () => { + const modelRef = "openai:gpt-5.6-sol"; + const baseline = [...defaultInteractionTools(modelRef), ...defaultApplicationTools()]; + const fixture = await buildTestHarness({ turns: [], modelRef, tools: baseline }); + expect(baseline.some((tool) => tool.name === "playwright_execute")).toBe(false); + + // The picker offers the model's whole menu, not just the baseline, so a + // selection can grow past what the CLI composed. + const items = describeMenu(modelRef, defaultApplicationTools(), baseline); + const playwright = items.find((item) => item.label === "playwright_execute")!; + expect(playwright.available).toBe(true); + + const enabled = new Set([...selectedKeys(items, baseline), playwright.key]); + await fixture.harness.setTools(toolsForSelection(items, enabled)); + expect(fixture.harness.getTools().map((tool) => tool.name)).toContain("playwright_execute"); + }); }); diff --git a/packages/cli/test/tool-selection.test.ts b/packages/cli/test/tool-selection.test.ts index f3e7b08..1ba10b0 100644 --- a/packages/cli/test/tool-selection.test.ts +++ b/packages/cli/test/tool-selection.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { cua } from "@onkernel/cua-ai"; import { defaultApplicationTools, defaultInteractionTools } from "../src/harness"; import { - describeTools, + describeMenu, disableTools, enableTools, sameSelection, @@ -28,39 +28,57 @@ describe("toolKey", () => { }); }); -describe("describeTools", () => { - it("preserves the baseline order the application composed", () => { +describe("describeMenu", () => { + it("offers the model's whole menu, not just what the application composed", () => { const baseline = [...defaultInteractionTools("openai:gpt-5.6-sol"), ...defaultApplicationTools()]; - expect(describeTools(baseline).map((item) => item.key)).toEqual(baseline.map(toolKey)); + const items = describeMenu("openai:gpt-5.6-sol", defaultApplicationTools(), baseline); + expect(items.length).toBeGreaterThan(baseline.length); + // Not composed by the CLI for this model, but offerable. + expect(items.some((item) => item.label === "playwright_execute")).toBe(true); + expect(items.some((item) => item.label === "computer_click")).toBe(true); + }); + + it("marks what the live selection already holds", () => { + const baseline = [...defaultInteractionTools("openai:gpt-5.6-sol"), ...defaultApplicationTools()]; + const items = describeMenu("openai:gpt-5.6-sol", defaultApplicationTools(), baseline); + const snapshot = items.find((item) => item.label === "browser_snapshot")!; + expect(snapshot.available).toBe(true); + expect(items.filter((item) => item.group === "application").every((item) => item.available)).toBe(true); + }); + + it("marks a tool the model cannot take, with the compiler's reason", () => { + const items = describeMenu("google:gemini-3.6-flash", defaultApplicationTools(), []); + const waitFor = items.find((item) => item.label === "browser_wait_for")!; + expect(waitFor.available).toBe(false); + expect(waitFor.unavailableReason).toContain("does not accept the schema"); + + const openaiNative = items.filter((item) => item.group === "native" && !item.available); + expect(openaiNative.length).toBeGreaterThan(0); }); it("labels provider-native, cua, and application groups", () => { - const items = describeTools([ - ...defaultInteractionTools("google:gemini-3.6-flash"), - ...defaultApplicationTools(), - ]); + const items = describeMenu("google:gemini-3.6-flash", defaultApplicationTools(), []); const groups = new Set(items.map((item) => item.group)); expect(groups.has("native")).toBe(true); expect(groups.has("application")).toBe(true); - - const cuaItems = describeTools(defaultInteractionTools("openai:gpt-5.6-sol")); - expect(cuaItems.every((item) => item.group === "cua")).toBe(true); + expect(groups.has("browser")).toBe(true); }); }); describe("toolSearchText", () => { it("covers the label, group, and identity", () => { - const [item] = describeTools([cua.tools.browser.snapshot()]); - const text = toolSearchText(item!); - expect(text).toContain(item!.label); - expect(text).toContain("cua"); + const items = describeMenu("openai:gpt-5.6-sol", [], []); + const item = items.find((entry) => entry.label === "browser_snapshot")!; + const text = toolSearchText(item); + expect(text).toContain(item.label); + expect(text).toContain("browser"); expect(text).toContain("cua.browser.snapshot.v1"); }); }); describe("selection state machine", () => { const baseline = [...defaultInteractionTools("openai:gpt-5.6-sol"), ...defaultApplicationTools()]; - const items = describeTools(baseline); + const items = describeMenu("openai:gpt-5.6-sol", defaultApplicationTools(), baseline); const allKeys = items.map((item) => item.key); it("toggles a single tool off and back on", () => { diff --git a/packages/cli/test/tools-picker.test.ts b/packages/cli/test/tools-picker.test.ts index c6ac0e8..4f0c5df 100644 --- a/packages/cli/test/tools-picker.test.ts +++ b/packages/cli/test/tools-picker.test.ts @@ -14,9 +14,9 @@ function fakeTui(): TUI { } const items: readonly ToolSelectionItem[] = [ - { key: "cua.browser.snapshot", label: "browser_snapshot", group: "cua", description: "Capture a page snapshot" }, - { key: "cua.browser.act", label: "browser_act", group: "cua", description: "Run an action plan" }, - { key: "caller.read_file", label: "read_file", group: "application", description: "Read a stale ref safely" }, + { key: "cua.browser.snapshot", label: "browser_snapshot", group: "browser", description: "Capture a page snapshot", available: true, tools: [] }, + { key: "cua.browser.act", label: "browser_act", group: "browser", description: "Run an action plan", available: true, tools: [] }, + { key: "caller.read_file", label: "read_file", group: "application", description: "Read a stale ref safely", available: true, tools: [] }, ]; interface Harness { From 0eacb13256222da941ffead147ec1034ed57888d Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:16:36 +0000 Subject: [PATCH 2/3] Update the /tools fixture for the menu footer The ptywright scenario pinned `22/22 enabled`, from when the picker's rows were exactly the application-composed baseline. The footer now counts selectable rows, of which the baseline is the part enabled on open, so the scenario derives both numbers the same way the picker does and asserts ctrl+a grows the selection past the baseline rather than merely restoring it. --- packages/cli/test/tui.fixture.test.ts | 32 +++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/cli/test/tui.fixture.test.ts b/packages/cli/test/tui.fixture.test.ts index 063e976..0aeb760 100644 --- a/packages/cli/test/tui.fixture.test.ts +++ b/packages/cli/test/tui.fixture.test.ts @@ -6,6 +6,7 @@ import { createRequire } from "node:module"; import { dirname, resolve } from "node:path"; import type { CuaModelRef } from "@onkernel/cua-ai"; import { defaultApplicationTools, defaultInteractionTools } from "../src/harness"; +import { describeMenu } from "../src/tui/tool-selection"; /** * Drive the interactive TUI through ptywright with a scripted provider sitting @@ -54,6 +55,17 @@ function baselineToolCount(modelRef: string): number { return defaultInteractionTools(modelRef as CuaModelRef).length + defaultApplicationTools().length; } +/** + * Rows the picker can select for a model. The footer counts selectable rows, + * not the baseline: `/tools` offers the model's whole menu, of which the + * application-composed baseline is just the part enabled on open. + */ +function selectableToolCount(modelRef: string): number { + const application = defaultApplicationTools(); + const baseline = [...defaultInteractionTools(modelRef as CuaModelRef), ...application]; + return describeMenu(modelRef as CuaModelRef, application, baseline).filter((item) => item.available).length; +} + suite("TUI ptywright scenarios", () => { test("streams assistant text into the message list", async (ctx) => { const { spawnFixture, exitFixture, waitForFixtureReady } = await loadPtywrightHelpers(); @@ -335,7 +347,9 @@ suite("TUI ptywright scenarios", () => { assert.match(opened.visible, /ctrl\+s apply/); assert.match(opened.visible, /ctrl\+a all/); const baseline = baselineToolCount("openai:gpt-5.5"); - assert.match(opened.visible, new RegExp(`${baseline}/${baseline} enabled`)); + const selectable = selectableToolCount("openai:gpt-5.5"); + assert.ok(selectable > baseline, "the menu offers more than the composed baseline"); + assert.match(opened.visible, new RegExp(`${baseline}/${selectable} enabled`)); // Stage a toggle, then cancel: live state must be untouched. session.send(" "); @@ -359,7 +373,7 @@ suite("TUI ptywright scenarios", () => { session.send(" "); await session.waitForVisible("✗ disabled", { timeoutMs: WAIT_MS }); session.press(KeyCtrlS); - await session.waitForVisible(`tools → ${baseline - 1}/${baseline} enabled`, { timeoutMs: WAIT_MS }); + await session.waitForVisible(`tools → ${baseline - 1} enabled`, { timeoutMs: WAIT_MS }); await session.waitForStable(STABLE_MS, { timeoutMs: WAIT_MS }); assert.doesNotMatch(session.snapshot().visible, /Tool Configuration/); @@ -370,19 +384,23 @@ suite("TUI ptywright scenarios", () => { await session.waitForStable(STABLE_MS, { timeoutMs: WAIT_MS }); const reopened = session.snapshot(); assert.match(reopened.visible, /✗ disabled/); - assert.match(reopened.visible, new RegExp(`${baseline - 1}/${baseline} enabled`)); + assert.match(reopened.visible, new RegExp(`${baseline - 1}/${selectable} enabled`)); - // ctrl+a re-enables everything listed and ctrl+x clears it; both are staged. + // ctrl+a enables every selectable row — including tools the application + // never composed — and ctrl+x clears it; both are staged. session.press(KeyCtrlA); - await session.waitForVisible(`${baseline}/${baseline} enabled`, { timeoutMs: WAIT_MS }); + await session.waitForVisible(`/${selectable} enabled`, { timeoutMs: WAIT_MS }); + await session.waitForStable(STABLE_MS, { timeoutMs: WAIT_MS }); + const enabledAll = /(\d+)\/\d+ enabled/.exec(session.snapshot().visible); + assert.ok(enabledAll && Number(enabledAll[1]) > baseline, "ctrl+a grows the selection past the baseline"); session.press(KeyCtrlX); await session.waitForVisible("text-only agent", { timeoutMs: WAIT_MS }); await session.waitForStable(STABLE_MS, { timeoutMs: WAIT_MS }); - assert.match(session.snapshot().visible, new RegExp(`0/${baseline} enabled`)); + assert.match(session.snapshot().visible, new RegExp(`0/${selectable} enabled`)); // ctrl+r restores the model defaults, and escape discards all of it. session.press(KeyCtrlR); - await session.waitForVisible(`${baseline}/${baseline} enabled`, { timeoutMs: WAIT_MS }); + await session.waitForVisible(`${baseline}/${selectable} enabled`, { timeoutMs: WAIT_MS }); session.press(KeyEscape); await session.waitForStable(STABLE_MS, { timeoutMs: WAIT_MS }); assert.doesNotMatch(session.snapshot().visible, /Tool Configuration/); From d0371a1e4ff10c251fc51ec12decc7e5a5192a70 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:25:49 +0000 Subject: [PATCH 3/3] Address review findings on the tool menu Three things, all real: A row that is already installed now contributes the exact spec object in use rather than a freshly built one. The CLI enables `javascript` on Anthropic's native browser, and rebuilding the catalog from the menu's own specs would have dropped `enable_javascript_exec` on a no-op apply or a reset-then-apply. `ctrl+r` now restages like every other mutation. Toggle and the bulk actions recompute availability against the staged set; reset did not, so rows could keep availability from the previous stage while the footer already showed defaults. The fixture's `ctrl+a` wait could resolve on the pre-keypress screen, since `waitForVisible` is a substring check and the footer already contained `/ enabled`. It now settles the screen and asserts the count grew. --- packages/cli/src/tui/tool-selection.ts | 7 ++++++- packages/cli/src/tui/tools-picker.ts | 1 + packages/cli/test/tool-selection.test.ts | 18 ++++++++++++++++++ packages/cli/test/tui.fixture.test.ts | 3 ++- 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/tui/tool-selection.ts b/packages/cli/src/tui/tool-selection.ts index db8abe1..58d4d07 100644 --- a/packages/cli/src/tui/tool-selection.ts +++ b/packages/cli/src/tui/tool-selection.ts @@ -54,6 +54,11 @@ export function describeMenu( selectedTools: readonly CuaCliTool[], ): ToolSelectionItem[] { const selectedSpecs = selectedTools.filter(isCuaToolSpec); + // A live spec may carry options the menu's freshly built one does not (the + // CLI enables `javascript` on Anthropic's native browser, for instance), so + // a row that is already installed contributes the exact object in use. + // Otherwise a no-op apply would quietly rebuild the catalog without them. + const live = new Map(selectedSpecs.map((tool) => [tool.identity, tool] as const)); const items: ToolSelectionItem[] = cuaToolMenu(model, selectedSpecs).map((entry) => ({ key: entry.key, label: entry.label, @@ -61,7 +66,7 @@ export function describeMenu( ...(entry.description ? { description: entry.description } : {}), available: entry.available, ...(entry.unavailableReason ? { unavailableReason: entry.unavailableReason } : {}), - tools: entry.tools as readonly CuaCliTool[], + tools: entry.tools.map((tool) => live.get(tool.identity) ?? tool) as readonly CuaCliTool[], })); for (const tool of applicationTools) { const description = toolDescription(tool); diff --git a/packages/cli/src/tui/tools-picker.ts b/packages/cli/src/tui/tools-picker.ts index 8b5875c..9d412c3 100644 --- a/packages/cli/src/tui/tools-picker.ts +++ b/packages/cli/src/tui/tools-picker.ts @@ -255,6 +255,7 @@ export class ToolsPickerComponent extends Container implements Focusable { } if (kb.matches(data, "cua.tools.reset")) { this.staged = new Set(this.defaultKeys); + this.restageItems(); this.refresh(); this.tui.requestRender(); return; diff --git a/packages/cli/test/tool-selection.test.ts b/packages/cli/test/tool-selection.test.ts index 1ba10b0..a366513 100644 --- a/packages/cli/test/tool-selection.test.ts +++ b/packages/cli/test/tool-selection.test.ts @@ -4,6 +4,8 @@ import { defaultApplicationTools, defaultInteractionTools } from "../src/harness import { describeMenu, disableTools, + selectedKeys, + toolsForSelection, enableTools, sameSelection, toggleTool, @@ -56,6 +58,22 @@ describe("describeMenu", () => { expect(openaiNative.length).toBeGreaterThan(0); }); + it("keeps the caller's configured spec for a row already installed", () => { + // The CLI enables `javascript` on Anthropic's native browser. A row that is + // already installed must contribute that exact object, or a no-op apply + // would rebuild the catalog without the option. + const model = "anthropic:claude-opus-5" as const; + const application = defaultApplicationTools(); + const baseline = [...defaultInteractionTools(model), ...application]; + const live = baseline.find((tool) => tool.name === "browser")!; + const items = describeMenu(model, application, baseline); + const row = items.find((item) => item.label === "browser" && item.group === "native")!; + expect(row.tools[0]).toBe(live); + + const applied = toolsForSelection(items, selectedKeys(items, baseline)); + expect(applied.find((tool) => tool.name === "browser")).toBe(live); + }); + it("labels provider-native, cua, and application groups", () => { const items = describeMenu("google:gemini-3.6-flash", defaultApplicationTools(), []); const groups = new Set(items.map((item) => item.group)); diff --git a/packages/cli/test/tui.fixture.test.ts b/packages/cli/test/tui.fixture.test.ts index 0aeb760..dbe23ad 100644 --- a/packages/cli/test/tui.fixture.test.ts +++ b/packages/cli/test/tui.fixture.test.ts @@ -389,7 +389,8 @@ suite("TUI ptywright scenarios", () => { // ctrl+a enables every selectable row — including tools the application // never composed — and ctrl+x clears it; both are staged. session.press(KeyCtrlA); - await session.waitForVisible(`/${selectable} enabled`, { timeoutMs: WAIT_MS }); + // No `waitForVisible` here: the footer already reads `…/${selectable} + // enabled`, so a substring wait would resolve on the pre-keypress screen. await session.waitForStable(STABLE_MS, { timeoutMs: WAIT_MS }); const enabledAll = /(\d+)\/\d+ enabled/.exec(session.snapshot().visible); assert.ok(enabledAll && Number(enabledAll[1]) > baseline, "ctrl+a grows the selection past the baseline");