From fcbb0b3405435aaa500ddb9704a5e51fb282dc55 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 28 Aug 2026 10:35:21 +0200 Subject: [PATCH] feat(coding-agent): add facet-based slash commands --- packages/agent/docs/plugins.md | 4 + .../src/experimental/client-tui.ts | 229 ++++++++++-------- .../src/experimental/plugins/hello.ts | 23 ++ .../src/experimental/services/README.md | 7 +- .../experimental/services/models-provider.ts | 20 +- .../src/experimental/services/models.ts | 2 + .../services/slash-commands-provider.ts | 193 +++++++++++++++ .../experimental/services/slash-commands.ts | 38 +++ .../test/experimental-client-tui.test.ts | 32 ++- .../test/experimental-remote-runtime.test.ts | 7 + .../test/experimental-slash-commands.test.ts | 51 ++++ 11 files changed, 504 insertions(+), 102 deletions(-) create mode 100644 packages/coding-agent/src/experimental/plugins/hello.ts create mode 100644 packages/coding-agent/src/experimental/services/slash-commands-provider.ts create mode 100644 packages/coding-agent/src/experimental/services/slash-commands.ts create mode 100644 packages/coding-agent/test/experimental-slash-commands.test.ts diff --git a/packages/agent/docs/plugins.md b/packages/agent/docs/plugins.md index 97debfebd..ae655b983 100644 --- a/packages/agent/docs/plugins.md +++ b/packages/agent/docs/plugins.md @@ -351,6 +351,10 @@ interface TuiHost { const Tui = defineService("pi.local.tui", { local: true }); ``` +The first implemented presentation hookpoint is narrower than this eventual `TuiHost`: a process-local `SlashCommands` registry. Built-in presentation facets and plugin presentation facets acquire the same registry and add command metadata plus callbacks during activation. The returned cleanup removes the contribution, so facet reload and unload update autocomplete and dispatch without rebuilding the TUI. Command callbacks receive narrow selection, status, and prompt-submission operations rather than the raw renderer or editor. + +Each plugin host facet is an independent loader entry. The example `/hello` presentation facet has one default facet export; a future package build emits that facet as one pre-bundled file. Session, server, web, and other presentation facets from the same plugin are separate bundle entries connected by shared service IDs, not one aggregate runtime plugin object. + `acquireModal()` waits in one presentation-owned queue and holds the modal slot across a multi-step interaction. Its signal removes a queued request or dismisses an active one, and `close()` is idempotent. `select()` is the one-step acquire/select/close convenience. Both return selected values directly, so feature code never recovers identity from a display label. The TUI loads all of its facets into one generation. Its host routes `env.use(SessionDirectory)` to the connected server and `env.use(Models)` to the selected Session. While detached, Session calls fail with `session_not_attached` and replicated state has no value. Connection and attachment health are host-local services because they describe presentation control state. A future web host similarly binds local services for routes, views, and DOM dialogs. Its server and Session facets still use unqualified service operations. diff --git a/packages/coding-agent/src/experimental/client-tui.ts b/packages/coding-agent/src/experimental/client-tui.ts index cee2ff9ca..4e464cd60 100644 --- a/packages/coding-agent/src/experimental/client-tui.ts +++ b/packages/coding-agent/src/experimental/client-tui.ts @@ -1,6 +1,7 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core"; import type { SessionSummary } from "@earendil-works/pi-protocol"; import { + CombinedAutocompleteProvider, type Component, Container, type SelectItem, @@ -21,9 +22,10 @@ import { InteractiveThemeController } from "../modes/interactive/theme/theme-con import { createInteractiveTui } from "../modes/interactive/tui-renderer.ts"; import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runtime.ts"; import { ExperimentalChatView } from "./client-tui-chat.ts"; -import { combineFacetLoaders, type FacetLoader, type LoadedFacets } from "./facet-loader.ts"; +import { combineFacetLoaders, createStaticFacetLoader, type FacetLoader, type LoadedFacets } from "./facet-loader.ts"; import { createFacetHost, defineFacet, type FacetHost } from "./facets.ts"; import { type LaneReplica, type LaneWatchSource, openLaneReplica } from "./lane-replica.ts"; +import helloPluginFacet from "./plugins/hello.ts"; import { AgentController, type AgentOperationResponse, type AgentQueueResponse } from "./services/agent-controller.ts"; import type { ServerConnectionState, @@ -31,8 +33,12 @@ import type { SessionAttachmentState, SessionServiceConnection, } from "./services/connection.ts"; -import { Models } from "./services/models.ts"; import { SessionDirectory, SessionManagement } from "./services/sessions.ts"; +import { type SlashCommandExecutionContext, SlashCommands } from "./services/slash-commands.ts"; +import { + createBuiltInSlashCommandsFacet, + createSlashCommandsRuntimeFacet, +} from "./services/slash-commands-provider.ts"; export interface RunClientTuiOptions extends OpenClientRuntimeOptions { readonly facetLoader?: FacetLoader; @@ -54,9 +60,9 @@ interface SessionFeature { readonly laneWatches: LaneWatchSource; } -interface ModelSelectionFeature { +interface SlashCommandFeature { readonly serverId: string; - readonly models: Models; + readonly commands: SlashCommands; } interface AgentFeature { @@ -64,9 +70,11 @@ interface AgentFeature { readonly controller: AgentController; } -interface ModelAction { - readonly provider: string; - readonly modelId: string; +interface PendingSelection { + readonly title: string; + readonly items: readonly SelectItem[]; + readonly selectedValue?: string; + resolve(value: string | undefined): void; } const selectTheme = { @@ -92,13 +100,13 @@ export class ExperimentalClientTui implements Component { readonly #loadedFacets: LoadedFacets; readonly #facetHosts: FacetHost[] = []; readonly #sessions = new Map(); - readonly #modelSelections = new Map(); + readonly #slashCommands = new Map(); readonly #agents = new Map(); - readonly #modelActions = new Map(); readonly #keybindings = KeybindingsManager.create(); readonly #chatInput: CustomEditor; #selectList: SelectList | undefined; - #screen: "models" | "chat" = "chat"; + #selection: PendingSelection | undefined; + #screen: "select" | "chat" = "chat"; #selectedServerId: string | undefined; #sessionId: string | undefined; #status = "Starting Session…"; @@ -121,7 +129,7 @@ export class ExperimentalClientTui implements Component { this.#chatInput.onEscape = () => this.#interrupt(); this.#chatInput.onCtrlD = finish; this.#chatInput.onAction("app.clear", finish); - this.#chatInput.onAction("app.model.select", () => this.#showModels()); + this.#chatInput.onAction("app.model.select", () => void this.#executeSlashCommand("model", "")); this.#chatInput.onAction("app.message.followUp", () => { const text = this.#chatInput.getText().trim(); if (text.length === 0) return; @@ -148,9 +156,10 @@ export class ExperimentalClientTui implements Component { requestRender(): void; finish(): void; }): Promise { - const loadedFacets = await combineFacetLoaders( - options.facetLoader === undefined ? [] : [options.facetLoader], - ).load(); + const loadedFacets = await combineFacetLoaders([ + createStaticFacetLoader([helloPluginFacet]), + ...(options.facetLoader === undefined ? [] : [options.facetLoader]), + ]).load(); const component = new ExperimentalClientTui(options.ui, options.requestRender, options.finish, loadedFacets); try { await component.#start(options.servers); @@ -229,7 +238,7 @@ export class ExperimentalClientTui implements Component { setup: (env) => { const directory = env.use(SessionDirectory); const management = env.use(SessionManagement); - const models = env.use(Models); + const commands = env.use(SlashCommands); const controller = env.use(AgentController); const sessionFeature: SessionFeature = { serverId: server.serverId, @@ -241,11 +250,11 @@ export class ExperimentalClientTui implements Component { env.onActivate(() => { env.own(this.#register(this.#sessions, "Sessions", sessionFeature)); env.own( - this.#register(this.#modelSelections, "Model selection", { serverId: server.serverId, models }), + this.#register(this.#slashCommands, "Slash commands", { serverId: server.serverId, commands }), ); env.own(this.#register(this.#agents, "Agent controller", { serverId: server.serverId, controller })); env.own(directory.state.subscribe(() => this.#rebuild())); - env.own(models.state.subscribe(() => this.#rebuild())); + env.own(commands.subscribe(() => this.#updateAutocomplete())); if (server.radius) { env.own( server.server.connection.subscribe((state) => @@ -262,7 +271,12 @@ export class ExperimentalClientTui implements Component { }, }); const facetHost = await createFacetHost({ - facets: [presentationBridgeFacet, ...this.#loadedFacets.facets], + facets: [ + createSlashCommandsRuntimeFacet(), + presentationBridgeFacet, + createBuiltInSlashCommandsFacet(), + ...this.#loadedFacets.facets, + ], connections: [server.server, server.session], }); this.#facetHosts.push(facetHost); @@ -319,6 +333,7 @@ export class ExperimentalClientTui implements Component { await feature.session.whenAttached(summary.sessionId, BACKGROUND_CONTEXT); this.#selectedServerId = feature.serverId; this.#sessionId = summary.sessionId; + this.#updateAutocomplete(); await this.#openLane(feature, summary.sessionId); this.#screen = "chat"; this.#status = ""; @@ -327,6 +342,7 @@ export class ExperimentalClientTui implements Component { async #close(): Promise { this.#closed = true; + this.#completeSelection(undefined); const errors: unknown[] = []; try { await this.#recoveryTransition; @@ -373,21 +389,16 @@ export class ExperimentalClientTui implements Component { if (this.#chatView !== undefined) this.#statusContainer.addChild(this.#chatView.status); this.#footerComponent.setText(theme.fg("dim", this.#footer())); this.#editorContainer.clear(); - this.#modelActions.clear(); - if (this.#screen === "models") { + if (this.#screen === "select" && this.#selection !== undefined) { this.#chatInput.focused = false; const selector = new Container(); - selector.addChild(new Text(theme.bold("Select model:"), 1, 1)); - const items = this.#modelItems(); + selector.addChild(new Text(theme.bold(this.#selection.title), 1, 1)); + const items = [...this.#selection.items]; this.#selectList = new SelectList(items, Math.min(Math.max(items.length, 1), 12), selectTheme); - this.#selectList.onSelect = (item) => { - const action = this.#modelActions.get(item.value); - if (action !== undefined) void this.#selectModel(action); - }; - this.#selectList.onCancel = () => { - this.#screen = "chat"; - this.#rebuild(); - }; + const selectedIndex = items.findIndex((item) => item.value === this.#selection?.selectedValue); + if (selectedIndex >= 0) this.#selectList.setSelectedIndex(selectedIndex); + this.#selectList.onSelect = (item) => this.#completeSelection(item.value); + this.#selectList.onCancel = () => this.#completeSelection(undefined); selector.addChild(this.#selectList); this.#editorContainer.addChild(selector); } else { @@ -399,41 +410,51 @@ export class ExperimentalClientTui implements Component { this.#requestRender(); } - #modelItems(): SelectItem[] { - const feature = - this.#selectedServerId === undefined ? undefined : this.#modelSelections.get(this.#selectedServerId); - const state = feature?.models.state.value; - const selected = state?.configuration.model; - return (state?.catalog.availableModels ?? []).map((model) => { - const key = `model:${model.provider}:${model.modelId}`; - this.#modelActions.set(key, { provider: model.provider, modelId: model.modelId }); - return { - value: key, - label: - selected?.provider === model.provider && selected.modelId === model.modelId - ? `${model.name} (selected)` - : model.name, - description: `${model.provider}/${model.modelId}`, - }; + #select(title: string, items: readonly SelectItem[], selectedValue?: string): Promise { + if (this.#selection !== undefined) throw new Error("A slash command selector is already active"); + return new Promise((resolve) => { + this.#selection = { title, items, ...(selectedValue === undefined ? {} : { selectedValue }), resolve }; + this.#screen = "select"; + this.#rebuild(); }); } - async #selectModel(action: ModelAction): Promise { - if (this.#busy) return; - this.#busy = true; - try { - const feature = - this.#selectedServerId === undefined ? undefined : this.#modelSelections.get(this.#selectedServerId); - if (feature === undefined) throw new Error("No Session model selection is available"); - await feature.models.select({ provider: action.provider, modelId: action.modelId }, BACKGROUND_CONTEXT); - this.#screen = "chat"; - this.#status = `Selected ${action.provider}/${action.modelId}.`; - } catch (error) { - this.#status = `Error: ${message(error)}`; - } finally { - this.#busy = false; - this.#rebuild(); - } + #completeSelection(value: string | undefined): void { + const selection = this.#selection; + if (selection === undefined) return; + this.#selection = undefined; + this.#screen = "chat"; + selection.resolve(value); + if (!this.#closed) this.#rebuild(); + } + + #updateAutocomplete(): void { + const commands = this.#selectedSlashCommands()?.list() ?? []; + this.#chatInput.setAutocompleteProvider( + new CombinedAutocompleteProvider( + commands.map((command) => ({ + name: command.name, + description: command.description, + ...(command.argumentHint === undefined ? {} : { argumentHint: command.argumentHint }), + ...(command.getArgumentCompletions === undefined + ? {} + : { + getArgumentCompletions: async (prefix: string) => { + const items = await command.getArgumentCompletions!(prefix); + return items === null ? null : [...items]; + }, + }), + })), + process.cwd(), + ), + ); + this.#requestRender(); + } + + #selectedSlashCommands(): SlashCommands | undefined { + if (this.#selectedServerId !== undefined) return this.#slashCommands.get(this.#selectedServerId)?.commands; + if (this.#slashCommands.size === 1) return this.#slashCommands.values().next().value?.commands; + return undefined; } #handleConnectionState(serverId: string, state: ServerConnectionState): void { @@ -516,42 +537,65 @@ export class ExperimentalClientTui implements Component { async #runPrompt(messageText: string): Promise { const prompt = messageText.trim(); if (prompt.length === 0) return; - if (prompt === "/model") { - this.#showModels(); - return; - } - const controller = this.#selectedController(); - if (controller === undefined) { - this.#status = "No Session AgentController service is available."; - this.#rebuild(); + if (prompt.startsWith("/")) { + const separator = prompt.indexOf(" "); + const name = prompt.slice(1, separator === -1 ? undefined : separator); + const args = separator === -1 ? "" : prompt.slice(separator + 1).trim(); + await this.#executeSlashCommand(name, args); return; } this.#chatInput.setText(""); try { - if (prompt === "/compact" || prompt.startsWith("/compact ")) { - const instructions = prompt.slice("/compact".length).trim(); - this.#status = "Compacting…"; - this.#rebuild(); - this.#reportOperation( - await controller.compact( - { customInstructions: instructions.length === 0 ? null : instructions }, - BACKGROUND_CONTEXT, - ), - ); - return; - } - const operation = this.#laneReplica?.state().operation; - const running = operation !== null && operation !== undefined; - this.#status = running ? "Queueing steering message…" : "Running turn…"; - this.#rebuild(); - if (running) this.#reportQueue(await controller.steer({ message: prompt, images: null }, BACKGROUND_CONTEXT)); - else this.#reportOperation(await controller.prompt({ message: prompt, images: null }, BACKGROUND_CONTEXT)); + await this.#submitPrompt(prompt); } catch (error) { this.#status = `Error: ${message(error)}`; this.#rebuild(); } } + async #executeSlashCommand(name: string, args: string): Promise { + const command = this.#selectedSlashCommands() + ?.list() + .find((candidate) => candidate.name === name); + this.#chatInput.setText(""); + if (command === undefined) { + this.#status = `Unknown slash command: /${name}`; + this.#rebuild(); + return; + } + const context: SlashCommandExecutionContext = { + operation: BACKGROUND_CONTEXT, + select: (title, items, selectedValue) => + this.#select( + title, + items.map((item) => ({ ...item })), + selectedValue, + ), + submitPrompt: (message) => this.#submitPrompt(message), + showStatus: (status) => { + this.#status = status; + this.#rebuild(); + }, + }; + try { + await command.run(args, context); + } catch (error) { + this.#status = `Error: ${message(error)}`; + this.#rebuild(); + } + } + + async #submitPrompt(prompt: string): Promise { + const controller = this.#selectedController(); + if (controller === undefined) throw new Error("No Session AgentController service is available"); + const operation = this.#laneReplica?.state().operation; + const running = operation !== null && operation !== undefined; + this.#status = running ? "Queueing steering message…" : "Running turn…"; + this.#rebuild(); + if (running) this.#reportQueue(await controller.steer({ message: prompt, images: null }, BACKGROUND_CONTEXT)); + else this.#reportOperation(await controller.prompt({ message: prompt, images: null }, BACKGROUND_CONTEXT)); + } + async #queueFollowUp(text: string): Promise { const controller = this.#selectedController(); if (controller === undefined) return; @@ -591,19 +635,14 @@ export class ExperimentalClientTui implements Component { }); } - #showModels(): void { - this.#screen = "models"; - this.#rebuild(); - } - #selectedController(): AgentController | undefined { return this.#selectedServerId === undefined ? undefined : this.#agents.get(this.#selectedServerId)?.controller; } #footer(): string { const snapshot = this.#laneReplica?.state(); - if (!snapshot) return "/model · /compact"; - return `${snapshot.configuration.model.provider}/${snapshot.configuration.model.modelId} · thinking:${snapshot.configuration.thinkingLevel} · ${snapshot.stats.messageCount} messages · /model · /compact`; + if (!snapshot) return "/model · /thinking · /compact"; + return `${snapshot.configuration.model.provider}/${snapshot.configuration.model.modelId} · thinking:${snapshot.configuration.thinkingLevel} · ${snapshot.stats.messageCount} messages · /model · /thinking · /compact`; } } diff --git a/packages/coding-agent/src/experimental/plugins/hello.ts b/packages/coding-agent/src/experimental/plugins/hello.ts new file mode 100644 index 000000000..6934c773e --- /dev/null +++ b/packages/coding-agent/src/experimental/plugins/hello.ts @@ -0,0 +1,23 @@ +import { defineFacet } from "../facets.ts"; +import { SlashCommands } from "../services/slash-commands.ts"; + +/** Example presentation facet. A future plugin build emits this facet as one standalone bundle. */ +const helloPluginFacet = defineFacet({ + id: "@pi/example-hello", + setup(env) { + const commands = env.use(SlashCommands); + env.onActivate(() => { + env.own( + commands.register({ + name: "hello", + description: "Send a greeting from the example plugin", + async run(args, context) { + await context.submitPrompt(args.length === 0 ? "Hello from the example plugin." : args); + }, + }), + ); + }); + }, +}); + +export default helloPluginFacet; diff --git a/packages/coding-agent/src/experimental/services/README.md b/packages/coding-agent/src/experimental/services/README.md index 17ece6b14..55b786dd5 100644 --- a/packages/coding-agent/src/experimental/services/README.md +++ b/packages/coding-agent/src/experimental/services/README.md @@ -10,13 +10,14 @@ Facet setup generates each host's RPC service catalogue from its provided non-lo | session | `AgentController` | presentation-safe `AgentLane` facade for prompting, queueing, abort, resume, compaction, and navigation | add new lane operations only when a presentation requires them | | session | `Accounts` | state declared; mutations throw | connect the local credential service to the presentation-safe facade | | session | `Transcript` | authoritative snapshot declared but throws | implement snapshot projection and remove the compatibility lane watch | +| presentation | `SlashCommands` | process-local contribution registry with model, thinking, compact, and example hello commands | add more presentation hookpoints only as concrete plugin slices require them | `ServerServiceConnection.connection` and `SessionServiceConnection.attachment` are implemented local control states. With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Session before opening the service-only chat TUI. `pi client -c` and `pi client -r` attach the newest existing Session instead, preserving its durable model and thinking configuration. Model selection is available on demand through `/model`; it is never a startup screen. The presentation always uses the stable coding agent's alternate-screen renderer and shared transcript/dock viewport. It loads configured theme resources and uses the stable terminal light/dark detection and appearance-change notifications. Its replicated state feeds the stable editor, message, tool, status, theme, and tool-renderer components. The presentation drives the worker-owned main lane through `AgentController` and renders a coherent lane-watch replica with the Harness reducer while controller calls are pending. `Transcript` remains reserved for a later service-owned live-delta replacement of that compatibility watch. -The Session worker loads all built-in and configured Session facets through one `FacetLoader`, then creates one active `FacetHost`. Host-created implementation dependencies such as `AgentLane`, `ModelRuntime`, and `SettingsManager` are passed directly to built-in facet factories; they are not exposed as services. The TUI loads configured presentation facets and adds one private bridge facet that consumes the connected server and selected-Session services for `ExperimentalClientTui`. There is no local TUI service until the presentation host has a real command, modal, and renderer contribution API worth exposing to independently loaded facets. +The Session worker loads all built-in and configured Session facets through one `FacetLoader`, then creates one active `FacetHost`. Host-created implementation dependencies such as `AgentLane`, `ModelRuntime`, and `SettingsManager` are passed directly to built-in facet factories; they are not exposed as services. The TUI loads configured presentation facets and adds one private bridge facet that consumes the connected server and selected-Session services for `ExperimentalClientTui`. Its first concrete local presentation service is `SlashCommands`: built-in and plugin facets register command metadata and callbacks, and facet cleanup removes those contributions on reload or unload. Command callbacks receive narrow selection, status, and prompt-submission operations rather than the raw TUI. -A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same service facades while replacement implementations and singleton snapshots are installed behind them. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. All currently implemented experimental service tokens are non-local and automatically published by their providing host. +A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same service facades while replacement implementations and singleton snapshots are installed behind them. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. Server and Session service tokens are non-local and automatically published by their providing host. Presentation-only hookpoints such as `SlashCommands` are explicitly local and never enter an RPC catalogue. -A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. Transport namespaces remain internal binding machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, canvas, and rich local TUI service examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens. +A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. The example `/hello` plugin is one presentation-facet module with a default facet export and is loaded through a `FacetLoader`; a future plugin build may emit that module as one self-contained bundle without changing the runtime facet contract. Other host facets from the same plugin will be separate bundle entries rather than one aggregate plugin object. Transport namespaces remain internal binding machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, canvas, and rich local TUI service examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens. diff --git a/packages/coding-agent/src/experimental/services/models-provider.ts b/packages/coding-agent/src/experimental/services/models-provider.ts index ff3ea061c..426ea00a5 100644 --- a/packages/coding-agent/src/experimental/services/models-provider.ts +++ b/packages/coding-agent/src/experimental/services/models-provider.ts @@ -3,6 +3,7 @@ import { BACKGROUND_CONTEXT, type Context, type MutableReplicatedState, + type ThinkingLevel, } from "@earendil-works/pi-agent-core"; import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import type { ModelRuntime } from "../../core/model-runtime.ts"; @@ -34,6 +35,10 @@ export function createModelsService( thinkingLevel, }; }; + const readThinkingLevels = async (context: Context): Promise => { + const selected = await lane.getModel(context); + return selected === undefined ? ["off"] : getSupportedThinkingLevels(selected); + }; const readCatalog = async (context: Context): Promise => { const selected = await lane.getModel(context); const available = modelRuntime?.getAvailableSnapshot() ?? []; @@ -53,15 +58,16 @@ export function createModelsService( const service: ModelsService = { state, async cycleThinking(context) { - const selected = await lane.getModel(context); - if (selected === undefined) return; - const levels = getSupportedThinkingLevels(selected); + const levels = await readThinkingLevels(context); const current = await lane.getThinkingLevel(context); const index = levels.indexOf(current); const next = levels[(index + 1) % levels.length] ?? "off"; await lane.setThinkingLevel(next, context); state.set({ ...state.value, configuration: await readConfiguration(context) }, context); }, + async getThinkingLevels(context) { + return [...(await readThinkingLevels(context))]; + }, async refresh(context) { state.set({ ...state.value, refresh: { status: "refreshing" } }, context); const refresh = modelRuntime?.refresh({ signal: context.abortSignal }); @@ -97,6 +103,14 @@ export function createModelsService( await settingsManager?.flush(); state.set({ ...state.value, configuration: await readConfiguration(context) }, context); }, + async selectThinking(level, context) { + const levels = await readThinkingLevels(context); + if (!levels.includes(level)) { + throw new Error(`Thinking level ${level} is unavailable; choose one of: ${levels.join(", ")}`); + } + await lane.setThinkingLevel(level, context); + state.set({ ...state.value, configuration: await readConfiguration(context) }, context); + }, }; return { service, diff --git a/packages/coding-agent/src/experimental/services/models.ts b/packages/coding-agent/src/experimental/services/models.ts index 7d531fe3d..d14c55961 100644 --- a/packages/coding-agent/src/experimental/services/models.ts +++ b/packages/coding-agent/src/experimental/services/models.ts @@ -25,8 +25,10 @@ export interface ModelsState { export interface Models { readonly state: ReplicatedState; cycleThinking(context: Context): Promise; + getThinkingLevels(context: Context): Promise; refresh(context: Context): Promise; select(model: ModelRef, context: Context): Promise; + selectThinking(level: ThinkingLevel, context: Context): Promise; } export const Models = defineService("pi.models"); diff --git a/packages/coding-agent/src/experimental/services/slash-commands-provider.ts b/packages/coding-agent/src/experimental/services/slash-commands-provider.ts new file mode 100644 index 000000000..1853b4d9e --- /dev/null +++ b/packages/coding-agent/src/experimental/services/slash-commands-provider.ts @@ -0,0 +1,193 @@ +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { defineFacet, type Facet } from "../facets.ts"; +import { AgentController, type AgentOperationResponse } from "./agent-controller.ts"; +import { type ModelSummary, Models, type Models as ModelsService } from "./models.ts"; +import { type SlashCommandContribution, type SlashCommandExecutionContext, SlashCommands } from "./slash-commands.ts"; + +const THINKING_DESCRIPTIONS: Record = { + off: "No reasoning", + minimal: "Very brief reasoning", + low: "Light reasoning", + medium: "Moderate reasoning", + high: "Deep reasoning", + xhigh: "Extra-high reasoning", + max: "Maximum reasoning", +}; + +export class SlashCommandRegistry implements SlashCommands { + readonly #commands = new Map(); + readonly #listeners = new Set<(commands: readonly SlashCommandContribution[]) => void>(); + + register(command: SlashCommandContribution): () => void { + if (!/^[a-z0-9][a-z0-9:-]*$/u.test(command.name)) { + throw new TypeError(`Invalid slash command name: ${command.name}`); + } + if (this.#commands.has(command.name)) throw new Error(`Slash command /${command.name} is already registered`); + const registered = Object.freeze({ ...command }); + this.#commands.set(command.name, registered); + this.#publish(); + let closed = false; + return () => { + if (closed) return; + closed = true; + if (this.#commands.get(command.name) !== registered) return; + this.#commands.delete(command.name); + this.#publish(); + }; + } + + list(): readonly SlashCommandContribution[] { + return Object.freeze([...this.#commands.values()]); + } + + subscribe(listener: (commands: readonly SlashCommandContribution[]) => void): () => void { + this.#listeners.add(listener); + listener(this.list()); + return () => this.#listeners.delete(listener); + } + + #publish(): void { + const commands = this.list(); + for (const listener of this.#listeners) listener(commands); + } +} + +export function createSlashCommandsRuntimeFacet(registry = new SlashCommandRegistry()): Facet { + return defineFacet({ + id: "@pi/slash-commands-runtime", + setup(env) { + env.provide(SlashCommands, registry); + }, + }); +} + +export function createBuiltInSlashCommandsFacet(): Facet { + return defineFacet({ + id: "@pi/slash-commands-builtin", + setup(env) { + const commands = env.use(SlashCommands); + const models = env.use(Models); + const controller = env.use(AgentController); + env.onActivate(() => { + env.own(commands.register(modelCommand(models))); + env.own(commands.register(thinkingCommand(models))); + env.own(commands.register(compactCommand(controller))); + }); + }, + }); +} + +function modelCommand(models: ModelsService): SlashCommandContribution { + return { + name: "model", + description: "Select model", + argumentHint: "", + getArgumentCompletions(prefix) { + const normalized = prefix.toLowerCase(); + return (models.state.value?.catalog.availableModels ?? []) + .filter((model) => `${model.provider}/${model.modelId} ${model.name}`.toLowerCase().includes(normalized)) + .map((model) => ({ + value: `${model.provider}/${model.modelId}`, + label: model.modelId, + description: model.provider, + })); + }, + async run(args, context) { + const state = models.state.value; + if (state === undefined) throw new Error("Models service is not ready"); + let selected = exactModel(state.catalog.availableModels, args); + if (args.length > 0 && selected === undefined) { + throw new Error(`Unknown model: ${args}`); + } + if (selected === undefined) { + const value = await context.select( + "Select model:", + state.catalog.availableModels.map((model) => ({ + value: `${model.provider}/${model.modelId}`, + label: + state.configuration.model?.provider === model.provider && + state.configuration.model.modelId === model.modelId + ? `${model.name} (selected)` + : model.name, + description: `${model.provider}/${model.modelId}`, + })), + state.configuration.model === null + ? undefined + : `${state.configuration.model.provider}/${state.configuration.model.modelId}`, + ); + if (value === undefined) return; + selected = exactModel(state.catalog.availableModels, value); + if (selected === undefined) throw new Error(`Unknown model: ${value}`); + } + await models.select({ provider: selected.provider, modelId: selected.modelId }, context.operation); + context.showStatus(`Selected ${selected.provider}/${selected.modelId}.`); + }, + }; +} + +function thinkingCommand(models: ModelsService): SlashCommandContribution { + return { + name: "thinking", + description: "Set thinking level", + argumentHint: "", + async run(args, context) { + const levels = await models.getThinkingLevels(context.operation); + let selected = levels.find((level) => level === args.toLowerCase()); + if (args.length > 0 && selected === undefined) { + throw new Error(`Unknown thinking level "${args}". Available levels: ${levels.join(", ")}.`); + } + if (selected === undefined) { + const value = await context.select( + "Select thinking level:", + levels.map((level) => ({ + value: level, + label: models.state.value?.configuration.thinkingLevel === level ? `${level} (selected)` : level, + description: THINKING_DESCRIPTIONS[level], + })), + models.state.value?.configuration.thinkingLevel, + ); + if (value === undefined) return; + selected = levels.find((level) => level === value); + if (selected === undefined) throw new Error(`Unknown thinking level: ${value}`); + } + await models.selectThinking(selected, context.operation); + context.showStatus(`Thinking level: ${selected}.`); + }, + }; +} + +function compactCommand(controller: AgentController): SlashCommandContribution { + return { + name: "compact", + description: "Manually compact the session context", + argumentHint: "", + async run(args, context) { + context.showStatus("Compacting…"); + reportOperation( + await controller.compact({ customInstructions: args.length === 0 ? null : args }, context.operation), + context, + ); + }, + }; +} + +function exactModel(models: readonly ModelSummary[], query: string): ModelSummary | undefined { + if (query.length === 0) return undefined; + const normalized = query.toLowerCase(); + const matches = models.filter( + (model) => + `${model.provider}/${model.modelId}`.toLowerCase() === normalized || + model.modelId.toLowerCase() === normalized, + ); + return matches.length === 1 ? matches[0] : undefined; +} + +function reportOperation(response: AgentOperationResponse, context: SlashCommandExecutionContext): void { + context.showStatus( + response.accepted + ? response.error === null + ? "" + : `Operation failed: ${response.error.message}` + : `Operation rejected: ${response.error.message}`, + ); +} diff --git a/packages/coding-agent/src/experimental/services/slash-commands.ts b/packages/coding-agent/src/experimental/services/slash-commands.ts new file mode 100644 index 000000000..f3a8dbdb4 --- /dev/null +++ b/packages/coding-agent/src/experimental/services/slash-commands.ts @@ -0,0 +1,38 @@ +import { type Context, defineService } from "@earendil-works/pi-agent-core"; + +export interface SlashCommandCompletion { + readonly value: string; + readonly label: string; + readonly description?: string; +} + +export interface SlashCommandSelectItem { + readonly value: string; + readonly label: string; + readonly description?: string; +} + +export interface SlashCommandExecutionContext { + readonly operation: Context; + select(title: string, items: readonly SlashCommandSelectItem[], selectedValue?: string): Promise; + submitPrompt(message: string): Promise; + showStatus(message: string): void; +} + +export interface SlashCommandContribution { + readonly name: string; + readonly description?: string; + readonly argumentHint?: string; + getArgumentCompletions?( + argumentPrefix: string, + ): readonly SlashCommandCompletion[] | null | Promise; + run(args: string, context: SlashCommandExecutionContext): void | Promise; +} + +export interface SlashCommands { + register(command: SlashCommandContribution): () => void; + list(): readonly SlashCommandContribution[]; + subscribe(listener: (commands: readonly SlashCommandContribution[]) => void): () => void; +} + +export const SlashCommands = defineService("pi.local.slash-commands", { local: true }); diff --git a/packages/coding-agent/test/experimental-client-tui.test.ts b/packages/coding-agent/test/experimental-client-tui.test.ts index 005cb8dca..b04694f6b 100644 --- a/packages/coding-agent/test/experimental-client-tui.test.ts +++ b/packages/coding-agent/test/experimental-client-tui.test.ts @@ -98,6 +98,12 @@ describe("experimental client TUI", () => { BACKGROUND_CONTEXT, ); }); + const selectThinking = vi.fn(async (thinkingLevel: "off" | "high") => { + modelsState.set( + { ...modelsState.value, configuration: { ...modelsState.value.configuration, thinkingLevel } }, + BACKGROUND_CONTEXT, + ); + }); let watchListener: ((event: LaneEvent) => void | Promise) | undefined; const snapshot = laneSnapshot(); const watchSession = vi.fn(async (sessionId: string) => ({ @@ -176,8 +182,12 @@ describe("experimental client TUI", () => { sessionProvider.provide(Models, { state: modelsState, async cycleThinking() {}, + async getThinkingLevels() { + return ["off", "high"]; + }, async refresh() {}, select, + selectThinking, }); sessionProvider.provide(AgentController, createAgentController({ prompt } as unknown as AgentLane)); @@ -275,9 +285,19 @@ describe("experimental client TUI", () => { expect(component.render(80).join("\n")).not.toContain("Experimental Sessions"); expect(component.render(80).join("\n")).not.toContain("Experimental Models"); + component.handleInput("/"); + await vi.waitFor(() => { + const rendered = component.render(80).join("\n"); + expect(rendered).toContain("Select model"); + expect(rendered).toContain("Set thinking level"); + expect(rendered).toContain("Send a greeting from the example plugin"); + }); component.handleInput("hello"); + component.handleInput("\u001b"); component.handleInput("\r"); - await vi.waitFor(() => expect(prompt).toHaveBeenCalledWith("hello", undefined, expect.anything())); + await vi.waitFor(() => + expect(prompt).toHaveBeenCalledWith("Hello from the example plugin.", undefined, expect.anything()), + ); await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("remote answer")); expect(component.render(80).join("\n")).toContain("hello"); expect(component.render(80).join("\n")).not.toContain("Operation run-1 completed"); @@ -301,6 +321,7 @@ describe("experimental client TUI", () => { await vi.waitFor(() => expect(component.render(80).join("\n")).not.toContain("Reattaching")); component.handleInput("/model"); + component.handleInput("\u001b"); component.handleInput("\r"); await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Select model:")); component.handleInput("\u001b[B"); @@ -311,6 +332,15 @@ describe("experimental client TUI", () => { expect(modelsState.value.configuration.model).toEqual({ provider: "test", modelId: "two" }); await vi.waitFor(() => expect(component.render(80).join("\n")).not.toContain("Select model:")); + component.handleInput("/thinking"); + component.handleInput("\u001b"); + component.handleInput("\r"); + await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("Select thinking level:")); + component.handleInput("\u001b[B"); + component.handleInput("\r"); + await vi.waitFor(() => expect(selectThinking).toHaveBeenCalledWith("high", expect.anything())); + expect(modelsState.value.configuration.thinkingLevel).toBe("high"); + component.handleInput("\u0003"); await vi.waitFor(() => expect(finished).toBe(true)); diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index cfac18b38..01b877ee4 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -395,11 +395,18 @@ describe("experimental durable server composition", () => { ); expect(secondModels.state.value).toEqual(firstModels.state.value); const previousThinking = firstModels.state.value!.configuration.thinkingLevel; + const thinkingLevels = await firstModels.getThinkingLevels(BACKGROUND_CONTEXT); + expect(thinkingLevels).toContain(previousThinking); await firstModels.cycleThinking(BACKGROUND_CONTEXT); await vi.waitFor(() => { expect(firstModels.state.value!.configuration.thinkingLevel).not.toBe(previousThinking); expect(secondModels.state.value).toEqual(firstModels.state.value); }); + await firstModels.selectThinking(previousThinking, BACKGROUND_CONTEXT); + await vi.waitFor(() => { + expect(firstModels.state.value!.configuration.thinkingLevel).toBe(previousThinking); + expect(secondModels.state.value).toEqual(firstModels.state.value); + }); expect(errors).toEqual([]); await Promise.all([firstServices.dispose(BACKGROUND_CONTEXT), secondServices.dispose(BACKGROUND_CONTEXT)]); diff --git a/packages/coding-agent/test/experimental-slash-commands.test.ts b/packages/coding-agent/test/experimental-slash-commands.test.ts new file mode 100644 index 000000000..42f7f03e4 --- /dev/null +++ b/packages/coding-agent/test/experimental-slash-commands.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test, vi } from "vitest"; +import { createFacetHost, defineFacet } from "../src/experimental/facets.ts"; +import helloPluginFacet from "../src/experimental/plugins/hello.ts"; +import { SlashCommands } from "../src/experimental/services/slash-commands.ts"; +import { + createSlashCommandsRuntimeFacet, + SlashCommandRegistry, +} from "../src/experimental/services/slash-commands-provider.ts"; + +describe("experimental slash command facets", () => { + test("registers and removes contributions", () => { + const registry = new SlashCommandRegistry(); + const snapshots: string[][] = []; + const unsubscribe = registry.subscribe((commands) => snapshots.push(commands.map(({ name }) => name))); + const close = registry.register({ name: "hello", description: "Hello", run() {} }); + expect(registry.list().map(({ name }) => name)).toEqual(["hello"]); + expect(() => registry.register({ name: "hello", run() {} })).toThrow("already registered"); + close(); + close(); + expect(registry.list()).toEqual([]); + expect(snapshots).toEqual([[], ["hello"], []]); + unsubscribe(); + }); + + test("tracks plugin facet reload and unload", async () => { + const registry = new SlashCommandRegistry(); + const host = await createFacetHost({ + facets: [createSlashCommandsRuntimeFacet(registry), helloPluginFacet], + }); + expect(registry.list().map(({ name }) => name)).toEqual(["hello"]); + + const replacementRun = vi.fn(); + await host.reload([ + defineFacet({ + id: "@pi/example-hello", + setup(env) { + const commands = env.use(SlashCommands); + env.onActivate(() => + env.own(commands.register({ name: "hello", description: "Replacement", run: replacementRun })), + ); + }, + }), + ]); + expect(registry.list()).toEqual([ + expect.objectContaining({ name: "hello", description: "Replacement", run: replacementRun }), + ]); + + await host.dispose(); + expect(registry.list()).toEqual([]); + }); +});