diff --git a/.github/workflows/agent-sdk.yml b/.github/workflows/agent-sdk.yml index 27b1e5c7..b3d69f5f 100644 --- a/.github/workflows/agent-sdk.yml +++ b/.github/workflows/agent-sdk.yml @@ -13,6 +13,9 @@ on: - ".github/workflows/agent-sdk.yml" workflow_dispatch: +permissions: + contents: read + jobs: check: name: Check agent-sdk diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index d0bb298a..61a490df 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -5,6 +5,11 @@ on: - cron: '0 0 * * 1' workflow_dispatch: +permissions: + contents: read + issues: write + checks: write + jobs: audit: name: Cargo Audit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdc7762d..51b881da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + env: CARGO_TERM_COLOR: always RUSTFLAGS: "-Dwarnings" diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml index 44658bb0..da308d66 100644 --- a/.github/workflows/commit-lint.yml +++ b/.github/workflows/commit-lint.yml @@ -4,6 +4,10 @@ on: pull_request: types: [opened, edited, synchronize, reopened] +permissions: + contents: read + pull-requests: read + jobs: lint: name: Lint PR Title diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml index ab42230e..3412d707 100644 --- a/.github/workflows/release-dryrun.yml +++ b/.github/workflows/release-dryrun.yml @@ -6,6 +6,9 @@ on: paths: - 'Cargo.toml' +permissions: + contents: read + jobs: dry-run-publish: name: Validate npm publish diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e3ff24..29ba7083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file. ### Features +- **Fable 5 and Claude Agent SDK 0.3.198 migration** (#229, @srothgan): Make Fable 5 the default model alias, preserve resolved model IDs from the SDK catalog, and refresh bridge metadata handling for current models, MCP request timeouts, and newer SDK status fields. +- **Separate model and thinking effort config dialogs** (#229, @srothgan): Split the combined `/config` model and effort picker into independent dialogs, keep effort choices filtered by the selected model, and explain the model-dependent effort behavior only in the effort dialog. - **Mention file autocomplete** (#226, @srothgan): Move `@` file matching off the UI path, support raw indexed file and folder mentions with spaces, preserve whole-mention replacement boundaries, and rank shallow project paths ahead of deep dependency matches unless the query explicitly targets the deep path. - **Atomic input placeholders** (#227, @srothgan): Treat image badges and pasted-text placeholders as shared textarea atoms, keeping cursor movement, deletion, undo/redo, image attachment state, and paste expansion aligned through `tui-textarea-2` `0.12.0` atomic range support. - **Question and list rendering** (#228, @srothgan): Render markdown lists with indentation instead of injected blank gaps, and show `AskUserQuestion` answers as structured results with selected options, descriptions, previews, and notes. @@ -16,6 +18,7 @@ All notable changes to this project will be documented in this file. ### Fixes +- **SDK metadata and MCP preservation** (#229, @srothgan): Preserve MCP `request_timeout_ms` through status display, diagnostics, and dynamic server removal, and keep app-owned slash commands from being duplicated by SDK command snapshots. - **Inline chat resize transactions** (#217, @srothgan): Treat terminal size as a draw-transaction snapshot, recover from mid-draw resizes with purge/replay, and clip stale inline viewport geometry before owned-region clears. - **Streaming markdown tables** (#226, @srothgan): Preserve table rendering across streamed cache splits so partial assistant updates do not corrupt table layout. @@ -26,6 +29,8 @@ All notable changes to this project will be documented in this file. ### CI and Dependencies +- **Workflow permission hardening** (#229, @srothgan): Declare least-privilege GitHub Actions permissions for CI, audit, commit-lint, release dry-run, and Agent SDK workflows to resolve CodeQL code scanning alerts. +- **Claude Agent SDK update** (#229, @srothgan): Bump `@anthropic-ai/claude-agent-sdk` to `0.3.198` in the root package and bundled bridge package locks. - **CodeQL scanning** (#228, @srothgan): Add a CodeQL workflow for repository code scanning. - **quinn-proto advisory fix** (#220, @srothgan): Bump `quinn-proto` to `0.11.15` for `RUSTSEC-2026-0185` (remote memory exhaustion from unbounded out-of-order stream reassembly). - **Security Audit workflow resilience** (#220, @srothgan): Mark the `cargo audit` step `continue-on-error` so newly published advisories still report and file a tracking issue without failing the scheduled Security Audit run. diff --git a/agent-sdk/package-lock.json b/agent-sdk/package-lock.json index 4ea7b624..6fe151c4 100644 --- a/agent-sdk/package-lock.json +++ b/agent-sdk/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.193", + "@anthropic-ai/claude-agent-sdk": "0.3.198", "@anthropic-ai/sdk": "0.106.0", "@modelcontextprotocol/sdk": "1.29.0", "zod": "4.4.3" @@ -22,22 +22,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.193.tgz", - "integrity": "sha512-WzL03VJE1sT0Nz3rEpsYMYR+9n6iyQtLVt7ghMWnYC9pvDsiy6kwcMplZniWSjH8Dm6CfkUBN5t6KB4i/JfouA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.198.tgz", + "integrity": "sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.193", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.193" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.198" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -46,9 +46,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.193.tgz", - "integrity": "sha512-1hT7b+KIm/3E1OSJofr7PF21Xq2zT1ccnjzuVcWQ5LYXJ09lgCMvA/ZfcDBKuoyCZ4lSnuicKXZ/h5vRbWfqrA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.198.tgz", + "integrity": "sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==", "cpu": [ "arm64" ], @@ -59,9 +59,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.193.tgz", - "integrity": "sha512-9x5Y/L6iwETwEJFmPaYXfsE8q0cVgx75V7nL60HvSzi8K1XQNcG5u53jOzRvqDNah8mvGYOb+9AWrMCSJvmFyA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.198.tgz", + "integrity": "sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==", "cpu": [ "x64" ], @@ -72,9 +72,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.193.tgz", - "integrity": "sha512-gvfD9pKHXWxCkkIX6bC4/FOALTxqHYjAu83iT1bzn5mCv6QWSZRl6WLRRtvKpWtWuY1jpML1lL3YfKADsAX/rA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.198.tgz", + "integrity": "sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==", "cpu": [ "arm64" ], @@ -85,9 +85,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.193.tgz", - "integrity": "sha512-a3qsVTBe4G6ndsVavfIXEVAXLVXM8uvBUNYpm4jKqk2+ovWZQe/96CXOwmerg9U+/bllg4QJ9lSyYCsdtVIr6w==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.198.tgz", + "integrity": "sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==", "cpu": [ "arm64" ], @@ -98,9 +98,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.193.tgz", - "integrity": "sha512-1sz+7cn0iuh0ThInuAYF1jpFvLyOaZ0PZYQIF7eb9JDZbBohUf6INeTCwjAwUL0ASCq2xS7Odu/qDVuTtLTeDA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.198.tgz", + "integrity": "sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==", "cpu": [ "x64" ], @@ -111,9 +111,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.193.tgz", - "integrity": "sha512-DLXlO4tlcWygz0Ft4nu6ai5KssByYt2tOeWdc4dFXKt6uBKXpbZVziUUq3ePO5zuAFyU6w7EjYLv8MMPURbAiQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.198.tgz", + "integrity": "sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==", "cpu": [ "x64" ], @@ -124,9 +124,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.193.tgz", - "integrity": "sha512-36LJKiGuKusgaPTVeh9QanL00UcaE0RcC4pgK800/0SenApbh979ndxI0XKUVfLHzlGkqlhkhT3foMdqS+zx1w==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.198.tgz", + "integrity": "sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==", "cpu": [ "arm64" ], @@ -137,9 +137,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.193.tgz", - "integrity": "sha512-VyyKZlWQpbD6nkUTeNvgmLvpqt1QaPQQBOC30tbEYwYsV5MC1I35Li0H7nWwngGqPSTtMvHpjpz6Eb2FSTn7/A==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.198.tgz", + "integrity": "sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==", "cpu": [ "x64" ], @@ -248,9 +248,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -268,9 +265,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -288,9 +282,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -308,9 +299,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -586,9 +574,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -606,9 +591,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -626,9 +608,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -646,9 +625,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -666,9 +642,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -686,9 +659,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -706,9 +676,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -726,9 +693,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -941,9 +905,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -958,9 +919,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -975,9 +933,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -992,9 +947,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1009,9 +961,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1026,9 +975,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1043,9 +989,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1060,9 +1003,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/agent-sdk/package.json b/agent-sdk/package.json index 03e5761f..d32cfea1 100644 --- a/agent-sdk/package.json +++ b/agent-sdk/package.json @@ -17,7 +17,7 @@ "test": "npm run build && node --test dist/**/*.test.js" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.193", + "@anthropic-ai/claude-agent-sdk": "0.3.198", "@anthropic-ai/sdk": "0.106.0", "@modelcontextprotocol/sdk": "1.29.0", "zod": "4.4.3" diff --git a/agent-sdk/src/bridge.test.ts b/agent-sdk/src/bridge.test.ts index 52291b69..6b2b2946 100644 --- a/agent-sdk/src/bridge.test.ts +++ b/agent-sdk/src/bridge.test.ts @@ -467,6 +467,7 @@ test("parseCommandEnvelope validates mcp_set_servers command", () => { "X-Test": "1", }, timeout: 5000, + request_timeout_ms: 30000, always_load: true, tools: [ { @@ -501,6 +502,7 @@ test("parseCommandEnvelope validates mcp_set_servers command", () => { "X-Test": "1", }, timeout: 5000, + request_timeout_ms: 30000, always_load: true, tools: [ { @@ -551,6 +553,24 @@ test("parseCommandEnvelope rejects invalid latest MCP config fields", () => { /mcp_set_servers\.servers\.bad\.timeout must be an integer >= 1000/, ); + assert.throws( + () => + parseCommandEnvelope( + JSON.stringify({ + command: "mcp_set_servers", + session_id: "session-123", + servers: { + bad: { + type: "http", + url: "https://mcp.example.com", + request_timeout_ms: 999, + }, + }, + }), + ), + /mcp_set_servers\.servers\.bad\.request_timeout_ms must be an integer >= 1000/, + ); + assert.throws( () => parseCommandEnvelope( @@ -642,6 +662,7 @@ test("handleMcpSetServersCommand emits SDK result", async () => { type: "http", url: "https://example.test/mcp", always_load: true, + request_timeout_ms: 30000, }, }, }, @@ -654,6 +675,7 @@ test("handleMcpSetServersCommand emits SDK result", async () => { type: "http", url: "https://example.test/mcp", alwaysLoad: true, + requestTimeoutMs: 30000, }, }); assert.deepEqual(events, [ @@ -738,6 +760,7 @@ test("bridgeMcpConfigToSdk maps latest MCP fields to SDK casing", () => { type: "sse", url: "https://mcp.example.com/sse", timeout: 2500, + request_timeout_ms: 30000, always_load: true, tools: [ { name: "search" }, @@ -748,6 +771,7 @@ test("bridgeMcpConfigToSdk maps latest MCP fields to SDK casing", () => { type: "sse", url: "https://mcp.example.com/sse", timeout: 2500, + requestTimeoutMs: 30000, alwaysLoad: true, tools: [ { name: "search" }, @@ -766,12 +790,13 @@ test("mapMcpServerStatus preserves latest MCP status config fields", () => { url: "https://mcp.notion.com/mcp", headers: { Authorization: "Bearer token" }, timeout: 5000, + requestTimeoutMs: 30000, alwaysLoad: true, tools: [ { name: "search" }, { name: "write", permission_policy: "always_deny", org_max_permission: "ask" }, ], - }, + } as unknown as NonNullable, tools: [], }); @@ -780,6 +805,7 @@ test("mapMcpServerStatus preserves latest MCP status config fields", () => { url: "https://mcp.notion.com/mcp", headers: { Authorization: "Bearer token" }, timeout: 5000, + request_timeout_ms: 30000, always_load: true, tools: [ { name: "search" }, @@ -1400,6 +1426,7 @@ test("buildQueryOptions enables dangerous skip flag for bypass permissions start assert.equal(options.permissionMode, "bypassPermissions"); assert.equal(options.allowDangerouslySkipPermissions, true); + assert.equal("canUseTool" in options, false); }); test("buildQueryOptions omits optional startup overrides but keeps bridge guard prompt", () => { @@ -5112,6 +5139,7 @@ test("createToolCall maps project and artifact tools to compact titles", () => { file_path: "C:/work/report.html", favicon: "R", label: "report-v2", + description: "Quarterly report", }); const artifactFallback = createToolCall("tc-artifact-path", "Artifact", { file_path: "C:/work/report.html", @@ -5125,6 +5153,10 @@ test("createToolCall maps project and artifact tools to compact titles", () => { assert.equal(projectSearch.title, "Projects: search migration"); assert.equal(artifactWithLabel.kind, "other"); assert.equal(artifactWithLabel.title, "Artifact: report-v2"); + assert.equal( + (artifactWithLabel.raw_input as Record).description, + "Quarterly report", + ); assert.equal(artifactFallback.title, "Artifact: C:/work/report.html"); assert.equal(rolePicker.kind, "other"); assert.equal(rolePicker.title, "ShowOnboardingRolePicker"); @@ -5979,7 +6011,7 @@ test("looksLikeAuthRequired detects login hints", () => { }); test("agent sdk version compatibility check matches pinned version", () => { - assert.equal(resolveInstalledAgentSdkVersion(), "0.3.193"); + assert.equal(resolveInstalledAgentSdkVersion(), "0.3.198"); assert.equal(agentSdkVersionCompatibilityError(), undefined); }); @@ -7302,6 +7334,7 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () => const mapped = mapAvailableModels([ { value: "sonnet", + resolvedModel: "claude-sonnet-5", displayName: "Claude Sonnet", description: "Balanced model", supportsEffort: true, @@ -7321,6 +7354,7 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () => assert.deepEqual(mapped, [ { id: "sonnet", + resolved_model: "claude-sonnet-5", display_name: "Claude Sonnet", description: "Balanced model", supports_effort: true, @@ -7339,12 +7373,13 @@ test("mapAvailableModels preserves optional fast and auto mode metadata", () => ]); }); -test("mapAvailableModels filters unavailable Fable models while preserving unknown ids", () => { +test("mapAvailableModels preserves Fable models and unknown ids", () => { const mapped = mapAvailableModels([ { value: "fable", + resolvedModel: "claude-fable-5", displayName: "Claude Fable", - description: "Unavailable model alias", + description: "Default model alias", supportsEffort: true, }, { @@ -7368,6 +7403,28 @@ test("mapAvailableModels filters unavailable Fable models while preserving unkno ]); assert.deepEqual(mapped, [ + { + id: "fable", + resolved_model: "claude-fable-5", + display_name: "Claude Fable", + description: "Default model alias", + supports_effort: true, + supported_effort_levels: [], + }, + { + id: "claude-fable-5", + display_name: "Claude Fable 5", + description: "Unavailable model", + supports_effort: true, + supported_effort_levels: [], + }, + { + id: "claude-fable-5-20260612", + display_name: "Claude Fable 5 dated", + description: "Unavailable dated model", + supports_effort: true, + supported_effort_levels: [], + }, { id: "claude-unknown-1", display_name: "Claude Unknown", @@ -7378,6 +7435,29 @@ test("mapAvailableModels filters unavailable Fable models while preserving unkno ]); }); +test("resolveCurrentModel matches full Fable runtime ids to the fable alias", () => { + const session = makeSessionState(); + session.model = "fable"; + session.requestedModelId = "fable"; + session.resolvedRuntimeModelId = "claude-fable-5-20260612"; + session.availableModels = [ + { + id: "fable", + resolved_model: "claude-fable-5", + display_name: "Claude Fable 5", + supports_effort: true, + supported_effort_levels: ["low", "medium", "high", "xhigh", "max"], + }, + ]; + + const currentModel = resolveCurrentModel(session); + + assert.equal(currentModel.display_name_short, "Fable 5"); + assert.equal(currentModel.display_name_long, "Claude Fable 5"); + assert.equal(currentModel.catalog_id, "fable"); + assert.equal(currentModel.supports_effort, true); +}); + test("resolveCurrentModel keeps 1M context suffix in short and long display names", () => { const session = makeSessionState(); session.resolvedRuntimeModelId = "claude-opus-4-7[1m]"; diff --git a/agent-sdk/src/bridge.ts b/agent-sdk/src/bridge.ts index d054dcd8..d6cdc2bd 100644 --- a/agent-sdk/src/bridge.ts +++ b/agent-sdk/src/bridge.ts @@ -267,7 +267,7 @@ export function emitAgentConfigOptionUpdate(sessionId: string, agent: string | n }); } -const EXPECTED_AGENT_SDK_VERSION = "0.3.193"; +const EXPECTED_AGENT_SDK_VERSION = "0.3.198"; const require = createRequire(import.meta.url); export function resolveInstalledAgentSdkVersion(): string | undefined { diff --git a/agent-sdk/src/bridge/mcp_metadata.ts b/agent-sdk/src/bridge/mcp_metadata.ts index 60c929fd..3a93fd37 100644 --- a/agent-sdk/src/bridge/mcp_metadata.ts +++ b/agent-sdk/src/bridge/mcp_metadata.ts @@ -18,6 +18,7 @@ type McpServerDiagnosticSummary = { config_type: string; scope?: string; timeout_ms?: number; + request_timeout_ms?: number; always_load?: boolean; tool_count: number; configured_tool_policy_count: number; @@ -91,6 +92,20 @@ function optionalTimeout( return value; } +function optionalRequestTimeoutMs( + record: Record, + context: string, +): number | undefined { + const value = record.request_timeout_ms; + if (value === undefined) { + return undefined; + } + if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 1000) { + throw new Error(`${context}.request_timeout_ms must be an integer >= 1000`); + } + return value; +} + function optionalAlwaysLoad( record: Record, context: string, @@ -150,6 +165,7 @@ export function parseMcpServerConfig(value: unknown, context: string): McpServer } const timeout = optionalTimeout(record, context); + const requestTimeoutMs = optionalRequestTimeoutMs(record, context); const alwaysLoad = optionalAlwaysLoad(record, context); switch (type) { @@ -167,6 +183,7 @@ export function parseMcpServerConfig(value: unknown, context: string): McpServer ...(optionalStringArray(record, "args", context) ? { args: optionalStringArray(record, "args", context) } : {}), ...(optionalStringMap(record, "env", context) ? { env: optionalStringMap(record, "env", context) } : {}), ...(timeout === undefined ? {} : { timeout }), + ...(requestTimeoutMs === undefined ? {} : { request_timeout_ms: requestTimeoutMs }), ...(alwaysLoad === undefined ? {} : { always_load: alwaysLoad }), }; } @@ -183,6 +200,7 @@ export function parseMcpServerConfig(value: unknown, context: string): McpServer ...(optionalStringMap(record, "headers", context) ? { headers: optionalStringMap(record, "headers", context) } : {}), ...(tools === undefined ? {} : { tools }), ...(timeout === undefined ? {} : { timeout }), + ...(requestTimeoutMs === undefined ? {} : { request_timeout_ms: requestTimeoutMs }), ...(alwaysLoad === undefined ? {} : { always_load: alwaysLoad }), }; } @@ -209,6 +227,10 @@ function toSdkToolPolicies(tools?: McpServerToolPolicy[]): import("@anthropic-ai })); } +function sdkRequestTimeoutConfig(config: { request_timeout_ms?: number }): { requestTimeoutMs?: number } { + return config.request_timeout_ms === undefined ? {} : { requestTimeoutMs: config.request_timeout_ms }; +} + export function bridgeMcpConfigToSdk(config: McpServerConfig): SdkMcpServerConfig { switch (config.type) { case "stdio": @@ -218,6 +240,7 @@ export function bridgeMcpConfigToSdk(config: McpServerConfig): SdkMcpServerConfi ...(config.args ? { args: config.args } : {}), ...(config.env ? { env: config.env } : {}), ...(config.timeout === undefined ? {} : { timeout: config.timeout }), + ...sdkRequestTimeoutConfig(config), ...(config.always_load === undefined ? {} : { alwaysLoad: config.always_load }), }; case "sse": @@ -227,6 +250,7 @@ export function bridgeMcpConfigToSdk(config: McpServerConfig): SdkMcpServerConfi ...(config.headers ? { headers: config.headers } : {}), ...(config.tools ? { tools: toSdkToolPolicies(config.tools) } : {}), ...(config.timeout === undefined ? {} : { timeout: config.timeout }), + ...sdkRequestTimeoutConfig(config), ...(config.always_load === undefined ? {} : { alwaysLoad: config.always_load }), }; case "http": @@ -236,6 +260,7 @@ export function bridgeMcpConfigToSdk(config: McpServerConfig): SdkMcpServerConfi ...(config.headers ? { headers: config.headers } : {}), ...(config.tools ? { tools: toSdkToolPolicies(config.tools) } : {}), ...(config.timeout === undefined ? {} : { timeout: config.timeout }), + ...sdkRequestTimeoutConfig(config), ...(config.always_load === undefined ? {} : { alwaysLoad: config.always_load }), }; } @@ -319,6 +344,17 @@ export function mapMcpServerStatus(status: SdkMcpServerStatus): McpServerStatus }; } +function sdkRequestTimeoutMs(config: unknown): number | undefined { + if (!config || typeof config !== "object" || Array.isArray(config)) { + return undefined; + } + const raw = config as Record; + const value = raw.requestTimeoutMs ?? raw.request_timeout_ms; + return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) + ? value + : undefined; +} + export function mapMcpServerStatusConfig(config: SdkMcpServerStatusConfig): McpServerStatusConfig { switch (config.type) { case "stdio": @@ -328,6 +364,7 @@ export function mapMcpServerStatusConfig(config: SdkMcpServerStatusConfig): McpS ...(Array.isArray(config.args) && config.args.length > 0 ? { args: config.args } : {}), ...(config.env ? { env: config.env } : {}), ...(config.timeout === undefined ? {} : { timeout: config.timeout }), + ...(sdkRequestTimeoutMs(config) === undefined ? {} : { request_timeout_ms: sdkRequestTimeoutMs(config) }), ...(config.alwaysLoad === undefined ? {} : { always_load: config.alwaysLoad }), }; case "sse": { @@ -338,6 +375,7 @@ export function mapMcpServerStatusConfig(config: SdkMcpServerStatusConfig): McpS ...(config.headers ? { headers: config.headers } : {}), ...(tools === undefined ? {} : { tools }), ...(config.timeout === undefined ? {} : { timeout: config.timeout }), + ...(sdkRequestTimeoutMs(config) === undefined ? {} : { request_timeout_ms: sdkRequestTimeoutMs(config) }), ...(config.alwaysLoad === undefined ? {} : { always_load: config.alwaysLoad }), }; } @@ -349,6 +387,7 @@ export function mapMcpServerStatusConfig(config: SdkMcpServerStatusConfig): McpS ...(config.headers ? { headers: config.headers } : {}), ...(tools === undefined ? {} : { tools }), ...(config.timeout === undefined ? {} : { timeout: config.timeout }), + ...(sdkRequestTimeoutMs(config) === undefined ? {} : { request_timeout_ms: sdkRequestTimeoutMs(config) }), ...(config.alwaysLoad === undefined ? {} : { always_load: config.alwaysLoad }), }; } @@ -382,6 +421,7 @@ export function mapMcpServerStatusConfig(config: SdkMcpServerStatusConfig): McpS function mcpStatusConfigDiagnostics(config: McpServerStatusConfig | undefined): { config_type: string; timeout_ms?: number; + request_timeout_ms?: number; always_load?: boolean; configured_tool_policy_count: number; } { @@ -397,6 +437,7 @@ function mcpStatusConfigDiagnostics(config: McpServerStatusConfig | undefined): return { config_type: "stdio", ...(config.timeout === undefined ? {} : { timeout_ms: config.timeout }), + ...(config.request_timeout_ms === undefined ? {} : { request_timeout_ms: config.request_timeout_ms }), ...(config.always_load === undefined ? {} : { always_load: config.always_load }), configured_tool_policy_count: 0, }; @@ -405,6 +446,7 @@ function mcpStatusConfigDiagnostics(config: McpServerStatusConfig | undefined): return { config_type: config.type, ...(config.timeout === undefined ? {} : { timeout_ms: config.timeout }), + ...(config.request_timeout_ms === undefined ? {} : { request_timeout_ms: config.request_timeout_ms }), ...(config.always_load === undefined ? {} : { always_load: config.always_load }), configured_tool_policy_count: config.tools?.length ?? 0, }; @@ -438,6 +480,7 @@ export function summarizeMcpServersForDiagnostics( config_type: config.config_type, ...(server.scope ? { scope: server.scope } : {}), ...(config.timeout_ms === undefined ? {} : { timeout_ms: config.timeout_ms }), + ...(config.request_timeout_ms === undefined ? {} : { request_timeout_ms: config.request_timeout_ms }), ...(config.always_load === undefined ? {} : { always_load: config.always_load }), tool_count: server.tools.length, configured_tool_policy_count: config.configured_tool_policy_count, diff --git a/agent-sdk/src/bridge/model_metadata.ts b/agent-sdk/src/bridge/model_metadata.ts index 313d3b21..87c78149 100644 --- a/agent-sdk/src/bridge/model_metadata.ts +++ b/agent-sdk/src/bridge/model_metadata.ts @@ -10,23 +10,17 @@ type ModelMetadataSession = { type NormalizedModelKey = { original: string; - family: "opus" | "sonnet" | "haiku" | "unknown"; + family: "fable" | "opus" | "sonnet" | "haiku" | "unknown"; versionParts: number[]; variantParts: string[]; buildParts: string[]; contextSuffix?: string; }; -const OPUS_MODEL_ALIAS = "opus"; +const DEFAULT_MODEL_ALIAS = "fable"; const MAX_MODEL_VERSION_PARTS = 2; const RELEASE_BUILD_TOKEN = /^20\d{6}$/; -function isUnavailableModelId(id: string): boolean { - const normalized = id.trim().toLowerCase(); - // TODO: Revisit only after a product decision if Anthropic restores Fable 5 access. - return normalized === "fable" || normalized.startsWith("claude-fable-5"); -} - function isEffortLevel(value: unknown): value is EffortLevel { return ( value === "low" || @@ -44,7 +38,7 @@ function normalizeModelKey(id: string): NormalizedModelKey { } const lower = original.toLowerCase(); - const contextMatch = lower.match(/\[([^\]]+)\]$/); + const contextMatch = lower.match(/\[([^[\]]+)\]$/); const contextSuffix = contextMatch?.[1]; const withoutContext = contextMatch ? lower.slice(0, contextMatch.index) : lower; const withoutPrefix = withoutContext.startsWith("claude-") @@ -53,7 +47,10 @@ function normalizeModelKey(id: string): NormalizedModelKey { const parts = withoutPrefix.split("-").filter((part) => part.length > 0); const familyPart = parts[0] ?? ""; const family = - familyPart === "opus" || familyPart === "sonnet" || familyPart === "haiku" + familyPart === "fable" || + familyPart === "opus" || + familyPart === "sonnet" || + familyPart === "haiku" ? familyPart : "unknown"; const versionParts: number[] = []; @@ -63,6 +60,10 @@ function normalizeModelKey(id: string): NormalizedModelKey { if (family !== "unknown") { for (const part of parts.slice(1)) { if (/^\d+$/.test(part)) { + if (versionParts.length > 0 && RELEASE_BUILD_TOKEN.test(part)) { + buildParts.push(part); + continue; + } if (versionParts.length < MAX_MODEL_VERSION_PARTS) { const parsed = Number.parseInt(part, 10); if (Number.isFinite(parsed)) { @@ -161,11 +162,13 @@ function humanizeModelId(id: string): string { } const familyLabel = - normalized.family === "opus" - ? "Opus" - : normalized.family === "sonnet" - ? "Sonnet" - : "Haiku"; + normalized.family === "fable" + ? "Fable" + : normalized.family === "opus" + ? "Opus" + : normalized.family === "sonnet" + ? "Sonnet" + : "Haiku"; const versionLabel = normalized.versionParts.length > 0 ? ` ${normalized.versionParts.join(".")}` : ""; const contextLabel = @@ -197,16 +200,22 @@ function resolveCatalogModel( resolvedId: string, requestedId: string | undefined, ): AvailableModel | undefined { - const exactResolved = availableModels.find((entry) => entry.id === resolvedId); + const exactResolved = availableModels.find( + (entry) => entry.id === resolvedId || entry.resolved_model === resolvedId, + ); if (exactResolved) { return exactResolved; } if (requestedId) { - const exactRequested = availableModels.find((entry) => entry.id === requestedId); + const exactRequested = availableModels.find( + (entry) => entry.id === requestedId || entry.resolved_model === requestedId, + ); if ( exactRequested && - modelKeysAreCompatible(exactRequested.id, resolvedId) && + (modelKeysAreCompatible(exactRequested.id, resolvedId) || + (exactRequested.resolved_model !== undefined && + modelKeysAreCompatible(exactRequested.resolved_model, resolvedId))) && !hasVariantSiblingConflict(availableModels, exactRequested.id, resolvedId) ) { return exactRequested; @@ -215,7 +224,9 @@ function resolveCatalogModel( const compatible = availableModels.filter( (entry) => - modelKeysAreCompatible(entry.id, resolvedId) && + (modelKeysAreCompatible(entry.id, resolvedId) || + (entry.resolved_model !== undefined && + modelKeysAreCompatible(entry.resolved_model, resolvedId))) && !hasVariantSiblingConflict(availableModels, entry.id, resolvedId), ); return compatible.length === 1 ? compatible[0] : undefined; @@ -231,13 +242,15 @@ export function mapAvailableModels(models: ModelInfo[] | undefined): AvailableMo return ( typeof entry?.value === "string" && entry.value.trim().length > 0 && - !isUnavailableModelId(entry.value) && typeof entry.displayName === "string" && entry.displayName.trim().length > 0 ); }) .map((entry) => ({ id: entry.value, + ...(typeof entry.resolvedModel === "string" && entry.resolvedModel.trim().length > 0 + ? { resolved_model: entry.resolvedModel.trim() } + : {}), display_name: entry.displayName, supports_effort: entry.supportsEffort === true, supported_effort_levels: Array.isArray(entry.supportedEffortLevels) @@ -264,9 +277,9 @@ export function resolveCurrentModel(session: ModelMetadataSession): CurrentModel session.resolvedRuntimeModelId?.trim() || session.model.trim() || requestedId || - OPUS_MODEL_ALIAS; + DEFAULT_MODEL_ALIAS; const catalogModel = resolveCatalogModel(session.availableModels, resolvedId, requestedId); - const runtimeDisplayId = resolvedId || requestedId || OPUS_MODEL_ALIAS; + const runtimeDisplayId = resolvedId || requestedId || DEFAULT_MODEL_ALIAS; const displayNameShort = shortDisplayNameForModelId(runtimeDisplayId); const displayNameLong = catalogModel?.display_name ?? humanizeModelId(runtimeDisplayId); return { diff --git a/agent-sdk/src/bridge/session_lifecycle.ts b/agent-sdk/src/bridge/session_lifecycle.ts index f6479612..a9f64457 100644 --- a/agent-sdk/src/bridge/session_lifecycle.ts +++ b/agent-sdk/src/bridge/session_lifecycle.ts @@ -90,7 +90,7 @@ const BRIDGE_RUNTIME_PROCESS_NAME = const BRIDGE_RUNTIME_GUARD_PROMPT = `Do not terminate the Claude Rust bridge runtime process \`${BRIDGE_RUNTIME_PROCESS_NAME}\`; ` + "when cleaning up development servers, only stop processes by explicit PIDs you started in this session."; -const STARTUP_FALLBACK_MODEL_ALIAS = "opus"; +const STARTUP_FALLBACK_MODEL_ALIAS = "fable"; function permissionDisplayFromCanUseOptions( options: Parameters[2], @@ -883,6 +883,7 @@ export function buildQueryOptions(params: QueryOptionsBuilderParams) { const systemPrompt = systemPromptFromLaunchSettings(params.launchSettings); const modelOption = startupModelOption(params.launchSettings); const permissionModeOptions = startupPermissionModeOptions(params.launchSettings); + const shouldPassCanUseTool = permissionModeOptions.permissionMode !== "bypassPermissions"; const settings = normalizedSettingsFromLaunchSettings(params.launchSettings); return { cwd: params.cwd, @@ -947,7 +948,7 @@ export function buildQueryOptions(params: QueryOptionsBuilderParams) { settingSources: DEFAULT_SETTING_SOURCES, resume: params.resume, ...(params.resumeSessionAt ? { resumeSessionAt: params.resumeSessionAt } : {}), - canUseTool: params.canUseTool, + ...(shouldPassCanUseTool ? { canUseTool: params.canUseTool } : {}), onElicitation: async (request: { mode?: string; serverName?: string; diff --git a/agent-sdk/src/types.ts b/agent-sdk/src/types.ts index 2beae537..ac4674b0 100644 --- a/agent-sdk/src/types.ts +++ b/agent-sdk/src/types.ts @@ -40,6 +40,7 @@ export type EffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; export interface AvailableModel { id: string; + resolved_model?: string; display_name: string; description?: string; supports_effort: boolean; @@ -489,6 +490,7 @@ export type McpServerConfig = args?: string[]; env?: Record; timeout?: number; + request_timeout_ms?: number; always_load?: boolean; } | { @@ -497,6 +499,7 @@ export type McpServerConfig = headers?: Record; tools?: McpServerToolPolicy[]; timeout?: number; + request_timeout_ms?: number; always_load?: boolean; } | { @@ -505,6 +508,7 @@ export type McpServerConfig = headers?: Record; tools?: McpServerToolPolicy[]; timeout?: number; + request_timeout_ms?: number; always_load?: boolean; }; diff --git a/package-lock.json b/package-lock.json index dcc547a6..c04d688a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.193", + "@anthropic-ai/claude-agent-sdk": "0.3.198", "@anthropic-ai/sdk": "0.106.0", "@modelcontextprotocol/sdk": "1.29.0", "zod": "4.4.3" @@ -26,22 +26,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.193.tgz", - "integrity": "sha512-WzL03VJE1sT0Nz3rEpsYMYR+9n6iyQtLVt7ghMWnYC9pvDsiy6kwcMplZniWSjH8Dm6CfkUBN5t6KB4i/JfouA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.198.tgz", + "integrity": "sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.193", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.193", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.193" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.198" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -50,9 +50,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.193.tgz", - "integrity": "sha512-1hT7b+KIm/3E1OSJofr7PF21Xq2zT1ccnjzuVcWQ5LYXJ09lgCMvA/ZfcDBKuoyCZ4lSnuicKXZ/h5vRbWfqrA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.198.tgz", + "integrity": "sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==", "cpu": [ "arm64" ], @@ -63,9 +63,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.193.tgz", - "integrity": "sha512-9x5Y/L6iwETwEJFmPaYXfsE8q0cVgx75V7nL60HvSzi8K1XQNcG5u53jOzRvqDNah8mvGYOb+9AWrMCSJvmFyA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.198.tgz", + "integrity": "sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==", "cpu": [ "x64" ], @@ -76,9 +76,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.193.tgz", - "integrity": "sha512-gvfD9pKHXWxCkkIX6bC4/FOALTxqHYjAu83iT1bzn5mCv6QWSZRl6WLRRtvKpWtWuY1jpML1lL3YfKADsAX/rA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.198.tgz", + "integrity": "sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==", "cpu": [ "arm64" ], @@ -89,9 +89,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.193.tgz", - "integrity": "sha512-a3qsVTBe4G6ndsVavfIXEVAXLVXM8uvBUNYpm4jKqk2+ovWZQe/96CXOwmerg9U+/bllg4QJ9lSyYCsdtVIr6w==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.198.tgz", + "integrity": "sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==", "cpu": [ "arm64" ], @@ -102,9 +102,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.193.tgz", - "integrity": "sha512-1sz+7cn0iuh0ThInuAYF1jpFvLyOaZ0PZYQIF7eb9JDZbBohUf6INeTCwjAwUL0ASCq2xS7Odu/qDVuTtLTeDA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.198.tgz", + "integrity": "sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==", "cpu": [ "x64" ], @@ -115,9 +115,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.193.tgz", - "integrity": "sha512-DLXlO4tlcWygz0Ft4nu6ai5KssByYt2tOeWdc4dFXKt6uBKXpbZVziUUq3ePO5zuAFyU6w7EjYLv8MMPURbAiQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.198.tgz", + "integrity": "sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==", "cpu": [ "x64" ], @@ -128,9 +128,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.193.tgz", - "integrity": "sha512-36LJKiGuKusgaPTVeh9QanL00UcaE0RcC4pgK800/0SenApbh979ndxI0XKUVfLHzlGkqlhkhT3foMdqS+zx1w==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.198.tgz", + "integrity": "sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==", "cpu": [ "arm64" ], @@ -141,9 +141,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.193", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.193.tgz", - "integrity": "sha512-VyyKZlWQpbD6nkUTeNvgmLvpqt1QaPQQBOC30tbEYwYsV5MC1I35Li0H7nWwngGqPSTtMvHpjpz6Eb2FSTn7/A==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.198.tgz", + "integrity": "sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==", "cpu": [ "x64" ], @@ -442,9 +442,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -459,9 +456,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -476,9 +470,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 02abbda5..a1e5bd9d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "README.md" ], "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.193", + "@anthropic-ai/claude-agent-sdk": "0.3.198", "@anthropic-ai/sdk": "0.106.0", "@modelcontextprotocol/sdk": "1.29.0", "zod": "4.4.3" diff --git a/src/agent/client.rs b/src/agent/client.rs index 7261badd..d01fbf9f 100644 --- a/src/agent/client.rs +++ b/src/agent/client.rs @@ -813,6 +813,7 @@ mod tests { headers: BTreeMap::new(), tools: Vec::new(), timeout: Some(5000), + request_timeout_ms: Some(30000), always_load: Some(true), }, )]); diff --git a/src/agent/model/catalog.rs b/src/agent/model/catalog.rs index 1ee26a81..8c6177ac 100644 --- a/src/agent/model/catalog.rs +++ b/src/agent/model/catalog.rs @@ -142,6 +142,7 @@ impl EffortLevel { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AvailableModel { pub id: String, + pub resolved_model: Option, pub display_name: String, pub description: Option, pub supports_effort: bool, @@ -156,6 +157,7 @@ impl AvailableModel { pub fn new(id: impl Into, display_name: impl Into) -> Self { Self { id: id.into(), + resolved_model: None, display_name: display_name.into(), description: None, supports_effort: false, @@ -172,6 +174,12 @@ impl AvailableModel { self } + #[must_use] + pub fn resolved_model(mut self, resolved_model: impl Into) -> Self { + self.resolved_model = Some(resolved_model.into()); + self + } + #[must_use] pub fn supports_effort(mut self, supports_effort: bool) -> Self { self.supports_effort = supports_effort; diff --git a/src/agent/model/mcp.rs b/src/agent/model/mcp.rs index 3d6b7834..02746f62 100644 --- a/src/agent/model/mcp.rs +++ b/src/agent/model/mcp.rs @@ -117,6 +117,7 @@ pub enum McpServerStatusConfig { args: Vec, env: BTreeMap, timeout: Option, + request_timeout_ms: Option, always_load: Option, }, Sse { @@ -124,6 +125,7 @@ pub enum McpServerStatusConfig { headers: BTreeMap, tools: Vec, timeout: Option, + request_timeout_ms: Option, always_load: Option, }, Http { @@ -131,6 +133,7 @@ pub enum McpServerStatusConfig { headers: BTreeMap, tools: Vec, timeout: Option, + request_timeout_ms: Option, always_load: Option, }, Sdk { diff --git a/src/agent/types.rs b/src/agent/types.rs index 21ccfe2f..ab6eca35 100644 --- a/src/agent/types.rs +++ b/src/agent/types.rs @@ -102,6 +102,7 @@ pub enum EffortLevel { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AvailableModel { pub id: String, + pub resolved_model: Option, pub display_name: String, pub description: Option, pub supports_effort: bool, @@ -825,6 +826,8 @@ pub enum McpServerConfig { #[serde(skip_serializing_if = "Option::is_none")] timeout: Option, #[serde(skip_serializing_if = "Option::is_none")] + request_timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] always_load: Option, }, Sse { @@ -836,6 +839,8 @@ pub enum McpServerConfig { #[serde(skip_serializing_if = "Option::is_none")] timeout: Option, #[serde(skip_serializing_if = "Option::is_none")] + request_timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] always_load: Option, }, Http { @@ -847,6 +852,8 @@ pub enum McpServerConfig { #[serde(skip_serializing_if = "Option::is_none")] timeout: Option, #[serde(skip_serializing_if = "Option::is_none")] + request_timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] always_load: Option, }, } @@ -861,6 +868,7 @@ pub enum McpServerStatusConfig { #[serde(default)] env: BTreeMap, timeout: Option, + request_timeout_ms: Option, always_load: Option, }, Sse { @@ -870,6 +878,7 @@ pub enum McpServerStatusConfig { #[serde(default)] tools: Vec, timeout: Option, + request_timeout_ms: Option, always_load: Option, }, Http { @@ -879,6 +888,7 @@ pub enum McpServerStatusConfig { #[serde(default)] tools: Vec, timeout: Option, + request_timeout_ms: Option, always_load: Option, }, Sdk { @@ -1121,17 +1131,25 @@ mod tests { } ], "timeout": 5000, + "request_timeout_ms": 30000, "always_load": true }, "tools": [] })) .expect("deserialize MCP server status"); - let Some(McpServerStatusConfig::Http { tools, timeout, always_load, .. }) = server.config + let Some(McpServerStatusConfig::Http { + tools, + timeout, + request_timeout_ms, + always_load, + .. + }) = server.config else { panic!("expected http MCP config"); }; assert_eq!(timeout, Some(5000)); + assert_eq!(request_timeout_ms, Some(30000)); assert_eq!(always_load, Some(true)); assert_eq!(tools.len(), 3); assert_eq!(tools[0].permission_policy, Some(McpServerToolPermissionPolicy::Ask)); diff --git a/src/agent/wire.rs b/src/agent/wire.rs index eb80d632..58d00726 100644 --- a/src/agent/wire.rs +++ b/src/agent/wire.rs @@ -698,6 +698,7 @@ mod tests { }, ], timeout: Some(5000), + request_timeout_ms: Some(30000), always_load: Some(true), }, )]), @@ -728,6 +729,7 @@ mod tests { } ], "timeout": 5000, + "request_timeout_ms": 30000, "always_load": true } } diff --git a/src/app/config/edit.rs b/src/app/config/edit.rs index 5b346813..c094eea6 100644 --- a/src/app/config/edit.rs +++ b/src/app/config/edit.rs @@ -1,10 +1,10 @@ use super::resolve::{language_input_validation_message, normalized_language_value}; use super::{ - AddMarketplaceOverlayState, ConfigOverlayState, ConfirmationAction, DefaultPermissionMode, - LanguageOverlayState, ModelAndEffortOverlayState, OPUS_MODEL_ALIAS_ID, OutputStyle, - OutputStyleOverlayState, OverlayFocus, PendingSessionTitleChangeKind, - PendingSessionTitleChangeState, PreferredNotifChannel, ResolvedChoice, ResolvedSettingValue, - SessionRenameOverlayState, SettingFile, SettingId, SettingOptions, SettingSpec, + AddMarketplaceOverlayState, ConfigOverlayState, ConfirmationAction, DEFAULT_MODEL_ALIAS_ID, + DefaultPermissionMode, LanguageOverlayState, ModelOverlayState, OutputStyle, + OutputStyleOverlayState, PendingSessionTitleChangeKind, PendingSessionTitleChangeState, + PreferredNotifChannel, ResolvedChoice, ResolvedSettingValue, SessionRenameOverlayState, + SettingFile, SettingId, SettingOptions, SettingSpec, ThinkingEffortOverlayState, resolved_setting, setting_display_value, setting_spec, store, }; use crate::agent::model::EffortLevel; @@ -78,11 +78,9 @@ pub(super) fn activate_setting(app: &mut App, spec: &SettingSpec) { }); } SettingId::Language => open_language_overlay(app), - SettingId::Model => open_model_and_effort_overlay(app, OverlayFocus::Model), + SettingId::Model => open_model_overlay(app), SettingId::OutputStyle => open_output_style_overlay(app), - SettingId::ThinkingEffort => { - open_model_and_effort_overlay(app, OverlayFocus::Effort); - } + SettingId::ThinkingEffort => open_thinking_effort_overlay(app), SettingId::Theme | SettingId::Notifications | SettingId::EditorMode => { cycle_static_enum(app, spec, 1); } @@ -134,13 +132,18 @@ pub(super) fn handle_overlay_key(app: &mut App, key: KeyEvent) { return; } match app.config.overlay.clone() { - Some(ConfigOverlayState::ModelAndEffort(_)) => match (key.code, key.modifiers) { - (KeyCode::Enter, KeyModifiers::NONE) => confirm_model_and_effort_overlay(app), + Some(ConfigOverlayState::Model(_)) => match (key.code, key.modifiers) { + (KeyCode::Enter, KeyModifiers::NONE) => confirm_model_overlay(app), + (KeyCode::Esc, KeyModifiers::NONE) => app.config.clear_overlay(), + (KeyCode::Up, KeyModifiers::NONE) => move_model_overlay_selection(app, -1), + (KeyCode::Down, KeyModifiers::NONE) => move_model_overlay_selection(app, 1), + _ => {} + }, + Some(ConfigOverlayState::ThinkingEffort(_)) => match (key.code, key.modifiers) { + (KeyCode::Enter, KeyModifiers::NONE) => confirm_thinking_effort_overlay(app), (KeyCode::Esc, KeyModifiers::NONE) => app.config.clear_overlay(), - (KeyCode::Tab | KeyCode::Right | KeyCode::Left, KeyModifiers::NONE) - | (KeyCode::BackTab, _) => toggle_model_and_effort_focus(app), - (KeyCode::Up, KeyModifiers::NONE) => move_overlay_selection(app, -1), - (KeyCode::Down, KeyModifiers::NONE) => move_overlay_selection(app, 1), + (KeyCode::Up, KeyModifiers::NONE) => move_effort_overlay_selection(app, -1), + (KeyCode::Down, KeyModifiers::NONE) => move_effort_overlay_selection(app, 1), _ => {} }, Some(ConfigOverlayState::OutputStyle(_)) => match (key.code, key.modifiers) { @@ -193,7 +196,8 @@ pub(super) fn handle_overlay_paste(app: &mut App, text: &str) -> bool { true } Some( - ConfigOverlayState::ModelAndEffort(_) + ConfigOverlayState::Model(_) + | ConfigOverlayState::ThinkingEffort(_) | ConfigOverlayState::OutputStyle(_) | ConfigOverlayState::InstalledPluginActions(_) | ConfigOverlayState::PluginInstallActions(_) @@ -294,17 +298,11 @@ fn confirm_confirmation_overlay(app: &mut App) { } } -pub(crate) fn model_supports_effort(app: &App, model_id: &str) -> bool { +pub(crate) fn supported_effort_levels_for_model(app: &App, model_id: &str) -> Vec { model_overlay_options(app) .into_iter() - .find(|option| option.id == model_id) - .is_none_or(|option| option.supports_effort) -} - -pub(crate) fn supported_effort_levels_for_model(app: &App, model_id: &str) -> Vec { - model_overlay_options(app).into_iter().find(|option| option.id == model_id).map_or_else( - Vec::new, - |option| { + .find(|option| option.matches_model_id(model_id)) + .map_or_else(Vec::new, |option| { if option.supports_effort { option .supported_effort_levels @@ -314,13 +312,13 @@ pub(crate) fn supported_effort_levels_for_model(app: &App, model_id: &str) -> Ve } else { Vec::new() } - }, - ) + }) } #[derive(Debug, Clone)] pub(crate) struct OverlayModelOption { pub id: String, + pub resolved_model: Option, pub display_name: String, pub description: Option, pub supports_effort: bool, @@ -330,12 +328,20 @@ pub(crate) struct OverlayModelOption { pub supports_auto_mode: Option, } +impl OverlayModelOption { + #[must_use] + pub fn matches_model_id(&self, model_id: &str) -> bool { + self.id == model_id || self.resolved_model.as_deref() == Some(model_id) + } +} + pub(crate) fn model_overlay_options(app: &App) -> Vec { let mut options = app .available_models .iter() .map(|model| OverlayModelOption { id: model.id.clone(), + resolved_model: model.resolved_model.clone(), display_name: model.display_name.clone(), description: model.description.clone(), supports_effort: model.supports_effort, @@ -463,18 +469,29 @@ const fn default_static_value(setting_id: SettingId) -> &'static str { } } -fn open_model_and_effort_overlay(app: &mut App, focus: OverlayFocus) { +fn open_model_overlay(app: &mut App) { let options = model_overlay_options(app); let current_model = app .config .model_effective() - .filter(|value| options.iter().any(|option| option.id == *value)) - .unwrap_or_else(|| OPUS_MODEL_ALIAS_ID.to_owned()); + .and_then(|value| { + options + .iter() + .find(|option| option.matches_model_id(&value)) + .map(|option| option.id.clone()) + }) + .unwrap_or_else(|| DEFAULT_MODEL_ALIAS_ID.to_owned()); + app.config.replace_overlay(ConfigOverlayState::Model(ModelOverlayState { + selected_model: current_model, + })); + app.config.last_error = None; +} + +fn open_thinking_effort_overlay(app: &mut App) { + let current_model = selected_model_for_effort(app); let current_effort = app.config.thinking_effort_effective(); let selected_effort = overlay_effort_for_model(app, ¤t_model, current_effort); - app.config.replace_overlay(ConfigOverlayState::ModelAndEffort(ModelAndEffortOverlayState { - focus, - selected_model: current_model, + app.config.replace_overlay(ConfigOverlayState::ThinkingEffort(ThinkingEffortOverlayState { selected_effort, })); app.config.last_error = None; @@ -550,27 +567,10 @@ pub(super) fn generate_session_title(app: &mut App) { } } -fn toggle_model_and_effort_focus(app: &mut App) { - let Some(overlay) = app.config.model_and_effort_overlay_mut() else { +fn move_model_overlay_selection(app: &mut App, delta: isize) { + let Some(overlay) = app.config.model_overlay().cloned() else { return; }; - overlay.focus = match overlay.focus { - OverlayFocus::Model => OverlayFocus::Effort, - OverlayFocus::Effort => OverlayFocus::Model, - }; -} - -fn move_overlay_selection(app: &mut App, delta: isize) { - let Some(overlay) = app.config.model_and_effort_overlay().cloned() else { - return; - }; - match overlay.focus { - OverlayFocus::Model => move_overlay_model_selection(app, &overlay, delta), - OverlayFocus::Effort => move_overlay_effort_selection(app, &overlay, delta), - } -} - -fn move_overlay_model_selection(app: &mut App, overlay: &ModelAndEffortOverlayState, delta: isize) { let options = model_overlay_options(app); if options.is_empty() { return; @@ -579,35 +579,47 @@ fn move_overlay_model_selection(app: &mut App, overlay: &ModelAndEffortOverlaySt options.iter().position(|option| option.id == overlay.selected_model).unwrap_or(0); let next_index = step_index_clamped(current_index, delta, options.len()); let next_model = &options[next_index]; - let next_effort = overlay_effort_for_model(app, &next_model.id, overlay.selected_effort); - if let Some(state) = app.config.model_and_effort_overlay_mut() { + if let Some(state) = app.config.model_overlay_mut() { state.selected_model.clone_from(&next_model.id); - state.selected_effort = next_effort; } } -fn move_overlay_effort_selection( - app: &mut App, - overlay: &ModelAndEffortOverlayState, - delta: isize, -) { - let levels = supported_effort_levels_for_model(app, &overlay.selected_model); +fn move_effort_overlay_selection(app: &mut App, delta: isize) { + let Some(overlay) = app.config.thinking_effort_overlay().copied() else { + return; + }; + let current_model = selected_model_for_effort(app); + let levels = supported_effort_levels_for_model(app, ¤t_model); if levels.is_empty() { return; } let current_index = levels.iter().position(|level| *level == overlay.selected_effort).unwrap_or(0); let next_index = step_index_clamped(current_index, delta, levels.len()); - if let Some(state) = app.config.model_and_effort_overlay_mut() { + if let Some(state) = app.config.thinking_effort_overlay_mut() { state.selected_effort = levels[next_index]; } } -fn confirm_model_and_effort_overlay(app: &mut App) { - let Some(overlay) = app.config.model_and_effort_overlay().cloned() else { +fn confirm_model_overlay(app: &mut App) { + let Some(overlay) = app.config.model_overlay().cloned() else { + return; + }; + if persist_model_change(app, &overlay.selected_model) { + app.config.clear_overlay(); + } +} + +fn confirm_thinking_effort_overlay(app: &mut App) { + let Some(overlay) = app.config.thinking_effort_overlay().copied() else { return; }; - if persist_model_and_effort_change(app, &overlay.selected_model, overlay.selected_effort) { + let current_model = selected_model_for_effort(app); + if supported_effort_levels_for_model(app, ¤t_model).is_empty() { + app.config.clear_overlay(); + return; + } + if persist_thinking_effort_change(app, overlay.selected_effort) { app.config.clear_overlay(); } } @@ -753,16 +765,35 @@ fn confirm_session_rename_overlay(app: &mut App) { } } -fn persist_model_and_effort_change(app: &mut App, model: &str, effort: EffortLevel) -> bool { +fn persist_model_change(app: &mut App, model: &str) -> bool { let Some(path) = app.config.path_for(SettingFile::Settings).cloned() else { app.config.set_overlay_error("Settings paths are not available"); return false; }; let mut next_document = app.config.committed_settings_document.clone(); store::set_model(&mut next_document, Some(model)); - if model_supports_effort(app, model) - && store::set_thinking_effort_level(&mut next_document, effort).is_err() - { + match store::save(&path, &next_document) { + Ok(()) => { + app.config.committed_settings_document = next_document; + app.reconcile_runtime_from_persisted_settings_change(); + app.config.last_error = None; + app.config.status_message = None; + true + } + Err(err) => { + app.config.set_overlay_error(err); + false + } + } +} + +fn persist_thinking_effort_change(app: &mut App, effort: EffortLevel) -> bool { + let Some(path) = app.config.path_for(SettingFile::Settings).cloned() else { + app.config.set_overlay_error("Settings paths are not available"); + return false; + }; + let mut next_document = app.config.committed_settings_document.clone(); + if store::set_thinking_effort_level(&mut next_document, effort).is_err() { app.config.set_overlay_error(format!( "{} cannot be saved as a default thinking effort. Use /effort {} for the active session.", effort.label(), @@ -775,7 +806,7 @@ fn persist_model_and_effort_change(app: &mut App, model: &str, effort: EffortLev app.config.committed_settings_document = next_document; app.reconcile_runtime_from_persisted_settings_change(); app.config.last_error = None; - app.config.status_message = None; + app.config.status_message = Some(format!("Saved Thinking effort: {}", effort.label())); true } Err(err) => { @@ -785,6 +816,10 @@ fn persist_model_and_effort_change(app: &mut App, model: &str, effort: EffortLev } } +fn selected_model_for_effort(app: &App) -> String { + app.config.model_effective().unwrap_or_else(|| DEFAULT_MODEL_ALIAS_ID.to_owned()) +} + fn overlay_effort_for_model(app: &App, model_id: &str, current: EffortLevel) -> EffortLevel { let supported = supported_effort_levels_for_model(app, model_id); if supported.is_empty() || supported.contains(¤t) { diff --git a/src/app/config/mcp/removal.rs b/src/app/config/mcp/removal.rs index 5fde8508..6fa2c09d 100644 --- a/src/app/config/mcp/removal.rs +++ b/src/app/config/mcp/removal.rs @@ -194,33 +194,51 @@ fn dynamic_mcp_server_config_for_set_servers( }; match config { - model::McpServerStatusConfig::Stdio { command, args, env, timeout, always_load } => { - Ok(types::McpServerConfig::Stdio { - command: command.clone(), - args: args.clone(), - env: env.clone(), - timeout: *timeout, - always_load: *always_load, - }) - } - model::McpServerStatusConfig::Sse { url, headers, tools, timeout, always_load } => { - Ok(types::McpServerConfig::Sse { - url: url.clone(), - headers: headers.clone(), - tools: tools.iter().map(dynamic_mcp_tool_policy_for_set_servers).collect(), - timeout: *timeout, - always_load: *always_load, - }) - } - model::McpServerStatusConfig::Http { url, headers, tools, timeout, always_load } => { - Ok(types::McpServerConfig::Http { - url: url.clone(), - headers: headers.clone(), - tools: tools.iter().map(dynamic_mcp_tool_policy_for_set_servers).collect(), - timeout: *timeout, - always_load: *always_load, - }) - } + model::McpServerStatusConfig::Stdio { + command, + args, + env, + timeout, + request_timeout_ms, + always_load, + } => Ok(types::McpServerConfig::Stdio { + command: command.clone(), + args: args.clone(), + env: env.clone(), + timeout: *timeout, + request_timeout_ms: *request_timeout_ms, + always_load: *always_load, + }), + model::McpServerStatusConfig::Sse { + url, + headers, + tools, + timeout, + request_timeout_ms, + always_load, + } => Ok(types::McpServerConfig::Sse { + url: url.clone(), + headers: headers.clone(), + tools: tools.iter().map(dynamic_mcp_tool_policy_for_set_servers).collect(), + timeout: *timeout, + request_timeout_ms: *request_timeout_ms, + always_load: *always_load, + }), + model::McpServerStatusConfig::Http { + url, + headers, + tools, + timeout, + request_timeout_ms, + always_load, + } => Ok(types::McpServerConfig::Http { + url: url.clone(), + headers: headers.clone(), + tools: tools.iter().map(dynamic_mcp_tool_policy_for_set_servers).collect(), + timeout: *timeout, + request_timeout_ms: *request_timeout_ms, + always_load: *always_load, + }), model::McpServerStatusConfig::Sdk { .. } => Err(format!( "Cannot safely preserve dynamic MCP server {} because SDK-server instances cannot be represented by the Rust bridge.", server.name diff --git a/src/app/config/mcp/snapshot.rs b/src/app/config/mcp/snapshot.rs index e74374bc..39bdc893 100644 --- a/src/app/config/mcp/snapshot.rs +++ b/src/app/config/mcp/snapshot.rs @@ -127,7 +127,8 @@ fn clear_missing_mcp_server_overlays(app: &mut App) { Some(overlay.request.server_name.as_str()) } Some( - ConfigOverlayState::ModelAndEffort(_) + ConfigOverlayState::Model(_) + | ConfigOverlayState::ThinkingEffort(_) | ConfigOverlayState::OutputStyle(_) | ConfigOverlayState::Language(_) | ConfigOverlayState::SessionRename(_) diff --git a/src/app/config/mod.rs b/src/app/config/mod.rs index 416d6f99..28a6a266 100644 --- a/src/app/config/mod.rs +++ b/src/app/config/mod.rs @@ -36,8 +36,10 @@ pub(crate) use mcp::{ }; pub use overlays::*; pub(crate) use resolve::language_input_validation_message; +#[allow(unused_imports)] pub(crate) use settings::{ - DEFAULT_PERMISSION_OPTIONS, LANGUAGE_MAX_CHARS, LANGUAGE_MIN_CHARS, OPUS_MODEL_ALIAS_ID, + DEFAULT_MODEL_ALIAS_ID, DEFAULT_MODEL_ALIAS_LABEL, DEFAULT_PERMISSION_OPTIONS, + LANGUAGE_MAX_CHARS, LANGUAGE_MIN_CHARS, }; #[allow(unused_imports)] pub use settings::{ diff --git a/src/app/config/overlays.rs b/src/app/config/overlays.rs index 9570e591..f9432615 100644 --- a/src/app/config/overlays.rs +++ b/src/app/config/overlays.rs @@ -7,16 +7,13 @@ use super::mcp::{ }; use super::prelude::*; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OverlayFocus { - Model, - Effort, -} - #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ModelAndEffortOverlayState { - pub focus: OverlayFocus, +pub struct ModelOverlayState { pub selected_model: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ThinkingEffortOverlayState { pub selected_effort: EffortLevel, } @@ -180,7 +177,8 @@ pub struct ConfirmationOverlayState { #[derive(Debug, Clone, PartialEq)] pub enum ConfigOverlayState { - ModelAndEffort(ModelAndEffortOverlayState), + Model(ModelOverlayState), + ThinkingEffort(ThinkingEffortOverlayState), OutputStyle(OutputStyleOverlayState), Language(LanguageOverlayState), SessionRename(SessionRenameOverlayState), diff --git a/src/app/config/resolve.rs b/src/app/config/resolve.rs index 141fce4a..3d83bba6 100644 --- a/src/app/config/resolve.rs +++ b/src/app/config/resolve.rs @@ -1,6 +1,6 @@ use super::{ - DEFAULT_PERMISSION_OPTIONS, DefaultPermissionMode, LANGUAGE_MAX_CHARS, LANGUAGE_MIN_CHARS, - OPUS_MODEL_ALIAS_ID, OutputStyle, PreferredNotifChannel, ResolvedChoice, ResolvedSetting, + DEFAULT_MODEL_ALIAS_ID, DEFAULT_PERMISSION_OPTIONS, DefaultPermissionMode, LANGUAGE_MAX_CHARS, + LANGUAGE_MIN_CHARS, OutputStyle, PreferredNotifChannel, ResolvedChoice, ResolvedSetting, ResolvedSettingValue, RuntimeCatalogKind, SettingId, SettingOptions, SettingSpec, SettingValidation, store, }; @@ -115,8 +115,10 @@ fn resolve_model_setting( }, Ok(store::PersistedSettingValue::String(value)) if available_models.is_empty() - || value == OPUS_MODEL_ALIAS_ID - || available_models.iter().any(|model| model.id == value) => + || value == DEFAULT_MODEL_ALIAS_ID + || available_models.iter().any(|model| { + model.id == value || model.resolved_model.as_deref() == Some(value.as_str()) + }) => { ResolvedSetting { value: ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)), @@ -143,7 +145,9 @@ fn option_exists(spec: &SettingSpec, value: &str) -> bool { SettingOptions::RuntimeCatalog(RuntimeCatalogKind::PermissionModes) => { DEFAULT_PERMISSION_OPTIONS.iter().any(|option| option.stored == value) } - SettingOptions::RuntimeCatalog(RuntimeCatalogKind::Models) => value == OPUS_MODEL_ALIAS_ID, + SettingOptions::RuntimeCatalog(RuntimeCatalogKind::Models) => { + value == DEFAULT_MODEL_ALIAS_ID + } SettingOptions::None => false, } } diff --git a/src/app/config/settings.rs b/src/app/config/settings.rs index a290672c..18ef4abd 100644 --- a/src/app/config/settings.rs +++ b/src/app/config/settings.rs @@ -312,8 +312,8 @@ const EDITOR_MODE_OPTIONS: &[SettingOption] = &[ SettingOption { stored: "default", label: "Default" }, SettingOption { stored: "vim", label: "Vim" }, ]; -pub(crate) const OPUS_MODEL_ALIAS_ID: &str = "opus"; -pub(crate) const OPUS_MODEL_ALIAS_LABEL: &str = "Opus"; +pub(crate) const DEFAULT_MODEL_ALIAS_ID: &str = "fable"; +pub(crate) const DEFAULT_MODEL_ALIAS_LABEL: &str = "Fable 5"; pub(crate) const LANGUAGE_MIN_CHARS: usize = 2; pub(crate) const LANGUAGE_MAX_CHARS: usize = 30; @@ -336,7 +336,7 @@ const CONFIG_SETTINGS: [SettingSpec; 14] = [ id: SettingId::Model, entry_id: "A19", label: "Model", - description: "Sets the model for new sessions and opens the combined model and thinking effort picker.", + description: "Sets the model for new sessions.", file: SettingFile::Settings, json_path: &["model"], kind: SettingKind::DynamicEnum, @@ -582,7 +582,7 @@ pub fn setting_display_value(app: &App, spec: &SettingSpec, resolved: &ResolvedS } } (ResolvedSettingValue::Choice(ResolvedChoice::Automatic), SettingId::Model) => { - OPUS_MODEL_ALIAS_LABEL.to_owned() + DEFAULT_MODEL_ALIAS_LABEL.to_owned() } (ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)), SettingId::Model) => { model_status_label(Some(value), app) @@ -630,7 +630,7 @@ pub fn setting_detail_options(app: &App, spec: &SettingSpec) -> Vec { SettingOptions::RuntimeCatalog(RuntimeCatalogKind::Models) => { if app.available_models.is_empty() { vec![ - OPUS_MODEL_ALIAS_LABEL.to_owned(), + DEFAULT_MODEL_ALIAS_LABEL.to_owned(), "Connect to load available models".to_owned(), ] } else { diff --git a/src/app/config/state.rs b/src/app/config/state.rs index 288381cb..e9d853de 100644 --- a/src/app/config/state.rs +++ b/src/app/config/state.rs @@ -93,7 +93,7 @@ impl ConfigState { .value { ResolvedSettingValue::Choice(ResolvedChoice::Automatic) => { - Some(OPUS_MODEL_ALIAS_ID.to_owned()) + Some(DEFAULT_MODEL_ALIAS_ID.to_owned()) } ResolvedSettingValue::Choice(ResolvedChoice::Stored(value)) => Some(value), ResolvedSettingValue::Bool(_) | ResolvedSettingValue::Text(_) => None, @@ -173,16 +173,33 @@ impl ConfigState { } #[must_use] - pub fn model_and_effort_overlay(&self) -> Option<&ModelAndEffortOverlayState> { - if let Some(ConfigOverlayState::ModelAndEffort(overlay)) = &self.overlay { + pub fn model_overlay(&self) -> Option<&ModelOverlayState> { + if let Some(ConfigOverlayState::Model(overlay)) = &self.overlay { Some(overlay) } else { None } } - pub fn model_and_effort_overlay_mut(&mut self) -> Option<&mut ModelAndEffortOverlayState> { - if let Some(ConfigOverlayState::ModelAndEffort(overlay)) = &mut self.overlay { + pub fn model_overlay_mut(&mut self) -> Option<&mut ModelOverlayState> { + if let Some(ConfigOverlayState::Model(overlay)) = &mut self.overlay { + Some(overlay) + } else { + None + } + } + + #[must_use] + pub fn thinking_effort_overlay(&self) -> Option<&ThinkingEffortOverlayState> { + if let Some(ConfigOverlayState::ThinkingEffort(overlay)) = &self.overlay { + Some(overlay) + } else { + None + } + } + + pub fn thinking_effort_overlay_mut(&mut self) -> Option<&mut ThinkingEffortOverlayState> { + if let Some(ConfigOverlayState::ThinkingEffort(overlay)) = &mut self.overlay { Some(overlay) } else { None diff --git a/src/app/config/status.rs b/src/app/config/status.rs index 49e4a4a0..2fd94b79 100644 --- a/src/app/config/status.rs +++ b/src/app/config/status.rs @@ -36,14 +36,14 @@ pub fn request_status_snapshot_if_needed(app: &App) { pub(crate) fn model_status_label(model: Option<&str>, app: &App) -> String { match model { - None => OPUS_MODEL_ALIAS_LABEL.to_owned(), + None => DEFAULT_MODEL_ALIAS_LABEL.to_owned(), Some(model_id) => model_overlay_options(app) .into_iter() - .find(|candidate| candidate.id == model_id) + .find(|candidate| candidate.matches_model_id(model_id)) .map_or_else( || { - if model_id == OPUS_MODEL_ALIAS_ID { - OPUS_MODEL_ALIAS_LABEL.to_owned() + if model_id == DEFAULT_MODEL_ALIAS_ID { + DEFAULT_MODEL_ALIAS_LABEL.to_owned() } else { model_id.to_owned() } diff --git a/src/app/config/tests.rs b/src/app/config/tests.rs index f4ec4ccb..986da7c0 100644 --- a/src/app/config/tests.rs +++ b/src/app/config/tests.rs @@ -126,6 +126,7 @@ fn dynamic_http_mcp_server_status(name: &str, url: &str) -> McpServerStatus { url: url.to_owned(), headers: BTreeMap::new(), timeout: None, + request_timeout_ms: None, tools: Vec::new(), always_load: None, }), @@ -958,6 +959,7 @@ fn thinking_effort_rejects_max_as_persisted_setting() { fn model_effort_overlay_uses_persistable_effort_levels_when_runtime_omits_level_list() { let (_dir, mut app) = open_settings_test_app(); app.available_models = vec![AvailableModel::new("opus", "Opus").supports_effort(true)]; + store::set_model(&mut app.config.committed_settings_document, Some("opus")); select_setting(&mut app, SettingId::ThinkingEffort); handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); @@ -965,7 +967,7 @@ fn model_effort_overlay_uses_persistable_effort_levels_when_runtime_omits_level_ handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); } - let overlay = app.config.model_and_effort_overlay().expect("model and effort overlay"); + let overlay = app.config.thinking_effort_overlay().expect("thinking effort overlay"); assert_eq!(overlay.selected_effort, EffortLevel::XHigh); } @@ -977,6 +979,7 @@ fn model_effort_overlay_filters_session_only_max_from_runtime_levels() { .supports_effort(true) .supported_effort_levels(EffortLevel::ALL.to_vec()), ]; + store::set_model(&mut app.config.committed_settings_document, Some("opus")); select_setting(&mut app, SettingId::ThinkingEffort); handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); @@ -984,10 +987,63 @@ fn model_effort_overlay_filters_session_only_max_from_runtime_levels() { handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); } - let overlay = app.config.model_and_effort_overlay().expect("model and effort overlay"); + let overlay = app.config.thinking_effort_overlay().expect("thinking effort overlay"); assert_eq!(overlay.selected_effort, EffortLevel::XHigh); } +#[test] +fn model_overlay_confirm_persists_model_without_rewriting_effort() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + let mut app = open_settings_app_in_dir(&dir); + app.available_models = vec![ + AvailableModel::new("fable", "Fable 5").supports_effort(true), + AvailableModel::new("opus", "Opus").supports_effort(true), + ]; + store::set_model(&mut app.config.committed_settings_document, Some("fable")); + store::set_thinking_effort_level( + &mut app.config.committed_settings_document, + EffortLevel::High, + ) + .expect("high is persistable"); + + select_setting(&mut app, SettingId::Model); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!(app.config.model_overlay().is_some()); + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let saved = read_json_file(&path); + assert_eq!(json_at(&saved, &["model"]), Some(&Value::String("opus".to_owned()))); + assert_eq!(json_at(&saved, &["effortLevel"]), Some(&Value::String("high".to_owned()))); + assert_eq!(app.config.thinking_effort_effective(), EffortLevel::High); + assert!(app.config.overlay.is_none()); +} + +#[test] +fn thinking_effort_overlay_confirm_persists_effort_without_rewriting_model() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join(".claude").join("settings.json"); + let mut app = open_settings_app_in_dir(&dir); + app.available_models = vec![ + AvailableModel::new("fable", "Fable 5") + .supports_effort(true) + .supported_effort_levels(EffortLevel::PERSISTABLE_SETTINGS.to_vec()), + ]; + store::set_model(&mut app.config.committed_settings_document, Some("fable")); + + select_setting(&mut app, SettingId::ThinkingEffort); + handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!(app.config.thinking_effort_overlay().is_some()); + handle_key(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let saved = read_json_file(&path); + assert_eq!(json_at(&saved, &["model"]), Some(&Value::String("fable".to_owned()))); + assert_eq!(json_at(&saved, &["effortLevel"]), Some(&Value::String("high".to_owned()))); + assert!(app.config.overlay.is_none()); +} + #[test] fn respect_gitignore_toggles_in_preferences_document() { let (_dir, mut app) = open_settings_test_app(); @@ -1058,7 +1114,20 @@ fn resolved_model_uses_runtime_fallback_when_catalog_rejects_value() { let resolved = resolved_setting(&app, setting_spec(SettingId::Model)); assert_eq!(resolved.validation, SettingValidation::UnavailableOption); - assert_eq!(setting_display_value(&app, setting_spec(SettingId::Model), &resolved), "Opus"); + assert_eq!(setting_display_value(&app, setting_spec(SettingId::Model), &resolved), "Fable 5"); +} + +#[test] +fn resolved_model_accepts_runtime_alias_resolved_model_id() { + let mut app = App::test_default(); + app.available_models = + vec![AvailableModel::new("fable", "Fable 5").resolved_model("claude-fable-5")]; + store::set_model(&mut app.config.committed_settings_document, Some("claude-fable-5")); + + let resolved = resolved_setting(&app, setting_spec(SettingId::Model)); + + assert_eq!(resolved.validation, SettingValidation::Valid); + assert_eq!(setting_display_value(&app, setting_spec(SettingId::Model), &resolved), "Fable 5"); } #[test] @@ -1507,6 +1576,7 @@ fn mcp_enter_opens_details_overlay_instead_of_closing_config() { args: vec!["@modelcontextprotocol/server-filesystem".to_owned()], env: BTreeMap::new(), timeout: None, + request_timeout_ms: None, always_load: None, }), scope: Some("project".to_owned()), @@ -1898,6 +1968,7 @@ fn dynamic_mcp_config_remove_uses_sdk_set_servers_and_preserves_other_dynamic_se url: "https://search.example.test/mcp".to_owned(), headers: std::collections::BTreeMap::new(), timeout: None, + request_timeout_ms: None, tools: Vec::new(), always_load: None, }), @@ -1909,6 +1980,7 @@ fn dynamic_mcp_config_remove_uses_sdk_set_servers_and_preserves_other_dynamic_se url: "https://example.test/mcp".to_owned(), headers: keep_headers.clone(), timeout: Some(5_000), + request_timeout_ms: Some(30_000), tools: vec![crate::agent::model::McpServerToolPolicy { name: "search".to_owned(), permission_policy: Some( @@ -1945,6 +2017,7 @@ fn dynamic_mcp_config_remove_uses_sdk_set_servers_and_preserves_other_dynamic_se org_max_permission: Some(crate::agent::types::McpServerOrgMaxPermission::Ask), }], timeout: Some(5_000), + request_timeout_ms: Some(30_000), always_load: Some(true), }) ); @@ -1998,6 +2071,7 @@ fn dynamic_mcp_config_remove_waits_for_snapshot_when_sdk_result_does_not_name_se url: "https://search.example.test/mcp".to_owned(), headers: BTreeMap::new(), timeout: None, + request_timeout_ms: None, tools: Vec::new(), always_load: None, }), @@ -2045,6 +2119,7 @@ fn dynamic_mcp_config_remove_fails_when_confirming_snapshot_still_contains_serve url: "https://search.example.test/mcp".to_owned(), headers: BTreeMap::new(), timeout: None, + request_timeout_ms: None, tools: Vec::new(), always_load: None, }), @@ -2084,6 +2159,7 @@ fn dynamic_mcp_config_remove_succeeds_when_confirming_snapshot_proves_absence() url: "https://search.example.test/mcp".to_owned(), headers: BTreeMap::new(), timeout: None, + request_timeout_ms: None, tools: Vec::new(), always_load: None, }), @@ -2095,6 +2171,7 @@ fn dynamic_mcp_config_remove_succeeds_when_confirming_snapshot_proves_absence() url: "https://example.test/mcp".to_owned(), headers: BTreeMap::new(), timeout: None, + request_timeout_ms: None, tools: Vec::new(), always_load: None, }), @@ -2107,6 +2184,7 @@ fn dynamic_mcp_config_remove_succeeds_when_confirming_snapshot_proves_absence() url: "https://example.test/mcp".to_owned(), headers: BTreeMap::new(), timeout: None, + request_timeout_ms: None, tools: Vec::new(), always_load: None, }), @@ -2155,6 +2233,7 @@ fn removed_config_guard_stops_suppressing_when_confirming_snapshot_still_contain url: "https://search.example.test/mcp".to_owned(), headers: BTreeMap::new(), timeout: None, + request_timeout_ms: None, tools: Vec::new(), always_load: None, }), @@ -2193,6 +2272,7 @@ fn dynamic_mcp_config_remove_failure_from_sdk_keeps_server_visible() { url: "https://search.example.test/mcp".to_owned(), headers: BTreeMap::new(), timeout: None, + request_timeout_ms: None, tools: Vec::new(), always_load: None, }), @@ -2244,6 +2324,7 @@ fn dynamic_mcp_config_remove_refuses_to_drop_unrepresentable_dynamic_servers() { url: "https://search.example.test/mcp".to_owned(), headers: BTreeMap::new(), timeout: None, + request_timeout_ms: None, tools: Vec::new(), always_load: None, }), diff --git a/src/app/connect/session_start.rs b/src/app/connect/session_start.rs index 4b0fc800..8a037e18 100644 --- a/src/app/connect/session_start.rs +++ b/src/app/connect/session_start.rs @@ -320,7 +320,7 @@ mod tests { session_launch_settings_for_reason(&app, SessionStartReason::NewSession); assert_eq!(launch_settings.language, None); - assert_setting_value(&launch_settings, "model", &Value::String("opus".to_owned())); + assert_setting_value(&launch_settings, "model", &Value::String("fable".to_owned())); assert_setting_value(&launch_settings, "alwaysThinkingEnabled", &Value::Bool(false)); assert_permission_mode(&launch_settings, "default"); assert_setting_value(&launch_settings, "fastMode", &Value::Bool(false)); @@ -332,7 +332,7 @@ mod tests { } #[test] - fn persisted_launch_settings_include_supported_settings_json_with_explicit_opus_when_unset() { + fn persisted_launch_settings_include_supported_settings_json_with_explicit_fable_when_unset() { let mut app = App::test_default(); store::set_always_thinking_enabled(&mut app.config.committed_settings_document, true); store::set_thinking_effort_level( @@ -354,7 +354,7 @@ mod tests { let launch_settings = session_launch_settings_for_reason(&app, SessionStartReason::Startup); assert_eq!(launch_settings.language, None); - assert_setting_value(&launch_settings, "model", &Value::String("opus".to_owned())); + assert_setting_value(&launch_settings, "model", &Value::String("fable".to_owned())); assert_setting_value(&launch_settings, "alwaysThinkingEnabled", &Value::Bool(true)); assert_permission_mode(&launch_settings, "default"); assert_setting_value(&launch_settings, "fastMode", &Value::Bool(true)); diff --git a/src/app/connect/type_converters.rs b/src/app/connect/type_converters.rs index b218fdd8..aebcfc47 100644 --- a/src/app/connect/type_converters.rs +++ b/src/app/connect/type_converters.rs @@ -89,27 +89,51 @@ fn map_mcp_tool_policy(policy: types::McpServerToolPolicy) -> model::McpServerTo fn map_mcp_status_config(config: types::McpServerStatusConfig) -> model::McpServerStatusConfig { match config { - types::McpServerStatusConfig::Stdio { command, args, env, timeout, always_load } => { - model::McpServerStatusConfig::Stdio { command, args, env, timeout, always_load } - } - types::McpServerStatusConfig::Sse { url, headers, tools, timeout, always_load } => { - model::McpServerStatusConfig::Sse { - url, - headers, - tools: tools.into_iter().map(map_mcp_tool_policy).collect(), - timeout, - always_load, - } - } - types::McpServerStatusConfig::Http { url, headers, tools, timeout, always_load } => { - model::McpServerStatusConfig::Http { - url, - headers, - tools: tools.into_iter().map(map_mcp_tool_policy).collect(), - timeout, - always_load, - } - } + types::McpServerStatusConfig::Stdio { + command, + args, + env, + timeout, + request_timeout_ms, + always_load, + } => model::McpServerStatusConfig::Stdio { + command, + args, + env, + timeout, + request_timeout_ms, + always_load, + }, + types::McpServerStatusConfig::Sse { + url, + headers, + tools, + timeout, + request_timeout_ms, + always_load, + } => model::McpServerStatusConfig::Sse { + url, + headers, + tools: tools.into_iter().map(map_mcp_tool_policy).collect(), + timeout, + request_timeout_ms, + always_load, + }, + types::McpServerStatusConfig::Http { + url, + headers, + tools, + timeout, + request_timeout_ms, + always_load, + } => model::McpServerStatusConfig::Http { + url, + headers, + tools: tools.into_iter().map(map_mcp_tool_policy).collect(), + timeout, + request_timeout_ms, + always_load, + }, types::McpServerStatusConfig::Sdk { name } => model::McpServerStatusConfig::Sdk { name }, types::McpServerStatusConfig::ClaudeaiProxy { url, id, timeout } => { model::McpServerStatusConfig::ClaudeaiProxy { url, id, timeout } @@ -275,6 +299,11 @@ pub(super) fn map_available_models( .into_iter() .map(|model_info| { let mut mapped = model::AvailableModel::new(model_info.id, model_info.display_name); + if let Some(resolved_model) = model_info.resolved_model + && !resolved_model.trim().is_empty() + { + mapped = mapped.resolved_model(resolved_model); + } if let Some(description) = model_info.description && !description.trim().is_empty() { @@ -967,6 +996,7 @@ mod tests { let mapped = map_available_models(vec![ types::AvailableModel { id: "sonnet".to_owned(), + resolved_model: Some("claude-sonnet-5".to_owned()), display_name: "Claude Sonnet".to_owned(), description: Some("Balanced model".to_owned()), supports_effort: true, @@ -983,6 +1013,7 @@ mod tests { }, types::AvailableModel { id: "haiku".to_owned(), + resolved_model: None, display_name: "Claude Haiku".to_owned(), description: None, supports_effort: false, @@ -997,6 +1028,7 @@ mod tests { mapped, vec![ model::AvailableModel::new("sonnet", "Claude Sonnet") + .resolved_model("claude-sonnet-5") .description("Balanced model") .supports_effort(true) .supported_effort_levels(vec![ @@ -1642,6 +1674,7 @@ mod tests { }, ], timeout: Some(5000), + request_timeout_ms: Some(30000), always_load: Some(true), }), scope: Some("project".to_owned()), @@ -1650,12 +1683,18 @@ mod tests { let mapped = super::map_mcp_server_status(status); - let Some(model::McpServerStatusConfig::Http { tools, timeout, always_load, .. }) = - mapped.config + let Some(model::McpServerStatusConfig::Http { + tools, + timeout, + request_timeout_ms, + always_load, + .. + }) = mapped.config else { panic!("expected http MCP config"); }; assert_eq!(timeout, Some(5000)); + assert_eq!(request_timeout_ms, Some(30000)); assert_eq!(always_load, Some(true)); assert_eq!(tools.len(), 2); assert_eq!(tools[0].permission_policy, Some(model::McpServerToolPermissionPolicy::Deny)); diff --git a/src/app/events/client.rs b/src/app/events/client.rs index 30e3dc99..58dfbc5e 100644 --- a/src/app/events/client.rs +++ b/src/app/events/client.rs @@ -16,23 +16,31 @@ fn mcp_status_label(status: McpServerConnectionStatus) -> &'static str { fn mcp_config_diagnostics( config: Option<&McpServerStatusConfig>, -) -> (&'static str, Option, Option, usize) { +) -> (&'static str, Option, Option, Option, usize) { match config { - Some(McpServerStatusConfig::Stdio { timeout, always_load, .. }) => { - ("stdio", *timeout, *always_load, 0) - } - Some(McpServerStatusConfig::Sse { tools, timeout, always_load, .. }) => { - ("sse", *timeout, *always_load, tools.len()) - } - Some(McpServerStatusConfig::Http { tools, timeout, always_load, .. }) => { - ("http", *timeout, *always_load, tools.len()) - } - Some(McpServerStatusConfig::Sdk { .. }) => ("sdk", None, None, 0), + Some(McpServerStatusConfig::Stdio { timeout, request_timeout_ms, always_load, .. }) => { + ("stdio", *timeout, *request_timeout_ms, *always_load, 0) + } + Some(McpServerStatusConfig::Sse { + tools, + timeout, + request_timeout_ms, + always_load, + .. + }) => ("sse", *timeout, *request_timeout_ms, *always_load, tools.len()), + Some(McpServerStatusConfig::Http { + tools, + timeout, + request_timeout_ms, + always_load, + .. + }) => ("http", *timeout, *request_timeout_ms, *always_load, tools.len()), + Some(McpServerStatusConfig::Sdk { .. }) => ("sdk", None, None, None, 0), Some(McpServerStatusConfig::ClaudeaiProxy { timeout, .. }) => { - ("claudeai-proxy", *timeout, None, 0) + ("claudeai-proxy", *timeout, None, None, 0) } - Some(McpServerStatusConfig::Unknown { .. }) => ("unknown", None, None, 0), - None => ("missing", None, None, 0), + Some(McpServerStatusConfig::Unknown { .. }) => ("unknown", None, None, None, 0), + None => ("missing", None, None, None, 0), } } @@ -40,14 +48,20 @@ fn mcp_server_diagnostic_summaries(servers: &[McpServerStatus]) -> Vec Vec { - app.available_commands.iter().map(|cmd| normalize_slash_name(&cmd.name)).collect() + app.available_commands + .iter() + .map(|cmd| normalize_slash_name(&cmd.name)) + .filter(|name| command_spec(name).is_none()) + .collect() } pub(super) fn find_advertised_command<'a>( app: &'a App, command_name: &str, ) -> Option<&'a crate::agent::model::AvailableCommand> { + if command_spec(command_name).is_some() { + return None; + } app.available_commands.iter().find(|cmd| normalize_slash_name(&cmd.name) == command_name) } @@ -200,6 +207,9 @@ pub(super) fn supported_command_candidates(app: &App) -> Vec { for cmd in &app.available_commands { let name = normalize_slash_name(&cmd.name); + if command_spec(&name).is_some() { + continue; + } by_name.entry(name).or_insert_with(|| cmd.description.clone()); } diff --git a/src/app/slash/mod.rs b/src/app/slash/mod.rs index 6d9e57d2..4e81f232 100644 --- a/src/app/slash/mod.rs +++ b/src/app/slash/mod.rs @@ -88,6 +88,11 @@ pub fn is_cancel_command(text: &str) -> bool { parse(text).is_some_and(|parsed| parsed.name == "/cancel") } +pub(crate) fn app_owned_command_name(name: &str) -> Option<&'static str> { + let normalized = normalize_slash_name(name.trim()); + command_spec(&normalized).map(|spec| spec.name) +} + fn normalize_slash_name(name: &str) -> String { if name.starts_with('/') { name.to_owned() } else { format!("/{name}") } } diff --git a/src/app/slash/tests.rs b/src/app/slash/tests.rs index 50c2f5ab..02e4c738 100644 --- a/src/app/slash/tests.rs +++ b/src/app/slash/tests.rs @@ -80,6 +80,50 @@ fn config_without_args_opens_settings_view() { ); } +#[test] +fn app_config_shadows_advertised_config_command() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut app = App::test_default(); + app.settings_home_override = Some(dir.path().to_path_buf()); + app.available_commands = + vec![model::AvailableCommand::new("/config", "SDK config command").input_hint("")]; + + let consumed = try_handle_submit(&mut app, "/config"); + + assert!(consumed); + assert_eq!( + app.surface_mode, + super::super::SurfaceMode::Fullscreen(super::super::FullscreenView::Config) + ); +} + +#[test] +fn app_config_candidate_ignores_advertised_config_metadata() { + let mut app = App::test_default(); + app.available_commands = + vec![model::AvailableCommand::new("/config", "SDK config command").input_hint("")]; + app.input.set_text("/config"); + let _ = app.input.set_cursor(0, "/config".chars().count()); + + let slash = super::candidates::build_slash_state(&app).expect("slash state"); + let config_candidates: Vec<_> = + slash.candidates.iter().filter(|candidate| candidate.primary == "/config").collect(); + + assert_eq!(config_candidates.len(), 1); + assert_eq!(config_candidates[0].secondary.as_deref(), Some("Open settings")); +} + +#[test] +fn app_config_does_not_enter_advertised_argument_mode() { + let mut app = App::test_default(); + app.available_commands = + vec![model::AvailableCommand::new("/config", "SDK config command").input_hint("")]; + app.input.set_text("/config "); + let _ = app.input.set_cursor(0, "/config ".chars().count()); + + assert!(super::candidates::build_slash_state(&app).is_none()); +} + #[test] fn help_without_args_opens_help_tab() { let dir = tempfile::tempdir().expect("tempdir"); @@ -953,6 +997,25 @@ fn docs_commands_reuse_help_rows() { assert!(block.text.contains("/rewind")); } +#[test] +fn docs_commands_do_not_show_advertised_command_shadowed_by_app_command() { + let mut app = App::test_default(); + app.available_commands = vec![ + crate::agent::model::AvailableCommand::new("/config", "SDK config command") + .input_hint(""), + ]; + + let consumed = try_handle_submit(&mut app, "/docs commands"); + + assert!(consumed); + let last = app.messages.last().expect("expected system message"); + let Some(MessageBlock::Text(block)) = last.blocks.first() else { + panic!("expected text block"); + }; + assert!(block.text.contains("| /config | Open the fullscreen settings tab. |")); + assert!(!block.text.contains("SDK config command")); +} + #[test] fn docs_shortcuts_use_live_help_state() { let mut app = App::test_default(); diff --git a/src/ui/config.rs b/src/ui/config.rs index 999c8b76..d6f25ca1 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -12,7 +12,7 @@ mod usage; use crate::agent::model::EffortLevel; use crate::app::config::{ - OutputStyle, OverlayFocus, language_input_validation_message, model_overlay_options, + DEFAULT_MODEL_ALIAS_ID, OutputStyle, language_input_validation_message, model_overlay_options, supported_effort_levels_for_model, }; use crate::app::{App, ConfigTab}; @@ -27,7 +27,7 @@ use unicode_width::UnicodeWidthChar; use super::theme; use input::{add_marketplace_example_lines, render_text_input_field}; use overlay::{ - OverlayChrome, OverlayLayoutSpec, overlay_line_style, render_overlay_header, + OverlayChrome, OverlayLayoutSpec, overlay_line_style, render_overlay_separator as shared_render_overlay_separator, render_overlay_shell, selected_scroll, }; @@ -139,8 +139,10 @@ fn config_help_text(app: &App) -> String { } fn render_active_overlay(frame: &mut Frame, frame_area: Rect, app: &App) { - if app.config.model_and_effort_overlay().is_some() { - render_model_and_effort_overlay(frame, frame_area, app); + if app.config.model_overlay().is_some() { + render_model_overlay(frame, frame_area, app); + } else if app.config.thinking_effort_overlay().is_some() { + render_thinking_effort_overlay(frame, frame_area, app); } else if app.config.output_style_overlay().is_some() { render_output_style_overlay(frame, frame_area, app); } else if app.config.language_overlay().is_some() { @@ -168,10 +170,10 @@ fn render_active_overlay(frame: &mut Frame, frame_area: Rect, app: &App) { } } -fn render_model_and_effort_overlay(frame: &mut Frame, area: Rect, app: &App) { - let Some(overlay) = app.config.model_and_effort_overlay() else { +fn render_model_overlay(frame: &mut Frame, area: Rect, app: &App) { + if app.config.model_overlay().is_none() { return; - }; + } let rendered = render_overlay_shell( frame, area, @@ -185,47 +187,55 @@ fn render_model_and_effort_overlay(frame: &mut Frame, area: Rect, app: &App) { inner_margin: Margin { vertical: 1, horizontal: 1 }, }, OverlayChrome { - title: "Model and Thinking Effort", + title: "Model", subtitle: None, - help: Some("Tab switches model/effort | Enter confirm | Esc cancel"), + help: Some("Up/Down select | Enter confirm | Esc cancel"), message: app.config.overlay_message.as_ref(), }, ); let model_lines = model_overlay_lines(app); - let effort_lines = effort_overlay_lines(app); - let (model_height, effort_height) = model_and_effort_section_heights(rendered.body_area.height); - let sections = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Length(model_height), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(effort_height), - ]) - .split(rendered.body_area); - - let model_focused = overlay.focus == OverlayFocus::Model; - let effort_focused = overlay.focus == OverlayFocus::Effort; - render_overlay_header(frame, sections[0], "Model", model_focused); - shared_render_overlay_separator(frame, sections[2]); - render_overlay_header(frame, sections[3], "Thinking effort", effort_focused); - - let model_scroll = model_overlay_scroll(app, sections[1].height, sections[1].width); + let model_scroll = + model_overlay_scroll(app, rendered.body_area.height, rendered.body_area.width); frame.render_widget( Paragraph::new(model_lines).scroll((model_scroll, 0)).wrap(Wrap { trim: false }), - sections[1], + rendered.body_area, ); +} - let effort_scroll = effort_overlay_scroll(app, sections[4].height, sections[4].width); +fn render_thinking_effort_overlay(frame: &mut Frame, area: Rect, app: &App) { + if app.config.thinking_effort_overlay().is_none() { + return; + } + let rendered = render_overlay_shell( + frame, + area, + OverlayLayoutSpec { + min_width: 1, + min_height: 1, + width_percent: 76, + height_percent: 70, + preferred_height: 16, + fullscreen_below: Some((72, 16)), + inner_margin: Margin { vertical: 1, horizontal: 2 }, + }, + OverlayChrome { + title: "Thinking effort", + subtitle: Some("Available effort levels depend on the selected model."), + help: Some("Up/Down select | Enter confirm | Esc cancel"), + message: app.config.overlay_message.as_ref(), + }, + ); + let effort_lines = effort_overlay_lines(app); + let effort_scroll = + effort_overlay_scroll(app, rendered.body_area.height, rendered.body_area.width); frame.render_widget( Paragraph::new(effort_lines).scroll((effort_scroll, 0)).wrap(Wrap { trim: false }), - sections[4], + rendered.body_area, ); } fn model_overlay_lines(app: &App) -> Vec> { - let Some(overlay) = app.config.model_and_effort_overlay() else { + let Some(overlay) = app.config.model_overlay() else { return Vec::new(); }; let mut lines = model_overlay_options(app) @@ -233,12 +243,7 @@ fn model_overlay_lines(app: &App) -> Vec> { .flat_map(|option| { let selected = option.id == overlay.selected_model; let marker = if selected { ">" } else { " " }; - let mut lines = vec![model_overlay_title_line( - &option, - marker, - selected, - overlay.focus == OverlayFocus::Model, - )]; + let mut lines = vec![model_overlay_title_line(&option, marker, selected, true)]; if let Some(description) = option.description { lines.push(Line::from(Span::styled( format!(" {description}"), @@ -256,10 +261,11 @@ fn model_overlay_lines(app: &App) -> Vec> { } fn effort_overlay_lines(app: &App) -> Vec> { - let Some(overlay) = app.config.model_and_effort_overlay() else { + let Some(overlay) = app.config.thinking_effort_overlay() else { return Vec::new(); }; - let levels = supported_effort_levels_for_model(app, &overlay.selected_model); + let selected_model = selected_model_for_effort(app); + let levels = supported_effort_levels_for_model(app, &selected_model); if levels.is_empty() { return vec![ Line::from(Span::styled( @@ -280,7 +286,7 @@ fn effort_overlay_lines(app: &App) -> Vec> { vec![ Line::from(Span::styled( format!("{} {}", if selected { ">" } else { " " }, level.label()), - overlay_line_style(selected, overlay.focus == OverlayFocus::Effort), + overlay_line_style(selected, true), )), Line::from(Span::styled( format!(" {}", level.description()), @@ -903,24 +909,8 @@ fn char_width(ch: char) -> usize { UnicodeWidthChar::width(ch).unwrap_or(0) } -fn model_and_effort_section_heights(inner_height: u16) -> (u16, u16) { - const CHROME_HEIGHT: u16 = 3; - const DEFAULT_EFFORT_HEIGHT: u16 = 8; - - let content_height = inner_height.saturating_sub(CHROME_HEIGHT); - match content_height { - 0 => (0, 0), - 1 => (1, 0), - _ => { - let effort_height = DEFAULT_EFFORT_HEIGHT.min(content_height.saturating_sub(1)); - let model_height = content_height.saturating_sub(effort_height); - (model_height, effort_height) - } - } -} - fn model_overlay_scroll(app: &App, viewport_height: u16, viewport_width: u16) -> u16 { - let Some(overlay) = app.config.model_and_effort_overlay() else { + let Some(overlay) = app.config.model_overlay() else { return 0; }; let options = model_overlay_options(app); @@ -947,10 +937,11 @@ fn model_overlay_scroll(app: &App, viewport_height: u16, viewport_width: u16) -> } fn effort_overlay_scroll(app: &App, viewport_height: u16, viewport_width: u16) -> u16 { - let Some(overlay) = app.config.model_and_effort_overlay() else { + let Some(overlay) = app.config.thinking_effort_overlay() else { return 0; }; - let levels = supported_effort_levels_for_model(app, &overlay.selected_model); + let selected_model = selected_model_for_effort(app); + let levels = supported_effort_levels_for_model(app, &selected_model); if levels.is_empty() || viewport_height == 0 || viewport_width == 0 { return 0; } @@ -973,6 +964,10 @@ fn effort_overlay_scroll(app: &App, viewport_height: u16, viewport_width: u16) - selected_scroll(selected_start, selected_height, viewport_height) } +fn selected_model_for_effort(app: &App) -> String { + app.config.model_effective().unwrap_or_else(|| DEFAULT_MODEL_ALIAS_ID.to_owned()) +} + fn effort_overlay_option_height(level: EffortLevel, is_last: bool, viewport_width: u16) -> usize { let lines = vec![ Line::from(format!(" {}", level.label())), @@ -1111,8 +1106,8 @@ mod tests { use crate::agent::model::{AvailableModel, EffortLevel}; use crate::app::App; use crate::app::config::{ - ConfigOverlayState, LanguageOverlayState, ModelAndEffortOverlayState, OutputStyle, - OutputStyleOverlayState, OverlayFocus, SettingId, setting_specs, + ConfigOverlayState, LanguageOverlayState, ModelOverlayState, OutputStyle, + OutputStyleOverlayState, SettingId, ThinkingEffortOverlayState, setting_specs, supported_effort_levels_for_model, }; use ratatui::Terminal; @@ -1203,7 +1198,7 @@ mod tests { let options = crate::app::config::model_overlay_options(app); let overlay = app .config - .model_and_effort_overlay() + .model_overlay() .expect("model overlay should be present for visibility assertions"); let selected_index = options .iter() @@ -1261,10 +1256,8 @@ mod tests { ]), AvailableModel::new("haiku", "Haiku").description("Fastest").supports_effort(false), ]; - app.config.overlay = Some(ConfigOverlayState::ModelAndEffort(ModelAndEffortOverlayState { - focus: OverlayFocus::Model, + app.config.overlay = Some(ConfigOverlayState::Model(ModelOverlayState { selected_model: "sonnet".to_owned(), - selected_effort: EffortLevel::High, })); let scroll = assert_selected_model_visible(&app, 6, 40); @@ -1285,10 +1278,8 @@ mod tests { ]), AvailableModel::new("haiku", "Haiku").supports_effort(false), ]; - app.config.overlay = Some(ConfigOverlayState::ModelAndEffort(ModelAndEffortOverlayState { - focus: OverlayFocus::Model, + app.config.overlay = Some(ConfigOverlayState::Model(ModelOverlayState { selected_model: "haiku".to_owned(), - selected_effort: EffortLevel::Medium, })); let narrow_scroll = assert_selected_model_visible(&app, 4, 10); @@ -1319,10 +1310,8 @@ mod tests { .supports_effort(false) .supports_fast_mode(Some(true)), ]; - app.config.overlay = Some(ConfigOverlayState::ModelAndEffort(ModelAndEffortOverlayState { - focus: OverlayFocus::Model, + app.config.overlay = Some(ConfigOverlayState::Model(ModelOverlayState { selected_model: "haiku".to_owned(), - selected_effort: EffortLevel::Medium, })); let narrow_scroll = assert_selected_model_visible(&app, 3, 12); @@ -1339,17 +1328,19 @@ mod tests { .supports_effort(true) .supported_effort_levels(EffortLevel::ALL.to_vec()), ]; - app.config.overlay = Some(ConfigOverlayState::ModelAndEffort(ModelAndEffortOverlayState { - focus: OverlayFocus::Effort, - selected_model: "opus".to_owned(), + crate::app::config::store::set_model( + &mut app.config.committed_settings_document, + Some("opus"), + ); + app.config.overlay = Some(ConfigOverlayState::ThinkingEffort(ThinkingEffortOverlayState { selected_effort: EffortLevel::Low, })); assert_eq!(effort_overlay_scroll(&app, 8, 40), 0); app.config - .model_and_effort_overlay_mut() - .expect("model and effort overlay") + .thinking_effort_overlay_mut() + .expect("thinking effort overlay") .selected_effort = EffortLevel::XHigh; assert!(effort_overlay_scroll(&app, 8, 40) > 0); @@ -1381,10 +1372,8 @@ mod tests { .supports_fast_mode(Some(true)) .supports_auto_mode(None), ]; - app.config.overlay = Some(ConfigOverlayState::ModelAndEffort(ModelAndEffortOverlayState { - focus: OverlayFocus::Model, + app.config.overlay = Some(ConfigOverlayState::Model(ModelOverlayState { selected_model: "sonnet".to_owned(), - selected_effort: EffortLevel::High, })); let rendered = @@ -1410,6 +1399,7 @@ mod tests { let line = model_overlay_title_line( &crate::app::config::OverlayModelOption { id: "sonnet".to_owned(), + resolved_model: None, display_name: "Sonnet".to_owned(), description: None, supports_effort: true, @@ -2337,6 +2327,7 @@ mod tests { args: vec!["@modelcontextprotocol/server-filesystem".to_owned()], env: BTreeMap::new(), timeout: Some(5000), + request_timeout_ms: Some(30000), always_load: Some(true), }), scope: Some("project".to_owned()), diff --git a/src/ui/config/mcp.rs b/src/ui/config/mcp.rs index f73617d0..c1041677 100644 --- a/src/ui/config/mcp.rs +++ b/src/ui/config/mcp.rs @@ -546,23 +546,56 @@ fn auth_redirect_action_lines( fn config_lines(config: &McpServerStatusConfig) -> Vec> { match config { - McpServerStatusConfig::Stdio { command, args, env, timeout, always_load } => { + McpServerStatusConfig::Stdio { + command, + args, + env, + timeout, + request_timeout_ms, + always_load, + } => { let args_label = if args.is_empty() { "(none)".to_owned() } else { args.join(" ") }; let mut lines = vec![ detail_kv("Command", command, Color::White), detail_kv("Args", &args_label, Color::White), detail_kv("Env", &format!("{} variable(s)", env.len()), Color::White), ]; - append_mcp_runtime_config_lines(&mut lines, *timeout, *always_load, &[]); + append_mcp_runtime_config_lines( + &mut lines, + *timeout, + *request_timeout_ms, + *always_load, + &[], + ); lines } - McpServerStatusConfig::Sse { url, headers, tools, timeout, always_load } - | McpServerStatusConfig::Http { url, headers, tools, timeout, always_load } => { + McpServerStatusConfig::Sse { + url, + headers, + tools, + timeout, + request_timeout_ms, + always_load, + } + | McpServerStatusConfig::Http { + url, + headers, + tools, + timeout, + request_timeout_ms, + always_load, + } => { let mut lines = vec![ detail_kv("URL", url, Color::White), detail_kv("Headers", &format!("{} configured", headers.len()), Color::White), ]; - append_mcp_runtime_config_lines(&mut lines, *timeout, *always_load, tools); + append_mcp_runtime_config_lines( + &mut lines, + *timeout, + *request_timeout_ms, + *always_load, + tools, + ); lines } McpServerStatusConfig::Sdk { name } => vec![detail_kv("SDK server", name, Color::White)], @@ -571,7 +604,7 @@ fn config_lines(config: &McpServerStatusConfig) -> Vec> { detail_kv("Proxy URL", url, Color::White), detail_kv("Proxy ID", id, Color::White), ]; - append_mcp_runtime_config_lines(&mut lines, *timeout, None, &[]); + append_mcp_runtime_config_lines(&mut lines, *timeout, None, None, &[]); lines } McpServerStatusConfig::Unknown { raw_type } => { @@ -583,12 +616,16 @@ fn config_lines(config: &McpServerStatusConfig) -> Vec> { fn append_mcp_runtime_config_lines( lines: &mut Vec>, timeout: Option, + request_timeout_ms: Option, always_load: Option, tools: &[crate::agent::model::McpServerToolPolicy], ) { if let Some(timeout_ms) = timeout { lines.push(detail_kv("Timeout", &format!("{timeout_ms} ms"), Color::White)); } + if let Some(timeout_ms) = request_timeout_ms { + lines.push(detail_kv("Request timeout", &format!("{timeout_ms} ms"), Color::White)); + } if let Some(always_load) = always_load { lines.push(detail_kv( "Always load", @@ -865,6 +902,7 @@ mod tests { org_max_permission: None, }], timeout: Some(5000), + request_timeout_ms: Some(30000), always_load: Some(true), }), scope: Some("user".to_owned()), @@ -886,6 +924,7 @@ mod tests { ], env: BTreeMap::new(), timeout: None, + request_timeout_ms: None, always_load: None, }), scope: Some("project".to_owned()), @@ -934,11 +973,13 @@ mod tests { }, ], timeout: Some(5000), + request_timeout_ms: Some(30000), always_load: Some(true), }); let text = lines.iter().map(Line::to_string).collect::>().join("\n"); assert!(text.contains("5000 ms")); + assert!(text.contains("30000 ms")); assert!(text.contains("enabled")); assert!(text.contains("search")); assert!(text.contains("always deny")); diff --git a/src/ui/config/overlay.rs b/src/ui/config/overlay.rs index fbb88174..5e046523 100644 --- a/src/ui/config/overlay.rs +++ b/src/ui/config/overlay.rs @@ -101,21 +101,6 @@ pub(super) fn overlay_line_style(selected: bool, focused: bool) -> Style { } } -pub(super) fn render_overlay_header(frame: &mut Frame, area: Rect, title: &str, focused: bool) { - let prefix = if focused { "> " } else { " " }; - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - format!("{prefix}{title}"), - if focused { - Style::default().fg(theme::RUST_ORANGE).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme::DIM) - }, - ))), - area, - ); -} - pub(super) fn render_overlay_separator(frame: &mut Frame, area: Rect) { let width = usize::from(area.width.max(1)); frame.render_widget( diff --git a/src/ui/help.rs b/src/ui/help.rs index 4bd1f66b..809e1a5f 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -220,6 +220,9 @@ fn build_slash_command_items(app: &App) -> Vec<(String, String)> { for cmd in &app.available_commands { let name = if cmd.name.starts_with('/') { cmd.name.clone() } else { format!("/{}", cmd.name) }; + if crate::app::slash::app_owned_command_name(&name).is_some() { + continue; + } commands.entry(name).or_insert_with(|| cmd.description.clone()); } diff --git a/src/ui/tool_call/artifact.rs b/src/ui/tool_call/artifact.rs index c4c32b14..3734be32 100644 --- a/src/ui/tool_call/artifact.rs +++ b/src/ui/tool_call/artifact.rs @@ -36,6 +36,9 @@ fn render_input_content(tc: &ToolCallInfo) -> Vec> { if let Some(label) = json_string(input, "label") { artifact_fields.push(ToolField::new("Label", label)); } + if let Some(description) = json_string(input, "description") { + artifact_fields.push(ToolField::new("Description", description)); + } if let Some(file_path) = json_string(input, "file_path") { artifact_fields.push(ToolField::new("File path", file_path)); } @@ -159,6 +162,7 @@ mod tests { json!({ "file_path": "C:/work/dashboard.html", "label": "dashboard", + "description": "Interactive dashboard", "url": "https://artifact.local/old" }), Some( @@ -172,6 +176,7 @@ mod tests { rendered_line_texts(&lines), vec![ "Label: dashboard", + "Description: Interactive dashboard", "File path: C:/work/dashboard.html", "URL: https://artifact.local/old", "Title: Dashboard",