diff --git a/.changeset/eight-seas-matter.md b/.changeset/eight-seas-matter.md new file mode 100644 index 000000000..8db5dc365 --- /dev/null +++ b/.changeset/eight-seas-matter.md @@ -0,0 +1,5 @@ +--- +'@nanocollective/nanocoder': patch +--- + +Defer ink and @/app loading in the CLI entry point until the interactive TUI branch, so --acp, --plain and auth paths no longer pay the Ink/App module-graph cost at startup. diff --git a/.changeset/feat-vscode-settings-dashboard.md b/.changeset/feat-vscode-settings-dashboard.md new file mode 100644 index 000000000..df0d4b664 --- /dev/null +++ b/.changeset/feat-vscode-settings-dashboard.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Added Settings Tab to the VS Code extension webview for configuring providers and behavior directly from the UI. diff --git a/.changeset/fix-cancel-stops-execution.md b/.changeset/fix-cancel-stops-execution.md new file mode 100644 index 000000000..ad77ca111 --- /dev/null +++ b/.changeset/fix-cancel-stops-execution.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fixed the VS Code extension's stop button leaving a request running. Two holes: `AcpSession.cancel()` aborted the current controller and immediately replaced it with a fresh one, so a cancel that landed before the turn read the signal — the window while the agent is still resolving the prompt's file references — handed the turn an unaborted controller and the stop was lost. The controller is now rotated when a turn begins instead. Separately, the extension never answered the agent's pending permission request when you hit stop: the tool card kept its spinner and Allow/Deny buttons, and because the request stayed on the pending list every later message was refused with "Please approve or deny the pending tool before sending a new message" until the window was reloaded. Stopping (or reconnecting after the agent process restarts) now resolves those requests as cancelled. Thanks to @akramcodez. Closes #864. diff --git a/.changeset/fix-vscode-tool-aggregator-reset.md b/.changeset/fix-vscode-tool-aggregator-reset.md new file mode 100644 index 000000000..ae0fb3da1 --- /dev/null +++ b/.changeset/fix-vscode-tool-aggregator-reset.md @@ -0,0 +1,11 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Fixed new tool calls landing back in an earlier card instead of a fresh one when a thought, reply, edit card, or plan update came in between. Closes #856. + +Fixed a manually collapsed tool card re-expanding on its next update. + +Reused one footer per agent turn instead of creating one per text segment. + +Fixed a turn's copy button sometimes copying a newer turn's text instead of its own. diff --git a/.changeset/loud-plums-invite.md b/.changeset/loud-plums-invite.md new file mode 100644 index 000000000..f37778630 --- /dev/null +++ b/.changeset/loud-plums-invite.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Setup wizard's config location picker now shows the resolved path next to each option instead of a bare label. diff --git a/.changeset/sunset-setup-commands.md b/.changeset/sunset-setup-commands.md new file mode 100644 index 000000000..4ca098476 --- /dev/null +++ b/.changeset/sunset-setup-commands.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Sunset `/setup-providers` and `/setup-mcp` in favour of `/settings`. Both retired names still work for now — they open the matching `/settings` tab with a notice instead of erroring. `/settings` now takes a tab argument (`/settings providers`, `/settings mcp`), MCP has its own settings tab, and provider edits made from settings apply to the running session instead of waiting for the next launch. Selecting a provider or MCP server in settings now opens that entry's edit/delete choice directly, with a separate row for adding a new one. diff --git a/.changeset/vscode-code-lens-actions.md b/.changeset/vscode-code-lens-actions.md new file mode 100644 index 000000000..c79ac7af7 --- /dev/null +++ b/.changeset/vscode-code-lens-actions.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": minor +--- + +Added editor code lenses to the VS Code extension: every function, method, constructor and class now carries `Explain Code` and `Generate Tests` links, and clicking one reveals the chat view and sends that symbol - instruction, `file:startLine-endLine` and the source, fenced with the document language - as a prompt. Symbols come from the language server, so no per-language parsing is involved, and the lenses can be turned off with `nanocoder.codeLens`. Long symbols are capped before being inlined, so a lens click on a large class cannot spend a whole context window on one turn. Also fixes a pre-existing hang where sending a message while a tool approval was still pending left the composer spinning forever. Closes #750. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index e203ce047..3649986e7 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -62,6 +62,11 @@ jobs: - name: Run TypeScript type checking run: pnpm test:types + # The root tsconfig only includes source/**, so the extension's own + # sources and specs are checked by their own project or not at all. + - name: Run TypeScript type checking (VS Code extension) + run: pnpm test:types:vscode + test-format: name: Format Checks runs-on: ubuntu-latest diff --git a/assets/nanocoder-vscode.vsix b/assets/nanocoder-vscode.vsix index 7b2873feb..2e9d16fd8 100644 Binary files a/assets/nanocoder-vscode.vsix and b/assets/nanocoder-vscode.vsix differ diff --git a/badges/coverage.svg b/badges/coverage.svg index 9831c5951..78e9125c8 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -COVERAGE: 91.4%COVERAGE91.4% \ No newline at end of file +COVERAGE: 91.26%COVERAGE91.26% \ No newline at end of file diff --git a/badges/forks.svg b/badges/forks.svg index d91d311ca..3bdb95c45 100644 --- a/badges/forks.svg +++ b/badges/forks.svg @@ -1 +1 @@ -FORKS257 \ No newline at end of file +FORKS263 \ No newline at end of file diff --git a/badges/npm-downloads-monthly.svg b/badges/npm-downloads-monthly.svg index 05fa8913e..8ee88064b 100644 --- a/badges/npm-downloads-monthly.svg +++ b/badges/npm-downloads-monthly.svg @@ -1 +1 @@ -DOWNLOADS: 8.1K/MONTHDOWNLOADS8.1K/MONTH \ No newline at end of file +DOWNLOADS: 8.9K/MONTHDOWNLOADS8.9K/MONTH \ No newline at end of file diff --git a/badges/repo-size.svg b/badges/repo-size.svg index 7ee40d540..075309d8d 100644 --- a/badges/repo-size.svg +++ b/badges/repo-size.svg @@ -1 +1 @@ -REPO SIZE: 35.1 MIBREPO SIZE35.1 MIB \ No newline at end of file +REPO SIZE: 33.2 MIBREPO SIZE33.2 MIB \ No newline at end of file diff --git a/benchmarks/measure.ts b/benchmarks/measure.ts index da00918d4..14d9b0038 100644 --- a/benchmarks/measure.ts +++ b/benchmarks/measure.ts @@ -122,34 +122,30 @@ function measureModuleCount(args: string[]): number { } /** - * Boots the CLI in interactive mode under the counting loader, waits until - * the module-resolution count has been stable for `stableMs`, then kills the - * process and returns the final count. + * Boots the CLI under the counting loader, waits until the module-resolution + * count has been stable for `stableMs`, then kills the process and returns + * the final count plus approximate wall-clock boot time. * - * This measures the full app graph (Ink, hooks, tool manager, command - * registry, MCP loader, etc.) rather than just the `--help` fast path. + * This measures the full resolved graph for the given args (interactive TUI, + * ACP server, etc.) rather than just a oneshot `--help` fast path. * Polling-until-stable keeps the result deterministic regardless of machine * speed: we wait for steady state, not a fixed wall-clock window. - */ -/** - * Same polling-until-stable pattern as `measureInteractiveModuleCount`, but - * returns both the module count AND the wall-clock time (in ms) from process - * spawn to the moment the module count reached its final value. * * The wall-clock number is labelled **approximate** because it's * machine-dependent and subject to I/O / scheduler noise. It's not a * deterministic metric like module count — it's a sanity check that * optimizations translate into real boot-time savings on the local machine. */ -async function measureInteractiveStartup( +async function measureStartupUntilStable( args: string[] = [], + label = 'interactive', ): Promise<{moduleCount: number; bootMs: number}> { const loaderPath = path.join(__dirname, 'count-loader.mjs'); const loaderUrl = new URL(`file://${loaderPath}`).href; const countFile = path.join( repoRoot, 'benchmarks', - `.module-count-interactive-${process.pid}-${Date.now()}`, + `.module-count-${label}-${process.pid}-${Date.now()}`, ); const child = spawn( @@ -356,7 +352,7 @@ async function runWithUrlCapture( CI: '1', // Keep the user's global MCP config out of the graph // so the explain breakdown only reflects nanocoder's own - // boot cost. Matches `measureInteractiveStartup`. + // boot cost. Matches `measureStartupUntilStable`. NODE_ENV: 'test', }, }, @@ -709,13 +705,23 @@ export async function measure(): Promise { // clock boot number; the module count is deterministic so we only keep // the first one. const interactiveRuns = [ - await measureInteractiveStartup(), - await measureInteractiveStartup(), - await measureInteractiveStartup(), + await measureStartupUntilStable([], 'interactive'), + await measureStartupUntilStable([], 'interactive'), + await measureStartupUntilStable([], 'interactive'), ]; const interactiveModuleCount = interactiveRuns[0].moduleCount; const bootSamples = interactiveRuns.map(r => r.bootMs).sort((a, b) => a - b); const interactiveBootMsMedian = bootSamples[1]; + // ACP server is long-lived (JSON-RPC over stdin/stdout). Poll until the + // module graph is stable, then terminate — same pattern as interactive. + const acpRuns = [ + await measureStartupUntilStable(['--acp'], 'acp'), + await measureStartupUntilStable(['--acp'], 'acp'), + await measureStartupUntilStable(['--acp'], 'acp'), + ]; + const acpModuleCount = acpRuns[0].moduleCount; + const acpBootSamples = acpRuns.map(r => r.bootMs).sort((a, b) => a - b); + const acpBootMsMedian = acpBootSamples[1]; // Time from spawn to first stdout byte — what the user actually perceives. const firstRenderMs = await measureTimeToFirstRender(); const distStats = measureDir(path.join(repoRoot, 'dist')); @@ -810,6 +816,18 @@ export async function measure(): Promise { value: interactiveBootMsMedian, warnOnDecrease: false, }, + // ACP server steady-state module graph (no Ink / App). Deterministic + // proxy for editor-integration startup cost. + acp_module_count: { + kind: 'numeric', + value: acpModuleCount, + warnOnDecrease: false, + }, + acp_boot_ms_approx: { + kind: 'numeric', + value: acpBootMsMedian, + warnOnDecrease: false, + }, // Time from spawn to first stdout byte — what the user sees. // Measured without the ESM loader so there's no instrumentation // overhead. Median of 3 runs. diff --git a/docs/configuration/mcp-configuration.md b/docs/configuration/mcp-configuration.md index 20143f6f2..b38cc09a3 100644 --- a/docs/configuration/mcp-configuration.md +++ b/docs/configuration/mcp-configuration.md @@ -38,7 +38,7 @@ Create a `.mcp.json` file in your project root: } ``` -Use `/mcp` to view connected servers and their tools. Use `/setup-mcp` for interactive setup. +Use `/mcp` to view connected servers and their tools. Use `/settings mcp` for interactive setup. ## Optional: Local-First Cross-Session Memory @@ -241,7 +241,7 @@ Supported syntax: `$VAR`, `${VAR}`, `${VAR:-default}` ## Setup Wizard -Run `/setup-mcp` for interactive configuration with: +Run `/settings mcp` for interactive configuration with: - Pre-configured templates for popular servers (Filesystem, GitHub, Brave Search, Context7, DeepWiki, Playwright, etc.) - Custom server setup for stdio, HTTP, and WebSocket diff --git a/docs/configuration/providers/anthropic.md b/docs/configuration/providers/anthropic.md index 9dc576d89..7ee7f939c 100644 --- a/docs/configuration/providers/anthropic.md +++ b/docs/configuration/providers/anthropic.md @@ -29,4 +29,4 @@ The `sdkProvider: "anthropic"` field enables the native Anthropic SDK instead of ## Fetching Available Models -The `/setup-providers` wizard can automatically fetch available models from your Anthropic account. +The `/settings providers` wizard can automatically fetch available models from your Anthropic account. diff --git a/docs/configuration/providers/atlas-cloud.md b/docs/configuration/providers/atlas-cloud.md index ae307d1ab..4ddff8900 100644 --- a/docs/configuration/providers/atlas-cloud.md +++ b/docs/configuration/providers/atlas-cloud.md @@ -29,4 +29,4 @@ sidebar_order: 17 ## Fetching Available Models -The `/setup-providers` wizard can automatically fetch available models from your Atlas Cloud account. +The `/settings providers` wizard can automatically fetch available models from your Atlas Cloud account. diff --git a/docs/configuration/providers/atomic-chat.md b/docs/configuration/providers/atomic-chat.md index ab7cba166..9ff6e8af0 100644 --- a/docs/configuration/providers/atomic-chat.md +++ b/docs/configuration/providers/atomic-chat.md @@ -50,4 +50,4 @@ Load your model with a context length as high as your system's memory can handle ## Fetching Available Models -The `/setup-providers` wizard can automatically fetch your loaded models from Atomic Chat when configuring this provider. +The `/settings providers` wizard can automatically fetch your loaded models from Atomic Chat when configuring this provider. diff --git a/docs/configuration/providers/custom.md b/docs/configuration/providers/custom.md index 46b6d51a8..eface961a 100644 --- a/docs/configuration/providers/custom.md +++ b/docs/configuration/providers/custom.md @@ -32,7 +32,7 @@ Custom providers support all [provider configuration fields](index.md#provider-c ## Setup via Wizard -Select "Custom Provider" in the `/setup-providers` wizard to add one interactively. The wizard will prompt for: +Select "Custom Provider" in the `/settings providers` wizard to add one interactively. The wizard will prompt for: 1. Provider name 2. Base URL diff --git a/docs/configuration/providers/github-models.md b/docs/configuration/providers/github-models.md index cf0ebc366..bd4053a91 100644 --- a/docs/configuration/providers/github-models.md +++ b/docs/configuration/providers/github-models.md @@ -26,4 +26,4 @@ sidebar_order: 13 ## Fetching Available Models -The `/setup-providers` wizard can automatically fetch available models from GitHub Models. +The `/settings providers` wizard can automatically fetch available models from GitHub Models. diff --git a/docs/configuration/providers/index.md b/docs/configuration/providers/index.md index 6bdaca75c..99acc0501 100644 --- a/docs/configuration/providers/index.md +++ b/docs/configuration/providers/index.md @@ -10,7 +10,7 @@ Nanocoder supports multiple AI providers including any OpenAI-compatible API, na ## Configuration Methods -1. **Interactive Setup (Recommended for new users)**: Run `/setup-providers` inside Nanocoder for a guided wizard with provider templates. The wizard allows you to: +1. **Interactive Setup (Recommended for new users)**: Run `/settings providers` inside Nanocoder for a guided wizard with provider templates. The wizard allows you to: - Choose between project-level or global configuration - Select from common provider templates - Add custom OpenAI-compatible providers manually @@ -18,7 +18,7 @@ Nanocoder supports multiple AI providers including any OpenAI-compatible API, na - Fetch available models automatically from your provider 2. **Manual Configuration**: Create an `agents.config.json` file (see [Configuration](../index.md) for file locations) -> **Note**: The `/setup-providers` wizard requires at least one provider to be configured before saving. You cannot exit without adding a provider. +> **Note**: The `/settings providers` wizard requires at least one provider to be configured before saving. You cannot exit without adding a provider. ## Local Providers diff --git a/docs/configuration/providers/mistral.md b/docs/configuration/providers/mistral.md index 48de8a465..f5bac666e 100644 --- a/docs/configuration/providers/mistral.md +++ b/docs/configuration/providers/mistral.md @@ -26,4 +26,4 @@ sidebar_order: 12 ## Fetching Available Models -The `/setup-providers` wizard can automatically fetch available models from your Mistral account. +The `/settings providers` wizard can automatically fetch available models from your Mistral account. diff --git a/docs/configuration/providers/ollama.md b/docs/configuration/providers/ollama.md index ffdbb0c58..e128b0cfd 100644 --- a/docs/configuration/providers/ollama.md +++ b/docs/configuration/providers/ollama.md @@ -48,4 +48,4 @@ If your context limit is too low, you may notice: ## Fetching Available Models -The `/setup-providers` wizard can automatically fetch your installed Ollama models when configuring this provider. +The `/settings providers` wizard can automatically fetch your installed Ollama models when configuring this provider. diff --git a/docs/configuration/providers/openai.md b/docs/configuration/providers/openai.md index bda11e0cb..d6d6284ed 100644 --- a/docs/configuration/providers/openai.md +++ b/docs/configuration/providers/openai.md @@ -40,4 +40,4 @@ If your API key is associated with multiple organizations, specify which one to ## Fetching Available Models -The `/setup-providers` wizard can automatically fetch available models from your OpenAI account. +The `/settings providers` wizard can automatically fetch available models from your OpenAI account. diff --git a/docs/configuration/providers/orcarouter.md b/docs/configuration/providers/orcarouter.md index 7847faf5a..94f50b2a4 100644 --- a/docs/configuration/providers/orcarouter.md +++ b/docs/configuration/providers/orcarouter.md @@ -29,6 +29,6 @@ Model names follow the `provider/model-name` format, e.g. `openai/gpt-5.5` or `a ## Fetching Available Models -The `/setup-providers` wizard can automatically fetch available models from your OrcaRouter account. +The `/settings providers` wizard can automatically fetch available models from your OrcaRouter account. See the [OrcaRouter docs](https://docs.orcarouter.ai) for the full model catalog and routing options. diff --git a/docs/configuration/providers/requesty.md b/docs/configuration/providers/requesty.md index 9380e2424..c4d09f017 100644 --- a/docs/configuration/providers/requesty.md +++ b/docs/configuration/providers/requesty.md @@ -29,6 +29,6 @@ Model names follow the `provider/model-name` format, e.g. `openai/gpt-4o-mini` o ## Fetching Available Models -The `/setup-providers` wizard can automatically fetch available models from your Requesty account. +The `/settings providers` wizard can automatically fetch available models from your Requesty account. See the [Requesty documentation](https://docs.requesty.ai) for the full model catalog and routing options. diff --git a/docs/configuration/providers/together.md b/docs/configuration/providers/together.md index 894ce4817..e261ee480 100644 --- a/docs/configuration/providers/together.md +++ b/docs/configuration/providers/together.md @@ -29,4 +29,4 @@ Together AI model IDs are namespaced (e.g. `deepseek-ai/DeepSeek-V3`, `meta-llam ## Fetching Available Models -The `/setup-providers` wizard can automatically fetch available models from your Together AI account. \ No newline at end of file +The `/settings providers` wizard can automatically fetch available models from your Together AI account. \ No newline at end of file diff --git a/docs/features/acp.md b/docs/features/acp.md index eddade3ae..2e2a8eb0e 100644 --- a/docs/features/acp.md +++ b/docs/features/acp.md @@ -1,29 +1,29 @@ --- title: "ACP (Editor Integration)" -description: "Run Nanocoder as an Agent Client Protocol server for editors like Zed" +description: "Run Nanocoder as an Agent Client Protocol server for editors like Zed and the VS Code sidebar" sidebar_order: 9 --- # ACP (Agent Client Protocol) -Nanocoder can run as an [Agent Client Protocol](https://agentclientprotocol.com) (ACP) server, letting ACP-compatible editors drive it as a native coding agent. Instead of the Ink terminal UI, Nanocoder speaks JSON-RPC over stdin/stdout, and the editor renders the conversation, tool calls, diffs, and permission prompts in its own UI. +Nanocoder can run as an [Agent Client Protocol](https://agentclientprotocol.com) (ACP) server, letting ACP-compatible clients drive it as a native coding agent. Instead of the Ink terminal UI, Nanocoder speaks JSON-RPC over stdin/stdout, and the client renders the conversation, tool calls, diffs, and permission prompts in its own UI. ```bash nanocoder --acp ``` -You normally don't run this command yourself — the editor spawns it for you (see [Setup in Zed](#setup-in-zed) below). +You normally don't run this command yourself — the editor or extension spawns it for you (see [Setup in Zed](#setup-in-zed) and the [VS Code extension](vscode-extension.md)). -## ACP vs. the VS Code extension +## ACP vs. the VS Code companion -Both connect Nanocoder to an editor, but they are different mechanisms — pick the one your editor supports: +ACP is the transport for both the **VS Code sidebar chat** and other ACP clients (Zed, etc.). The older `--vscode` WebSocket path is a separate, opt-in **legacy terminal companion** for Ink TUI sessions — not how the sidebar works. -| | Transport | Flag | Editors | +| | Transport | Flag | Clients | | --- | --- | --- | --- | -| **ACP** | JSON-RPC over stdin/stdout | `--acp` | Zed and other ACP clients | -| **[VS Code extension](vscode-extension.md)** | WebSocket | `--vscode` | VS Code | +| **ACP** | JSON-RPC over stdin/stdout | `--acp` | VS Code sidebar, Zed, and other ACP clients | +| **Legacy companion** | WebSocket | `--vscode` | VS Code (opt-in terminal companion) | -With ACP the **editor is the UI**: the agent runs headless and everything (streaming text, tool cards, diffs, approvals) is rendered by the editor. With the VS Code extension, the Nanocoder terminal UI stays in charge and the editor adds diff previews and editor context on top. +With ACP the **client is the UI**: the agent runs headless and everything (streaming text, tool cards, diffs, approvals) is rendered by the editor or sidebar. The legacy `--vscode` companion keeps the Nanocoder terminal TUI in charge and adds diff previews and editor context on top — see [Legacy Companion Mode](vscode-extension.md#legacy-companion-mode). ## What works over ACP diff --git a/docs/features/commands.md b/docs/features/commands.md index 46a47174a..bb3ac90ef 100644 --- a/docs/features/commands.md +++ b/docs/features/commands.md @@ -14,15 +14,13 @@ Type `/` in the chat input to see available commands. All commands start with `/ |---------|-------------| | `/help` | Show available commands | | `/init` | Initialize project with intelligent analysis, create AGENTS.md and configuration files. Use `/init --force` to regenerate AGENTS.md if it already exists, or `/init --lean` to skip merging `CLAUDE.md` content into the generated AGENTS.md | -| `/setup-providers` | Interactive wizard for configuring AI providers with templates | -| `/setup-mcp` | Interactive wizard for configuring MCP servers with templates | | `/setup-config` | Open a configuration file in your `$EDITOR` (lists project and global config files) | | `/clear` | Clear chat history | | `/model` | Switch between available models from any configured provider | | `/status` | Display current status (CWD, provider, model, theme, available updates, AGENTS setup) | | `/tasks` | Manage task list for tracking complex work (see [Task Management](task-management.md)) | | `/model-database` | Browse coding models from OpenRouter (searchable, filterable by open/proprietary) | -| `/settings` | Interactive menu to access Nanocoder settings (theme, title-shape, nanocoder-shape, paste-threshold) | +| `/settings` | Interactive settings menu. Accepts a tab name to jump straight there: `/settings providers`, `/settings mcp`, `/settings appearance`, `/settings input`, `/settings behavior`, `/settings advanced` | | `/mcp` | Show connected MCP servers and their tools | | `/custom-commands` | List all custom commands | | `/checkpoint` | Save and restore conversation snapshots (see [Checkpointing](checkpointing.md)) | diff --git a/docs/features/index.md b/docs/features/index.md index 3a1151244..a1c8b68d0 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -220,7 +220,7 @@ nanocoder --acp Extend Nanocoder's capabilities by connecting [MCP (Model Context Protocol) servers](../configuration/mcp-configuration.md). MCP servers add new tools the AI can use — from database queries to API calls to custom integrations. ```bash -/setup-mcp # interactive setup wizard +/settings mcp # interactive setup wizard /mcp # see connected servers and tools ``` diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 15a6ade98..c8624aff0 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -22,7 +22,7 @@ Welcome to Nanocoder! This section covers everything you need to install, config nanocoder ``` -3. **Configure** a provider when prompted, or run `/setup-providers` for the interactive wizard. +3. **Configure** a provider when prompted, or run `/settings providers` for the interactive wizard. ## CLI Options diff --git a/package.json b/package.json index 7dfe45949..d9300f79e 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "test:ava:coverage": "c8 ava", "test:format": "biome check --no-errors-on-unmatched .", "test:types": "tsc --noEmit", + "test:types:vscode": "tsc --noEmit -p plugins/vscode/tsconfig.json", "test:lint": "biome lint .", "test:lint:fix": "biome check --write --unsafe .", "test:knip": "knip", @@ -143,8 +144,6 @@ "plugins/**/*.spec.ts", "plugins/**/*.spec.tsx", "!plugins/**/node_modules/**", - "!plugins/vscode/src/acp-client.spec.ts", - "!plugins/vscode/src/acp-process-manager.spec.ts", "!source/**/*-helpers.ts", "!source/**/test-helpers.ts" ], diff --git a/plugins/vscode/.vscodeignore b/plugins/vscode/.vscodeignore index 031130ab2..1605242de 100644 --- a/plugins/vscode/.vscodeignore +++ b/plugins/vscode/.vscodeignore @@ -1,6 +1,7 @@ .vscode/** .vscode-test/** src/** +test-stubs/** node_modules/** .gitignore tsconfig.json diff --git a/plugins/vscode/README.md b/plugins/vscode/README.md index 655abc0a5..76cd89623 100644 --- a/plugins/vscode/README.md +++ b/plugins/vscode/README.md @@ -14,6 +14,7 @@ The extension provides a native sidebar chat powered by the Agent Client Protoco - **Task Checklist**: The AI's task list renders as a live checklist card with per-task status and progress - **Cancellation**: Stop ends the whole turn - the current tool aborts and queued tools are skipped - **Diff Previews**: Click a file-edit card to open the change in VS Code's diff viewer +- **Editor Code Lenses**: `Explain Code` and `Generate Tests` links above every function, method, constructor and class - clicking one opens the chat and sends that symbol as context - **Legacy Companion Mode** (opt-in): Pairs with a terminal CLI session over WebSocket for diff previews and editor context ## Installation @@ -126,6 +127,7 @@ Configure the extension in VS Code settings (`Ctrl+,` / `Cmd+,`): | `nanocoder.mode` | `auto-accept` | Operating mode for the assistant | | `nanocoder.model` | (empty) | Model for Nanocoder sessions (set via the model dropdown) | | `nanocoder.showDiffPreview` | `true` | Show diff preview before applying file changes | +| `nanocoder.codeLens` | `true` | Show Explain Code / Generate Tests lenses above functions and classes | | `nanocoder.autoConnect` | `false` | Auto-connect the legacy WebSocket companion on startup | | `nanocoder.autoStartCli` | `false` | Auto-start the CLI for companion mode if not running | | `nanocoder.serverPort` | `51820` | WebSocket port for the legacy companion mode | diff --git a/plugins/vscode/media/chat-panel.css b/plugins/vscode/media/chat-panel.css index aeef917cd..0d0c065da 100644 --- a/plugins/vscode/media/chat-panel.css +++ b/plugins/vscode/media/chat-panel.css @@ -1 +1 @@ -*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-10{top:-2.5rem}.bottom-24{bottom:6rem}.bottom-\[calc\(100\%\+8px\)\]{bottom:calc(100% + 8px)}.left-0{left:0}.left-1\/2{left:50%}.right-0{right:0}.top-0{top:0}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-3{margin-top:.75rem;margin-bottom:.75rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.ml-1{margin-left:.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mr-\[2px\]{margin-right:2px}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-\[1px\]{margin-top:1px}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.hidden{display:none}.h-12{height:3rem}.h-24{height:6rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-full{height:100%}.max-h-64{max-height:16rem}.max-h-\[250px\]{max-height:250px}.max-h-\[calc\(100vh-100px\)\]{max-height:calc(100vh - 100px)}.max-h-full{max-height:100%}.min-h-\[28px\]{min-height:28px}.min-h-\[44px\]{min-height:44px}.w-12{width:3rem}.w-24{width:6rem}.w-5{width:1.25rem}.w-8{width:2rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[15\%\]{max-width:15%}.max-w-\[30\%\]{max-width:30%}.max-w-\[40\%\]{max-width:40%}.max-w-\[85\%\]{max-width:85%}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-hidden{overflow-y:hidden}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-\[3px\]{border-left-width:3px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-vscode-border{border-color:var(--vscode-panel-border,hsla(0,0%,50%,.2))}.border-vscode-button-secondary{border-color:var(--vscode-button-secondaryBackground)}.border-vscode-focusBorder{border-color:var(--vscode-focusBorder)}.border-vscode-input-border{border-color:var(--vscode-input-border,transparent)}.border-vscode-input-focus{border-color:var(--vscode-focusBorder)}.border-vscode-widget-border{border-color:var(--vscode-widget-border)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-transparent{background-color:transparent}.bg-vscode-bg{background-color:var(--vscode-editor-background)}.bg-vscode-button-bg{background-color:var(--vscode-button-background)}.bg-vscode-button-secondary{background-color:var(--vscode-button-secondaryBackground)}.bg-vscode-dropdown-bg{background-color:var(--vscode-dropdown-background)}.bg-vscode-input-bg{background-color:var(--vscode-input-background)}.bg-vscode-list-active{background-color:var(--vscode-list-activeSelectionBackground)}.bg-vscode-widget-bg{background-color:var(--vscode-editorWidget-background)}.bg-vscode-widget-header{background-color:var(--vscode-editorWidget-border)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-3{padding-left:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.font-vscode{font-family:var(--vscode-font-family)}.text-\[0\.65em\]{font-size:.65em}.text-\[0\.75em\]{font-size:.75em}.text-\[0\.78em\]{font-size:.78em}.text-\[0\.85em\]{font-size:.85em}.text-\[0\.8em\]{font-size:.8em}.text-\[0\.95em\]{font-size:.95em}.text-\[0\.9em\]{font-size:.9em}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-\[0\.04em\]{letter-spacing:.04em}.tracking-\[0\.06em\]{letter-spacing:.06em}.text-\[\#3178C6\]{--tw-text-opacity:1;color:rgb(49 120 198/var(--tw-text-opacity,1))}.text-\[\#563D7C\]{--tw-text-opacity:1;color:rgb(86 61 124/var(--tw-text-opacity,1))}.text-\[\#89d185\]{--tw-text-opacity:1;color:rgb(137 209 133/var(--tw-text-opacity,1))}.text-\[\#CB3837\]{--tw-text-opacity:1;color:rgb(203 56 55/var(--tw-text-opacity,1))}.text-\[\#E34F26\]{--tw-text-opacity:1;color:rgb(227 79 38/var(--tw-text-opacity,1))}.text-\[\#F1E05A\]{--tw-text-opacity:1;color:rgb(241 224 90/var(--tw-text-opacity,1))}.text-\[\#cccccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity,1))}.text-\[\#f14c4c\]{--tw-text-opacity:1;color:rgb(241 76 76/var(--tw-text-opacity,1))}.text-vscode-button-fg{color:var(--vscode-button-foreground)}.text-vscode-dropdown-fg{color:var(--vscode-dropdown-foreground)}.text-vscode-fg{color:var(--vscode-editor-foreground)}.text-vscode-input-fg{color:var(--vscode-input-foreground)}.text-vscode-list-activeFg{color:var(--vscode-list-activeSelectionForeground)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}body,html{height:100%;width:100%;margin:0;padding:0;overflow:hidden;font-family:var(--vscode-font-family);font-size:var(--vscode-font-size)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--vscode-scrollbarSlider-background);border:3px solid transparent;background-clip:padding-box;border-radius:5px}::-webkit-scrollbar-thumb:hover{background:var(--vscode-scrollbarSlider-hoverBackground);border:3px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:active{background:var(--vscode-scrollbarSlider-activeBackground);border:3px solid transparent;background-clip:padding-box}select{border:1px solid var(--vscode-dropdown-border,transparent)}select,select option{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground)}.markdown-body p{margin-bottom:.5em;margin-top:0}.markdown-body p:last-child{margin-bottom:0}.markdown-body strong{font-weight:600}.markdown-body ul{list-style-type:disc}.markdown-body ol,.markdown-body ul{padding-left:1.5em;margin-bottom:.75em}.markdown-body ol{list-style-type:decimal}.markdown-body li{margin-bottom:.25em}.markdown-body code{font-family:var(--vscode-editor-font-family,monospace);padding:.1em .3em;border-radius:3px;font-size:.9em}.markdown-body code,.markdown-body pre{background-color:var(--vscode-textCodeBlock-background,rgba(0,0,0,.1))}.markdown-body pre{padding:.75em;border-radius:4px;overflow-x:auto;max-width:100%;margin-bottom:.75em}.markdown-body pre code{background-color:transparent;padding:0;font-size:.85em}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{font-weight:600;margin-top:1em;margin-bottom:.5em}.markdown-body h1{font-size:1.5em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.1em}.context-chip{display:inline-flex;align-items:center;gap:6px;max-width:200px;padding:4px 8px;border-radius:12px;border:1px solid var(--vscode-dropdown-border,hsla(0,0%,59%,.2));background-color:var(--vscode-editorWidget-background);font-size:.85em;font-weight:500;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap;transition:all .15s ease}.context-chip:hover{border-color:var(--vscode-focusBorder);opacity:.95}.context-chip .chip-name{overflow:hidden;text-overflow:ellipsis}.context-chip .chip-remove{margin-left:2px;opacity:.5;font-size:1.1em;line-height:1;cursor:pointer;transition:opacity .15s ease}.context-chip .chip-remove:hover{opacity:1}#composer-box.drag-over:after{content:"Drop files or folders";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border:2px dashed var(--vscode-focusBorder);border-radius:inherit;background:var(--vscode-input-background);opacity:.92;font-size:.85em;font-weight:600;pointer-events:none;z-index:10}#timeline-strip.timeline-disabled{opacity:.45;pointer-events:none}#timeline-nodes{position:relative}.timeline-line{position:absolute;left:10px;right:10px;top:50%;height:2px;background:var(--vscode-panel-border,hsla(0,0%,50%,.35));transform:translateY(-50%);pointer-events:none}.timeline-node{position:relative;z-index:1;width:28px;height:28px;display:flex;align-items:center;justify-content:center;flex-shrink:0;background:transparent;border:none;padding:0;cursor:pointer}.timeline-dot{width:10px;height:10px;border-radius:50%;border:2px solid var(--vscode-focusBorder);background:var(--vscode-editor-background);transition:transform .12s ease,background .12s ease}.timeline-node[data-kind=edit] .timeline-dot{border-color:var(--vscode-focusBorder)}.timeline-node[data-kind=execute] .timeline-dot{border-color:var(--vscode-editorWarning-foreground,#cca700)}.timeline-node[data-kind=other] .timeline-dot{border-color:var(--vscode-descriptionForeground,hsla(0,0%,50%,.8))}.timeline-node[data-kind=now] .timeline-dot{width:8px;height:8px;background:var(--vscode-button-background);border-color:var(--vscode-button-background)}.timeline-node.is-selected .timeline-dot,.timeline-node:hover .timeline-dot{transform:scale(1.35);background:var(--vscode-focusBorder)}.timeline-node[data-kind=now].is-selected .timeline-dot,.timeline-node[data-kind=now]:hover .timeline-dot{background:var(--vscode-button-background)}.timeline-tooltip{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);white-space:nowrap;max-width:240px;overflow:hidden;text-overflow:ellipsis;padding:4px 8px;border-radius:4px;background:var(--vscode-editorWidget-background);border:1px solid var(--vscode-widget-border);color:var(--vscode-editor-foreground);font-size:.75em;z-index:5;pointer-events:none}.timeline-node:hover .timeline-tooltip{display:block}.timeline-confirm-actions{display:flex;gap:8px;margin-top:8px}.timeline-confirm-actions button{border:none;border-radius:4px;padding:4px 10px;font-size:.85em;cursor:pointer;font-family:var(--vscode-font-family)}.first\:border-t-0:first-child{border-top-width:0}.empty\:hidden:empty{display:none}.focus-within\:border-vscode-input-focus:focus-within{border-color:var(--vscode-focusBorder)}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:bg-black\/80:hover{background-color:rgba(0,0,0,.8)}.hover\:bg-vscode-button-hover:hover{background-color:var(--vscode-button-hoverBackground)}.hover\:bg-vscode-button-secondaryHover:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.hover\:bg-vscode-list-hover:hover{background-color:var(--vscode-list-hoverBackground)}.hover\:bg-vscode-toolbarHover:hover{background-color:var(--vscode-toolbar-hoverBackground,hsla(0,0%,50%,.15))}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.group:hover .group-hover\:opacity-100{opacity:1}.group.is-processing .group-\[\.is-processing\]\:block{display:block}.group.is-processing .group-\[\.is-processing\]\:hidden{display:none}.\[\&\.is-processing\]\:bg-vscode-button-secondary.is-processing{background-color:var(--vscode-button-secondaryBackground)}.\[\&\.is-processing\]\:hover\:bg-vscode-button-secondaryHover:hover.is-processing{background-color:var(--vscode-button-secondaryHoverBackground)}.\[\&_svg\]\:mr-0 svg{margin-right:0}.\[\&_svg\]\:h-6 svg{height:1.5rem}.\[\&_svg\]\:w-6 svg{width:1.5rem} \ No newline at end of file +*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.pointer-events-none{pointer-events:none}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-top-10{top:-2.5rem}.bottom-24{bottom:6rem}.bottom-\[calc\(100\%\+8px\)\]{bottom:calc(100% + 8px)}.left-0{left:0}.left-1\/2{left:50%}.right-0{right:0}.top-0{top:0}.z-0{z-index:0}.z-10{z-index:10}.z-50{z-index:50}.mx-1{margin-left:.25rem;margin-right:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.my-3{margin-top:.75rem;margin-bottom:.75rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.ml-1{margin-left:.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mr-\[2px\]{margin-right:2px}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-\[1px\]{margin-top:1px}.\!block{display:block!important}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.hidden{display:none}.h-12{height:3rem}.h-24{height:6rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-full{height:100%}.max-h-64{max-height:16rem}.max-h-\[250px\]{max-height:250px}.max-h-\[calc\(100vh-100px\)\]{max-height:calc(100vh - 100px)}.max-h-full{max-height:100%}.min-h-\[28px\]{min-height:28px}.min-h-\[44px\]{min-height:44px}.w-12{width:3rem}.w-24{width:6rem}.w-5{width:1.25rem}.w-8{width:2rem}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[15\%\]{max-width:15%}.max-w-\[30\%\]{max-width:30%}.max-w-\[40\%\]{max-width:40%}.max-w-\[85\%\]{max-width:85%}.max-w-full{max-width:100%}.flex-1{flex:1 1 0%}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-hidden{overflow-y:hidden}.truncate{overflow:hidden;white-space:nowrap}.text-ellipsis,.truncate{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-bl{border-bottom-left-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-\[3px\]{border-left-width:3px}.border-t{border-top-width:1px}.border-none{border-style:none}.border-vscode-border{border-color:var(--vscode-panel-border,hsla(0,0%,50%,.2))}.border-vscode-button-secondary{border-color:var(--vscode-button-secondaryBackground)}.border-vscode-focusBorder{border-color:var(--vscode-focusBorder)}.border-vscode-input-border{border-color:var(--vscode-input-border,transparent)}.border-vscode-input-focus{border-color:var(--vscode-focusBorder)}.border-vscode-widget-border{border-color:var(--vscode-widget-border)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-transparent{background-color:transparent}.bg-vscode-bg{background-color:var(--vscode-editor-background)}.bg-vscode-button-bg{background-color:var(--vscode-button-background)}.bg-vscode-button-secondary{background-color:var(--vscode-button-secondaryBackground)}.bg-vscode-dropdown-bg{background-color:var(--vscode-dropdown-background)}.bg-vscode-input-bg{background-color:var(--vscode-input-background)}.bg-vscode-list-active{background-color:var(--vscode-list-activeSelectionBackground)}.bg-vscode-widget-bg{background-color:var(--vscode-editorWidget-background)}.bg-vscode-widget-header{background-color:var(--vscode-editorWidget-border)}.object-contain{-o-object-fit:contain;object-fit:contain}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.pb-1{padding-bottom:.25rem}.pb-1\.5{padding-bottom:.375rem}.pb-2{padding-bottom:.5rem}.pl-3{padding-left:.75rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-2\.5{padding-top:.625rem}.pt-3{padding-top:.75rem}.text-left{text-align:left}.text-center{text-align:center}.font-vscode{font-family:var(--vscode-font-family)}.text-\[0\.65em\]{font-size:.65em}.text-\[0\.75em\]{font-size:.75em}.text-\[0\.78em\]{font-size:.78em}.text-\[0\.85em\]{font-size:.85em}.text-\[0\.8em\]{font-size:.8em}.text-\[0\.95em\]{font-size:.95em}.text-\[0\.9em\]{font-size:.9em}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.tracking-\[0\.04em\]{letter-spacing:.04em}.tracking-\[0\.06em\]{letter-spacing:.06em}.text-\[\#3178C6\]{--tw-text-opacity:1;color:rgb(49 120 198/var(--tw-text-opacity,1))}.text-\[\#563D7C\]{--tw-text-opacity:1;color:rgb(86 61 124/var(--tw-text-opacity,1))}.text-\[\#89d185\]{--tw-text-opacity:1;color:rgb(137 209 133/var(--tw-text-opacity,1))}.text-\[\#CB3837\]{--tw-text-opacity:1;color:rgb(203 56 55/var(--tw-text-opacity,1))}.text-\[\#E34F26\]{--tw-text-opacity:1;color:rgb(227 79 38/var(--tw-text-opacity,1))}.text-\[\#F1E05A\]{--tw-text-opacity:1;color:rgb(241 224 90/var(--tw-text-opacity,1))}.text-\[\#cccccc\]{--tw-text-opacity:1;color:rgb(204 204 204/var(--tw-text-opacity,1))}.text-\[\#f14c4c\]{--tw-text-opacity:1;color:rgb(241 76 76/var(--tw-text-opacity,1))}.text-vscode-button-fg{color:var(--vscode-button-foreground)}.text-vscode-dropdown-fg{color:var(--vscode-dropdown-foreground)}.text-vscode-fg{color:var(--vscode-editor-foreground)}.text-vscode-input-fg{color:var(--vscode-input-foreground)}.text-vscode-list-activeFg{color:var(--vscode-list-activeSelectionForeground)}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.line-through{text-decoration-line:line-through}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.shadow-sm,.shadow-xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color)}.outline-none{outline:2px solid transparent;outline-offset:2px}.blur{--tw-blur:blur(8px)}.blur,.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}body,html{height:100%;width:100%;margin:0;padding:0;overflow:hidden;font-family:var(--vscode-font-family);font-size:var(--vscode-font-size)}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--vscode-scrollbarSlider-background);border:3px solid transparent;background-clip:padding-box;border-radius:5px}::-webkit-scrollbar-thumb:hover{background:var(--vscode-scrollbarSlider-hoverBackground);border:3px solid transparent;background-clip:padding-box}::-webkit-scrollbar-thumb:active{background:var(--vscode-scrollbarSlider-activeBackground);border:3px solid transparent;background-clip:padding-box}select{border:1px solid var(--vscode-dropdown-border,transparent)}select,select option{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground)}.markdown-body p{margin-bottom:.5em;margin-top:0}.markdown-body p:last-child{margin-bottom:0}.markdown-body strong{font-weight:600}.markdown-body ul{list-style-type:disc}.markdown-body ol,.markdown-body ul{padding-left:1.5em;margin-bottom:.75em}.markdown-body ol{list-style-type:decimal}.markdown-body li{margin-bottom:.25em}.markdown-body code{font-family:var(--vscode-editor-font-family,monospace);padding:.1em .3em;border-radius:3px;font-size:.9em}.markdown-body code,.markdown-body pre{background-color:var(--vscode-textCodeBlock-background,rgba(0,0,0,.1))}.markdown-body pre{padding:.75em;border-radius:4px;overflow-x:auto;max-width:100%;margin-bottom:.75em}.markdown-body pre code{background-color:transparent;padding:0;font-size:.85em}.markdown-body h1,.markdown-body h2,.markdown-body h3,.markdown-body h4{font-weight:600;margin-top:1em;margin-bottom:.5em}.markdown-body h1{font-size:1.5em}.markdown-body h2{font-size:1.3em}.markdown-body h3{font-size:1.1em}.context-chip{display:inline-flex;align-items:center;gap:6px;max-width:200px;padding:4px 8px;border-radius:12px;border:1px solid var(--vscode-dropdown-border,hsla(0,0%,59%,.2));background-color:var(--vscode-editorWidget-background);font-size:.85em;font-weight:500;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;white-space:nowrap;transition:all .15s ease}.context-chip:hover{border-color:var(--vscode-focusBorder);opacity:.95}.context-chip .chip-name{overflow:hidden;text-overflow:ellipsis}.context-chip .chip-remove{margin-left:2px;opacity:.5;font-size:1.1em;line-height:1;cursor:pointer;transition:opacity .15s ease}.context-chip .chip-remove:hover{opacity:1}#composer-box.drag-over:after{content:"Drop files or folders";position:absolute;inset:0;display:flex;align-items:center;justify-content:center;border:2px dashed var(--vscode-focusBorder);border-radius:inherit;background:var(--vscode-input-background);opacity:.92;font-size:.85em;font-weight:600;pointer-events:none;z-index:10}#timeline-strip.timeline-disabled{opacity:.45;pointer-events:none}#timeline-nodes{position:relative}.timeline-line{position:absolute;left:10px;right:10px;top:50%;height:2px;background:var(--vscode-panel-border,hsla(0,0%,50%,.35));transform:translateY(-50%);pointer-events:none}.timeline-node{position:relative;z-index:1;width:28px;height:28px;display:flex;align-items:center;justify-content:center;flex-shrink:0;background:transparent;border:none;padding:0;cursor:pointer}.timeline-dot{width:10px;height:10px;border-radius:50%;border:2px solid var(--vscode-focusBorder);background:var(--vscode-editor-background);transition:transform .12s ease,background .12s ease}.timeline-node[data-kind=edit] .timeline-dot{border-color:var(--vscode-focusBorder)}.timeline-node[data-kind=execute] .timeline-dot{border-color:var(--vscode-editorWarning-foreground,#cca700)}.timeline-node[data-kind=other] .timeline-dot{border-color:var(--vscode-descriptionForeground,hsla(0,0%,50%,.8))}.timeline-node[data-kind=now] .timeline-dot{width:8px;height:8px;background:var(--vscode-button-background);border-color:var(--vscode-button-background)}.timeline-node.is-selected .timeline-dot,.timeline-node:hover .timeline-dot{transform:scale(1.35);background:var(--vscode-focusBorder)}.timeline-node[data-kind=now].is-selected .timeline-dot,.timeline-node[data-kind=now]:hover .timeline-dot{background:var(--vscode-button-background)}.timeline-tooltip{display:none;position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);white-space:nowrap;max-width:240px;overflow:hidden;text-overflow:ellipsis;padding:4px 8px;border-radius:4px;background:var(--vscode-editorWidget-background);border:1px solid var(--vscode-widget-border);color:var(--vscode-editor-foreground);font-size:.75em;z-index:5;pointer-events:none}.timeline-node:hover .timeline-tooltip{display:block}.timeline-confirm-actions{display:flex;gap:8px;margin-top:8px}.timeline-confirm-actions button{border:none;border-radius:4px;padding:4px 10px}.settings-tab,.timeline-confirm-actions button{font-size:.85em;cursor:pointer;font-family:var(--vscode-font-family)}.settings-tab{background:transparent;border:none;border-bottom:2px solid transparent;color:var(--vscode-editor-foreground);opacity:.6;padding:.5rem .75rem;transition:opacity .15s,border-color .15s}.settings-tab:hover{opacity:.9}.settings-tab.active{opacity:1;border-bottom-color:var(--vscode-focusBorder);font-weight:600}.settings-section{background:var(--vscode-editorWidget-background);border:1px solid var(--vscode-widget-border);border-radius:6px;padding:.75rem}.settings-section-title{font-size:.8em;font-weight:600;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-bottom:.5rem}.settings-list{display:flex;flex-direction:column;gap:0}.settings-list-item{display:flex;align-items:center;gap:.5rem;padding:.375rem 0;font-size:.9em;border-bottom:1px solid var(--vscode-widget-border)}.settings-list-item:last-child{border-bottom:none}.settings-list-item-name{font-weight:500;flex-shrink:0}.settings-list-item-detail{opacity:.6;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.settings-list-empty{font-size:.85em;opacity:.5;padding:.25rem 0;font-style:italic}.settings-row{display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding:.375rem 0;border-bottom:1px solid var(--vscode-widget-border)}.settings-row:last-child{border-bottom:none}.settings-row-info{display:flex;flex-direction:column;flex:1;min-width:0}.settings-row-label{font-size:.9em;font-weight:500}.settings-row-desc{font-size:.78em;opacity:.55;margin-top:.1em}.settings-toggle{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;cursor:pointer}.settings-toggle input{opacity:0;width:0;height:0}.settings-toggle-slider{position:absolute;top:0;left:0;right:0;bottom:0;background-color:var(--vscode-input-background);border:1px solid var(--vscode-input-border,transparent);border-radius:20px;transition:background-color .2s}.settings-toggle-slider:before{content:"";position:absolute;height:14px;width:14px;left:2px;bottom:2px;background-color:var(--vscode-editor-foreground);opacity:.6;border-radius:50%;transition:transform .2s,opacity .2s}.settings-toggle input:checked+.settings-toggle-slider{background-color:var(--vscode-button-background);border-color:var(--vscode-button-background)}.settings-toggle input:checked+.settings-toggle-slider:before{transform:translateX(16px);opacity:1;background-color:var(--vscode-button-foreground)}.settings-select{background-color:var(--vscode-dropdown-background);color:var(--vscode-dropdown-foreground);border:1px solid var(--vscode-dropdown-border,transparent);border-radius:4px;padding:.25rem .5rem;font-family:var(--vscode-font-family);font-size:.85em;cursor:pointer;outline:none;min-width:100px}.settings-select:focus{border-color:var(--vscode-focusBorder)}.settings-number-input{background-color:var(--vscode-input-background);color:var(--vscode-input-foreground);border:1px solid var(--vscode-input-border,transparent);border-radius:4px;padding:.25rem .5rem;font-family:var(--vscode-font-family);font-size:.85em;width:70px;outline:none;text-align:center}.settings-number-input:focus{border-color:var(--vscode-focusBorder)}.settings-action-btn{display:flex;align-items:center;gap:.5rem;background:transparent;border:1px solid var(--vscode-button-secondaryBackground);color:var(--vscode-editor-foreground);border-radius:4px;padding:.375rem .625rem;font-family:var(--vscode-font-family);font-size:.85em;cursor:pointer;transition:background-color .15s}.settings-action-btn:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.settings-action-btn-danger{border-color:rgba(241,76,76,.4);color:#f14c4c}.settings-action-btn-danger:hover{background-color:rgba(241,76,76,.1)}.settings-badge{display:inline-flex;align-items:center;gap:.25rem;font-size:.78em;padding:.125rem .375rem;border-radius:3px;font-weight:500}.settings-badge-ok{background-color:rgba(137,209,133,.15);color:#89d185}.settings-badge-off{background-color:hsla(0,0%,80%,.1);color:#999}.first\:border-t-0:first-child{border-top-width:0}.empty\:hidden:empty{display:none}.focus-within\:border-vscode-input-focus:focus-within{border-color:var(--vscode-focusBorder)}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:bg-black\/80:hover{background-color:rgba(0,0,0,.8)}.hover\:bg-vscode-button-hover:hover{background-color:var(--vscode-button-hoverBackground)}.hover\:bg-vscode-button-secondaryHover:hover{background-color:var(--vscode-button-secondaryHoverBackground)}.hover\:bg-vscode-list-hover:hover{background-color:var(--vscode-list-hoverBackground)}.hover\:bg-vscode-toolbarHover:hover{background-color:var(--vscode-toolbar-hoverBackground,hsla(0,0%,50%,.15))}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-80:hover{opacity:.8}.hover\:opacity-90:hover{opacity:.9}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:opacity-100{opacity:1}.group.is-processing .group-\[\.is-processing\]\:block{display:block}.group.is-processing .group-\[\.is-processing\]\:hidden{display:none}.\[\&\.is-processing\]\:bg-vscode-button-secondary.is-processing{background-color:var(--vscode-button-secondaryBackground)}.\[\&\.is-processing\]\:hover\:bg-vscode-button-secondaryHover:hover.is-processing{background-color:var(--vscode-button-secondaryHoverBackground)}.\[\&_svg\]\:mr-0 svg{margin-right:0}.\[\&_svg\]\:h-6 svg{height:1.5rem}.\[\&_svg\]\:w-6 svg{width:1.5rem} \ No newline at end of file diff --git a/plugins/vscode/media/chat-panel.html b/plugins/vscode/media/chat-panel.html index 085f11495..bf72dcf66 100644 --- a/plugins/vscode/media/chat-panel.html +++ b/plugins/vscode/media/chat-panel.html @@ -45,6 +45,187 @@
+ + +
diff --git a/plugins/vscode/media/chat-panel.js b/plugins/vscode/media/chat-panel.js index f205c46c6..4ef284f0a 100644 --- a/plugins/vscode/media/chat-panel.js +++ b/plugins/vscode/media/chat-panel.js @@ -499,6 +499,8 @@ function toggleHistoryView() { isHistoryView = !isHistoryView; if (isHistoryView) { + isSettingsView = false; + document.getElementById('settings-view').classList.add('hidden'); document.getElementById('chat-view').classList.add('hidden'); document.getElementById('history-view').classList.remove('hidden'); // Fetch sessions from extension host and render immediately @@ -511,8 +513,10 @@ function showChatView() { isHistoryView = false; - document.getElementById('chat-view').classList.remove('hidden'); + isSettingsView = false; document.getElementById('history-view').classList.add('hidden'); + document.getElementById('settings-view').classList.add('hidden'); + document.getElementById('chat-view').classList.remove('hidden'); } const sendStopBtn = document.getElementById('send-stop-btn'); @@ -529,6 +533,7 @@ let isProcessing = false; let currentAggregator = null; let currentThoughtBox = null; + let currentTurnFooter = null; let visualLoader = null; const toolKinds = new Map(); let toastTimeout = null; @@ -593,7 +598,7 @@ function createMessageFooter(getText, role, sentAt) { const footer = document.createElement('div'); - footer.className = 'flex h-5 items-center gap-1.5 mt-1 text-xs text-vscode-fg opacity-60 ' + + footer.className = 'message-footer flex h-5 items-center gap-1.5 mt-1 text-xs text-vscode-fg opacity-60 ' + (role === 'user' ? 'self-end' : 'self-start'); const btn = document.createElement('button'); @@ -965,13 +970,6 @@ const imagesToSubmit = pendingImages.length > 0 ? [...pendingImages] : undefined; - // Send message to extension host - vscode.postMessage({ - type: 'submitMessage', - text: text, - images: imagesToSubmit - }); - // Clear input. Close the mention first — its token offsets point into // text that is about to disappear. closeMention(); @@ -984,8 +982,23 @@ attachedPaths = []; renderChips(); + dispatchPrompt(text, imagesToSubmit); + } + + // Send `text` to the agent as a turn of its own. Split out of + // submitMessage so an editor-driven prompt can bypass the composer: going + // through it would overwrite a draft the user is typing and sweep up chips + // and images they staged for a different question. + function dispatchPrompt(text, images) { + // Send message to extension host + vscode.postMessage({ + type: 'submitMessage', + text: text, + images: images + }); + // Optimistically append user message - appendMessage(text, 'user', imagesToSubmit); + appendMessage(text, 'user', images); pendingUserMessageText = text; if (!isProcessing) { @@ -1194,7 +1207,10 @@ // A user message opens a new turn, so the agent segments that follow get // a fresh id. The raw-text accumulator is handed over lazily, once the // new response produces text. - if (role === 'user') agentTurnId++; + if (role === 'user') { + agentTurnId++; + currentTurnFooter = null; + } const wrapper = document.createElement('div'); wrapper.className = 'group flex flex-col min-w-0 shrink-0 ' + @@ -1382,8 +1398,15 @@ msgEl.appendChild(textContainer); wrapper.appendChild(msgEl); - wrapper.appendChild(createMessageFooter(() => wrapper.dataset.rawText || '', 'agent', new Date())); - wrapper.dataset.rawText = currentTurnText; + if (currentTurnFooter) { + currentTurnFooter.remove(); + } else { + // captures footer, not currentTurnFooter - avoids copying the next turn's text + const footer = createMessageFooter(() => footer.dataset.rawText || '', 'agent', new Date()); + currentTurnFooter = footer; + } + currentTurnFooter.dataset.rawText = lastAgentRawText; + wrapper.appendChild(currentTurnFooter); messagesContainer.appendChild(wrapper); currentTurnEl = msgEl; @@ -1393,8 +1416,8 @@ // Append to existing turn currentTurnText += textChunk; syncLastAgentRawText(); - if (currentTurnEl.parentElement) { - currentTurnEl.parentElement.dataset.rawText = currentTurnText; + if (currentTurnFooter) { + currentTurnFooter.dataset.rawText = lastAgentRawText; } if (typeof marked !== 'undefined') { @@ -1574,7 +1597,8 @@ case 'clear': // Session reset (new chat or resume) should return to the active // chat view, not leave the panel stuck on the history list. - showChatView(); + if (isHistoryView) showChatView(); + if (isSettingsView) hideSettingsView(); if (renderTimeout) { clearTimeout(renderTimeout); renderTimeout = null; } if (message.isLoading) { messagesContainer.innerHTML = `
${ICONS.pending}
Loading session...
`; @@ -1584,6 +1608,7 @@ currentTurnEl = null; currentTextEl = null; currentTurnText = ''; + currentTurnFooter = null; toolKinds.clear(); agentTurnId = 0; lastAgentRawTurnId = -1; @@ -1616,7 +1641,17 @@ case 'permissionsCancelled': handlePermissionsCancelled(message.toolCallIds); break; - + case 'toggleSettings': + toggleSettingsView(); + break; + case 'settingsData': + renderSettingsData(message.settings); + break; + case 'settingsUpdated': + if (!message.success) { + console.error('Failed to update setting:', message.error); + } + break; case 'syncState': handleSyncState(message); break; @@ -1627,6 +1662,11 @@ case 'updateTimeline': timelineStrip.setEntries(message.entries || []); break; + case 'runPrompt': + if (isHistoryView) showChatView(); + dispatchPrompt(message.text); + chatInput.focus(); + break; case 'copyLastCodeBlock': copyLastCodeBlock(); break; @@ -1816,6 +1856,20 @@ syncLastAgentRawText(); } + function aggregatorHasPendingTools(aggregator) { + for (const item of aggregator.toolItems.values()) { + if (item.dataset.pending === 'true') return true; + } + return false; + } + + function closeAggregatorIfIdle() { + if (currentAggregator && !aggregatorHasPendingTools(currentAggregator)) { + currentAggregator.close(); + currentAggregator = null; + } + } + function handleAcpUpdate(payload) { if (!payload) return; const update = payload.update ? payload.update : payload; @@ -1839,6 +1893,7 @@ if (currentThoughtBox) { currentThoughtBox.pause(); } + closeAggregatorIfIdle(); if (update.content && update.content.text) { stopVisualLoader(); appendChunk(update.content.text); @@ -1847,6 +1902,7 @@ if (!currentThoughtBox) { endCurrentTextBlock(); currentThoughtBox = new ThoughtAggregator(); + closeAggregatorIfIdle(); } if (update.content && update.content.text) { currentThoughtBox.append(update.content.text); @@ -1871,6 +1927,208 @@ keepVisualLoaderAtBottom(); } + // ─── Settings Panel Logic ─────────────────────────────────────── + + let isSettingsView = false; + + function showSettingsView() { + isSettingsView = true; + isHistoryView = false; + document.getElementById('chat-view').classList.add('hidden'); + document.getElementById('history-view').classList.add('hidden'); + document.getElementById('settings-view').classList.remove('hidden'); + // Request fresh settings data from extension host + vscode.postMessage({ type: 'requestSettings' }); + } + + function hideSettingsView() { + isSettingsView = false; + document.getElementById('settings-view').classList.add('hidden'); + showChatView(); + } + + function toggleSettingsView() { + if (isSettingsView) { + hideSettingsView(); + } else { + showSettingsView(); + } + } + + // Settings tab switching + document.querySelectorAll('.settings-tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + const tabId = tab.dataset.tab; + document.querySelectorAll('.settings-tab-content').forEach(c => c.classList.add('hidden')); + const content = document.getElementById('settings-tab-' + tabId); + if (content) content.classList.remove('hidden'); + }); + }); + + // Settings action buttons (edit config, restart, etc.) + document.querySelectorAll('.settings-action-btn').forEach(btn => { + btn.addEventListener('click', () => { + const action = btn.dataset.action; + if (action === 'edit-providers' || action === 'edit-mcp' || action === 'edit-tools' || action === 'open-agents-config') { + vscode.postMessage({ type: 'openConfigFile', file: 'agents.config.json' }); + } else if (action === 'open-preferences') { + vscode.postMessage({ type: 'openConfigFile', file: 'nanocoder-preferences.json' }); + } else if (action === 'restart-acp') { + vscode.postMessage({ type: 'restartAcp' }); + } + }); + }); + + // Behavior tab — interactive controls change handlers + function initSettingsControls() { + // Default mode + const modeSelect = document.getElementById('setting-defaultMode'); + if (modeSelect) { + modeSelect.addEventListener('change', () => { + vscode.postMessage({ type: 'updateSetting', key: 'defaultMode', value: modeSelect.value || null }); + }); + } + + // Auto-compact enabled + const acEnabled = document.getElementById('setting-autoCompact-enabled'); + if (acEnabled) { + acEnabled.addEventListener('change', () => { + vscode.postMessage({ type: 'updateSetting', key: 'autoCompact.enabled', value: acEnabled.checked }); + }); + } + + // Auto-compact threshold + const acThreshold = document.getElementById('setting-autoCompact-threshold'); + if (acThreshold) { + acThreshold.addEventListener('change', () => { + const val = parseInt(acThreshold.value, 10); + if (!isNaN(val) && val >= 50 && val <= 95) { + vscode.postMessage({ type: 'updateSetting', key: 'autoCompact.threshold', value: val }); + } + }); + } + + // Auto-compact mode + const acMode = document.getElementById('setting-autoCompact-mode'); + if (acMode) { + acMode.addEventListener('change', () => { + vscode.postMessage({ type: 'updateSetting', key: 'autoCompact.mode', value: acMode.value }); + }); + } + + // Reasoning traces + const rtToggle = document.getElementById('setting-reasoningTraces'); + if (rtToggle) { + rtToggle.addEventListener('change', () => { + vscode.postMessage({ type: 'updateSetting', key: 'reasoningTraces', value: rtToggle.checked }); + }); + } + + // Sessions auto-save + const saToggle = document.getElementById('setting-sessions-autoSave'); + if (saToggle) { + saToggle.addEventListener('change', () => { + vscode.postMessage({ type: 'updateSetting', key: 'sessions.autoSave', value: saToggle.checked }); + }); + } + } + initSettingsControls(); + + /** + * Populate the settings UI with data received from the extension host. + */ + function renderSettingsData(settings) { + // ── Providers list ── + const providersList = document.getElementById('settings-providers-list'); + if (providersList) { + if (settings.providers.length === 0) { + providersList.innerHTML = '
No providers configured
'; + } else { + providersList.innerHTML = settings.providers.map(p => { + const detail = p.baseUrl || 'default endpoint'; + const models = p.models.length > 0 + ? p.models[0] + (p.models.length > 1 ? ` +${p.models.length - 1}` : '') + : 'no models'; + const keyBadge = p.apiKeySet + ? 'Key ✓' + : 'No key'; + return `
+ ${escapeHtml(p.name)} + ${escapeHtml(detail)} · ${escapeHtml(models)} + ${keyBadge} +
`; + }).join(''); + } + } + + // ── MCP Servers list ── + const mcpList = document.getElementById('settings-mcp-list'); + if (mcpList) { + if (settings.mcpServers.length === 0) { + mcpList.innerHTML = '
No MCP servers configured
'; + } else { + mcpList.innerHTML = settings.mcpServers.map(s => { + const detail = s.command || s.url || '(no endpoint)'; + return `
+ ${escapeHtml(s.name)} + ${escapeHtml(s.transport)} · ${escapeHtml(detail)} +
`; + }).join(''); + } + } + + // ── Tool auto-approval list ── + const toolsList = document.getElementById('settings-tools-list'); + if (toolsList) { + if (settings.alwaysAllow.length === 0) { + toolsList.innerHTML = '
No tools auto-approved
'; + } else { + toolsList.innerHTML = settings.alwaysAllow.map(t => + `
+ ${escapeHtml(t)} +
` + ).join(''); + } + } + + // ── Web search status ── + const wsStatus = document.getElementById('settings-websearch-status'); + if (wsStatus) { + wsStatus.innerHTML = settings.webSearch.configured + ? '
API key configured ✓
' + : '
Not configured
'; + } + + // ── Behavior controls ── + const modeSelect = document.getElementById('setting-defaultMode'); + if (modeSelect) modeSelect.value = settings.defaultMode || 'normal'; + + const acEnabled = document.getElementById('setting-autoCompact-enabled'); + if (acEnabled) acEnabled.checked = settings.autoCompact.enabled; + + const acThreshold = document.getElementById('setting-autoCompact-threshold'); + if (acThreshold) acThreshold.value = settings.autoCompact.threshold; + + const acMode = document.getElementById('setting-autoCompact-mode'); + if (acMode) acMode.value = settings.autoCompact.mode; + + const rtToggle = document.getElementById('setting-reasoningTraces'); + if (rtToggle) rtToggle.checked = settings.reasoningTraces; + + const saToggle = document.getElementById('setting-sessions-autoSave'); + if (saToggle) saToggle.checked = settings.sessions.autoSave; + } + + function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + // ─── End Settings Panel Logic ─────────────────────────────────── + class ThoughtAggregator { constructor() { this.el = document.createElement('div'); @@ -2020,8 +2278,8 @@ messagesContainer.appendChild(this.el); } - toggle() { - this.isOpen = !this.isOpen; + toggle(force) { + this.isOpen = force !== undefined ? force : !this.isOpen; this.body.style.display = this.isOpen ? '' : 'none'; const svg = this.chevron.querySelector('svg'); @@ -2078,20 +2336,27 @@ statusEl.dataset.status = update.status || 'pending'; if (update.status === 'success' || update.status === 'completed') { statusEl.innerHTML = ICONS.success; + item.dataset.pending = 'false'; } else if ( update.status === 'cancelled' || update.status === 'denied' || // ACP has no 'cancelled' status, so a cancel arrives as failed with // 'Cancelled by user'. Case-insensitive, or the capital C misses. - (update.status === 'failed' && update.rawOutput && typeof update.rawOutput === 'string' && /aborterror|cancelled/i.test(update.rawOutput)) + (update.status === 'failed' && update.rawOutput && typeof update.rawOutput === 'string' && /aborterror|cancelled|denied/i.test(update.rawOutput)) ) { statusEl.innerHTML = ICONS.cancelled; + item.dataset.pending = 'false'; } else if (update.status === 'error' || update.status === 'failed') { statusEl.innerHTML = ICONS.error; + item.dataset.pending = 'false'; } else if (update.status === 'pending') { + // Queued, not yet running - still unfinished, so the + // aggregator must stay open for it. statusEl.innerHTML = ICONS.circle; + item.dataset.pending = 'true'; } else { statusEl.innerHTML = ICONS.pending; + item.dataset.pending = 'true'; } } @@ -2113,6 +2378,7 @@ if (!card) { endCurrentTextBlock(); + closeAggregatorIfIdle(); card = document.createElement('div'); card.id = `plan-card-${agentTurnId}`; card.className = 'my-3 border border-vscode-widget-border rounded bg-vscode-widget-bg overflow-hidden shrink-0'; @@ -2186,6 +2452,7 @@ if (toolKinds.get(toolCallId) === 'edit') { let card = document.getElementById(`tool-card-${toolCallId}`); if (!card) { + closeAggregatorIfIdle(); card = createEditCard(toolCallId, update); messagesContainer.appendChild(card); scrollToBottom(); diff --git a/plugins/vscode/package.json b/plugins/vscode/package.json index a86abdd57..75e1dd583 100644 --- a/plugins/vscode/package.json +++ b/plugins/vscode/package.json @@ -82,6 +82,12 @@ "category": "Nanocoder", "icon": "$(add)" }, + { + "command": "nanocoder.toggleSettings", + "title": "Settings", + "category": "Nanocoder", + "icon": "$(gear)" + }, { "command": "nanocoder.cancel", "title": "Cancel Current Response", @@ -91,6 +97,16 @@ "command": "nanocoder.copyLastCodeBlock", "title": "Copy Last Code Block", "category": "Nanocoder" + }, + { + "command": "nanocoder.explainCode", + "title": "Explain Code", + "category": "Nanocoder" + }, + { + "command": "nanocoder.generateTests", + "title": "Generate Tests", + "category": "Nanocoder" } ], "keybindings": [ @@ -112,6 +128,21 @@ "command": "nanocoder.toggleHistory", "when": "view == nanocoder.chatView", "group": "navigation@2" + }, + { + "command": "nanocoder.toggleSettings", + "when": "view == nanocoder.chatView", + "group": "navigation@3" + } + ], + "commandPalette": [ + { + "command": "nanocoder.explainCode", + "when": "false" + }, + { + "command": "nanocoder.generateTests", + "when": "false" } ] }, @@ -158,6 +189,12 @@ "type": "string", "scope": "resource", "description": "Working directory for the Nanocoder CLI. Defaults to the current VS Code workspace root." + }, + "nanocoder.codeLens": { + "type": "boolean", + "default": true, + "scope": "resource", + "description": "Show Explain Code / Generate Tests actions above functions and classes in the editor." } } } diff --git a/plugins/vscode/src/acp-client.spec.ts b/plugins/vscode/src/acp-client.spec.ts index c632be87c..78f111232 100644 --- a/plugins/vscode/src/acp-client.spec.ts +++ b/plugins/vscode/src/acp-client.spec.ts @@ -50,6 +50,7 @@ test('NanocoderAcpClient - cancel resolves and clears pending permissions', asyn const requestPromise = client.handlePermissionRequest({ toolCall: { toolCallId: 'call_123', name: 'write_file', arguments: {} }, }); + t.true(client.hasPendingPermissions()); await client.cancel(); @@ -152,3 +153,18 @@ test('NanocoderAcpClient - revertTimeline calls timeline/revert', async (t) => { t.deepEqual(called.params, {sessionId: 'session-1', checkpointId: 'cp-1'}); }); +test('NanocoderAcpClient - reconnecting clears permissions left by the dead process', async (t) => { + const outputChannel = {appendLine: () => {}} as any; + const stateManager = new AcpStateManager(); + const client = new NanocoderAcpClient(outputChannel, stateManager); + + const requestPromise = client.handlePermissionRequest({ + toolCall: {toolCallId: 'call_456', name: 'test_tool', arguments: {}}, + }); + + client.setConnection({} as any); + + const result = await requestPromise; + t.is((result as any).outcome.outcome, 'cancelled'); + t.false(client.hasPendingPermissions()); +}); diff --git a/plugins/vscode/src/acp-client.ts b/plugins/vscode/src/acp-client.ts index 0b6ab558a..e5fc78aba 100644 --- a/plugins/vscode/src/acp-client.ts +++ b/plugins/vscode/src/acp-client.ts @@ -101,6 +101,7 @@ export class NanocoderAcpClient { setConnection(connection: ClientSideConnection): void { this.connection = connection; this._sessionId = undefined; // Clear any stale session to force re-creation + this._clearPendingPermissions(); } async handlePermissionRequest(params: any): Promise { @@ -346,10 +347,10 @@ export class NanocoderAcpClient { } async cancel(): Promise { - if (!this.connection || !this._sessionId) return; this.cancelRequested = true; // Before the notification, so the map is emptied even if cancel() throws. this._clearPendingPermissions(); + if (!this.connection || !this._sessionId) return; try { await this.connection.cancel({ sessionId: this._sessionId diff --git a/plugins/vscode/src/chat-webview-provider.ts b/plugins/vscode/src/chat-webview-provider.ts index 9fdd3097c..ff1683c64 100644 --- a/plugins/vscode/src/chat-webview-provider.ts +++ b/plugins/vscode/src/chat-webview-provider.ts @@ -3,8 +3,11 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { WebviewToExtensionMessage, ExtensionToWebviewMessage, MentionItem } from './webview-protocol'; + + import { NanocoderAcpClient } from './acp-client'; import { DiffManager } from './diff-manager'; +import { SettingsManager } from './settings-manager'; import { searchMentions, MentionSearchDeps } from './mention-search'; import { readCappedFile, readCappedDirectory } from './context-attachment'; @@ -22,19 +25,34 @@ const MENTION_ALWAYS_EXCLUDE = [ '**/coverage/**', ]; -export class ChatWebviewProvider implements vscode.WebviewViewProvider { +/** + * How long an editor-driven prompt waits for the webview shell and the ACP + * session before it is dropped. Without a bound, a prompt queued while the CLI + * is down would fire whenever the connection eventually came up - long after + * the user moved on from the code they clicked. + */ +const PENDING_PROMPT_TIMEOUT_MS = 30_000; + +export class ChatWebviewProvider + implements vscode.WebviewViewProvider, vscode.Disposable { public static readonly viewType = 'nanocoder.chatView'; private _view?: vscode.WebviewView; private _isWebviewReady = false; private _timelineRefreshTimer?: ReturnType; + /** Code lens prompt waiting on the webview shell and the ACP session. */ + private _pendingPrompt: string | null = null; + private _pendingPromptTimer: ReturnType | null = null; + + private readonly _settingsManager: SettingsManager; constructor( private readonly _extensionUri: vscode.Uri, private readonly _outputChannel: vscode.OutputChannel, private readonly _acpClient: NanocoderAcpClient, private readonly _diffManager: DiffManager - ) { + ) { + this._settingsManager = new SettingsManager(this._outputChannel); // Listen for session updates from ACP this._acpClient.onSessionUpdate = (update: any) => { this.handleDiffs(update); @@ -96,6 +114,58 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { } } + /** + * Reveal the chat view and run `text` as a prompt. An editor code lens can + * fire long before the sidebar has ever been opened, so the prompt is held + * until the shell reports ready and a session exists. + */ + public async sendPrompt(text: string) { + this._queuePendingPrompt(text); + await vscode.commands.executeCommand(`${ChatWebviewProvider.viewType}.focus`); + await this._initializeSessionIfReady(); + } + + private _queuePendingPrompt(text: string) { + this._clearPendingPrompt(); + this._pendingPrompt = text; + this._pendingPromptTimer = setTimeout(() => { + this._pendingPromptTimer = null; + this._pendingPrompt = null; + vscode.window.showWarningMessage( + 'Nanocoder: the agent did not start in time, so your editor request was not sent. Try again once the chat view is connected.', + ); + }, PENDING_PROMPT_TIMEOUT_MS); + } + + private _clearPendingPrompt() { + if (this._pendingPromptTimer) { + clearTimeout(this._pendingPromptTimer); + this._pendingPromptTimer = null; + } + this._pendingPrompt = null; + } + + /** + * Hand a queued prompt to the webview. Cleared before posting so a failed + * delivery can't be retried into a half-loaded shell. + */ + private _flushPendingPrompt() { + const text = this._pendingPrompt; + if (text === null) { + return; + } + this._clearPendingPrompt(); + this.postMessage({type: 'runPrompt', text}); + } + + /** + * Registered with the extension's subscriptions so a deactivate cannot + * leave the pending-prompt timer running against a disposed view. + */ + public dispose() { + this._clearPendingPrompt(); + } + public requestCopyLastCodeBlock() { if (!this._view) { vscode.window.showInformationMessage('Nanocoder: open the Nanocoder chat view first.'); @@ -110,12 +180,35 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { } } + public toggleSettings() { + if (this._view) { + this._view.webview.postMessage({ type: 'toggleSettings' }); + } + } + public resolveWebviewView( webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, ) { this._view = webviewView; + // A re-resolve means a brand new shell that has not run its script yet. + // Leaving the flag set from the previous one would let a queued prompt + // post into a webview with no message listener attached, dropping it. + this._isWebviewReady = false; + webviewView.onDidDispose(() => { + // A disposal can land after a newer view has already been resolved + // (VS Code tears the old one down late). Without this guard that + // stale event would null out the live view and drop its state. + if (this._view !== webviewView) { + return; + } + this._view = undefined; + this._isWebviewReady = false; + // A queued prompt is deliberately kept: a disposal is usually a + // re-reveal in progress, and the next resolve is what delivers it. + // The timeout is what bounds the wait if no view comes back. + }); webviewView.webview.options = { enableScripts: true, @@ -203,6 +296,22 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { this._broadcastSessions(); }); break; + case 'requestSettings': + this._outputChannel.appendLine('[Webview] Settings data requested.'); + this._handleRequestSettings(); + break; + case 'updateSetting': + this._outputChannel.appendLine(`[Webview] Update setting: ${message.key}`); + this._handleUpdateSetting(message.key, message.value); + break; + case 'openConfigFile': + this._outputChannel.appendLine(`[Webview] Open config file: ${message.file}`); + this._handleOpenConfigFile(message.file); + break; + case 'restartAcp': + this._outputChannel.appendLine('[Webview] Restart ACP requested.'); + vscode.commands.executeCommand('nanocoder.restartAcp'); + break; case 'requestPathInfo': { try { const stat = fs.statSync(message.path); @@ -286,6 +395,7 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { // Broadcast session list to populate History tab await this._broadcastSessions(); await this._broadcastTimeline(); + this._flushPendingPrompt(); } } catch (error) { this._outputChannel.appendLine(`Failed to initialize session on ready: ${error}`); @@ -336,6 +446,50 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { } } + private _handleRequestSettings() { + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); + const settings = this._settingsManager.readSettings(cwd); + this.postMessage({type: 'settingsData', settings}); + } + + private _handleUpdateSetting(key: string, value: unknown) { + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); + const result = this._settingsManager.updateSetting(cwd, key, value); + this.postMessage({ + type: 'settingsUpdated', + key, + success: result.success, + error: result.error, + }); + + // If successful, send refreshed settings so the UI stays in sync + if (result.success) { + const settings = this._settingsManager.readSettings(cwd); + this.postMessage({type: 'settingsData', settings}); + } else { + vscode.window.showErrorMessage(`Failed to save setting '${key}': ${result.error}`); + } + } + + private async _handleOpenConfigFile(file: string) { + const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); + const paths = this._settingsManager.getConfigPaths(cwd); + const filePath = file === 'agents.config.json' ? paths.agentsConfig : paths.preferences; + + try { + if (!fs.existsSync(filePath)) { + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + fs.writeFileSync(filePath, '{}\n', 'utf-8'); + } + const doc = await vscode.workspace.openTextDocument(filePath); + await vscode.window.showTextDocument(doc); + } catch { + vscode.window.showErrorMessage( + `Could not open ${file} at ${filePath}. Ensure the file exists.`, + ); + } + } + /** * Exclude glob for `@` search. * @@ -463,6 +617,10 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { try { if (this._acpClient.hasPendingPermissions()) { vscode.window.showWarningMessage('Nanocoder: Please approve or deny the pending tool before sending a new message.'); + // The webview has already drawn the user bubble and flipped to + // the loading state, and no turn is going to start - so end the + // turn here or the composer spins until the user hits Escape. + this.postMessage({type: 'acpUpdate', update: {sessionUpdate: 'prompt_response'}}); return; } diff --git a/plugins/vscode/src/code-lens-provider.spec.ts b/plugins/vscode/src/code-lens-provider.spec.ts new file mode 100644 index 000000000..1283f0b01 --- /dev/null +++ b/plugins/vscode/src/code-lens-provider.spec.ts @@ -0,0 +1,232 @@ +import test from 'ava'; +import * as vscode from 'vscode'; +import { + buildCodeLensPrompt, + MAX_LENS_SOURCE_LINES, + NanocoderCodeLensProvider, + sendCodeLensPrompt, + truncateLensSource, +} from './code-lens-provider'; + +const {__test} = vscode as unknown as {__test: any}; + +const NEVER_CANCELLED = { + isCancellationRequested: false, + onCancellationRequested: () => ({dispose: () => {}}), +} as unknown as vscode.CancellationToken; + +const DOCUMENT = { + uri: vscode.Uri.file('/repo/src/thing.ts'), +} as unknown as vscode.TextDocument; + +function symbol( + name: string, + kind: vscode.SymbolKind, + children: any[] = [], +): any { + return { + name, + kind, + range: new vscode.Range(0, 0, 10, 0), + selectionRange: new vscode.Range(0, 6, 0, 6 + name.length), + children, + }; +} + +test.beforeEach(() => { + __test.reset(); +}); + +test('provideCodeLenses - keeps lensable kinds and walks nested children', async t => { + __test.executeCommand = async () => [ + symbol('Widget', vscode.SymbolKind.Class, [ + symbol('constructor', vscode.SymbolKind.Constructor), + symbol('render', vscode.SymbolKind.Method), + // Fields and properties are deliberately skipped - a lens on every + // one of them would bury the editor. + symbol('count', vscode.SymbolKind.Property), + symbol('label', vscode.SymbolKind.Field), + ]), + symbol('helper', vscode.SymbolKind.Function, [ + // Nested one level deeper than any top-level symbol, so it only + // shows up if the walk actually recurses. + symbol('inner', vscode.SymbolKind.Function), + ]), + symbol('total', vscode.SymbolKind.Variable), + ]; + + const lenses = await new NanocoderCodeLensProvider().provideCodeLenses( + DOCUMENT, + NEVER_CANCELLED, + ); + + // Class, Constructor, Method, Function, nested Function - two lenses each. + t.is(lenses.length, 10); + t.deepEqual( + [...new Set(lenses.map(lens => lens.command?.title))], + ['Explain Code', 'Generate Tests'], + ); + t.deepEqual( + [...new Set(lenses.map(lens => lens.command?.command))], + ['nanocoder.explainCode', 'nanocoder.generateTests'], + ); + // The lens is anchored on the name, but the command carries the whole body. + t.is((lenses[0].command?.arguments?.[1] as vscode.Range).end.line, 10); +}); + +test('provideCodeLenses - skips legacy symbols with no selectionRange', async t => { + const legacy = symbol('legacy', vscode.SymbolKind.Function); + legacy.selectionRange = undefined; + __test.executeCommand = async () => [legacy]; + + const lenses = await new NanocoderCodeLensProvider().provideCodeLenses( + DOCUMENT, + NEVER_CANCELLED, + ); + + t.deepEqual(lenses, []); +}); + +test('provideCodeLenses - short-circuits when nanocoder.codeLens is false', async t => { + let symbolProviderCalls = 0; + __test.configuration = (_section: string, key: string, fallback: unknown) => + key === 'codeLens' ? false : fallback; + __test.executeCommand = async () => { + symbolProviderCalls++; + return [symbol('helper', vscode.SymbolKind.Function)]; + }; + + const lenses = await new NanocoderCodeLensProvider().provideCodeLenses( + DOCUMENT, + NEVER_CANCELLED, + ); + + t.deepEqual(lenses, []); + // Bailing before the symbol request is the point: the language server is + // not asked to do work whose result is thrown away. + t.is(symbolProviderCalls, 0); +}); + +test('buildCodeLensPrompt - instruction, locator, then fenced source', t => { + const prompt = buildCodeLensPrompt({ + instruction: 'Explain what this code does.', + relativePath: 'src/thing.ts', + startLine: 12, + endLine: 14, + languageId: 'typescript', + source: 'function add(a, b) {\n\treturn a + b;\n}', + }); + + t.is( + prompt, + [ + 'Explain what this code does.', + '', + 'src/thing.ts:12-14', + '```typescript', + 'function add(a, b) {', + '\treturn a + b;', + '}', + '```', + ].join('\n'), + ); +}); + +test('buildCodeLensPrompt - caps a long symbol and points at the file', t => { + const source = Array.from( + {length: MAX_LENS_SOURCE_LINES + 40}, + (_unused, i) => `\tline ${i};`, + ).join('\n'); + + const prompt = buildCodeLensPrompt({ + instruction: 'Write unit tests for this code.', + relativePath: 'src/huge.ts', + startLine: 1, + endLine: MAX_LENS_SOURCE_LINES + 40, + languageId: 'typescript', + source, + }); + + t.true(prompt.includes('\tline 0;')); + t.false(prompt.includes(`\tline ${MAX_LENS_SOURCE_LINES};`)); + t.true(prompt.endsWith('(truncated - 40 more lines; read src/huge.ts for the rest)')); + // The locator survives truncation, so the agent can still find the rest. + t.true(prompt.includes(`src/huge.ts:1-${MAX_LENS_SOURCE_LINES + 40}`)); +}); + +test('truncateLensSource - leaves a short symbol untouched', t => { + const source = 'const a = 1;\nconst b = 2;'; + t.deepEqual(truncateLensSource(source), { + text: source, + omittedLines: 0, + truncated: false, + }); +}); + +test('truncateLensSource - cuts a single line that busts the char cap', t => { + const result = truncateLensSource('x'.repeat(500), 200, 100); + t.is(result.text.length, 100); + t.is(result.omittedLines, 0); + t.true(result.truncated); +}); + +test('truncateLensSource - char cap can bind before the line cap', t => { + const result = truncateLensSource('abcd\nabcd\nabcd\nabcd', 200, 12); + t.is(result.text, 'abcd\nabcd'); + t.is(result.omittedLines, 2); + t.true(result.truncated); +}); + +test('sendCodeLensPrompt - hands the built prompt to the chat view', async t => { + __test.asRelativePath = () => 'src/thing.ts'; + __test.openTextDocument = async () => ({ + languageId: 'typescript', + getText: () => 'const answer = 42;', + }); + + const sent: string[] = []; + const chatProvider = { + sendPrompt: async (text: string) => { + sent.push(text); + }, + } as any; + + await sendCodeLensPrompt( + chatProvider, + 'Explain what this code does.', + vscode.Uri.file('/repo/src/thing.ts'), + new vscode.Range(11, 0, 13, 1), + ); + + t.deepEqual(sent, [ + [ + 'Explain what this code does.', + '', + // The range is 0-based; the locator the agent sees is not. + 'src/thing.ts:12-14', + '```typescript', + 'const answer = 42;', + '```', + ].join('\n'), + ]); +}); + +test('sendCodeLensPrompt - explains itself when invoked without lens arguments', async t => { + let sendPromptCalls = 0; + const chatProvider = { + sendPrompt: async () => { + sendPromptCalls++; + }, + } as any; + + // A keybinding or another extension can reach the command directly, with + // nothing to describe. + await sendCodeLensPrompt(chatProvider, 'Explain what this code does.'); + + t.is(sendPromptCalls, 0); + t.deepEqual( + __test.shownMessages.map((m: any) => m.kind), + ['info'], + ); + t.regex(__test.shownMessages[0].message, /Explain Code \/ Generate Tests/); +}); diff --git a/plugins/vscode/src/code-lens-provider.ts b/plugins/vscode/src/code-lens-provider.ts new file mode 100644 index 000000000..0a31acbbb --- /dev/null +++ b/plugins/vscode/src/code-lens-provider.ts @@ -0,0 +1,200 @@ +import * as vscode from 'vscode'; +import type { ChatWebviewProvider } from './chat-webview-provider'; + +/** + * Symbols worth a lens. Anything finer-grained (properties, variables) would + * bury the editor in links. Constructors are included because they read as + * ordinary methods to the user, and a class lens covers the whole body rather + * than the constructor on its own. + */ +export const LENS_SYMBOL_KINDS: ReadonlySet = new Set([ + vscode.SymbolKind.Function, + vscode.SymbolKind.Method, + vscode.SymbolKind.Constructor, + vscode.SymbolKind.Class, +]); + +/** + * Caps on the source inlined into a lens prompt. `Generate Tests` on a + * thousand-line class would otherwise paste the entire body into the + * conversation and spend a local model's whole context on one turn. The head of + * a symbol carries the signature and the shape, so a truncated body plus the + * `file:start-end` locator still leaves the agent enough to work from - it can + * read the file for the remainder. + */ +export const MAX_LENS_SOURCE_LINES = 200; +export const MAX_LENS_SOURCE_CHARS = 8_000; + +export interface TruncatedSource { + text: string; + /** Whole lines dropped from the end. Zero when a lone long line was cut. */ + omittedLines: number; + truncated: boolean; +} + +/** + * Clip `source` to the line and character caps, whichever binds first. At least + * one line is always kept so the symbol's signature survives. + */ +export function truncateLensSource( + source: string, + maxLines: number = MAX_LENS_SOURCE_LINES, + maxChars: number = MAX_LENS_SOURCE_CHARS, +): TruncatedSource { + const lines = source.split('\n'); + + let keptLines = 0; + let chars = 0; + for (const line of lines) { + if (keptLines >= maxLines) break; + const next = chars + line.length + (keptLines > 0 ? 1 : 0); + if (keptLines > 0 && next > maxChars) break; + chars = next; + keptLines++; + } + + if (keptLines === lines.length && chars <= maxChars) { + return { text: source, omittedLines: 0, truncated: false }; + } + + // A single line over the character cap - minified or generated code - has no + // line boundary to fall back to, so it is cut mid-line. + const text = lines.slice(0, keptLines).join('\n').slice(0, maxChars); + return { text, omittedLines: lines.length - keptLines, truncated: true }; +} + +export interface CodeLensPromptInput { + instruction: string; + /** Workspace-relative path of the clicked symbol's file. */ + relativePath: string; + /** 1-based, inclusive. */ + startLine: number; + /** 1-based, inclusive. */ + endLine: number; + languageId: string; + source: string; +} + +/** + * Build the prompt a lens click sends. The symbol source is inlined rather than + * attached as a file: the agent should see the one function the user clicked, + * not everything around it. The locator is always present, so a truncated body + * still points at the rest. + */ +export function buildCodeLensPrompt(input: CodeLensPromptInput): string { + const { text, omittedLines, truncated } = truncateLensSource(input.source); + const location = `${input.relativePath}:${input.startLine}-${input.endLine}`; + + const parts = [ + input.instruction, + '', + location, + '```' + input.languageId, + text, + '```', + ]; + + if (truncated) { + parts.push( + omittedLines > 0 + ? `(truncated - ${omittedLines} more lines; read ${input.relativePath} for the rest)` + : `(truncated; read ${input.relativePath} for the rest)`, + ); + } + + return parts.join('\n'); +} + +export class NanocoderCodeLensProvider + implements vscode.CodeLensProvider, vscode.Disposable { + private readonly _onDidChangeCodeLenses = new vscode.EventEmitter(); + public readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; + + public refresh(): void { + this._onDidChangeCodeLenses.fire(); + } + + public dispose(): void { + this._onDidChangeCodeLenses.dispose(); + } + + public async provideCodeLenses( + document: vscode.TextDocument, + token: vscode.CancellationToken, + ): Promise { + // Scoped to the document so a folder-level override wins in a + // multi-root workspace. + const config = vscode.workspace.getConfiguration('nanocoder', document.uri); + if (!config.get('codeLens', true)) { + return []; + } + + // The language server already knows where the functions are, so nothing + // here has to parse a single line of source. + const symbols = await vscode.commands.executeCommand( + 'vscode.executeDocumentSymbolProvider', + document.uri, + ); + if (token.isCancellationRequested || !Array.isArray(symbols)) { + return []; + } + + const lenses: vscode.CodeLens[] = []; + const walk = (nodes: vscode.DocumentSymbol[]) => { + for (const symbol of nodes) { + // selectionRange is absent on the legacy SymbolInformation shape + // some providers still return; skip those rather than throw. + if (LENS_SYMBOL_KINDS.has(symbol.kind) && symbol.selectionRange) { + // Anchored on the name so the lens sits on the declaration + // line instead of above a preceding doc comment, while the + // command still receives the symbol's whole body. + const args = [document.uri, symbol.range]; + lenses.push( + new vscode.CodeLens(symbol.selectionRange, { + title: 'Explain Code', + command: 'nanocoder.explainCode', + arguments: args, + }), + new vscode.CodeLens(symbol.selectionRange, { + title: 'Generate Tests', + command: 'nanocoder.generateTests', + arguments: args, + }), + ); + } + walk(symbol.children ?? []); + } + }; + walk(symbols); + + return lenses; + } +} + +export async function sendCodeLensPrompt( + chatProvider: ChatWebviewProvider, + instruction: string, + uri?: vscode.Uri, + range?: vscode.Range, +): Promise { + // The commands are hidden from the palette, but a keybinding or another + // extension can still invoke them with no lens arguments. + if (!uri || !range) { + vscode.window.showInformationMessage( + 'Nanocoder: use the Explain Code / Generate Tests links above a function to run this.', + ); + return; + } + + const document = await vscode.workspace.openTextDocument(uri); + await chatProvider.sendPrompt( + buildCodeLensPrompt({ + instruction, + relativePath: vscode.workspace.asRelativePath(uri), + startLine: range.start.line + 1, + endLine: range.end.line + 1, + languageId: document.languageId, + source: document.getText(range), + }), + ); +} diff --git a/plugins/vscode/src/extension.ts b/plugins/vscode/src/extension.ts index 73ff365e4..49827d49e 100644 --- a/plugins/vscode/src/extension.ts +++ b/plugins/vscode/src/extension.ts @@ -13,6 +13,10 @@ import {AcpStateManager, ACPStatus} from './acp-state'; import {NanocoderAcpClient} from './acp-client'; import {AcpProcessManager} from './acp-process-manager'; import {ChatWebviewProvider} from './chat-webview-provider'; +import { + NanocoderCodeLensProvider, + sendCodeLensPrompt, +} from './code-lens-provider'; const DEFAULT_PORT = 51820; const ACTIVE_EDITOR_DEBOUNCE_MS = 150; @@ -55,6 +59,7 @@ export function activate(context: vscode.ExtensionContext) { // Register Webview Provider const chatProvider = new ChatWebviewProvider(context.extensionUri, outputChannel, acpClient, diffManager); context.subscriptions.push( + chatProvider, vscode.window.registerWebviewViewProvider(ChatWebviewProvider.viewType, chatProvider, { // Preserve DOM when user switches to Explorer/SCM/etc. and back. // Without this VS Code destroys the webview on hide, wiping the transcript. @@ -62,10 +67,13 @@ export function activate(context: vscode.ExtensionContext) { }) ); - // Register Title Bar Action + // Register Title Bar Actions context.subscriptions.push( vscode.commands.registerCommand('nanocoder.toggleHistory', () => { chatProvider.toggleHistory(); + }), + vscode.commands.registerCommand('nanocoder.toggleSettings', () => { + chatProvider.toggleSettings(); }) ); @@ -113,6 +121,25 @@ export function activate(context: vscode.ExtensionContext) { }), ); + // Inline "Explain Code" / "Generate Tests" links above every function and + // class, so a symbol can be handed to the agent without leaving the editor. + const codeLensProvider = new NanocoderCodeLensProvider(); + context.subscriptions.push( + codeLensProvider, + vscode.languages.registerCodeLensProvider({scheme: 'file'}, codeLensProvider), + vscode.workspace.onDidChangeConfiguration(event => { + if (event.affectsConfiguration('nanocoder.codeLens')) { + codeLensProvider.refresh(); + } + }), + vscode.commands.registerCommand('nanocoder.explainCode', (uri?: vscode.Uri, range?: vscode.Range) => + sendCodeLensPrompt(chatProvider, 'Explain what this code does.', uri, range), + ), + vscode.commands.registerCommand('nanocoder.generateTests', (uri?: vscode.Uri, range?: vscode.Range) => + sendCodeLensPrompt(chatProvider, 'Write unit tests for this code.', uri, range), + ), + ); + // Push active editor state to the CLI so the input box can show an // "In " pill and auto-attach a selection as context on submit. context.subscriptions.push( diff --git a/plugins/vscode/src/settings-manager.spec.ts b/plugins/vscode/src/settings-manager.spec.ts new file mode 100644 index 000000000..e9d7a9ddd --- /dev/null +++ b/plugins/vscode/src/settings-manager.spec.ts @@ -0,0 +1,144 @@ +import test from 'ava'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; +import * as os from 'os'; +import { SettingsManager } from './settings-manager'; + +// Create a mock output channel +const mockOutputChannel = { + appendLine: (msg: string) => {}, +}; + +test.serial('SettingsManager - getConfigPaths resolves project paths correctly', (t) => { + const manager = new SettingsManager(mockOutputChannel); + const cwd = process.cwd(); // mock cwd + const paths = manager.getConfigPaths(cwd); + + t.is(typeof paths.agentsConfig, 'string'); + t.is(typeof paths.preferences, 'string'); +}); + +test.serial('SettingsManager - returns fallback values for empty or missing config', (t) => { + const manager = new SettingsManager(mockOutputChannel); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-')); + + // Create a dummy SettingsManager that uses this temp dir as cwd and global dir + // We'll override getGlobalConfigDir to point to tempDir to avoid reading real configs + const anyManager = manager as any; + anyManager.getGlobalConfigDir = () => tempDir; + + const settings = manager.readSettings(tempDir); + t.deepEqual(settings.providers, []); + t.deepEqual(settings.mcpServers, []); + t.deepEqual(settings.alwaysAllow, []); + t.is(settings.defaultMode, null); + t.is(settings.autoCompact.enabled, true); + t.is(settings.autoCompact.threshold, 60); + t.is(settings.autoCompact.mode, 'conservative'); + t.is(settings.reasoningTraces, false); + t.is(settings.sessions.autoSave, true); + t.is(settings.webSearch.configured, false); + + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +test.serial('SettingsManager - updates setting correctly (atomic write)', (t) => { + const manager = new SettingsManager(mockOutputChannel); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-')); + const anyManager = manager as any; + anyManager.getGlobalConfigDir = () => tempDir; + + // Initial write + const result = manager.updateSetting(tempDir, 'defaultMode', 'yolo'); + t.is(result.success, true); + + // Verify file was written + const agentsConfigPath = path.join(tempDir, 'agents.config.json'); + t.is(fs.existsSync(agentsConfigPath), true); + + // Verify content + const content = JSON.parse(fs.readFileSync(agentsConfigPath, 'utf8')); + t.is(content.nanocoder.defaultMode, 'yolo'); + + // Verify readSettings picks it up + const settings = manager.readSettings(tempDir); + t.is(settings.defaultMode, 'yolo'); + + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +test.serial('SettingsManager - handles invalid JSON gracefully on read', (t) => { + const manager = new SettingsManager(mockOutputChannel); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-')); + const anyManager = manager as any; + anyManager.getGlobalConfigDir = () => tempDir; + + const agentsConfigPath = path.join(tempDir, 'agents.config.json'); + fs.writeFileSync(agentsConfigPath, '{ invalid: json }'); // Syntax error + + const settings = manager.readSettings(tempDir); + t.is(settings.defaultMode, null); // Falls back to default gracefully + + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +test.serial('SettingsManager - prevents update when JSON is invalid', (t) => { + const manager = new SettingsManager(mockOutputChannel); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-')); + const anyManager = manager as any; + anyManager.getGlobalConfigDir = () => tempDir; + + const agentsConfigPath = path.join(tempDir, 'agents.config.json'); + fs.writeFileSync(agentsConfigPath, '{ invalid: json }'); // Syntax error + + const result = manager.updateSetting(tempDir, 'defaultMode', 'yolo'); + t.is(result.success, false); + t.regex(result.error || '', /invalid JSON/); + + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +test.serial('SettingsManager - validates defaultMode values', (t) => { + const manager = new SettingsManager(mockOutputChannel); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-')); + const anyManager = manager as any; + anyManager.getGlobalConfigDir = () => tempDir; + + // Invalid value + const result = manager.updateSetting(tempDir, 'defaultMode', 'chat'); + t.is(result.success, false); + t.regex(result.error || '', /Invalid defaultMode/); + + // Invalid type + const result2 = manager.updateSetting(tempDir, 'defaultMode', 123); + t.is(result2.success, false); + t.regex(result2.error || '', /Invalid defaultMode value type/); + + // Valid value + const result3 = manager.updateSetting(tempDir, 'defaultMode', 'yolo'); + t.is(result3.success, true); + + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +test.serial('SettingsManager - validates autoCompact.threshold values', (t) => { + const manager = new SettingsManager(mockOutputChannel); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanocoder-test-')); + const anyManager = manager as any; + anyManager.getGlobalConfigDir = () => tempDir; + + // Invalid type + const result = manager.updateSetting(tempDir, 'autoCompact.threshold', 'high'); + t.is(result.success, false); + t.regex(result.error || '', /must be a number/); + + // Valid value is clamped + const result2 = manager.updateSetting(tempDir, 'autoCompact.threshold', 200); + t.is(result2.success, true); + + const settings = manager.readSettings(tempDir); + t.is(settings.autoCompact.threshold, 95); // clamped to max 95 + + fs.rmSync(tempDir, { recursive: true, force: true }); +}); diff --git a/plugins/vscode/src/settings-manager.ts b/plugins/vscode/src/settings-manager.ts new file mode 100644 index 000000000..7cbeb6783 --- /dev/null +++ b/plugins/vscode/src/settings-manager.ts @@ -0,0 +1,307 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as crypto from 'crypto'; + +/** + * Shape of the settings data sent to the webview. This is a flattened, + * UI-friendly view of agents.config.json + nanocoder-preferences.json. + */ +export interface SettingsData { + providers: Array<{ name: string; baseUrl?: string; models: string[]; apiKeySet: boolean }>; + mcpServers: Array<{ name: string; transport: string; command?: string; url?: string }>; + alwaysAllow: string[]; + defaultMode: string | null; + autoCompact: { enabled: boolean; threshold: number; mode: string }; + reasoningTraces: boolean; + sessions: { autoSave: boolean }; + webSearch: { configured: boolean }; +} + +/** + * Manages reading and writing Nanocoder configuration files from the + * extension host. Mirrors the resolution logic in the CLI's config/index.ts: + * 1. Check /agents.config.json + * 2. Fall back to ~/.config/nanocoder/agents.config.json + * Same for nanocoder-preferences.json. + */ +export class SettingsManager { + constructor(private outputChannel: { appendLine: (msg: string) => void }) {} + + /** + * Discover the active config file paths, preferring project-level files. + */ + getConfigPaths(cwd: string): { agentsConfig: string; preferences: string } { + const globalDir = this.getGlobalConfigDir(); + + const agentsConfig = this.resolveConfigPath(cwd, globalDir, 'agents.config.json'); + const preferences = this.resolveConfigPath(cwd, globalDir, 'nanocoder-preferences.json'); + + return { agentsConfig, preferences }; + } + + /** + * Read current settings from disk and return a flattened SettingsData. + */ + readSettings(cwd: string): SettingsData { + const paths = this.getConfigPaths(cwd); + const agentsConfig = this.readJsonSafe(paths.agentsConfig); + const preferences = this.readJsonSafe(paths.preferences); + + const nc = agentsConfig?.nanocoder ?? {}; + + // Parse providers — mask API keys + const providers = Array.isArray(nc.providers) ? nc.providers.map((p: any) => ({ + name: p.name || 'unnamed', + baseUrl: p.baseUrl, + models: Array.isArray(p.models) ? p.models : [], + apiKeySet: Boolean(p.apiKey), + })) : []; + + // Parse MCP servers + const mcpServers = Array.isArray(nc.mcpServers) ? nc.mcpServers.map((s: any) => ({ + name: s.name || 'unnamed', + transport: s.transport || 'stdio', + command: s.command, + url: s.url, + })) : []; + + // Parse alwaysAllow + const alwaysAllow: string[] = Array.isArray(nc.alwaysAllow) + ? nc.alwaysAllow.filter((x: unknown) => typeof x === 'string') + : []; + + // Parse defaultMode + const validModes = ['normal', 'auto-accept', 'yolo', 'plan']; + let defaultMode: string | null = typeof nc.defaultMode === 'string' ? nc.defaultMode : null; + if (defaultMode && !validModes.includes(defaultMode)) { + defaultMode = 'normal'; + } + + // Parse autoCompact + const ac = nc.autoCompact ?? {}; + const autoCompact = { + enabled: ac.enabled !== false, + threshold: typeof ac.threshold === 'number' ? ac.threshold : 60, + mode: typeof ac.mode === 'string' ? ac.mode : 'conservative', + }; + + // Parse reasoning traces from preferences + const reasoningTraces = preferences?.reasoningExpanded ?? false; + + // Parse sessions from preferences + const sessionsPref = preferences?.nanocoder?.sessions ?? {}; + const sessions = { + autoSave: sessionsPref.autoSave !== false, + }; + + // Web search + const webSearch = { + configured: Boolean(nc.nanocoderTools?.webSearch?.apiKey), + }; + + return { + providers, + mcpServers, + alwaysAllow, + defaultMode, + autoCompact, + reasoningTraces, + sessions, + webSearch, + }; + } + + /** + * Update a setting by dot-notated key. Returns success/error. + * + * Supported keys: + * - 'defaultMode' → agents.config.json → nanocoder.defaultMode + * - 'autoCompact.enabled' → agents.config.json → nanocoder.autoCompact.enabled + * - 'autoCompact.threshold' → agents.config.json → nanocoder.autoCompact.threshold + * - 'autoCompact.mode' → agents.config.json → nanocoder.autoCompact.mode + * - 'reasoningTraces' → nanocoder-preferences.json → reasoningExpanded + * - 'sessions.autoSave' → nanocoder-preferences.json → nanocoder.sessions.autoSave + */ + updateSetting(cwd: string, key: string, value: unknown): { success: boolean; error?: string } { + try { + const paths = this.getConfigPaths(cwd); + + if (key === 'defaultMode') { + if (value === null) { + this.updateAgentsConfig(paths.agentsConfig, 'defaultMode', null); + } else if (typeof value === 'string') { + const normalized = value.toLowerCase().trim(); + const validModes = ['normal', 'auto-accept', 'yolo', 'plan'] as const; + if (!validModes.includes(normalized as (typeof validModes)[number])) { + return { success: false, error: `Invalid defaultMode: ${value}` }; + } + this.updateAgentsConfig(paths.agentsConfig, 'defaultMode', normalized); + } else { + return { + success: false, + error: `Invalid defaultMode value type: ${typeof value}`, + }; + } + } else if (key === 'autoCompact.enabled') { + if (typeof value !== 'boolean') { + return { success: false, error: 'autoCompact.enabled must be a boolean' }; + } + this.updateAgentsConfigNested(paths.agentsConfig, 'autoCompact', 'enabled', value); + } else if (key === 'autoCompact.threshold') { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return { success: false, error: 'autoCompact.threshold must be a number' }; + } + const threshold = Math.max(50, Math.min(95, Math.round(value))); + this.updateAgentsConfigNested(paths.agentsConfig, 'autoCompact', 'threshold', threshold); + } else if (key === 'autoCompact.mode') { + if (typeof value !== 'string') { + return { success: false, error: 'autoCompact.mode must be a string' }; + } + const validModes = ['conservative', 'default', 'aggressive'] as const; + if (!validModes.includes(value as (typeof validModes)[number])) { + return { success: false, error: `Invalid autoCompact.mode: ${value}` }; + } + this.updateAgentsConfigNested(paths.agentsConfig, 'autoCompact', 'mode', value); + } else if (key === 'reasoningTraces') { + if (typeof value !== 'boolean') { + return { success: false, error: 'reasoningTraces must be a boolean' }; + } + this.updatePreferences(paths.preferences, 'reasoningExpanded', value); + } else if (key === 'sessions.autoSave') { + if (typeof value !== 'boolean') { + return { success: false, error: 'sessions.autoSave must be a boolean' }; + } + this.updatePreferencesNested(paths.preferences, 'nanocoder', 'sessions', 'autoSave', value); + } else { + return { success: false, error: `Unknown setting key: ${key}` }; + } + + return { success: true }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + this.outputChannel.appendLine(`[Settings] Failed to update ${key}: ${msg}`); + return { success: false, error: msg }; + } + } + + // ----- Private helpers ----- + + private getGlobalConfigDir(): string { + if (process.env.NANOCODER_CONFIG_DIR) { + return process.env.NANOCODER_CONFIG_DIR; + } + + let baseConfigPath: string; + switch (process.platform) { + case 'win32': + baseConfigPath = process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'); + break; + case 'darwin': + baseConfigPath = path.join(os.homedir(), 'Library', 'Preferences'); + break; + default: + baseConfigPath = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'); + } + return path.join(baseConfigPath, 'nanocoder'); + } + + /** + * Resolve a config file: project-level first, then global. + * If neither exists, return the global path (it will be created on write). + */ + private resolveConfigPath(cwd: string, globalDir: string, fileName: string): string { + // fileName is never user input - both call sites pass a string literal. + // Same shape as getConfigPath() in source/config/index.ts. + const projectPath = path.join(cwd, fileName); // nosemgrep + if (fs.existsSync(projectPath)) { + return projectPath; + } + return path.join(globalDir, fileName); // nosemgrep + } + + private readJsonSafe(filePath: string): any { + try { + if (fs.existsSync(filePath)) { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } + return {}; + } catch (error) { + this.outputChannel.appendLine(`[Settings] Failed to read ${filePath}: ${error}`); + return null; + } + } + + private updateAgentsConfig(configPath: string, key: string, value: unknown): void { + let config = this.readJsonSafe(configPath); + if (config === null) throw new Error(`Config file ${configPath} contains invalid JSON. Cannot update.`); + config = config || {}; + if (!config.nanocoder || typeof config.nanocoder !== 'object') { + config.nanocoder = {}; + } + config.nanocoder[key] = value; + this.atomicWrite(configPath, config); + } + + private updateAgentsConfigNested(configPath: string, parentKey: string, childKey: string, value: unknown): void { + let config = this.readJsonSafe(configPath); + if (config === null) throw new Error(`Config file ${configPath} contains invalid JSON. Cannot update.`); + config = config || {}; + if (!config.nanocoder || typeof config.nanocoder !== 'object') { + config.nanocoder = {}; + } + if (!config.nanocoder[parentKey] || typeof config.nanocoder[parentKey] !== 'object') { + config.nanocoder[parentKey] = {}; + } + config.nanocoder[parentKey][childKey] = value; + this.atomicWrite(configPath, config); + } + + private updatePreferences(filePath: string, key: string, value: unknown): void { + let prefs = this.readJsonSafe(filePath); + if (prefs === null) throw new Error(`Preferences file ${filePath} contains invalid JSON. Cannot update.`); + prefs = prefs || {}; + prefs[key] = value; + this.atomicWrite(filePath, prefs); + } + + private updatePreferencesNested(filePath: string, ...keys: (string | unknown)[]): void { + let prefs = this.readJsonSafe(filePath); + if (prefs === null) throw new Error(`Preferences file ${filePath} contains invalid JSON. Cannot update.`); + prefs = prefs || {}; + const value = keys[keys.length - 1]; + const pathArgs = keys.slice(0, -1) as string[]; + + let obj = prefs; + for (let i = 0; i < pathArgs.length - 1; i++) { + if (!obj[pathArgs[i]] || typeof obj[pathArgs[i]] !== 'object') { + obj[pathArgs[i]] = {}; + } + // Keys are string literals from this file's own call sites, never + // user input, so no __proto__ can reach the walk. + obj = obj[pathArgs[i]]; // nosemgrep + } + obj[pathArgs[pathArgs.length - 1]] = value; + this.atomicWrite(filePath, prefs); + } + + /** + * Atomic write: write to a temp file, then rename. Mirrors + * config-writer.ts's atomicWriteFileSync pattern to prevent + * truncated config files on crash. + */ + private atomicWrite(filePath: string, data: unknown): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const tmpPath = `${filePath}.${crypto.randomUUID()}.tmp`; + try { + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf-8'); + fs.renameSync(tmpPath, filePath); + } catch (error) { + try { fs.unlinkSync(tmpPath); } catch { /* ignore cleanup error */ } + throw error; + } + } +} diff --git a/plugins/vscode/src/styles.css b/plugins/vscode/src/styles.css index 4b91827af..af2468fe2 100644 --- a/plugins/vscode/src/styles.css +++ b/plugins/vscode/src/styles.css @@ -268,3 +268,38 @@ select option { cursor: pointer; font-family: var(--vscode-font-family); } + +/* ── Settings UI ────────────────────────────────────── */ +.settings-tab { background: transparent; border: none; border-bottom: 2px solid transparent; color: var(--vscode-editor-foreground); opacity: 0.6; padding: 0.5rem 0.75rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; transition: opacity 0.15s, border-color 0.15s; } +.settings-tab:hover { opacity: 0.9; } +.settings-tab.active { opacity: 1; border-bottom-color: var(--vscode-focusBorder); font-weight: 600; } +.settings-section { background: var(--vscode-editorWidget-background); border: 1px solid var(--vscode-widget-border); border-radius: 6px; padding: 0.75rem; } +.settings-section-title { font-size: 0.8em; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.6; margin-bottom: 0.5rem; } +.settings-list { display: flex; flex-direction: column; gap: 0; } +.settings-list-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.9em; border-bottom: 1px solid var(--vscode-widget-border); } +.settings-list-item:last-child { border-bottom: none; } +.settings-list-item-name { font-weight: 500; flex-shrink: 0; } +.settings-list-item-detail { opacity: 0.6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } +.settings-list-empty { font-size: 0.85em; opacity: 0.5; padding: 0.25rem 0; font-style: italic; } +.settings-row { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.375rem 0; border-bottom: 1px solid var(--vscode-widget-border); } +.settings-row:last-child { border-bottom: none; } +.settings-row-info { display: flex; flex-direction: column; flex: 1; min-width: 0; } +.settings-row-label { font-size: 0.9em; font-weight: 500; } +.settings-row-desc { font-size: 0.78em; opacity: 0.55; margin-top: 0.1em; } +.settings-toggle { position: relative; display: inline-block; width: 36px; height: 20px; flex-shrink: 0; cursor: pointer; } +.settings-toggle input { opacity: 0; width: 0; height: 0; } +.settings-toggle-slider { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border, transparent); border-radius: 20px; transition: background-color 0.2s; } +.settings-toggle-slider:before { content: ""; position: absolute; height: 14px; width: 14px; left: 2px; bottom: 2px; background-color: var(--vscode-editor-foreground); opacity: 0.6; border-radius: 50%; transition: transform 0.2s, opacity 0.2s; } +.settings-toggle input:checked + .settings-toggle-slider { background-color: var(--vscode-button-background); border-color: var(--vscode-button-background); } +.settings-toggle input:checked + .settings-toggle-slider:before { transform: translateX(16px); opacity: 1; background-color: var(--vscode-button-foreground); } +.settings-select { background-color: var(--vscode-dropdown-background); color: var(--vscode-dropdown-foreground); border: 1px solid var(--vscode-dropdown-border, transparent); border-radius: 4px; padding: 0.25rem 0.5rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; outline: none; min-width: 100px; } +.settings-select:focus { border-color: var(--vscode-focusBorder); } +.settings-number-input { background-color: var(--vscode-input-background); color: var(--vscode-input-foreground); border: 1px solid var(--vscode-input-border, transparent); border-radius: 4px; padding: 0.25rem 0.5rem; font-family: var(--vscode-font-family); font-size: 0.85em; width: 70px; outline: none; text-align: center; } +.settings-number-input:focus { border-color: var(--vscode-focusBorder); } +.settings-action-btn { display: flex; align-items: center; gap: 0.5rem; background: transparent; border: 1px solid var(--vscode-button-secondaryBackground); color: var(--vscode-editor-foreground); border-radius: 4px; padding: 0.375rem 0.625rem; font-family: var(--vscode-font-family); font-size: 0.85em; cursor: pointer; transition: background-color 0.15s; } +.settings-action-btn:hover { background-color: var(--vscode-button-secondaryHoverBackground); } +.settings-action-btn-danger { border-color: rgba(241, 76, 76, 0.4); color: #f14c4c; } +.settings-action-btn-danger:hover { background-color: rgba(241, 76, 76, 0.1); } +.settings-badge { display: inline-flex; align-items: center; gap: 0.25rem; font-size: 0.78em; padding: 0.125rem 0.375rem; border-radius: 3px; font-weight: 500; } +.settings-badge-ok { background-color: rgba(137, 209, 133, 0.15); color: #89d185; } +.settings-badge-off { background-color: hsla(0, 0%, 80%, 0.1); color: #999; } diff --git a/plugins/vscode/src/webview-protocol.ts b/plugins/vscode/src/webview-protocol.ts index 1b9e57e77..98eb7eb9d 100644 --- a/plugins/vscode/src/webview-protocol.ts +++ b/plugins/vscode/src/webview-protocol.ts @@ -82,6 +82,12 @@ export interface ExtensionMessageCopyLastCodeBlock { type: 'copyLastCodeBlock'; } +/** Prompt built by an editor code lens; the composer submits it verbatim. */ +export interface ExtensionMessageRunPrompt { + type: 'runPrompt'; + text: string; +} + export interface ExtensionMessageCopyResult { type: 'copyResult'; ok: boolean; @@ -114,6 +120,31 @@ export interface ExtensionMessageUpdateSessions { }>; } +export interface ExtensionMessageSettingsData { + type: 'settingsData'; + settings: { + providers: Array<{ name: string; baseUrl?: string; models: string[]; apiKeySet: boolean }>; + mcpServers: Array<{ name: string; transport: string; command?: string; url?: string }>; + alwaysAllow: string[]; + defaultMode: string | null; + autoCompact: { enabled: boolean; threshold: number; mode: string }; + reasoningTraces: boolean; + sessions: { autoSave: boolean }; + webSearch: { configured: boolean }; + }; +} + +export interface ExtensionMessageSettingsUpdated { + type: 'settingsUpdated'; + key: string; + success: boolean; + error?: string; +} + +export interface ExtensionMessageToggleSettings { + type: 'toggleSettings'; +} + export interface ExtensionMessagePathInfoResolved { type: 'pathInfoResolved'; path: string; @@ -162,9 +193,13 @@ export type ExtensionToWebviewMessage = | ExtensionMessageSyncState | ExtensionMessageUpdateSessions | ExtensionMessageSessionLoaded + | ExtensionMessageSettingsData + | ExtensionMessageSettingsUpdated + | ExtensionMessageToggleSettings | ExtensionMessagePathInfoResolved | ExtensionMessageCopyLastCodeBlock | ExtensionMessageCopyResult + | ExtensionMessageRunPrompt | ExtensionMessageMentionCompletions | ExtensionMessageUpdateTimeline; @@ -239,6 +274,25 @@ export interface WebviewMessageDeleteSession { sessionId: string; } +export interface WebviewMessageRequestSettings { + type: 'requestSettings'; +} + +export interface WebviewMessageUpdateSetting { + type: 'updateSetting'; + key: string; + value: unknown; +} + +export interface WebviewMessageOpenConfigFile { + type: 'openConfigFile'; + file: 'agents.config.json' | 'nanocoder-preferences.json'; +} + +export interface WebviewMessageRestartAcp { + type: 'restartAcp'; +} + export interface WebviewMessageRenameSession { type: 'renameSession'; sessionId: string; @@ -306,6 +360,10 @@ export type WebviewToExtensionMessage = | WebviewMessageListSessions | WebviewMessageResumeSession | WebviewMessageDeleteSession + | WebviewMessageRequestSettings + | WebviewMessageUpdateSetting + | WebviewMessageOpenConfigFile + | WebviewMessageRestartAcp | WebviewMessageRenameSession | WebviewMessageRequestPathInfo | WebviewMessageRequestOpenDialog diff --git a/plugins/vscode/test-stubs/vscode.ts b/plugins/vscode/test-stubs/vscode.ts new file mode 100644 index 000000000..7b256b822 --- /dev/null +++ b/plugins/vscode/test-stubs/vscode.ts @@ -0,0 +1,288 @@ +/** + * Runtime stand-in for the `vscode` module. + * + * The real module is injected by the extension host and cannot be resolved from + * plain Node, so nothing under `plugins/vscode/src` that imports it was + * loadable by AVA. The root tsconfig maps the bare `vscode` specifier here (see + * its `paths` entry) so the extension's units can be exercised without an + * extension host. + * + * Only the surface the extension actually touches is implemented. Anything a + * test needs to steer is overridable through `__test`. + */ + +export class Position { + constructor( + public readonly line: number, + public readonly character: number, + ) {} +} + +export class Range { + public readonly start: Position; + public readonly end: Position; + + constructor(start: Position, end: Position); + constructor( + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, + ); + constructor( + a: Position | number, + b?: Position | number, + c?: number, + d?: number, + ) { + if (typeof a === 'number') { + this.start = new Position(a, b as number); + this.end = new Position(c as number, d as number); + } else { + this.start = a; + this.end = b as Position; + } + } + + get isEmpty(): boolean { + return ( + this.start.line === this.end.line && + this.start.character === this.end.character + ); + } +} + +export class Selection extends Range {} + +export interface Command { + title: string; + command: string; + arguments?: unknown[]; +} + +export class CodeLens { + constructor( + public readonly range: Range, + public readonly command?: Command, + ) {} + + get isResolved(): boolean { + return this.command !== undefined; + } +} + +export class Disposable { + constructor(private readonly _callOnDispose: () => void) {} + dispose(): void { + this._callOnDispose(); + } +} + +export class EventEmitter { + private readonly _listeners = new Set<(e: T) => unknown>(); + + // A bound property, not a method: consumers hand `emitter.event` out + // directly as their public `onDidX`. + public readonly event = (listener: (e: T) => unknown) => { + this._listeners.add(listener); + return new Disposable(() => this._listeners.delete(listener)); + }; + + fire(data: T): void { + for (const listener of [...this._listeners]) listener(data); + } + + dispose(): void { + this._listeners.clear(); + } +} + +export class Uri { + private constructor( + public readonly scheme: string, + public readonly fsPath: string, + ) {} + + static file(fsPath: string): Uri { + return new Uri('file', fsPath); + } + + static joinPath(base: Uri, ...parts: string[]): Uri { + return new Uri(base.scheme, [base.fsPath, ...parts].join('/')); + } + + get path(): string { + return this.fsPath; + } + + with(_change: Record): Uri { + return this; + } + + toString(): string { + return `${this.scheme}://${this.fsPath}`; + } +} + +export enum SymbolKind { + File = 0, + Module = 1, + Namespace = 2, + Package = 3, + Class = 4, + Method = 5, + Property = 6, + Field = 7, + Constructor = 8, + Enum = 9, + Interface = 10, + Function = 11, + Variable = 12, + Constant = 13, + String = 14, + Number = 15, + Boolean = 16, + Array = 17, + Object = 18, + Key = 19, + Null = 20, + EnumMember = 21, + Struct = 22, + Event = 23, + Operator = 24, + TypeParameter = 25, +} + +export enum ConfigurationTarget { + Global = 1, + Workspace = 2, + WorkspaceFolder = 3, +} + +export enum StatusBarAlignment { + Left = 1, + Right = 2, +} + +export enum DiagnosticSeverity { + Error = 0, + Warning = 1, + Information = 2, + Hint = 3, +} + +export enum ViewColumn { + Active = -1, + One = 1, +} + +/** + * Test-controlled behaviour. Every hook falls back to an inert default, so a + * test only overrides what it cares about; `reset()` restores all of them. + */ +export const __test = { + /** Backs `workspace.getConfiguration(section, scope).get(key, default)`. */ + configuration: (_section: string, _key: string, fallback: unknown): unknown => + fallback, + /** Backs `commands.executeCommand`. */ + executeCommand: async (_command: string, ..._args: unknown[]): Promise => + undefined, + /** Backs `workspace.openTextDocument`. */ + openTextDocument: async (_uri: Uri): Promise => ({ + languageId: 'plaintext', + getText: () => '', + }), + /** Backs `workspace.asRelativePath`. */ + asRelativePath: (target: Uri | string): string => + typeof target === 'string' ? target : target.fsPath, + /** Messages surfaced through `window.show*Message`, newest last. */ + shownMessages: [] as {kind: 'info' | 'warning' | 'error'; message: string}[], + + reset(): void { + __test.configuration = (_s, _k, fallback) => fallback; + __test.executeCommand = async () => undefined; + __test.openTextDocument = async () => ({ + languageId: 'plaintext', + getText: () => '', + }); + __test.asRelativePath = target => + typeof target === 'string' ? target : target.fsPath; + __test.shownMessages = []; + }, +}; + +const noopDisposable = new Disposable(() => {}); + +export const workspace = { + workspaceFolders: undefined as {uri: Uri}[] | undefined, + textDocuments: [] as unknown[], + getConfiguration(section: string, _scope?: unknown) { + return { + get: (key: string, fallback?: T): T => + __test.configuration(section, key, fallback) as T, + update: async () => undefined, + }; + }, + openTextDocument: (uri: Uri) => __test.openTextDocument(uri), + asRelativePath: (target: Uri | string) => __test.asRelativePath(target), + onDidChangeConfiguration: () => noopDisposable, + onDidChangeTextDocument: () => noopDisposable, +}; + +export const commands = { + executeCommand: (command: string, ...args: unknown[]) => + __test.executeCommand(command, ...args), + registerCommand: () => noopDisposable, +}; + +export const window = { + activeTextEditor: undefined as unknown, + showInformationMessage: async (message: string) => { + __test.shownMessages.push({kind: 'info', message}); + return undefined; + }, + showWarningMessage: async (message: string) => { + __test.shownMessages.push({kind: 'warning', message}); + return undefined; + }, + showErrorMessage: async (message: string) => { + __test.shownMessages.push({kind: 'error', message}); + return undefined; + }, + createOutputChannel: (_name: string) => ({ + appendLine: () => {}, + append: () => {}, + show: () => {}, + dispose: () => {}, + }), + createStatusBarItem: () => ({ + text: '', + tooltip: '', + command: '', + show: () => {}, + hide: () => {}, + dispose: () => {}, + }), + createTerminal: () => ({sendText: () => {}, show: () => {}, dispose: () => {}}), + showTextDocument: async () => undefined, + showOpenDialog: async () => undefined, + registerWebviewViewProvider: () => noopDisposable, + onDidChangeActiveTextEditor: () => noopDisposable, + onDidChangeTextEditorSelection: () => noopDisposable, +}; + +export const languages = { + registerCodeLensProvider: () => noopDisposable, + getDiagnostics: () => [] as unknown[], +}; + +export const extensions = { + getExtension: (_id: string) => undefined as unknown, +}; + +export const env = { + clipboard: { + writeText: async (_text: string) => undefined, + readText: async () => '', + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fac397f81..83176a66f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,7 +59,7 @@ importers: version: 0.6.5 clipboardy: specifier: ^5.3.1 - version: 5.3.2 + version: 5.3.1 croner: specifier: ^10.0.1 version: 10.0.1 @@ -119,7 +119,7 @@ importers: version: 10.0.0 ws: specifier: ^8.18.0 - version: 8.21.3 + version: 8.21.1 xdg-basedir: specifier: ^5.1.0 version: 5.1.0 @@ -135,10 +135,10 @@ importers: version: 2.5.0 '@changesets/cli': specifier: ^2.31.0 - version: 2.31.0(@types/node@26.1.2) + version: 2.31.0(@types/node@26.2.0) '@types/node': specifier: ^26.1.2 - version: 26.1.2 + version: 26.2.0 '@types/react': specifier: ^19.0.0 version: 19.2.17 @@ -183,11 +183,11 @@ importers: version: 1.3.0(zod@4.4.3) ws: specifier: ^8.16.0 - version: 8.21.3 + version: 8.21.1 devDependencies: '@types/node': specifier: ^26.1.2 - version: 26.1.2 + version: 26.2.0 '@types/vscode': specifier: ^1.125.0 version: 1.125.0 @@ -199,7 +199,7 @@ importers: version: 3.9.2 autoprefixer: specifier: ^10.4.19 - version: 10.5.4(postcss@8.5.26) + version: 10.5.4(postcss@8.5.25) concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -211,7 +211,7 @@ importers: version: 10.4.1(jiti@1.21.7) postcss: specifier: ^8.4.38 - version: 8.5.26 + version: 8.5.25 tailwindcss: specifier: ^3.4.4 version: 3.4.19(tsx@4.22.4)(yaml@2.9.0) @@ -1231,8 +1231,11 @@ packages: '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} - '@types/node@26.1.2': - resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + + '@types/node@26.2.0': + resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1655,8 +1658,8 @@ packages: engines: {node: '>=20'} hasBin: true - clipboardy@5.3.2: - resolution: {integrity: sha512-R35PENCHFCw6lsd5SjYPuAVV3Zawr74mKc7ogFNzoDPoQsmWDoJgUNNnWCk/czeqdZGZs8Y0M8zkOlVoySfHEQ==} + clipboardy@5.3.1: + resolution: {integrity: sha512-fPWgBqpp9ctiOQCkE5yjYGzv11ZU55g6ahEgr3COiio6dXdt1mbchCPXQrSR2Y9sZqfi8L7QD3+UosgXVIuPdg==} engines: {node: '>=20'} cliui@7.0.4: @@ -2867,8 +2870,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -3211,8 +3214,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} powershell-utils@0.2.0: @@ -3422,11 +3425,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -3818,6 +3816,9 @@ packages: undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -3959,8 +3960,8 @@ packages: resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} engines: {node: ^20.17.0 || >=22.9.0} - ws@8.21.3: - resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -4307,7 +4308,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.31.0(@types/node@26.1.2)': + '@changesets/cli@2.31.0(@types/node@26.2.0)': dependencies: '@changesets/apply-release-plan': 7.1.1 '@changesets/assemble-release-plan': 6.0.10 @@ -4323,7 +4324,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@26.1.2) + '@inquirer/external-editor': 1.0.3(@types/node@26.2.0) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 enquirer: 2.4.1 @@ -4579,12 +4580,12 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inquirer/external-editor@1.0.3(@types/node@26.1.2)': + '@inquirer/external-editor@1.0.3(@types/node@26.2.0)': dependencies: chardet: 2.2.0 iconv-lite: 0.7.2 optionalDependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 '@isaacs/fs-minipass@4.0.1': dependencies: @@ -5023,7 +5024,11 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@26.1.2': + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + + '@types/node@26.2.0': dependencies: undici-types: 8.3.0 @@ -5041,7 +5046,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 26.1.2 + '@types/node': 25.9.3 '@typespec/ts-http-runtime@0.3.6': dependencies: @@ -5245,13 +5250,13 @@ snapshots: auto-bind@5.0.1: {} - autoprefixer@10.5.4(postcss@8.5.26): + autoprefixer@10.5.4(postcss@8.5.25): dependencies: browserslist: 4.28.6 caniuse-lite: 1.0.30001806 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.26 + postcss: 8.5.25 postcss-value-parser: 4.2.0 ava@7.0.0(@ava/typescript@7.0.0): @@ -5523,7 +5528,7 @@ snapshots: dependencies: run-jxa: 3.0.0 - clipboardy@5.3.2: + clipboardy@5.3.1: dependencies: clipboard-image: 0.1.0 execa: 9.6.1 @@ -6361,7 +6366,7 @@ snapshots: type-fest: 5.7.0 widest-line: 6.0.0 wrap-ansi: 9.0.2 - ws: 8.21.3 + ws: 8.21.1 yoga-layout: 3.2.1 optionalDependencies: '@types/react': 19.2.17 @@ -6645,7 +6650,7 @@ snapshots: macos-version@6.0.0: dependencies: - semver: 7.8.5 + semver: 7.8.2 make-dir@4.0.0: dependencies: @@ -6739,7 +6744,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.18: {} + nanoid@3.3.16: {} napi-build-utils@2.0.0: optional: true @@ -7054,30 +7059,30 @@ snapshots: pluralize@8.0.0: {} - postcss-import@15.1.0(postcss@8.5.26): + postcss-import@15.1.0(postcss@8.5.25): dependencies: - postcss: 8.5.26 + postcss: 8.5.25 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.12 - postcss-js@4.1.0(postcss@8.5.26): + postcss-js@4.1.0(postcss@8.5.25): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.26 + postcss: 8.5.25 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.22.4)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.25)(tsx@4.22.4)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.26 + postcss: 8.5.25 tsx: 4.22.4 yaml: 2.9.0 - postcss-nested@6.2.0(postcss@8.5.26): + postcss-nested@6.2.0(postcss@8.5.25): dependencies: - postcss: 8.5.26 + postcss: 8.5.25 postcss-selector-parser: 6.1.4 postcss-selector-parser@6.1.4: @@ -7087,9 +7092,9 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.26: + postcss@8.5.25: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -7309,8 +7314,6 @@ snapshots: semver@7.8.2: {} - semver@7.8.5: {} - send@1.2.1: dependencies: debug: 4.4.3 @@ -7562,11 +7565,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.26 - postcss-import: 15.1.0(postcss@8.5.26) - postcss-js: 4.1.0(postcss@8.5.26) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.22.4)(yaml@2.9.0) - postcss-nested: 6.2.0(postcss@8.5.26) + postcss: 8.5.25 + postcss-import: 15.1.0(postcss@8.5.25) + postcss-js: 4.1.0(postcss@8.5.25) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.25)(tsx@4.22.4)(yaml@2.9.0) + postcss-nested: 6.2.0(postcss@8.5.25) postcss-selector-parser: 6.1.4 resolve: 1.22.12 sucrase: 3.35.1 @@ -7741,6 +7744,8 @@ snapshots: undici-types@5.26.5: {} + undici-types@7.24.6: {} + undici-types@8.3.0: {} undici@7.29.0: {} @@ -7857,7 +7862,7 @@ snapshots: dependencies: signal-exit: 4.1.0 - ws@8.21.3: {} + ws@8.21.1: {} wsl-utils@0.1.0: dependencies: diff --git a/scripts/test.sh b/scripts/test.sh index 4c1c24d36..7ecf127e6 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -15,6 +15,7 @@ echo "" echo "🔍 Checking TypeScript types..." pnpm test:types +pnpm test:types:vscode echo "" echo "✅ Type check passed" echo "" diff --git a/source/acp/acp-agent.spec.ts b/source/acp/acp-agent.spec.ts index 5db3b8c39..ad5447827 100644 --- a/source/acp/acp-agent.spec.ts +++ b/source/acp/acp-agent.spec.ts @@ -465,6 +465,44 @@ test('AcpAgent.cancel - aborts session for known session', async t => { t.pass(); }); +test('AcpAgent.cancel - stops a turn cancelled before the loop reads the signal', async t => { + const context = createMockInitContext(); + let chatCalls = 0; + (context.client as any).chat = async () => { + chatCalls++; + return {choices: [{message: {content: 'Test response'}}]}; + }; + const agent = new AcpAgent(context, createMockConn()); + const session = await agent.newSession({cwd: '/tmp'}); + + const turn = agent.prompt({ + sessionId: session.sessionId, + prompt: [{type: 'text', text: 'hi'}], + }); + await agent.cancel({sessionId: session.sessionId}); + + t.is((await turn).stopReason, 'cancelled'); + t.is(chatCalls, 0); +}); + +test('AcpAgent.prompt - a cancelled turn does not block the next prompt', async t => { + const {agent} = createAgent(); + const session = await agent.newSession({cwd: '/tmp'}); + + const cancelled = agent.prompt({ + sessionId: session.sessionId, + prompt: [{type: 'text', text: 'first'}], + }); + await agent.cancel({sessionId: session.sessionId}); + t.is((await cancelled).stopReason, 'cancelled'); + + const next = await agent.prompt({ + sessionId: session.sessionId, + prompt: [{type: 'text', text: 'second'}], + }); + t.is(next.stopReason, 'end_turn'); +}); + // ============================================================================ // setSessionMode() // ============================================================================ diff --git a/source/acp/acp-agent.ts b/source/acp/acp-agent.ts index 385d70953..533ab18e0 100644 --- a/source/acp/acp-agent.ts +++ b/source/acp/acp-agent.ts @@ -165,6 +165,8 @@ export class AcpAgent implements Agent { ); } + session.beginTurn(); + const {text: userText, images} = await acpContentToUserMessage( params.prompt, { diff --git a/source/acp/acp-session.spec.ts b/source/acp/acp-session.spec.ts index 6b0c39de6..671a28c65 100644 --- a/source/acp/acp-session.spec.ts +++ b/source/acp/acp-session.spec.ts @@ -113,7 +113,7 @@ test('AcpSession - cancel aborts the old controller', t => { t.true(oldController.signal.aborted); }); -test('AcpSession - cancel creates fresh abort controller', t => { +test('AcpSession - cancel leaves the session signal aborted', t => { const session = new AcpSession({ sessionId: 'test-id', cwd: '/tmp', @@ -121,10 +121,35 @@ test('AcpSession - cancel creates fresh abort controller', t => { }); const original = session.abortController; session.cancel(); - t.not(session.abortController, original); + t.is(session.abortController, original); + t.true(session.abortController.signal.aborted); +}); + +test('AcpSession - beginTurn creates fresh abort controller', t => { + const session = new AcpSession({ + sessionId: 'test-id', + cwd: '/tmp', + conn: createMockConn(), + }); + session.cancel(); + const cancelled = session.abortController; + session.beginTurn(); + t.not(session.abortController, cancelled); t.false(session.abortController.signal.aborted); }); +test('AcpSession - cancel after beginTurn aborts the turn controller', t => { + const session = new AcpSession({ + sessionId: 'test-id', + cwd: '/tmp', + conn: createMockConn(), + }); + session.beginTurn(); + const turnController = session.abortController; + session.cancel(); + t.true(turnController.signal.aborted); +}); + test('AcpSession - cancel can be called multiple times safely', t => { const session = new AcpSession({ sessionId: 'test-id', @@ -134,7 +159,7 @@ test('AcpSession - cancel can be called multiple times safely', t => { session.cancel(); session.cancel(); session.cancel(); - t.false(session.abortController.signal.aborted); + t.true(session.abortController.signal.aborted); }); // ============================================================================ diff --git a/source/acp/acp-session.ts b/source/acp/acp-session.ts index 40a079560..ccd12f5ae 100644 --- a/source/acp/acp-session.ts +++ b/source/acp/acp-session.ts @@ -38,7 +38,9 @@ export class AcpSession { cancel(): void { this.abortController.abort(); - // Create a fresh controller for potential subsequent prompts + } + + beginTurn(): void { this.abortController = new AbortController(); } } diff --git a/source/app/App.tsx b/source/app/App.tsx index 7570e5e69..604868d67 100644 --- a/source/app/App.tsx +++ b/source/app/App.tsx @@ -415,6 +415,7 @@ export default function App({ getMessageTokens: appState.getMessageTokens, setActiveMode: appState.setActiveMode, setIsSettingsMode: appState.setIsSettingsMode, + setSettingsActiveTab: appState.setSettingsActiveTab, addToChatQueue: appState.addToChatQueue, reinitializeMCPServers: appInitialization.reinitializeMCPServers, setTune: appState.setTune, @@ -523,9 +524,7 @@ export default function App({ getMessageTokens: appState.getMessageTokens, enterModelSelectionMode: modeHandlers.enterModelSelectionMode, enterModelDatabaseMode: modeHandlers.enterModelDatabaseMode, - enterConfigWizardMode: modeHandlers.enterConfigWizardMode, enterSettingsMode: modeHandlers.enterSettingsMode, - enterMcpWizardMode: modeHandlers.enterMcpWizardMode, enterExplorerMode: modeHandlers.enterExplorerMode, enterIdeSelectionMode: modeHandlers.enterIdeSelectionMode, enterTune: modeHandlers.enterTune, diff --git a/source/app/components/app-container.tsx b/source/app/components/app-container.tsx index 8b4c267fa..222c493ff 100644 --- a/source/app/components/app-container.tsx +++ b/source/app/components/app-container.tsx @@ -10,6 +10,7 @@ import { getGitStatusSummarySync, } from '@/tools/git/utils'; import {DEVELOPMENT_MODE_LABELS, type DevelopmentMode} from '@/types/core'; +import {homeRelative} from '@/utils/path'; /** * Format a {@link GitStatusSummary} for inline display next to the @@ -54,8 +55,7 @@ function BootSummary({ const {colors} = useTheme(); const {isNarrow} = useResponsiveTerminal(); const configPath = getClosestConfigFile('agents.config.json'); - const homedir = process.env.HOME || process.env.USERPROFILE || ''; - const shortConfig = homedir ? configPath.replace(homedir, '~') : configPath; + const shortConfig = homeRelative(configPath); const modeLabel = mode ? DEVELOPMENT_MODE_LABELS[mode] : undefined; const gitStatus = getGitStatusSummarySync(); const gitLabel = gitStatus ? formatBootSummaryGitLabel(gitStatus) : undefined; diff --git a/source/app/components/modal-selectors.spec.tsx b/source/app/components/modal-selectors.spec.tsx index 4bd7922fd..dd58b4830 100644 --- a/source/app/components/modal-selectors.spec.tsx +++ b/source/app/components/modal-selectors.spec.tsx @@ -20,8 +20,6 @@ function createDefaultProps( onModelDatabaseCancel: () => {}, onConfigWizardComplete: async () => {}, onConfigWizardCancel: () => {}, - onMcpWizardComplete: async () => {}, - onMcpWizardCancel: () => {}, onCheckpointSelect: async () => {}, onCheckpointCancel: () => {}, onSessionSelect: () => {}, @@ -72,17 +70,6 @@ test('ModalSelectors renders ConfigWizard when activeMode is configWizard', t => unmount(); }); -test('ModalSelectors renders McpWizard when activeMode is mcpWizard', t => { - const props = createDefaultProps({activeMode: 'mcpWizard'}); - const component = ModalSelectors(props); - t.truthy(component); - - const {lastFrame, unmount} = renderWithTheme(<>{component}); - const output = lastFrame(); - t.truthy(output); - unmount(); -}); - test('ModalSelectors renders SettingsSelector when isSettingsMode is true', t => { const props = createDefaultProps({isSettingsMode: true}); const component = ModalSelectors(props); diff --git a/source/app/components/modal-selectors.tsx b/source/app/components/modal-selectors.tsx index 87b03eb27..67e6b6780 100644 --- a/source/app/components/modal-selectors.tsx +++ b/source/app/components/modal-selectors.tsx @@ -5,8 +5,8 @@ import ModelSelector from '@/components/model-selector'; import SessionSelector from '@/components/session-selector'; import type {ActiveMode} from '@/hooks/useAppState'; import type {CheckpointListItem, TuneConfig} from '@/types'; -import {McpWizard} from '@/wizards/mcp-wizard'; import {ProviderWizard} from '@/wizards/provider-wizard'; +import type {SettingsTabId} from './settings-constants'; import {SettingsSelector} from './settings-tabs'; import {TuneSelector} from './tune-selector'; @@ -14,8 +14,11 @@ export interface ModalSelectorsProps { onLaunchTune?: () => void; onLaunchIde?: () => void; onMcpChanged?: () => void | Promise; + onProvidersChanged?: () => void | Promise; activeMode: ActiveMode; isSettingsMode: boolean; + settingsInitialTab?: SettingsTabId; + onSettingsTabChange?: (tab: SettingsTabId) => void; showAllSessions: boolean; // Current values @@ -37,10 +40,6 @@ export interface ModalSelectorsProps { onConfigWizardComplete: (configPath: string) => Promise; onConfigWizardCancel: () => void; - // Handlers - MCP Wizard - onMcpWizardComplete: (configPath: string) => Promise; - onMcpWizardCancel: () => void; - // Handlers - Checkpoint onCheckpointSelect: (name: string, backup: boolean) => Promise; onCheckpointCancel: () => void; @@ -65,6 +64,8 @@ export interface ModalSelectorsProps { export function ModalSelectors({ activeMode, isSettingsMode, + settingsInitialTab, + onSettingsTabChange, showAllSessions, currentModel, currentProvider, @@ -74,8 +75,6 @@ export function ModalSelectors({ onModelDatabaseCancel, onConfigWizardComplete, onConfigWizardCancel, - onMcpWizardComplete, - onMcpWizardCancel, onCheckpointSelect, onCheckpointCancel, onSessionSelect, @@ -84,6 +83,7 @@ export function ModalSelectors({ onLaunchTune, onLaunchIde, onMcpChanged, + onProvidersChanged, tuneConfig, onTuneSelect, onTuneCancel, @@ -116,6 +116,9 @@ export function ModalSelectors({ onLaunchTune={onLaunchTune} onLaunchIde={onLaunchIde} onMcpChanged={onMcpChanged} + onProvidersChanged={onProvidersChanged} + initialTab={settingsInitialTab} + onTabChange={onSettingsTabChange} /> ); } @@ -134,16 +137,6 @@ export function ModalSelectors({ ); } - if (activeMode === 'mcpWizard') { - return ( - void onMcpWizardComplete(configPath)} - onCancel={onMcpWizardCancel} - /> - ); - } - if (activeMode === 'checkpointLoad' && checkpointLoadData) { return ( { + const {lastFrame} = render( + {}} onCancel={() => {}} />, + ); + + const output = lastFrame()!; + t.regex(output, /filesystem/); + t.regex(output, /github/); +}); + +test('offers an explicit add row separate from the server rows', t => { + const {lastFrame} = render( + {}} onCancel={() => {}} />, + ); + + const output = lastFrame()!; + // The old panel had a single "+ Add or edit MCP servers…" row and every + // server row opened that same generic flow. + t.regex(output, /\+ Add an MCP server/); + t.notRegex(output, /Add or edit MCP servers/); +}); + +test('tells the user that Enter acts on the selected server', t => { + const {lastFrame} = render( + {}} onCancel={() => {}} />, + ); + + t.regex(lastFrame()!, /edits or deletes the selected server/); +}); diff --git a/source/app/components/settings-mcp-list.tsx b/source/app/components/settings-mcp-list.tsx index 5506140c6..acb812649 100644 --- a/source/app/components/settings-mcp-list.tsx +++ b/source/app/components/settings-mcp-list.tsx @@ -8,8 +8,8 @@ import {useTheme} from '@/hooks/useTheme'; import {McpWizard} from '@/wizards/mcp-wizard'; /** - * Lists the configured MCP servers first, then opens the existing MCP wizard to - * add/edit rather than jumping straight into it. + * Lists the configured MCP servers first. Selecting a server opens the wizard on + * that entry's edit/delete choice; the trailing row adds a new one. */ export function SettingsMcpListPanel({ onBack, @@ -21,40 +21,43 @@ export function SettingsMcpListPanel({ }) { const {colors} = useTheme(); const {boxWidth, isNarrow} = useResponsiveTerminal(); - const [editing, setEditing] = useState(false); + // null = not editing. '' = adding a new server (no entry targeted). + const [editTarget, setEditTarget] = useState(null); const servers = getAppConfig().mcpServers ?? []; useInput((_, key) => { - if (editing) return; + if (editTarget !== null) return; if (key.escape) onBack(); if (key.shift && key.tab) onBack(); }); - if (editing) { + if (editTarget !== null) { return ( { + initialEditName={editTarget || undefined} + onComplete={async () => { // Rebuild the running session's MCP connections; otherwise a server // added here stays inert until the next launch. - void onMcpChanged?.(); - onBack(); + await onMcpChanged?.(); + setEditTarget(null); }} - onCancel={() => setEditing(false)} + onCancel={() => setEditTarget(null)} /> ); } const items = [ - ...servers.map((s, i) => { + ...servers.map(s => { const detail = s.command ? s.command : s.url ? s.url : '(no endpoint)'; + // Value is the server name so the wizard can target this entry. return { label: `${s.name} · ${s.transport} · ${detail}`, - value: String(i), + value: s.name, }; }), - {label: '+ Add or edit MCP servers…', value: 'edit'}, + {label: '+ Add an MCP server…', value: ''}, ]; return ( @@ -70,10 +73,13 @@ export function SettingsMcpListPanel({ {servers.length} server{servers.length === 1 ? '' : 's'} configured. - Enter opens the wizard to add or edit. + Enter edits or deletes the selected server. - setEditing(true)} /> + setEditTarget(item.value)} + /> Shift+Tab back · Esc back diff --git a/source/app/components/settings-providers-list.spec.tsx b/source/app/components/settings-providers-list.spec.tsx new file mode 100644 index 000000000..7f73395e1 --- /dev/null +++ b/source/app/components/settings-providers-list.spec.tsx @@ -0,0 +1,76 @@ +import {mkdtempSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import test from 'ava'; +import React from 'react'; + +// CRITICAL: point config reads at a temp dir BEFORE the panel's @/config/index +// import chain loads, so this spec never reads the developer's real config. +// The providers must live in a *project* config: loadAllProviderConfigs skips +// global providers entirely when NODE_ENV is 'test' (which AVA sets), so a +// global-only config would load as zero providers. +const configDir = mkdtempSync(join(tmpdir(), 'nanocoder-spec-')); +process.env.NANOCODER_CONFIG_DIR = configDir; +// AVA runs each spec file in its own process (workerThreads: false), so this +// chdir cannot leak into another spec and needs no restore hook — and a hook +// declared between top-level awaits would not be picked up anyway. +process.chdir(configDir); +writeFileSync( + join(configDir, 'agents.config.json'), + JSON.stringify({ + nanocoder: { + providers: [ + { + name: 'ollama', + baseUrl: 'http://localhost:11434/v1', + models: ['llama2'], + }, + { + name: 'openrouter', + baseUrl: 'https://openrouter.ai/api/v1', + models: ['gpt-4', 'claude'], + }, + ], + }, + }), +); + +const {reloadAppConfig} = await import('@/config/index'); +reloadAppConfig(); + +const {renderWithTheme: render} = await import( + '../../test-utils/render-with-theme' +); +const {SettingsProvidersListPanel} = await import('./settings-providers-list'); + +console.log(`\nsettings-providers-list.spec.tsx – ${React.version}`); + +test('lists every configured provider as its own row', t => { + const {lastFrame} = render( + {}} onCancel={() => {}} />, + ); + + const output = lastFrame()!; + t.regex(output, /ollama/); + t.regex(output, /openrouter/); +}); + +test('offers an explicit add row separate from the provider rows', t => { + const {lastFrame} = render( + {}} onCancel={() => {}} />, + ); + + const output = lastFrame()!; + // The old panel had a single "+ Add or edit providers…" row and every + // provider row opened that same generic flow. + t.regex(output, /\+ Add a provider/); + t.notRegex(output, /Add or edit providers/); +}); + +test('tells the user that Enter acts on the selected provider', t => { + const {lastFrame} = render( + {}} onCancel={() => {}} />, + ); + + t.regex(lastFrame()!, /edits or deletes the selected provider/); +}); diff --git a/source/app/components/settings-providers-list.tsx b/source/app/components/settings-providers-list.tsx index 785c0d558..b235ff339 100644 --- a/source/app/components/settings-providers-list.tsx +++ b/source/app/components/settings-providers-list.tsx @@ -9,46 +9,58 @@ import {ProviderWizard} from '@/wizards/provider-wizard'; /** * Lists the configured AI providers first (inspired by openclaude's - * ProviderManager and codex/opencode provider pickers), then opens the existing - * provider wizard to add/edit rather than jumping straight into it. + * ProviderManager and codex/opencode provider pickers). Selecting a provider + * opens the wizard on that entry's edit/delete choice; the trailing row adds a + * new one. */ export function SettingsProvidersListPanel({ onBack, + onProvidersChanged, }: { onBack: () => void; onCancel: () => void; + onProvidersChanged?: () => void | Promise; }) { const {colors} = useTheme(); const {boxWidth, isNarrow} = useResponsiveTerminal(); - const [editing, setEditing] = useState(false); + // null = not editing. '' = adding a new provider (no entry targeted). + const [editTarget, setEditTarget] = useState(null); const providers = getAppConfig().providers ?? []; useInput((_, key) => { - if (editing) return; + if (editTarget !== null) return; if (key.escape) onBack(); if (key.shift && key.tab) onBack(); }); - if (editing) { + if (editTarget !== null) { return ( setEditing(false)} + initialEditName={editTarget || undefined} + onComplete={async () => { + // Rebuild the client for current provider/model without clearing + // messages. The parent owns closing the editing state. + await onProvidersChanged?.(); + setEditTarget(null); + }} + onCancel={() => setEditTarget(null)} /> ); } const items = [ - ...providers.map((p, i) => { + ...providers.map(p => { const where = p.baseUrl ? p.baseUrl : 'default endpoint'; const models = p.models?.length ? `${p.models[0]}${p.models.length > 1 ? ` +${p.models.length - 1}` : ''}` : 'no models'; - return {label: `${p.name} · ${where} · ${models}`, value: String(i)}; + // Value is the provider name so the wizard can target this entry even + // though it loads a single config file rather than the resolved config. + return {label: `${p.name} · ${where} · ${models}`, value: p.name}; }), - {label: '+ Add or edit providers…', value: 'edit'}, + {label: '+ Add a provider…', value: ''}, ]; return ( @@ -64,10 +76,13 @@ export function SettingsProvidersListPanel({ {providers.length} provider{providers.length === 1 ? '' : 's'}{' '} - configured. Enter opens the wizard to add or edit. + configured. Enter edits or deletes the selected provider. - setEditing(true)} /> + setEditTarget(item.value)} + /> Shift+Tab back · Esc back diff --git a/source/app/components/settings-selector.tsx b/source/app/components/settings-selector.tsx index facc1a736..4e0ed1355 100644 --- a/source/app/components/settings-selector.tsx +++ b/source/app/components/settings-selector.tsx @@ -28,6 +28,7 @@ import type {NotificationsConfig} from '@/types/config'; import type {NanocoderShape, ThemePreset} from '@/types/ui'; import {setNotificationsConfig} from '@/utils/notifications'; import {DEFAULT_SINGLE_LINE_PASTE_THRESHOLD} from '@/utils/paste-utils'; +import type {SettingsTabId} from './settings-constants'; /** * The set of "managed" settings panels: preserved full-featured sub-UIs that @@ -65,6 +66,20 @@ export interface SettingsSelectorProps { * this, servers added here only take effect on the next launch. */ onMcpChanged?: () => void | Promise; + /** + * Rebuild the client for the current provider/model after the Providers panel + * edits config, without clearing messages or resetting to default provider. + */ + onProvidersChanged?: () => void | Promise; + /** + * The tab to open initially. Defaults to 'appearance' if not specified. + */ + initialTab?: SettingsTabId; + /** + * Called when the active tab changes, so the parent can track it for + * returning after launching wizards. + */ + onTabChange?: (tab: SettingsTabId) => void; } function ThemePreviewMessage({ diff --git a/source/app/components/settings-tabs.tsx b/source/app/components/settings-tabs.tsx index 126f017a9..62deb24e4 100644 --- a/source/app/components/settings-tabs.tsx +++ b/source/app/components/settings-tabs.tsx @@ -19,6 +19,7 @@ import {useTitleShape} from '@/hooks/useTitleShape'; import {fuzzyScore} from '@/utils/fuzzy-matching'; import {DEFAULT_SINGLE_LINE_PASTE_THRESHOLD} from '@/utils/paste-utils'; import {SettingsAutoCompactPanel} from './settings-auto-compact'; +import {SETTINGS_TAB_IDS, type SettingsTabId} from './settings-constants'; import {SettingsDefaultModePanel} from './settings-default-mode'; import {SettingsEnvironmentPanel} from './settings-environment'; import {SettingsJsonConfigPanel} from './settings-json-config'; @@ -48,25 +49,29 @@ import {SettingsWebSearchPanel} from './settings-web-search'; * Every existing preference must be reachable from exactly one of these * its tabs. */ -export type SettingsTabId = - | 'appearance' - | 'input' - | 'behavior' - | 'providers' - | 'advanced'; interface TabDefinition { id: SettingsTabId; label: string; } -const TABS: TabDefinition[] = [ - {id: 'appearance', label: 'Appearance'}, - {id: 'input', label: 'Input'}, - {id: 'behavior', label: 'Behavior'}, - {id: 'providers', label: 'Providers'}, - {id: 'advanced', label: 'Advanced'}, -]; +/** + * Labels for each tab. Keyed by `SettingsTabId`, so adding an id to + * settings-constants.ts without a label here is a compile error. + */ +const TAB_LABELS: Record = { + appearance: 'Appearance', + input: 'Input', + behavior: 'Behavior', + providers: 'Providers', + mcp: 'MCP', + advanced: 'Advanced', +}; + +const TABS: TabDefinition[] = SETTINGS_TAB_IDS.map(id => ({ + id, + label: TAB_LABELS[id], +})); type SettingRow = | { @@ -216,13 +221,6 @@ function buildRowsForTab( value: `${getAppConfig().providers?.length ?? 0} configured`, panel: 'providers-config', }, - { - kind: 'managed', - id: 'mcp-config', - label: 'Configure MCP Servers', - value: `${getAppConfig().mcpServers?.length ?? 0} configured`, - panel: 'mcp-config', - }, { kind: 'managed', id: 'web-search', @@ -240,6 +238,16 @@ function buildRowsForTab( panel: 'tool-approval', }, ]; + case 'mcp': + return [ + { + kind: 'managed', + id: 'mcp-config', + label: 'Configure MCP Servers', + value: `${getAppConfig().mcpServers?.length ?? 0} configured`, + panel: 'mcp-config', + }, + ]; case 'advanced': { const rows: SettingRow[] = [ { @@ -376,6 +384,7 @@ function renderManagedPanel( panel: ManagedSettingsPanel, onBack: () => void, onMcpChanged?: () => void | Promise, + onProvidersChanged?: () => void | Promise, ): ReactElement { switch (panel) { case 'theme': @@ -409,7 +418,13 @@ function renderManagedPanel( case 'environment': return ; case 'providers-config': - return ; + return ( + + ); case 'mcp-config': return ( ('appearance'); + const [activeTab, setActiveTabState] = useState( + initialTab ?? 'appearance', + ); + + // The only sanctioned way to change tabs: keeps the parent's tracked tab in + // step so returning from Tune/IDE lands back where the user was. Nothing + // should call setActiveTabState directly. + const updateActiveTab = (tab: SettingsTabId) => { + setActiveTabState(tab); + onTabChange?.(tab); + }; const [focus, setFocus] = useState('header'); const [openPanel, setOpenPanel] = useState(null); @@ -576,7 +604,7 @@ export function SettingsSelector({ const goToTab = (direction: 1 | -1) => { const idx = TABS.findIndex(t => t.id === activeTab); const next = TABS[(idx + direction + TABS.length) % TABS.length]; - if (next) setActiveTab(next.id); + if (next) updateActiveTab(next.id); }; const activateRow = (row: SettingRow) => { @@ -731,7 +759,12 @@ export function SettingsSelector({ setVersion(v => v + 1); setOpenPanel(null); }; - return renderManagedPanel(openPanel, onBack, onMcpChanged); + return renderManagedPanel( + openPanel, + onBack, + onMcpChanged, + onProvidersChanged, + ); } const width = isNarrow ? '100%' : boxWidth; diff --git a/source/app/hooks/useAppLogging.tsx b/source/app/hooks/useAppLogging.tsx index 05e0555fd..71bc6ba39 100644 --- a/source/app/hooks/useAppLogging.tsx +++ b/source/app/hooks/useAppLogging.tsx @@ -109,7 +109,6 @@ export function useAppLogging({ !isToolExecuting && !isToolConfirmationMode && activeMode !== 'configWizard' && - activeMode !== 'mcpWizard' && pendingToolCallsLength === 0 ) { const correlationId = generateCorrelationId(); diff --git a/source/app/sections/interactive-app.spec.tsx b/source/app/sections/interactive-app.spec.tsx index 4e26cfa1a..9c610779a 100644 --- a/source/app/sections/interactive-app.spec.tsx +++ b/source/app/sections/interactive-app.spec.tsx @@ -109,8 +109,6 @@ function makeProps(o: Overrides = {}) { handleModelDatabaseCancel: noop, handleConfigWizardComplete: noop, handleConfigWizardCancel: noop, - handleMcpWizardComplete: noop, - handleMcpWizardCancel: noop, handleSettingsCancel: noop, handleTuneSelect: noop, handleTuneCancel: noop, diff --git a/source/app/sections/interactive-app.tsx b/source/app/sections/interactive-app.tsx index 5639ee9fe..486e7494e 100644 --- a/source/app/sections/interactive-app.tsx +++ b/source/app/sections/interactive-app.tsx @@ -3,6 +3,7 @@ import React from 'react'; import {ChatHistory} from '@/app/components/chat-history'; import {ChatInput} from '@/app/components/chat-input'; import {ModalSelectors} from '@/app/components/modal-selectors'; +import type {SettingsTabId} from '@/app/components/settings-constants'; import {FileExplorer} from '@/components/file-explorer'; import {IdeSelector} from '@/components/ide-selector'; import PlanReviewPrompt from '@/components/plan-review-prompt'; @@ -78,12 +79,16 @@ export function InteractiveApp({ // Tune / IDE are launched by closing settings first, so their exit has no way // to know it should land back in settings rather than in chat. const launchedFromSettingsRef = React.useRef(false); + // Track which tab was active when launching Tune/IDE so we can return to it. + const launchedFromTabRef = React.useRef(undefined); const returnFromLaunchedWizard = React.useCallback( (exit: () => void) => () => { exit(); if (launchedFromSettingsRef.current) { launchedFromSettingsRef.current = false; - modeHandlers.enterSettingsMode(); + // Return to the tab that was active when the wizard was launched. + modeHandlers.enterSettingsMode(launchedFromTabRef.current); + launchedFromTabRef.current = undefined; } }, [modeHandlers], @@ -335,6 +340,8 @@ export function InteractiveApp({ { + // Capture the current tab before closing settings. + launchedFromTabRef.current = appState.settingsActiveTab; launchedFromSettingsRef.current = true; modeHandlers.handleSettingsCancel(); modeHandlers.enterTune(); }} onLaunchIde={() => { + // Capture the current tab before closing settings. + launchedFromTabRef.current = appState.settingsActiveTab; launchedFromSettingsRef.current = true; modeHandlers.handleSettingsCancel(); modeHandlers.enterIdeSelectionMode(); diff --git a/source/app/utils/app-util.spec.ts b/source/app/utils/app-util.spec.ts index 4bb599ae2..0ea86a516 100644 --- a/source/app/utils/app-util.spec.ts +++ b/source/app/utils/app-util.spec.ts @@ -3,9 +3,9 @@ import React from 'react'; import { createClearMessagesHandler, handleMessageSubmission, - parseContextLimit, parseCustomCommandArgs, } from './app-util.js'; +import {SETTINGS_TAB_IDS} from '@/app/components/settings-constants'; import {lazyCommands} from '@/commands/lazy-registry'; import BashProgress from '@/components/bash-progress'; import type {MessageSubmissionOptions} from '@/types/index'; @@ -143,19 +143,6 @@ test('checkpoint load detection - other checkpoint subcommand', t => { t.false(isCheckpointLoad); }); -// Test setup-mcp command parsing -test('setup-mcp command parsing - extracts command name correctly', t => { - const message = '/setup-mcp'; - const commandName = message.slice(1).split(/\s+/)[0]; - t.is(commandName, 'setup-mcp'); -}); - -test('setup-mcp command parsing - handles command with extra whitespace', t => { - const message = '/setup-mcp '; - const commandName = message.slice(1).split(/\s+/)[0]; - t.is(commandName, 'setup-mcp'); -}); - // Test /commands create detection test('commands create detection - matches commands create', t => { const message = '/commands create my-tool'; @@ -217,51 +204,6 @@ test('commands create - preserves .md extension when present', t => { t.is(safeName, 'my-tool.md'); }); -// Test parseContextLimit -test('parseContextLimit - plain number', t => { - t.is(parseContextLimit('8192'), 8192); -}); - -test('parseContextLimit - k suffix lowercase', t => { - t.is(parseContextLimit('128k'), 128000); -}); - -test('parseContextLimit - K suffix uppercase', t => { - t.is(parseContextLimit('128K'), 128000); -}); - -test('parseContextLimit - fractional k value', t => { - t.is(parseContextLimit('4.5k'), 4500); -}); - -test('parseContextLimit - zero returns null', t => { - t.is(parseContextLimit('0'), null); -}); - -test('parseContextLimit - negative returns null', t => { - t.is(parseContextLimit('-5'), null); -}); - -test('parseContextLimit - non-numeric returns null', t => { - t.is(parseContextLimit('abc'), null); -}); - -test('parseContextLimit - just k returns null', t => { - t.is(parseContextLimit('k'), null); -}); - -test('parseContextLimit - whitespace is trimmed', t => { - t.is(parseContextLimit(' 8192 '), 8192); -}); - -test('parseContextLimit - large value with k suffix', t => { - t.is(parseContextLimit('256k'), 256000); -}); - -test('parseContextLimit - decimal without k suffix', t => { - t.is(parseContextLimit('1024.5'), 1025); -}); - // Test /ide command parsing test('ide command parsing - extracts command name correctly', t => { const message = '/ide'; @@ -274,8 +216,6 @@ test('ide command parsing - recognized as special command', t => { CLEAR: 'clear', MODEL: 'model', MODEL_DATABASE: 'model-database', - SETUP_PROVIDERS: 'setup-providers', - SETUP_MCP: 'setup-mcp', SETTINGS: 'settings', STATUS: 'status', CHECKPOINT: 'checkpoint', @@ -306,9 +246,7 @@ function createResumeTestOptions(overrides: { onClearMessages: async () => {}, onEnterModelSelectionMode: () => {}, onEnterModelDatabaseMode: () => {}, - onEnterConfigWizardMode: () => {}, onEnterSettingsMode: () => {}, - onEnterMcpWizardMode: () => {}, onEnterExplorerMode: () => {}, onEnterIdeSelectionMode: () => {}, onEnterCheckpointLoadMode: () => {}, @@ -744,9 +682,7 @@ function createRenameTestOptions(overrides: { commandArgs: overrides.commandArgs, onEnterModelSelectionMode: () => {}, onEnterModelDatabaseMode: () => {}, - onEnterConfigWizardMode: () => {}, onEnterSettingsMode: () => {}, - onEnterMcpWizardMode: () => {}, onEnterExplorerMode: () => {}, onEnterIdeSelectionMode: () => {}, onEnterCheckpointLoadMode: () => {}, @@ -887,3 +823,143 @@ test('createClearMessagesHandler - does not throw when client is null', async t const handler = createClearMessagesHandler(() => {}, null); await t.notThrowsAsync(() => handler()); }); + +// --- /settings tabs and retired /setup-* commands --- + +function createSettingsTestOptions(overrides: { + onEnterSettingsMode?: (tab?: string) => void; + onAddToChatQueue?: (component: React.ReactNode) => void; + commandArgs?: string[]; +}): MessageSubmissionOptions { + return { + customCommandCache: new Map(), + customCommandLoader: null, + customCommandExecutor: null, + onClearMessages: async () => {}, + onRenameSession: () => {}, + commandArgs: overrides.commandArgs, + onEnterModelSelectionMode: () => {}, + onEnterModelDatabaseMode: () => {}, + onEnterSettingsMode: overrides.onEnterSettingsMode ?? (() => {}), + onEnterExplorerMode: () => {}, + onEnterIdeSelectionMode: () => {}, + onEnterTune: () => {}, + onEnterCheckpointLoadMode: () => {}, + onShowStatus: () => {}, + onHandleChatMessage: async () => {}, + onAddToChatQueue: overrides.onAddToChatQueue ?? (() => {}), + setLiveComponent: () => {}, + setIsToolExecuting: () => {}, + setMessages: () => {}, + messages: [], + provider: 'test', + model: 'test', + theme: 'dark', + updateInfo: null, + getMessageTokens: () => 0, + } as unknown as MessageSubmissionOptions; +} + +test('settings command - no argument leaves the tab unset', async t => { + let captured: string | undefined | symbol = Symbol('uncalled'); + const options = createSettingsTestOptions({ + onEnterSettingsMode: tab => { + captured = tab; + }, + }); + await handleMessageSubmission('/settings', options); + t.is(captured, undefined); +}); + +test('settings command - known tab argument opens that tab', async t => { + let captured: string | undefined; + const options = createSettingsTestOptions({ + onEnterSettingsMode: tab => { + captured = tab; + }, + commandArgs: ['mcp'], + }); + await handleMessageSubmission('/settings mcp', options); + t.is(captured, 'mcp'); +}); + +test('settings command - tab argument is case-insensitive', async t => { + let captured: string | undefined; + const options = createSettingsTestOptions({ + onEnterSettingsMode: tab => { + captured = tab; + }, + commandArgs: ['Providers'], + }); + await handleMessageSubmission('/settings Providers', options); + t.is(captured, 'providers'); +}); + +test('settings command - unknown tab reports an error instead of opening', async t => { + let called = false; + const queue: React.ReactNode[] = []; + const options = createSettingsTestOptions({ + onEnterSettingsMode: () => { + called = true; + }, + onAddToChatQueue: c => queue.push(c), + commandArgs: ['bogus'], + }); + await handleMessageSubmission('/settings bogus', options); + t.false(called, 'an unknown tab should not silently open the default tab'); + t.true( + findMessageInQueue(queue, m => m.includes('Unknown settings tab: "bogus"')), + 'the error names the offending argument', + ); +}); + +test('settings command - unknown tab error lists the valid tabs', async t => { + const queue: React.ReactNode[] = []; + const options = createSettingsTestOptions({ + onAddToChatQueue: c => queue.push(c), + commandArgs: ['providrs'], + }); + await handleMessageSubmission('/settings providrs', options); + t.true( + findMessageInQueue(queue, m => + SETTINGS_TAB_IDS.every(tab => m.includes(tab)), + ), + 'the error lists every valid tab so the typo is recoverable', + ); +}); + +test('retired setup-providers - forwards to the settings providers tab', async t => { + let captured: string | undefined; + const queue: React.ReactNode[] = []; + const options = createSettingsTestOptions({ + onEnterSettingsMode: tab => { + captured = tab; + }, + onAddToChatQueue: c => queue.push(c), + }); + await handleMessageSubmission('/setup-providers', options); + t.is(captured, 'providers'); + t.true(findMessageInQueue(queue, m => m.includes('/settings providers'))); +}); + +test('retired setup-mcp - forwards to the settings mcp tab', async t => { + let captured: string | undefined; + const queue: React.ReactNode[] = []; + const options = createSettingsTestOptions({ + onEnterSettingsMode: tab => { + captured = tab; + }, + onAddToChatQueue: c => queue.push(c), + }); + await handleMessageSubmission('/setup-mcp', options); + t.is(captured, 'mcp'); + t.true(findMessageInQueue(queue, m => m.includes('/settings mcp'))); +}); + +test('retired setup commands - no longer registered in the slash menu', t => { + const names = lazyCommands.map(c => c.name); + t.false(names.includes('setup-providers')); + t.false(names.includes('setup-mcp')); + t.true(names.includes('settings')); + t.true(names.includes('setup-config'), 'unrelated /setup-config stays'); +}); diff --git a/source/app/utils/app-util.ts b/source/app/utils/app-util.ts index 265583618..44b5950b7 100644 --- a/source/app/utils/app-util.ts +++ b/source/app/utils/app-util.ts @@ -1,4 +1,8 @@ import React from 'react'; +import { + SETTINGS_TAB_IDS, + type SettingsTabId, +} from '@/app/components/settings-constants'; import {parseInput} from '@/command-parser'; import {commandRegistry} from '@/commands'; import {CodexLogin} from '@/commands/codex-login'; @@ -26,9 +30,6 @@ import { import {handleRetryCommand} from './handlers/retry-handler'; import {handleResumeCommand} from './handlers/session-handler'; -// Re-export for consumers that import parseContextLimit from here -export {parseContextLimit} from './handlers/context-max-handler'; - /** * "Special commands" need access to app-level state (setting modes, mutating * messages, swapping live components) that the standard `Command.handler` @@ -43,8 +44,6 @@ const SPECIAL_COMMANDS = { CLEAR: 'clear', MODEL: 'model', MODEL_DATABASE: 'model-database', - SETUP_PROVIDERS: 'setup-providers', - SETUP_MCP: 'setup-mcp', SETTINGS: 'settings', STATUS: 'status', CHECKPOINT: 'checkpoint', @@ -54,6 +53,12 @@ const SPECIAL_COMMANDS = { RENAME: 'rename', } as const; +/** Retired in favour of `/settings`; forwarded so they don't error out. */ +const RETIRED_SETUP_COMMANDS: Record = { + 'setup-providers': 'providers', + 'setup-mcp': 'mcp', +}; + /** Checkpoint subcommands */ const CHECKPOINT_SUBCOMMANDS = { LOAD: 'load', @@ -228,6 +233,10 @@ async function handleCustomCommand( return true; } +function isSettingsTabId(value: string): value is SettingsTabId { + return (SETTINGS_TAB_IDS as readonly string[]).includes(value); +} + /** * Handles special commands that need app state access (/clear, /model, etc.) * Returns true if a special command was handled. @@ -241,9 +250,7 @@ async function handleSpecialCommand( onRenameSession, onEnterModelSelectionMode, onEnterModelDatabaseMode, - onEnterConfigWizardMode, onEnterSettingsMode, - onEnterMcpWizardMode, onEnterExplorerMode, onShowStatus, onCommandComplete, @@ -256,9 +263,6 @@ async function handleSpecialCommand( const enterModeCommands: Record void> = { [SPECIAL_COMMANDS.MODEL]: onEnterModelSelectionMode, [SPECIAL_COMMANDS.MODEL_DATABASE]: onEnterModelDatabaseMode, - [SPECIAL_COMMANDS.SETUP_PROVIDERS]: onEnterConfigWizardMode, - [SPECIAL_COMMANDS.SETUP_MCP]: onEnterMcpWizardMode, - [SPECIAL_COMMANDS.SETTINGS]: onEnterSettingsMode, [SPECIAL_COMMANDS.EXPLORER]: onEnterExplorerMode, [SPECIAL_COMMANDS.IDE]: options.onEnterIdeSelectionMode, [SPECIAL_COMMANDS.TUNE]: options.onEnterTune, @@ -271,7 +275,41 @@ async function handleSpecialCommand( return true; } + const retiredTab = RETIRED_SETUP_COMMANDS[commandName]; + if (retiredTab) { + onAddToChatQueue( + infoMsg( + `/${commandName} has moved to /settings — opening the ${retiredTab} tab. Use /settings ${retiredTab} next time.`, + `${commandName}-retired`, + ), + ); + onEnterSettingsMode(retiredTab); + onCommandComplete?.(); + return true; + } + switch (commandName) { + case SPECIAL_COMMANDS.SETTINGS: { + const rawTab = commandArgs?.[0]; + const tabArg = rawTab?.toLowerCase(); + let tab: SettingsTabId | undefined; + if (tabArg) { + if (!isSettingsTabId(tabArg)) { + onAddToChatQueue( + errorMsg( + `Unknown settings tab: "${rawTab}". Valid tabs: ${SETTINGS_TAB_IDS.join(', ')}`, + 'settings-error', + ), + ); + setTimeout(() => onCommandComplete?.(), DELAY_COMMAND_COMPLETE_MS); + return true; + } + tab = tabArg; + } + onEnterSettingsMode(tab); + onCommandComplete?.(); + return true; + } case SPECIAL_COMMANDS.CLEAR: await onClearMessages(); await clearAllTasks(); diff --git a/source/app/utils/handlers/context-max-handler.spec.ts b/source/app/utils/handlers/context-max-handler.spec.ts index 24cb8c910..be6356690 100644 --- a/source/app/utils/handlers/context-max-handler.spec.ts +++ b/source/app/utils/handlers/context-max-handler.spec.ts @@ -12,9 +12,7 @@ function createOptions(overrides: Partial = {}): Messa onEnterModelSelectionMode: () => {}, onEnterProviderSelectionMode: () => {}, onEnterModelDatabaseMode: () => {}, - onEnterConfigWizardMode: () => {}, onEnterSettingsMode: () => {}, - onEnterMcpWizardMode: () => {}, onEnterExplorerMode: () => {}, onEnterIdeSelectionMode: () => {}, onEnterTune: () => {}, diff --git a/source/app/utils/handlers/context-max-handler.ts b/source/app/utils/handlers/context-max-handler.ts index aee9f1190..f0e6f6368 100644 --- a/source/app/utils/handlers/context-max-handler.ts +++ b/source/app/utils/handlers/context-max-handler.ts @@ -9,28 +9,7 @@ import { import {generateKey} from '@/session/key-generator'; import type {MessageSubmissionOptions} from '@/types/index'; import {errorMsg, infoMsg, successMsg} from '@/utils/message-factory'; - -/** - * Parses a context limit value string, supporting k/K suffix. - * e.g. "8192" -> 8192, "128k" -> 128000, "128K" -> 128000 - */ -export function parseContextLimit(value: string): number | null { - const trimmed = value.trim().toLowerCase(); - let multiplier = 1; - let numStr = trimmed; - - if (trimmed.endsWith('k')) { - multiplier = 1000; - numStr = trimmed.slice(0, -1); - } - - const parsed = Number.parseFloat(numStr); - if (Number.isNaN(parsed) || parsed <= 0) { - return null; - } - - return Math.round(parsed * multiplier); -} +import {parseContextLimit} from '@/utils/parse-context-limit'; /** * Handles /context-max command. Returns true if handled. diff --git a/source/cli.tsx b/source/cli.tsx index ec2baffd5..d8c7bcc7f 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -121,18 +121,9 @@ function isValidOutputFormat(value: unknown): value is 'text' | 'json' { } async function main(): Promise { - // Dynamic imports so the fast-path flag handlers above never pay for them. - const [ - {render}, - {default: App}, - {parseContextLimit}, - {setSessionContextLimit}, - ] = await Promise.all([ - import('ink'), - import('@/app'), - import('@/app/utils/handlers/context-max-handler'), - import('@/models/index'), - ]); + // Parse args and dispatch non-TUI branches BEFORE importing ink or @/app. + // Those packages pull ~thousand+ modules; --acp / --plain / auth must stay + // on the lightweight path. Ink + App load only in the final TUI branch. const vscodeMode = args.includes('--vscode'); @@ -178,9 +169,13 @@ async function main(): Promise { } } - // Extract --context-max if specified + // Extract --context-max if specified (framework-free parser — no React/Ink) const contextMaxArgIndex = args.findIndex(arg => arg === '--context-max'); if (contextMaxArgIndex !== -1 && args[contextMaxArgIndex + 1]) { + const [{parseContextLimit}, {setSessionContextLimit}] = await Promise.all([ + import('@/utils/parse-context-limit'), + import('@/models/index'), + ]); const limit = parseContextLimit(args[contextMaxArgIndex + 1]); if (limit !== null) { setSessionContextLimit(limit); @@ -193,6 +188,7 @@ async function main(): Promise { } // Extract --mode if specified. Accept `--mode value` and `--mode=value`. + // `@/app/types` is a tiny const module (no React/Ink) — safe before TUI. const {VALID_MODES} = await import('@/app/types'); type CliMode = (typeof VALID_MODES)[number]; let cliMode: CliMode | undefined; @@ -460,11 +456,16 @@ async function main(): Promise { outputFormat, }); } else { + // Interactive TUI — load Ink + App only now. + const [{render}, {default: App}] = await Promise.all([ + import('ink'), + import('@/app'), + ]); + // Prevent Node's global performance entry buffer from growing without // bound during long Ink sessions. See issue #521. const {installPerfBufferGuard} = await import('@/utils/perf-buffer'); installPerfBufferGuard(); - // Resolve --continue/--resume into a Session BEFORE rendering, so // the app can apply it on first mount (see App's initialSession prop). // A bare --resume (no id) instead opens the picker at startup — no diff --git a/source/commands/lazy-registry.ts b/source/commands/lazy-registry.ts index 57b8260bc..a8383832b 100644 --- a/source/commands/lazy-registry.ts +++ b/source/commands/lazy-registry.ts @@ -142,17 +142,6 @@ export const lazyCommands: LazyCommand[] = [ load: () => import('@/commands/setup-config').then(m => m.setupConfigCommand), }, - { - name: 'setup-providers', - description: 'Launch interactive configuration wizard', - load: () => - import('@/commands/setup-providers').then(m => m.setupProvidersCommand), - }, - { - name: 'setup-mcp', - description: 'Launch interactive MCP server configuration wizard', - load: () => import('@/commands/setup-mcp').then(m => m.setupMcpCommand), - }, { name: 'usage', description: 'Display token usage statistics', @@ -189,7 +178,7 @@ export const lazyCommands: LazyCommand[] = [ { name: 'settings', description: - 'Configure UI settings (theme, shapes, branding, paste threshold)', + 'Configure settings (providers, MCP, theme, shapes, paste threshold). Accepts a tab: /settings providers', load: () => import('@/commands/settings').then(m => m.settingsCommand), }, { diff --git a/source/commands/mcp.tsx b/source/commands/mcp.tsx index 49ec77e1f..95ddc0901 100644 --- a/source/commands/mcp.tsx +++ b/source/commands/mcp.tsx @@ -75,8 +75,8 @@ export function MCP({toolManager}: MCPProps) { - Use /setup-providers to - configure servers interactively. + Use /settings mcp to configure + servers interactively. ) : ( diff --git a/source/commands/settings.ts b/source/commands/settings.ts index fa7a0a4b8..bfc7456f6 100644 --- a/source/commands/settings.ts +++ b/source/commands/settings.ts @@ -2,5 +2,5 @@ import {createStubCommand} from '@/commands/create-stub-command'; export const settingsCommand = createStubCommand( 'settings', - 'Configure UI settings (theme, shapes, branding, paste threshold, notifications)', + 'Configure settings (providers, MCP, theme, shapes, paste threshold). Accepts a tab: /settings providers', ); diff --git a/source/commands/setup-mcp.tsx b/source/commands/setup-mcp.tsx deleted file mode 100644 index 1a65fc32f..000000000 --- a/source/commands/setup-mcp.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import {Text} from 'ink'; -import React from 'react'; -import {Command} from '@/types/index'; - -/** - * `/setup-mcp` is a "special command": registered here for slash-menu - * discovery and `/help` text, but actually dispatched in - * `source/app/utils/app-util.ts` (see `SPECIAL_COMMANDS.SETUP_MCP`), which - * calls `onEnterMcpWizardMode()` to swap the chat UI for the wizard. - * - * The handler is unreachable; it returns an empty Text so the Command type's - * required handler shape is satisfied. - */ -export const setupMcpCommand: Command = { - name: 'setup-mcp', - description: 'Launch interactive MCP server configuration wizard', - handler: () => Promise.resolve(React.createElement(Text, {}, '')), -}; diff --git a/source/commands/setup-providers.tsx b/source/commands/setup-providers.tsx deleted file mode 100644 index 08d9c420c..000000000 --- a/source/commands/setup-providers.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import {Text} from 'ink'; -import React from 'react'; -import {Command} from '@/types/index'; - -/** - * `/setup-providers` is a "special command": registered here for slash-menu - * discovery and `/help` text, but actually dispatched in - * `source/app/utils/app-util.ts` (see `SPECIAL_COMMANDS.SETUP_PROVIDERS`), - * which calls `onEnterConfigWizardMode()` to swap the chat UI for the wizard. - * - * The handler is unreachable; it returns an empty Text so the Command type's - * required handler shape is satisfied. - */ -export const setupProvidersCommand: Command = { - name: 'setup-providers', - description: 'Launch interactive configuration wizard', - handler: () => Promise.resolve(React.createElement(Text, {}, '')), -}; diff --git a/source/components/ui/styled-select-input.tsx b/source/components/ui/styled-select-input.tsx index 4fc59b992..93bc5ad2b 100644 --- a/source/components/ui/styled-select-input.tsx +++ b/source/components/ui/styled-select-input.tsx @@ -1,6 +1,6 @@ import {Box, Text} from 'ink'; import SelectInput from 'ink-select-input'; -import type {ReactElement} from 'react'; +import type {ComponentProps, ReactElement} from 'react'; import {useTheme} from '@/hooks/useTheme'; @@ -25,25 +25,37 @@ interface Item { * and selected-label colour are a hardcoded `blue` that all but disappears * against a dark terminal background. */ -interface StyledSelectInputProps { - items?: Array>; +interface StyledSelectInputProps = Item> { + items?: Array; isFocused?: boolean; initialIndex?: number; limit?: number; - onSelect?: (item: Item) => void; - onHighlight?: (item: Item) => void; - itemComponent?: (props: { - isSelected?: boolean; - label: string; - }) => ReactElement; + onSelect?: (item: I) => void; + onHighlight?: (item: I) => void; + itemComponent?: (props: I & {isSelected?: boolean}) => ReactElement; } -export function StyledSelectInput(props: StyledSelectInputProps) { +export function StyledSelectInput = Item>( + props: StyledSelectInputProps, +) { const {colors} = useTheme(); + const itemComponent = + props.itemComponent ?? + (({isSelected, label}) => ( + + {label} + + )); + + // ink-select-input's runtime spreads the whole item into these; its own + // types just don't say so. return ( )} // Fixed-width indicator: Ink trims a trailing space only on rows that // overflow, which left truncated rows a column left of short ones. indicatorComponent={({isSelected}) => ( @@ -56,15 +68,9 @@ export function StyledSelectInput(props: StyledSelectInputProps) { // Truncate rather than wrap: a long label (a path, a URL) reflowed with // no hanging indent and the list read as a jumble on narrow terminals. itemComponent={ - props.itemComponent ?? - (({isSelected, label}) => ( - - {label} - - )) + itemComponent as unknown as ComponentProps< + typeof SelectInput + >['itemComponent'] } /> ); diff --git a/source/constants.ts b/source/constants.ts index 8524dd6ee..ece26b47d 100644 --- a/source/constants.ts +++ b/source/constants.ts @@ -77,6 +77,8 @@ export const EMPTY_CONTENT_MARKER = '[file is empty]'; export const PATH_LENGTH_NARROW_TERMINAL = 30; export const PATH_LENGTH_NORMAL_TERMINAL = 60; export const TABLE_COLUMN_MIN_WIDTH = 10; +export const WIZARD_ROW_CHROME_CHARS = 10; +export const MIN_PATH_BUDGET_CHARS = 10; // === TOKEN THRESHOLDS (percentages - useChatHandler) === export const TOKEN_THRESHOLD_WARNING_PERCENT = 80; diff --git a/source/hooks/useAppHandlers.spec.tsx b/source/hooks/useAppHandlers.spec.tsx index 9102578f9..0b93a73c9 100644 --- a/source/hooks/useAppHandlers.spec.tsx +++ b/source/hooks/useAppHandlers.spec.tsx @@ -68,9 +68,7 @@ function makeProps(overrides: ProbeOverrides) { const setLiveComponent = spy<[React.ReactNode]>(); const enterModelSelectionMode = spy<[]>(); const enterModelDatabaseMode = spy<[]>(); - const enterConfigWizardMode = spy<[]>(); const enterSettingsMode = spy<[]>(); - const enterMcpWizardMode = spy<[]>(); const enterExplorerMode = spy<[]>(); const enterIdeSelectionMode = spy<[]>(); const enterTune = spy<[]>(); @@ -114,9 +112,7 @@ function makeProps(overrides: ProbeOverrides) { getMessageTokens: () => 0, enterModelSelectionMode, enterModelDatabaseMode, - enterConfigWizardMode, enterSettingsMode, - enterMcpWizardMode, enterExplorerMode, enterIdeSelectionMode, enterTune, diff --git a/source/hooks/useAppHandlers.tsx b/source/hooks/useAppHandlers.tsx index ee18724c7..1d58d3769 100644 --- a/source/hooks/useAppHandlers.tsx +++ b/source/hooks/useAppHandlers.tsx @@ -1,5 +1,6 @@ import {randomBytes} from 'node:crypto'; import React from 'react'; +import type {SettingsTabId} from '@/app/components/settings-constants'; import { createClearMessagesHandler, handleMessageSubmission, @@ -105,9 +106,7 @@ interface UseAppHandlersProps { // Mode handlers enterModelSelectionMode: () => void; enterModelDatabaseMode: () => void; - enterConfigWizardMode: () => void; - enterSettingsMode: () => void; - enterMcpWizardMode: () => void; + enterSettingsMode: (tab?: SettingsTabId) => void; enterExplorerMode: () => void; enterIdeSelectionMode: () => void; enterTune: () => void; @@ -666,9 +665,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { commandArgs, onEnterModelSelectionMode: props.enterModelSelectionMode, onEnterModelDatabaseMode: props.enterModelDatabaseMode, - onEnterConfigWizardMode: props.enterConfigWizardMode, onEnterSettingsMode: props.enterSettingsMode, - onEnterMcpWizardMode: props.enterMcpWizardMode, onEnterExplorerMode: props.enterExplorerMode, onEnterIdeSelectionMode: props.enterIdeSelectionMode, onEnterTune: props.enterTune, @@ -707,9 +704,7 @@ export function useAppHandlers(props: UseAppHandlersProps): AppHandlers { props.customCommandExecutor, props.enterModelSelectionMode, props.enterModelDatabaseMode, - props.enterConfigWizardMode, props.enterSettingsMode, - props.enterMcpWizardMode, props.enterExplorerMode, props.enterIdeSelectionMode, props.enterTune, diff --git a/source/hooks/useAppState.tsx b/source/hooks/useAppState.tsx index e9e9c6d94..28030d511 100644 --- a/source/hooks/useAppState.tsx +++ b/source/hooks/useAppState.tsx @@ -1,4 +1,5 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import type {SettingsTabId} from '@/app/components/settings-constants'; import type {TitleShape} from '@/components/ui/styled-title'; import {getAppConfig} from '@/config/index'; import {loadPreferences} from '@/config/preferences'; @@ -34,7 +35,6 @@ export type ActiveMode = | 'model' | 'modelDatabase' | 'configWizard' - | 'mcpWizard' | 'explorer' | 'ideSelection' | 'checkpointLoad' @@ -97,6 +97,9 @@ export function useAppState( const [isConversationComplete, setIsConversationComplete] = useState(false); const [isSettingsMode, setIsSettingsMode] = useState(false); + const [settingsActiveTab, setSettingsActiveTab] = useState< + SettingsTabId | undefined + >(undefined); // Plan review state (post-plan-generation action bar) const [planReviewState, setPlanReviewState] = useState<{ @@ -341,6 +344,7 @@ export function useAppState( isCancelling, isConversationComplete, isSettingsMode, + settingsActiveTab, planReviewState, planTurnCompleted, pendingPlanProceed, @@ -407,6 +411,7 @@ export function useAppState( setIsCancelling, setIsConversationComplete, setIsSettingsMode, + setSettingsActiveTab, setPlanReviewState, setPlanTurnCompleted, setPendingPlanProceed, diff --git a/source/hooks/useModeHandlers.spec.tsx b/source/hooks/useModeHandlers.spec.tsx index 1309ecc76..812c09b2a 100644 --- a/source/hooks/useModeHandlers.spec.tsx +++ b/source/hooks/useModeHandlers.spec.tsx @@ -13,6 +13,7 @@ process.env.NANOCODER_CONFIG_DIR = mkdtempSync( const {resetPreferencesCache} = await import('@/config/preferences'); resetPreferencesCache(); +import type {SettingsTabId} from '@/app/components/settings-constants'; import type {ActiveMode} from '@/hooks/useAppState'; import type {LLMClient, Message} from '@/types/core'; import type {AIProviderConfig, TuneConfig} from '@/types/config'; @@ -72,6 +73,7 @@ function setup(probe: ProbeProps = {}) { const setMessages = spy<[Message[]]>(); const setActiveMode = spy<[ActiveMode]>(); const setIsSettingsMode = spy<[boolean]>(); + const setSettingsActiveTab = spy<[SettingsTabId | undefined]>(); const addToChatQueue = spy<[React.ReactNode]>(); const reinitializeMCPServers = spy<[unknown]>(); const setTune = spy<[TuneConfig]>(); @@ -89,6 +91,7 @@ function setup(probe: ProbeProps = {}) { getMessageTokens: () => 0, setActiveMode, setIsSettingsMode, + setSettingsActiveTab, addToChatQueue, reinitializeMCPServers: async () => { reinitializeMCPServers(undefined); @@ -105,6 +108,7 @@ function setup(probe: ProbeProps = {}) { setMessages, setActiveMode, setIsSettingsMode, + setSettingsActiveTab, addToChatQueue, setTune, }; @@ -117,15 +121,12 @@ test('returns the expected handler surface', t => { t.is(typeof handlers.exitMode, 'function'); t.is(typeof handlers.enterModelSelectionMode, 'function'); t.is(typeof handlers.enterModelDatabaseMode, 'function'); - t.is(typeof handlers.enterConfigWizardMode, 'function'); - t.is(typeof handlers.enterMcpWizardMode, 'function'); t.is(typeof handlers.enterExplorerMode, 'function'); t.is(typeof handlers.enterIdeSelectionMode, 'function'); t.is(typeof handlers.enterSettingsMode, 'function'); t.is(typeof handlers.enterTune, 'function'); t.is(typeof handlers.handleModelSelect, 'function'); t.is(typeof handlers.handleConfigWizardComplete, 'function'); - t.is(typeof handlers.handleMcpWizardComplete, 'function'); t.is(typeof handlers.handleTuneSelect, 'function'); }); @@ -151,8 +152,6 @@ test('each enter*Mode helper sets the matching active mode', t => { handlers.enterModelSelectionMode(); handlers.enterModelDatabaseMode(); - handlers.enterConfigWizardMode(); - handlers.enterMcpWizardMode(); handlers.enterExplorerMode(); handlers.enterIdeSelectionMode(); handlers.enterTune(); @@ -160,8 +159,6 @@ test('each enter*Mode helper sets the matching active mode', t => { t.deepEqual(setActiveMode.calls, [ ['model'], ['modelDatabase'], - ['configWizard'], - ['mcpWizard'], ['explorer'], ['ideSelection'], ['tune'], @@ -183,12 +180,11 @@ test('cancel handlers all return active mode to null', t => { handlers.handleModelSelectionCancel(); handlers.handleModelDatabaseCancel(); handlers.handleConfigWizardCancel(); - handlers.handleMcpWizardCancel(); handlers.handleExplorerCancel(); handlers.handleIdeSelectionCancel(); handlers.handleTuneCancel(); - t.is(setActiveMode.calls.length, 7); + t.is(setActiveMode.calls.length, 6); for (const args of setActiveMode.calls) { t.deepEqual(args, [null]); } @@ -289,11 +285,33 @@ test('handleConfigWizardComplete with no path only exits mode', async t => { t.is(addToChatQueue.calls.length, 0); }); -test('handleMcpWizardComplete with no path only exits mode', async t => { - const {handlers, setActiveMode, addToChatQueue} = setup(); +test('reloadProviders leaves the conversation and settings panel intact', async t => { + const { + handlers, + setMessages, + setActiveMode, + setIsSettingsMode, + addToChatQueue, + } = setup({client: createMockClient()}); + + await handlers.reloadProviders(); + + // This is why reloadProviders exists rather than reusing + // handleConfigWizardComplete: editing a provider mid-session must not wipe + // the model's history or tear down the settings panel around the user. + // These hold whether the rebuild succeeds or fails - handleConfigWizardComplete + // would have called exitMode() before it ever reached the client swap. + t.is(setMessages.calls.length, 0, 'conversation history is left alone'); + t.is(setActiveMode.calls.length, 0, 'does not exit the current mode'); + t.is(setIsSettingsMode.calls.length, 0, 'leaves the settings panel open'); + t.is(addToChatQueue.calls.length, 1, 'reports the outcome exactly once'); +}); - await handlers.handleMcpWizardComplete(); +test('enterSettingsMode forwards the requested tab', t => { + const {handlers, setSettingsActiveTab} = setup(); - t.deepEqual(setActiveMode.calls, [[null]]); - t.is(addToChatQueue.calls.length, 0); + handlers.enterSettingsMode('mcp'); + handlers.enterSettingsMode(); + + t.deepEqual(setSettingsActiveTab.calls, [['mcp'], [undefined]]); }); diff --git a/source/hooks/useModeHandlers.tsx b/source/hooks/useModeHandlers.tsx index 72769c1d9..09c97ed05 100644 --- a/source/hooks/useModeHandlers.tsx +++ b/source/hooks/useModeHandlers.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {SettingsTabId} from '@/app/components/settings-constants'; import {createLLMClient} from '@/client-factory'; import { ErrorMessage, @@ -33,6 +34,7 @@ interface UseModeHandlersProps { getMessageTokens: (message: Message) => number; setActiveMode: (mode: ActiveMode) => void; setIsSettingsMode: (mode: boolean) => void; + setSettingsActiveTab: (tab: SettingsTabId | undefined) => void; addToChatQueue: (component: React.ReactNode) => void; reinitializeMCPServers: ( toolManager: import('@/tools/tool-manager').ToolManager, @@ -53,6 +55,7 @@ export function useModeHandlers({ getMessageTokens, setActiveMode, setIsSettingsMode, + setSettingsActiveTab, addToChatQueue, reinitializeMCPServers, setTune, @@ -307,19 +310,43 @@ export function useModeHandlers({ } }; - // Handle MCP wizard complete - reinitializes MCP servers - const handleMcpWizardComplete = async (configPath?: string) => { - exitMode(); - if (configPath) { + /** + * Pick up provider config written to disk and rebuild the client for the + * current provider/model, leaving messages alone. Split out of the wizard + * handler so the settings panel can reuse it without the mode-exit side + * effects (clearing conversation, resetting to default provider/model). + */ + const reloadProviders = async () => { + reloadAppConfig(); + + try { + const {client: newClient, actualProvider} = await createLLMClient( + currentProvider, + currentModel, + ); + + setClient(newClient); + setCurrentProvider(actualProvider); + setCurrentProviderConfig(newClient.getProviderConfig()); + + const newModel = newClient.getCurrentModel(); + setCurrentModel(newModel); + addToChatQueue( , + ); + } catch (error) { + addToChatQueue( + , ); - - await reloadMcpServers(); } }; @@ -373,20 +400,20 @@ export function useModeHandlers({ // Convenience enter helpers enterModelSelectionMode: () => enterMode('model'), enterModelDatabaseMode: () => enterMode('modelDatabase'), - enterConfigWizardMode: () => enterMode('configWizard'), - enterMcpWizardMode: () => enterMode('mcpWizard'), enterExplorerMode: () => enterMode('explorer'), enterIdeSelectionMode: () => enterMode('ideSelection'), - enterSettingsMode: () => setIsSettingsMode(true), + enterSettingsMode: (tab?: SettingsTabId) => { + setSettingsActiveTab(tab); + setIsSettingsMode(true); + }, // Cancel/complete handlers handleModelSelect, handleModelSelectionCancel: exitMode, handleModelDatabaseCancel: exitMode, handleConfigWizardComplete, handleConfigWizardCancel: exitMode, - handleMcpWizardComplete, - handleMcpWizardCancel: exitMode, reloadMcpServers, + reloadProviders, handleSettingsCancel: () => setIsSettingsMode(false), handleExplorerCancel: exitMode, handleIdeSelectionCancel: exitMode, diff --git a/source/types/app.ts b/source/types/app.ts index 044f769d1..20a81c687 100644 --- a/source/types/app.ts +++ b/source/types/app.ts @@ -1,4 +1,5 @@ import React from 'react'; +import type {SettingsTabId} from '@/app/components/settings-constants'; import {CustomCommandExecutor} from '@/custom-commands/executor'; import {CustomCommandLoader} from '@/custom-commands/loader'; import type {Session} from '@/session/session-manager'; @@ -24,9 +25,7 @@ export interface MessageSubmissionOptions { commandArgs?: string[]; onEnterModelSelectionMode: () => void; onEnterModelDatabaseMode: () => void; - onEnterConfigWizardMode: () => void; - onEnterSettingsMode: () => void; - onEnterMcpWizardMode: () => void; + onEnterSettingsMode: (tab?: SettingsTabId) => void; onEnterExplorerMode: () => void; onEnterIdeSelectionMode: () => void; onEnterTune: () => void; diff --git a/source/utils/parse-context-limit.spec.ts b/source/utils/parse-context-limit.spec.ts new file mode 100644 index 000000000..65da78b6f --- /dev/null +++ b/source/utils/parse-context-limit.spec.ts @@ -0,0 +1,46 @@ +import test from 'ava'; +import {parseContextLimit} from './parse-context-limit'; + +test('parseContextLimit - plain number', t => { + t.is(parseContextLimit('8192'), 8192); +}); + +test('parseContextLimit - k suffix lowercase', t => { + t.is(parseContextLimit('128k'), 128000); +}); + +test('parseContextLimit - K suffix uppercase', t => { + t.is(parseContextLimit('128K'), 128000); +}); + +test('parseContextLimit - fractional k value', t => { + t.is(parseContextLimit('4.5k'), 4500); +}); + +test('parseContextLimit - zero returns null', t => { + t.is(parseContextLimit('0'), null); +}); + +test('parseContextLimit - negative returns null', t => { + t.is(parseContextLimit('-5'), null); +}); + +test('parseContextLimit - non-numeric returns null', t => { + t.is(parseContextLimit('abc'), null); +}); + +test('parseContextLimit - just k returns null', t => { + t.is(parseContextLimit('k'), null); +}); + +test('parseContextLimit - whitespace is trimmed', t => { + t.is(parseContextLimit(' 8192 '), 8192); +}); + +test('parseContextLimit - large value with k suffix', t => { + t.is(parseContextLimit('256k'), 256000); +}); + +test('parseContextLimit - decimal without k suffix', t => { + t.is(parseContextLimit('1024.5'), 1025); +}); diff --git a/source/utils/parse-context-limit.ts b/source/utils/parse-context-limit.ts new file mode 100644 index 000000000..5c1b23c3e --- /dev/null +++ b/source/utils/parse-context-limit.ts @@ -0,0 +1,24 @@ +/** + * Parses a context limit value string, supporting k/K suffix. + * e.g. "8192" -> 8192, "128k" -> 128000, "128K" -> 128000 + * + * Framework-free so the CLI can apply `--context-max` without loading + * React/Ink (needed for the ACP / plain / auth fast paths). + */ +export function parseContextLimit(value: string): number | null { + const trimmed = value.trim().toLowerCase(); + let multiplier = 1; + let numStr = trimmed; + + if (trimmed.endsWith('k')) { + multiplier = 1000; + numStr = trimmed.slice(0, -1); + } + + const parsed = Number.parseFloat(numStr); + if (Number.isNaN(parsed) || parsed <= 0) { + return null; + } + + return Math.round(parsed * multiplier); +} diff --git a/source/utils/path.spec.ts b/source/utils/path.spec.ts new file mode 100644 index 000000000..9551d0b73 --- /dev/null +++ b/source/utils/path.spec.ts @@ -0,0 +1,44 @@ +import test from 'ava'; +import {resolve, sep} from 'node:path'; +import {homeRelative, truncateMiddle} from './path.js'; + +const HOME = resolve('/Users/will'); + +test('homeRelative shortens a path inside home to a tilde form', t => { + const input = resolve('/Users/will/projects/app'); + t.is(homeRelative(input, HOME), `~${sep}projects${sep}app`); +}); + +test('homeRelative returns a bare tilde for the home directory itself', t => { + t.is(homeRelative(resolve('/Users/will'), HOME), '~'); +}); + +test('homeRelative does not mangle a sibling directory that shares a prefix', t => { + const input = resolve('/Users/willy/projects/app'); + t.is(homeRelative(input, HOME), input); +}); + +test('homeRelative leaves unrelated paths untouched', t => { + const input = resolve('/etc/config'); + t.is(homeRelative(input, HOME), input); +}); + +test('homeRelative leaves paths untouched when home is the filesystem root', t => { + const root = resolve('/'); + const child = resolve('/foo'); + t.is(homeRelative(child, root), child); + t.is(homeRelative(root, root), root); +}); + +test('truncateMiddle leaves short strings untouched', t => { + t.is(truncateMiddle('/short/path', 40), '/short/path'); +}); + +test('truncateMiddle keeps both the root and the leaf segment', t => { + const long = '/Users/will/projects/some-really-long-monorepo-name/src/index.ts'; + const result = truncateMiddle(long, 30); + t.is(result.length, 30); + t.true(result.startsWith('/Users/wi')); + t.true(result.endsWith('index.ts')); + t.true(result.includes('...')); +}); diff --git a/source/utils/path.ts b/source/utils/path.ts new file mode 100644 index 000000000..939e3cc8b --- /dev/null +++ b/source/utils/path.ts @@ -0,0 +1,38 @@ +import {homedir} from 'node:os'; +import {resolve, sep} from 'node:path'; + +export function homeRelative(path: string, home: string = homedir()): string { + const resolved = resolve(path); + const resolvedHome = resolve(home); + + if (resolvedHome === sep || /^[A-Za-z]:\\$/.test(resolvedHome)) { + return resolved; + } + + if (resolved === resolvedHome) { + return '~'; + } + + if (resolved.startsWith(resolvedHome + sep)) { + return `~${resolved.slice(resolvedHome.length)}`; + } + + return resolved; +} + +// Keeps root and leaf visible; truncatePath (useTerminalWidth.tsx) only keeps the tail. +export function truncateMiddle(str: string, maxLength: number): string { + if (str.length <= maxLength) { + return str; + } + + const ellipsis = '...'; + if (maxLength <= ellipsis.length) { + return str.slice(0, Math.max(0, maxLength)); + } + + const keepStart = Math.ceil((maxLength - ellipsis.length) / 2); + const keepEnd = Math.floor((maxLength - ellipsis.length) / 2); + + return str.slice(0, keepStart) + ellipsis + str.slice(str.length - keepEnd); +} diff --git a/source/vscode/chat-panel-harness.ts b/source/vscode/chat-panel-harness.ts index d02716c2d..47972c96b 100644 --- a/source/vscode/chat-panel-harness.ts +++ b/source/vscode/chat-panel-harness.ts @@ -19,6 +19,8 @@ const PANEL_SOURCE = readFileSync(mediaUrl('chat-panel.js'), 'utf8'); const SHELL_IDS = [ 'add-image-btn', + 'add-menu-btn', + 'add-menu-dropdown', 'attach-btn', 'chat-input', 'chat-view', @@ -32,6 +34,8 @@ const SHELL_IDS = [ 'image-modal', 'image-preview-container', 'image-upload', + 'menu-attach-file', + 'menu-upload-image', 'mention-dropdown', 'messages-container', 'modal-image', @@ -77,6 +81,9 @@ function queryAll(root: StubElement, selector: string): StubElement[] { export function createElement(tagName: string): StubElement { const classes = new Set(); const attributes = new Map(); + // Registered handlers, so a test can drive a real listener rather than only + // the `onclick` properties the panel assigns directly. + const listeners = new Map void)[]>(); let html = ''; let text = ''; @@ -122,10 +129,21 @@ export function createElement(tagName: string): StubElement { closest: () => null, setAttribute: (name: string, value: string) => attributes.set(name, value), getAttribute: (name: string) => attributes.get(name) ?? null, - addEventListener: () => {}, - removeEventListener: () => {}, + addEventListener: (type: string, fn: (event: StubElement) => void) => { + const registered = listeners.get(type); + if (registered) registered.push(fn); + else listeners.set(type, [fn]); + }, + removeEventListener: (type: string, fn: (event: StubElement) => void) => { + listeners.set( + type, + (listeners.get(type) ?? []).filter(candidate => candidate !== fn), + ); + }, focus: () => {}, - click: () => {}, + click: (event: StubElement = {}) => { + for (const fn of listeners.get('click') ?? []) fn(event); + }, scrollTop: 0, scrollHeight: 0, }; @@ -202,6 +220,8 @@ export function createPanel(options: {marked?: boolean} = {}) { const messageListeners: ((event: {data: unknown}) => void)[] = []; // Everything the panel posts back to the extension host. const sent: unknown[] = []; + // Everything a copy button has put on the clipboard, newest last. + const copied: string[] = []; const sandbox: Record = { document: { body, @@ -221,7 +241,14 @@ export function createPanel(options: {marked?: boolean} = {}) { if (type === 'message') messageListeners.push(fn); }, }, - navigator: {userAgent: '', clipboard: {writeText: async () => {}}}, + navigator: { + userAgent: '', + clipboard: { + writeText: async (value: string) => { + copied.push(value); + }, + }, + }, acquireVsCodeApi: () => ({ postMessage: (message: unknown) => { sent.push(message); @@ -240,6 +267,7 @@ export function createPanel(options: {marked?: boolean} = {}) { sandbox.marked = {parse: (value: string) => `${value}`}; } + sandbox.globalThis = sandbox; createContext(sandbox); runInContext(MENTION_UTILS_SOURCE, sandbox); runInContext(PANEL_SOURCE, sandbox); @@ -249,6 +277,7 @@ export function createPanel(options: {marked?: boolean} = {}) { return { container, sent, + copied, post(message: unknown) { for (const listener of messageListeners) listener({data: message}); }, @@ -298,5 +327,15 @@ export function createPanel(options: {marked?: boolean} = {}) { child.className.includes('thought-aggregator'), ); }, + /** The tool-call cards, in the order they were inserted. */ + aggregators(): StubElement[] { + return container.children.filter((child: StubElement) => + child.className.includes('tool-aggregator'), + ); + }, + /** The copy/timestamp footers currently in the transcript. */ + footers(): StubElement[] { + return container.querySelectorAll('.message-footer'); + }, }; } diff --git a/source/vscode/chat-panel-tool-aggregation.spec.ts b/source/vscode/chat-panel-tool-aggregation.spec.ts new file mode 100644 index 000000000..78344ac23 --- /dev/null +++ b/source/vscode/chat-panel-tool-aggregation.spec.ts @@ -0,0 +1,231 @@ +import test from 'ava'; +import {createPanel, type StubElement} from '@/vscode/chat-panel-harness'; + +console.log('\nchat-panel-tool-aggregation.spec.ts'); + +// ============================================================================ +// Helpers +// ============================================================================ + +/** A non-edit call that starts running and stays unfinished. */ +const startTool = (panel: any, toolCallId: string, path = 'src/a.ts') => + panel.update({ + sessionUpdate: 'tool_call', + toolCallId, + title: `read_file: ${path}`, + kind: 'read', + status: 'in_progress', + }); + +const finishTool = (panel: any, toolCallId: string) => + panel.update({ + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'completed', + }); + +/** A call that runs to completion, so the phase it belongs to is idle. */ +const runTool = (panel: any, toolCallId: string, path?: string) => { + startTool(panel, toolCallId, path); + finishTool(panel, toolCallId); +}; + +const runEdit = (panel: any, toolCallId = 'edit-1') => { + panel.update({ + sessionUpdate: 'tool_call', + toolCallId, + title: 'write_file: src/b.ts', + kind: 'edit', + status: 'in_progress', + }); + finishTool(panel, toolCallId); +}; + +const sendPlan = (panel: any) => + panel.update({ + sessionUpdate: 'plan', + entries: [{content: 'Do the thing', status: 'in_progress'}], + }); + +/** The tools listed in one aggregated card, top to bottom. */ +const rowsOf = (card: StubElement): string[] => + card.querySelectorAll('.tool-label').map((row: StubElement) => row.textContent); + +const isOpen = (card: StubElement) => card.children[1].style.display !== 'none'; + +const collapse = (card: StubElement) => card.children[0].onclick(); + +/** What the transcript holds, in order, one word per element. */ +const transcript = (panel: any): string[] => + panel.container.children.map((child: StubElement) => { + if (child.className.includes('tool-aggregator')) return 'tools'; + if (child.className.includes('thought-aggregator')) return 'thoughts'; + if (child.className.includes('tool-card')) return 'edit'; + if (String(child.id).startsWith('plan-card')) return 'plan'; + return 'text'; + }); + +// ============================================================================ +// A finished tool phase closes when anything else is inserted (#856) +// +// The aggregated card used to be reset only at the end of a turn, so a tool +// arriving after an interruption was appended to the card ABOVE whatever the +// agent had just inserted, putting the transcript out of order. +// ============================================================================ + +test('a thought between two tool phases starts a fresh card', t => { + const panel = createPanel(); + runTool(panel, 'r1', 'first.ts'); + panel.thought('considering'); + runTool(panel, 'r2', 'second.ts'); + + const cards = panel.aggregators(); + t.is(cards.length, 2); + t.deepEqual(rowsOf(cards[0]), ['Reading first.ts']); + t.deepEqual(rowsOf(cards[1]), ['Reading second.ts']); + t.deepEqual(transcript(panel), ['tools', 'thoughts', 'tools']); +}); + +test('reply text between two tool phases starts a fresh card', t => { + const panel = createPanel(); + runTool(panel, 'r1', 'first.ts'); + panel.text('Here is what I found.'); + runTool(panel, 'r2', 'second.ts'); + + const cards = panel.aggregators(); + t.is(cards.length, 2); + t.deepEqual(rowsOf(cards[1]), ['Reading second.ts']); + t.deepEqual(transcript(panel), ['tools', 'text', 'tools']); +}); + +test('an edit card between two tool phases starts a fresh card', t => { + const panel = createPanel(); + runTool(panel, 'r1', 'first.ts'); + runEdit(panel); + runTool(panel, 'r2', 'second.ts'); + + const cards = panel.aggregators(); + t.is(cards.length, 2); + // The read after the edit must not fall back into the card above it. + t.deepEqual(rowsOf(cards[0]), ['Reading first.ts']); + t.deepEqual(rowsOf(cards[1]), ['Reading second.ts']); + t.deepEqual(transcript(panel), ['tools', 'edit', 'tools']); +}); + +test('a plan card between two tool phases starts a fresh card', t => { + const panel = createPanel(); + runTool(panel, 'r1', 'first.ts'); + sendPlan(panel); + runTool(panel, 'r2', 'second.ts'); + + const cards = panel.aggregators(); + t.is(cards.length, 2); + t.deepEqual(rowsOf(cards[1]), ['Reading second.ts']); + t.deepEqual(transcript(panel), ['tools', 'plan', 'tools']); +}); + +test('an uninterrupted run of tools stays in one card', t => { + const panel = createPanel(); + runTool(panel, 'r1', 'first.ts'); + runTool(panel, 'r2', 'second.ts'); + + const cards = panel.aggregators(); + t.is(cards.length, 1); + t.deepEqual(rowsOf(cards[0]), ['Reading first.ts', 'Reading second.ts']); +}); + +test('a finished phase collapses once the agent moves on', t => { + const panel = createPanel(); + runTool(panel, 'r1'); + t.true(isOpen(panel.aggregators()[0]), 'stays open while it is the phase'); + + panel.text('Done looking.'); + t.false(isOpen(panel.aggregators()[0])); +}); + +// ============================================================================ +// An unfinished tool keeps its card, so it cannot be duplicated +// +// Closing a card while one of its tools was still running orphaned that tool's +// row: the next update for it built a second row in a new card, leaving the +// first spinning forever. +// ============================================================================ + +test('a running tool holds its card open across an interruption', t => { + const panel = createPanel(); + startTool(panel, 'r1'); + panel.thought('while that runs'); + + t.is(panel.aggregators().length, 1); + t.true(isOpen(panel.aggregators()[0])); +}); + +test('a running tool interrupted mid-flight is not duplicated', t => { + const panel = createPanel(); + startTool(panel, 'r1'); + panel.thought('while that runs'); + finishTool(panel, 'r1'); + + const cards = panel.aggregators(); + t.is(cards.length, 1, 'the late completion reuses the original card'); + t.deepEqual(rowsOf(cards[0]), ['Reading src/a.ts']); + t.is(panel.container.querySelectorAll('.tool-status').length, 1); +}); + +test('a queued tool also holds its card open', t => { + const panel = createPanel(); + // 'pending' is the queued announcement, before the call starts running. + panel.update({ + sessionUpdate: 'tool_call', + toolCallId: 'r1', + title: 'read_file: src/a.ts', + kind: 'read', + status: 'pending', + }); + panel.text('Queued that up.'); + finishTool(panel, 'r1'); + + const cards = panel.aggregators(); + t.is(cards.length, 1); + t.deepEqual(rowsOf(cards[0]), ['Reading src/a.ts']); +}); + +test('a phase closes once its last tool finishes', t => { + const panel = createPanel(); + startTool(panel, 'r1', 'first.ts'); + finishTool(panel, 'r1'); + panel.text('All done.'); + runTool(panel, 'r2', 'second.ts'); + + t.is(panel.aggregators().length, 2); +}); + +// ============================================================================ +// A manual collapse survives the card being closed +// +// close() collapsed by calling toggle(false), but toggle ignored its argument +// and simply flipped, so closing a card the user had already collapsed +// re-expanded it. +// ============================================================================ + +test('closing does not re-expand a card the user collapsed', t => { + const panel = createPanel(); + runTool(panel, 'r1'); + + collapse(panel.aggregators()[0]); + t.false(isOpen(panel.aggregators()[0])); + + panel.text('Moving on.'); + t.false(isOpen(panel.aggregators()[0]), 'stays as the user left it'); +}); + +test('a collapsed card can still be reopened by hand', t => { + const panel = createPanel(); + runTool(panel, 'r1'); + + collapse(panel.aggregators()[0]); + panel.text('Moving on.'); + collapse(panel.aggregators()[0]); + + t.true(isOpen(panel.aggregators()[0])); +}); diff --git a/source/vscode/chat-panel-turn-footer.spec.ts b/source/vscode/chat-panel-turn-footer.spec.ts new file mode 100644 index 000000000..3205a8930 --- /dev/null +++ b/source/vscode/chat-panel-turn-footer.spec.ts @@ -0,0 +1,143 @@ +import test from 'ava'; +import {createPanel, type StubElement} from '@/vscode/chat-panel-harness'; + +console.log('\nchat-panel-turn-footer.spec.ts'); + +// ============================================================================ +// Helpers +// ============================================================================ + +/** Agent footers only - a user message carries its own, aligned the other way. */ +const agentFooters = (panel: any): StubElement[] => + panel.footers().filter((footer: StubElement) => + footer.classList.contains('self-start'), + ); + +const copyButton = (footer: StubElement): StubElement => + footer.children.find((child: StubElement) => child.title === 'Copy'); + +const timestamp = (footer: StubElement): string => + footer.children.find((child: StubElement) => child.title !== 'Copy') + .textContent; + +const runTool = (panel: any, toolCallId: string) => { + panel.update({ + sessionUpdate: 'tool_call', + toolCallId, + title: 'read_file: src/a.ts', + kind: 'read', + status: 'in_progress', + }); + panel.update({ + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'completed', + }); +}; + +// ============================================================================ +// One footer per response, not one per text segment +// +// A tool card splits a response into several text blocks. Each block used to +// grow its own copy button and timestamp, so a single answer ended up with a +// row of them down the transcript. +// ============================================================================ + +test('a response split by a tool card keeps one footer', t => { + const panel = createPanel(); + panel.userMessage('go'); + panel.text('Looking into it.'); + runTool(panel, 'r1'); + panel.text('Here is the answer.'); + + t.is(agentFooters(panel).length, 1); +}); + +test('the footer moves to the newest text block', t => { + const panel = createPanel(); + panel.userMessage('go'); + panel.text('Looking into it.'); + runTool(panel, 'r1'); + panel.text('Here is the answer.'); + + const [footer] = agentFooters(panel); + const blocks = panel.container.children; + t.is( + blocks.indexOf(footer.parentElement), + blocks.length - 1, + 'the footer sits under the last block, not the first', + ); +}); + +test('each response gets its own footer', t => { + const panel = createPanel(); + panel.userMessage('first'); + panel.text('Response A'); + panel.finish(); + panel.userMessage('second'); + panel.text('Response B'); + + t.is(agentFooters(panel).length, 2); +}); + +// ============================================================================ +// A footer copies its own response +// +// The copy closure read the turn wrapper it happened to be sitting in, so once +// a newer response arrived, an older footer handed back the newer text. +// ============================================================================ + +test('an older response copies its own text, not a newer one', t => { + const panel = createPanel(); + panel.userMessage('first'); + panel.text('Response A'); + panel.finish(); + panel.userMessage('second'); + panel.text('Response B'); + + const [first, second] = agentFooters(panel); + copyButton(first).click(); + t.deepEqual(panel.copied, ['Response A']); + + copyButton(second).click(); + t.deepEqual(panel.copied, ['Response A', 'Response B']); +}); + +test('copying a split response yields every segment', t => { + const panel = createPanel(); + panel.userMessage('go'); + panel.text('Looking into it.'); + runTool(panel, 'r1'); + panel.text('Here is the answer.'); + + copyButton(agentFooters(panel)[0]).click(); + t.deepEqual(panel.copied, ['Looking into it.\n\nHere is the answer.']); +}); + +test('the footer keeps up with text still streaming in', t => { + const panel = createPanel(); + panel.userMessage('go'); + panel.text('Half a '); + panel.text('sentence.'); + + copyButton(agentFooters(panel)[0]).click(); + t.deepEqual(panel.copied, ['Half a sentence.']); +}); + +test('clearing the session drops the footer', t => { + const panel = createPanel(); + panel.userMessage('go'); + panel.text('Response A'); + const before = timestamp(agentFooters(panel)[0]); + + panel.post({type: 'clear'}); + panel.advance(90 * 60 * 1000); + panel.text('Response B'); + + const footers = agentFooters(panel); + t.is(footers.length, 1, 'the cleared turn does not leave its footer behind'); + // A reused footer would still be stamped with the cleared session's time. + t.not(timestamp(footers[0]), before); + copyButton(footers[0]).click(); + t.deepEqual(panel.copied, ['Response B']); +}); diff --git a/source/wizards/mcp-wizard.tsx b/source/wizards/mcp-wizard.tsx index 07f737034..8bf66e735 100644 --- a/source/wizards/mcp-wizard.tsx +++ b/source/wizards/mcp-wizard.tsx @@ -9,6 +9,8 @@ interface McpWizardProps { projectDir: string; onComplete: (configPath: string) => void; onCancel?: () => void; + /** Open straight into the edit/delete choice for this server. */ + initialEditName?: string; } type McpServers = Record; @@ -42,7 +44,12 @@ function McpSummaryItems({items}: {items: McpServers}) { ); } -export function McpWizard({projectDir, onComplete, onCancel}: McpWizardProps) { +export function McpWizard({ + projectDir, + onComplete, + onCancel, + initialEditName, +}: McpWizardProps) { return ( title="MCP Server Configuration" @@ -65,6 +72,7 @@ export function McpWizard({projectDir, onComplete, onCancel}: McpWizardProps) { onBack={onBack} onDelete={onDelete} configExists={configExists} + initialEditName={initialEditName} /> )} renderSummaryItems={items => } diff --git a/source/wizards/provider-wizard.tsx b/source/wizards/provider-wizard.tsx index 9618c3260..7f75f3a24 100644 --- a/source/wizards/provider-wizard.tsx +++ b/source/wizards/provider-wizard.tsx @@ -15,6 +15,8 @@ interface ProviderWizardProps { projectDir: string; onComplete: (configPath: string) => void; onCancel?: () => void; + /** Open straight into the edit/delete choice for this provider. */ + initialEditName?: string; } function parseProviderConfig(raw: unknown): ProviderWizardState { @@ -127,12 +129,14 @@ function ProviderWizardSteps({ onBack, onDelete, configExists, + initialEditName, }: { items: ProviderWizardState; onComplete: (items: ProviderWizardState) => void; onBack: () => void; onDelete: () => void; configExists: boolean; + initialEditName?: string; }) { const [step, setStep] = useState<'providers' | 'modes'>('providers'); const [providers, setProviders] = useState(items.providers); @@ -148,6 +152,7 @@ function ProviderWizardSteps({ onBack={onBack} onDelete={onDelete} configExists={configExists} + initialEditName={initialEditName} /> ); } @@ -168,6 +173,7 @@ export function ProviderWizard({ projectDir, onComplete, onCancel, + initialEditName, }: ProviderWizardProps) { return ( @@ -178,7 +184,9 @@ export function ProviderWizard({ parseConfig={parseProviderConfig} buildConfig={buildProviderConfigObject} hasItems={items => items.providers.length > 0} - renderConfigureStep={args => } + renderConfigureStep={args => ( + + )} renderSummaryItems={items => } renderCompleteExtras={items => } projectDir={projectDir} diff --git a/source/wizards/steps/location-step.spec.tsx b/source/wizards/steps/location-step.spec.tsx index a2873edbd..4b0f014d3 100644 --- a/source/wizards/steps/location-step.spec.tsx +++ b/source/wizards/steps/location-step.spec.tsx @@ -1,8 +1,34 @@ +import {resolve} from 'node:path'; import test from 'ava'; +import {TitledBoxWithPreferences} from '@/components/ui/titled-box'; +import {useResponsiveTerminal} from '@/hooks/useTerminalWidth'; import {renderWithTheme as render} from '@/test-utils/render-with-theme'; import React from 'react'; import {LocationStep} from './location-step.js'; +// Mirrors base-config-wizard.tsx's real LocationStep box. +function WizardBox({ + projectDir, + onComplete, +}: { + projectDir: string; + onComplete: () => void; +}) { + const {boxWidth} = useResponsiveTerminal(); + return ( + + + + ); +} + // ============================================================================ // Tests for LocationStep Component Rendering // ============================================================================ @@ -37,6 +63,51 @@ test('LocationStep shows global config option', t => { t.regex(output!, /Global user config/); }); +test('LocationStep shows the resolved project path next to the option', t => { + const {lastFrame} = render( + {}} projectDir="/test/project" />, + ); + + const output = lastFrame(); + t.truthy(output); + const lines = output!.split('\n'); + const stemIndex = lines.findIndex(line => + line.includes('Current project directory'), + ); + t.true(stemIndex !== -1, 'expected to find the project directory stem'); + t.is(lines[stemIndex + 1]?.trim(), resolve('/test/project')); +}); + +test('LocationStep does not clip the leaf directory inside the real wizard box', t => { + const originalColumns = process.stdout.columns; + try { + for (const columns of [60, 80, 120]) { + Object.defineProperty(process.stdout, 'columns', { + value: columns, + configurable: true, + }); + + const {lastFrame} = render( + {}} + projectDir="/Users/will/Documents/GitHub/some-org/some-really-long-monorepo-name/packages/leaf-dir" + />, + ); + + const output = lastFrame(); + t.true( + output!.includes('leaf-dir'), + `at ${columns} cols, expected leaf directory to stay visible, got: ${output}`, + ); + } + } finally { + Object.defineProperty(process.stdout, 'columns', { + value: originalColumns, + configurable: true, + }); + } +}); + test('LocationStep shows tip about config types', t => { const {lastFrame} = render( {}} projectDir="/test/project" />, diff --git a/source/wizards/steps/location-step.tsx b/source/wizards/steps/location-step.tsx index 178012604..50299f57c 100644 --- a/source/wizards/steps/location-step.tsx +++ b/source/wizards/steps/location-step.tsx @@ -5,7 +5,9 @@ import {useState} from 'react'; import {StyledSelectInput} from '@/components/ui/styled-select-input'; import {getColors} from '@/config'; import {getConfigPath} from '@/config/paths'; +import {MIN_PATH_BUDGET_CHARS, WIZARD_ROW_CHROME_CHARS} from '@/constants'; import {useResponsiveTerminal} from '@/hooks/useTerminalWidth'; +import {homeRelative, truncateMiddle} from '@/utils/path'; export type ConfigLocation = 'project' | 'global'; @@ -19,6 +21,7 @@ interface LocationStepProps { interface LocationOption { label: string; value: ConfigLocation; + path: string; } export function LocationStep({ @@ -28,7 +31,7 @@ export function LocationStep({ configFileName = 'agents.config.json', }: LocationStepProps) { const colors = getColors(); - const {isNarrow, truncatePath} = useResponsiveTerminal(); + const {boxWidth, isNarrow} = useResponsiveTerminal(); const projectPath = join(projectDir, configFileName); const globalPath = join(getConfigPath(), configFileName); @@ -51,12 +54,14 @@ export function LocationStep({ const locationOptions: LocationOption[] = [ { - label: `Global user config`, + label: 'Global user config', value: 'global', + path: homeRelative(getConfigPath()), }, { - label: `Current project directory`, + label: 'Current project directory', value: 'project', + path: homeRelative(projectDir), }, ]; @@ -101,7 +106,7 @@ export function LocationStep({ Configuration found at:{' '} - {isNarrow ? truncatePath(existingPath, 40) : existingPath} + {isNarrow ? truncateMiddle(existingPath, 40) : existingPath} handleLocationSelect(item)} + itemComponent={({isSelected, label, path}) => { + const color = isSelected ? colors.primary : colors.text; + const pathBudget = Math.max( + MIN_PATH_BUDGET_CHARS, + boxWidth - WIZARD_ROW_CHROME_CHARS, + ); + return ( + + + {label} + + + + {truncateMiddle(path, pathBudget)} + + + + ); + }} /> {!isNarrow && ( diff --git a/source/wizards/steps/mcp-step.spec.tsx b/source/wizards/steps/mcp-step.spec.tsx index 2997bef2b..150708c17 100644 --- a/source/wizards/steps/mcp-step.spec.tsx +++ b/source/wizards/steps/mcp-step.spec.tsx @@ -964,3 +964,50 @@ test.serial( unmount(); }, ); + +// ============================================================================ +// Deep-linking straight into one server's edit/delete choice +// ============================================================================ + +const deepLinkServers = { + filesystem: {transport: 'stdio' as const, command: 'mcp-fs'}, + github: {transport: 'stdio' as const, command: 'mcp-gh'}, +}; + +test('McpStep with initialEditName opens that server edit/delete choice', t => { + const {lastFrame} = render( + {}} + existingServers={deepLinkServers} + initialEditName="github" + />, + ); + + const output = lastFrame()!; + t.regex(output, /Edit this server/); + t.notRegex( + output, + /Add MCP servers/, + 'should skip the initial menu entirely', + ); +}); + +test('McpStep falls back to the menu when initialEditName is unknown', t => { + const {lastFrame} = render( + {}} + existingServers={deepLinkServers} + initialEditName="not-a-configured-server" + />, + ); + + t.regex(lastFrame()!, /Add MCP servers/); +}); + +test('McpStep without initialEditName still opens the initial menu', t => { + const {lastFrame} = render( + {}} existingServers={deepLinkServers} />, + ); + + t.regex(lastFrame()!, /Add MCP servers/); +}); diff --git a/source/wizards/steps/mcp-step.tsx b/source/wizards/steps/mcp-step.tsx index 5d7d98c94..bc6d4819f 100644 --- a/source/wizards/steps/mcp-step.tsx +++ b/source/wizards/steps/mcp-step.tsx @@ -26,6 +26,21 @@ interface McpStepProps { onDelete?: () => void; existingServers?: Record; configExists?: boolean; + /** + * Open straight into the edit/delete choice for this server instead of the + * initial menu. An unknown name falls back to the normal menu, since the + * caller lists the resolved config while this step lists whichever config + * file the wizard loaded. + */ + initialEditName?: string; +} + +function findServerName( + servers: Record, + name: string | undefined, +): string | null { + if (!name) return null; + return Object.hasOwn(servers, name) ? name : null; } type Mode = @@ -47,6 +62,7 @@ export function McpStep({ onDelete, existingServers = {}, configExists = false, + initialEditName, }: McpStepProps) { const colors = getColors(); const {isNarrow} = useResponsiveTerminal(); @@ -60,7 +76,11 @@ export function McpStep({ setServers(existingServers); }, [existingServers]); - const [mode, setMode] = useState('initial-menu'); + const [mode, setMode] = useState(() => + findServerName(existingServers, initialEditName) === null + ? 'initial-menu' + : 'edit-or-delete', + ); const { selectedTemplate, currentFieldIndex, @@ -78,7 +98,7 @@ export function McpStep({ } = useWizardForm(); const [multilineBuffer, setMultilineBuffer] = useState(''); const [editingServerName, setEditingServerName] = useState( - null, + () => findServerName(existingServers, initialEditName), ); const [activeTab, setActiveTab] = useState<'local' | 'remote'>('local'); diff --git a/source/wizards/steps/provider-step.spec.tsx b/source/wizards/steps/provider-step.spec.tsx index de168b641..fbb42f272 100644 --- a/source/wizards/steps/provider-step.spec.tsx +++ b/source/wizards/steps/provider-step.spec.tsx @@ -913,3 +913,68 @@ test.serial( unmount(); }, ); + +// ============================================================================ +// Deep-linking straight into one provider's edit/delete choice +// ============================================================================ + +const deepLinkProviders = [ + {name: 'ollama', baseUrl: 'http://localhost:11434/v1', models: ['llama2']}, + {name: 'openrouter', baseUrl: 'https://openrouter.ai/api/v1', models: ['x']}, +]; + +test('ProviderStep with initialEditName opens that provider edit/delete choice', t => { + const {lastFrame} = render( + {}} + existingProviders={deepLinkProviders} + initialEditName="openrouter" + />, + ); + + const output = lastFrame()!; + t.regex(output, /Edit this provider/); + t.regex(output, /Delete this provider/); + t.notRegex( + output, + /Let's add AI providers/, + 'should skip the template menu entirely', + ); +}); + +test('ProviderStep initialEditName targets the named provider, not the first', t => { + const {lastFrame} = render( + {}} + existingProviders={deepLinkProviders} + initialEditName="openrouter" + />, + ); + + t.regex(lastFrame()!, /openrouter/); +}); + +test('ProviderStep falls back to the menu when initialEditName is unknown', t => { + const {lastFrame} = render( + {}} + existingProviders={deepLinkProviders} + initialEditName="not-a-configured-provider" + />, + ); + + // The settings panel lists the resolved config while the wizard loads a + // single file, so a name it cannot find must not strand the user. + t.regex(lastFrame()!, /Let's add AI providers/); +}); + +test('ProviderStep without initialEditName still opens the template menu', t => { + const {lastFrame} = render( + {}} + existingProviders={deepLinkProviders} + />, + ); + + t.regex(lastFrame()!, /Let's add AI providers/); +}); diff --git a/source/wizards/steps/provider-step.tsx b/source/wizards/steps/provider-step.tsx index 07a776757..266410893 100644 --- a/source/wizards/steps/provider-step.tsx +++ b/source/wizards/steps/provider-step.tsx @@ -25,6 +25,23 @@ interface ProviderStepProps { onDelete?: () => void; existingProviders?: ProviderConfig[]; configExists?: boolean; + /** + * Open straight into the edit/delete choice for this provider instead of the + * template menu. Matched by name rather than index: the caller (the settings + * panel) lists the resolved config, while this step lists whichever config + * file the wizard loaded, so positions need not line up. An unknown name + * falls back to the normal menu. + */ + initialEditName?: string; +} + +function findProviderIndex( + providers: ProviderConfig[] | undefined, + name: string | undefined, +): number | null { + if (!name || !providers) return null; + const index = providers.findIndex(provider => provider.name === name); + return index === -1 ? null : index; } type Mode = @@ -82,6 +99,7 @@ export function ProviderStep({ onDelete, existingProviders, configExists = false, + initialEditName, }: ProviderStepProps) { const colors = getColors(); const {isNarrow} = useResponsiveTerminal(); @@ -98,7 +116,11 @@ export function ProviderStep({ } }, [existingProviders]); - const [mode, setMode] = useState('select-template-or-custom'); + const [mode, setMode] = useState(() => + findProviderIndex(existingProviders, initialEditName) === null + ? 'select-template-or-custom' + : 'edit-or-delete', + ); const { selectedTemplate, currentFieldIndex, @@ -115,7 +137,9 @@ export function ProviderStep({ bumpInputKey, } = useWizardForm(); const [cameFromCustom, setCameFromCustom] = useState(false); - const [editingIndex, setEditingIndex] = useState(null); + const [editingIndex, setEditingIndex] = useState(() => + findProviderIndex(existingProviders, initialEditName), + ); const [fetchedModels, setFetchedModels] = useState([]); const [selectedModelIds, setSelectedModelIds] = useState>( new Set(), diff --git a/tsconfig.json b/tsconfig.json index e248637a1..454e4003c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,13 @@ "types": ["node"], "baseUrl": ".", "paths": { - "@/*": ["source/*"] + "@/*": ["source/*"], + // The real `vscode` module only exists inside the extension host, so + // AVA (which loads specs through tsx, and tsx honours these paths) + // resolves it to a stub. Test-only: the extension is bundled from + // plugins/vscode/tsconfig.json with `--external:vscode`, and nothing + // under source/ imports it. + "vscode": ["plugins/vscode/test-stubs/vscode.ts"] } }, "include": ["source/**/*"],