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..58d4d07 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,58 @@ 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); + // 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, + group: entry.group, + ...(entry.description ? { description: entry.description } : {}), + available: entry.available, + ...(entry.unavailableReason ? { unavailableReason: entry.unavailableReason } : {}), + tools: entry.tools.map((tool) => live.get(tool.identity) ?? tool) 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..9d412c3 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,18 +241,21 @@ 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; } 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-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..a366513 100644 --- a/packages/cli/test/tool-selection.test.ts +++ b/packages/cli/test/tool-selection.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { cua } from "@onkernel/cua-ai"; import { defaultApplicationTools, defaultInteractionTools } from "../src/harness"; import { - describeTools, + describeMenu, disableTools, + selectedKeys, + toolsForSelection, enableTools, sameSelection, toggleTool, @@ -28,39 +30,73 @@ 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("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 = 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 { diff --git a/packages/cli/test/tui.fixture.test.ts b/packages/cli/test/tui.fixture.test.ts index 063e976..dbe23ad 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,24 @@ 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 }); + // 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"); 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/);