diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index ed1fd9fa..a2296fa8 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -157,6 +157,7 @@ The Claude surface exposes these tool names: - `write` - `edit` - `bash` +- `show_changes` DevSpace uses the Codex-style surface by default. It exposes: @@ -165,6 +166,7 @@ DevSpace uses the Codex-style surface by default. It exposes: - `apply_patch` - `exec_command` - `write_stdin` +- `show_changes` In this mode, `write`, `edit`, and `bash` are not registered. `exec_command` returns a process session ID when a command is still @@ -179,18 +181,16 @@ the configured shell tool with command-line tools such as `rg`, `find`, and ## Show Changes -By default, `DEVSPACE_WIDGETS=full`. +DevSpace exposes `show_changes` in both tool modes and attaches widget UI only +to `open_workspace` and `show_changes`. Reads, edits, and commands return normal +MCP results without creating an iframe for each call. Set `ui.enabled` to +`false` in `~/.devspace/config.json` to disable UI metadata while keeping the +aggregate review tool available. -In that mode, DevSpace attaches widget UI to the exposed workspace, file, edit, -and shell tools. The aggregate `show_changes` tool is not exposed by default. - -Use `DEVSPACE_WIDGETS=off` to disable widget UI, or `DEVSPACE_WIDGETS=changes` -to expose the aggregate show-changes flow. - -When `show_changes` is exposed, call it exactly once after the final file -modification in any turn that changes files. It shows the combined changes for -that turn and advances the review point automatically. Reusing a workspace does -not change this workflow. +Call `show_changes` exactly once after the final file modification in any turn +that changes files. It shows the combined changes for that turn and advances +the review point automatically. Reusing a workspace does not change this +workflow. ## Shell Use diff --git a/docs/configuration.md b/docs/configuration.md index 4ce9c95a..d246f4f1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -118,15 +118,22 @@ Codex-mode commands run without a PTY by default. Set `tty: true` on `node-pty` dependency; `write_stdin` can send input, poll output, and resize PTY sessions. -## Widgets +## UI -`DEVSPACE_WIDGETS` controls ChatGPT Apps iframe usage. +DevSpace attaches ChatGPT Apps UI metadata only to `open_workspace` and +`show_changes`. This avoids creating an iframe for every read, edit, or command +tool call. The aggregate `show_changes` tool remains available to every MCP +host, including hosts that ignore UI metadata. -| Value | Behavior | -| --- | --- | -| `full` | Default. Widget UI is attached to exposed workspace, file, edit, and shell tools. | -| `changes` | Enables the aggregate `show_changes` tool and attaches widget UI to `open_workspace` and `show_changes`. | -| `off` | Disables widget UI. | +UI is enabled by default. Disable it without removing `show_changes`: + +```json +{ + "ui": { + "enabled": false + } +} +``` ## Skills @@ -259,7 +266,6 @@ DEVSPACE_ALLOWED_ROOTS="$HOME/personal,$HOME/work" \ DEVSPACE_PUBLIC_BASE_URL="https://devspace.example.com" \ DEVSPACE_WORKTREE_ROOT="$HOME/.devspace/worktrees" \ DEVSPACE_ARTIFACTS="1" \ -DEVSPACE_WIDGETS="full" \ npx @waishnav/devspace serve ``` diff --git a/docs/gotchas.md b/docs/gotchas.md index 495243bb..cd536931 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -253,12 +253,10 @@ If a skill appears in `open_workspace`, the model must read that skill's ## Review Card Does Not Appear -Per-tool widget cards are enabled by default with: +DevSpace attaches widget UI only to `open_workspace` and `show_changes`. +Ordinary reads, edits, and commands intentionally render as normal tool results +to avoid one iframe per call. Plain MCP clients may ignore ChatGPT Apps widget +metadata and only show text results; `show_changes` remains available there. -```bash -DEVSPACE_WIDGETS=full -``` - -The aggregate `show_changes` tool is only exposed with -`DEVSPACE_WIDGETS=changes`. Plain MCP clients may ignore ChatGPT Apps widget -metadata and only show text results. +If both cards are missing in ChatGPT, confirm that `ui.enabled` is not `false` +in `~/.devspace/config.json` and reconnect the MCP server. diff --git a/src/config.test.ts b/src/config.test.ts index e7478ca9..bb464d84 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -11,10 +11,7 @@ const baseEnv = { DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", }; -assert.equal(loadConfig(baseEnv).widgets, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "changes" }).widgets, "changes"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "full" }).widgets, "full"); -assert.equal(loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "off" }).widgets, "off"); +assert.equal(loadConfig(baseEnv).uiEnabled, true); assert.equal(loadConfig(baseEnv).toolMode, "codex"); assert.equal(loadConfig(baseEnv).skillsEnabled, true); assert.equal(loadConfig(baseEnv).devspaceSkillsDir, join(emptyConfigDir, "skills")); @@ -33,18 +30,6 @@ assert.deepEqual(loadConfig({ ...baseEnv, DEVSPACE_SUBAGENTS: "1" }).subagents, enabled: true, providers: [], }); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "invalid" }), - /Invalid DEVSPACE_WIDGETS: invalid/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "minimal" }), - /Invalid DEVSPACE_WIDGETS: minimal/, -); -assert.throws( - () => loadConfig({ ...baseEnv, DEVSPACE_WIDGETS: "write-only" }), - /Invalid DEVSPACE_WIDGETS: write-only/, -); assert.deepEqual(loadConfig(baseEnv).logging, { level: "info", format: "json", @@ -154,6 +139,7 @@ writeFileSync( artifactsEnabled: true, artifactMaxFileBytes: 321, tools: { mode: "claude" }, + ui: { enabled: false }, }), ); writeFileSync( @@ -172,6 +158,7 @@ assert.equal(fileConfig.subagents.providers.length, 7); assert.equal(fileConfig.artifactsEnabled, true); assert.equal(fileConfig.artifactMaxFileBytes, 321); assert.equal(fileConfig.toolMode, "claude"); +assert.equal(fileConfig.uiEnabled, false); assert.deepEqual(fileConfig.allowedHosts, [ "localhost", "127.0.0.1", diff --git a/src/config.ts b/src/config.ts index bd8f47a8..e4236557 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,7 +7,6 @@ import { devspaceAgentsDir, devspaceSkillsDir, loadDevspaceFiles } from "./user- import { resolveSubagentsConfig, type SubagentsConfig } from "./local-agent-config.js"; export type ToolMode = "claude" | "codex"; -export type WidgetMode = "off" | "changes" | "full"; const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60; const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60; const DEFAULT_ARTIFACT_MAX_FILE_BYTES = 100 * 1024 * 1024; @@ -20,7 +19,7 @@ export interface ServerConfig { allowedHosts: string[]; publicBaseUrl: string; toolMode: ToolMode; - widgets: WidgetMode; + uiEnabled: boolean; stateDir: string; worktreeRoot: string; artifactsEnabled: boolean; @@ -145,13 +144,6 @@ function parseLoggingConfig(env: NodeJS.ProcessEnv): LoggingConfig { }; } -function parseWidgetMode(value: string | undefined): WidgetMode { - if (!value || value === "full") return "full"; - if (value === "off" || value === "changes") return value; - - throw new Error(`Invalid DEVSPACE_WIDGETS: ${value}`); -} - function parseRequiredSecret(value: string | undefined, name: string): string { const secret = value?.trim(); if (!secret) { @@ -221,7 +213,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts), publicBaseUrl, toolMode: files.config.tools?.mode ?? "codex", - widgets: parseWidgetMode(env.DEVSPACE_WIDGETS), + uiEnabled: files.config.ui?.enabled ?? true, stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())), worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())), artifactsEnabled: diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 0c2aeb7b..499cdb9b 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -21,6 +21,24 @@ test("a clean workspace reports no changes from the last-shown checkpoint", asyn assert.match(clean.result, /No changes since last shown changes/); }); +test("initialization reports whether aggregate review is available", async (t) => { + const gitRoot = await committedRepository(t); + const plainRoot = await mkdtemp(join(tmpdir(), "devspace-review-plain-test-")); + t.after(() => rm(plainRoot, { recursive: true, force: true })); + const manager = createReviewCheckpointManager(); + + assert.deepEqual( + await manager.initializeWorkspace({ workspaceId: "ws_git", root: gitRoot }), + { available: true }, + ); + const unavailable = await manager.initializeWorkspace({ + workspaceId: "ws_plain", + root: plainRoot, + }); + assert.equal(unavailable.available, false); + if (!unavailable.available) assert.match(unavailable.reason, /git repository/i); +}); + test("show_changes reports and advances the last-shown checkpoint", async (t) => { const root = await committedRepository(t); const manager = createReviewCheckpointManager(); diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index 0fd8bf36..21a0d660 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -26,6 +26,10 @@ export interface ReviewChangesResult { patch: string; } +export type ReviewAvailability = + | { available: true } + | { available: false; reason: string }; + interface WorkspaceReviewState { root: string; gitRoot?: string; @@ -37,7 +41,7 @@ interface WorkspaceReviewState { } export interface ReviewCheckpointManager { - initializeWorkspace(input: { workspaceId: string; root: string }): Promise; + initializeWorkspace(input: { workspaceId: string; root: string }): Promise; reviewChanges(input: { workspaceId: string; root: string; @@ -57,14 +61,15 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { const existingState = states.get(workspaceId); assertWorkspaceRoot(existingState, workspaceId, root); if (existingState?.root === root && existingState.gitRoot !== undefined) { - return; + return reviewAvailability(existingState); } const pending = initializations.get(workspaceId); if (pending) { await pending; - assertWorkspaceRoot(states.get(workspaceId), workspaceId, root); - return; + const initializedState = states.get(workspaceId); + assertWorkspaceRoot(initializedState, workspaceId, root); + return reviewAvailability(initializedState); } const initialize = initializeWorkspaceState(states, workspaceId, root); @@ -76,6 +81,7 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { initializations.delete(workspaceId); } } + return reviewAvailability(states.get(workspaceId)); }, async reviewChanges({ workspaceId, root, since = "last_shown", markReviewed = true }) { @@ -193,6 +199,15 @@ async function initializeWorkspaceState( } } +function reviewAvailability(state: WorkspaceReviewState | undefined): ReviewAvailability { + return state?.gitRoot + ? { available: true } + : { + available: false, + reason: state?.diagnostic ?? "show_changes is unavailable for this workspace.", + }; +} + function isReadyState(state: WorkspaceReviewState | undefined): boolean { return state?.gitRoot !== undefined; } diff --git a/src/server.test.ts b/src/server.test.ts index 592cbda2..4f1215c1 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -7,7 +7,7 @@ import test, { type TestContext } from "node:test"; import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { loadConfig, type ServerConfig, type ToolMode, type WidgetMode } from "./config.js"; +import { loadConfig, type ServerConfig, type ToolMode } from "./config.js"; import type { LocalAgentProviderAvailability } from "./local-agent-availability.js"; import { buildLocalAgentProviderStatuses } from "./local-agent-catalog.js"; import type { SubagentsConfig } from "./local-agent-config.js"; @@ -26,17 +26,17 @@ test("tool modes expose the expected host-facing tool surface", async (t) => { }> = [ { mode: "claude", - expected: ["open_workspace", "read", "write", "edit", "bash"], + expected: ["open_workspace", "read", "write", "edit", "bash", "show_changes"], }, { mode: "codex", - expected: ["open_workspace", "read", "apply_patch", "exec_command", "write_stdin"], + expected: ["open_workspace", "read", "apply_patch", "exec_command", "write_stdin", "show_changes"], }, ]; for (const { mode, expected } of cases) { await t.test(mode, async (nested) => { - const context = await fixture(nested, { toolMode: mode, widgets: "off" }); + const context = await fixture(nested, { toolMode: mode, uiEnabled: false }); const tools = await context.client.listTools(); assert.deepEqual( @@ -47,31 +47,70 @@ test("tool modes expose the expected host-facing tool surface", async (t) => { } }); -test("widget modes compose independently from tool modes", async (t) => { - const cases: Array<{ - widgets: WidgetMode; - showChanges: boolean; - workspaceCard: boolean; - }> = [ - { widgets: "off", showChanges: false, workspaceCard: false }, - { widgets: "changes", showChanges: true, workspaceCard: true }, - { widgets: "full", showChanges: false, workspaceCard: true }, - ]; - - for (const { widgets, showChanges, workspaceCard } of cases) { - await t.test(widgets, async (nested) => { - const context = await fixture(nested, { toolMode: "claude", widgets }); +test("UI metadata is limited to workspace and aggregate review", async (t) => { + for (const uiEnabled of [true, false]) { + await t.test(uiEnabled ? "enabled" : "disabled", async (nested) => { + const context = await fixture(nested, { toolMode: "claude", uiEnabled }); const tools = await context.client.listTools(); - const workspace = tools.tools.find((tool) => tool.name === "open_workspace"); - const changes = tools.tools.find((tool) => tool.name === "show_changes"); - const workspaceMeta = workspace?._meta as { ui?: unknown } | undefined; + const toolsWithUi = tools.tools + .filter((tool) => Boolean((tool._meta as { ui?: unknown } | undefined)?.ui)) + .map((tool) => tool.name) + .sort(); - assert.equal(Boolean(changes), showChanges); - assert.equal(Boolean(workspaceMeta?.ui), workspaceCard); + assert.deepEqual(toolsWithUi, uiEnabled ? ["open_workspace", "show_changes"] : []); }); } }); +test("open_workspace reports aggregate review availability", async (t) => { + const plain = await fixture(t); + const gitWorkspace = await fixture(t, { git: true }); + + const plainReview = structuredContent(await callOpen(plain.client, plain.project, "plain")).review; + const gitReview = structuredContent(await callOpen(gitWorkspace.client, gitWorkspace.project, "git")).review; + + assert.equal((plainReview as { available: boolean }).available, false); + assert.deepEqual(gitReview, { available: true }); +}); + +test("show_changes exposes the aggregate diff to plain MCP hosts", async (t) => { + const context = await fixture(t, { git: true, uiEnabled: false }); + const opened = structuredContent( + await callOpen(context.client, context.project, "review"), + ); + const workspaceId = opened.workspaceId; + assert.equal(typeof workspaceId, "string"); + + await writeFile(join(context.project, "README.md"), "goodbye\n"); + const review = await context.client.callTool({ + name: "show_changes", + arguments: { workspaceId }, + }); + const structured = structuredContent(review); + + assert.deepEqual(structured.summary, { + files: 1, + additions: 1, + removals: 1, + }); + assert.deepEqual(structured.files, [ + { + path: "README.md", + type: "change", + additions: 1, + removals: 1, + }, + ]); + assert.match(structured.patch as string, /-hello\n\+goodbye/); + + const tools = await context.client.listTools(); + const outputProperties = tools.tools.find((tool) => tool.name === "show_changes") + ?.outputSchema?.properties; + assert.ok(outputProperties && "summary" in outputProperties); + assert.ok(outputProperties && "files" in outputProperties); + assert.ok(outputProperties && "patch" in outputProperties); +}); + test("open_workspace keeps lifecycle flags out of model output and preserves complete card metadata", async (t) => { const providerNote = "available"; const context = await fixture(t, { @@ -301,7 +340,7 @@ async function fixture( localAgentProviders?: LocalAgentProviderAvailability[] | (() => LocalAgentProviderAvailability[]); subagents?: SubagentsConfig; toolMode?: ToolMode; - widgets?: WidgetMode; + uiEnabled?: boolean; } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); @@ -339,7 +378,6 @@ async function fixture( DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_WIDGETS: options.widgets ?? "full", DEVSPACE_SUBAGENTS: options.localAgentProviders ? "1" : "0", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", @@ -347,6 +385,7 @@ async function fixture( const modeConfig: ServerConfig = { ...loadedConfig, toolMode: options.toolMode ?? loadedConfig.toolMode, + uiEnabled: options.uiEnabled ?? loadedConfig.uiEnabled, }; const config: ServerConfig = options.localAgentProviders ? { diff --git a/src/server.ts b/src/server.ts index 768608d6..39839958 100644 --- a/src/server.ts +++ b/src/server.ts @@ -62,7 +62,7 @@ import { resultOutputSchema, textBlock, textSummary, - toolWidgetDescriptorMeta, + workspaceAppDescriptorMeta, } from "./tool-surfaces/shared.js"; import { WORKSPACE_APP_URI, @@ -103,9 +103,7 @@ function serverInstructions( ? " When the user supplies or generates a file that is not present on the DevSpace host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs." : ""; const showChangesInstruction = - config.widgets === "changes" - ? " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs." - : ""; + " If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change."; const skills = config.skillsEnabled ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories. ` : ""; @@ -393,9 +391,16 @@ export function createMcpServer( agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(), agents: z.array(workspaceLocalAgentOutputSchema).optional(), skillDiagnostics: z.array(z.unknown()).optional(), + review: z.discriminatedUnion("available", [ + z.object({ available: z.literal(true) }), + z.object({ + available: z.literal(false), + reason: z.string(), + }), + ]), instruction: z.string(), }, - ...toolWidgetDescriptorMeta(config, "workspace"), + ...workspaceAppDescriptorMeta(config), annotations: { readOnlyHint: true }, }, async ({ path, mode, baseRef }, { _meta }) => { @@ -410,12 +415,10 @@ export function createMcpServer( { path, mode, baseRef }, { conversationScopeId: openAiConversationScopeId(_meta) }, ); - if (config.widgets === "changes") { - await reviewCheckpoints.initializeWorkspace({ - workspaceId: workspace.id, - root: workspace.root, - }); - } + const review = await reviewCheckpoints.initializeWorkspace({ + workspaceId: workspace.id, + root: workspace.root, + }); const cardSkills = workspace.skills .filter((skill) => !skill.disableModelInvocation) .map((skill) => ({ @@ -517,6 +520,7 @@ export function createMcpServer( skills: cardSkills, agentProviders: cardAgentProviders, agents: cardAgents, + review, instruction: cardInstruction, summary: { mode: workspace.mode, @@ -534,6 +538,7 @@ export function createMcpServer( mode: workspace.mode, sourceRoot: workspace.sourceRoot, worktree: workspace.worktree, + review, ...(includeBootstrapContext ? { agentsFiles: loadedAgentsFiles, @@ -550,8 +555,7 @@ export function createMcpServer( }, ); - registerAppTool( - server, + server.registerTool( toolNames.read, { title: "Read file", @@ -590,7 +594,6 @@ export function createMcpServer( .describe("Maximum number of lines to read."), }, outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "read"), annotations: { readOnlyHint: true }, }, async ({ workspaceId, ...input }) => { @@ -654,58 +657,63 @@ export function createMcpServer( processSessions, }); - if (config.widgets === "changes") { - registerAppTool( - server, - "show_changes", - { - title: "Show changes", - description: - "Show the changes made in this turn for an open workspace. Call this once after the final related file change and before your final response so the user can review the combined diff. Do not call it after each individual file change.", - inputSchema: { - workspaceId: z.string().describe(workspaceIdDescription), - }, - outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "show_changes"), - annotations: { readOnlyHint: true }, + registerAppTool( + server, + "show_changes", + { + title: "Show changes", + description: + "Show the changes made in this turn for an open workspace. Call this once after the final related file change and before your final response so the user can review the combined diff. Do not call it after each individual file change.", + inputSchema: { + workspaceId: z.string().describe(workspaceIdDescription), }, - async ({ workspaceId }) => { - const startedAt = performance.now(); - const workspace = workspaces.getWorkspace(workspaceId); - const review = await reviewCheckpoints.reviewChanges({ - workspaceId, - root: workspace.root, - markReviewed: true, - }); + outputSchema: resultOutputSchema({ + summary: reviewSummaryOutputSchema, + files: z.array(reviewFileOutputSchema), + patch: z.string(), + }), + ...workspaceAppDescriptorMeta(config), + annotations: { readOnlyHint: true }, + }, + async ({ workspaceId }) => { + const startedAt = performance.now(); + const workspace = workspaces.getWorkspace(workspaceId); + const review = await reviewCheckpoints.reviewChanges({ + workspaceId, + root: workspace.root, + markReviewed: true, + }); - const content = [textBlock(review.result)]; - logToolCall(config, { - tool: "show_changes", - workspaceId, - success: true, - durationMs: Math.round(performance.now() - startedAt), - }); + const content = [textBlock(review.result)]; + logToolCall(config, { + tool: "show_changes", + workspaceId, + success: true, + durationMs: Math.round(performance.now() - startedAt), + }); - return { - content, - _meta: { - tool: "show_changes", - card: { - workspaceId, - summary: review.summary, - files: review.files, - payload: { - patch: review.patch, - }, + return { + content, + _meta: { + tool: "show_changes", + card: { + workspaceId, + summary: review.summary, + files: review.files, + payload: { + patch: review.patch, }, }, - structuredContent: { - result: contentText(content), - }, - }; - }, - ); - } + }, + structuredContent: { + result: contentText(content), + summary: review.summary, + files: review.files, + patch: review.patch, + }, + }; + }, + ); if (config.artifactsEnabled && isArtifactDownloadSupportedPlatform()) { registerArtifactTools(server, { diff --git a/src/tool-surfaces/claude.ts b/src/tool-surfaces/claude.ts index 8c590e9c..19a47049 100644 --- a/src/tool-surfaces/claude.ts +++ b/src/tool-surfaces/claude.ts @@ -1,4 +1,3 @@ -import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import { existsSync } from "node:fs"; import * as z from "zod/v4"; import { @@ -25,7 +24,6 @@ import { resultOutputSchema, textBlock, textSummary, - toolWidgetDescriptorMeta, } from "./shared.js"; const CLAUDE_INSTRUCTIONS = `Use ${toolNames.read} for direct file reads, ${toolNames.shell} with command-line tools such as rg, find, ls, and tree for search and directory inspection, ${toolNames.edit} for targeted modifications, and ${toolNames.write} only for new files or complete rewrites. Use ${toolNames.shell} for tests, builds, git inspection, package scripts, and other commands, but do not create or modify files through shell commands. Shell commands run with the local user's authority and are not sandboxed; workspace validation only selects their initial working directory. Follow instructions returned by ${toolNames.openWorkspace}; read applicable instruction and skill files before working in their scope.`; @@ -47,8 +45,7 @@ const CLAUDE_SHELL_DESCRIPTION = `Run a shell command in a workspace with the lo function registerClaudeMutationTools(context: ToolRegistrationContext): void { const { server, config, workspaces } = context; - registerAppTool( - server, + server.registerTool( toolNames.write, { title: "Write file", @@ -61,7 +58,6 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { content: z.string().describe("Complete new file content."), }, outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "write"), annotations: WRITE_TOOL_ANNOTATIONS, }, async ({ workspaceId, ...input }) => { @@ -128,8 +124,7 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { }, ); - registerAppTool( - server, + server.registerTool( toolNames.edit, { title: "Edit file", @@ -155,7 +150,6 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { outputSchema: resultOutputSchema({ status: z.literal("applied"), }), - ...toolWidgetDescriptorMeta(config, "edit"), annotations: EDIT_TOOL_ANNOTATIONS, }, async ({ workspaceId, ...input }) => { @@ -224,8 +218,7 @@ function registerClaudeMutationTools(context: ToolRegistrationContext): void { function registerShellTool(context: ToolRegistrationContext): void { const { server, config, workspaces } = context; - registerAppTool( - server, + server.registerTool( toolNames.shell, { title: "Bash", @@ -251,7 +244,6 @@ function registerShellTool(context: ToolRegistrationContext): void { .describe("Timeout in seconds. Defaults to 30, max 300."), }, outputSchema: resultOutputSchema(), - ...toolWidgetDescriptorMeta(config, "shell"), annotations: SHELL_TOOL_ANNOTATIONS, }, async ({ workspaceId, workingDirectory, ...input }) => { diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts index 3007656e..c9a196f3 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -1,4 +1,3 @@ -import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; import * as z from "zod/v4"; import { applyPatch } from "../apply-patch.js"; import type { ProcessSnapshot } from "../process-sessions.js"; @@ -15,7 +14,6 @@ import { runLoggedToolOperation, textBlock, textSummary, - toolWidgetDescriptorMeta, } from "./shared.js"; type CodexRegistration = (context: ToolRegistrationContext) => void; @@ -95,8 +93,7 @@ function processToolResponse( function registerApplyPatchTool(context: ToolRegistrationContext): void { const { server, config, workspaces } = context; - registerAppTool( - server, + server.registerTool( "apply_patch", { title: "Apply patch", @@ -121,7 +118,6 @@ function registerApplyPatchTool(context: ToolRegistrationContext): void { }), ), }), - ...toolWidgetDescriptorMeta(config, "edit"), annotations: EDIT_TOOL_ANNOTATIONS, }, async ({ workspaceId, patch }) => { @@ -173,8 +169,7 @@ function registerApplyPatchTool(context: ToolRegistrationContext): void { function registerCodexProcessTools(context: ToolRegistrationContext): void { const { server, config, workspaces, processSessions } = context; - registerAppTool( - server, + server.registerTool( "exec_command", { title: "Execute command", @@ -227,7 +222,6 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { .describe("Approximate output token budget. Defaults to 10000."), }, outputSchema: processOutputSchema(), - ...toolWidgetDescriptorMeta(config, "shell"), annotations: SHELL_TOOL_ANNOTATIONS, }, async ({ @@ -281,8 +275,7 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { }, ); - registerAppTool( - server, + server.registerTool( "write_stdin", { title: "Write to process", @@ -333,7 +326,6 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { .describe("Approximate output token budget. Defaults to 10000."), }, outputSchema: processOutputSchema(), - ...toolWidgetDescriptorMeta(config, "shell"), annotations: SHELL_TOOL_ANNOTATIONS, }, async ({ diff --git a/src/tool-surfaces/shared.ts b/src/tool-surfaces/shared.ts index c2bb22bd..45c3ba83 100644 --- a/src/tool-surfaces/shared.ts +++ b/src/tool-surfaces/shared.ts @@ -1,13 +1,12 @@ import * as z from "zod/v4"; import { logEvent, commandPreview } from "../logger.js"; -import type { ServerConfig, WidgetMode } from "../config.js"; +import type { ServerConfig } from "../config.js"; import { WORKSPACE_APP_URI, type DiffStats, type ToolContent, type ToolLogFields, type ToolWidgetDescriptorMeta, - type ToolWidgetKind, } from "./types.js"; export function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { @@ -21,11 +20,8 @@ export function resultOutputSchema(extra: z.ZodRawShape = {}): z.ZodRawShape { }; } -export function toolWidgetDescriptorMeta( - config: ServerConfig, - kind: ToolWidgetKind, -): ToolWidgetDescriptorMeta { - if (!shouldAttachWidget(config.widgets, kind)) return { _meta: {} }; +export function workspaceAppDescriptorMeta(config: ServerConfig): ToolWidgetDescriptorMeta { + if (!config.uiEnabled) return { _meta: {} }; return { _meta: { @@ -163,14 +159,3 @@ export function newFilePatch(path: string, content: string): string { .filter((line) => line.length > 0) .join("\n"); } - -function shouldAttachWidget(mode: WidgetMode, kind: ToolWidgetKind): boolean { - switch (mode) { - case "off": - return false; - case "changes": - return kind === "workspace" || kind === "show_changes"; - case "full": - return true; - } -} diff --git a/src/tool-surfaces/types.ts b/src/tool-surfaces/types.ts index f10fbd9b..a9d8131b 100644 --- a/src/tool-surfaces/types.ts +++ b/src/tool-surfaces/types.ts @@ -58,14 +58,6 @@ export interface DiffStats { removals: number; } -export type ToolWidgetKind = - | "workspace" - | "read" - | "write" - | "edit" - | "shell" - | "show_changes"; - export interface ToolDefinitionMeta extends Record { ui: { resourceUri: string; diff --git a/src/user-config.ts b/src/user-config.ts index 203bc7c3..00535be2 100644 --- a/src/user-config.ts +++ b/src/user-config.ts @@ -26,6 +26,9 @@ const devspaceUserConfigSchema = z.object({ tools: z.object({ mode: z.enum(["claude", "codex"]).optional(), }).strict().optional(), + ui: z.object({ + enabled: z.boolean().optional(), + }).strict().optional(), }).passthrough(); const devspaceAuthConfigSchema = z.object({