diff --git a/.agents/skills/launch/SKILL.md b/.agents/skills/launch/SKILL.md new file mode 100644 index 00000000000..e1b425e497d --- /dev/null +++ b/.agents/skills/launch/SKILL.md @@ -0,0 +1,291 @@ +--- +name: launch +description: "Launch and automate VS Code (Code OSS) using agent-browser via Chrome DevTools Protocol. Use when you need to interact with the VS Code UI, automate the chat panel, test UI features, or take screenshots of VS Code. Triggers include 'automate VS Code', 'interact with chat', 'test the UI', 'take a screenshot', 'launch Code OSS with debugging'." +metadata: + allowed-tools: Bash(agent-browser:*), Bash(npx agent-browser:*) +--- + +# VS Code Automation + +Automate VS Code (Code OSS) using agent-browser. VS Code is built on Electron/Chromium and exposes a Chrome DevTools Protocol (CDP) port that agent-browser can connect to, enabling the same snapshot-interact workflow used for web pages. + +## Prerequisites + +- **`agent-browser` must be installed.** It's listed in devDependencies — run `npm install` in the repo root. Use `npx agent-browser` if it's not on your PATH, or install globally with `npm install -g agent-browser`. +- **For Code OSS (VS Code dev build):** The repo must be built before launching. `./scripts/code.sh` runs the build automatically if needed, or set `VSCODE_SKIP_PRELAUNCH=1` to skip the compile step if you've already built. +- **CSS selectors are internal implementation details.** Selectors like `.interactive-input-part`, `.interactive-input-editor`, and `.part.auxiliarybar` used in `eval` commands are VS Code internals that may change across versions. If they stop working, use `agent-browser snapshot -i` to re-discover the current DOM structure. + +## Core Workflow + +1. **Launch** Code OSS with remote debugging enabled +2. **Connect** agent-browser to the CDP port +3. **Snapshot** to discover interactive elements +4. **Interact** using element refs +5. **Re-snapshot** after navigation or state changes + +```bash +# Launch Code OSS with remote debugging +./scripts/code.sh --remote-debugging-port=9224 + +# Wait for Code OSS to start, retry until connected +for i in 1 2 3 4 5; do agent-browser connect 9224 2>/dev/null && break || sleep 3; done + +# Discover UI elements +agent-browser snapshot -i + +# Focus the chat input (macOS) +agent-browser press Control+Meta+i +``` + +## Connecting + +```bash +# Connect to a specific port +agent-browser connect 9222 + +# Or use --cdp on each command +agent-browser --cdp 9222 snapshot -i + +# Auto-discover a running Chromium-based app +agent-browser --auto-connect snapshot -i +``` + +After `connect`, all subsequent commands target the connected app without needing `--cdp`. + +## Tab Management + +Electron apps often have multiple windows or webviews. Use tab commands to list and switch between them: + +```bash +# List all available targets (windows, webviews, etc.) +agent-browser tab + +# Switch to a specific tab by index +agent-browser tab 2 + +# Switch by URL pattern +agent-browser tab --url "*settings*" +``` + +## Launching Code OSS (VS Code Dev Build) + +The VS Code repository includes `scripts/code.sh` which launches Code OSS from source. It passes all arguments through to the Electron binary, so `--remote-debugging-port` works directly: + +```bash +cd # the root of your VS Code checkout +./scripts/code.sh --remote-debugging-port=9224 +``` + +Wait for the window to fully initialize, then connect: + +```bash +# Wait for Code OSS to start, retry until connected +for i in 1 2 3 4 5; do agent-browser connect 9224 2>/dev/null && break || sleep 3; done +agent-browser snapshot -i +``` + +**Tips:** +- Set `VSCODE_SKIP_PRELAUNCH=1` to skip the compile step if you've already built: `VSCODE_SKIP_PRELAUNCH=1 ./scripts/code.sh --remote-debugging-port=9224` (from the repo root) +- Code OSS uses the default user data directory. Unlike VS Code Insiders, you don't typically need `--user-data-dir` since there's usually only one Code OSS instance running. +- If you see "Sent env to running instance. Terminating..." it means Code OSS is already running and forwarded your args to the existing instance. Quit Code OSS and relaunch with the flag, or use `--user-data-dir=/tmp/code-oss-debug` to force a new instance. + +## Launching VS Code Extensions for Debugging + +To debug a VS Code extension via agent-browser, launch VS Code Insiders with `--extensionDevelopmentPath` and `--remote-debugging-port`. Use `--user-data-dir` to avoid conflicting with an already-running instance. + +```bash +# Build the extension first +cd # e.g., the root of your extension checkout +npm run compile + +# Launch VS Code Insiders with the extension and CDP +code-insiders \ + --extensionDevelopmentPath="" \ + --remote-debugging-port=9223 \ + --user-data-dir=/tmp/vscode-ext-debug + +# Wait for VS Code to start, retry until connected +for i in 1 2 3 4 5; do agent-browser connect 9223 2>/dev/null && break || sleep 3; done +agent-browser snapshot -i +``` + +**Key flags:** +- `--extensionDevelopmentPath=` — loads your extension from source (must be compiled first) +- `--remote-debugging-port=9223` — enables CDP (use 9223 to avoid conflicts with other apps on 9222) +- `--user-data-dir=` — uses a separate profile so it starts a new process instead of sending to an existing VS Code instance + +**Without `--user-data-dir`**, VS Code detects the running instance, forwards the args to it, and exits immediately — you'll see "Sent env to running instance. Terminating..." and CDP never starts. + +## Interacting with Monaco Editor (Chat Input, Code Editors) + +VS Code uses Monaco Editor for all text inputs including the Copilot Chat input. Monaco editors require specific agent-browser techniques — standard `click`, `fill`, and `keyboard type` commands may not work depending on the VS Code build. + +### The Universal Pattern: Focus via Keyboard Shortcut + `press` + +This works on **all** VS Code builds (Code OSS, Insiders, stable): + +```bash +# 1. Open and focus the chat input with the keyboard shortcut +# macOS: +agent-browser press Control+Meta+i +# Linux / Windows: +agent-browser press Control+Alt+i + +# 2. Type using individual press commands +agent-browser press H +agent-browser press e +agent-browser press l +agent-browser press l +agent-browser press o +agent-browser press Space # Use "Space" for spaces +agent-browser press w +agent-browser press o +agent-browser press r +agent-browser press l +agent-browser press d + +# Verify text appeared (optional) +agent-browser eval ' +(() => { + const sidebar = document.querySelector(".part.auxiliarybar"); + const viewLines = sidebar.querySelectorAll(".interactive-input-editor .view-line"); + return Array.from(viewLines).map(vl => vl.textContent).join("|"); +})()' + +# 3. Send the message (same on all platforms) +agent-browser press Enter +``` + +**Chat focus shortcut by platform:** +- **macOS:** `Ctrl+Cmd+I` → `agent-browser press Control+Meta+i` +- **Linux:** `Ctrl+Alt+I` → `agent-browser press Control+Alt+i` +- **Windows:** `Ctrl+Alt+I` → `agent-browser press Control+Alt+i` + +This shortcut focuses the chat input and sets `document.activeElement` to a `DIV` with class `native-edit-context` — VS Code's native text editing surface that correctly processes key events from `agent-browser press`. + +### `type @ref` — Works on Some Builds + +On VS Code Insiders (extension debug mode), `type @ref` handles focus and input in one step: + +```bash +agent-browser snapshot -i +# Look for: textbox "The editor is not accessible..." [ref=e62] +agent-browser type @e62 "Hello from George!" +``` + +However, **`type @ref` silently fails on Code OSS** — the command completes without error but no text appears. This also applies to `keyboard type` and `keyboard inserttext`. Always verify text appeared after typing, and fall back to the keyboard shortcut + `press` pattern if it didn't. The `press`-per-key approach works universally across all builds. + +### Compatibility Matrix + +| Method | VS Code Insiders | Code OSS | +|--------|-----------------|----------| +| `press` per key (after focus shortcut) | ✅ Works | ✅ Works | +| `type @ref` | ✅ Works | ❌ Silent fail | +| `keyboard type` (after focus) | ✅ Works | ❌ Silent fail | +| `keyboard inserttext` (after focus) | ✅ Works | ❌ Silent fail | +| `click @ref` | ❌ Blocked by overlay | ❌ Blocked by overlay | +| `fill @ref` | ❌ Element not visible | ❌ Element not visible | + +### Fallback: Focus via JavaScript Mouse Events + +If the keyboard shortcut doesn't work (e.g., chat panel isn't configured), you can focus the editor via JavaScript: + +```bash +agent-browser eval ' +(() => { + const inputPart = document.querySelector(".interactive-input-part"); + const editor = inputPart.querySelector(".monaco-editor"); + const rect = editor.getBoundingClientRect(); + const x = rect.x + rect.width / 2; + const y = rect.y + rect.height / 2; + editor.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: x, clientY: y })); + editor.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, clientX: x, clientY: y })); + editor.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: x, clientY: y })); + return "activeElement: " + document.activeElement?.className; +})()' + +# Then use press for each character +agent-browser press H +agent-browser press e +# ... +``` + +### Verifying Text and Clearing + +```bash +# Verify text in the chat input +agent-browser eval ' +(() => { + const sidebar = document.querySelector(".part.auxiliarybar"); + const viewLines = sidebar.querySelectorAll(".interactive-input-editor .view-line"); + return Array.from(viewLines).map(vl => vl.textContent).join("|"); +})()' + +# Clear the input (Select All + Backspace) +# macOS: +agent-browser press Meta+a +# Linux / Windows: +agent-browser press Control+a +# Then delete: +agent-browser press Backspace +``` + +### Screenshot Tips for VS Code + +On ultrawide monitors, the chat sidebar may be in the far-right corner of the CDP screenshot. Options: +- Use `agent-browser screenshot --full` to capture the entire window +- Use element screenshots: `agent-browser screenshot ".part.auxiliarybar" sidebar.png` +- Use `agent-browser screenshot --annotate` to see labeled element positions +- Maximize the sidebar first: click the "Maximize Secondary Side Bar" button + +> **macOS:** If `agent-browser screenshot` returns "Permission denied", your terminal needs Screen Recording permission. Grant it in **System Settings → Privacy & Security → Screen Recording**. As a fallback, use the `eval` verification snippet to confirm text was entered — this doesn't require screen permissions. + +## Troubleshooting + +### "Connection refused" or "Cannot connect" + +- Make sure Code OSS was launched with `--remote-debugging-port=NNNN` +- If Code OSS was already running, quit and relaunch with the flag +- Check that the port isn't in use by another process: + - macOS / Linux: `lsof -i :9224` + - Windows: `netstat -ano | findstr 9224` + +### Elements not appearing in snapshot + +- VS Code uses multiple webviews. Use `agent-browser tab` to list targets and switch to the right one +- Use `agent-browser snapshot -i -C` to include cursor-interactive elements (divs with onclick handlers) + +### Cannot type in Monaco Editor inputs + +- Use `agent-browser press` for individual keystrokes after focusing the input. Focus the chat input with the keyboard shortcut (macOS: `Ctrl+Cmd+I`, Linux/Windows: `Ctrl+Alt+I`). +- `type @ref`, `keyboard type`, and `keyboard inserttext` work on VS Code Insiders but **silently fail on Code OSS** — they complete without error but no text appears. The `press`-per-key approach works universally. +- See the "Interacting with Monaco Editor" section above for the full compatibility matrix. + +## Cleanup / Disconnect + +> **⚠️ IMPORTANT: Always quit Code OSS when you're done.** Code OSS is a full Electron app that consumes significant memory (often 1–4 GB+). Leaving it running in the background will slow your machine considerably. Don't just disconnect agent-browser — **kill the Code OSS process too.** + +```bash +# 1. Disconnect agent-browser +agent-browser close + +# 2. QUIT Code OSS — do not leave it running! +# macOS: Cmd+Q in the app window, or: +# Find the process +lsof -i :9224 | grep LISTEN +# Kill it (replace with the actual PID) +kill + +# Linux: +# kill $(lsof -t -i :9224) + +# Windows: +# taskkill /F /PID +# Or use Task Manager to end "Code - OSS" +``` + +If you launched with `./scripts/code.sh`, the process name is `Electron` or `Code - OSS`. Verify it's gone: +```bash +# Confirm no process is listening on the debug port +lsof -i :9224 # should return nothing +``` diff --git a/.claude/skills/launch b/.claude/skills/launch new file mode 120000 index 00000000000..b41e2b420ad --- /dev/null +++ b/.claude/skills/launch @@ -0,0 +1 @@ +../../.agents/skills/launch \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 06005d9424b..8d56465c45a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -24,6 +24,7 @@ Visual Studio Code is built with a layered architecture using TypeScript, web AP - `workbench/api/` - Extension host and VS Code API implementation - `src/vs/code/` - Electron main process specific implementation - `src/vs/server/` - Server specific implementation +- `src/vs/sessions/` - Agent sessions window, a dedicated workbench layer for agentic workflows (sits alongside `vs/workbench`, may import from it but not vice versa) The core architecture follows these principles: - **Layered architecture** - from `base`, `platform`, `editor`, to `workbench` @@ -49,15 +50,15 @@ Each extension follows the standard VS Code extension structure with `package.js ## Validating TypeScript changes -MANDATORY: Always check the `VS Code - Build` watch task output via #runTasks/getTaskOutput for compilation errors before running ANY script or declaring work complete, then fix all compilation errors before moving forward. +MANDATORY: Always check for compilation errors before running any tests or validation scripts, or declaring work complete, then fix all compilation errors before moving forward. - NEVER run tests if there are compilation errors -- NEVER use `npm run compile` to compile TypeScript files but call #runTasks/getTaskOutput instead +- NEVER use `npm run compile` to compile TypeScript files ### TypeScript compilation steps -- Monitor the `VS Code - Build` task outputs for real-time compilation errors as you make changes -- This task runs `Core - Build` and `Ext - Build` to incrementally compile VS Code TypeScript sources and built-in extensions -- Start the task if it's not already running in the background +- If the `#runTasks/getTaskOutput` tool is available, check the `VS Code - Build` watch task output for compilation errors. This task runs `Core - Build` and `Ext - Build` to incrementally compile VS Code TypeScript sources and built-in extensions. Start the task if it's not already running in the background. +- If the tool is not available (e.g. in CLI environments) and you only changed code under `src/`, run `npm run compile-check-ts-native` after making changes to type-check the main VS Code sources (it validates `./src/tsconfig.json`). +- If you changed built-in extensions under `extensions/` and the tool is not available, run the corresponding gulp task `npm run gulp compile-extensions` instead so that TypeScript errors in extensions are also reported. - For TypeScript changes in the `build` folder, you can simply run `npm run typecheck` in the `build` folder. ### TypeScript validation steps @@ -135,6 +136,7 @@ function f(x: number, y: string): void { } - Prefer regex capture groups with names over numbered capture groups. - If you create any temporary new files, scripts, or helper files for iteration, clean up these files by removing them at the end of the task - Never duplicate imports. Always reuse existing imports if they are present. +- When removing an import, do not leave behind blank lines where the import was. Ensure the surrounding code remains compact. - Do not use `any` or `unknown` as the type for variables, parameters, or return values unless absolutely necessary. If they need type annotations, they should have proper types or interfaces defined. - When adding file watching, prefer correlated file watchers (via fileService.createWatcher) to shared ones. - When adding tooltips to UI elements, prefer the use of IHoverService service. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f7e3481c75b..07296619597 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,27 @@ updates: directory: "/" schedule: interval: "weekly" + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "daily" + allow: + - dependency-name: "@vscode/component-explorer" + - dependency-name: "@vscode/component-explorer-cli" + groups: + component-explorer: + patterns: + - "@vscode/component-explorer" + - "@vscode/component-explorer-cli" + - package-ecosystem: "npm" + directory: "/build/vite" + schedule: + interval: "daily" + allow: + - dependency-name: "@vscode/component-explorer" + - dependency-name: "@vscode/component-explorer-vite-plugin" + groups: + component-explorer: + patterns: + - "@vscode/component-explorer" + - "@vscode/component-explorer-vite-plugin" diff --git a/.github/hooks/hooks.json b/.github/hooks/hooks.json index 59c170e420e..5e27e6db893 100644 --- a/.github/hooks/hooks.json +++ b/.github/hooks/hooks.json @@ -4,7 +4,20 @@ "sessionStart": [ { "type": "command", - "bash": "if [ -f ~/.vscode-worktree-setup ]; then nohup npm ci > /tmp/npm-ci-$(date +%Y-%m-%d_%H-%M-%S).log 2>&1 & fi" + "bash": "if [ -f ~/.vscode-worktree-setup ]; then nohup bash -c 'npm ci && npm run compile' > /tmp/worktree-setup-$(date +%Y-%m-%d_%H-%M-%S).log 2>&1 & fi", + "powershell": "if (Test-Path \"$env:USERPROFILE\\.vscode-worktree-setup\") { $log = \"$env:TEMP\\worktree-setup-$(Get-Date -Format 'yyyy-MM-dd_HH-mm-ss').log\"; $dir = $PWD.Path; Start-Job -ScriptBlock { param($d, $l) Set-Location $d; & { npm ci; if ($LASTEXITCODE -eq 0) { npm run compile } } *> $l } -ArgumentList $dir, $log | Out-Null }" + } + ], + "sessionEnd": [ + { + "type": "command", + "bash": "" + } + ], + "agentStop": [ + { + "type": "command", + "bash": "" } ], "userPromptSubmitted": [ @@ -26,4 +39,4 @@ } ] } -} \ No newline at end of file +} diff --git a/.github/skills/add-policy/SKILL.md b/.github/skills/add-policy/SKILL.md new file mode 100644 index 00000000000..64119b056ba --- /dev/null +++ b/.github/skills/add-policy/SKILL.md @@ -0,0 +1,139 @@ +--- +name: add-policy +description: Use when adding, modifying, or reviewing VS Code configuration policies. Covers the full policy lifecycle from registration to export to platform-specific artifacts. Run on ANY change that adds a `policy:` field to a configuration property. +--- + +# Adding a Configuration Policy + +Policies allow enterprise administrators to lock configuration settings via OS-level mechanisms (Windows Group Policy, macOS managed preferences, Linux config files) or via Copilot account-level policy data. This skill covers the complete procedure. + +## When to Use + +- Adding a new `policy:` field to any configuration property +- Modifying an existing policy (rename, category change, etc.) +- Reviewing a PR that touches policy registration +- Adding account-based policy support via `IPolicyData` + +## Architecture Overview + +### Policy Sources (layered, last writer wins) + +| Source | Implementation | How it reads policies | +|--------|---------------|----------------------| +| **OS-level** (Windows registry, macOS plist) | `NativePolicyService` via `@vscode/policy-watcher` | Watches `Software\Policies\Microsoft\{productName}` (Windows) or bundle identifier prefs (macOS) | +| **Linux file** | `FilePolicyService` | Reads `/etc/vscode/policy.json` | +| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function | +| **Multiplex** | `MultiplexPolicyService` | Combines OS-level + account policy services; used in desktop main | + +### Key Files + +| File | Purpose | +|------|---------| +| `src/vs/base/common/policy.ts` | `PolicyCategory` enum, `IPolicy` interface | +| `src/vs/platform/policy/common/policy.ts` | `IPolicyService`, `AbstractPolicyService`, `PolicyDefinition` | +| `src/vs/platform/configuration/common/configurations.ts` | `PolicyConfiguration` — bridges policies to configuration values | +| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation | +| `src/vs/workbench/services/policies/common/multiplexPolicyService.ts` | Combines multiple policy services | +| `src/vs/workbench/contrib/policyExport/electron-browser/policyExport.contribution.ts` | `--export-policy-data` CLI handler | +| `src/vs/base/common/defaultAccount.ts` | `IPolicyData` interface for account-level policy fields | +| `build/lib/policies/policyData.jsonc` | Auto-generated policy catalog (DO NOT edit manually) | +| `build/lib/policies/policyGenerator.ts` | Generates ADMX/ADML (Windows), plist (macOS), JSON (Linux) | +| `build/lib/test/policyConversion.test.ts` | Tests for policy artifact generation | + +## Procedure + +### Step 1 — Add the `policy` field to the configuration property + +Find the configuration registration (typically in a `*.contribution.ts` file) and add a `policy` object to the property schema. + +**Required fields:** + +**Determining `minimumVersion`:** Always read `version` from the root `package.json` and use the `major.minor` portion. For example, if `package.json` has `"version": "1.112.0"`, use `minimumVersion: '1.112'`. Never hardcode an old version like `'1.99'`. + +```typescript +policy: { + name: 'MyPolicyName', // PascalCase, unique across all policies + category: PolicyCategory.InteractiveSession, // From PolicyCategory enum + minimumVersion: '1.112', // Use major.minor from package.json version + localization: { + description: { + key: 'my.config.key', // NLS key for the description + value: nls.localize('my.config.key', "Human-readable description."), + } + } +} +``` + +**Optional: `value` function for account-based policy:** + +If this policy should also be controllable via Copilot account policy data (from `IPolicyData`), add a `value` function: + +```typescript +policy: { + name: 'MyPolicyName', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.112', // Use major.minor from package.json version + value: (policyData) => policyData.my_field === false ? false : undefined, + localization: { /* ... */ } +} +``` + +The `value` function receives `IPolicyData` (from `src/vs/base/common/defaultAccount.ts`) and should: +- Return a concrete value to **override** the user's setting +- Return `undefined` to **not apply** any account-level override (falls through to OS policy or user setting) + +If you need a new field on `IPolicyData`, add it to the interface in `src/vs/base/common/defaultAccount.ts`. + +**Optional: `enumDescriptions` for enum/string policies:** + +```typescript +localization: { + description: { key: '...', value: nls.localize('...', "...") }, + enumDescriptions: [ + { key: 'opt.none', value: nls.localize('opt.none', "No access.") }, + { key: 'opt.all', value: nls.localize('opt.all', "Full access.") }, + ] +} +``` + +### Step 2 — Ensure `PolicyCategory` is imported + +```typescript +import { PolicyCategory } from '../../../../base/common/policy.js'; +``` + +Existing categories in the `PolicyCategory` enum: +- `Extensions` +- `IntegratedTerminal` +- `InteractiveSession` (used for all chat/Copilot policies) +- `Telemetry` +- `Update` + +If you need a new category, add it to `PolicyCategory` in `src/vs/base/common/policy.ts` and add corresponding `PolicyCategoryData` localization. + +### Step 3 — Validate TypeScript compilation + +Check the `VS Code - Build` watch task output, or run: + +```bash +npm run compile-check-ts-native +``` + +### Step 4 — Export the policy data + +Regenerate the auto-generated policy catalog: + +```bash +npm run transpile-client && ./scripts/code.sh --export-policy-data +``` + +This updates `build/lib/policies/policyData.jsonc`. **Never edit this file manually.** Verify your new policy appears in the output. You will need code review from a codeowner to merge the change to main. + + +## Policy for extension-provided settings + +For an extension author to provide policies for their extension's settings, a change must be made in `vscode-distro` to the `product.json`. + +## Examples + +Search the codebase for `policy:` to find all the examples of different policy configurations. diff --git a/.github/skills/agent-sessions-layout/SKILL.md b/.github/skills/agent-sessions-layout/SKILL.md index a76794d9c7d..af4f03a3f60 100644 --- a/.github/skills/agent-sessions-layout/SKILL.md +++ b/.github/skills/agent-sessions-layout/SKILL.md @@ -45,7 +45,7 @@ When proposing or implementing changes, follow these rules from the spec: 4. **New parts go in the right section** — Any new parts should be added to the horizontal branch alongside Chat Bar and Auxiliary Bar 5. **Preserve no-op methods** — Unsupported features (zen mode, centered layout, etc.) should remain as no-ops, not throw errors 6. **Handle pane composite lifecycle** — When hiding/showing parts, manage the associated pane composites -7. **Use agent session parts** — New part functionality goes in the agent session part classes (`SidebarPart`, `AuxiliaryBarPart`, `PanelPart`, `ChatBarPart`), not the standard workbench parts +7. **Use agent session parts** — New part functionality goes in the agent session part classes (`SidebarPart`, `AuxiliaryBarPart`, `PanelPart`, `ChatBarPart`, `ProjectBarPart`), not the standard workbench parts 8. **Use separate storage keys** — Agent session parts use their own storage keys (prefixed with `workbench.agentsession.` or `workbench.chatbar.`) to avoid conflicts with regular workbench state 9. **Use agent session menu IDs** — Actions should use `Menus.*` menu IDs (from `sessions/browser/menus.ts`), not shared `MenuId.*` constants @@ -53,20 +53,24 @@ When proposing or implementing changes, follow these rules from the spec: | File | Purpose | |------|---------| -| `sessions/LAYOUT.md` | Authoritative specification | +| `sessions/LAYOUT.md` | Authoritative layout specification | | `sessions/browser/workbench.ts` | Main layout implementation (`Workbench` class) | | `sessions/browser/menus.ts` | Agent sessions menu IDs (`Menus` export) | | `sessions/browser/layoutActions.ts` | Layout actions (toggle sidebar, panel, secondary sidebar) | | `sessions/browser/paneCompositePartService.ts` | `AgenticPaneCompositePartService` | -| `sessions/browser/style.css` | Layout-specific styles | -| `sessions/browser/parts/` | Agent session part implementations | +| `sessions/browser/media/style.css` | Layout-specific styles | +| `sessions/browser/parts/parts.ts` | `AgenticParts` enum | | `sessions/browser/parts/titlebarPart.ts` | Titlebar part, MainTitlebarPart, AuxiliaryTitlebarPart, TitleService | -| `sessions/browser/parts/sidebarPart.ts` | Sidebar part (with footer) | +| `sessions/browser/parts/sidebarPart.ts` | Sidebar part (with footer and macOS traffic light spacer) | | `sessions/browser/parts/chatBarPart.ts` | Chat Bar part | -| `sessions/browser/widget/` | Agent sessions chat widget | +| `sessions/browser/parts/auxiliaryBarPart.ts` | Auxiliary Bar part (with run script dropdown) | +| `sessions/browser/parts/panelPart.ts` | Panel part | +| `sessions/browser/parts/projectBarPart.ts` | Project Bar part (folder entries, icon customization) | +| `sessions/contrib/configuration/browser/configuration.contribution.ts` | Sets `workbench.editor.useModal` to `'all'` for modal editor overlay | | `sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts` | Title bar widget and session picker | -| `sessions/contrib/chat/browser/runScriptAction.ts` | Run script contribution | +| `sessions/contrib/chat/browser/runScriptAction.ts` | Run script split button for titlebar | | `sessions/contrib/accountMenu/browser/account.contribution.ts` | Account widget for sidebar footer | +| `sessions/electron-browser/parts/titlebarPart.ts` | Desktop (Electron) titlebar part | ## 5. Testing Changes diff --git a/.github/skills/component-fixtures/SKILL.md b/.github/skills/component-fixtures/SKILL.md index ec2df9d4e92..6c7eb5a6059 100644 --- a/.github/skills/component-fixtures/SKILL.md +++ b/.github/skills/component-fixtures/SKILL.md @@ -30,7 +30,7 @@ src/vs/workbench/test/browser/componentFixtures/ ```typescript import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'myFeature/' }, { Default: defineComponentFixture({ render: renderMyComponent }), AnotherVariant: defineComponentFixture({ render: renderMyComponent }), }); diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index fc49548b7a3..e39957e5f66 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -15,8 +15,6 @@ The `src/vs/sessions/` directory contains authoritative specification documents. | Layout spec | `src/vs/sessions/LAYOUT.md` | Grid structure, part positions, sizing, CSS classes, API reference | | AI Customizations | `src/vs/sessions/AI_CUSTOMIZATIONS.md` | AI customization editor and tree view design | | Chat Widget | `src/vs/sessions/browser/widget/AGENTS_CHAT_WIDGET.md` | Chat widget wrapper architecture, deferred session creation, option delivery | -| AI Customization Mgmt | `src/vs/sessions/contrib/aiCustomizationManagement/browser/SPEC.md` | Management editor specification | -| AI Customization Tree | `src/vs/sessions/contrib/aiCustomizationTreeView/browser/SPEC.md` | Tree view specification | If you modify the implementation, you **must** update the corresponding spec to keep it in sync. Update the Revision History table at the bottom of `LAYOUT.md` with a dated entry. @@ -62,44 +60,57 @@ src/vs/sessions/ ├── AI_CUSTOMIZATIONS.md # AI customization design document ├── sessions.common.main.ts # Common (browser + desktop) entry point ├── sessions.desktop.main.ts # Desktop entry point (imports all contributions) -├── common/ # Shared types and context keys -│ └── contextkeys.ts # ChatBar context keys +├── common/ # Shared types, context keys, and theme +│ ├── categories.ts # Command categories +│ ├── contextkeys.ts # ChatBar and welcome context keys +│ └── theme.ts # Theme contributions ├── browser/ # Core workbench implementation │ ├── workbench.ts # Main Workbench class (implements IWorkbenchLayoutService) │ ├── menus.ts # Agent sessions menu IDs (Menus export) │ ├── layoutActions.ts # Layout toggle actions (sidebar, panel, auxiliary bar) │ ├── paneCompositePartService.ts # AgenticPaneCompositePartService -│ ├── style.css # Layout-specific styles │ ├── widget/ # Agent sessions chat widget -│ │ ├── AGENTS_CHAT_WIDGET.md # Chat widget architecture doc -│ │ ├── agentSessionsChatWidget.ts # Main wrapper around ChatWidget -│ │ ├── agentSessionsChatTargetConfig.ts # Observable target state -│ │ ├── agentSessionsTargetPickerActionItem.ts # Target picker for input toolbar -│ │ └── media/ -│ └── parts/ # Workbench part implementations -│ ├── parts.ts # AgenticParts enum -│ ├── titlebarPart.ts # Titlebar (3-section toolbar layout) -│ ├── sidebarPart.ts # Sidebar (with footer for account widget) -│ ├── chatBarPart.ts # Chat Bar (primary chat surface) -│ ├── auxiliaryBarPart.ts # Auxiliary Bar (with run script dropdown) -│ ├── panelPart.ts # Panel (terminal, output, etc.) -│ ├── projectBarPart.ts # Project bar (folder entries) -│ ├── agentSessionsChatInputPart.ts # Chat input part adapter -│ ├── agentSessionsChatWelcomePart.ts # Welcome view (mascot + target buttons + pickers) -│ └── media/ # Part CSS files +│ │ └── AGENTS_CHAT_WIDGET.md # Chat widget architecture doc +│ ├── parts/ # Workbench part implementations +│ │ ├── parts.ts # AgenticParts enum +│ │ ├── titlebarPart.ts # Titlebar (3-section toolbar layout) +│ │ ├── sidebarPart.ts # Sidebar (with footer for account widget) +│ │ ├── chatBarPart.ts # Chat Bar (primary chat surface) +│ │ ├── auxiliaryBarPart.ts # Auxiliary Bar +│ │ ├── panelPart.ts # Panel (terminal, output, etc.) +│ │ ├── projectBarPart.ts # Project bar (folder entries) +│ │ └── media/ # Part CSS files +│ └── media/ # Layout-specific styles ├── electron-browser/ # Desktop-specific entry points │ ├── sessions.main.ts # Desktop main bootstrap │ ├── sessions.ts # Electron process entry │ ├── sessions.html # Production HTML shell -│ └── sessions-dev.html # Development HTML shell +│ ├── sessions-dev.html # Development HTML shell +│ ├── titleService.ts # Desktop title service override +│ └── parts/ +│ └── titlebarPart.ts # Desktop titlebar part +├── services/ # Service overrides +│ ├── configuration/browser/ # Configuration service overrides +│ └── workspace/browser/ # Workspace service overrides +├── test/ # Unit tests +│ └── browser/ +│ └── layoutActions.test.ts └── contrib/ # Feature contributions ├── accountMenu/browser/ # Account widget for sidebar footer - ├── aiCustomizationManagement/browser/ # AI customization management editor + ├── agentFeedback/browser/ # Agent feedback attachments, overlays, hover ├── aiCustomizationTreeView/browser/ # AI customization tree view sidebar + ├── applyToParentRepo/browser/ # Apply changes to parent repo ├── changesView/browser/ # File changes view ├── chat/browser/ # Chat actions (run script, branch, prompts) ├── configuration/browser/ # Configuration overrides - └── sessions/browser/ # Sessions view, title bar widget, active session service + ├── files/browser/ # File-related contributions + ├── fileTreeView/browser/ # File tree view (filesystem provider) + ├── gitSync/browser/ # Git sync contributions + ├── logs/browser/ # Log contributions + ├── sessions/browser/ # Sessions view, title bar widget, active session service + ├── terminal/browser/ # Terminal contributions + ├── welcome/browser/ # Welcome view contribution + └── workspace/browser/ # Workspace contributions ``` ## 4. Layout @@ -165,18 +176,21 @@ The agent sessions window uses **its own menu IDs** defined in `browser/menus.ts | Menu ID | Purpose | |---------|---------| -| `Menus.TitleBarLeft` | Left toolbar (toggle sidebar) | -| `Menus.TitleBarCenter` | Not used directly (see CommandCenter) | -| `Menus.TitleBarRight` | Right toolbar (run script, open, toggle auxiliary bar) | +| `Menus.ChatBarTitle` | Chat bar title actions | | `Menus.CommandCenter` | Center toolbar with session picker widget | -| `Menus.TitleBarControlMenu` | Submenu intercepted to render `SessionsTitleBarWidget` | +| `Menus.CommandCenterCenter` | Center section of command center | +| `Menus.TitleBarContext` | Titlebar context menu | +| `Menus.TitleBarLeftLayout` | Left layout toolbar | +| `Menus.TitleBarSessionTitle` | Session title in titlebar | +| `Menus.TitleBarSessionMenu` | Session menu in titlebar | +| `Menus.TitleBarRightLayout` | Right layout toolbar | | `Menus.PanelTitle` | Panel title bar actions | | `Menus.SidebarTitle` | Sidebar title bar actions | | `Menus.SidebarFooter` | Sidebar footer (account widget) | +| `Menus.SidebarCustomizations` | Sidebar customizations menu | | `Menus.AuxiliaryBarTitle` | Auxiliary bar title actions | | `Menus.AuxiliaryBarTitleLeft` | Auxiliary bar left title actions | -| `Menus.OpenSubMenu` | "Open..." split button (Open Terminal, Open in VS Code) | -| `Menus.ChatBarTitle` | Chat bar title actions | +| `Menus.AgentFeedbackEditorContent` | Agent feedback editor content menu | ## 7. Context Keys @@ -187,7 +201,7 @@ Defined in `common/contextkeys.ts`: | `activeChatBar` | `string` | ID of the active chat bar panel | | `chatBarFocus` | `boolean` | Whether chat bar has keyboard focus | | `chatBarVisible` | `boolean` | Whether chat bar is visible | - +| `sessionsWelcomeVisible` | `boolean` | Whether the sessions welcome overlay is visible | ## 8. Contributions Feature contributions live under `contrib//browser/` and are registered via imports in `sessions.desktop.main.ts` (desktop) or `sessions.common.main.ts` (browser-compatible). @@ -199,13 +213,18 @@ Feature contributions live under `contrib//browser/` and are regist | **Sessions View** | `contrib/sessions/browser/` | Sessions list in sidebar, session picker, active session service | | **Title Bar Widget** | `contrib/sessions/browser/sessionsTitleBarWidget.ts` | Session picker in titlebar center | | **Account Widget** | `contrib/accountMenu/browser/` | Account button in sidebar footer | -| **Run Script** | `contrib/chat/browser/runScriptAction.ts` | Run configured script in terminal | -| **Branch Chat Session** | `contrib/chat/browser/branchChatSessionAction.ts` | Branch a chat session | -| **Open in VS Code / Terminal** | `contrib/chat/browser/chat.contribution.ts` | Open worktree in VS Code or terminal | -| **Prompts Service** | `contrib/chat/browser/promptsService.ts` | Agentic prompts service override | +| **Chat Actions** | `contrib/chat/browser/` | Chat actions (run script, branch, prompts, customizations debug log) | | **Changes View** | `contrib/changesView/browser/` | File changes in auxiliary bar | -| **AI Customization Editor** | `contrib/aiCustomizationManagement/browser/` | Management editor for prompts, hooks, MCP, etc. | +| **Agent Feedback** | `contrib/agentFeedback/browser/` | Agent feedback attachments, editor overlays, hover | | **AI Customization Tree** | `contrib/aiCustomizationTreeView/browser/` | Sidebar tree for AI customizations | +| **Apply to Parent Repo** | `contrib/applyToParentRepo/browser/` | Apply changes to parent repo | +| **Files** | `contrib/files/browser/` | File-related contributions | +| **File Tree View** | `contrib/fileTreeView/browser/` | File tree view (filesystem provider) | +| **Git Sync** | `contrib/gitSync/browser/` | Git sync contributions | +| **Logs** | `contrib/logs/browser/` | Log contributions | +| **Terminal** | `contrib/terminal/browser/` | Terminal contributions | +| **Welcome** | `contrib/welcome/browser/` | Welcome view contribution | +| **Workspace** | `contrib/workspace/browser/` | Workspace contributions | | **Configuration** | `contrib/configuration/browser/` | Configuration overrides | ### 8.2 Service Overrides @@ -216,6 +235,10 @@ The agent sessions window registers its own implementations for: - `IPromptsService` → `AgenticPromptsService` (scopes prompt discovery to active session worktree) - `IActiveSessionService` → `ActiveSessionService` (tracks active session) +Service overrides also live under `services/`: +- `services/configuration/browser/` - configuration service overrides +- `services/workspace/browser/` - workspace service overrides + ### 8.3 `WindowVisibility.Sessions` Views and contributions that should only appear in the agent sessions window (not in regular VS Code) use `WindowVisibility.Sessions` in their registration. @@ -224,12 +247,14 @@ Views and contributions that should only appear in the agent sessions window (no | File | Purpose | |------|---------| -| `sessions.common.main.ts` | Common entry — imports browser-compatible services, workbench contributions | -| `sessions.desktop.main.ts` | Desktop entry — imports desktop services, electron contributions, all `contrib/` modules | +| `sessions.common.main.ts` | Common entry; imports browser-compatible services, workbench contributions | +| `sessions.desktop.main.ts` | Desktop entry; imports desktop services, electron contributions, all `contrib/` modules | | `electron-browser/sessions.main.ts` | Desktop bootstrap | | `electron-browser/sessions.ts` | Electron process entry | | `electron-browser/sessions.html` | Production HTML shell | | `electron-browser/sessions-dev.html` | Development HTML shell | +| `electron-browser/titleService.ts` | Desktop title service override | +| `electron-browser/parts/titlebarPart.ts` | Desktop titlebar part | ## 10. Development Guidelines @@ -243,7 +268,15 @@ Views and contributions that should only appear in the agent sessions window (no 6. Use agent session part classes, not standard workbench parts 7. Mark views with `WindowVisibility.Sessions` so they only appear in this window -### 10.2 Layout Changes +### 10.2 Validating Changes + +1. Run `npm run compile-check-ts-native` to run a repo-wide TypeScript compilation check (including `src/vs/sessions/`). This is a fast way to catch TypeScript errors introduced by your changes. +2. Run `npm run valid-layers-check` to verify layering rules are not violated. +3. Use `scripts/test.sh` (or `scripts\test.bat` on Windows) for unit tests (add `--grep ` to filter tests) + +**Important** do not run `tsc` to check for TypeScript errors always use above methods to validate TypeScript changes in `src/vs/**`. + +### 10.3 Layout Changes 1. **Read `LAYOUT.md` first** — it's the authoritative spec 2. Use the `agent-sessions-layout` skill for detailed implementation guidance diff --git a/.github/workflows/api-proposal-version-check.yml b/.github/workflows/api-proposal-version-check.yml new file mode 100644 index 00000000000..23f6e052f9f --- /dev/null +++ b/.github/workflows/api-proposal-version-check.yml @@ -0,0 +1,270 @@ +name: API Proposal Version Check + +on: + pull_request: + branches: + - main + - 'release/*' + paths: + - 'src/vscode-dts/vscode.proposed.*.d.ts' + issue_comment: + types: [created] + +permissions: + contents: read + pull-requests: write + actions: write + +concurrency: + group: api-proposal-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +jobs: + check-version-changes: + name: Check API Proposal Version Changes + # Run on PR events, or on issue_comment if it's on a PR and contains the override command + if: false # temporarily disabled + # github.event_name == 'pull_request' || + # (github.event_name == 'issue_comment' && + # github.event.issue.pull_request && + # contains(github.event.comment.body, '/api-proposal-change-required')) + runs-on: ubuntu-latest + steps: + - name: Get PR info + id: pr_info + uses: actions/github-script@v7 + with: + script: | + let prNumber, headSha, baseSha; + + if (context.eventName === 'pull_request') { + prNumber = context.payload.pull_request.number; + headSha = context.payload.pull_request.head.sha; + baseSha = context.payload.pull_request.base.sha; + } else { + // issue_comment event - need to fetch PR details + prNumber = context.payload.issue.number; + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + headSha = pr.head.sha; + baseSha = pr.base.sha; + } + + core.setOutput('number', prNumber); + core.setOutput('head_sha', headSha); + core.setOutput('base_sha', baseSha); + + - name: Check for override comment + id: check_override + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ steps.pr_info.outputs.number }}; + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + // Only accept overrides from trusted users (repo members/collaborators) + const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; + const overrideComment = comments.find(comment => + comment.body.includes('/api-proposal-change-required') && + trustedAssociations.includes(comment.author_association) + ); + + if (overrideComment) { + console.log(`Override comment found by ${overrideComment.user.login} (${overrideComment.author_association})`); + core.setOutput('override_found', 'true'); + core.setOutput('override_user', overrideComment.user.login); + } else { + // Check if there's an override from an untrusted user + const untrustedOverride = comments.find(comment => + comment.body.includes('/api-proposal-change-required') && + !trustedAssociations.includes(comment.author_association) + ); + if (untrustedOverride) { + console.log(`Override comment by ${untrustedOverride.user.login} ignored (${untrustedOverride.author_association} is not trusted)`); + } + console.log('No valid override comment found'); + core.setOutput('override_found', 'false'); + } + + # If triggered by the override comment, re-run the failed workflow to update its status + # Only allow trusted users to trigger re-runs to prevent spam + - name: Re-run failed workflow on override + if: | + steps.check_override.outputs.override_found == 'true' && + github.event_name == 'issue_comment' && + (github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR') + uses: actions/github-script@v7 + with: + script: | + const headSha = '${{ steps.pr_info.outputs.head_sha }}'; + console.log(`Override comment found by ${{ steps.check_override.outputs.override_user }}`); + console.log('API proposal version change has been acknowledged.'); + + // Find the failed workflow run for this PR's head SHA + const { data: runs } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'api-proposal-version-check.yml', + head_sha: headSha, + status: 'completed', + per_page: 10 + }); + + // Find the most recent failed run + const failedRun = runs.workflow_runs.find(run => + run.conclusion === 'failure' && run.event === 'pull_request' + ); + + if (failedRun) { + console.log(`Re-running failed workflow run ${failedRun.id}`); + await github.rest.actions.reRunWorkflow({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: failedRun.id + }); + console.log('Workflow re-run triggered successfully'); + } else { + console.log('No failed pull_request workflow run found to re-run'); + // The check will pass on this run since override exists + } + + - name: Pass on override comment + if: steps.check_override.outputs.override_found == 'true' + run: | + echo "Override comment found by ${{ steps.check_override.outputs.override_user }}" + echo "API proposal version change has been acknowledged." + + # Only continue checking if no override found + - name: Checkout repository + if: steps.check_override.outputs.override_found != 'true' + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check for version changes + if: steps.check_override.outputs.override_found != 'true' + id: version_check + env: + HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }} + BASE_SHA: ${{ steps.pr_info.outputs.base_sha }} + run: | + set -e + + # Use merge-base to get accurate diff of what the PR actually changes + MERGE_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA") + echo "Merge base: $MERGE_BASE" + + # Get the list of changed proposed API files (diff against merge-base) + CHANGED_FILES=$(git diff --name-only "$MERGE_BASE" "$HEAD_SHA" -- 'src/vscode-dts/vscode.proposed.*.d.ts' || true) + + if [ -z "$CHANGED_FILES" ]; then + echo "No proposed API files changed" + echo "version_changed=false" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "Changed proposed API files:" + echo "$CHANGED_FILES" + + VERSION_CHANGED="false" + CHANGED_LIST="" + + for FILE in $CHANGED_FILES; do + # Check if file exists in head + if ! git cat-file -e "$HEAD_SHA:$FILE" 2>/dev/null; then + echo "File $FILE was deleted, skipping version check" + continue + fi + + # Get version from head (current PR) + HEAD_VERSION=$(git show "$HEAD_SHA:$FILE" | grep -E '^// version: [0-9]+' | sed 's/.*version: //' || echo "") + + # Get version from merge-base (what the PR is based on) + BASE_VERSION=$(git show "$MERGE_BASE:$FILE" 2>/dev/null | grep -E '^// version: [0-9]+' | sed 's/.*version: //' || echo "") + + echo "File: $FILE" + echo " Base version: ${BASE_VERSION:-'(none)'}" + echo " Head version: ${HEAD_VERSION:-'(none)'}" + + # Check if version was added or changed + if [ -n "$HEAD_VERSION" ] && [ "$HEAD_VERSION" != "$BASE_VERSION" ]; then + echo " -> Version changed!" + VERSION_CHANGED="true" + FILENAME=$(basename "$FILE") + if [ -n "$CHANGED_LIST" ]; then + CHANGED_LIST="$CHANGED_LIST, $FILENAME" + else + CHANGED_LIST="$FILENAME" + fi + fi + done + + echo "version_changed=$VERSION_CHANGED" >> $GITHUB_OUTPUT + echo "changed_files=$CHANGED_LIST" >> $GITHUB_OUTPUT + + - name: Post warning comment + if: steps.check_override.outputs.override_found != 'true' && steps.version_check.outputs.version_changed == 'true' + uses: actions/github-script@v7 + with: + script: | + const prNumber = ${{ steps.pr_info.outputs.number }}; + const changedFiles = '${{ steps.version_check.outputs.changed_files }}'; + + // Check if we already posted a warning comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber + }); + + const marker = ''; + const existingComment = comments.find(comment => + comment.body.includes(marker) + ); + + const body = `${marker} + ## ⚠️ API Proposal Version Change Detected + + The following proposed API files have version changes: **${changedFiles}** + + API proposal version changes should only be used when maintaining compatibility is not possible. Consider keeping the version as is and maintaining backward compatibility. + + **Any version changes must be adopted by the consuming extensions before the next insiders for the extension to work.** + + --- + + If the version change is required, comment \`/api-proposal-change-required\` to unblock this check and acknowledge that you will update any critical consuming extensions (Copilot Chat).`; + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: body + }); + console.log('Updated existing warning comment'); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: body + }); + console.log('Posted new warning comment'); + } + + - name: Fail if version changed without override + if: steps.check_override.outputs.override_found != 'true' && steps.version_check.outputs.version_changed == 'true' + run: | + echo "::error::API proposal version changed in: ${{ steps.version_check.outputs.changed_files }}" + echo "To unblock, comment '/api-proposal-change-required' on the PR." + exit 1 diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index c876d2a3782..56cd6e6ba2e 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -212,7 +212,7 @@ jobs: if: always() - name: Publish Crash Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -223,7 +223,7 @@ jobs: # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - name: Publish Node Modules - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -232,7 +232,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index df6ab20e586..7922ec107f9 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -258,7 +258,7 @@ jobs: if: always() - name: Publish Crash Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -269,7 +269,7 @@ jobs: # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - name: Publish Node Modules - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -278,7 +278,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index bd4a62d42fa..2bde317b480 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -249,7 +249,7 @@ jobs: if: always() - name: Publish Crash Reports - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -260,7 +260,7 @@ jobs: # In order to properly symbolify above crash reports # (if any), we need the compiled native modules too - name: Publish Node Modules - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() continue-on-error: true with: @@ -269,7 +269,7 @@ jobs: if-no-files-found: ignore - name: Publish Log Files - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: always() continue-on-error: true with: diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index decfcf2a6f8..c3107065279 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -18,6 +18,7 @@ concurrency: jobs: screenshots: + if: false # temporarily disabled name: Checking Component Screenshots runs-on: ubuntu-latest steps: @@ -73,14 +74,14 @@ jobs: fi - name: Upload explorer artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: component-explorer path: /tmp/explorer-artifact/ - name: Upload screenshot report if: steps.compare.outcome == 'failure' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: screenshot-diff path: | diff --git a/.gitignore b/.gitignore index 1e08dc59e25..e5ca4dd32cc 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,21 @@ test/componentFixtures/.screenshots/* !test/componentFixtures/.screenshots/baseline/ dist .playwright-cli +.agents/agents/*.local.md +.claude/agents/*.local.md +.github/agents/*.local.md +.agents/agents/*.local.agent.md +.claude/agents/*.local.agent.md +.github/agents/*.local.agent.md +.agents/hooks/*.local.json +.claude/hooks/*.local.json +.github/hooks/*.local.json +.agents/instructions/*.local.instructions.md +.claude/instructions/*.local.instructions.md +.github/instructions/*.local.instructions.md +.agents/prompts/*.local.prompt.md +.claude/prompts/*.local.prompt.md +.github/prompts/*.local.prompt.md +.agents/skills/.local/ +.claude/skills/.local/ +.github/skills/.local/ diff --git a/.npmrc b/.npmrc index b07eade64d5..a275846ab5c 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="39.6.0" -ms_build_id="13330601" +target="39.8.0" +ms_build_id="13470701" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts index df9abf863ae..8927b0b7064 100644 --- a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts +++ b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts @@ -197,26 +197,17 @@ export class NpmUpToDateFeature extends vscode.Disposable { return ''; } try { - return this._normalizeFileContent(path.join(this._root, file)); + const script = path.join(this._root, 'build', 'npm', 'installStateHash.ts'); + return cp.execFileSync(process.execPath, [script, '--normalize-file', path.join(this._root, file)], { + cwd: this._root, + timeout: 10_000, + encoding: 'utf8', + }); } catch { return ''; } } - private _normalizeFileContent(filePath: string): string { - const raw = fs.readFileSync(filePath, 'utf8'); - if (path.basename(filePath) === 'package.json') { - const json = JSON.parse(raw); - for (const key of NpmUpToDateFeature._packageJsonIgnoredKeys) { - delete json[key]; - } - return JSON.stringify(json, null, '\t') + '\n'; - } - return raw; - } - - private static readonly _packageJsonIgnoredKeys = ['distro']; - private _getChangedFiles(state: InstallState): { readonly label: string; readonly isFile: boolean }[] { if (!state.saved) { return [{ label: '(no postinstall state found)', isFile: false }]; diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts index 296ed1e9f12..09e9a2af6d2 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts @@ -545,6 +545,9 @@ export class SourceMapStore { } } + if (/^[a-zA-Z]:/.test(source) || source.startsWith('/')) { + return vscode.Uri.file(source); + } return vscode.Uri.parse(source); } diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index d3f716f5749..40235fad54e 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"February 2026\"" + "value": "$MILESTONE=milestone:\"1.111.0\"" }, { "kind": 1, diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index b58910ad675..f44d9c4a45b 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,12 +7,12 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"February 2026\"\n\n$MINE=assignee:@me" + "value": "$MILESTONE=milestone:\"1.111.0\"\n\n$MINE=assignee:@me" }, { "kind": 2, "language": "github-issues", - "value": "$NOT_TEAM_MEMBERS=-author:aeschli -author:alexdima -author:alexr00 -author:AmandaSilver -author:bamurtaugh -author:bpasero -author:chrmarti -author:Chuxel -author:claudiaregio -author:connor4312 -author:dbaeumer -author:deepak1556 -author:devinvalenciano -author:digitarald -author:DonJayamanne -author:egamma -author:fiveisprime -author:ntrogh -author:hediet -author:isidorn -author:joaomoreno -author:jrieken -author:kieferrm -author:lramos15 -author:lszomoru -author:meganrogge -author:misolori -author:mjbvz -author:rebornix -author:roblourens -author:rzhao271 -author:sandy081 -author:sbatten -author:stevencl -author:TylerLeonhardt -author:Tyriar -author:weinand -author:amunger -author:karthiknadig -author:eleanorjboyd -author:Yoyokrazy -author:ulugbekna -author:aiday-mar -author:bhavyaus -author:justschen -author:benibenj -author:luabud -author:anthonykim1 -author:joshspicer -author:osortega -author:hawkticehurst -author:pierceboggan -author:benvillalobos -author:dileepyavan -author:dineshc-msft -author:dmitrivMS -author:eli-w-king -author:jo-oikawa -author:jruales -author:jytjyt05 -author:kycutler -author:mrleemurray -author:pwang347 -author:vijayupadya -author:bryanchen-d -author:cwebster-99 -author:rwoll -author:lostintangent -author:jukasper -author:zhichli" + "value": "$NOT_TEAM_MEMBERS=-author:aeschli -author:alexdima -author:alexr00 -author:AmandaSilver -author:bamurtaugh -author:bpasero -author:chrmarti -author:Chuxel -author:claudiaregio -author:connor4312 -author:dbaeumer -author:deepak1556 -author:devinvalenciano -author:digitarald -author:DonJayamanne -author:egamma -author:fiveisprime -author:ntrogh -author:hediet -author:isidorn -author:joaomoreno -author:jrieken -author:kieferrm -author:lramos15 -author:lszomoru -author:meganrogge -author:mjbvz -author:rebornix -author:roblourens -author:rzhao271 -author:sandy081 -author:sbatten -author:stevencl -author:TylerLeonhardt -author:Tyriar -author:amunger -author:karthiknadig -author:eleanorjboyd -author:Yoyokrazy -author:ulugbekna -author:aiday-mar -author:bhavyaus -author:justschen -author:benibenj -author:luabud -author:anthonykim1 -author:joshspicer -author:osortega -author:hawkticehurst -author:pierceboggan -author:benvillalobos -author:dileepyavan -author:dmitrivMS -author:eli-w-king -author:jo-oikawa -author:jruales -author:jytjyt05 -author:kycutler -author:mrleemurray -author:pwang347 -author:vijayupadya -author:bryanchen-d -author:cwebster-99 -author:rwoll -author:lostintangent -author:jukasper -author:zhichli" }, { "kind": 1, diff --git a/.vscode/settings.json b/.vscode/settings.json index 74343459e02..bd45f6441fa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -209,4 +209,9 @@ "azureMcp.serverMode": "all", "azureMcp.readOnly": true, "debug.breakpointsView.presentation": "tree", + "chat.agentSkillsLocations": { + ".github/skills/.local": true, + ".agents/skills/.local": true, + ".claude/skills/.local": true, + } } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e950c75d912..e6bf967ddf0 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -225,8 +225,7 @@ "windows": { "command": ".\\scripts\\code.bat" }, - "problemMatcher": [], - "inSessions": true + "problemMatcher": [] }, { "label": "Run Dev Sessions", @@ -238,6 +237,18 @@ "args": [ "--sessions" ], + "problemMatcher": [] + }, + { + "label": "Run and Compile Dev Sessions", + "type": "shell", + "command": "npm run transpile-client && ./scripts/code.sh", + "windows": { + "command": "npm run transpile-client && .\\scripts\\code.bat" + }, + "args": [ + "--sessions" + ], "inSessions": true, "problemMatcher": [] }, diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 896b59001d6..0a15b3ff5fc 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -684,7 +684,7 @@ more details. --------------------------------------------------------- -go-syntax 0.8.5 - MIT +go-syntax 0.8.6 - MIT https://github.com/worlpaker/go-syntax MIT License diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 3df57a48a97..5d8343f42a9 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -1a1bb622d9788793310458b7bf9eedcea8347da9556dd1d7661b757c15ebfdd5 *chromedriver-v39.6.0-darwin-arm64.zip -c84565c127adeca567ca69e85bbd8f387fff1f83c09e69f6f851528f5602dc4e *chromedriver-v39.6.0-darwin-x64.zip -f50df11f99a2e3df84560d5331608cd0a9d7a147a1490f25edfd8a95531918a2 *chromedriver-v39.6.0-linux-arm64.zip -a571fd25e33f3b3bded91506732a688319d93eb652e959bb19a09cd3f67f9e5f *chromedriver-v39.6.0-linux-armv7l.zip -2a50751190bbfe07984f7d8cbf2f12c257a4c132a36922a78c4e320169b8f498 *chromedriver-v39.6.0-linux-x64.zip -cf6034c20b727c48a6f44bb87b1ec89fd4189f56200a32cd39cedaab3f19e007 *chromedriver-v39.6.0-mas-arm64.zip -d2107db701c41fa5f3aaa04c279275ac4dcffde4542c032c806939acd8c6cd6c *chromedriver-v39.6.0-mas-x64.zip -1593ed5550fa11c549fd4ff5baea5cb7806548bff15b79340343ac24a86d6de3 *chromedriver-v39.6.0-win32-arm64.zip -deee89cbeed935a57551294fbc59f6a346b76769e27dd78a59a35a82ae3037d9 *chromedriver-v39.6.0-win32-ia32.zip -f88a23ebc246ed2a506d6d172eb9ffbb4c9d285103285a735e359268fcd08895 *chromedriver-v39.6.0-win32-x64.zip -2e1ec8568f4fda21dc4bb7231cdb0427fa31bb03c4bc39f8aa36659894f2d23e *electron-api.json -03e743428685b44beeab9aa51bad7437387dc2ce299b94745ed8fb0923dd9a07 *electron-v39.6.0-darwin-arm64-dsym-snapshot.zip -723d64530286ebd58539bc29deb65e9334ae8450a714b075d369013b4bbfdce0 *electron-v39.6.0-darwin-arm64-dsym.zip -8f529fbbed8c386f3485614fa059ea9408ebe17d3f0c793269ea52ef3efdf8df *electron-v39.6.0-darwin-arm64-symbols.zip -dace1f9e5c49f4f63f32341f8b0fb7f16b8cf07ce5fcb17abcc0b33782966b8c *electron-v39.6.0-darwin-arm64.zip -e2425514469c4382be374e676edff6779ef98ca1c679b1500337fa58aa863e98 *electron-v39.6.0-darwin-x64-dsym-snapshot.zip -877e72afd7d8695e8a4420a74765d45c30fad30606d3dbab07a0e88fe600e3f6 *electron-v39.6.0-darwin-x64-dsym.zip -ae958c150c6fe76fc7989a28ddb6104851f15d2e24bd32fe60f51e308954a816 *electron-v39.6.0-darwin-x64-symbols.zip -bed88dac3ac28249a020397d83f3f61871c7eaea2099d5bf6b1e92878cb14f19 *electron-v39.6.0-darwin-x64.zip -a86e9470d6084611f38849c9f9b3311584393fa81b55d0bbf7e284a649b729cf *electron-v39.6.0-linux-arm64-debug.zip -e7d7aec3873a6d2f2c9fe406a27a8668910f8b4fdf55a36b5302d9db3ec390db *electron-v39.6.0-linux-arm64-symbols.zip -d6ded47a49046eb031800cf70f2b5d763ccac11dac64e70a874c62aaa115ccba *electron-v39.6.0-linux-arm64.zip -2bf6a75c9f3c2400698c325e48c9b6444d108e4d76544fb130d04605002ae084 *electron-v39.6.0-linux-armv7l-debug.zip -421d02c8a063602b22e4f16a2614fe6cc13e07f9d4ead309fe40aeac296fe951 *electron-v39.6.0-linux-armv7l-symbols.zip -ee34896d1317f1572ed4f3ed8eb1719f599f250d442fc6afb6ec40091c4f4cdc *electron-v39.6.0-linux-armv7l.zip -233f55caae4514144310928248a96bd3a3ce7ac6dc1ff99e7531737a579793b1 *electron-v39.6.0-linux-x64-debug.zip -eca69e741b00ce141b9c2e6e63c1f77cd834a85aa095385f032fdb58d3154fff *electron-v39.6.0-linux-x64-symbols.zip -94bf4bee48f3c657edffd4556abbe62556ca8225cbb4528d62eb858233a3c34b *electron-v39.6.0-linux-x64.zip -6dfebeb760627df74c65ff8da7088fb77e0ae222cab5590fea4cdd37c060ea06 *electron-v39.6.0-mas-arm64-dsym-snapshot.zip -b327d41507546799451a684b6061caed10f1c16ee39a7e686aac71187f8b7afe *electron-v39.6.0-mas-arm64-dsym.zip -02a56a9c3c3522ebc653f03ad88be9a2f46594c730a767a28e7322ddb7a789b7 *electron-v39.6.0-mas-arm64-symbols.zip -2fe93cd39521371bb5722c358feebadc5e79d79628b07a79a00a9d918e261de4 *electron-v39.6.0-mas-arm64.zip -f25ddc8a9b2b699d6d9e54fdf66220514e387ae36e45efeb4d8217b1462503f6 *electron-v39.6.0-mas-x64-dsym-snapshot.zip -6732026b6a3728bea928af0c5928bf82d565eebeb3f5dc5b6991639d27e7c457 *electron-v39.6.0-mas-x64-dsym.zip -5260dabf5b0fc369e0f69d3286fbcce9d67bc65e3364e17f7bb13dd49e320422 *electron-v39.6.0-mas-x64-symbols.zip -905f7cf95270afa92972b6c9242fc50c0afd65ffd475a81ded6033588f27a613 *electron-v39.6.0-mas-x64.zip -9204c9844e89f5ca0b32a8347cf9141d8dcb66671906e299afa06004f464d9b0 *electron-v39.6.0-win32-arm64-pdb.zip -6778c54d8cf7a0d305e4334501c3b877daf4737197187120ac18064f4e093b23 *electron-v39.6.0-win32-arm64-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-arm64-toolchain-profile.zip -22b96aca4cf8f7823b98e3b20b6131e521e0100c5cd03ab76f106eefbd0399cf *electron-v39.6.0-win32-arm64.zip -f5b69c8c1c9349a1f3b4309fb3fa1cf6326953e0807d2063fc27ba9f1400232e *electron-v39.6.0-win32-ia32-pdb.zip -1d6e103869acdeb0330b26ee08089667e0b5afc506efcd7021ba761ed8b786b5 *electron-v39.6.0-win32-ia32-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-ia32-toolchain-profile.zip -2b30e5bc923fff1443e2a4d1971cb9b26f61bd6a454cfbb991042457bab4d623 *electron-v39.6.0-win32-ia32.zip -5f93924c317206a2a4800628854e44e68662a9c40b3457c9e72690d6fff884d3 *electron-v39.6.0-win32-x64-pdb.zip -eab07439f0a21210cd560c1169c04ea5e23c6fe0ab65bd60cffce2b9f69fd36e *electron-v39.6.0-win32-x64-symbols.zip -efec460f92ff99a9d5970dd7a67fd0be5272989cfacc9389dec954c706b23f7d *electron-v39.6.0-win32-x64-toolchain-profile.zip -e8eee36be3bb85ba6fd8fcd26cf3a264bc946ac0717762c64e168896695c8e34 *electron-v39.6.0-win32-x64.zip -2e84c606e40c7bab5530e4c83bbf3a24c28143b0a768dafa5ecf78b18d889297 *electron.d.ts -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.6.0-darwin-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.6.0-darwin-x64.zip -52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.6.0-linux-arm64.zip -622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.6.0-linux-armv7l.zip -ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.6.0-linux-x64.zip -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.6.0-mas-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.6.0-mas-x64.zip -2a358c2dbeeb259c0b6a18057b52ffb0109de69112086cb2ce02f3a79bd70cee *ffmpeg-v39.6.0-win32-arm64.zip -4555510880a7b8dff5d5d0520f641665c62494689782adbed67fa0e24b45ae67 *ffmpeg-v39.6.0-win32-ia32.zip -091ab3c97d5a1cda1e04c6bd263a2c07ea63ed7ec3fd06600af6d7e23bbbbe15 *ffmpeg-v39.6.0-win32-x64.zip -650fb5fbc7e6cc27e5caeb016f72aba756469772bbfdfb3ec0b229f973d8ad46 *hunspell_dictionaries.zip -669ef1bf8ed0f6378e67f4f8bc23d2907d7cc1db7369dbdf468e164f4ef49365 *libcxx-objects-v39.6.0-linux-arm64.zip -996d81ad796524246144e15e22ffef75faff055a102c49021d70b03f039c3541 *libcxx-objects-v39.6.0-linux-armv7l.zip -1ffb610613c11169640fa76e4790137034a0deb3b48e2aef51a01c9b96b7700a *libcxx-objects-v39.6.0-linux-x64.zip -6dd8db57473992367c7914b50d06cae3a1b713cc09ceebecfcd4107df333e759 *libcxx_headers.zip -e5c18f813cc64a7d3b0404ee9adeb9cbb49e7ee5e1054b62c71fa7d1a448ad1b *libcxxabi_headers.zip -7f58d6e1d8c75b990f7d2259de8d0896414d0f2cff2f0fe4e5c7f8037d8fe879 *mksnapshot-v39.6.0-darwin-arm64.zip -be1178e4aa1f4910ba2b8f35b5655e12182657b9e32d509b47f0b2db033f0ac5 *mksnapshot-v39.6.0-darwin-x64.zip -5e36a594067fea08bb3d7bcd60873c3e240ebcee2208bcebfbc9f77d3075cc0d *mksnapshot-v39.6.0-linux-arm64-x64.zip -2db9196d2af0148ebb7b6f1f597f46a535b7af482f95739bd1ced78e1ebf39e7 *mksnapshot-v39.6.0-linux-armv7l-x64.zip -cd673e0a908fc950e0b4246e2b099018a8ee879d12a62973a01cb7de522f5bcf *mksnapshot-v39.6.0-linux-x64.zip -0749d8735a1fd8c666862cd7020b81317c45203d01319c9be089d1e750cb2c15 *mksnapshot-v39.6.0-mas-arm64.zip -81ae98e064485f8c6c69cd6c875ee72666c0cc801a8549620d382c2d0cea3b5c *mksnapshot-v39.6.0-mas-x64.zip -2e44f75df797922e7c8bad61a1b41fed14b070a54257a6a751892b2b8b9dfe29 *mksnapshot-v39.6.0-win32-arm64-x64.zip -fb5d73a8bf4b8db80f61b7073aa8458b5c46cce5c2a4b23591e851c6fcbd0144 *mksnapshot-v39.6.0-win32-ia32.zip -118ae88dbcd6b260cfa370e46ccfb0ab00af5efbf59495aaeea56a2831f604b2 *mksnapshot-v39.6.0-win32-x64.zip +d70954386008ad2c65d9849bb89955ab3c7dd08763256ae0d91d8604e8894d64 *chromedriver-v39.8.0-darwin-arm64.zip +2f6b654337133c13440aafdaf9e8b15f5ebb244e7d49f20977f03438e9bb8adb *chromedriver-v39.8.0-darwin-x64.zip +ef8681bb6b6af42cdf0e14c9ce188f035e01620781308c06cd3c6b922aaea2e6 *chromedriver-v39.8.0-linux-arm64.zip +c03fea6ac2b743d771407dc5f58809f44d2a885b1830b847957823cac2e7b222 *chromedriver-v39.8.0-linux-armv7l.zip +4bb7c6d9b3a7bfdd89edd0db98e63599ebf6dacdb888d5985bbb73f6153acc0c *chromedriver-v39.8.0-linux-x64.zip +aad1f6f970b5636d637c1c242766fbaa5bebe2707a605a38aadc7b40724b3d11 *chromedriver-v39.8.0-mas-arm64.zip +e89ebebe3a135d3ce40168152a0aabfd055b9fa6b118262a6df18405fd2ea433 *chromedriver-v39.8.0-mas-x64.zip +232e1a0460f6a59056499cccfff3265bf92eae22f20f02f2419e5e49552aaed7 *chromedriver-v39.8.0-win32-arm64.zip +ab92f46cc55da7c719175b50203c734781828389b8b3a1a535204bf0dc7d1296 *chromedriver-v39.8.0-win32-ia32.zip +a40eb521063e4ea6791ed4005815fa8ac259c1febc850246a83a47ce120121ce *chromedriver-v39.8.0-win32-x64.zip +d6a33b4c3c0de845ea23d1e2614c6c6d3bbe35b771bb63ae521c4db11373b021 *electron-api.json +5425323fdb23167870075e944ec6cf3ae383fbe45ad141d08b1d9689030ccd05 *electron-v39.8.0-darwin-arm64-dsym-snapshot.zip +aa32ab00ee58d8827cd53ca561b8c26b7cb7e2ad8cb0801acdda117ee728388e *electron-v39.8.0-darwin-arm64-dsym.zip +f94e589804a3394a4735543b888927be873f8f402899d0debe32a9dc570d6285 *electron-v39.8.0-darwin-arm64-symbols.zip +681d82c2ec6677ff0bf12f5bb1808b5a51dcbf10894bd0298641015119a3e04d *electron-v39.8.0-darwin-arm64.zip +a95e83b5cde762a37e64229e5669b0c19b95aac148689d96ca344535109eb983 *electron-v39.8.0-darwin-x64-dsym-snapshot.zip +8c989d8ca835ecdd93d49d9627f5548272c0ed03e263392b21ed287960b29e41 *electron-v39.8.0-darwin-x64-dsym.zip +b4b6fda9c5b9063a104318645aa29ef4738dd099da2b722e3e9b6dde5e098418 *electron-v39.8.0-darwin-x64-symbols.zip +ec53f2ba79498410323bb96a19ce98741bf28666cc9d83e07d11dadcc5506f38 *electron-v39.8.0-darwin-x64.zip +9141e64f9d4ea7f0e6a43ae364c8232a0dac79ecec44de2d4a0e5d688fbb742c *electron-v39.8.0-linux-arm64-debug.zip +5fac949d5331abaff0643dbcda7cc187e548cd4bf9d198c1ffc361383bfaa79f *electron-v39.8.0-linux-arm64-symbols.zip +c9db883fa671237fbc16256cf89aba55b9fcfbd9825fec32a6d57724a6446fe1 *electron-v39.8.0-linux-arm64.zip +b26ac10e84f6b7d338c13a38547aa66b5e9afbe2f1355b183ebc2ff8f428cfa9 *electron-v39.8.0-linux-armv7l-debug.zip +16c47c008a8783f6c8d6387fe01ea15425161befbf4211e4667bbdd6bb806ef0 *electron-v39.8.0-linux-armv7l-symbols.zip +b1b37fd450a5081a876c2b00b6ca007d454747a7d1d8f04feb16119d6ace94c6 *electron-v39.8.0-linux-armv7l.zip +1e8039cdf60b27785771c9e3f3c4c39fad37602bb0e6b75a30f83c57fdbef069 *electron-v39.8.0-linux-x64-debug.zip +ff9ca169c6e79649dd4c5a49a82a8d4b1761b62fbe14c15c61bf534381a9f653 *electron-v39.8.0-linux-x64-symbols.zip +854076cc4c63d6d6c320df1ca3f4bd7084ef9f9bb47c7b75d80feb2c2ed920b4 *electron-v39.8.0-linux-x64.zip +91bc313cbd009435552d8d5efff5d6ed0ff15465743c2629dac1cfe99ac34e4d *electron-v39.8.0-mas-arm64-dsym-snapshot.zip +974f10f80ec6c65f8d9f2ac1ccd8c4395bb34e24e2b09dc0ff80bd351099692e *electron-v39.8.0-mas-arm64-dsym.zip +b3878bc9198cff324b7c829ce2fbea7a4ee505f2f99b0bb3c11ac5e60651be59 *electron-v39.8.0-mas-arm64-symbols.zip +48dac99c757a850b0db7b38c1b95e08270f690a7ea1b58872e45308c2f7c8c93 *electron-v39.8.0-mas-arm64.zip +1a6e4df1092f89ed46833938d6dd1b3036640037bd09f0630a369ae386a7c872 *electron-v39.8.0-mas-x64-dsym-snapshot.zip +81425eb867527341af64c00726bd462957fec4d5f073922df891d830addbc5bc *electron-v39.8.0-mas-x64-dsym.zip +748ce154e894a27b117b46354cc288dc9442fade844c637b59fe1c1f3f7c625d *electron-v39.8.0-mas-x64-symbols.zip +91f8f7d4eb1a42ac4fa0eaa93034c8e6155ccb50718f9f55541ce2be4a4ed6d0 *electron-v39.8.0-mas-x64.zip +b775b7584afb84e52b0a770e1e63a2f17384b66eeebe845e0c5c82beacaf7e93 *electron-v39.8.0-win32-arm64-pdb.zip +ac62373d11ed682b4fcdae27de2bd72ebf7d46d3b569f5fcf242de01786d0948 *electron-v39.8.0-win32-arm64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.0-win32-arm64-toolchain-profile.zip +08b79fa5deabbcace447f1e15eb99b3b117b42a84b71ad5b0f52d2da68a34192 *electron-v39.8.0-win32-arm64.zip +f4fb798d76a0c2f80717ef1607571537dbbb07f1cc5f177048bcfd17046c2255 *electron-v39.8.0-win32-ia32-pdb.zip +37c1d2988793604294724b648589fca6459472021189abab1550d5e1eecff1a7 *electron-v39.8.0-win32-ia32-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.0-win32-ia32-toolchain-profile.zip +59b70a12abedb550795614bc74c5803787e824da3529a631fdb5c2b5aad00196 *electron-v39.8.0-win32-ia32.zip +0357c6fb0d7198c45cba0e8c939473ea1d971e1efe801bc84e2c559141b368e7 *electron-v39.8.0-win32-x64-pdb.zip +8e6f4e8516d15aecde5244beac315067c13513c7074383086523eef2638a5e8d *electron-v39.8.0-win32-x64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.0-win32-x64-toolchain-profile.zip +9edc111b22aee1a0efb5103d6d3b48645af57b48214eeb48f75f9edfc3e271d6 *electron-v39.8.0-win32-x64.zip +b6eca0e05fcff2464382278dff52367f6f21eb1a580dd8a0a954fc16397ab085 *electron.d.ts +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.0-darwin-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.0-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.0-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.0-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.0-linux-x64.zip +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.0-mas-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.0-mas-x64.zip +3ba7c7507181e0d4836f70f3d8800b4e9ba379e1086e9e89fda7ff9b3b9ad2cb *ffmpeg-v39.8.0-win32-arm64.zip +f37e7d51b8403e2ed8ca192bc6ae759cf63d80010e747b15eeb7120b575578b2 *ffmpeg-v39.8.0-win32-ia32.zip +b252e232438010f9683e8fd10c3bf0631df78e42a6ae11d6cb7aa7e6ac11185f *ffmpeg-v39.8.0-win32-x64.zip +365735192f58a7f7660100227ec348ba3df604415ff5264b54d93cb6cf5f6f6f *hunspell_dictionaries.zip +6384ee31daa39de4dd4bd3aa225cdb14cdddb7f463a2c1663b38a79e122a13e2 *libcxx-objects-v39.8.0-linux-arm64.zip +9748b3272e52a8274fe651def2d6ae2dad7a3771b520dd105f46f4020ba9d63b *libcxx-objects-v39.8.0-linux-armv7l.zip +74d47a155ecc6c2054418c7c3e0540f32b983ebdc65e8b4ea5d3e257d29b3f4f *libcxx-objects-v39.8.0-linux-x64.zip +c0755fbb84011664bd36459fc6e06a603078dccd3b7b260f6ed6ad1d409f79f7 *libcxx_headers.zip +3ea41e9bd56e8f52ab8562c1406ba9416abe3993640935e981cbbd77c0f2654b *libcxxabi_headers.zip +befcd6067f35d911a6a87b927e79dc531cb7bea39e85f86a65e9ab82ef0cece1 *mksnapshot-v39.8.0-darwin-arm64.zip +f0e692655298ffed60630c3e6490ced69e9d8726e85bcaecfa34485f3a991469 *mksnapshot-v39.8.0-darwin-x64.zip +d5d0901cd1eafdf921d2a0d1565829cf60f454a71ce74fa60db98780fd8a1a96 *mksnapshot-v39.8.0-linux-arm64-x64.zip +1bc0a3294d258a59846aa5c5359cd8b0f43831ebd7c3e1dde9a6cfaa39d845bf *mksnapshot-v39.8.0-linux-armv7l-x64.zip +4e414dbe75f460cb34508608db984aa6f4d274f333fa327a3d631da4a516da8f *mksnapshot-v39.8.0-linux-x64.zip +c51c86e3a11ad75fb4f7559798f6d64ec7def19583c96ce08de7ee5796568841 *mksnapshot-v39.8.0-mas-arm64.zip +6544d1e93adea1e9a694f9b9f539d96f84df647d9c9319b29d4fc88751ff9075 *mksnapshot-v39.8.0-mas-x64.zip +372b4685c53f19ccc72c33d78c1283d9389c72f42cd48224439fe4f89199caa0 *mksnapshot-v39.8.0-win32-arm64-x64.zip +199e9244f4522a4a02aece09a6a33887b24d7ec837640d39c930170e4b3caa57 *mksnapshot-v39.8.0-win32-ia32.zip +970e979e7a8b70f300f7854cb571756d9049bc42b44a6153a9ce3a18e1a83243 *mksnapshot-v39.8.0-win32-x64.zip diff --git a/build/darwin/dmg-settings.py.template b/build/darwin/dmg-settings.py.template index 4a54a69ab02..f471029f32a 100644 --- a/build/darwin/dmg-settings.py.template +++ b/build/darwin/dmg-settings.py.template @@ -6,8 +6,9 @@ format = 'ULMO' badge_icon = {{BADGE_ICON}} background = {{BACKGROUND}} -# Volume size (None = auto-calculate) -size = None +# Volume size +size = '1g' +shrink = False # Files and symlinks files = [{{APP_PATH}}] diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 0dfb90f264b..0912be85425 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -100,6 +100,7 @@ const vscodeResourceIncludes = [ // Sessions 'out-build/vs/sessions/contrib/chat/browser/media/*.svg', + 'out-build/vs/sessions/prompts/*.prompt.md', // Extensions 'out-build/vs/workbench/contrib/extensions/browser/media/{theme-icon.png,language-icon.svg}', @@ -237,6 +238,9 @@ function runTsGoTypeCheck(): Promise { } const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; +const isCI = !!process.env['CI'] || !!process.env['BUILD_ARTIFACTSTAGINGDIRECTORY'] || !!process.env['GITHUB_WORKSPACE']; +const useCdnSourceMapsForPackagingTasks = isCI; +const stripSourceMapsInPackagingTasks = isCI; const minifyVSCodeTask = task.define('minify-vscode', task.series( bundleVSCodeTask, util.rimraf('out-vscode-min'), @@ -349,8 +353,11 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const extensions = gulp.src(['.build/extensions/**', ...platformSpecificBuiltInExtensionsExclusions], { base: '.build', dot: true }); + const sourceFilterPattern = stripSourceMapsInPackagingTasks + ? ['**', '!**/*.{js,css}.map'] + : ['**']; const sources = es.merge(src, extensions) - .pipe(filter(['**', '!**/*.{js,css}.map'], { dot: true })); + .pipe(filter(sourceFilterPattern, { dot: true })); let version = packageJson.version; const quality = (product as { quality?: string }).quality; @@ -420,8 +427,13 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d const productionDependencies = getProductionDependencies(root); const dependenciesSrc = productionDependencies.map(d => path.relative(root, d)).map(d => [`${d}/**`, `!${d}/**/{test,tests}/**`]).flat().concat('!**/*.mk'); + const depFilterPattern = ['**', `!**/${config.version}/**`, '!**/bin/darwin-arm64-87/**', '!**/package-lock.json', '!**/yarn.lock']; + if (stripSourceMapsInPackagingTasks) { + depFilterPattern.push('!**/*.{js,css}.map'); + } + const deps = gulp.src(dependenciesSrc, { base: '.', dot: true }) - .pipe(filter(['**', `!**/${config.version}/**`, '!**/bin/darwin-arm64-87/**', '!**/package-lock.json', '!**/yarn.lock', '!**/*.{js,css}.map'])) + .pipe(filter(depFilterPattern)) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))) .pipe(jsFilter) @@ -519,6 +531,11 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d darwinMiniAppName: embedded.nameShort, darwinMiniAppBundleIdentifier: embedded.darwinBundleIdentifier, darwinMiniAppIcon: 'resources/darwin/sessions.icns', + darwinMiniAppBundleURLTypes: [{ + role: 'Viewer', + name: embedded.nameLong, + urlSchemes: [embedded.urlProtocol] + }], win32ProxyAppName: embedded.nameShort, win32ProxyIcon: 'resources/win32/sessions.ico', } : {}) @@ -533,7 +550,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d '**', '!LICENSE', '!version', - ...(platform === 'darwin' && !isInsiderOrExploration ? ['!**/Contents/Applications'] : []), + ...(platform === 'darwin' && !isInsiderOrExploration ? ['!**/Contents/Applications', '!**/Contents/Applications/**'] : []), ...(platform === 'win32' && !isInsiderOrExploration ? ['!**/electron_proxy.exe'] : []), ], { dot: true })); @@ -583,6 +600,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d } result = es.merge(result, gulp.src('resources/win32/VisualElementsManifest.xml', { base: 'resources/win32' }) + .pipe(replace('@@VERSIONFOLDER@@', versionedResourcesFolder ? `${versionedResourcesFolder}\\` : '')) .pipe(rename(product.nameShort + '.VisualElementsManifest.xml'))); result = es.merge(result, gulp.src('.build/policies/win32/**', { base: '.build/policies/win32' }) @@ -696,7 +714,13 @@ BUILD_TARGETS.forEach(buildTarget => { if (useEsbuildTranspile) { const esbuildBundleTask = task.define( `esbuild-bundle${dashed(platform)}${dashed(arch)}${dashed(minified)}`, - () => runEsbuildBundle(sourceFolderName, !!minified, true, 'desktop', minified ? `${sourceMappingURLBase}/core` : undefined) + () => runEsbuildBundle( + sourceFolderName, + !!minified, + true, + 'desktop', + minified && useCdnSourceMapsForPackagingTasks ? `${sourceMappingURLBase}/core` : undefined + ) ); vscodeTask = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( copyCodiconsTask, diff --git a/build/gulpfile.vscode.web.ts b/build/gulpfile.vscode.web.ts index e9cc3720fcf..3e6b29adfe9 100644 --- a/build/gulpfile.vscode.web.ts +++ b/build/gulpfile.vscode.web.ts @@ -33,7 +33,7 @@ const quality = (product as { quality?: string }).quality; const version = (quality && quality !== 'stable') ? `${packageJson.version}-${quality}` : packageJson.version; // esbuild-based bundle for standalone web -function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean): Promise { +function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean, sourceMapBaseUrl?: string): Promise { return new Promise((resolve, reject) => { const scriptPath = path.join(REPO_ROOT, 'build/next/index.ts'); const args = [scriptPath, 'bundle', '--out', outDir, '--target', 'web']; @@ -44,6 +44,9 @@ function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean): Promis if (nls) { args.push('--nls'); } + if (sourceMapBaseUrl) { + args.push('--source-map-base-url', sourceMapBaseUrl); + } const proc = cp.spawn(process.execPath, args, { cwd: REPO_ROOT, @@ -164,8 +167,9 @@ const minifyVSCodeWebTask = task.define('minify-vscode-web-OLD', task.series( gulp.task(minifyVSCodeWebTask); // esbuild-based tasks (new) +const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; const esbuildBundleVSCodeWebTask = task.define('esbuild-vscode-web', () => runEsbuildBundle('out-vscode-web', false, true)); -const esbuildBundleVSCodeWebMinTask = task.define('esbuild-vscode-web-min', () => runEsbuildBundle('out-vscode-web-min', true, true)); +const esbuildBundleVSCodeWebMinTask = task.define('esbuild-vscode-web-min', () => runEsbuildBundle('out-vscode-web-min', true, true, `${sourceMappingURLBase}/core`)); function packageTask(sourceFolderName: string, destinationFolderName: string) { const destination = path.join(BUILD_ROOT, destinationFolderName); diff --git a/build/gulpfile.vscode.win32.ts b/build/gulpfile.vscode.win32.ts index 1f525cff35a..0f81323c98d 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -113,7 +113,7 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { Quality: quality }; - const isInsiderOrExploration = false; + const isInsiderOrExploration = quality === 'insider' || quality === 'exploration'; const embedded = isInsiderOrExploration ? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded : undefined; diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 2ea27460de9..f58dda70afa 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -169,6 +169,20 @@ "type": "boolean", "default": true }, + { + "key": "chat.editMode.hidden", + "name": "DeprecatedEditModeHidden", + "category": "InteractiveSession", + "minimumVersion": "1.112", + "localization": { + "description": { + "key": "chat.editMode.hidden", + "value": "When enabled, hides the Edit mode from the chat mode picker." + } + }, + "type": "boolean", + "default": true + }, { "key": "chat.useHooks", "name": "ChatHooks", diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 9e2eea3b858..5b0fc9b6ad5 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -36,6 +36,7 @@ "--vscode-breadcrumb-focusForeground", "--vscode-breadcrumb-foreground", "--vscode-breadcrumbPicker-background", + "--vscode-browser-border", "--vscode-button-background", "--vscode-button-border", "--vscode-button-foreground", @@ -948,6 +949,7 @@ "--testMessageDecorationFontSize", "--title-border-bottom-color", "--title-wco-width", + "--update-progress", "--reveal-button-size", "--part-background", "--part-border-color", @@ -971,6 +973,14 @@ "--vscode-repl-line-height", "--vscode-sash-hover-size", "--vscode-sash-size", + "--vscode-shadow-active-tab", + "--vscode-shadow-depth-x", + "--vscode-shadow-depth-y", + "--vscode-shadow-hover", + "--vscode-shadow-lg", + "--vscode-shadow-md", + "--vscode-shadow-sm", + "--vscode-shadow-xl", "--vscode-testing-coverage-lineHeight", "--vscode-editorStickyScroll-scrollableWidth", "--vscode-editorStickyScroll-foldingOpacityTransition", @@ -1001,6 +1011,7 @@ "--text-link-decoration", "--vscode-action-item-auto-timeout", "--monaco-editor-warning-decoration", + "--animation-angle", "--animation-opacity", "--chat-setup-dialog-glow-angle", "--vscode-chat-font-family", diff --git a/build/next/index.ts b/build/next/index.ts index 77886ad43a9..a77b98b5c63 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -273,6 +273,9 @@ const desktopResourcePatterns = [ 'vs/workbench/services/extensionManagement/common/media/*.png', 'vs/workbench/browser/parts/editor/media/*.png', 'vs/workbench/contrib/debug/browser/media/*.png', + + // Sessions - built-in prompts + 'vs/sessions/prompts/*.prompt.md', ]; // Resources for server target (minimal - no UI) @@ -897,6 +900,13 @@ ${tslib}`, const mangleStats: { file: string; result: ConvertPrivateFieldsResult }[] = []; // Map from JS file path to pre-mangle content + edits, for source map adjustment const mangleEdits = new Map(); + // Map from JS file path to pre-NLS content + edits, for source map adjustment + const nlsEdits = new Map(); + // Defer .map files until all .js files are processed, because esbuild may + // emit the .map file in a different build result than the .js file (e.g. + // code-split chunks), and we need the NLS/mangle edits from the .js pass + // to be available when adjusting the .map. + const deferredMaps: { path: string; text: string; contents: Uint8Array }[] = []; for (const { result } of buildResults) { if (!result.outputFiles) { continue; @@ -925,7 +935,12 @@ ${tslib}`, // Apply NLS post-processing if enabled (JS only) if (file.path.endsWith('.js') && doNls && indexMap.size > 0) { - content = postProcessNLS(content, indexMap, preserveEnglish); + const preNLSCode = content; + const nlsResult = postProcessNLS(content, indexMap, preserveEnglish); + content = nlsResult.code; + if (nlsResult.edits.length > 0) { + nlsEdits.set(file.path, { preNLSCode, edits: nlsResult.edits }); + } } // Rewrite sourceMappingURL to CDN URL if configured @@ -943,16 +958,8 @@ ${tslib}`, await fs.promises.writeFile(file.path, content); } else if (file.path.endsWith('.map')) { - // Source maps may need adjustment if private fields were mangled - const jsPath = file.path.replace(/\.map$/, ''); - const editInfo = mangleEdits.get(jsPath); - if (editInfo) { - const mapJson = JSON.parse(file.text); - const adjusted = adjustSourceMap(mapJson, editInfo.preMangleCode, editInfo.edits); - await fs.promises.writeFile(file.path, JSON.stringify(adjusted)); - } else { - await fs.promises.writeFile(file.path, file.contents); - } + // Defer .map processing until all .js files have been handled + deferredMaps.push({ path: file.path, text: file.text, contents: file.contents }); } else { // Write other files (assets, etc.) as-is await fs.promises.writeFile(file.path, file.contents); @@ -961,6 +968,27 @@ ${tslib}`, bundled++; } + // Second pass: process deferred .map files now that all mangle/NLS edits + // have been collected from .js processing above. + for (const mapFile of deferredMaps) { + const jsPath = mapFile.path.replace(/\.map$/, ''); + const mangle = mangleEdits.get(jsPath); + const nls = nlsEdits.get(jsPath); + + if (mangle || nls) { + let mapJson = JSON.parse(mapFile.text); + if (mangle) { + mapJson = adjustSourceMap(mapJson, mangle.preMangleCode, mangle.edits); + } + if (nls) { + mapJson = adjustSourceMap(mapJson, nls.preNLSCode, nls.edits); + } + await fs.promises.writeFile(mapFile.path, JSON.stringify(mapJson)); + } else { + await fs.promises.writeFile(mapFile.path, mapFile.contents); + } + } + // Log mangle-privates stats if (doManglePrivates && mangleStats.length > 0) { let totalClasses = 0, totalFields = 0, totalEdits = 0, totalElapsed = 0; diff --git a/build/next/nls-plugin.ts b/build/next/nls-plugin.ts index 7be3faccf24..e2b19f7d7f1 100644 --- a/build/next/nls-plugin.ts +++ b/build/next/nls-plugin.ts @@ -12,6 +12,7 @@ import { analyzeLocalizeCalls, parseLocalizeKeyOrValue } from '../lib/nls-analysis.ts'; +import type { TextEdit } from './private-to-property.ts'; // ============================================================================ // Types @@ -148,12 +149,13 @@ export async function finalizeNLS( /** * Post-processes a JavaScript file to replace NLS placeholders with indices. + * Returns the transformed code and the edits applied (for source map adjustment). */ export function postProcessNLS( content: string, indexMap: Map, preserveEnglish: boolean -): string { +): { code: string; edits: readonly TextEdit[] } { return replaceInOutput(content, indexMap, preserveEnglish); } @@ -244,7 +246,7 @@ function generateNLSSourceMap( const generator = new SourceMapGenerator(); generator.setSourceContent(filePath, originalSource); - const lineCount = originalSource.split('\n').length; + const lines = originalSource.split('\n'); // Group edits by line const editsByLine = new Map(); @@ -257,7 +259,7 @@ function generateNLSSourceMap( arr.push(edit); } - for (let line = 0; line < lineCount; line++) { + for (let line = 0; line < lines.length; line++) { const smLine = line + 1; // source maps use 1-based lines // Always map start of line @@ -273,7 +275,8 @@ function generateNLSSourceMap( let cumulativeShift = 0; - for (const edit of lineEdits) { + for (let i = 0; i < lineEdits.length; i++) { + const edit = lineEdits[i]; const origLen = edit.endCol - edit.startCol; // Map start of edit: the replacement begins at the same original position @@ -285,12 +288,20 @@ function generateNLSSourceMap( cumulativeShift += edit.newLength - origLen; - // Map content after edit: columns resume with the shift applied - generator.addMapping({ - generated: { line: smLine, column: edit.endCol + cumulativeShift }, - original: { line: smLine, column: edit.endCol }, - source: filePath, - }); + // Source maps don't interpolate columns — each query resolves to the + // last segment with generatedColumn <= queryColumn. A single mapping + // at edit-end would cause every subsequent column on this line to + // collapse to that one original position. Add per-column identity + // mappings from edit-end to the next edit (or end of line) so that + // esbuild's source-map composition preserves fine-grained accuracy. + const nextBound = i + 1 < lineEdits.length ? lineEdits[i + 1].startCol : lines[line].length; + for (let origCol = edit.endCol; origCol < nextBound; origCol++) { + generator.addMapping({ + generated: { line: smLine, column: origCol + cumulativeShift }, + original: { line: smLine, column: origCol }, + source: filePath, + }); + } } } } @@ -302,17 +313,19 @@ function replaceInOutput( content: string, indexMap: Map, preserveEnglish: boolean -): string { - // Replace all placeholders in a single pass using regex - // Two types of placeholders: - // - %%NLS:moduleId#key%% for localize() - message replaced with null - // - %%NLS2:moduleId#key%% for localize2() - message preserved - // Note: esbuild may use single or double quotes, so we handle both +): { code: string; edits: readonly TextEdit[] } { + // Collect all matches first, then apply from back to front so that byte + // offsets remain valid. Each match becomes a TextEdit in terms of the + // ORIGINAL content offsets, which is what adjustSourceMap expects. + + interface PendingEdit { start: number; end: number; replacement: string } + const pending: PendingEdit[] = []; if (preserveEnglish) { - // Just replace the placeholder with the index (both NLS and NLS2) - return content.replace(/["']%%NLS2?:([^%]+)%%["']/g, (match, inner) => { - // Try NLS first, then NLS2 + const re = /["']%%NLS2?:([^%]+)%%["']/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + const inner = m[1]; let placeholder = `%%NLS:${inner}%%`; let index = indexMap.get(placeholder); if (index === undefined) { @@ -320,45 +333,60 @@ function replaceInOutput( index = indexMap.get(placeholder); } if (index !== undefined) { - return String(index); + pending.push({ start: m.index, end: m.index + m[0].length, replacement: String(index) }); } - // Placeholder not found in map, leave as-is (shouldn't happen) - return match; - }); + } } else { - // For NLS (localize): replace placeholder with index AND replace message with null - // For NLS2 (localize2): replace placeholder with index, keep message - // Note: Use (?:[^"\\]|\\.)* to properly handle escaped quotes like \" or \\ - // Note: esbuild may use single or double quotes, so we handle both - - // First handle NLS (localize) - replace both key and message - content = content.replace( - /["']%%NLS:([^%]+)%%["'](\s*,\s*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g, - (match, inner, comma) => { - const placeholder = `%%NLS:${inner}%%`; - const index = indexMap.get(placeholder); - if (index !== undefined) { - return `${index}${comma}null`; - } - return match; + // NLS (localize): replace placeholder with index AND replace message with null + const reNLS = /["']%%NLS:([^%]+)%%["'](\s*,\s*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g; + let m: RegExpExecArray | null; + while ((m = reNLS.exec(content)) !== null) { + const inner = m[1]; + const comma = m[2]; + const placeholder = `%%NLS:${inner}%%`; + const index = indexMap.get(placeholder); + if (index !== undefined) { + pending.push({ start: m.index, end: m.index + m[0].length, replacement: `${index}${comma}null` }); } - ); + } - // Then handle NLS2 (localize2) - replace only key, keep message - content = content.replace( - /["']%%NLS2:([^%]+)%%["']/g, - (match, inner) => { - const placeholder = `%%NLS2:${inner}%%`; - const index = indexMap.get(placeholder); - if (index !== undefined) { - return String(index); - } - return match; + // NLS2 (localize2): replace only key, keep message + const reNLS2 = /["']%%NLS2:([^%]+)%%["']/g; + while ((m = reNLS2.exec(content)) !== null) { + const inner = m[1]; + const placeholder = `%%NLS2:${inner}%%`; + const index = indexMap.get(placeholder); + if (index !== undefined) { + pending.push({ start: m.index, end: m.index + m[0].length, replacement: String(index) }); } - ); - - return content; + } } + + if (pending.length === 0) { + return { code: content, edits: [] }; + } + + // Sort by offset ascending, then apply back-to-front to keep offsets valid + pending.sort((a, b) => a.start - b.start); + + // Build TextEdit[] (in original-content coordinates) and apply edits + const edits: TextEdit[] = []; + for (const p of pending) { + edits.push({ start: p.start, end: p.end, newText: p.replacement }); + } + + // Apply edits using forward-scanning parts array — O(N+K) instead of + // O(N*K) from repeated substring concatenation on large strings. + const parts: string[] = []; + let lastEnd = 0; + for (const p of pending) { + parts.push(content.substring(lastEnd, p.start)); + parts.push(p.replacement); + lastEnd = p.end; + } + parts.push(content.substring(lastEnd)); + + return { code: parts.join(''), edits }; } // ============================================================================ @@ -399,7 +427,11 @@ export function nlsPlugin(options: NLSPluginOptions): esbuild.Plugin { // back to the original. Embed it inline so esbuild composes it // with its own bundle source map, making the final map point to // the original TS source. - const sourceName = relativePath.replace(/\\/g, '/'); + // This inline source map is resolved relative to esbuild's sourcefile + // for args.path. Using the full repo-relative path here makes esbuild + // resolve it against the file's own directory, which duplicates the + // directory segments in the final bundled source map. + const sourceName = path.basename(args.path); const sourcemap = generateNLSSourceMap(source, sourceName, edits); const encodedMap = Buffer.from(sourcemap).toString('base64'); const contentsWithMap = code + `\n//# sourceMappingURL=data:application/json;base64,${encodedMap}\n`; diff --git a/build/next/private-to-property.ts b/build/next/private-to-property.ts index 11f977774a5..98ff98a6440 100644 --- a/build/next/private-to-property.ts +++ b/build/next/private-to-property.ts @@ -220,15 +220,53 @@ export function adjustSourceMap( return sourceMapJson; } - // Build a line-offset table for the original code to convert byte offsets to line/column - const lineStarts: number[] = [0]; - for (let i = 0; i < originalCode.length; i++) { - if (originalCode.charCodeAt(i) === 10 /* \n */) { - lineStarts.push(i + 1); - } + // Build line-offset tables for the original code and the code after edits. + // When edits span newlines (e.g. NLS replacing a multi-line template literal + // with `null`), subsequent lines shift up and columns change. We handle this + // by converting each mapping's old generated (line, col) to a byte offset, + // adjusting the offset for the edits, then converting back to (line, col) in + // the post-edit coordinate system. + + const oldLineStarts = buildLineStarts(originalCode); + const newLineStarts = buildLineStartsAfterEdits(originalCode, edits); + + // Precompute cumulative byte-shift after each edit for binary search + const n = edits.length; + const editStarts: number[] = new Array(n); + const editEnds: number[] = new Array(n); + const cumShifts: number[] = new Array(n); // cumulative shift *after* edit[i] + let cumShift = 0; + for (let i = 0; i < n; i++) { + editStarts[i] = edits[i].start; + editEnds[i] = edits[i].end; + cumShift += edits[i].newText.length - (edits[i].end - edits[i].start); + cumShifts[i] = cumShift; } - function offsetToLineCol(offset: number): { line: number; col: number } { + function adjustOffset(oldOff: number): number { + // Binary search: find last edit with start <= oldOff + let lo = 0, hi = n - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (editStarts[mid] <= oldOff) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + // hi = index of last edit where start <= oldOff, or -1 if none + if (hi < 0) { + return oldOff; + } + if (oldOff < editEnds[hi]) { + // Inside edit range — clamp to edit start in new coordinates + const prevShift = hi > 0 ? cumShifts[hi - 1] : 0; + return editStarts[hi] + prevShift; + } + return oldOff + cumShifts[hi]; + } + + function offsetToLineCol(lineStarts: readonly number[], offset: number): { line: number; col: number } { let lo = 0, hi = lineStarts.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; @@ -241,23 +279,9 @@ export function adjustSourceMap( return { line: lo, col: offset - lineStarts[lo] }; } - // Convert edits from byte offsets to per-line column shifts - interface LineEdit { col: number; origLen: number; newLen: number } - const editsByLine = new Map(); - for (const edit of edits) { - const pos = offsetToLineCol(edit.start); - const origLen = edit.end - edit.start; - let arr = editsByLine.get(pos.line); - if (!arr) { - arr = []; - editsByLine.set(pos.line, arr); - } - arr.push({ col: pos.col, origLen, newLen: edit.newText.length }); - } - // Use source-map library to read, adjust, and write const consumer = new SourceMapConsumer(sourceMapJson); - const generator = new SourceMapGenerator({ file: sourceMapJson.file }); + const generator = new SourceMapGenerator({ file: sourceMapJson.file, sourceRoot: sourceMapJson.sourceRoot }); // Copy sourcesContent for (let i = 0; i < sourceMapJson.sources.length; i++) { @@ -267,15 +291,19 @@ export function adjustSourceMap( } } - // Walk every mapping, adjust the generated column, and add to the new generator + // Walk every mapping, convert old generated position → byte offset → adjust → new position consumer.eachMapping(mapping => { - const lineEdits = editsByLine.get(mapping.generatedLine - 1); // 0-based for our data - const adjustedCol = adjustColumn(mapping.generatedColumn, lineEdits); + const oldLine0 = mapping.generatedLine - 1; // 0-based + const oldOff = (oldLine0 < oldLineStarts.length + ? oldLineStarts[oldLine0] + : oldLineStarts[oldLineStarts.length - 1]) + mapping.generatedColumn; + + const newOff = adjustOffset(oldOff); + const newPos = offsetToLineCol(newLineStarts, newOff); - // Some mappings may be unmapped (no original position/source) - skip those. if (mapping.source !== null && mapping.originalLine !== null && mapping.originalColumn !== null) { const newMapping: Mapping = { - generated: { line: mapping.generatedLine, column: adjustedCol }, + generated: { line: newPos.line + 1, column: newPos.col }, original: { line: mapping.originalLine, column: mapping.originalColumn }, source: mapping.source, }; @@ -283,25 +311,82 @@ export function adjustSourceMap( newMapping.name = mapping.name; } generator.addMapping(newMapping); + } else { + // Preserve unmapped segments (generated-only mappings with no original + // position). These create essential "gaps" that prevent + // originalPositionFor() from wrongly interpolating between distant + // valid mappings on the same line in minified output. + // eslint-disable-next-line local/code-no-dangerous-type-assertions + generator.addMapping({ + generated: { line: newPos.line + 1, column: newPos.col }, + } as Mapping); } }); return JSON.parse(generator.toString()); } -function adjustColumn(col: number, lineEdits: { col: number; origLen: number; newLen: number }[] | undefined): number { - if (!lineEdits) { - return col; - } - let shift = 0; - for (const edit of lineEdits) { - if (edit.col + edit.origLen <= col) { - shift += edit.newLen - edit.origLen; - } else if (edit.col < col) { - return edit.col + shift; - } else { +function buildLineStarts(text: string): number[] { + const starts: number[] = [0]; + let pos = 0; + while (true) { + const nl = text.indexOf('\n', pos); + if (nl === -1) { break; } + starts.push(nl + 1); + pos = nl + 1; } - return col + shift; + return starts; +} + +/** + * Compute line starts for the code that results from applying `edits` to + * `originalCode`, without materialising the full new string. + */ +function buildLineStartsAfterEdits(originalCode: string, edits: readonly TextEdit[]): number[] { + const starts: number[] = [0]; + let oldPos = 0; + let newPos = 0; + + for (const edit of edits) { + // Scan unchanged region [oldPos, edit.start) for newlines + let from = oldPos; + while (true) { + const nl = originalCode.indexOf('\n', from); + if (nl === -1 || nl >= edit.start) { + break; + } + starts.push(newPos + (nl - oldPos) + 1); + from = nl + 1; + } + newPos += edit.start - oldPos; + + // Scan replacement text for newlines + let replFrom = 0; + while (true) { + const nl = edit.newText.indexOf('\n', replFrom); + if (nl === -1) { + break; + } + starts.push(newPos + nl + 1); + replFrom = nl + 1; + } + newPos += edit.newText.length; + + oldPos = edit.end; + } + + // Scan remaining unchanged text after last edit + let from = oldPos; + while (true) { + const nl = originalCode.indexOf('\n', from); + if (nl === -1) { + break; + } + starts.push(newPos + (nl - oldPos) + 1); + from = nl + 1; + } + + return starts; } diff --git a/build/next/test/nls-sourcemap.test.ts b/build/next/test/nls-sourcemap.test.ts index fd732b86802..c3aad2c80dc 100644 --- a/build/next/test/nls-sourcemap.test.ts +++ b/build/next/test/nls-sourcemap.test.ts @@ -10,6 +10,7 @@ import * as fs from 'fs'; import * as os from 'os'; import { type RawSourceMap, SourceMapConsumer } from 'source-map'; import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from '../nls-plugin.ts'; +import { adjustSourceMap } from '../private-to-property.ts'; // analyzeLocalizeCalls requires the import path to end with `/nls` const NLS_STUB = [ @@ -36,7 +37,7 @@ interface BundleResult { async function bundleWithNLS( files: Record, entryPoint: string, - opts?: { postProcess?: boolean } + opts?: { postProcess?: boolean; minify?: boolean } ): Promise { const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nls-sm-test-')); const srcDir = path.join(tmpDir, 'src'); @@ -64,6 +65,7 @@ async function bundleWithNLS( packages: 'external', sourcemap: 'linked', sourcesContent: true, + minify: opts?.minify ?? false, write: false, plugins: [ nlsPlugin({ baseDir: srcDir, collector }), @@ -91,7 +93,16 @@ async function bundleWithNLS( // Optionally apply NLS post-processing (replaces placeholders with indices) if (opts?.postProcess) { const nlsResult = await finalizeNLS(collector, outDir); - jsContent = postProcessNLS(jsContent, nlsResult.indexMap, false); + const preNLSCode = jsContent; + const nlsProcessed = postProcessNLS(jsContent, nlsResult.indexMap, false); + jsContent = nlsProcessed.code; + + // Adjust source map for NLS edits + if (nlsProcessed.edits.length > 0) { + const mapJson = JSON.parse(mapContent); + const adjusted = adjustSourceMap(mapJson, preNLSCode, nlsProcessed.edits); + mapContent = JSON.stringify(adjusted); + } } assert.ok(jsContent, 'Expected JS output'); @@ -209,6 +220,28 @@ suite('NLS plugin source maps', () => { } }); + test('NLS-affected nested file keeps a non-duplicated source path', async () => { + const source = [ + 'import { localize } from "../../vs/nls";', + 'export const msg = localize("myKey", "Hello World");', + ].join('\n'); + + const { mapJson, cleanup } = await bundleWithNLS( + { 'nested/deep/file.ts': source }, + 'nested/deep/file.ts', + ); + + try { + const sources: string[] = mapJson.sources ?? []; + const nestedSource = sources.find((s: string) => s.endsWith('/nested/deep/file.ts')); + assert.ok(nestedSource, 'Should find nested/deep/file.ts in sources'); + assert.ok(!nestedSource.includes('/nested/deep/nested/deep/file.ts'), + `Source path should not duplicate directory segments. Actual: ${nestedSource}`); + } finally { + cleanup(); + } + }); + test('line mapping correct for code after localize calls', async () => { const source = [ 'import { localize } from "../vs/nls";', // 1 @@ -370,4 +403,82 @@ suite('NLS plugin source maps', () => { cleanup(); } }); + + test('post-processed NLS - column mappings correct after placeholder replacement', async () => { + // NLS placeholders like "%%NLS:test/drift#k%%" are much longer than their + // replacements (e.g. "0"). Without source map adjustment the columns for + // tokens AFTER the replacement drift by the cumulative length delta. + const source = [ + 'import { localize } from "../vs/nls";', // 1 + 'export const a = localize("k1", "Alpha"); export const MARKER = "FINDME";', // 2 + ].join('\n'); + + const { js, map, cleanup } = await bundleWithNLS( + { 'test/drift.ts': source }, + 'test/drift.ts', + { postProcess: true } + ); + + try { + assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced'); + + const bundleLine = findLine(js, 'FINDME'); + const bundleCol = findColumn(js, '"FINDME"'); + const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); + + assert.ok(pos.source, 'Should have source'); + assert.strictEqual(pos.line, 2, 'Should map to line 2'); + + const originalCol = findColumn(source, '"FINDME"'); + const columnDrift = Math.abs(pos.column! - originalCol); + assert.ok(columnDrift <= 20, + `Column drift after NLS post-processing should be small. ` + + `Expected ~${originalCol}, got ${pos.column} (drift: ${columnDrift}). ` + + `Large drift means postProcessNLS edits were not applied to the source map.`); + } finally { + cleanup(); + } + }); + + test('minified bundle with NLS - end-to-end column mapping', async () => { + // With minification, the entire output is (roughly) on one line. + // Multiple NLS replacements compound their column shifts. A function + // defined after several localize() calls must still map correctly. + const source = [ + 'import { localize } from "../vs/nls";', // 1 + '', // 2 + 'export const a = localize("k1", "Alpha message");', // 3 + 'export const b = localize("k2", "Bravo message that is quite long");', // 4 + 'export const c = localize("k3", "Charlie");', // 5 + 'export const d = localize("k4", "Delta is the fourth letter");', // 6 + '', // 7 + 'export function computeResult(x: number): number {', // 8 + '\treturn x * 42;', // 9 + '}', // 10 + ].join('\n'); + + const { js, map, cleanup } = await bundleWithNLS( + { 'test/minified.ts': source }, + 'test/minified.ts', + { postProcess: true, minify: true } + ); + + try { + assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced'); + + // Find the computeResult function in the minified output. + // esbuild minifies `x * 42` and may rename the parameter, so + // search for `*42` which survives both minification and renaming. + const needle = '*42'; + const bundleLine = findLine(js, needle); + const bundleCol = findColumn(js, needle); + const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol }); + + assert.ok(pos.source, 'Should have source for minified mapping'); + assert.strictEqual(pos.line, 9, + `Should map "*42" back to line 9. Got line ${pos.line}.`); + } finally { + cleanup(); + } + }); }); diff --git a/build/next/test/private-to-property.test.ts b/build/next/test/private-to-property.test.ts index aa9da72ce9a..9b976797679 100644 --- a/build/next/test/private-to-property.test.ts +++ b/build/next/test/private-to-property.test.ts @@ -439,6 +439,41 @@ suite('adjustSourceMap', () => { assert.strictEqual(pos.column, origGetValueCol, 'getValue column should match original'); }); + test('multi-line edit: removing newlines shifts subsequent lines up', () => { + // Simulates the NLS scenario: a template literal with embedded newlines + // is replaced with `null`, collapsing 3 lines into 1. + const code = [ + 'var a = "hello";', // line 0 (0-based) + 'var b = `line1', // line 1 + 'line2', // line 2 + 'line3`;', // line 3 + 'var c = "world";', // line 4 + ].join('\n'); + const map = createIdentitySourceMap(code, 'test.js'); + + // Replace the template literal `line1\nline2\nline3` with `null` + // (keeps `var b = ` and `;` intact) + const tplStart = code.indexOf('`line1'); + const tplEnd = code.indexOf('line3`') + 'line3`'.length; + const edits = [{ start: tplStart, end: tplEnd, newText: 'null' }]; + + const result = adjustSourceMap(map, code, edits); + const consumer = new SourceMapConsumer(result); + + // After edit, code is: + // "var a = \"hello\";\nvar b = null;\nvar c = \"world\";" + // "var c" was on line 5 (1-based), now on line 3 (1-based) since 2 newlines removed + + // 'var c' at original line 5, col 0 should now map at generated line 3 + const pos = consumer.originalPositionFor({ line: 3, column: 0 }); + assert.strictEqual(pos.line, 5, 'var c should map to original line 5'); + assert.strictEqual(pos.column, 0, 'var c column should be 0'); + + // 'var a' on line 1 should be unaffected + const posA = consumer.originalPositionFor({ line: 1, column: 0 }); + assert.strictEqual(posA.line, 1, 'var a should still map to original line 1'); + }); + test('brand check: #field in obj -> string replacement adjusts map', () => { const code = 'class C { #x; check(o) { return #x in o; } }'; const map = createIdentitySourceMap(code, 'test.js'); diff --git a/build/next/working.md b/build/next/working.md index 298d1fb8cbd..a7ea64db8b6 100644 --- a/build/next/working.md +++ b/build/next/working.md @@ -222,13 +222,13 @@ Two categories of corruption: 2. **`--source-map-base-url` option** - Rewrites `sourceMappingURL` comments to point to CDN URLs. -3. **NLS plugin inline source maps** (`nls-plugin.ts`) - The `onLoad` handler now generates an inline source map (`//# sourceMappingURL=data:...`) mapping from NLS-transformed source back to original. esbuild composes this with its own bundle source map. `SourceMapGenerator.setSourceContent` embeds the original source so `sourcesContent` in the final `.map` has the real TypeScript. Tests in `test/nls-sourcemap.test.ts`. +3. **NLS plugin inline source maps** (`nls-plugin.ts`) - The `onLoad` handler generates an inline source map (`//# sourceMappingURL=data:...`) mapping from NLS-transformed source back to original. esbuild composes this with its own bundle source map. `SourceMapGenerator.setSourceContent` embeds the original source so `sourcesContent` in the final `.map` has the real TypeScript. `generateNLSSourceMap` adds per-column identity mappings after each edit on a line so that esbuild's source-map composition preserves fine-grained column accuracy (source maps don't interpolate columns — they use binary search, so a single boundary mapping would collapse all subsequent columns to the edit-end position). Tests in `test/nls-sourcemap.test.ts`. 4. **`convertPrivateFields` source map adjustment** (`private-to-property.ts`) - `convertPrivateFields` returns its sorted edits as `TextEdit[]`. `adjustSourceMap()` uses `SourceMapConsumer` to walk every mapping, adjusts generated columns based on cumulative edit shifts per line, and rebuilds with `SourceMapGenerator`. The post-processing loop in `index.ts` saves pre-mangle content + edits per JS file, then applies `adjustSourceMap` to the corresponding `.map`. Tests in `test/private-to-property.test.ts`. -### Not Yet Fixed +5. **`postProcessNLS` source map adjustment** (`nls-plugin.ts`, `index.ts`) — `postProcessNLS` now returns `{ code, edits }` where `edits` is a `TextEdit[]` tracking each replacement's byte offset. The bundle loop in `index.ts` chains `adjustSourceMap` calls: first for mangle edits, then for NLS edits, so both transforms are accurately reflected in the final `.map` file. Tests in `test/nls-sourcemap.test.ts`. -**`postProcessNLS` column drift** - Replaces NLS placeholders with short indices in bundled output without updating `.map` files. Shifts columns but never lines, so line-level debugging and crash reporting work correctly. Fixing would require tracking replacement offsets through regex matches and adjusting the source map, similar to `adjustSourceMap`. +6. **`adjustSourceMap` unmapped segment preservation** (`private-to-property.ts`) — Previously, `adjustSourceMap()` silently dropped mappings where `source === null`. These unmapped segments create essential "gaps" that prevent `originalPositionFor()` from wrongly interpolating between distant valid mappings on the same minified line. Now emits them as generated-only mappings. Also preserves `sourceRoot` from the input map. ### Key Technical Details @@ -241,6 +241,71 @@ Two categories of corruption: **Plugin interaction:** Both the NLS plugin and `fileContentMapperPlugin` register `onLoad({ filter: /\.ts$/ })`. In esbuild, the first `onLoad` to return non-`undefined` wins. The NLS plugin is `unshift`ed (runs first), so files with NLS calls skip `fileContentMapperPlugin`. This is safe in practice since `product.ts` (which has `BUILD->INSERT_PRODUCT_CONFIGURATION`) has no localize calls. +### Still Broken — Full Production Build (`npm run gulp vscode-min`) + +**Symptom:** Source maps are totally broken in the minified production build. E.g. a breakpoint at `src/vs/editor/browser/editorExtensions.ts` line 308 resolves to `src/vs/editor/common/cursor/cursorMoveCommands.ts` line 732 — a completely different file. This is **cross-file** mapping corruption, not just column drift. + +**Status of unit tests:** The fixes above pass in isolated unit tests (small 1–2 file bundles via `esbuild.build` with `minify: true`). The tests verify column drift ≤ 20 and correct line mapping for single-file bundles with NLS. **183 tests pass, 0 failing.** But the full production build bundles hundreds of files into huge minified outputs (e.g. `workbench.desktop.main.js` at ~15 MB) and the source maps break at that scale. + +**Suspected root causes (need investigation):** + +1. **`generateNLSSourceMap` per-column identity mappings may overwhelm esbuild's source-map composition.** The fix added one mapping per column from edit-end to end-of-line (or next edit). For a long TypeScript line with a `localize()` call near the beginning, this generates hundreds of identity mappings per line. Across hundreds of files, the inline source maps embedded in `onLoad` responses may be extremely large. esbuild must compose these with its own source maps during bundling — it may hit limits, silently drop mappings, or produce incorrect composed maps at this scale. **Mitigation to try:** Instead of per-column mappings, use sparser "checkpoint" mappings (e.g., every N characters) or rely only on boundary mappings and accept some column drift within the NLS-transformed region. The old boundary-only approach was wrong (collapsed all downstream columns), but per-column may be the other extreme. + +2. **`adjustSourceMap` may corrupt source indices in large minified bundles.** In a minified bundle, the entire output is on one or very few lines. `adjustSourceMap()` walks every mapping via `SourceMapConsumer.eachMapping()` and adjusts `generatedColumn` using `adjustColumn()`. But when thousands of mappings all share `generatedLine: 1` and there are hundreds of NLS edits on that same line, there may be sorting/ordering bugs: `eachMapping()` returns mappings in generated order by default, but `adjustColumn()` binary-searches through edits sorted by column. If edits cover regions that interleave with mappings from different source files, the cumulative shift calculation might produce wrong columns that then resolve to wrong source files. + +3. **Chained `adjustSourceMap` calls (mangle → NLS) may compound errors.** After the first `adjustSourceMap` for mangle edits, the source map's generated columns are updated. The second call for NLS edits uses `nlsEdits` which were computed against `preNLSCode` — but `preNLSCode` is the post-mangle JS, which is what the first `adjustSourceMap` maps from. This chaining _should_ be correct, but needs verification at scale with a real minified bundle. + +4. **The `source-map` v0.6.1 library may have precision issues with very large VLQ-encoded maps.** The bundled outputs have source maps with hundreds of thousands of mappings. The library is old (2017) and there may be numerical precision or sorting issues with very large maps. Consider testing with `source-map` v0.7+ or the Rust-based `@aspect-build/source-map`. + +5. **Alternative approach: skip per-column NLS plugin mappings, fix only `postProcessNLS`.** The NLS plugin `onLoad` replaces `"key"` with `"%%NLS:longPlaceholder%%"` — a length change that only affects columns on affected lines. The subsequent `postProcessNLS` then replaces the long placeholder with a short index. If the `adjustSourceMap` for `postProcessNLS` is correct, it should compensate for both expansions (plugin expansion + post-process contraction). We might not need per-column mappings in `generateNLSSourceMap` at all — just the boundary mapping. The column will drift in the intermediate representation but `adjustSourceMap` for NLS should fix it. **This hypothesis needs testing.** + +6. **Alternative approach: do NLS replacement purely in post-processing.** Skip the `onLoad` two-phase approach (placeholder insertion + post-processing replacement) entirely. Instead, run `postProcessNLS` as a single post-processing step that directly replaces `localize("key", "message")` → `localize(0, null)` in the bundled JS output, with proper source-map adjustment via `adjustSourceMap`. This avoids both the inline source map composition complexity and the two-step replacement. The downside is that post-processing must parse/regex-match real `localize()` calls (not easy placeholders), which is more fragile. + +**Summary of fixes applied vs status:** + +| Bug | Fix | Unit test | Production | +|-----|-----|-----------|------------| +| `generateNLSSourceMap` only had boundary mappings → columns collapsed | Added per-column identity mappings after each edit | Pass (drift: 0) | **Broken** — may overwhelm esbuild composition at scale | +| `postProcessNLS` didn't track edits for source map adjustment | Returns `{ code, edits }`, chained in `index.ts` | Pass | **Broken** — `adjustSourceMap` may corrupt source indices on huge single-line minified output | +| `adjustSourceMap` dropped unmapped segments | Preserves generated-only mappings + `sourceRoot` | Pass (no regressions) | **Broken** — same cross-file mapping issue | + +**Files involved:** +- `build/next/nls-plugin.ts` — `generateNLSSourceMap()` (per-column mappings), `postProcessNLS()` (returns edits), `replaceInOutput()` (regex replacement) +- `build/next/private-to-property.ts` — `adjustSourceMap()` (column adjustment) +- `build/next/index.ts` — bundle post-processing loop (lines ~899–975), chains adjustSourceMap calls +- `build/next/test/nls-sourcemap.test.ts` — unit tests (pass but don't cover production-scale bundles) + +**How to reproduce:** +```bash +npm run gulp vscode-min +# Open out-vscode-min/ in a debugger, set breakpoints in editor files +# Observe breakpoints resolve to wrong files +``` + +**How to debug further:** +```bash +# 1. Build with just --nls (no mangle) to isolate NLS from mangle issues +npx tsx build/next/index.ts bundle --nls --minify --target desktop --out out-debug + +# 2. Build with just --mangle-privates (no NLS) to isolate mangle issues +npx tsx build/next/index.ts bundle --mangle-privates --minify --target desktop --out out-debug + +# 3. Build with neither (baseline — does esbuild's own map work?) +npx tsx build/next/index.ts bundle --minify --target desktop --out out-debug + +# 4. Compare .map files across the three builds to find where mappings diverge + +# 5. Validate a specific mapping in the large bundle: +node -e " +const {SourceMapConsumer} = require('source-map'); +const fs = require('fs'); +const map = JSON.parse(fs.readFileSync('./out-debug/vs/workbench/workbench.desktop.main.js.map','utf8')); +const c = new SourceMapConsumer(map); +// Look up a known position and see which source file it resolves to +console.log(c.originalPositionFor({line: 1, column: XXXX})); +" +``` + --- ## Self-hosting Setup diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index e2785131796..3142db6e89d 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -1069,9 +1069,9 @@ } }, "node_modules/tar": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", - "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", + "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/build/npm/installStateHash.ts b/build/npm/installStateHash.ts index 5674a1eaee3..f52c0a4696d 100644 --- a/build/npm/installStateHash.ts +++ b/build/npm/installStateHash.ts @@ -36,15 +36,46 @@ export interface PostinstallState { readonly fileHashes: Record; } -const packageJsonIgnoredKeys = new Set(['distro']); +const packageJsonRelevantKeys = new Set([ + 'name', + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + 'peerDependenciesMeta', + 'overrides', + 'engines', + 'workspaces', + 'bundledDependencies', + 'bundleDependencies', +]); + +const packageLockJsonIgnoredKeys = new Set(['version']); function normalizeFileContent(filePath: string): string { const raw = fs.readFileSync(filePath, 'utf8'); - if (path.basename(filePath) === 'package.json') { + const basename = path.basename(filePath); + if (basename === 'package.json') { const json = JSON.parse(raw); - for (const key of packageJsonIgnoredKeys) { + const filtered: Record = {}; + for (const key of packageJsonRelevantKeys) { + // eslint-disable-next-line local/code-no-in-operator + if (key in json) { + filtered[key] = json[key]; + } + } + return JSON.stringify(filtered, null, '\t') + '\n'; + } + if (basename === 'package-lock.json') { + const json = JSON.parse(raw); + for (const key of packageLockJsonIgnoredKeys) { delete json[key]; } + if (json.packages?.['']) { + for (const key of packageLockJsonIgnoredKeys) { + delete json.packages[''][key]; + } + } return JSON.stringify(json, null, '\t') + '\n'; } return raw; @@ -110,11 +141,19 @@ export function readSavedContents(): Record | undefined { // When run directly, output state as JSON for tooling (e.g. the vscode-extras extension). if (import.meta.filename === process.argv[1]) { - console.log(JSON.stringify({ - root, - stateContentsFile, - current: computeState(), - saved: readSavedState(), - files: [...collectInputFiles(), stateFile], - })); + if (process.argv[2] === '--normalize-file') { + const filePath = process.argv[3]; + if (!filePath) { + process.exit(1); + } + process.stdout.write(normalizeFileContent(filePath)); + } else { + console.log(JSON.stringify({ + root, + stateContentsFile, + current: computeState(), + saved: readSavedState(), + files: [...collectInputFiles(), stateFile], + })); + } } diff --git a/build/package-lock.json b/build/package-lock.json index ec46db00b08..3dd620ab59e 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -2318,9 +2318,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { @@ -4526,9 +4526,9 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index de462673a18..b9b854eacf2 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -8,8 +8,8 @@ "name": "@vscode/sample-source", "version": "0.0.0", "devDependencies": { - "@vscode/component-explorer": "^0.1.1-16", - "@vscode/component-explorer-vite-plugin": "^0.1.1-16", + "@vscode/component-explorer": "^0.1.1-20", + "@vscode/component-explorer-vite-plugin": "^0.1.1-19", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" @@ -683,9 +683,9 @@ "license": "MIT" }, "node_modules/@vscode/component-explorer": { - "version": "0.1.1-16", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-16.tgz", - "integrity": "sha512-is1RxdlNO5K1RSqWd5z8BN6gPrqEBZfjgUi3ZJbQj8Z4VqmqoJsNLIzBXOIlQJX+5mWgeNdOq3vxe0u15ZkAlA==", + "version": "0.1.1-20", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-20.tgz", + "integrity": "sha512-HvMWH+wK0SWC+eKZ2cL2LSsWnXiQjyQRURUgW2FBd8SM1G99+kKce0ESTYSr4b0tNJ1/FONE0ixADFlSRduzTg==", "dev": true, "license": "MIT", "dependencies": { @@ -694,9 +694,9 @@ } }, "node_modules/@vscode/component-explorer-vite-plugin": { - "version": "0.1.1-16", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-16.tgz", - "integrity": "sha512-z2EqusWl49dUF3vNDgmJJJQXkv4ejeBH9AdFZUWOiGaMvjjFX6UV7oQ733b+vo5YFE8my9WaK7D691i2wZ47Fg==", + "version": "0.1.1-19", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-19.tgz", + "integrity": "sha512-V0wMhLvHMbeUHOzwGrBPMwwvcbGhXXaQTCGc9hNfF4fjUutOtQFu5o+9XKDG1hIcKgk5qyvcRoXjVazBcg19lA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/build/vite/package.json b/build/vite/package.json index 5e5d59d1a16..67e2f227e40 100644 --- a/build/vite/package.json +++ b/build/vite/package.json @@ -9,8 +9,8 @@ "preview": "vite preview" }, "devDependencies": { - "@vscode/component-explorer": "^0.1.1-16", - "@vscode/component-explorer-vite-plugin": "^0.1.1-16", + "@vscode/component-explorer": "^0.1.1-20", + "@vscode/component-explorer-vite-plugin": "^0.1.1-19", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" diff --git a/build/win32/Cargo.lock b/build/win32/Cargo.lock index d35c41e4098..bc102802568 100644 --- a/build/win32/Cargo.lock +++ b/build/win32/Cargo.lock @@ -3,93 +3,141 @@ version = 4 [[package]] -name = "bitflags" -version = "1.3.2" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byteorder" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "crc" -version = "3.0.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] name = "crossbeam-channel" -version = "0.5.5" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c02a4d71819009c192cf4872265391563fd6a84c81ff2c0f2a7026ca4c1d85c" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ - "cfg-if", "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.10" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d82ee10ce34d7bc12c2122495e7593a9c41347ecdd64185af4ecf72cb1a7f83" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ - "cfg-if", - "once_cell", + "powerfmt", ] [[package]] -name = "dirs-next" -version = "2.0.0" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "dirs-sys-next" -version = "0.1.2" +name = "erased-serde" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" dependencies = [ - "libc", - "redox_users", - "winapi", + "serde", ] [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -99,37 +147,102 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "getrandom" -version = "0.2.7" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "getrandom" -version = "0.3.3" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasip2", + "wasip3", ] [[package]] -name = "hermit-abi" -version = "0.3.9" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] [[package]] name = "inno_updater" -version = "0.18.2" +version = "0.19.0" dependencies = [ "byteorder", "crc", @@ -142,40 +255,74 @@ dependencies = [ [[package]] name = "is-terminal" -version = "0.4.12" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "itoa" -version = "1.0.2" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.181" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] -name = "num_threads" -version = "0.1.6" +name = "log" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "libc", + "autocfg", ] [[package]] @@ -185,19 +332,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] -name = "proc-macro2" -version = "1.0.40" +name = "powerfmt" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.20" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -208,56 +371,96 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "redox_syscall" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_users" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" -dependencies = [ - "getrandom 0.2.7", - "redox_syscall", - "thiserror", -] - [[package]] name = "rustix" -version = "1.0.7" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.9.1", + "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustversion" -version = "1.0.7" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0a5f7c728f5d284929a1cccb5bc19884422bfe6ef4d6c409da2c41838983fcf" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "slog" -version = "2.7.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8347046d4ebd943127157b94d63abb990fcf729dc4e9978927fdf4ac3c998d06" +checksum = "9b3b8565691b22d2bdfc066426ed48f837fc0c5f2c8cad8d9718f7f99d6995c1" +dependencies = [ + "anyhow", + "erased-serde", + "rustversion", + "serde_core", +] [[package]] name = "slog-async" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "766c59b252e62a34651412870ff55d8c4e6d04df19b43eecb2703e417b097ffe" +checksum = "72c8038f898a2c79507940990f05386455b3a317d8f18d4caea7cbc3d5096b84" dependencies = [ "crossbeam-channel", "slog", @@ -267,10 +470,11 @@ dependencies = [ [[package]] name = "slog-term" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6e022d0b998abfe5c3782c1f03551a596269450ccd677ea51c56f8b214610e8" +checksum = "5cb1fc680b38eed6fad4c02b3871c09d2c81db8c96aa4e9c0a34904c830f09b5" dependencies = [ + "chrono", "is-terminal", "slog", "term", @@ -280,9 +484,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.98" +version = "2.0.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" dependencies = [ "proc-macro2", "quote", @@ -297,42 +501,193 @@ checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" [[package]] name = "tempfile" -version = "3.20.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "term" -version = "0.7.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "dirs-next", + "windows-sys 0.61.2", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", "rustversion", - "winapi", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "thiserror" -version = "1.0.31" +name = "wasm-bindgen-macro" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ - "thiserror-impl", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "thiserror-impl" -version = "1.0.31" +name = "wasm-bindgen-macro-support" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -340,113 +695,62 @@ dependencies = [ ] [[package]] -name = "thread_local" -version = "1.1.4" +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "once_cell", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "time" -version = "0.3.11" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72c91f41dcb2f096c05f0873d667dceec1087ce5bcf984ec8ffb19acddbb3217" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "itoa", - "libc", - "num_threads", - "time-macros", + "windows-link", ] [[package]] -name = "time-macros" -version = "0.2.4" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" - -[[package]] -name = "unicode-ident" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "wit-bindgen-rt", + "windows-link", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-sys" version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" -dependencies = [ - "windows_aarch64_gnullvm 0.52.5", - "windows_aarch64_msvc 0.52.5", - "windows_i686_gnu 0.52.5", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.5", - "windows_x86_64_gnu 0.52.5", - "windows_x86_64_gnullvm 0.52.5", - "windows_x86_64_msvc 0.52.5", + "windows-link", ] [[package]] @@ -455,78 +759,36 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" - [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" - [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -534,16 +796,95 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" [[package]] -name = "windows_x86_64_msvc" -version = "0.52.5" +name = "wit-bindgen" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen-core" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ - "bitflags 2.9.1", + "anyhow", + "heck", + "wit-parser", ] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/build/win32/Cargo.toml b/build/win32/Cargo.toml index 40e1a7a60fd..3e400552cc0 100644 --- a/build/win32/Cargo.toml +++ b/build/win32/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "inno_updater" -version = "0.18.2" +version = "0.19.0" authors = ["Microsoft "] build = "build.rs" @@ -9,7 +9,7 @@ byteorder = "1.4.3" crc = "3.0.1" slog = "2.7.0" slog-async = "2.7.0" -slog-term = "2.9.1" +slog-term = "2.9.2" tempfile = "3.5.0" [target.'cfg(windows)'.dependencies.windows-sys] diff --git a/cglicenses.json b/cglicenses.json index 37bba3145ba..48d2c3b093c 100644 --- a/cglicenses.json +++ b/cglicenses.json @@ -306,11 +306,6 @@ "name": "russh-keys", "fullLicenseTextUri": "https://raw.githubusercontent.com/warp-tech/russh/1da80d0d599b6ee2d257c544c0d6af4f649c9029/LICENSE-2.0.txt" }, - { - // Reason: license is in a subdirectory in repo - "name": "dirs-next", - "fullLicenseTextUri": "https://raw.githubusercontent.com/xdg-rs/dirs/af4aa39daba0ac68e222962a5aca17360158b7cc/dirs/LICENSE-MIT" - }, { // Reason: license is in a subdirectory in repo "name": "openssl", @@ -361,10 +356,6 @@ "name": "toml_datetime", "fullLicenseTextUri": "https://raw.githubusercontent.com/toml-rs/toml/main/crates/toml_datetime/LICENSE-MIT" }, - { // License is MIT/Apache and tool doesn't look in subfolders - "name": "dirs-sys-next", - "fullLicenseTextUri": "https://raw.githubusercontent.com/xdg-rs/dirs/master/dirs-sys/LICENSE-MIT" - }, { // License is MIT/Apache and gitlab API doesn't find the project "name": "libredox", "fullLicenseTextUri": "https://gitlab.redox-os.org/redox-os/libredox/-/raw/master/LICENSE" diff --git a/cgmanifest.json b/cgmanifest.json index 21554434500..1b1e1711ccf 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "a229dbf7a56336b847b34dfff1bac79afc311eee", - "tag": "39.6.0" + "commitHash": "69c8cbf259da0f84e9c1db04958516a68f7170aa", + "tag": "39.8.0" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.6.0" + "version": "39.8.0" }, { "component": { diff --git a/cli/ThirdPartyNotices.txt b/cli/ThirdPartyNotices.txt index 9edb0ae9d23..6e21ddb3729 100644 --- a/cli/ThirdPartyNotices.txt +++ b/cli/ThirdPartyNotices.txt @@ -1666,7 +1666,6 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [//]: # (crates) [`aead`]: ./aead -[`async‑signature`]: ./signature/async [`cipher`]: ./cipher [`crypto‑common`]: ./crypto-common [`crypto`]: ./crypto @@ -1828,7 +1827,6 @@ Unless you explicitly state otherwise, any contribution intentionally submitted [//]: # (crates) [`aead`]: ./aead -[`async‑signature`]: ./signature/async [`cipher`]: ./cipher [`crypto‑common`]: ./crypto-common [`crypto`]: ./crypto @@ -13671,33 +13669,7 @@ ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation a zbus 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -13705,33 +13677,7 @@ DEALINGS IN THE SOFTWARE. zbus_macros 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -13739,33 +13685,7 @@ DEALINGS IN THE SOFTWARE. zbus_names 2.6.1 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -14211,33 +14131,7 @@ DEALINGS IN THE SOFTWARE. zvariant 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -14245,33 +14139,7 @@ DEALINGS IN THE SOFTWARE. zvariant_derive 3.15.2 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- --------------------------------------------------------- @@ -14279,31 +14147,5 @@ DEALINGS IN THE SOFTWARE. zvariant_utils 1.0.1 - MIT https://github.com/z-galaxy/zbus/ -The MIT License (MIT) - -Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors - -Permission is hereby granted, free of charge, to any -person obtaining a copy of this software and associated -documentation files (the "Software"), to deal in the -Software without restriction, including without -limitation the rights to use, copy, modify, merge, -publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software -is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice -shall be included in all copies or substantial portions -of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF -ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED -TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A -PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT -SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR -IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. +LICENSE-MIT --------------------------------------------------------- \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 184b2962f24..1099751c390 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2064,6 +2064,7 @@ export default tseslint.config( 'vs/editor/contrib/*/~', 'vs/workbench/~', 'vs/workbench/services/*/~', + 'vs/sessions/services/*/~', { 'when': 'test', 'pattern': 'vs/workbench/contrib/*/~' diff --git a/extensions/CONTRIBUTING.md b/extensions/CONTRIBUTING.md new file mode 100644 index 00000000000..cfaf6b0ca8d --- /dev/null +++ b/extensions/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to Built-In Extensions + +This directory contains built-in extensions that ship with VS Code. + +## Basic Structure + +A typical TypeScript-based built-in extension has the following structure: + +- `package.json`: extension manifest. +- `src/`: Main directory for TypeScript source code. +- `tsconfig.json`: primary TypeScript config. This should inherit from `tsconfig.base.json`. +- `esbuild.mts`: esbuild build script used for production builds. +- `.vscodeignore`: Ignore file list. You can copy this from an existing extension. + +TypeScript-based extensions have the following output structure: + +- `out`: Output directory for development builds +- `dist`: Output directory for production builds. + + +## Enabling an Extension in the Browser + +By default extensions will only target desktop. To enable an extension in browsers as well: + +- Add a `"browser"` entry in `package.json` pointing to the browser bundle (for example `"./dist/browser/extension"`). +- Add `tsconfig.browser.json` that typechecks only browser-safe sources. +- Add an `esbuild.browser.mts` file. This should set `platform: 'browser'`. + +Make sure the browser build of the extension only uses browser-safe APIs. If an extension needs different behavior between desktop and web, you can create distinct entrypoints for each target: + +- `src/extension.ts`: Desktop entrypoint. +- `src/extension.browser.ts`: Browser entrypoint. Make sure `esbuild.browser.mts` builds this and that `tsconfig.browser.json` targets it. diff --git a/extensions/css/cgmanifest.json b/extensions/css/cgmanifest.json index 7b85089b6b9..93bd8ba0f31 100644 --- a/extensions/css/cgmanifest.json +++ b/extensions/css/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "microsoft/vscode-css", "repositoryUrl": "https://github.com/microsoft/vscode-css", - "commitHash": "a927fe2f73927bf5c25d0b0c4dd0e63d69fd8887" + "commitHash": "9a07d76cb0e7a56f9bfc76328a57227751e4adb4" } }, "licenseDetail": [ diff --git a/extensions/css/syntaxes/css.tmLanguage.json b/extensions/css/syntaxes/css.tmLanguage.json index 5ba8bc90b73..484af027c19 100644 --- a/extensions/css/syntaxes/css.tmLanguage.json +++ b/extensions/css/syntaxes/css.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/microsoft/vscode-css/commit/a927fe2f73927bf5c25d0b0c4dd0e63d69fd8887", + "version": "https://github.com/microsoft/vscode-css/commit/9a07d76cb0e7a56f9bfc76328a57227751e4adb4", "name": "CSS", "scopeName": "source.css", "patterns": [ @@ -1401,7 +1401,7 @@ "property-keywords": { "patterns": [ { - "match": "(?xi) (?(); private readmeQ = new Set(); private timer: NodeJS.Timeout | undefined; - private markdownIt: MarkdownItType.MarkdownIt | undefined; + private markdownIt: MarkdownIt | undefined; private parse5: typeof import('parse5') | undefined; constructor() { @@ -292,7 +292,7 @@ export class ExtensionLinter { this.markdownIt = new ((await import('markdown-it')).default); } const tokens = this.markdownIt.parse(text, {}); - const tokensAndPositions: TokenAndPosition[] = (function toTokensAndPositions(this: ExtensionLinter, tokens: MarkdownItType.Token[], begin = 0, end = text.length): TokenAndPosition[] { + const tokensAndPositions: TokenAndPosition[] = (function toTokensAndPositions(this: ExtensionLinter, tokens: MarkdownIt.Token[], begin = 0, end = text.length): TokenAndPosition[] { const tokensAndPositions = tokens.map(token => { if (token.map) { const tokenBegin = document.offsetAt(new Position(token.map[0], 0)); @@ -313,7 +313,7 @@ export class ExtensionLinter { }); return tokensAndPositions.concat( ...tokensAndPositions.filter(tnp => tnp.token.children && tnp.token.children.length) - .map(tnp => toTokensAndPositions.call(this, tnp.token.children, tnp.begin, tnp.end)) + .map(tnp => toTokensAndPositions.call(this, tnp.token.children ?? [], tnp.begin, tnp.end)) ); }).call(this, tokens); @@ -373,7 +373,7 @@ export class ExtensionLinter { } } - private locateToken(text: string, begin: number, end: number, token: MarkdownItType.Token, content: string | null) { + private locateToken(text: string, begin: number, end: number, token: MarkdownIt.Token, content: string | null) { if (content) { const tokenBegin = text.indexOf(content, begin); if (tokenBegin !== -1) { diff --git a/extensions/git/esbuild.mts b/extensions/git/esbuild.mts index 35c8f6c63f0..1b397880bc6 100644 --- a/extensions/git/esbuild.mts +++ b/extensions/git/esbuild.mts @@ -2,12 +2,27 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); +async function copyNonTsFiles(outDir: string): Promise { + const entries = await fs.readdir(srcDir, { withFileTypes: true, recursive: true }); + for (const entry of entries) { + if (!entry.isFile() || entry.name.endsWith('.ts')) { + continue; + } + const srcPath = path.join(entry.parentPath, entry.name); + const relativePath = path.relative(srcDir, srcPath); + const destPath = path.join(outDir, relativePath); + await fs.mkdir(path.dirname(destPath), { recursive: true }); + await fs.copyFile(srcPath, destPath); + } +} + run({ platform: 'node', entryPoints: { @@ -17,4 +32,4 @@ run({ }, srcDir, outdir: outDir, -}, process.argv); +}, process.argv, copyNonTsFiles); diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index abe5c331074..b97337596d1 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -320,6 +320,10 @@ export class ApiRepository implements Repository { return this.#repository.mergeAbort(); } + rebase(branch: string): Promise { + return this.#repository.rebase(branch); + } + createStash(options?: { message?: string; includeUntracked?: boolean; staged?: boolean }): Promise { return this.#repository.createStash(options?.message, options?.includeUntracked, options?.staged); } @@ -347,6 +351,14 @@ export class ApiRepository implements Repository { migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise { return this.#repository.migrateChanges(sourceRepositoryPath, options); } + + generateRandomBranchName(): Promise { + return this.#repository.generateRandomBranchName(); + } + + isBranchProtected(branch?: Branch): boolean { + return this.#repository.isBranchProtected(branch); + } } export class ApiGit implements Git { diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 287dd4399bf..8a258ba5741 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -315,6 +315,7 @@ export interface Repository { commit(message: string, opts?: CommitOptions): Promise; merge(ref: string): Promise; mergeAbort(): Promise; + rebase(branch: string): Promise; createStash(options?: { message?: string; includeUntracked?: boolean; staged?: boolean }): Promise; applyStash(index?: number): Promise; @@ -325,6 +326,10 @@ export interface Repository { deleteWorktree(path: string, options?: { force?: boolean }): Promise; migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; + + generateRandomBranchName(): Promise; + + isBranchProtected(branch?: Branch): boolean; } export interface RemoteSource { diff --git a/extensions/git/src/artifactProvider.ts b/extensions/git/src/artifactProvider.ts index 832b5626ae0..f9e2d99087f 100644 --- a/extensions/git/src/artifactProvider.ts +++ b/extensions/git/src/artifactProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { LogOutputChannel, SourceControlArtifactProvider, SourceControlArtifactGroup, SourceControlArtifact, Event, EventEmitter, ThemeIcon, l10n, workspace, Uri, Disposable, Command } from 'vscode'; -import { coalesce, dispose, filterEvent, IDisposable, isCopilotWorktree } from './util'; +import { coalesce, dispose, filterEvent, IDisposable, isCopilotWorktreeFolder } from './util'; import { Repository } from './repository'; import type { Ref, Worktree } from './api/git'; import { RefType } from './api/git.constants'; @@ -178,7 +178,7 @@ export class GitArtifactProvider implements SourceControlArtifactProvider, IDisp ]).join(' \u2022 '), icon: w.main ? new ThemeIcon('repo') - : isCopilotWorktree(w.path) + : isCopilotWorktreeFolder(w.path) ? new ThemeIcon('chat-sparkle') : new ThemeIcon('worktree') })); diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 1fc850565de..51cbef08d2e 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -7,7 +7,6 @@ import * as os from 'os'; import * as path from 'path'; import { Command, commands, Disposable, MessageOptions, Position, QuickPickItem, Range, SourceControlResourceState, TextDocumentShowOptions, TextEditor, Uri, ViewColumn, window, workspace, WorkspaceEdit, WorkspaceFolder, TimelineItem, env, Selection, TextDocumentContentProvider, InputBoxValidationSeverity, TabInputText, TabInputTextMerge, QuickPickItemKind, TextDocument, LogOutputChannel, l10n, Memento, UIKind, QuickInputButton, ThemeIcon, SourceControlHistoryItem, SourceControl, InputBoxValidationMessage, Tab, TabInputNotebook, QuickInputButtonLocation, languages, SourceControlArtifact, ProgressLocation } from 'vscode'; import TelemetryReporter from '@vscode/extension-telemetry'; -import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator'; import type { CommitOptions, RemoteSourcePublisher, Remote, Branch, Ref } from './api/git'; import { ForcePushMode, GitErrorCodes, RefType, Status } from './api/git.constants'; import { Git, GitError, Repository as GitRepository, Stash, Worktree } from './git'; @@ -1041,24 +1040,73 @@ export class CommandCenter { } @command('_git.cloneRepository') - async cloneRepository(url: string, parentPath: string): Promise { + async cloneRepository(url: string, localPath: string, ref?: string): Promise { const opts = { location: ProgressLocation.Notification, title: l10n.t('Cloning git repository "{0}"...', url), cancellable: true }; + const parentPath = path.dirname(localPath); + const targetName = path.basename(localPath); + await window.withProgress( opts, - (progress, token) => this.model.git.clone(url, { parentPath, progress }, token) + (progress, token) => this.model.git.clone(url, { parentPath, targetName, progress, ref }, token) ); } - @command('_git.pull') - async pullRepository(repositoryPath: string): Promise { + @command('_git.checkout') + async checkoutRepository(repositoryPath: string, treeish: string, detached?: boolean): Promise { const dotGit = await this.git.getRepositoryDotGit(repositoryPath); const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); - await repo.pull(); + await repo.checkout(treeish, [], detached ? { detached: true } : {}); + } + + @command('_git.pull') + async pullRepository(repositoryPath: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + return repo.pull(); + } + + @command('_git.fetchRepository') + async fetchRepository(repositoryPath: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + await repo.fetch(); + } + + @command('_git.revParse') + async revParse(repositoryPath: string, ref: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + const result = await repo.exec(['rev-parse', ref]); + return result.stdout.trim(); + } + + @command('_git.revListCount') + async revListCount(repositoryPath: string, fromRef: string, toRef: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + const result = await repo.exec(['rev-list', '--count', `${fromRef}..${toRef}`]); + return Number(result.stdout.trim()) || 0; + } + + @command('_git.revParseAbbrevRef') + async revParseAbbrevRef(repositoryPath: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + const result = await repo.exec(['rev-parse', '--abbrev-ref', 'HEAD']); + return result.stdout.trim(); + } + + @command('_git.mergeBranch') + async mergeBranch(repositoryPath: string, branch: string): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + const result = await repo.exec(['merge', branch, '--no-edit']); + return result.stdout.trim(); } @command('git.init') @@ -2943,48 +2991,6 @@ export class CommandCenter { await this._branch(repository, undefined, true); } - private async generateRandomBranchName(repository: Repository, separator: string): Promise { - const config = workspace.getConfiguration('git'); - const branchRandomNameDictionary = config.get('branchRandomName.dictionary')!; - - const dictionaries: string[][] = []; - for (const dictionary of branchRandomNameDictionary) { - if (dictionary.toLowerCase() === 'adjectives') { - dictionaries.push(adjectives); - } - if (dictionary.toLowerCase() === 'animals') { - dictionaries.push(animals); - } - if (dictionary.toLowerCase() === 'colors') { - dictionaries.push(colors); - } - if (dictionary.toLowerCase() === 'numbers') { - dictionaries.push(NumberDictionary.generate({ length: 3 })); - } - } - - if (dictionaries.length === 0) { - return ''; - } - - // 5 attempts to generate a random branch name - for (let index = 0; index < 5; index++) { - const randomName = uniqueNamesGenerator({ - dictionaries, - length: dictionaries.length, - separator - }); - - // Check for local ref conflict - const refs = await repository.getRefs({ pattern: `refs/heads/${randomName}` }); - if (refs.length === 0) { - return randomName; - } - } - - return ''; - } - private async promptForBranchName(repository: Repository, defaultName?: string, initialValue?: string): Promise { const config = workspace.getConfiguration('git'); const branchPrefix = config.get('branchPrefix')!; @@ -2998,8 +3004,7 @@ export class CommandCenter { } const getBranchName = async (): Promise => { - const branchName = branchRandomNameEnabled ? await this.generateRandomBranchName(repository, branchWhitespaceChar) : ''; - return `${branchPrefix}${branchName}`; + return await repository.generateRandomBranchName() ?? branchPrefix; }; const getValueSelection = (value: string): [number, number] | undefined => { @@ -5639,15 +5644,14 @@ export class CommandCenter { options.modal = false; break; default: { - const hint = (err.stderr || err.message || String(err)) + const hintLines = (err.stderr || err.stdout || err.message || String(err)) .replace(/^error: /mi, '') .replace(/^> husky.*$/mi, '') .split(/[\r\n]/) - .filter((line: string) => !!line) - [0]; + .filter((line: string) => !!line); - message = hint - ? l10n.t('Git: {0}', hint) + message = hintLines.length > 0 + ? l10n.t('Git: {0}', err.stdout ? hintLines[hintLines.length - 1] : hintLines[0]) : l10n.t('Git error'); break; diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 5f7d1100f70..90284866a51 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -378,6 +378,7 @@ const STASH_FORMAT = '%H%n%P%n%gd%n%gs%n%at%n%ct'; export interface ICloneOptions { readonly parentPath: string; + readonly targetName?: string; readonly progress: Progress<{ increment: number }>; readonly recursive?: boolean; readonly ref?: string; @@ -433,14 +434,16 @@ export class Git { } async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise { - const baseFolderName = decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository'; + const baseFolderName = options.targetName || decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository'; let folderName = baseFolderName; let folderPath = path.join(options.parentPath, folderName); let count = 1; - while (count < 20 && await new Promise(c => exists(folderPath, c))) { - folderName = `${baseFolderName}-${count++}`; - folderPath = path.join(options.parentPath, folderName); + if (!options.targetName) { + while (count < 20 && await new Promise(c => exists(folderPath, c))) { + folderName = `${baseFolderName}-${count++}`; + folderPath = path.join(options.parentPath, folderName); + } } await mkdirp(options.parentPath); @@ -2421,7 +2424,7 @@ export class Repository { await this.exec(args, spawnOptions); } - async pull(rebase?: boolean, remote?: string, branch?: string, options: PullOptions = {}): Promise { + async pull(rebase?: boolean, remote?: string, branch?: string, options: PullOptions = {}): Promise { const args = ['pull']; if (options.tags) { @@ -2447,10 +2450,11 @@ export class Repository { } try { - await this.exec(args, { + const result = await this.exec(args, { cancellationToken: options.cancellationToken, env: { 'GIT_HTTP_USER_AGENT': this.git.userAgent } }); + return !/Already up to date/i.test(result.stdout); } catch (err) { if (/^CONFLICT \([^)]+\): \b/m.test(err.stdout || '')) { err.gitErrorCode = GitErrorCodes.Conflict; diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 6810f3cca42..bd6b6a5c7ff 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import TelemetryReporter from '@vscode/extension-telemetry'; +import { uniqueNamesGenerator, adjectives, animals, colors, NumberDictionary } from '@joaomoreno/unique-names-generator'; import * as fs from 'fs'; import * as fsPromises from 'fs/promises'; import * as path from 'path'; @@ -24,7 +25,7 @@ import { IPushErrorHandlerRegistry } from './pushError'; import { IRemoteSourcePublisherRegistry } from './remotePublisher'; import { StatusBarCommands } from './statusbar'; import { toGitUri } from './uri'; -import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, getCommitShortHash, IDisposable, isCopilotWorktree, isDescendant, isLinuxSnap, isRemote, isWindows, Limiter, onceEvent, pathEquals, relativePath } from './util'; +import { anyEvent, combinedDisposable, debounceEvent, dispose, EmptyDisposable, eventToPromise, filterEvent, find, getCommitShortHash, IDisposable, isCopilotWorktreeFolder, isDescendant, isLinuxSnap, isRemote, isWindows, Limiter, onceEvent, pathEquals, relativePath } from './util'; import { IFileWatcher, watch } from './watch'; import { ISourceControlHistoryItemDetailsProviderRegistry } from './historyItemDetailsProvider'; import { GitArtifactProvider } from './artifactProvider'; @@ -953,7 +954,7 @@ export class Repository implements Disposable { const icon = repository.kind === 'submodule' ? new ThemeIcon('archive') : repository.kind === 'worktree' - ? isCopilotWorktree(repository.root) + ? isCopilotWorktreeFolder(repository.root) ? new ThemeIcon('chat-sparkle') : new ThemeIcon('worktree') : new ThemeIcon('repo'); @@ -966,7 +967,7 @@ export class Repository implements Disposable { // from the Repositories view. this._isHidden = workspace.workspaceFolders === undefined || (repository.kind === 'worktree' && - isCopilotWorktree(repository.root) && parent !== undefined); + isCopilotWorktreeFolder(repository.root) && parent !== undefined); const root = Uri.file(repository.root); this._sourceControl = scm.createSourceControl('git', 'Git', root, icon, this._isHidden, parent); @@ -3294,6 +3295,56 @@ export class Repository implements Disposable { return this.unpublishedCommits; } + async generateRandomBranchName(): Promise { + const config = workspace.getConfiguration('git', Uri.file(this.root)); + const branchRandomNameEnabled = config.get('branchRandomName.enable', false); + + if (!branchRandomNameEnabled) { + return undefined; + } + + const branchPrefix = config.get('branchPrefix', ''); + const branchWhitespaceChar = config.get('branchWhitespaceChar', '-'); + const branchRandomNameDictionary = config.get('branchRandomName.dictionary', ['adjectives', 'animals']); + + const dictionaries: string[][] = []; + for (const dictionary of branchRandomNameDictionary) { + if (dictionary.toLowerCase() === 'adjectives') { + dictionaries.push(adjectives); + } + if (dictionary.toLowerCase() === 'animals') { + dictionaries.push(animals); + } + if (dictionary.toLowerCase() === 'colors') { + dictionaries.push(colors); + } + if (dictionary.toLowerCase() === 'numbers') { + dictionaries.push(NumberDictionary.generate({ length: 3 })); + } + } + + if (dictionaries.length === 0) { + return undefined; + } + + // 5 attempts to generate a random branch name + for (let index = 0; index < 5; index++) { + const randomName = uniqueNamesGenerator({ + dictionaries, + length: dictionaries.length, + separator: branchWhitespaceChar + }); + + // Check for local ref conflict + const refs = await this.getRefs({ pattern: `refs/heads/${branchPrefix}${randomName}` }); + if (refs.length === 0) { + return `${branchPrefix}${randomName}`; + } + } + + return undefined; + } + dispose(): void { this.disposables = dispose(this.disposables); } diff --git a/extensions/git/src/util.ts b/extensions/git/src/util.ts index c6ec6ece45c..58a6d06419a 100644 --- a/extensions/git/src/util.ts +++ b/extensions/git/src/util.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event, Disposable, EventEmitter, SourceControlHistoryItemRef, l10n, workspace, Uri, DiagnosticSeverity, env, SourceControlHistoryItem } from 'vscode'; -import { dirname, normalize, sep, relative } from 'path'; +import { basename, dirname, normalize, sep, relative } from 'path'; import { Readable } from 'stream'; import { promises as fs, createReadStream } from 'fs'; import byline from 'byline'; @@ -867,10 +867,6 @@ export function getStashDescription(stash: Stash): string | undefined { return descriptionSegments.join(' \u2022 '); } -export function isCopilotWorktree(path: string): boolean { - const lastSepIndex = path.lastIndexOf(sep); - - return lastSepIndex !== -1 - ? path.substring(lastSepIndex + 1).startsWith('copilot-worktree-') - : path.startsWith('copilot-worktree-'); +export function isCopilotWorktreeFolder(path: string): boolean { + return basename(path).startsWith('copilot-'); } diff --git a/extensions/github/package.json b/extensions/github/package.json index 42f408ac96b..a122a856488 100644 --- a/extensions/github/package.json +++ b/extensions/github/package.json @@ -19,7 +19,8 @@ "extensionDependencies": [ "vscode.git-base" ], - "main": "./dist/extension.js", + "type": "module", + "main": "./out/extension.js", "capabilities": { "virtualWorkspaces": true, "untrustedWorkspaces": { @@ -182,20 +183,7 @@ "when": "github.hasGitHubRepo && timelineItem =~ /git:file:commit\\b/" } ], - "chat/input/editing/sessionToolbar": [ - { - "command": "github.createPullRequest", - "group": "navigation", - "order": 1, - "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli && !github.hasOpenPullRequest" - }, - { - "command": "github.openPullRequest", - "group": "navigation", - "order": 1, - "when": "isSessionsWindow && agentSessionHasChanges && chatSessionType == copilotcli && github.hasOpenPullRequest" - } - ] + "chat/input/editing/sessionApplyActions": [] }, "configuration": [ { diff --git a/extensions/github/src/commands.ts b/extensions/github/src/commands.ts index 4a1d1c10ce8..a8b69f10936 100644 --- a/extensions/github/src/commands.ts +++ b/extensions/github/src/commands.ts @@ -91,28 +91,6 @@ function resolveSessionRepo(gitAPI: GitAPI, sessionMetadata: { worktreePath?: st return { repository, remoteInfo, gitRemote: { name: gitRemote.name, fetchUrl: gitRemote.fetchUrl! }, head: head as ResolvedSessionRepo['head'] }; } -async function checkOpenPullRequest(gitAPI: GitAPI, _sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined): Promise { - const resolved = resolveSessionRepo(gitAPI, sessionMetadata, false); - if (!resolved) { - vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', false); - return; - } - - try { - const octokit = await getOctokit(); - const { data: openPRs } = await octokit.pulls.list({ - owner: resolved.remoteInfo.owner, - repo: resolved.remoteInfo.repo, - head: `${resolved.remoteInfo.owner}:${resolved.head.name}`, - state: 'all', - }); - - vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', openPRs.length > 0); - } catch { - vscode.commands.executeCommand('setContext', 'github.hasOpenPullRequest', false); - } -} - async function createPullRequest(gitAPI: GitAPI, sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined): Promise { if (!sessionResource) { return; @@ -264,9 +242,5 @@ export function registerCommands(gitAPI: GitAPI): vscode.Disposable { return openPullRequest(gitAPI, sessionResource, sessionMetadata); })); - disposables.add(vscode.commands.registerCommand('github.checkOpenPullRequest', async (sessionResource: vscode.Uri | undefined, sessionMetadata: { worktreePath?: string } | undefined) => { - return checkOpenPullRequest(gitAPI, sessionResource, sessionMetadata); - })); - return disposables; } diff --git a/extensions/github/src/typings/git.constants.ts b/extensions/github/src/typings/git.constants.ts index 5847e21d5d0..e39a3fb03b3 100644 --- a/extensions/github/src/typings/git.constants.ts +++ b/extensions/github/src/typings/git.constants.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type * as git from './git'; +import type * as git from './git.d.ts'; export type ForcePushMode = git.ForcePushMode; export type RefType = git.RefType; diff --git a/extensions/go/cgmanifest.json b/extensions/go/cgmanifest.json index b697426969b..bdad000b919 100644 --- a/extensions/go/cgmanifest.json +++ b/extensions/go/cgmanifest.json @@ -6,12 +6,12 @@ "git": { "name": "go-syntax", "repositoryUrl": "https://github.com/worlpaker/go-syntax", - "commitHash": "6e8421faf8f1445512825f63925e54a62106bcf1" + "commitHash": "c74e22eb9ef32958e3edd130ea750ce78d8b8241" } }, "license": "MIT", "description": "The file syntaxes/go.tmLanguage.json is from https://github.com/worlpaker/go-syntax, which in turn was derived from https://github.com/jeff-hykin/better-go-syntax.", - "version": "0.8.5" + "version": "0.8.6" } ], "version": 1 diff --git a/extensions/go/syntaxes/go.tmLanguage.json b/extensions/go/syntaxes/go.tmLanguage.json index 72d7df0cb40..b2aec3c4e14 100644 --- a/extensions/go/syntaxes/go.tmLanguage.json +++ b/extensions/go/syntaxes/go.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/worlpaker/go-syntax/commit/6e8421faf8f1445512825f63925e54a62106bcf1", + "version": "https://github.com/worlpaker/go-syntax/commit/c74e22eb9ef32958e3edd130ea750ce78d8b8241", "name": "Go", "scopeName": "source.go", "patterns": [ @@ -1929,12 +1929,12 @@ }, { "comment": "one line with semicolon(;) without formatting gofmt - single type | property variables and types", - "match": "(?:(?<=\\{)((?:\\s*(?:(?:(?:\\w+\\,\\s*)+)?(?:\\w+\\s+))?(?:(?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:[^\\s/]+)(?:\\;)?))+)\\s*(?=\\}))", + "match": "(?:(?<=\\{)((?:\\s*(?:(?:(?:\\w+\\,\\s*)+)?(?:\\w+\\s+))?(?:(?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:[^\\s/\\`\"]+)(?:\\;)?))+)\\s*(?=\\}))", "captures": { "1": { "patterns": [ { - "match": "(?:((?:(?:\\w+\\,\\s*)+)?(?:\\w+\\s+))?((?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:[^\\s/]+)(?:\\;)?))", + "match": "(?:((?:(?:\\w+\\,\\s*)+)?(?:\\w+\\s+))?((?:(?:\\s*(?:[\\*\\[\\]]+)?(?:\\<\\-\\s*)?\\bchan\\b(?:\\s*\\<\\-)?\\s*)+)?(?:[^\\s/\\`\"]+)(?:\\;)?))", "captures": { "1": { "patterns": [ diff --git a/extensions/markdown-basics/package.json b/extensions/markdown-basics/package.json index c77aad6a301..58cc459ce3f 100644 --- a/extensions/markdown-basics/package.json +++ b/extensions/markdown-basics/package.json @@ -8,7 +8,9 @@ "engines": { "vscode": "^1.20.0" }, - "categories": ["Programming Languages"], + "categories": [ + "Programming Languages" + ], "contributes": { "languages": [ { @@ -20,12 +22,16 @@ "extensions": [ ".md", ".mkd", + ".mkdn", ".mdwn", ".mdown", ".markdown", ".markdn", ".mdtxt", ".mdtext", + ".litcoffee", + ".ron", + ".ronn", ".workbook" ], "filenamePatterns": [ diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index fbde1da49b9..162ff688b0a 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "dompurify": "^3.2.7", + "dompurify": "^3.3.2", "highlight.js": "^11.8.0", "markdown-it": "^12.3.2", "markdown-it-front-matter": "^0.2.4", @@ -386,10 +386,13 @@ } }, "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", + "engines": { + "node": ">=20" + }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 745ba66b56c..b4141d80bf5 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -230,6 +230,14 @@ "group": "1_markdown" } ], + "modalEditor/editorTitle": [ + { + "command": "markdown.showPreviewToSide", + "when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused && !hasCustomMarkdownPreview", + "alt": "markdown.showPreview", + "group": "navigation" + } + ], "explorer/context": [ { "command": "markdown.showPreview", @@ -774,7 +782,7 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "dompurify": "^3.2.7", + "dompurify": "^3.3.2", "highlight.js": "^11.8.0", "markdown-it": "^12.3.2", "markdown-it-front-matter": "^0.2.4", diff --git a/extensions/mermaid-chat-features/package-lock.json b/extensions/mermaid-chat-features/package-lock.json index 0d0bb2a582b..23436bd9f00 100644 --- a/extensions/mermaid-chat-features/package-lock.json +++ b/extensions/mermaid-chat-features/package-lock.json @@ -9,7 +9,7 @@ "version": "10.0.0", "license": "MIT", "dependencies": { - "dompurify": "^3.2.7", + "dompurify": "^3.3.2", "mermaid": "^11.12.3" }, "devDependencies": { @@ -1016,10 +1016,13 @@ } }, "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", + "engines": { + "node": ">=20" + }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } diff --git a/extensions/mermaid-chat-features/package.json b/extensions/mermaid-chat-features/package.json index 811bff8076d..68f5271fef6 100644 --- a/extensions/mermaid-chat-features/package.json +++ b/extensions/mermaid-chat-features/package.json @@ -130,7 +130,7 @@ "@vscode/codicons": "^0.0.36" }, "dependencies": { - "dompurify": "^3.2.7", + "dompurify": "^3.3.2", "mermaid": "^11.12.3" } } diff --git a/extensions/notebook-renderers/package-lock.json b/extensions/notebook-renderers/package-lock.json index beacde5ae21..7908a9bf049 100644 --- a/extensions/notebook-renderers/package-lock.json +++ b/extensions/notebook-renderers/package-lock.json @@ -12,19 +12,218 @@ "@types/jsdom": "^21.1.0", "@types/node": "^22.18.10", "@types/vscode-notebook-renderer": "^1.60.0", - "jsdom": "^21.1.1" + "jsdom": "^28.1.0" }, "engines": { "vscode": "^1.57.0" } }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, "engines": { - "node": ">= 10" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.29", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz", + "integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, "node_modules/@types/jsdom": { @@ -60,121 +259,78 @@ "integrity": "sha512-u7TD2uuEZTVuitx0iijOJdKI0JLiQP6PsSBSRy2XmHXUOXcp5p1S56NrjOEDoF+PIHd3NL3eO6KTRSf5nukDqQ==", "dev": true }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "deprecated": "Use your platform's native atob() and btoa() methods instead", - "dev": true - }, - "node_modules/acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "dev": true, - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, - "node_modules/acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, - "dependencies": { - "debug": "4" - }, + "license": "MIT", "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" + "require-from-string": "^2.0.2" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, + "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" }, "engines": { - "node": ">= 0.8" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, "node_modules/cssstyle": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", - "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", "dev": true, + "license": "MIT", "dependencies": { - "rrweb-cssom": "^0.6.0" + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" }, "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/data-urls": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", - "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, + "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^12.0.0" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" }, "engines": { - "node": ">=14" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -186,53 +342,11 @@ } }, "node_modules/decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", - "dev": true - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } + "license": "MIT" }, "node_modules/entities": { "version": "4.4.0", @@ -246,284 +360,45 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escodegen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", - "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", - "dev": true, - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^5.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=6.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, + "license": "MIT", "dependencies": { - "whatwg-encoding": "^2.0.0" + "@exodus/bytes": "^1.6.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, + "license": "MIT", "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, + "license": "MIT", "dependencies": { - "agent-base": "6", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "node": ">= 14" } }, "node_modules/is-potential-custom-element-name": { @@ -533,43 +408,39 @@ "dev": true }, "node_modules/jsdom": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-21.1.1.tgz", - "integrity": "sha512-Jjgdmw48RKcdAIQyUD1UdBh2ecH7VqwaXPN3ehoZN6MqgVbMn+lRm1aAT1AsdJRAJpwfa4IpwgzySn61h2qu3w==", + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", "dev": true, + "license": "MIT", "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.2", - "acorn-globals": "^7.0.0", - "cssstyle": "^3.0.0", - "data-urls": "^4.0.0", - "decimal.js": "^10.4.3", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.6.0", + "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^12.0.1", - "ws": "^8.13.0", - "xml-name-validator": "^4.0.0" + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "canvas": "^2.5.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { "canvas": { @@ -577,78 +448,55 @@ } } }, - "node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.8.0" + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { - "mime-db": "1.52.0" + "entities": "^6.0.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/nwsapi": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.2.tgz", - "integrity": "sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==", - "dev": true - }, - "node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } + "license": "MIT" }, "node_modules/parse5": { "version": "7.1.2", @@ -662,53 +510,25 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true - }, "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true - }, - "node_modules/rrweb-cssom": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", - "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", - "dev": true - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/saxes": { "version": "6.0.0", @@ -722,12 +542,12 @@ "node": ">=v12.22.7" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, - "optional": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -738,43 +558,60 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true }, - "node_modules/tough-cookie": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", - "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "node_modules/tldts": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", + "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==", "dev": true, + "license": "MIT", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts-core": "^7.0.24" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz", + "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" }, "engines": { - "node": ">=6" + "node": ">=16" } }, "node_modules/tr46": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", - "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, + "license": "MIT", "dependencies": { - "punycode": "^2.3.0" + "punycode": "^2.3.1" }, "engines": { - "node": ">=14" + "node": ">=20" } }, - "node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2" - }, + "license": "MIT", "engines": { - "node": ">= 0.8.0" + "node": ">=20.18.1" } }, "node_modules/undici-types": { @@ -784,117 +621,62 @@ "dev": true, "license": "MIT" }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, + "license": "MIT", "dependencies": { - "xml-name-validator": "^4.0.0" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "dev": true, - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" + "node": ">=20" } }, "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=20" } }, "node_modules/whatwg-url": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", - "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, + "license": "MIT", "dependencies": { - "tr46": "^4.1.1", - "webidl-conversions": "^7.0.0" + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" }, "engines": { - "node": ">=14" - } - }, - "node_modules/word-wrap": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz", - "integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/xmlchars": { diff --git a/extensions/notebook-renderers/package.json b/extensions/notebook-renderers/package.json index e9890cad899..fad11bc9e30 100644 --- a/extensions/notebook-renderers/package.json +++ b/extensions/notebook-renderers/package.json @@ -50,7 +50,7 @@ "@types/jsdom": "^21.1.0", "@types/node": "^22.18.10", "@types/vscode-notebook-renderer": "^1.60.0", - "jsdom": "^21.1.1" + "jsdom": "^28.1.0" }, "repository": { "type": "git", diff --git a/extensions/php/cgmanifest.json b/extensions/php/cgmanifest.json index 090bdf642f9..1fe92816e2c 100644 --- a/extensions/php/cgmanifest.json +++ b/extensions/php/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "language-php", "repositoryUrl": "https://github.com/KapitanOczywisty/language-php", - "commitHash": "6941b924add3b2587a5be789248176edf5f14595" + "commitHash": "a0f3d9a3b0d017181455ed515e48a36607a90e3b" } }, "license": "MIT", diff --git a/extensions/php/syntaxes/php.tmLanguage.json b/extensions/php/syntaxes/php.tmLanguage.json index efb122c98d3..afa1a5bbb67 100644 --- a/extensions/php/syntaxes/php.tmLanguage.json +++ b/extensions/php/syntaxes/php.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/KapitanOczywisty/language-php/commit/6941b924add3b2587a5be789248176edf5f14595", + "version": "https://github.com/KapitanOczywisty/language-php/commit/a0f3d9a3b0d017181455ed515e48a36607a90e3b", "scopeName": "source.php", "patterns": [ { @@ -2464,7 +2464,7 @@ "name": "punctuation.definition.arguments.begin.bracket.round.php" } }, - "end": "\\)", + "end": "\\)|(?=\\?>)", "endCaptures": { "0": { "name": "punctuation.definition.arguments.end.bracket.round.php" @@ -2536,16 +2536,33 @@ ] }, "invoke-call": { - "captures": { + "begin": "(?i)((\\$+)[a-z_\\x{7f}-\\x{10ffff}][a-z0-9_\\x{7f}-\\x{10ffff}]*)\\s*(\\()", + "beginCaptures": { "1": { "name": "variable.other.php" }, "2": { "name": "punctuation.definition.variable.php" + }, + "3": { + "name": "punctuation.definition.arguments.begin.bracket.round.php" } }, - "match": "(?i)((\\$+)[a-z_\\x{7f}-\\x{10ffff}][a-z0-9_\\x{7f}-\\x{10ffff}]*)(?=\\s*\\()", - "name": "meta.function-call.invoke.php" + "end": "\\)|(?=\\?>)", + "endCaptures": { + "0": { + "name": "punctuation.definition.arguments.end.bracket.round.php" + } + }, + "name": "meta.function-call.invoke.php", + "patterns": [ + { + "include": "#named-arguments" + }, + { + "include": "$self" + } + ] }, "namespace": { "begin": "(?i)(?:(namespace)|[a-z_\\x{7f}-\\x{10ffff}][a-z0-9_\\x{7f}-\\x{10ffff}]*)?(\\\\)", diff --git a/extensions/ruby/cgmanifest.json b/extensions/ruby/cgmanifest.json index 5d7a9662061..0fb779250dd 100644 --- a/extensions/ruby/cgmanifest.json +++ b/extensions/ruby/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "Shopify/ruby-lsp", "repositoryUrl": "https://github.com/Shopify/ruby-lsp", - "commitHash": "59da6a0ae3409437474b85d0daa5535f1878699d" + "commitHash": "ba41f8b4f9677fb14c1ecbe15d73ebe12a0d3859" } }, "licenseDetail": [ diff --git a/extensions/ruby/syntaxes/ruby.tmLanguage.json b/extensions/ruby/syntaxes/ruby.tmLanguage.json index f5e3f2b0c0d..8cda18871b5 100644 --- a/extensions/ruby/syntaxes/ruby.tmLanguage.json +++ b/extensions/ruby/syntaxes/ruby.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/Shopify/ruby-lsp/commit/59da6a0ae3409437474b85d0daa5535f1878699d", + "version": "https://github.com/Shopify/ruby-lsp/commit/ba41f8b4f9677fb14c1ecbe15d73ebe12a0d3859", "name": "Ruby", "scopeName": "source.ruby", "patterns": [ @@ -1583,7 +1583,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)HTML)\\b\\1))", "comment": "Heredoc with embedded HTML", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)HTML)$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.html", "patterns": [ { @@ -1594,12 +1599,7 @@ } }, "contentName": "text.html", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)HTML)\\s*$)", "patterns": [ { "include": "#heredoc" @@ -1620,7 +1620,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)HAML)\\b\\1))", "comment": "Heredoc with embedded HAML", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)HAML)$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.haml", "patterns": [ { @@ -1631,12 +1636,7 @@ } }, "contentName": "text.haml", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)HAML)\\s*$)", "patterns": [ { "include": "#heredoc" @@ -1657,7 +1657,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)XML)\\b\\1))", "comment": "Heredoc with embedded XML", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)XML)$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.xml", "patterns": [ { @@ -1668,12 +1673,7 @@ } }, "contentName": "text.xml", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)XML)\\s*$)", "patterns": [ { "include": "#heredoc" @@ -1694,7 +1694,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)SQL)\\b\\1))", "comment": "Heredoc with embedded SQL", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)SQL)$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.sql", "patterns": [ { @@ -1705,12 +1710,7 @@ } }, "contentName": "source.sql", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)SQL)\\s*$)", "patterns": [ { "include": "#heredoc" @@ -1731,7 +1731,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)(?:GRAPHQL|GQL))\\b\\1))", "comment": "Heredoc with embedded GraphQL", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)(?:GRAPHQL|GQL))$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.graphql", "patterns": [ { @@ -1742,12 +1747,7 @@ } }, "contentName": "source.graphql", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)(?:GRAPHQL|GQL))\\s*$)", "patterns": [ { "include": "#heredoc" @@ -1768,7 +1768,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)CSS)\\b\\1))", "comment": "Heredoc with embedded CSS", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)CSS)$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.css", "patterns": [ { @@ -1779,12 +1784,7 @@ } }, "contentName": "source.css", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)CSS)\\s*$)", "patterns": [ { "include": "#heredoc" @@ -1805,7 +1805,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)CPP)\\b\\1))", "comment": "Heredoc with embedded C++", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)CPP)$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.cpp", "patterns": [ { @@ -1816,12 +1821,7 @@ } }, "contentName": "source.cpp", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)CPP)\\s*$)", "patterns": [ { "include": "#heredoc" @@ -1842,7 +1842,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)C)\\b\\1))", "comment": "Heredoc with embedded C", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)C)$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.c", "patterns": [ { @@ -1853,12 +1858,7 @@ } }, "contentName": "source.c", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)C)\\s*$)", "patterns": [ { "include": "#heredoc" @@ -1879,7 +1879,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)(?:JS|JAVASCRIPT))\\b\\1))", "comment": "Heredoc with embedded Javascript", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)(?:JS|JAVASCRIPT))$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.js", "patterns": [ { @@ -1890,12 +1895,7 @@ } }, "contentName": "source.js", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)(?:JS|JAVASCRIPT))\\s*$)", "patterns": [ { "include": "#heredoc" @@ -1916,7 +1916,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)JQUERY)\\b\\1))", "comment": "Heredoc with embedded jQuery Javascript", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)JQUERY)$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.js.jquery", "patterns": [ { @@ -1927,12 +1932,7 @@ } }, "contentName": "source.js.jquery", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)JQUERY)\\s*$)", "patterns": [ { "include": "#heredoc" @@ -1953,7 +1953,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)(?:SH|SHELL))\\b\\1))", "comment": "Heredoc with embedded Shell", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)(?:SH|SHELL))$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.shell", "patterns": [ { @@ -1964,12 +1969,7 @@ } }, "contentName": "source.shell", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)(?:SH|SHELL))\\s*$)", "patterns": [ { "include": "#heredoc" @@ -1990,7 +1990,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)LUA)\\b\\1))", "comment": "Heredoc with embedded Lua", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)LUA)$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.lua", "patterns": [ { @@ -2001,12 +2006,7 @@ } }, "contentName": "source.lua", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)LUA)\\s*$)", "patterns": [ { "include": "#heredoc" @@ -2027,7 +2027,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)RUBY)\\b\\1))", "comment": "Heredoc with embedded Ruby", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)RUBY)$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.ruby", "patterns": [ { @@ -2038,12 +2043,7 @@ } }, "contentName": "source.ruby", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)RUBY)\\s*$)", "patterns": [ { "include": "#heredoc" @@ -2064,7 +2064,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)(?:YAML|YML))\\b\\1))", "comment": "Heredoc with embedded YAML", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)(?:YAML|YML))$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.yaml", "patterns": [ { @@ -2075,12 +2080,7 @@ } }, "contentName": "source.yaml", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)(?:YAML|YML))\\s*$)", "patterns": [ { "include": "#heredoc" @@ -2101,7 +2101,12 @@ { "begin": "(?=(?><<[-~]?([\"'`]?)((?:[_\\w]+_|)SLIM)\\b\\1))", "comment": "Heredoc with embedded Slim", - "end": "(?!\\G)", + "end": "^\\s*((?:[_\\w]+_|)SLIM)$\\n?", + "endCaptures": { + "0": { + "name": "string.definition.end.ruby" + } + }, "name": "meta.embedded.block.slim", "patterns": [ { @@ -2112,12 +2117,7 @@ } }, "contentName": "text.slim", - "end": "^\\s*\\2$\\n?", - "endCaptures": { - "0": { - "name": "string.definition.end.ruby" - } - }, + "while": "^(?!\\s*((?:[_\\w]+_|)SLIM)\\s*$)", "patterns": [ { "include": "#heredoc" diff --git a/extensions/swift/cgmanifest.json b/extensions/swift/cgmanifest.json index ecd2705da2a..02ea0744ecd 100644 --- a/extensions/swift/cgmanifest.json +++ b/extensions/swift/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "jtbandes/swift-tmlanguage", "repositoryUrl": "https://github.com/jtbandes/swift-tmlanguage", - "commitHash": "45ac01d47c6d63402570c2c36bcfbadbd1c7bca6" + "commitHash": "3fca2fa10f7dc962d19ee617b17844d6eecfa2cb" } }, "license": "MIT" diff --git a/extensions/swift/syntaxes/swift.tmLanguage.json b/extensions/swift/syntaxes/swift.tmLanguage.json index a8bbe5d00b4..d52cabb836b 100644 --- a/extensions/swift/syntaxes/swift.tmLanguage.json +++ b/extensions/swift/syntaxes/swift.tmLanguage.json @@ -4,7 +4,7 @@ "If you want to provide a fix or improvement, please create a pull request against the original repository.", "Once accepted there, we are happy to receive an update request." ], - "version": "https://github.com/jtbandes/swift-tmlanguage/commit/45ac01d47c6d63402570c2c36bcfbadbd1c7bca6", + "version": "https://github.com/jtbandes/swift-tmlanguage/commit/3fca2fa10f7dc962d19ee617b17844d6eecfa2cb", "name": "Swift", "scopeName": "source.swift", "comment": "See swift.tmbundle/grammar-test.swift for test cases.", @@ -3848,7 +3848,7 @@ }, { "name": "string.quoted.double.block.raw.swift", - "begin": "#\"\"\"", + "begin": "#\"\"\"(?!#)(?=(?:[^\"]|\"(?!#))*$)", "end": "\"\"\"#(#*)", "beginCaptures": { "0": { @@ -3884,7 +3884,7 @@ }, { "name": "string.quoted.double.block.raw.swift", - "begin": "(##+)\"\"\"", + "begin": "(?/, or if it\'s part of the azure-samples organization.', + description: 'Initializes a new application from a template. You can use a Full URI, /, if it\'s part of the azure-samples organization, or a local directory path (./dir, ../dir, or absolute path).', args: [ { name: 'template', @@ -2059,6 +2562,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--location', '-l'], + description: 'Azure location for the new environment', + args: [ + { + name: 'location', + }, + ], + }, { name: ['--no-state'], description: '(Bicep only) Forces a fresh deployment based on current Bicep template files, ignoring any stored deployment state.', @@ -2067,6 +2579,15 @@ const completionSpec: Fig.Spec = { name: ['--preview'], description: 'Preview changes to Azure resources.', }, + { + name: ['--subscription'], + description: 'ID of an Azure subscription to use for the new environment', + args: [ + { + name: 'subscription', + }, + ], + }, ], args: { name: 'layer', @@ -2267,6 +2788,24 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--location', '-l'], + description: 'Azure location for the new environment', + args: [ + { + name: 'location', + }, + ], + }, + { + name: ['--subscription'], + description: 'ID of an Azure subscription to use for the new environment', + args: [ + { + name: 'subscription', + }, + ], + }, ], }, { @@ -2275,7 +2814,7 @@ const completionSpec: Fig.Spec = { }, { name: ['x'], - description: 'This extension provides a set of tools for AZD extension developers to test and debug their extensions.', + description: 'This extension provides a set of tools for azd extension developers to test and debug their extensions.', subcommands: [ { name: ['build'], @@ -2302,7 +2841,7 @@ const completionSpec: Fig.Spec = { }, { name: ['init'], - description: 'Initialize a new AZD extension project', + description: 'Initialize a new azd extension project', options: [ { name: ['--capabilities'], @@ -2506,7 +3045,7 @@ const completionSpec: Fig.Spec = { }, { name: ['watch'], - description: 'Watches the AZD extension project for file changes and rebuilds it.', + description: 'Watches the azd extension project for file changes and rebuilds it.', }, ], }, @@ -2530,6 +3069,14 @@ const completionSpec: Fig.Spec = { name: ['init'], description: 'Initialize a new AI agent project. (Preview)', }, + { + name: ['monitor'], + description: 'Monitor logs from a hosted agent container.', + }, + { + name: ['show'], + description: 'Show the status of a hosted agent deployment.', + }, { name: ['version'], description: 'Prints the version of the application', @@ -2584,6 +3131,56 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['models'], + description: 'Extension for managing custom models in Azure AI Foundry. (Preview)', + subcommands: [ + { + name: ['custom'], + description: 'Manage custom models in Azure AI Foundry', + subcommands: [ + { + name: ['create'], + description: 'Upload and register a custom model', + }, + { + name: ['delete'], + description: 'Delete a custom model', + }, + { + name: ['list'], + description: 'List all custom models', + }, + { + name: ['show'], + description: 'Show details of a custom model', + }, + ], + }, + { + name: ['init'], + description: 'Initialize a new AI models project. (Preview)', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], + }, + ], + }, + { + name: ['appservice'], + description: 'Extension for managing Azure App Service resources.', + subcommands: [ + { + name: ['swap'], + description: 'Swap deployment slots for an App Service.', + }, + { + name: ['version'], + description: 'Display the version of the extension.', + }, ], }, { @@ -2694,8 +3291,26 @@ const completionSpec: Fig.Spec = { }, { name: ['demo'], - description: 'This extension provides examples of the AZD extension framework.', + description: 'This extension provides examples of the azd extension framework.', subcommands: [ + { + name: ['ai'], + description: 'Interactive AI model discovery, deployment, and quota demos.', + subcommands: [ + { + name: ['deployment'], + description: 'Select model/version/SKU/capacity and resolve a valid deployment configuration.', + }, + { + name: ['models'], + description: 'Browse available AI models interactively.', + }, + { + name: ['quota'], + description: 'View usage meters and limits for a selected location.', + }, + ], + }, { name: ['colors', 'colours'], description: 'Displays all ASCII colors with their standard and high-intensity variants.', @@ -2706,7 +3321,7 @@ const completionSpec: Fig.Spec = { }, { name: ['context'], - description: 'Get the context of the AZD project & environment.', + description: 'Get the context of the azd project & environment.', }, { name: ['gh-url-parse'], @@ -2836,6 +3451,10 @@ const completionSpec: Fig.Spec = { name: ['remove'], description: 'Remove an extension source with the specified name', }, + { + name: ['validate'], + description: 'Validate an extension source\'s registry.json file.', + }, ], }, { @@ -2976,7 +3595,7 @@ const completionSpec: Fig.Spec = { }, { name: ['x'], - description: 'This extension provides a set of tools for AZD extension developers to test and debug their extensions.', + description: 'This extension provides a set of tools for azd extension developers to test and debug their extensions.', subcommands: [ { name: ['build'], @@ -2984,7 +3603,7 @@ const completionSpec: Fig.Spec = { }, { name: ['init'], - description: 'Initialize a new AZD extension project', + description: 'Initialize a new azd extension project', }, { name: ['pack'], @@ -3004,7 +3623,7 @@ const completionSpec: Fig.Spec = { }, { name: ['watch'], - description: 'Watches the AZD extension project for file changes and rebuilds it.', + description: 'Watches the azd extension project for file changes and rebuilds it.', }, ], }, diff --git a/extensions/theme-2026/package.json b/extensions/theme-2026/package.json index 305cc066c89..8360afdac5c 100644 --- a/extensions/theme-2026/package.json +++ b/extensions/theme-2026/package.json @@ -8,9 +8,6 @@ "engines": { "vscode": "^1.85.0" }, - "enabledApiProposals": [ - "css" - ], "categories": [ "Themes" ], @@ -28,11 +25,6 @@ "uiTheme": "vs-dark", "path": "./themes/2026-dark.json" } - ], - "css": [ - { - "path": "./themes/styles.css" - } - ] + ] } } diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index f1b217f5cca..d204a5506b0 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -5,7 +5,7 @@ "type": "dark", "colors": { "foreground": "#bfbfbf", - "disabledForeground": "#666666", + "disabledForeground": "#555555", "errorForeground": "#f48771", "descriptionForeground": "#8C8C8C", "icon.foreground": "#8C8C8C", @@ -25,7 +25,7 @@ "button.secondaryHoverBackground": "#FFFFFF10", "checkbox.background": "#242526", "checkbox.border": "#333536", - "checkbox.foreground": "#bfbfbf", + "checkbox.foreground": "#8C8C8C", "dropdown.background": "#191A1B", "dropdown.border": "#333536", "dropdown.foreground": "#bfbfbf", @@ -33,7 +33,7 @@ "input.background": "#191A1B", "input.border": "#333536FF", "input.foreground": "#bfbfbf", - "input.placeholderForeground": "#777777", + "input.placeholderForeground": "#555555", "inputOption.activeBackground": "#3994BC33", "inputOption.activeForeground": "#bfbfbf", "inputOption.activeBorder": "#2A2B2CFF", @@ -60,6 +60,7 @@ "list.hoverBackground": "#262728", "list.hoverForeground": "#bfbfbf", "list.dropBackground": "#3994BC1A", + "toolbar.hoverBackground": "#FFFFFF18", "list.focusBackground": "#3994BC26", "list.focusForeground": "#bfbfbf", "list.focusOutline": "#3994BCB3", @@ -99,16 +100,17 @@ "commandCenter.foreground": "#bfbfbf", "commandCenter.activeForeground": "#bfbfbf", "commandCenter.background": "#191A1B", - "commandCenter.activeBackground": "#252627", + "commandCenter.activeBackground": "#FFFFFF0F", "commandCenter.border": "#2E3031", "editor.background": "#121314", "editor.foreground": "#BBBEBF", "editorStickyScroll.background": "#121314", "editorStickyScrollHover.background": "#202122", + "editorStickyScroll.border": "#2A2B2CFF", "editorLineNumber.foreground": "#858889", "editorLineNumber.activeForeground": "#BBBEBF", "editorCursor.foreground": "#BBBEBF", - "editor.selectionBackground": "#27678280", + "editor.selectionBackground": "#276782dd", "editor.inactiveSelectionBackground": "#27678260", "editor.selectionHighlightBackground": "#27678260", "editor.wordHighlightBackground": "#27678250", @@ -153,8 +155,10 @@ "editorGutter.background": "#121314", "editorGutter.addedBackground": "#72C892", "editorGutter.deletedBackground": "#F28772", - "diffEditor.insertedTextBackground": "#72C89233", - "diffEditor.removedTextBackground": "#F2877233", + "diffEditor.insertedLineBackground": "#347d3926", + "diffEditor.insertedTextBackground": "#57ab5a4d", + "diffEditor.removedLineBackground": "#c93c3726", + "diffEditor.removedTextBackground": "#f470674d", "editorOverviewRuler.border": "#2A2B2CFF", "editorOverviewRuler.findMatchForeground": "#3a94bc99", "editorOverviewRuler.modifiedForeground": "#6ab890", @@ -187,15 +191,15 @@ "tab.inactiveForeground": "#8C8C8C", "tab.border": "#2A2B2CFF", "tab.lastPinnedBorder": "#2A2B2CFF", - "tab.activeBorder": "#121314", "tab.activeBorderTop": "#3994BC", - "tab.hoverBackground": "#262728", + "tab.hoverBackground": "#121314", "tab.hoverForeground": "#bfbfbf", "tab.unfocusedActiveBackground": "#121314", "tab.unfocusedActiveForeground": "#8C8C8C", "tab.unfocusedInactiveBackground": "#191A1B", "tab.unfocusedInactiveForeground": "#444444", "editorGroupHeader.tabsBackground": "#191A1B", + "tab.activeBorder": "#121314", "editorGroupHeader.tabsBorder": "#2A2B2CFF", "breadcrumb.foreground": "#8C8C8C", "breadcrumb.background": "#121314", @@ -263,374 +267,428 @@ "charts.yellow": "#E0B97F", "charts.orange": "#CD861A", "charts.green": "#86CF86", - "charts.purple": "#AD80D7" + "charts.purple": "#AD80D7", + "inlineChat.border": "#00000000", + "minimapSlider.background": "#83848533", + "minimapSlider.hoverBackground": "#83848566", + "minimapSlider.activeBackground": "#83848599", }, "tokenColors": [ { "scope": [ - "comment" + "comment", + "punctuation.definition.comment", + "string.comment" ], "settings": { - "foreground": "#6F9B60" + "foreground": "#768390" } }, { "scope": [ - "keyword", - "storage.modifier", - "storage.type", - "keyword.operator.new", - "keyword.operator.expression", - "keyword.operator.cast", - "keyword.operator.sizeof", - "keyword.operator.instanceof" + "constant.other.placeholder", + "constant.character" ], "settings": { - "foreground": "#4F8FDD" + "foreground": "#F47067" } }, { "scope": [ - "string" + "constant", + "entity.name.constant", + "variable.other.constant", + "variable.other.enummember", + "variable.language", + "entity" ], "settings": { - "foreground": "#C48081" + "foreground": "#6CB6FF" } }, { - "name": "Language constants", "scope": [ - "constant.language" + "entity.name", + "meta.export.default", + "meta.definition.variable" ], "settings": { - "foreground": "#4F8FDD" + "foreground": "#F69D50" + } + }, + { + "scope": [ + "variable.parameter.function", + "meta.jsx.children", + "meta.block", + "meta.tag.attributes", + "entity.name.constant", + "meta.object.member", + "meta.embedded.expression" + ], + "settings": { + "foreground": "#ADBAC7" + } + }, + { + "scope": "entity.name.function", + "settings": { + "foreground": "#DCBDFB" } }, { - "name": "HTML/XML tags", "scope": [ "entity.name.tag", - "meta.tag.sgml", - "markup.deleted.git_gutter" + "support.class.component" ], "settings": { - "foreground": "#4F9BDD" + "foreground": "#8DDB8C" } }, { - "name": "HTML/XML tag punctuation", - "scope": [ - "punctuation.definition.tag.html", - "punctuation.definition.tag.begin.html", - "punctuation.definition.tag.end.html" - ], + "scope": "keyword", "settings": { - "foreground": "#7A828B" - } - }, - { - "name": "HTML/XML attribute names", - "scope": [ - "entity.other.attribute-name" - ], - "settings": { - "foreground": "#90D5FF" - } - }, - { - "name": "Operators", - "scope": [ - "keyword.operator" - ], - "settings": { - "foreground": "#C5CCD6" - } - }, - { - "name": "Function declarations", - "scope": [ - "entity.name.function", - "support.function", - "support.constant.handlebars", - "source.powershell variable.other.member", - "entity.name.operator.custom-literal" - ], - "settings": { - "foreground": "#D1D6AE" - } - }, - { - "name": "Types declaration and references", - "scope": [ - "support.class", - "support.type", - "entity.name.type", - "entity.name.namespace", - "entity.other.attribute", - "entity.name.scope-resolution", - "entity.name.class", - "storage.type.numeric.go", - "storage.type.byte.go", - "storage.type.boolean.go", - "storage.type.string.go", - "storage.type.uintptr.go", - "storage.type.error.go", - "storage.type.rune.go", - "storage.type.cs", - "storage.type.generic.cs", - "storage.type.modifier.cs", - "storage.type.variable.cs", - "storage.type.annotation.java", - "storage.type.generic.java", - "storage.type.java", - "storage.type.object.array.java", - "storage.type.primitive.array.java", - "storage.type.primitive.java", - "storage.type.token.java", - "storage.type.groovy", - "storage.type.annotation.groovy", - "storage.type.parameters.groovy", - "storage.type.generic.groovy", - "storage.type.object.array.groovy", - "storage.type.primitive.array.groovy", - "storage.type.primitive.groovy" - ], - "settings": { - "foreground": "#48C9C4" - } - }, - { - "name": "Types declaration and references, TS grammar specific", - "scope": [ - "meta.type.cast.expr", - "meta.type.new.expr", - "support.constant.math", - "support.constant.dom", - "support.constant.json", - "entity.other.inherited-class", - "punctuation.separator.namespace.ruby" - ], - "settings": { - "foreground": "#48C9B9" - } - }, - { - "name": "Control flow / Special keywords", - "scope": [ - "keyword.control", - "source.cpp keyword.operator.new", - "keyword.operator.delete", - "keyword.other.using", - "keyword.other.directive.using", - "keyword.other.operator", - "entity.name.operator" - ], - "settings": { - "foreground": "#C184C6" - } - }, - { - "name": "Variable and parameter name", - "scope": [ - "variable", - "meta.definition.variable.name", - "support.variable", - "entity.name.variable", - "constant.other.placeholder" - ], - "settings": { - "foreground": "#90D5FF" - } - }, - { - "name": "Constants and enums", - "scope": [ - "variable.other.constant", - "variable.other.enummember" - ], - "settings": { - "foreground": "#4CBDFF" - } - }, - { - "name": "Object keys, TS grammar specific", - "scope": [ - "meta.object-literal.key" - ], - "settings": { - "foreground": "#90D5FF" - } - }, - { - "name": "CSS property value", - "scope": [ - "support.constant.property-value", - "support.constant.font-name", - "support.constant.media-type", - "support.constant.media", - "constant.other.color.rgb-value", - "constant.other.rgb-value", - "support.constant.color" - ], - "settings": { - "foreground": "#C48F80" - } - }, - { - "name": "Regular expression groups", - "scope": [ - "punctuation.definition.group.regexp", - "punctuation.definition.group.assertion.regexp", - "punctuation.definition.character-class.regexp", - "punctuation.character.set.begin.regexp", - "punctuation.character.set.end.regexp", - "keyword.operator.negation.regexp", - "support.other.parenthesis.regexp" - ], - "settings": { - "foreground": "#C49580" + "foreground": "#F47067" } }, { "scope": [ - "constant.character.character-class.regexp", - "constant.other.character-class.set.regexp", - "constant.other.character-class.regexp", - "constant.character.set.regexp" + "storage", + "storage.type" ], "settings": { - "foreground": "#C86971" + "foreground": "#F47067" } }, { "scope": [ - "keyword.operator.or.regexp", - "keyword.control.anchor.regexp" + "storage.modifier.package", + "storage.modifier.import", + "storage.type.java" ], "settings": { - "foreground": "#CBD6AE" - } - }, - { - "scope": "keyword.operator.quantifier.regexp", - "settings": { - "foreground": "#CCBD84" + "foreground": "#ADBAC7" } }, { "scope": [ - "constant.character", - "constant.other.option" + "string", + "string punctuation.section.embedded source" ], "settings": { - "foreground": "#4F9BDD" + "foreground": "#96D0FF" } }, { - "scope": "constant.character.escape", + "scope": "support", "settings": { - "foreground": "#CCB784" + "foreground": "#6CB6FF" } }, { - "scope": "entity.name.label", + "scope": "meta.property-name", "settings": { - "foreground": "#BAC2CC" + "foreground": "#6CB6FF" } }, { - "name": "Numbers", - "scope": [ - "constant.numeric" - ], + "scope": "variable", "settings": { - "foreground": "#A8CAAD" + "foreground": "#F69D50" } }, { - "name": "Markup Heading", - "scope": "markup.heading", + "scope": "variable.other", "settings": { - "foreground": "#64b0df", - "fontStyle": "bold" + "foreground": "#ADBAC7" } }, { - "name": "Markup Bold", - "scope": "markup.bold", - "settings": { - "foreground": "#C48081", - "fontStyle": "bold" - } - }, - { - "name": "Markup Italic", - "scope": "markup.italic", + "scope": "invalid.broken", "settings": { + "foreground": "#FF938A", "fontStyle": "italic" } }, { - "name": "Markup Strikethrough", - "scope": "markup.strikethrough", + "scope": "invalid.deprecated", "settings": { - "fontStyle": "strikethrough" + "foreground": "#FF938A", + "fontStyle": "italic" } }, { - "name": "Markup Underline", - "scope": "markup.underline", + "scope": "invalid.illegal", + "settings": { + "foreground": "#FF938A", + "fontStyle": "italic" + } + }, + { + "scope": "invalid.unimplemented", + "settings": { + "foreground": "#FF938A", + "fontStyle": "italic" + } + }, + { + "scope": "carriage-return", + "settings": { + "foreground": "#CDD9E5", + "background": "#F47067", + "fontStyle": "italic underline", + "content": "^M" + } + }, + { + "scope": "message.error", + "settings": { + "foreground": "#FF938A" + } + }, + { + "scope": "string variable", + "settings": { + "foreground": "#6CB6FF" + } + }, + { + "scope": [ + "source.regexp", + "string.regexp" + ], + "settings": { + "foreground": "#96D0FF" + } + }, + { + "scope": [ + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition" + ], + "settings": { + "foreground": "#96D0FF" + } + }, + { + "scope": "string.regexp constant.character.escape", + "settings": { + "foreground": "#8DDB8C", + "fontStyle": "bold" + } + }, + { + "scope": "support.constant", + "settings": { + "foreground": "#6CB6FF" + } + }, + { + "scope": "support.variable", + "settings": { + "foreground": "#6CB6FF" + } + }, + { + "scope": "support.type.property-name.json", + "settings": { + "foreground": "#8DDB8C" + } + }, + { + "scope": "meta.module-reference", + "settings": { + "foreground": "#6CB6FF" + } + }, + { + "scope": "punctuation.definition.list.begin.markdown", + "settings": { + "foreground": "#F69D50" + } + }, + { + "scope": [ + "markup.heading", + "markup.heading entity.name" + ], + "settings": { + "foreground": "#6CB6FF", + "fontStyle": "bold" + } + }, + { + "scope": "markup.quote", + "settings": { + "foreground": "#8DDB8C" + } + }, + { + "scope": "markup.italic", + "settings": { + "foreground": "#ADBAC7", + "fontStyle": "italic" + } + }, + { + "scope": "markup.bold", + "settings": { + "foreground": "#ADBAC7", + "fontStyle": "bold" + } + }, + { + "scope": [ + "markup.underline" + ], "settings": { "fontStyle": "underline" } }, { - "name": "Markup Quote", - "scope": "markup.quote", + "scope": [ + "markup.strikethrough" + ], "settings": { - "foreground": "#C184C6" + "fontStyle": "strikethrough" } }, { - "name": "Markup List", - "scope": "markup.list", - "settings": { - "foreground": "#48C9C4" - } - }, - { - "name": "Markup Inline Raw", "scope": "markup.inline.raw", "settings": { - "foreground": "#D1D6AE" + "foreground": "#6CB6FF" } }, { - "name": "Markup Raw/Fenced Code Block", "scope": [ - "markup.raw", - "markup.fenced_code" + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted" ], "settings": { - "foreground": "#8C8C8C" + "foreground": "#FF938A", + "background": "#5D0F12" } }, { - "name": "Markup Link", "scope": [ - "meta.link", - "markup.underline.link" + "punctuation.section.embedded" ], "settings": { - "foreground": "#48A0C7" + "foreground": "#F47067" + } + }, + { + "scope": [ + "markup.inserted", + "meta.diff.header.to-file", + "punctuation.definition.inserted" + ], + "settings": { + "foreground": "#8DDB8C", + "background": "#113417" + } + }, + { + "scope": [ + "markup.changed", + "punctuation.definition.changed" + ], + "settings": { + "foreground": "#F69D50", + "background": "#682D0F" + } + }, + { + "scope": [ + "markup.ignored", + "markup.untracked" + ], + "settings": { + "foreground": "#2D333B", + "background": "#6CB6FF" + } + }, + { + "scope": "meta.diff.range", + "settings": { + "foreground": "#DCBDFB", + "fontStyle": "bold" + } + }, + { + "scope": "meta.diff.header", + "settings": { + "foreground": "#6CB6FF" + } + }, + { + "scope": "meta.separator", + "settings": { + "foreground": "#6CB6FF", + "fontStyle": "bold" + } + }, + { + "scope": "meta.output", + "settings": { + "foreground": "#6CB6FF" + } + }, + { + "scope": [ + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote" + ], + "settings": { + "foreground": "#768390" + } + }, + { + "scope": "brackethighlighter.unmatched", + "settings": { + "foreground": "#FF938A" + } + }, + { + "scope": [ + "constant.other.reference.link", + "string.other.link" + ], + "settings": { + "foreground": "#96D0FF" + } + }, + { + "scope": "token.info-token", + "settings": { + "foreground": "#6796E6" + } + }, + { + "scope": "token.warn-token", + "settings": { + "foreground": "#CD9731" + } + }, + { + "scope": "token.error-token", + "settings": { + "foreground": "#F44747" + } + }, + { + "scope": "token.debug-token", + "settings": { + "foreground": "#B267E6" } } ], - "semanticHighlighting": true, - "semanticTokenColors": { - "newOperator": "#C586C0", - "stringLiteral": "#ce9178", - "customLiteral": "#DCDCAA", - "numberLiteral": "#b5cea8" - } + "semanticHighlighting": true } diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 9a3a44fb872..6df7171b0d2 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -27,7 +27,7 @@ "button.secondaryHoverBackground": "#F2F3F4", "checkbox.background": "#EAEAEA", "checkbox.border": "#D8D8D8", - "checkbox.foreground": "#202020", + "checkbox.foreground": "#606060", "dropdown.background": "#FFFFFF", "dropdown.border": "#D8D8D8", "dropdown.foreground": "#202020", @@ -50,9 +50,10 @@ "inputValidation.errorForeground": "#202020", "scrollbar.shadow": "#00000000", "widget.shadow": "#00000000", - "widget.border": "#EEEEF1", + "widget.border": "#E2E2E5", "editorStickyScroll.shadow": "#00000000", "editorStickyScrollHover.background": "#F0F0F3", + "editorStickyScroll.border": "#F0F1F2FF", "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", "listFilterWidget.shadow": "#00000000", @@ -104,7 +105,7 @@ "menu.selectionBackground": "#0069CC1A", "menu.selectionForeground": "#202020", "menu.separatorBackground": "#EEEEF1", - "menu.border": "#F0F1F2FF", + "menu.border": "#E4E5E6FF", "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", "commandCenter.background": "#FAFAFD", @@ -115,7 +116,7 @@ "editorLineNumber.foreground": "#606060", "editorLineNumber.activeForeground": "#202020", "editorCursor.foreground": "#202020", - "editor.selectionBackground": "#0069CC1A", + "editor.selectionBackground": "#0069CC40", "editor.inactiveSelectionBackground": "#0069CC1A", "editor.selectionHighlightBackground": "#0069CC15", "editor.wordHighlightBackground": "#0069CC26", @@ -134,26 +135,26 @@ "editorCodeLens.foreground": "#606060", "editorBracketMatch.background": "#0069CC40", "editorBracketMatch.border": "#F0F1F2FF", - "editorWidget.background": "#F0F0F3", - "editorWidget.border": "#EEEEF1", + "editorWidget.background": "#FAFAFD", + "editorWidget.border": "#E4E5E6FF", "editorWidget.foreground": "#202020", - "editorSuggestWidget.background": "#F0F0F3", - "editorSuggestWidget.border": "#EEEEF1", + "editorSuggestWidget.background": "#FAFAFD", + "editorSuggestWidget.border": "#E4E5E6FF", "editorSuggestWidget.foreground": "#202020", "editorSuggestWidget.highlightForeground": "#0069CC", "editorSuggestWidget.selectedBackground": "#0069CC26", - "editorHoverWidget.background": "#F0F0F3", - "editorHoverWidget.border": "#EEEEF1", + "editorHoverWidget.background": "#FAFAFD", + "editorHoverWidget.border": "#E4E5E6FF", "peekView.border": "#0069CC", - "peekViewEditor.background": "#F0F0F3", + "peekViewEditor.background": "#FAFAFD", "peekViewEditor.matchHighlightBackground": "#0069CC33", - "peekViewResult.background": "#F0F0F3", + "peekViewResult.background": "#FAFAFD", "peekViewResult.fileForeground": "#202020", "peekViewResult.lineForeground": "#606060", "peekViewResult.matchHighlightBackground": "#0069CC33", "peekViewResult.selectionBackground": "#0069CC26", "peekViewResult.selectionForeground": "#202020", - "peekViewTitle.background": "#F0F0F3", + "peekViewTitle.background": "#FAFAFD", "peekViewTitleDescription.foreground": "#606060", "peekViewTitleLabel.foreground": "#202020", "editorGutter.addedBackground": "#587c0c", @@ -187,22 +188,22 @@ "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", "statusBarItem.prominentHoverBackground": "#0069CC", - "toolbar.hoverBackground": "#DADADA4f", + "toolbar.hoverBackground": "#00000010", "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", "tab.inactiveBackground": "#FAFAFD", "tab.inactiveForeground": "#606060", "tab.border": "#F0F1F2FF", "tab.lastPinnedBorder": "#F0F1F2FF", - "tab.activeBorder": "#FAFAFD", "tab.activeBorderTop": "#000000", - "tab.hoverBackground": "#DADADA4f", + "tab.hoverBackground": "#FFFFFF", "tab.hoverForeground": "#202020", "tab.unfocusedActiveBackground": "#FAFAFD", "tab.unfocusedActiveForeground": "#606060", "tab.unfocusedInactiveBackground": "#FAFAFD", "tab.unfocusedInactiveForeground": "#BBBBBB", "editorGroupHeader.tabsBackground": "#FAFAFD", + "tab.activeBorder": "#FFFFFF", "editorGroupHeader.tabsBorder": "#F0F1F2FF", "breadcrumb.foreground": "#606060", "breadcrumb.background": "#FFFFFF", @@ -271,373 +272,403 @@ "charts.green": "#388A34", "charts.purple": "#652D90", "agentStatusIndicator.background": "#FFFFFF", + "inlineChat.border": "#00000000", + "minimapSlider.background": "#99999926", + "minimapSlider.hoverBackground": "#99999940", + "minimapSlider.activeBackground": "#99999955", }, "tokenColors": [ { "scope": [ - "comment" + "comment", + "punctuation.definition.comment", + "string.comment" ], "settings": { - "foreground": "#60984D" + "foreground": "#6e7781" } }, { "scope": [ - "keyword", - "storage.modifier", - "storage.type", - "keyword.operator.new", - "keyword.operator.expression", - "keyword.operator.cast", - "keyword.operator.sizeof", - "keyword.operator.instanceof" + "constant.other.placeholder", + "constant.character" ], "settings": { - "foreground": "#5460C1" + "foreground": "#cf222e" } }, { "scope": [ - "string" + "constant", + "entity.name.constant", + "variable.other.constant", + "variable.other.enummember", + "variable.language", + "entity" ], "settings": { - "foreground": "#B86855" + "foreground": "#0550ae" } }, { - "name": "Language constants", "scope": [ - "constant.language" + "entity.name", + "meta.export.default", + "meta.definition.variable" ], "settings": { - "foreground": "#5460C1" + "foreground": "#953800" + } + }, + { + "scope": [ + "variable.parameter.function", + "meta.jsx.children", + "meta.block", + "meta.tag.attributes", + "entity.name.constant", + "meta.object.member", + "meta.embedded.expression" + ], + "settings": { + "foreground": "#1f2328" + } + }, + { + "scope": "entity.name.function", + "settings": { + "foreground": "#8250df" } }, { - "name": "HTML/XML tags", "scope": [ "entity.name.tag", - "meta.tag.sgml", - "markup.deleted.git_gutter" + "support.class.component" ], "settings": { - "foreground": "#5751DE" + "foreground": "#116329" } }, { - "name": "HTML/XML tag punctuation", - "scope": [ - "punctuation.definition.tag.html", - "punctuation.definition.tag.begin.html", - "punctuation.definition.tag.end.html" - ], + "scope": "keyword", "settings": { - "foreground": "#93201A" - } - }, - { - "name": "HTML/XML attribute names", - "scope": [ - "entity.other.attribute-name" - ], - "settings": { - "foreground": "#E75854" - } - }, - { - "name": "Operators", - "scope": [ - "keyword.operator" - ], - "settings": { - "foreground": "#573F35" - } - }, - { - "name": "Function declarations", - "scope": [ - "entity.name.function", - "support.function", - "support.constant.handlebars", - "source.powershell variable.other.member", - "entity.name.operator.custom-literal" - ], - "settings": { - "foreground": "#98863B" - } - }, - { - "name": "Types declaration and references", - "scope": [ - "support.class", - "support.type", - "entity.name.type", - "entity.name.namespace", - "entity.other.attribute", - "entity.name.scope-resolution", - "entity.name.class", - "storage.type.numeric.go", - "storage.type.byte.go", - "storage.type.boolean.go", - "storage.type.string.go", - "storage.type.uintptr.go", - "storage.type.error.go", - "storage.type.rune.go", - "storage.type.cs", - "storage.type.generic.cs", - "storage.type.modifier.cs", - "storage.type.variable.cs", - "storage.type.annotation.java", - "storage.type.generic.java", - "storage.type.java", - "storage.type.object.array.java", - "storage.type.primitive.array.java", - "storage.type.primitive.java", - "storage.type.token.java", - "storage.type.groovy", - "storage.type.annotation.groovy", - "storage.type.parameters.groovy", - "storage.type.generic.groovy", - "storage.type.object.array.groovy", - "storage.type.primitive.array.groovy", - "storage.type.primitive.groovy" - ], - "settings": { - "foreground": "#46969A" - } - }, - { - "name": "Types declaration and references, TS grammar specific", - "scope": [ - "meta.type.cast.expr", - "meta.type.new.expr", - "support.constant.math", - "support.constant.dom", - "support.constant.json", - "entity.other.inherited-class", - "punctuation.separator.namespace.ruby" - ], - "settings": { - "foreground": "#419BB3" - } - }, - { - "name": "Control flow / Special keywords", - "scope": [ - "keyword.control", - "source.cpp keyword.operator.new", - "source.cpp keyword.operator.delete", - "keyword.other.using", - "keyword.other.directive.using", - "keyword.other.operator", - "entity.name.operator" - ], - "settings": { - "foreground": "#8F41AD" - } - }, - { - "name": "Variable and parameter name", - "scope": [ - "variable", - "meta.definition.variable.name", - "support.variable", - "entity.name.variable", - "constant.other.placeholder" - ], - "settings": { - "foreground": "#282D85" - } - }, - { - "name": "Constants and enums", - "scope": [ - "variable.other.constant", - "variable.other.enummember" - ], - "settings": { - "foreground": "#3086C5" - } - }, - { - "name": "Object keys, TS grammar specific", - "scope": [ - "meta.object-literal.key" - ], - "settings": { - "foreground": "#282D85" - } - }, - { - "name": "CSS property value", - "scope": [ - "support.constant.property-value", - "support.constant.font-name", - "support.constant.media-type", - "support.constant.media", - "constant.other.color.rgb-value", - "constant.other.rgb-value", - "support.constant.color" - ], - "settings": { - "foreground": "#2D6AAE" - } - }, - { - "name": "Regular expression groups", - "scope": [ - "punctuation.definition.group.regexp", - "punctuation.definition.group.assertion.regexp", - "punctuation.definition.character-class.regexp", - "punctuation.character.set.begin.regexp", - "punctuation.character.set.end.regexp", - "keyword.operator.negation.regexp", - "support.other.parenthesis.regexp" - ], - "settings": { - "foreground": "#D68490" + "foreground": "#cf222e" } }, { "scope": [ - "constant.character.character-class.regexp", - "constant.other.character-class.set.regexp", - "constant.other.character-class.regexp", - "constant.character.set.regexp" + "storage", + "storage.type" ], "settings": { - "foreground": "#A63350" - } - }, - { - "scope": "keyword.operator.quantifier.regexp", - "settings": { - "foreground": "#573F35" + "foreground": "#cf222e" } }, { "scope": [ - "keyword.operator.or.regexp", - "keyword.control.anchor.regexp" + "storage.modifier.package", + "storage.modifier.import", + "storage.type.java" ], "settings": { - "foreground": "#C54C5B" + "foreground": "#1f2328" } }, { "scope": [ - "constant.character", - "constant.other.option" + "string", + "string punctuation.section.embedded source" ], "settings": { - "foreground": "#5751DE" + "foreground": "#0a3069" } }, { - "scope": "constant.character.escape", + "scope": "support", "settings": { - "foreground": "#E14A46" + "foreground": "#0550ae" } }, { - "scope": "entity.name.label", + "scope": "meta.property-name", "settings": { - "foreground": "#5C3923" + "foreground": "#0550ae" + } + }, + { + "scope": "variable", + "settings": { + "foreground": "#953800" + } + }, + { + "scope": "variable.other", + "settings": { + "foreground": "#1f2328" + } + }, + { + "scope": "invalid.broken", + "settings": { + "fontStyle": "italic", + "foreground": "#82071e" + } + }, + { + "scope": "invalid.deprecated", + "settings": { + "fontStyle": "italic", + "foreground": "#82071e" + } + }, + { + "scope": "invalid.illegal", + "settings": { + "fontStyle": "italic", + "foreground": "#82071e" + } + }, + { + "scope": "invalid.unimplemented", + "settings": { + "fontStyle": "italic", + "foreground": "#82071e" + } + }, + { + "scope": "carriage-return", + "settings": { + "fontStyle": "italic underline", + "background": "#cf222e", + "foreground": "#f6f8fa", + "content": "^M" + } + }, + { + "scope": "message.error", + "settings": { + "foreground": "#82071e" + } + }, + { + "scope": "string variable", + "settings": { + "foreground": "#0550ae" } }, { - "name": "Numbers", "scope": [ - "constant.numeric" + "source.regexp", + "string.regexp" ], "settings": { - "foreground": "#2B9A69" + "foreground": "#0a3069" } }, { - "name": "Markup Heading", - "scope": "markup.heading", + "scope": [ + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition" + ], "settings": { - "foreground": "#5460C1", - "fontStyle": "bold" + "foreground": "#0a3069" } }, { - "name": "Markup Bold", - "scope": "markup.bold", + "scope": "string.regexp constant.character.escape", "settings": { - "foreground": "#B86855", - "fontStyle": "bold" + "fontStyle": "bold", + "foreground": "#116329" + } + }, + { + "scope": "support.constant", + "settings": { + "foreground": "#0550ae" + } + }, + { + "scope": "support.variable", + "settings": { + "foreground": "#0550ae" + } + }, + { + "scope": "support.type.property-name.json", + "settings": { + "foreground": "#116329" + } + }, + { + "scope": "meta.module-reference", + "settings": { + "foreground": "#0550ae" + } + }, + { + "scope": "punctuation.definition.list.begin.markdown", + "settings": { + "foreground": "#953800" + } + }, + { + "scope": [ + "markup.heading", + "markup.heading entity.name" + ], + "settings": { + "fontStyle": "bold", + "foreground": "#0550ae" + } + }, + { + "scope": "markup.quote", + "settings": { + "foreground": "#116329" } }, { - "name": "Markup Italic", "scope": "markup.italic", "settings": { - "fontStyle": "italic" + "fontStyle": "italic", + "foreground": "#1f2328" } }, { - "name": "Markup Strikethrough", - "scope": "markup.strikethrough", + "scope": "markup.bold", "settings": { - "fontStyle": "strikethrough" + "fontStyle": "bold", + "foreground": "#1f2328" } }, { - "name": "Markup Underline", - "scope": "markup.underline", + "scope": [ + "markup.underline" + ], "settings": { "fontStyle": "underline" } }, { - "name": "Markup Quote", - "scope": "markup.quote", + "scope": [ + "markup.strikethrough" + ], "settings": { - "foreground": "#8F41AD" + "fontStyle": "strikethrough" } }, { - "name": "Markup List", - "scope": "markup.list", - "settings": { - "foreground": "#46969A" - } - }, - { - "name": "Markup Inline Raw", "scope": "markup.inline.raw", "settings": { - "foreground": "#98863B" + "foreground": "#0550ae" } }, { - "name": "Markup Raw/Fenced Code Block", "scope": [ - "markup.raw", - "markup.fenced_code" + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted" ], "settings": { - "foreground": "#606060" + "background": "#ffebe9", + "foreground": "#82071e" } }, { - "name": "Markup Link", "scope": [ - "meta.link", - "markup.underline.link" + "punctuation.section.embedded" ], "settings": { - "foreground": "#0069CC" + "foreground": "#cf222e" + } + }, + { + "scope": [ + "markup.inserted", + "meta.diff.header.to-file", + "punctuation.definition.inserted" + ], + "settings": { + "background": "#dafbe1", + "foreground": "#116329" + } + }, + { + "scope": [ + "markup.changed", + "punctuation.definition.changed" + ], + "settings": { + "background": "#ffd8b5", + "foreground": "#953800" + } + }, + { + "scope": [ + "markup.ignored", + "markup.untracked" + ], + "settings": { + "foreground": "#eaeef2", + "background": "#0550ae" + } + }, + { + "scope": "meta.diff.range", + "settings": { + "foreground": "#8250df", + "fontStyle": "bold" + } + }, + { + "scope": "meta.diff.header", + "settings": { + "foreground": "#0550ae" + } + }, + { + "scope": "meta.separator", + "settings": { + "fontStyle": "bold", + "foreground": "#0550ae" + } + }, + { + "scope": "meta.output", + "settings": { + "foreground": "#0550ae" + } + }, + { + "scope": [ + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote" + ], + "settings": { + "foreground": "#57606a" + } + }, + { + "scope": "brackethighlighter.unmatched", + "settings": { + "foreground": "#82071e" + } + }, + { + "scope": [ + "constant.other.reference.link", + "string.other.link" + ], + "settings": { + "foreground": "#0a3069" } } ], - "semanticHighlighting": true, - "semanticTokenColors": { - "newOperator": "#AF00DB", - "stringLiteral": "#a31515", - "customLiteral": "#795E26", - "numberLiteral": "#098658" - } + "semanticHighlighting": true } diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css deleted file mode 100644 index 87790e5e7eb..00000000000 --- a/extensions/theme-2026/themes/styles.css +++ /dev/null @@ -1,502 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -:root { - --radius-sm: 4px; - --radius-lg: 8px; - - --shadow-sm: 0 0 4px rgba(0, 0, 0, 0.08); - --shadow-md: 0 0 6px rgba(0, 0, 0, 0.08); - --shadow-lg: 0 0 12px rgba(0, 0, 0, 0.14); - --shadow-xl: 0 0 20px rgba(0, 0, 0, 0.15); - --shadow-hover: 0 0 8px rgba(0, 0, 0, 0.12); - --shadow-sm-strong: 0 0 4px rgba(0, 0, 0, 0.18); - --shadow-active-tab: 0 8px 12px rgba(0, 0, 0, 0.02); - - /* Panel depth shadows cast onto the editor surface */ - --shadow-depth-x: 5px 0 10px -4px rgba(0, 0, 0, 0.05); - --shadow-depth-y: 0 5px 10px -4px rgba(0, 0, 0, 0.04); -} - -/* Dark theme: add brightness reduction for contrast-safe luminosity blending over bright backgrounds */ -.monaco-workbench.vs-dark { - --shadow-depth-x: 5px 0 12px -4px rgba(0, 0, 0, 0.14); - --shadow-depth-y: 0 5px 12px -4px rgba(0, 0, 0, 0.10); -} - -/* Stealth Shadows - panels appear to float above the editor. - * Instead of z-index on panels (which breaks webviews, iframes, sashes), - * the editor draws its own "received shadow" via a ::after pseudo-element. - * The surrounding panels stay at default stacking — no z-index needed. */ - -/* Activity Bar - only needs shadow when sidebar is hidden */ -.monaco-workbench.nosidebar .part.activitybar { - box-shadow: var(--shadow-md); -} - -.monaco-workbench.activitybar-right .part.activitybar { - box-shadow: var(--shadow-md); -} - -.monaco-pane-view .split-view-view:first-of-type > .pane > .pane-header { - border-top: 1px solid var(--vscode-sideBarSectionHeader-border) !important; -} - -/* Editor - the ::after pseudo-element draws inset shadows on each edge, - * creating the illusion that sidebar, panel, and auxiliarybar float above it. */ -.monaco-workbench.vs .part.editor { - position: relative; -} - -.monaco-workbench.vs .part.editor::after { - content: ''; - position: absolute; - inset: 0; - pointer-events: none; - z-index: 10; - box-shadow: - inset var(--shadow-depth-x), - inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04), - inset 0 calc(-1 * 5px) 10px -4px rgba(0, 0, 0, 0.05); -} - -/* When sidebar is on the right, flip the stronger shadow to the right edge */ -.monaco-workbench.sidebar-right.vs .part.editor::after { - box-shadow: - inset 5px 0 10px -4px rgba(0, 0, 0, 0.04), - inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.05), - inset 0 calc(-1 * 5px) 10px -4px rgba(0, 0, 0, 0.05); -} - -/* Panel positions: strengthen the shadow on whichever edge faces the panel */ -.monaco-workbench.panel-position-left.vs .part.editor::after { - box-shadow: - inset var(--shadow-depth-x), - inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04); -} - -.monaco-workbench.panel-position-right.vs .part.editor::after { - box-shadow: - inset 5px 0 10px -4px rgba(0, 0, 0, 0.04), - inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.05); -} - -.monaco-workbench.panel-position-top.vs .part.editor::after { - box-shadow: - inset var(--shadow-depth-x), - inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04), - inset 0 var(--shadow-depth-y); -} -.monaco-workbench.vs .part.editor > .content .editor-group-container > .title { - box-shadow: none; -} - -.monaco-workbench.vs .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { - box-shadow: inset var(--shadow-active-tab); -} - -.monaco-workbench.vs .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover:not(.active) { - box-shadow: var(--shadow-sm); -} - -/* Tab border bottom - make transparent */ -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-and-actions-container { - --tabs-border-bottom-color: transparent !important; -} - -.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab { - --tab-border-bottom-color: transparent !important; -} - -/* Title Bar */ -.monaco-workbench.vs .part.titlebar { - box-shadow: var(--shadow-md); -} - -/* Quick Input (Command Palette) */ -.monaco-workbench .quick-input-widget { - box-shadow: var(--shadow-xl) !important; -} - -.monaco-workbench.vs-dark .quick-input-widget { - border: 1px solid var(--vscode-menu-border) !important; -} - -.monaco-workbench .quick-input-widget .quick-input-header, -.monaco-workbench .quick-input-widget .quick-input-list, -.monaco-workbench .quick-input-widget .quick-input-titlebar, -.monaco-workbench .quick-input-widget .quick-input-title, -.monaco-workbench .quick-input-widget .quick-input-description, -.monaco-workbench .quick-input-widget .quick-input-filter, -.monaco-workbench .quick-input-widget .quick-input-action, -.monaco-workbench .quick-input-widget .quick-input-message, -.monaco-workbench .quick-input-widget .monaco-list, -.monaco-workbench .quick-input-widget .monaco-list-row:not(:has(.quick-input-list-separator-border)) { - border-color: transparent !important; - outline: none !important; -} - -.monaco-workbench .quick-input-widget .quick-input-list .monaco-list-rows { - background: transparent !important; -} - -.monaco-workbench .quick-input-list .quick-input-list-entry .quick-input-list-separator { - height: 16px; - margin-top: 2px; - display: flex; - align-items: center; - font-size: 11px; - padding: 0 4px; - border-radius: var(--vscode-cornerRadius-small) !important; - background: transparent !important; - color: var(--vscode-descriptionForeground) !important; - border: 1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 50%, transparent) !important; - margin-right: 8px; -} - -.monaco-workbench .monaco-list-row.focused .quick-input-list-entry .quick-input-list-separator, -.monaco-workbench .monaco-list-row.selected .quick-input-list-entry .quick-input-list-separator, -.monaco-workbench .monaco-list-row:hover .quick-input-list-entry .quick-input-list-separator { - background: transparent !important; - color: inherit !important; - border: none !important; - padding: 0; -} - -.monaco-workbench .quick-input-widget .monaco-list-rows { - background: transparent !important; -} - -.monaco-workbench .quick-input-widget .monaco-inputbox { - box-shadow: none !important; - background: transparent !important; -} - -.monaco-workbench .quick-input-widget .quick-input-filter .monaco-inputbox { - background: color-mix(in srgb, var(--vscode-input-background) 60%, transparent) !important; -} - -/* Chat Widget */ - -.monaco-workbench.vs .interactive-session .chat-input-container { - box-shadow: inset var(--shadow-sm); -} - -.monaco-workbench .part.panel .interactive-session, -.monaco-workbench .part.auxiliarybar .interactive-session { - position: relative; -} - -.monaco-workbench .interactive-session .chat-editing-session .chat-editing-session-container { - background-color: transparent !important; -} - -/* Notifications */ - -.monaco-workbench .notifications-toasts, -.monaco-workbench > .notifications-toasts .notification-toast-container { - overflow: visible; -} - -.monaco-workbench .notifications-list-container .monaco-list-rows { - background: transparent !important; -} - -/* Context Menus */ - -.monaco-workbench .context-view .monaco-menu { - box-shadow: var(--shadow-lg); - border: none; -} - -.monaco-workbench .monaco-select-box-dropdown-container { - box-shadow: var(--shadow-lg); -} - -.monaco-workbench .monaco-menu-container > .monaco-scrollable-element { - box-shadow: var(--shadow-lg) !important; -} - -.monaco-workbench .action-widget .action-widget-action-bar { - background: transparent; -} - -/* Suggest Widget */ -.monaco-workbench .monaco-editor .suggest-widget { - box-shadow: var(--shadow-lg); -} - -.monaco-workbench.vs-dark .monaco-editor .suggest-widget { - border: 1px solid var(--vscode-editorWidget-border); -} - -/* Find Widget */ -.monaco-workbench .monaco-editor .find-widget { - box-shadow: var(--shadow-lg); -} - -.monaco-workbench .inline-chat-gutter-menu { - box-shadow: var(--shadow-lg); -} - -/* Dialog */ -.monaco-workbench .monaco-dialog-box { - border: 1px solid var(--vscode-dialog-border); - box-shadow: var(--shadow-xl); -} - -/* Peek View */ -.monaco-workbench .monaco-editor .peekview-widget { - box-shadow: var(--shadow-hover); -} - -.monaco-workbench .monaco-editor .peekview-widget .head, -.monaco-workbench .monaco-editor .peekview-widget .body { - background: transparent !important; -} - -.monaco-editor .monaco-hover { - box-shadow: var(--shadow-sm-strong); -} - -.monaco-workbench .monaco-hover.workbench-hover, -.monaco-hover.workbench-hover { - box-shadow: var(--shadow-sm-strong); -} - -.monaco-workbench .defineKeybindingWidget { - border: 1px solid var(--vscode-editorWidget-border); - box-shadow: var(--shadow-lg) !important; -} - -.monaco-workbench .chat-editor-overlay-widget, -.monaco-workbench .chat-diff-change-content-widget { - box-shadow: var(--shadow-md); -} - -.monaco-workbench.vs-dark .chat-editor-overlay-widget, -.monaco-workbench.vs-dark .chat-diff-change-content-widget { - border: 1px solid var(--vscode-editorWidget-border); -} - -/* Settings */ -.monaco-workbench .settings-editor .settings-toc-container { - box-shadow: var(--shadow-sm); -} - -.monaco-workbench .settings-editor > .settings-header > .search-container > .search-container-widgets > .settings-count-widget { - border-radius: var(--radius-sm); - background: transparent !important; - color: var(--vscode-descriptionForeground) !important; - border: 1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 50%, transparent) !important; -} - -/* Welcome Tiles */ -.monaco-workbench .part.editor .welcomePageContainer .tile { - box-shadow: var(--shadow-md); - border: none; - border-radius: var(--radius-lg); -} - -.monaco-workbench .part.editor .welcomePageContainer .tile:hover { - box-shadow: var(--shadow-hover); -} - -/* Extensions */ -.monaco-workbench .extensions-list .extension-list-item { - box-shadow: var(--shadow-sm); - border: none; -} - -.monaco-workbench .extensions-list .extension-list-item:hover { - box-shadow: var(--shadow-md); -} - -/* Breadcrumbs */ - -.monaco-workbench.vs .breadcrumbs-control { - border-bottom: 1px solid var(--vscode-editorWidget-border); -} - -/* Input Boxes */ - - -.monaco-inputbox .monaco-action-bar .action-item .codicon, -.monaco-workbench .search-container .input-box, -.monaco-custom-toggle { - color: var(--vscode-icon-foreground) !important; -} - -/* Chat input toolbar icons should follow icon foreground token */ -.monaco-workbench .interactive-session .chat-input-toolbars .monaco-action-bar .action-item .codicon, -.monaco-workbench .interactive-session .chat-input-toolbars .action-label .codicon { - color: var(--vscode-icon-foreground) !important; -} - -/* Todo List Widget - remove shadows from buttons */ -.monaco-workbench.vs .chat-todo-list-widget .todo-list-expand .monaco-button, -.monaco-workbench.vs .chat-todo-list-widget .todo-list-expand .monaco-button:hover, -.monaco-workbench.vs .chat-todo-list-widget .todo-list-expand .monaco-button:active, -.monaco-workbench.vs .chat-todo-list-widget .todo-clear-button-container .monaco-button, -.monaco-workbench.vs .chat-todo-list-widget .todo-clear-button-container .monaco-button:hover, -.monaco-workbench.vs .chat-todo-list-widget .todo-clear-button-container .monaco-button:active { - box-shadow: none; -} - -/* Link buttons and tool call buttons - remove shadows */ -.monaco-workbench .monaco-button.link-button, -.monaco-workbench .monaco-button.link-button:hover, -.monaco-workbench .monaco-button.link-button:active, -.monaco-workbench .chat-confirmation-widget-title.monaco-button, -.monaco-workbench .chat-confirmation-widget-title.monaco-button:hover, -.monaco-workbench .chat-confirmation-widget-title.monaco-button:active, -.monaco-workbench .chat-used-context-label .monaco-button, -.monaco-workbench .chat-used-context-label .monaco-button:hover, -.monaco-workbench .chat-used-context-label .monaco-button:active { - box-shadow: none; -} - -/* Dropdowns */ -.monaco-workbench .monaco-dropdown .dropdown-menu { - box-shadow: var(--shadow-lg); -} - -/* SCM */ -.monaco-workbench .scm-view .scm-provider { - box-shadow: var(--shadow-sm); -} - -/* Debug Toolbar */ -.monaco-workbench .debug-toolbar { - box-shadow: var(--shadow-lg); -} - -.monaco-workbench .debug-hover-widget { - box-shadow: var(--shadow-lg); - color: var(--vscode-editor-foreground) !important; -} - -.monaco-editor .debug-hover-widget .debug-hover-tree .monaco-list-rows .monaco-list-row:hover:not(.highlighted):not(.selected):not(.focused) { - background-color: var(--vscode-list-hoverBackground); -} - -/* Action Widget */ -.monaco-workbench .action-widget { - box-shadow: var(--shadow-lg) !important; -} - -/* Parameter Hints */ -.monaco-workbench .monaco-editor .parameter-hints-widget { - box-shadow: var(--shadow-lg); -} - -/* Minimap */ - -.monaco-workbench .monaco-editor .minimap canvas { - opacity: 0.85; -} - -.monaco-workbench.vs-dark .monaco-editor .minimap, -.monaco-workbench .monaco-editor .minimap-shadow-visible { - box-shadow: var(--shadow-md); - opacity: 0.85; - background-color: var(--vscode-editor-background); - left: 0; -} - -/* Minimap autohide: ensure opacity:0 overrides the 0.85 above */ -.monaco-workbench .monaco-editor .minimap:is(.minimap-autohide-mouseover, .minimap-autohide-scroll) { - opacity: 0; -} - -.monaco-workbench .monaco-editor .minimap:is(.minimap-autohide-mouseover:hover, .minimap-autohide-scroll.active) { - opacity: 0.85; -} - -/* Sticky Scroll */ -.monaco-workbench .monaco-editor .sticky-widget { - box-shadow: var(--shadow-md) !important; - border-bottom: var(--vscode-editorWidget-border) !important; - background: transparent !important; -} - -.monaco-workbench .monaco-editor .sticky-widget > * { - background: transparent !important; -} - -.monaco-workbench.vs-dark .monaco-editor .sticky-widget { - border-bottom: none !important; -} - -.monaco-workbench .monaco-editor .sticky-widget .sticky-widget-lines-scrollable { - background: var(--vscode-editor-background) !important; -} - -.monaco-editor .sticky-widget .sticky-line-content { - background: var(--vscode-editor-background) !important; -} - -.monaco-workbench .monaco-editor .sticky-widget .sticky-widget-line-numbers { - background: var(--vscode-editor-background) !important; -} - -.monaco-workbench .monaco-editor .sticky-widget .sticky-line-content:hover { - background: var(--vscode-editorStickyScrollHover-background) !important; -} - -.monaco-editor .rename-box.preview { - box-shadow: var(--shadow-hover) !important; - border: 1px solid var(--vscode-editorWidget-border); -} - -/* Notebook */ - -.monaco-workbench .notebookOverlay .monaco-list-row .cell-editor-part:before { - box-shadow: inset var(--shadow-sm); -} - -.notebookOverlay .monaco-list-row .cell-title-toolbar { - background-color: var(--vscode-editorWidget-background) !important; - box-shadow: var(--shadow-sm); -} - -/* Inline Chat */ -.monaco-workbench .monaco-editor .inline-chat { - box-shadow: var(--shadow-lg); - border: none; -} - -/* Command Center */ -.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { - box-shadow: inset var(--shadow-sm) !important; -} - -.monaco-workbench .part.titlebar .command-center .agent-status-pill { - border-color: var(--vscode-input-border); -} - -.monaco-workbench .part.titlebar .command-center .agent-status-badge { - border-color: var(--vscode-input-border); -} - -.monaco-workbench.vs-dark .monaco-action-bar:not(.vertical) .agent-status-badge-section.sparkle .action-container:hover, -.monaco-workbench.vs-dark .monaco-action-bar:not(.vertical) .agent-status-badge-section.sparkle .dropdown-action-container:hover - { - background-color: var(--vscode-toolbar-hoverBackground); -} - -.monaco-workbench.vs-dark .monaco-action-bar:not(.vertical) .agent-status-badge .monaco-dropdown-with-primary:not(.disabled):hover { - background-color: var(--vscode-commandCenter-activeBackground); -} - -.monaco-workbench .unified-quick-access-tabs { - background: transparent; -} - -/* Quick Input List - use descriptionForeground color for descriptions */ -.monaco-workbench .quick-input-list .monaco-icon-label .label-description { - opacity: 1; - color: var(--vscode-descriptionForeground); -} diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index ed85772ade5..fb58ac53c25 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -30,7 +30,9 @@ "typescript.npm", "js/ts.tsserver.npm.path", "typescript.tsserver.nodePath", - "js/ts.tsserver.node.path" + "js/ts.tsserver.node.path", + "js/ts.tsserver.diagnosticDir", + "js/ts.tsserver.heapProfile" ] } }, @@ -2556,6 +2558,16 @@ "TypeScript" ] }, + "js/ts.tsserver.diagnosticDir": { + "type": "string", + "markdownDescription": "%configuration.tsserver.diagnosticDir%", + "scope": "machine", + "keywords": [ + "TypeScript", + "diagnostic", + "memory" + ] + }, "typescript.tsserver.maxTsServerMemory": { "type": "number", "default": 3072, @@ -2563,6 +2575,48 @@ "markdownDeprecationMessage": "%configuration.tsserver.maxTsServerMemory.unifiedDeprecationMessage%", "scope": "window" }, + "js/ts.tsserver.heapSnapshot": { + "type": "number", + "default": 0, + "minimum": 0, + "markdownDescription": "%configuration.tsserver.heapSnapshot%", + "scope": "window", + "keywords": [ + "TypeScript", + "memory", + "diagnostics" + ] + }, + "js/ts.tsserver.heapProfile": { + "type": "object", + "default": { + "enabled": false + }, + "markdownDescription": "%configuration.tsserver.heapProfile%", + "scope": "machine", + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.tsserver.heapProfile.enabled%" + }, + "dir": { + "type": "string", + "description": "%configuration.tsserver.heapProfile.dir%" + }, + "interval": { + "type": "number", + "minimum": 1, + "description": "%configuration.tsserver.heapProfile.interval%" + } + }, + "keywords": [ + "TypeScript", + "memory", + "heap", + "profile" + ] + }, "js/ts.tsserver.watchOptions": { "description": "%configuration.tsserver.watchOptions%", "scope": "window", diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 8c28dd87ccd..28b65dc0736 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -64,7 +64,7 @@ "format.semicolons.remove": "Remove unnecessary semicolons.", "format.indentSwitchCase": "Indent case clauses in switch statements. Requires using TypeScript 5.1+ in the workspace.", "format.enable": "Enable/disable the default JavaScript and TypeScript formatter.", - "configuration.format.enable.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.enable#` instead.", + "configuration.format.enable.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.enabled#` instead.", "configuration.format.insertSpaceAfterCommaDelimiter.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterCommaDelimiter#` instead.", "configuration.format.insertSpaceAfterConstructor.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterConstructor#` instead.", "configuration.format.insertSpaceAfterSemicolonInForStatements.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.format.insertSpaceAfterSemicolonInForStatements#` instead.", @@ -126,6 +126,12 @@ "configuration.tsserver.maxTsServerMemory": "The maximum amount of memory (in MB) to allocate to the TypeScript server process. To use a memory limit greater than 4 GB, use `#js/ts.tsserver.node.path#` to run TS Server with a custom Node installation.", "configuration.tsserver.maxTsServerMemory.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.tsserver.maxMemory#` instead.", "configuration.tsserver.maxMemory": "The maximum amount of memory (in MB) to allocate to the TypeScript server process. To use a memory limit greater than 4 GB, use `#js/ts.tsserver.node.path#` to run TS Server with a custom Node installation.", + "configuration.tsserver.diagnosticDir": "Directory where TypeScript server writes Node diagnostic output by passing `--diagnostic-dir`.", + "configuration.tsserver.heapSnapshot": "Controls how many near-heap-limit snapshots TypeScript server writes by passing `--heapsnapshot-near-heap-limit`. Set to `0` to disable.", + "configuration.tsserver.heapProfile": "Configures heap profiling for TypeScript server.", + "configuration.tsserver.heapProfile.enabled": "Enable heap profiling for TypeScript server by passing `--heap-prof`.", + "configuration.tsserver.heapProfile.dir": "Directory where TypeScript server writes heap profiles by passing `--heap-prof-dir`.", + "configuration.tsserver.heapProfile.interval": "Sampling interval in bytes for TypeScript server heap profiling by passing `--heap-prof-interval`.", "configuration.tsserver.experimental.enableProjectDiagnostics": "Enables project wide error reporting.", "configuration.tsserver.experimental.enableProjectDiagnostics.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.tsserver.experimental.enableProjectDiagnostics#` instead.", "typescript.locale": "Sets the locale used to report JavaScript and TypeScript errors. Defaults to use VS Code's locale.", diff --git a/extensions/typescript-language-features/src/configuration/configuration.ts b/extensions/typescript-language-features/src/configuration/configuration.ts index a557f08c024..ae43fda659e 100644 --- a/extensions/typescript-language-features/src/configuration/configuration.ts +++ b/extensions/typescript-language-features/src/configuration/configuration.ts @@ -110,6 +110,12 @@ export class ImplicitProjectConfiguration { } } +export interface TsServerHeapProfileConfiguration { + readonly enabled: boolean; + readonly dir: string | undefined; + readonly interval: number | undefined; +} + export interface TypeScriptServiceConfiguration { readonly locale: string | null; readonly globalTsdk: string | null; @@ -126,6 +132,9 @@ export interface TypeScriptServiceConfiguration { readonly enableDiagnosticsTelemetry: boolean; readonly enableProjectDiagnostics: boolean; readonly maxTsServerMemory: number; + readonly diagnosticDir: string | undefined; + readonly heapSnapshot: number; + readonly heapProfile: TsServerHeapProfileConfiguration; readonly enablePromptUseWorkspaceTsdk: boolean; readonly useVsCodeWatcher: boolean; readonly watchOptions: Proto.WatchOptions | undefined; @@ -168,6 +177,9 @@ export abstract class BaseServiceConfigurationProvider implements ServiceConfigu enableDiagnosticsTelemetry: this.readEnableDiagnosticsTelemetry(), enableProjectDiagnostics: this.readEnableProjectDiagnostics(), maxTsServerMemory: this.readMaxTsServerMemory(), + diagnosticDir: this.readDiagnosticDir(), + heapSnapshot: this.readHeapSnapshot(), + heapProfile: this.readHeapProfileConfiguration(), enablePromptUseWorkspaceTsdk: this.readEnablePromptUseWorkspaceTsdk(), useVsCodeWatcher: this.readUseVsCodeWatcher(configuration), watchOptions: this.readWatchOptions(), @@ -288,6 +300,42 @@ export abstract class BaseServiceConfigurationProvider implements ServiceConfigu return Math.max(memoryInMB, minimumMaxMemory); } + protected readDiagnosticDir(): string | undefined { + const diagnosticDir = readUnifiedConfig('tsserver.diagnosticDir', undefined, { fallbackSection: 'typescript' }); + return typeof diagnosticDir === 'string' && diagnosticDir.length > 0 ? diagnosticDir : undefined; + } + + protected readHeapSnapshot(): number { + const defaultNearHeapLimitSnapshotCount = 0; + const nearHeapLimitSnapshotCount = readUnifiedConfig('tsserver.heapSnapshot', defaultNearHeapLimitSnapshotCount, { fallbackSection: 'typescript' }); + if (!Number.isSafeInteger(nearHeapLimitSnapshotCount)) { + return defaultNearHeapLimitSnapshotCount; + } + return Math.max(nearHeapLimitSnapshotCount, 0); + } + + private readHeapProfileConfiguration(): TsServerHeapProfileConfiguration { + const defaultHeapProfileConfiguration: TsServerHeapProfileConfiguration = { + enabled: false, + dir: undefined, + interval: undefined, + }; + + const rawConfig = readUnifiedConfig<{ enabled?: unknown; dir?: unknown; interval?: unknown }>('tsserver.heapProfile', defaultHeapProfileConfiguration, { fallbackSection: 'typescript' }); + + const enabled = typeof rawConfig.enabled === 'boolean' ? rawConfig.enabled : false; + const dir = typeof rawConfig.dir === 'string' && rawConfig.dir.length > 0 ? rawConfig.dir : undefined; + const interval = typeof rawConfig.interval === 'number' && Number.isSafeInteger(rawConfig.interval) && rawConfig.interval > 0 + ? rawConfig.interval + : undefined; + + return { + enabled, + dir, + interval, + }; + } + protected readEnablePromptUseWorkspaceTsdk(): boolean { return readUnifiedConfig('tsdk.promptToUseWorkspaceVersion', false, { fallbackSection: 'typescript', fallbackSubSectionNameOverride: 'enablePromptUseWorkspaceTsdk' }); } diff --git a/extensions/typescript-language-features/src/tsServer/serverProcess.electron.ts b/extensions/typescript-language-features/src/tsServer/serverProcess.electron.ts index 7dbde90f792..a356fd7817b 100644 --- a/extensions/typescript-language-features/src/tsServer/serverProcess.electron.ts +++ b/extensions/typescript-language-features/src/tsServer/serverProcess.electron.ts @@ -24,6 +24,12 @@ const contentLengthSize: number = Buffer.byteLength(contentLength, 'utf8'); const blank: number = Buffer.from(' ', 'utf8')[0]; const backslashR: number = Buffer.from('\r', 'utf8')[0]; const backslashN: number = Buffer.from('\n', 'utf8')[0]; +const gracefulExitTimeout = 5000; +const tsServerExitRequest: Proto.Request = { + seq: 0, + type: 'request', + command: 'exit', +}; class ProtocolBuffer { @@ -162,6 +168,24 @@ function getExecArgv(kind: TsServerProcessKind, configuration: TypeScriptService args.push(`--max-old-space-size=${configuration.maxTsServerMemory}`); } + if (configuration.diagnosticDir) { + args.push(`--diagnostic-dir=${configuration.diagnosticDir}`); + } + + if (configuration.heapSnapshot > 0) { + args.push(`--heapsnapshot-near-heap-limit=${configuration.heapSnapshot}`); + } + + if (configuration.heapProfile.enabled) { + args.push('--heap-prof'); + if (configuration.heapProfile.dir) { + args.push(`--heap-prof-dir=${configuration.heapProfile.dir}`); + } + if (configuration.heapProfile.interval) { + args.push(`--heap-prof-interval=${configuration.heapProfile.interval}`); + } + } + return args; } @@ -189,10 +213,15 @@ function getTssDebugBrk(): string | undefined { } class IpcChildServerProcess extends Disposable implements TsServerProcess { + private _killTimeout: NodeJS.Timeout | undefined; + private _isShuttingDown = false; + constructor( private readonly _process: child_process.ChildProcess, + private readonly _useGracefulShutdown: boolean, ) { super(); + this._process.once('exit', () => this.clearKillTimeout()); } write(serverRequest: Proto.Request): void { @@ -212,18 +241,47 @@ class IpcChildServerProcess extends Disposable implements TsServerProcess { } kill(): void { - this._process.kill(); + if (!this._useGracefulShutdown) { + this._process.kill(); + return; + } + + if (this._isShuttingDown) { + return; + } + this._isShuttingDown = true; + + try { + this._process.send(tsServerExitRequest); + } catch { + this._process.kill(); + return; + } + + this._killTimeout = setTimeout(() => this._process.kill(), gracefulExitTimeout); + this._killTimeout.unref?.(); + } + + private clearKillTimeout(): void { + if (this._killTimeout) { + clearTimeout(this._killTimeout); + this._killTimeout = undefined; + } } } class StdioChildServerProcess extends Disposable implements TsServerProcess { private readonly _reader: Reader; + private _killTimeout: NodeJS.Timeout | undefined; + private _isShuttingDown = false; constructor( private readonly _process: child_process.ChildProcess, + private readonly _useGracefulShutdown: boolean, ) { super(); this._reader = this._register(new Reader(this._process.stdout!)); + this._process.once('exit', () => this.clearKillTimeout()); } write(serverRequest: Proto.Request): void { @@ -244,7 +302,39 @@ class StdioChildServerProcess extends Disposable implements TsServerProcess { } kill(): void { - this._process.kill(); + if (!this._useGracefulShutdown) { + this._process.kill(); + this._reader.dispose(); + return; + } + + if (this._isShuttingDown) { + return; + } + this._isShuttingDown = true; + + try { + this._process.stdin?.write(JSON.stringify(tsServerExitRequest) + '\r\n', 'utf8'); + this._process.stdin?.end(); + } catch { + this._process.kill(); + this._reader.dispose(); + return; + } + + this._killTimeout = setTimeout(() => { + this._process.kill(); + this._reader.dispose(); + }, gracefulExitTimeout); + this._killTimeout.unref?.(); + } + + private clearKillTimeout(): void { + if (this._killTimeout) { + clearTimeout(this._killTimeout); + this._killTimeout = undefined; + } + this._reader.dispose(); } } @@ -272,6 +362,7 @@ export class ElectronServiceProcessFactory implements TsServerProcessFactory { const env = generatePatchedEnv(process.env, tsServerPath, !!execPath); const runtimeArgs = [...args]; const execArgv = getExecArgv(kind, configuration); + const useGracefulShutdown = configuration.heapProfile.enabled; const useIpc = !execPath && version.apiVersion?.gte(API.v460); if (useIpc) { runtimeArgs.push('--useNodeIpc'); @@ -291,6 +382,6 @@ export class ElectronServiceProcessFactory implements TsServerProcessFactory { stdio: useIpc ? ['pipe', 'pipe', 'pipe', 'ipc'] : undefined, }); - return useIpc ? new IpcChildServerProcess(childProcess) : new StdioChildServerProcess(childProcess); + return useIpc ? new IpcChildServerProcess(childProcess, useGracefulShutdown) : new StdioChildServerProcess(childProcess, useGracefulShutdown); } } diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index 58ec85b1450..e5167429902 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -9,6 +9,7 @@ "authSession", "environmentPower", "chatParticipantPrivate", + "chatPromptFiles", "chatProvider", "contribStatusBarItems", "contribViewsRemote", @@ -70,6 +71,10 @@ { "vendor": "test-lm-vendor", "displayName": "Test LM Vendor" + }, + { + "vendor": "copilot", + "displayName": "Test Copilot LM Vendor" } ], "chatParticipants": [ @@ -146,6 +151,11 @@ "id": "test.treeId", "name": "test-tree", "when": "never" + }, + { + "id": "test.treeSwitchUpdate", + "name": "test-tree-switch-update", + "when": "never" } ] }, diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts index ff5b49d9b69..6ed6c911718 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts @@ -17,7 +17,7 @@ suite('chat', () => { disposables = []; // Register a dummy default model which is required for a participant request to go through - disposables.push(lm.registerLanguageModelChatProvider('test-lm-vendor', { + disposables.push(lm.registerLanguageModelChatProvider('copilot', { async provideLanguageModelChatInformation(_options, _token) { return [{ id: 'test-lm', diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/tree.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/tree.test.ts index a9d9bc5aa34..02259dc98c2 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/tree.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/tree.test.ts @@ -98,10 +98,11 @@ suite('vscode API - tree', () => { await provider.resolveNextRequest(); const [firstResult, secondResult] = await Promise.all([revealFirst, revealSecond]); - const error = firstResult.error ?? secondResult.error; - if (error && /Element with id .+ is already registered/.test(error.message)) { - assert.fail(error.message); - } + // Two concurrent root fetches race: the stale one gets invalidated and + // its reveal fails with "Cannot resolve". The other succeeds. + const errors = [firstResult.error, secondResult.error].filter((e): e is Error => !!e); + assert.strictEqual(errors.length, 1, 'Exactly one reveal should fail from the stale fetch'); + assert.ok(/Cannot resolve tree item/.test(errors[0].message), `Expected "Cannot resolve" error but got: ${errors[0].message}`); }); test('TreeView - element already registered after rapid root refresh', async function () { @@ -206,10 +207,113 @@ suite('vscode API - tree', () => { provider.resolveRequestWithElement(1, provider.getElement2()); const [firstResult, secondResult] = await Promise.all([firstReveal, secondReveal]); - const error = firstResult.error ?? secondResult.error; - if (error && /Element with id .+ is already registered/.test(error.message)) { - assert.fail(error.message); + const errors = [firstResult.error, secondResult.error].filter((e): e is Error => !!e); + assert.strictEqual(errors.length, 1, 'Exactly one reveal should fail from the stale fetch'); + assert.ok(/Cannot resolve tree item/.test(errors[0].message), `Expected "Cannot resolve" error but got: ${errors[0].message}`); + }); + + test('TreeView - element already registered during switch and update', async function () { + this.timeout(60_000); + + // This test reproduces a race condition where the tree is being "switched to" + // (via reveal, which triggers getChildren) while simultaneously the tree data + // is being updated with a new element being added. Both operations trigger + // concurrent getChildren calls. The first resolves with the old set of elements, + // the second resolves with a new set that includes a new element. If both try + // to register elements with the same ID, the error is thrown. + + type TreeElement = { readonly kind: 'leaf'; readonly instance: number }; + + class SwitchAndUpdateTreeDataProvider implements vscode.TreeDataProvider { + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly requestEmitter = new vscode.EventEmitter(); + private readonly pendingRequests: DeferredPromise[] = []; + private readonly existingOld: TreeElement = { kind: 'leaf', instance: 1 }; + private readonly existingNew: TreeElement = { kind: 'leaf', instance: 2 }; + private readonly addedElement: TreeElement = { kind: 'leaf', instance: 3 }; + + readonly onDidChangeTreeData = this.changeEmitter.event; + + getChildren(element?: TreeElement): Thenable { + if (!element) { + const deferred = new DeferredPromise(); + this.pendingRequests.push(deferred); + this.requestEmitter.fire(this.pendingRequests.length); + return deferred.p; + } + return Promise.resolve([]); + } + + getTreeItem(element: TreeElement): vscode.TreeItem { + if (element === this.addedElement) { + const item = new vscode.TreeItem('added', vscode.TreeItemCollapsibleState.None); + item.id = 'added-elem'; + return item; + } + const item = new vscode.TreeItem('existing', vscode.TreeItemCollapsibleState.None); + item.id = 'existing-elem'; + return item; + } + + getParent(): TreeElement | undefined { + return undefined; + } + + async waitForRequestCount(count: number): Promise { + while (this.pendingRequests.length < count) { + await asPromise(this.requestEmitter.event); + } + } + + resolveRequestAt(index: number, elements: TreeElement[]): void { + const request = this.pendingRequests[index]; + if (request) { + request.complete(elements); + } + } + + getExistingOld(): TreeElement { return this.existingOld; } + getExistingNew(): TreeElement { return this.existingNew; } + getAddedElement(): TreeElement { return this.addedElement; } + + dispose(): void { + this.changeEmitter.dispose(); + this.requestEmitter.dispose(); + while (this.pendingRequests.length) { + this.pendingRequests.shift()!.complete([]); + } + } } + + const provider = new SwitchAndUpdateTreeDataProvider(); + disposables.push(provider); + + const treeView = vscode.window.createTreeView('test.treeSwitchUpdate', { treeDataProvider: provider }); + disposables.push(treeView); + + // Two concurrent reveals simulate the tree being "switched to" while also + // being updated: both trigger getChildren calls on the ext host directly. + const revealFirst = (treeView.reveal(provider.getExistingOld(), { expand: true }) + .then(() => ({ error: undefined as Error | undefined })) as Promise<{ error: Error | undefined }>) + .catch(error => ({ error })); + const revealSecond = (treeView.reveal(provider.getExistingNew(), { expand: true }) + .then(() => ({ error: undefined as Error | undefined })) as Promise<{ error: Error | undefined }>) + .catch(error => ({ error })); + + // Wait for both getChildren calls to be pending + await provider.waitForRequestCount(2); + + // Resolve first request with old data (just the existing element, old instance) + provider.resolveRequestAt(0, [provider.getExistingOld()]); + await delay(0); + + // Resolve second request with new data: different instance of existing + added element + provider.resolveRequestAt(1, [provider.getExistingNew(), provider.getAddedElement()]); + + const [firstResult, secondResult] = await Promise.all([revealFirst, revealSecond]); + const errors = [firstResult.error, secondResult.error].filter((e): e is Error => !!e); + assert.strictEqual(errors.length, 1, 'Exactly one reveal should fail from the stale fetch'); + assert.ok(/Cannot resolve tree item/.test(errors[0].message), `Expected "Cannot resolve" error but got: ${errors[0].message}`); }); test('TreeView - element already registered after refresh', async function () { @@ -345,9 +449,7 @@ suite('vscode API - tree', () => { await provider.resolveChildRequestAt(0, [staleChild]); const [firstResult, secondResult] = await Promise.all([firstReveal, secondReveal]); - const error = firstResult.error ?? secondResult.error; - if (error && /Element with id .+ is already registered/.test(error.message)) { - assert.fail(error.message); - } + assert.strictEqual(firstResult.error, undefined, `First reveal should not fail: ${firstResult.error?.message}`); + assert.strictEqual(secondResult.error, undefined, `Second reveal should not fail: ${secondResult.error?.message}`); }); }); diff --git a/extensions/vscode-test-resolver/src/extension.browser.ts b/extensions/vscode-test-resolver/src/extension.browser.ts index 93703fde4df..e93a414a79a 100644 --- a/extensions/vscode-test-resolver/src/extension.browser.ts +++ b/extensions/vscode-test-resolver/src/extension.browser.ts @@ -24,7 +24,7 @@ export function activate(_context: vscode.ExtensionContext) { * actual WebSocket. */ class InitialManagedMessagePassing implements vscode.ManagedMessagePassing { - private readonly dataEmitter = new vscode.EventEmitter(); + private readonly dataEmitter = new vscode.EventEmitter>(); private readonly closeEmitter = new vscode.EventEmitter(); private readonly endEmitter = new vscode.EventEmitter(); @@ -38,7 +38,7 @@ class InitialManagedMessagePassing implements vscode.ManagedMessagePassing { public send(d: Uint8Array): void { if (this._actual) { // we already got the HTTP headers - this._actual.send(d); + this._actual.send(d as Uint8Array); return; } @@ -80,7 +80,7 @@ class OpeningManagedMessagePassing { private readonly socket: WebSocket; private isOpen = false; - private bufferedData: Uint8Array[] = []; + private bufferedData: Uint8Array[] = []; constructor( url: URL, @@ -119,7 +119,7 @@ class OpeningManagedMessagePassing { }); } - public send(d: Uint8Array): void { + public send(d: Uint8Array): void { if (!this.isOpen) { this.bufferedData.push(d); return; diff --git a/extensions/vscode-test-resolver/src/extension.ts b/extensions/vscode-test-resolver/src/extension.ts index 3e6c9f0ad49..c342647e672 100644 --- a/extensions/vscode-test-resolver/src/extension.ts +++ b/extensions/vscode-test-resolver/src/extension.ts @@ -211,12 +211,12 @@ export function activate(context: vscode.ExtensionContext) { console.log('Connecting via a managed authority'); return Promise.resolve(new vscode.ManagedResolvedAuthority(async () => { const remoteSocket = net.createConnection({ port: serverAddr.port }); - const dataEmitter = new vscode.EventEmitter(); + const dataEmitter = new vscode.EventEmitter>(); const closeEmitter = new vscode.EventEmitter(); const endEmitter = new vscode.EventEmitter(); await new Promise((res, rej) => { - remoteSocket.on('data', d => dataEmitter.fire(d)) + remoteSocket.on('data', d => dataEmitter.fire(d as Uint8Array)) .on('error', err => { rej(); closeEmitter.fire(err); }) .on('close', () => endEmitter.fire()) .on('end', () => endEmitter.fire()) diff --git a/package-lock.json b/package-lock.json index 467f41ec155..e75b305f52a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-oss-dev", - "version": "1.111.0", + "version": "1.112.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.111.0", + "version": "1.112.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -16,7 +16,7 @@ "@parcel/watcher": "^2.5.6", "@playwright/cli": "^0.1.1", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-11", + "@vscode/codicons": "^0.0.45-14", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -31,16 +31,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.167", - "@xterm/addon-image": "^0.10.0-beta.167", - "@xterm/addon-ligatures": "^0.11.0-beta.167", - "@xterm/addon-progress": "^0.3.0-beta.167", - "@xterm/addon-search": "^0.17.0-beta.167", - "@xterm/addon-serialize": "^0.15.0-beta.167", - "@xterm/addon-unicode11": "^0.10.0-beta.167", - "@xterm/addon-webgl": "^0.20.0-beta.166", - "@xterm/headless": "^6.1.0-beta.167", - "@xterm/xterm": "^6.1.0-beta.167", + "@xterm/addon-clipboard": "^0.3.0-beta.180", + "@xterm/addon-image": "^0.10.0-beta.180", + "@xterm/addon-ligatures": "^0.11.0-beta.180", + "@xterm/addon-progress": "^0.3.0-beta.180", + "@xterm/addon-search": "^0.17.0-beta.180", + "@xterm/addon-serialize": "^0.15.0-beta.180", + "@xterm/addon-unicode11": "^0.10.0-beta.180", + "@xterm/addon-webgl": "^0.20.0-beta.179", + "@xterm/headless": "^6.1.0-beta.180", + "@xterm/xterm": "^6.1.0-beta.180", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -83,10 +83,10 @@ "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", - "@typescript/native-preview": "^7.0.0-dev.20260130", - "@vscode/component-explorer": "^0.1.1-16", - "@vscode/component-explorer-cli": "^0.1.1-12", - "@vscode/gulp-electron": "1.40.0", + "@typescript/native-preview": "^7.0.0-dev.20260306", + "@vscode/component-explorer": "^0.1.1-22", + "@vscode/component-explorer-cli": "^0.1.1-18", + "@vscode/gulp-electron": "1.40.1", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", @@ -95,13 +95,14 @@ "@vscode/v8-heap-parser": "^0.1.0", "@vscode/vscode-perf": "^0.0.19", "@webgpu/types": "^0.1.66", + "agent-browser": "^0.16.3", "ansi-colors": "^3.2.3", "asar": "^3.0.3", "chromium-pickle-js": "^0.2.0", "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.6.0", + "electron": "39.8.0", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -151,7 +152,7 @@ "tar": "^7.5.9", "tsec": "0.2.7", "tslib": "^2.6.3", - "typescript": "^6.0.0-dev.20260130", + "typescript": "^6.0.0-dev.20260306", "typescript-eslint": "^8.45.0", "util": "^0.12.4", "xml2js": "^0.5.0", @@ -161,6 +162,245 @@ "windows-foreground-love": "0.6.1" } }, + "node_modules/@actions/exec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-1.1.1.tgz", + "integrity": "sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/io": "^1.0.1" + } + }, + "node_modules/@actions/github": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@actions/github/-/github-2.2.0.tgz", + "integrity": "sha512-9UAZqn8ywdR70n3GwVle4N8ALosQs4z50N7XMXrSTUVOmVpaBC5kE3TRTT7qQdi3OaQV24mjGuJZsHUmhD+ZXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/http-client": "^1.0.3", + "@octokit/graphql": "^4.3.1", + "@octokit/rest": "^16.43.1" + } + }, + "node_modules/@actions/github/node_modules/@octokit/auth-token": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.5.0.tgz", + "integrity": "sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3" + } + }, + "node_modules/@actions/github/node_modules/@octokit/endpoint": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.12.tgz", + "integrity": "sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3", + "is-plain-object": "^5.0.0", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/graphql": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.8.0.tgz", + "integrity": "sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^5.6.0", + "@octokit/types": "^6.0.3", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/openapi-types": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-12.11.0.tgz", + "integrity": "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@actions/github/node_modules/@octokit/plugin-paginate-rest": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-1.1.2.tgz", + "integrity": "sha512-jbsSoi5Q1pj63sC16XIUboklNw+8tL9VOnJsWycWYR78TKss5PVpIPb1TUUcMQ+bBh7cY579cVAWmf5qG+dw+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^2.0.1" + } + }, + "node_modules/@actions/github/node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.16.2.tgz", + "integrity": "sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/@actions/github/node_modules/@octokit/plugin-request-log": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz", + "integrity": "sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@octokit/core": ">=3" + } + }, + "node_modules/@actions/github/node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-2.4.0.tgz", + "integrity": "sha512-EZi/AWhtkdfAYi01obpX0DF7U6b1VRr30QNQ5xSFPITMdLSfhcBqjamE3F+sKcxPbD7eZuMHu3Qkk2V+JGxBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^2.0.1", + "deprecation": "^2.3.1" + } + }, + "node_modules/@actions/github/node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.16.2.tgz", + "integrity": "sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/@actions/github/node_modules/@octokit/request": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.6.3.tgz", + "integrity": "sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^6.0.1", + "@octokit/request-error": "^2.1.0", + "@octokit/types": "^6.16.1", + "is-plain-object": "^5.0.0", + "node-fetch": "^2.6.7", + "universal-user-agent": "^6.0.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/request-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.1.0.tgz", + "integrity": "sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^6.0.3", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/rest": { + "version": "16.43.2", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-16.43.2.tgz", + "integrity": "sha512-ngDBevLbBTFfrHZeiS7SAMAZ6ssuVmXuya+F/7RaVvlysgGa1JKJkKWY+jV6TCJYcW0OALfJ7nTIGXcBXzycfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^2.4.0", + "@octokit/plugin-paginate-rest": "^1.1.1", + "@octokit/plugin-request-log": "^1.0.0", + "@octokit/plugin-rest-endpoint-methods": "2.4.0", + "@octokit/request": "^5.2.0", + "@octokit/request-error": "^1.0.2", + "atob-lite": "^2.0.0", + "before-after-hook": "^2.0.0", + "btoa-lite": "^1.0.0", + "deprecation": "^2.0.0", + "lodash.get": "^4.4.2", + "lodash.set": "^4.3.2", + "lodash.uniq": "^4.5.0", + "octokit-pagination-methods": "^1.1.0", + "once": "^1.4.0", + "universal-user-agent": "^4.0.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/rest/node_modules/@octokit/request-error": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-1.2.1.tgz", + "integrity": "sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^2.0.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/rest/node_modules/@octokit/types": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-2.16.2.tgz", + "integrity": "sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/@actions/github/node_modules/@octokit/rest/node_modules/universal-user-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-4.0.1.tgz", + "integrity": "sha512-LnST3ebHwVL2aNe4mejI9IQh2HfZ1RLo8Io2HugSif8ekzD1TlWpHpColOB/eh8JHMLkGH3Akqf040I+4ylNxg==", + "dev": true, + "license": "ISC", + "dependencies": { + "os-name": "^3.1.0" + } + }, + "node_modules/@actions/github/node_modules/@octokit/types": { + "version": "6.41.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.41.0.tgz", + "integrity": "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^12.11.0" + } + }, + "node_modules/@actions/github/node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@actions/github/node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@actions/http-client": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-1.0.11.tgz", + "integrity": "sha512-VRYHGQV1rqnROJqdMvGUbY/Kn8vriQe/F9HR2AlYHzmKuM/p3kjNuXhmdBfcVgsvRWTz5C5XW5xvndZrVBuAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6" + } + }, + "node_modules/@actions/io": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-1.1.3.tgz", + "integrity": "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -203,6 +443,37 @@ "node": ">=18" } }, + "node_modules/@appium/logger": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@appium/logger/-/logger-1.7.1.tgz", + "integrity": "sha512-9C2o9X/lBEDBUnKfAi3mRo9oG7Z03nmISLwsGkWxIWjMAvBdJD0RRSJMekWVKzfXN3byrI1WlCXTITzN4LAoLw==", + "dev": true, + "license": "ISC", + "dependencies": { + "console-control-strings": "1.1.0", + "lodash": "4.17.21", + "lru-cache": "10.4.3", + "set-blocking": "2.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=8" + } + }, + "node_modules/@appium/logger/node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@appium/logger/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@azure-rest/ai-translation-text": { "version": "1.0.0-beta.1", "resolved": "https://registry.npmjs.org/@azure-rest/ai-translation-text/-/ai-translation-text-1.0.0-beta.1.tgz", @@ -734,6 +1005,100 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/highlight": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/parser": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", @@ -1129,6 +1494,449 @@ "xtend": "~4.0.1" } }, + "node_modules/@hediet/semver": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@hediet/semver/-/semver-0.2.2.tgz", + "integrity": "sha512-sdH+TwXwaYOgnKij3QQbJERl2HkJ+l8idWINwHBI+8nXl1yuTCMerDLDPC48t1wbr849qBTpJTV1EJXlh7OGAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@actions/exec": "^1.0.4", + "@actions/github": "^2.2.0", + "@typescript-eslint/eslint-plugin": "^3.0.1", + "@typescript-eslint/parser": "^3.0.1", + "eslint": "^7.1.0" + } + }, + "node_modules/@hediet/semver/node_modules/@babel/code-frame": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz", + "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.10.4" + } + }, + "node_modules/@hediet/semver/node_modules/@eslint/eslintrc": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", + "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.1.1", + "espree": "^7.3.0", + "globals": "^13.9.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/@hediet/semver/node_modules/@typescript-eslint/eslint-plugin": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz", + "integrity": "sha512-PQg0emRtzZFWq6PxBcdxRH3QIQiyFO3WCVpRL3fgj5oQS3CDs3AeAKfv4DxNhzn8ITdNJGJ4D3Qw8eAJf3lXeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/experimental-utils": "3.10.1", + "debug": "^4.1.1", + "functional-red-black-tree": "^1.0.1", + "regexpp": "^3.0.0", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^3.0.0", + "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@hediet/semver/node_modules/@typescript-eslint/parser": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.10.1.tgz", + "integrity": "sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/eslint-visitor-keys": "^1.0.0", + "@typescript-eslint/experimental-utils": "3.10.1", + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/typescript-estree": "3.10.1", + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@hediet/semver/node_modules/@typescript-eslint/types": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz", + "integrity": "sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@hediet/semver/node_modules/@typescript-eslint/typescript-estree": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz", + "integrity": "sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/visitor-keys": "3.10.1", + "debug": "^4.1.1", + "glob": "^7.1.6", + "is-glob": "^4.0.1", + "lodash": "^4.17.15", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@hediet/semver/node_modules/@typescript-eslint/visitor-keys": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz", + "integrity": "sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@hediet/semver/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@hediet/semver/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@hediet/semver/node_modules/eslint": { + "version": "7.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", + "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "7.12.11", + "@eslint/eslintrc": "^0.4.3", + "@humanwhocodes/config-array": "^0.5.0", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.0.1", + "doctrine": "^3.0.0", + "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^5.1.1", + "eslint-utils": "^2.1.0", + "eslint-visitor-keys": "^2.0.0", + "espree": "^7.3.1", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^5.1.2", + "globals": "^13.6.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^3.13.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "progress": "^2.0.0", + "regexpp": "^3.1.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", + "table": "^6.0.9", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@hediet/semver/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@hediet/semver/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/@hediet/semver/node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/@hediet/semver/node_modules/espree": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", + "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^7.4.0", + "acorn-jsx": "^5.3.1", + "eslint-visitor-keys": "^1.3.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/@hediet/semver/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@hediet/semver/node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/@hediet/semver/node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/@hediet/semver/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@hediet/semver/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@hediet/semver/node_modules/ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@hediet/semver/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@hediet/semver/node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@hediet/semver/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@hediet/semver/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hediet/semver/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", @@ -1166,6 +1974,22 @@ "node": ">=18.18.0" } }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", + "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^1.2.0", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=10.10.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1179,6 +2003,14 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", @@ -1308,15 +2140,6 @@ "node": ">=18.0.0" } }, - "node_modules/@isaacs/fs-minipass/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", @@ -2185,6 +3008,58 @@ "integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==", "license": "MIT" }, + "node_modules/@promptbook/utils": { + "version": "0.69.5", + "resolved": "https://registry.npmjs.org/@promptbook/utils/-/utils-0.69.5.tgz", + "integrity": "sha512-xm5Ti/Hp3o4xHrsK9Yy3MS6KbDxYbq485hDsFvxqaNA7equHLPdo8H8faTitTeb14QCDfLW4iwCxdVYu5sn6YQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://buymeacoffee.com/hejny" + }, + { + "type": "github", + "url": "https://github.com/webgptorg/promptbook/blob/main/README.md#%EF%B8%8F-contributing" + } + ], + "license": "CC-BY-4.0", + "dependencies": { + "spacetrim": "0.11.59" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -2288,6 +3163,13 @@ "node": ">= 10" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -2378,6 +3260,13 @@ "@types/json-schema": "*" } }, + "node_modules/@types/eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2566,6 +3455,13 @@ "@types/sinon": "*" } }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/svgo": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/@types/svgo/-/svgo-1.3.6.tgz", @@ -2604,6 +3500,13 @@ "integrity": "sha512-5iTjb39DpLn03ULUwrDR3L2Dy59RV4blSUHy0oLdQuIY11PhgWO4mXIcoFS0VxY1GZQ4IcjSf3ooT2Jrrcahnw==", "dev": true }, + "node_modules/@types/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", + "integrity": "sha512-113D3mDkZDjo+EeUEHCFy0qniNc1ZpecGiAU7WSo7YDoSzolZIQKpYFHrPpjkB2nuyahcKfrmLXeQlh7gqJYdw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/wicg-file-system-access": { "version": "2023.10.7", "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.7.tgz", @@ -2623,6 +3526,16 @@ "integrity": "sha1-kdZxDlNtNFucmwF8V0z2qNpkxRg= sha512-c4m/hnOI1j34i8hXlkZzelE6SXfOqaTWhBp0UgBuwmpiafh22OpsE261Rlg//agZtQHIY5cMgbkX8bnthUFrmA==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2681,6 +3594,146 @@ "node": ">= 4" } }, + "node_modules/@typescript-eslint/experimental-utils": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz", + "integrity": "sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/typescript-estree": "3.10.1", + "eslint-scope": "^5.0.0", + "eslint-utils": "^2.0.0" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/types": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.10.1.tgz", + "integrity": "sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz", + "integrity": "sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "3.10.1", + "@typescript-eslint/visitor-keys": "3.10.1", + "debug": "^4.1.1", + "glob": "^7.1.6", + "is-glob": "^4.0.1", + "lodash": "^4.17.15", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz", + "integrity": "sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": "^8.10.0 || ^10.13.0 || >=11.10.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/@typescript-eslint/experimental-utils/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/parser": { "version": "8.45.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", @@ -2913,28 +3966,28 @@ } }, "node_modules/@typescript/native-preview": { - "version": "7.0.0-dev.20260130.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260130.1.tgz", - "integrity": "sha512-lvt9sECmBkrABxl3rMNRAX2unzhYcoNhlTyR7rOvbyM//QTXKUctVD7ByWBvk02et2caUUwIWq2vnygaeW8Mew==", + "version": "7.0.0-dev.20260306.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260306.1.tgz", + "integrity": "sha512-4m7cOjtKu+iLazWW5MuJuI2ZZMkQkS42+GxN6FVdja1nL0t47l1wpaTnzUa1Ny9Xa0opIJ7psPAMBKYAPKbCKA==", "dev": true, "license": "Apache-2.0", "bin": { "tsgo": "bin/tsgo.js" }, "optionalDependencies": { - "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260130.1", - "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260130.1", - "@typescript/native-preview-linux-arm": "7.0.0-dev.20260130.1", - "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260130.1", - "@typescript/native-preview-linux-x64": "7.0.0-dev.20260130.1", - "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260130.1", - "@typescript/native-preview-win32-x64": "7.0.0-dev.20260130.1" + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260306.1", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260306.1", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260306.1", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260306.1", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260306.1", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260306.1", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260306.1" } }, "node_modules/@typescript/native-preview-darwin-arm64": { - "version": "7.0.0-dev.20260130.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260130.1.tgz", - "integrity": "sha512-Jo5kVoxaewKPn/3bKWyUB/gPR+Tjhj6isLc8VshV4OyFX4n6pkvVyk3ANivl7Kwmiv3WGKGUotbZ71DKCZATwA==", + "version": "7.0.0-dev.20260306.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260306.1.tgz", + "integrity": "sha512-4vuh4VlPydMS/nymDzjJIKDk3dntnEEB5UzyJV9mM4kxF5+geFgJih1DTtZS3qVafhHLB3e4l8omtvGftMnb8g==", "cpu": [ "arm64" ], @@ -2946,9 +3999,9 @@ ] }, "node_modules/@typescript/native-preview-darwin-x64": { - "version": "7.0.0-dev.20260130.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260130.1.tgz", - "integrity": "sha512-dR0fjdcLykfiDOIKjZMGqPBHVl9Dd/C+jFU43Wr3dcPFPFf1oVYsaWAZBSkTXnN9QP8i0/ZV+ZUr1gDjoi3x0Q==", + "version": "7.0.0-dev.20260306.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260306.1.tgz", + "integrity": "sha512-qxYfv0aM4KCZPEe584KIjT5sO4uR+xdyuQXX5tXbnH1UoksIz7bvJ9KUgRloS/q/ww0f8UjPS2+27LnRA4y7ig==", "cpu": [ "x64" ], @@ -2960,9 +4013,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm": { - "version": "7.0.0-dev.20260130.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260130.1.tgz", - "integrity": "sha512-wnx4bY/1u006U67fEkPtPVZ65VYMLgkFqOadGyrUxhtveR5WbbgFUuUBES0mPxvzS4ToZzn94jhcnAvN8VOTcA==", + "version": "7.0.0-dev.20260306.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260306.1.tgz", + "integrity": "sha512-8gRAFx0ExDWHOmphl8mzBrSoGWnLWDU4VpxkPRsWqaJpHVbjr9Yk2QkuJNIaDmF6q44eJmW/huSiObmHTbZ1UQ==", "cpu": [ "arm" ], @@ -2974,9 +4027,9 @@ ] }, "node_modules/@typescript/native-preview-linux-arm64": { - "version": "7.0.0-dev.20260130.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260130.1.tgz", - "integrity": "sha512-P/1YTpIiFd2pPtHt4sKEmUTaKf1xvuuiV0TvhQ7n2gDYskNjZ66iWCC9w7okjgsmWE9JLh/IRrNcb9FKVk3SHw==", + "version": "7.0.0-dev.20260306.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260306.1.tgz", + "integrity": "sha512-8G0BKvTkE+eKX1tSnyKeDaf3bWPWY7OI77SMipagCAyYi06v4gxx+IVE3Px7W7kLX2Wqp1MjWDXu2N76wfJtXQ==", "cpu": [ "arm64" ], @@ -2988,9 +4041,9 @@ ] }, "node_modules/@typescript/native-preview-linux-x64": { - "version": "7.0.0-dev.20260130.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260130.1.tgz", - "integrity": "sha512-OgHVjivuOS22WIZvIm+Pnm7yqFLwonkIrBOxRdew/pPwVGLQVSo+bQ+RocQDj2VFYxXcHs2yXwCk3PDmwLIYYg==", + "version": "7.0.0-dev.20260306.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260306.1.tgz", + "integrity": "sha512-rsJV3Z9J/zYCEtcqvm+WfLAml3i1OAyMEUn0hja7i8C0kzE+tXKXzsJ0+I1TrSU5O7hHvqlLTvueBoCoM4aL4g==", "cpu": [ "x64" ], @@ -3002,9 +4055,9 @@ ] }, "node_modules/@typescript/native-preview-win32-arm64": { - "version": "7.0.0-dev.20260130.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260130.1.tgz", - "integrity": "sha512-f/DUxQtIWkZq0eUjZHFmaSxterO/ccu1NxFk0L/Oqj7AfjWVDCqrLVgZJKjvwcG5TEb5AVt7GMUpGEAYZQiUvg==", + "version": "7.0.0-dev.20260306.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260306.1.tgz", + "integrity": "sha512-US1WsIu9IukaFzM+w8wt0fIAkmk2WtxeVuk8nkbrnH9S3ax39r0J4ikMNZSXEJE0VMxhXJoymzfWxhj3s9yW/Q==", "cpu": [ "arm64" ], @@ -3016,9 +4069,9 @@ ] }, "node_modules/@typescript/native-preview-win32-x64": { - "version": "7.0.0-dev.20260130.1", - "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260130.1.tgz", - "integrity": "sha512-Isr051Cq8RbXOUMYYmwLYw8yBGaEG/Zp0sp7HNeYhVVkc3/3KeveEqCk29q1QRwiBr7HnApdzJP7f+lSZk8gmg==", + "version": "7.0.0-dev.20260306.1", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260306.1.tgz", + "integrity": "sha512-MlneT0RWS9Zdb8XoWvHsUgmnMJu6K3S0BXRu5ZgUYjcbQKlkz+Z87aUB8eX8qnDFd9csJcMp3+ZrgQ/LKVGP1g==", "cpu": [ "x64" ], @@ -3030,29 +4083,31 @@ ] }, "node_modules/@vscode/codicons": { - "version": "0.0.45-11", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-11.tgz", - "integrity": "sha512-fLjx4i7pfSYJJzzmQ6tZnshWWSLYUfg8Ru6xNRBWRSFj8yZkuuXEZGMxju4mt/tuu8Y/gjhEGmIVmVC16fg+yQ==", + "version": "0.0.45-14", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-14.tgz", + "integrity": "sha512-EdrK2NnxNGluUm9ZlU1C5VTLfG1cpO4C0CCXloS+8bDuTbidE1qtwaF5lHPcoDE102WBqBzWA09nVKFoN8RSOA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { - "version": "0.1.1-16", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-16.tgz", - "integrity": "sha512-is1RxdlNO5K1RSqWd5z8BN6gPrqEBZfjgUi3ZJbQj8Z4VqmqoJsNLIzBXOIlQJX+5mWgeNdOq3vxe0u15ZkAlA==", + "version": "0.1.1-22", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-22.tgz", + "integrity": "sha512-T3L8WMHIP7BRNq5E8SHbQ1FINiVpgoWW6tAk95G5vc3yPnAHFoOEHC5kM9eUFJPzbuWO+nYl7gf+7787UBFT4w==", "dev": true, "license": "MIT", "dependencies": { + "@hediet/semver": "^0.2.2", "react": "^18.2.0", "react-dom": "^18.2.0" } }, "node_modules/@vscode/component-explorer-cli": { - "version": "0.1.1-12", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.1.1-12.tgz", - "integrity": "sha512-SaChUP94wkf1RaaJ/MnpQsxsr7pUpqQJq5Z9QLbrZuUqRil2TZEHwYLSqpQPqLgybNxZtrlMDivTjcCWXFTttg==", + "version": "0.1.1-18", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.1.1-18.tgz", + "integrity": "sha512-kInOiRaXOVaoI3bxa5yFiT9iYKVkxsufIQR4V7ll2D472nZYssbSWIfMrZtdTlG0SdXRHeufyFVVRTa7jLIHIw==", "dev": true, "license": "MIT", "dependencies": { + "@hediet/semver": "^0.2.2", "@modelcontextprotocol/sdk": "^1.26.0", "clipanion": "^4.0.0-rc.4", "express": "^5.0.0", @@ -3088,9 +4143,9 @@ } }, "node_modules/@vscode/gulp-electron": { - "version": "1.40.0", - "resolved": "git+ssh://git@github.com/microsoft/vscode-gulp-electron.git#580228be384d7942b39aca6466b5a5050e4744a2", - "integrity": "sha512-EfQqw/kFmqiUgBv7WXx3wIrtz9cujAgX2uKQzTq517MbVjlpg7BIAjNC4Iq/wVB4Vgpl/ZGB7/XuSN7LsaLdlA==", + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/@vscode/gulp-electron/-/gulp-electron-1.40.1.tgz", + "integrity": "sha512-ERN3Mly+bxicuhSGrF4ksSwr7UNCBcYOcVVClivTzkkEL4gy477V4H8YAURak/W1VPmdmDWn+VZknptRySDWew==", "dev": true, "license": "MIT", "dependencies": { @@ -3450,16 +4505,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@vscode/l10n-dev/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/@vscode/native-watchdog": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@vscode/native-watchdog/-/native-watchdog-1.4.6.tgz", @@ -3652,16 +4697,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@vscode/test-cli/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/@vscode/test-electron": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.4.0.tgz", @@ -3801,6 +4836,224 @@ "hasInstallScript": true, "license": "MIT" }, + "node_modules/@wdio/config": { + "version": "9.24.0", + "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.24.0.tgz", + "integrity": "sha512-rcHu0eG16rSEmHL0sEKDcr/vYFmGhQ5GOlmlx54r+1sgh6sf136q+kth4169s16XqviWGW3LjZbUfpTK29pGtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@wdio/logger": "9.18.0", + "@wdio/types": "9.24.0", + "@wdio/utils": "9.24.0", + "deepmerge-ts": "^7.0.3", + "glob": "^10.2.2", + "import-meta-resolve": "^4.0.0", + "jiti": "^2.6.1" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/config/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@wdio/config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@wdio/config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@wdio/logger": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/@wdio/logger/-/logger-9.18.0.tgz", + "integrity": "sha512-HdzDrRs+ywAqbXGKqe1i/bLtCv47plz4TvsHFH3j729OooT5VH38ctFn5aLXgECmiAKDkmH/A6kOq2Zh5DIxww==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.1.2", + "loglevel": "^1.6.0", + "loglevel-plugin-prefix": "^0.8.4", + "safe-regex2": "^5.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/logger/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@wdio/logger/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@wdio/logger/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@wdio/protocols": { + "version": "9.24.0", + "resolved": "https://registry.npmjs.org/@wdio/protocols/-/protocols-9.24.0.tgz", + "integrity": "sha512-ozQKYddBLT4TRvU9J+fGrhVUtx3iDAe+KNCJcTDMFMxNSdDMR2xFQdNp8HLHypspk58oXTYCvz6ZYjySthhqsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wdio/repl": { + "version": "9.16.2", + "resolved": "https://registry.npmjs.org/@wdio/repl/-/repl-9.16.2.tgz", + "integrity": "sha512-FLTF0VL6+o5BSTCO7yLSXocm3kUnu31zYwzdsz4n9s5YWt83sCtzGZlZpt7TaTzb3jVUfxuHNQDTb8UMkCu0lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/repl/node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@wdio/types": { + "version": "9.24.0", + "resolved": "https://registry.npmjs.org/@wdio/types/-/types-9.24.0.tgz", + "integrity": "sha512-PYYunNl8Uq1r8YMJAK6ReRy/V/XIrCSyj5cpCtR5EqCL6heETOORFj7gt4uPnzidfgbtMBcCru0LgjjlMiH1UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/types/node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@wdio/utils": { + "version": "9.24.0", + "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-9.24.0.tgz", + "integrity": "sha512-6WhtzC5SNCGRBTkaObX6A07Ofnnyyf+TQH/d/fuhZRqvBknrP4AMMZF+PFxGl1fwdySWdBn+gV2QLE+52Byowg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@puppeteer/browsers": "^2.2.0", + "@wdio/logger": "9.18.0", + "@wdio/types": "9.24.0", + "decamelize": "^6.0.0", + "deepmerge-ts": "^7.0.3", + "edgedriver": "^6.1.2", + "geckodriver": "^6.1.0", + "get-port": "^7.0.0", + "import-meta-resolve": "^4.0.0", + "locate-app": "^2.2.24", + "mitt": "^3.0.1", + "safaridriver": "^1.0.0", + "split2": "^4.2.0", + "wait-port": "^1.1.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@wdio/utils/node_modules/decamelize": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-6.0.1.tgz", + "integrity": "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@webgpu/types": { "version": "0.1.66", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz", @@ -3819,30 +5072,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.167.tgz", - "integrity": "sha512-+JSjagAk6okCaGVYFwkKl8qIBfy+W+h7p/qULIi9cC8QyeswOLaE4GOqY5yuGNQYU+zMlrpgR1ttyp0o6y9LHg==", + "version": "0.3.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.180.tgz", + "integrity": "sha512-8vJ2BN8tot7Qqgl1p0ALXIy/SOvF7nQmh3DEZph6ZARNuV3JUrpF8xdK+lmd25/fm7NFgnGdntLe9k/g2qbAgw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.167.tgz", - "integrity": "sha512-Bxi2oTaX7YM1gup0OSv02n9+tA3P1Ozlu5zyB/ZwSVkepB9FOxCODWD0l3DhWyLGMBqQ+OY/COw5SRxrKyvkNg==", + "version": "0.10.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.180.tgz", + "integrity": "sha512-CGZxW47ZllCqS6n4vVD0lVCz2sq9FqP6czQoCJBStFJho05ioVLy0wQr8XCDJO87wuYvPjJaU3BlsyCDL2xVzQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.167.tgz", - "integrity": "sha512-d+9ANnoz6D4J06CjronVolcG+J0jqUWQXbzciRqQkHq0or5k8PYuIj2DuuyBx/0rOaN7JYN347KQ9iylk+++xA==", + "version": "0.11.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.180.tgz", + "integrity": "sha512-YiCxw7P1rj5TVs25BJT9OiZ9QghNtboBhpYB7KZUOB2aqlCPsyIknf9GMbCrZegDfMMa22FWPyXF4oeZDBmpFg==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -3852,7 +5105,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -3874,74 +5127,99 @@ "license": "ISC" }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.167.tgz", - "integrity": "sha512-8eeaWnp0pnjYaKtOLsXVCE0hTFXS0A2kZCciWp52l6CbNGQsnky4VNWJXKaJrGbS+RHGxT6qWgcB+Mx5ETzZfg==", + "version": "0.3.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.180.tgz", + "integrity": "sha512-73vv8C6Fg8CBhZZUNyX286kaNNd3iwjimSqctPZhN1wPUO4wE9mOp52hP6vCi2Vq8aZvbREmVcAvAIzNx0iNNg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.167.tgz", - "integrity": "sha512-1K6POdu0iCdjtW0Bs2z3IGWpMU4gJypbYxGnecbGnsH86rNRGwAKS0bKwWlHAiUQLlOxSGxTiNbazbrDln03FQ==", + "version": "0.17.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.180.tgz", + "integrity": "sha512-w6dwnHeA9xxmTon2JF6SK1NwfNcmq8LzGPjyzPTwRKleFayHOH88MyIz/HCfgey+AbZRh8gsiA+lvS89/YMcQw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.167.tgz", - "integrity": "sha512-7EK/PN7QaUZcNE+bHmt7ELSNK3OBR2UZEuqNkE/0ooha7KqqI9mxZQG53Yn6wYcmRip34OFW9YF49kbTCkFuBg==", + "version": "0.15.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.180.tgz", + "integrity": "sha512-+lmgiIXKWMdQvxBM5MfEX4QQ6f4F1jz4VhxnIcfuxUodq5gE+aQHC7gA7zT/LE1R7UJKT0t2byNqFb96lVu0QQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.167.tgz", - "integrity": "sha512-uOJCfsMhML8GTesUKqCC4CH2cPH9yCIFnixiwgpcE5eLVrLszXW3tny25S/bu6EM+rfvE4nwIvLNTMrQYYnMFA==", + "version": "0.10.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.180.tgz", + "integrity": "sha512-lStuJCp27ftt7BC7V1poSbPM4J2rluNUybeMn9/D9Kj0bNQYEqMX+cxjYPgVCbgnQIluQAsay8fS2pGiRM4PZA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.166", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.166.tgz", - "integrity": "sha512-SZmz7HDeSMc4O0++x14ma/UWbK/0Ea8AikHw6V5ex/shjrjwbik7Uf2n8FfG2zMYNgBakvCy/SbwDPtQN+IbRQ==", + "version": "0.20.0-beta.179", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.179.tgz", + "integrity": "sha512-mYWo305LkZjK+wUt+27+GHXs8NTV5YCzxRY6l9vWaqd+/5V/JdzyZTTR4gA3Vc4CEQETUrhwAVex5arzjWE/IA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.167.tgz", - "integrity": "sha512-8TokXIwL8UeHhR4mAlUurzrqku5xaDXsikNi0HWpTcPCtZPdntxW36OaHxJmmpuHc8CecdaJehSuhApeW2TuZw==", + "version": "6.1.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.180.tgz", + "integrity": "sha512-ls1zAXWBWmBGclvhduQoNO66ZNFqaWZY+SqlKl76p/02EKoPMVdPQ0VSc+w7G+Il3OZdL+HMyq6of7b/V2nZ8Q==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.167.tgz", - "integrity": "sha512-OOG2gcH9OhEjY+KW3X2s30e1KzaRlynhkF9/oKfb2PNUJBYUdXeww4YAugrz7+nLP8KxCeOdSJrq7VvRzyZrwA==", + "version": "6.1.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.180.tgz", + "integrity": "sha512-EBF41C3OWwmy5XyWbxiWvgjnb1mXvzwLfFlOvRZPnHA/esXjvwF30aJRvpfI8tPxkNlT05zsx908hLv4XwDuRg==", "license": "MIT", "workspaces": [ "addons/*" ] }, + "node_modules/@zip.js/zip.js": { + "version": "2.8.21", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.21.tgz", + "integrity": "sha512-fkyzXISE3IMrstDO1AgPkJCx14MYHP/suIGiAovEYEuBjq3mffsuL6aMV7ohOSjW4rXtuACuUfpA3GtITgdtYg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3957,9 +5235,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -3987,11 +5265,43 @@ "node": ">= 14" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/agent-browser": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/agent-browser/-/agent-browser-0.16.3.tgz", + "integrity": "sha512-dsg8PTJNBIQ7/LPp/La42KQwLTzsP8sudbCLpP1atsJXps4Fbuz1CeepUJAGrgxb8koc9y4yKobYVPAsds8hPQ==", "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "node-simctl": "^7.4.0", + "playwright-core": "^1.57.0", + "webdriverio": "^9.15.0", + "ws": "^8.19.0", + "zod": "^3.22.4" + }, + "bin": { + "agent-browser": "bin/agent-browser.js" + } + }, + "node_modules/agent-browser/node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4122,6 +5432,261 @@ "node": ">=0.10.0" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/archiver/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", @@ -4143,6 +5708,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/arr-diff": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", @@ -4376,6 +5951,36 @@ "node": ">=0.10.0" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/async-done": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.2.tgz", @@ -4409,6 +6014,21 @@ "node": ">= 0.10" } }, + "node_modules/asyncbox": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-3.0.0.tgz", + "integrity": "sha512-X7U0nedUMKV3nn9c4R0Zgvdvv6cw97tbDlHSZicq1snGPi/oX9DgGmFSURWtxDdnBWd3V0YviKhqAYAVvoWQ/A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bluebird": "^3.5.1", + "lodash": "^4.17.4", + "source-map-support": "^0.x" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -4427,6 +6047,13 @@ "node": ">= 4.5.0" } }, + "node_modules/atob-lite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz", + "integrity": "sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==", + "dev": true, + "license": "MIT" + }, "node_modules/available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -4619,6 +6246,16 @@ "node": ">= 0.8" } }, + "node_modules/basic-ftp": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -4659,6 +6296,13 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -4769,6 +6413,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/btoa-lite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/btoa-lite/-/btoa-lite-1.0.0.tgz", + "integrity": "sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==", + "dev": true, + "license": "MIT" + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -5041,6 +6692,60 @@ "node": "*" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -5492,6 +7197,109 @@ "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==", "dev": true }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/compress-commons/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/compress-commons/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5538,6 +7346,13 @@ "proto-list": "~1.2.1" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC" + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -5647,6 +7462,106 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/crc32-stream/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/crc32-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5751,6 +7666,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-shorthand-properties": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/css-shorthand-properties/-/css-shorthand-properties-1.1.2.tgz", + "integrity": "sha512-C2AugXIpRGQTxaCW0N7n5jD/p5irUmCrwl03TrnMFBHDbdq44CFWR2zO7rK9xPN4Eo3pUxC4vQzQgbIpzrD1PQ==", + "dev": true, + "license": "MIT" + }, "node_modules/css-tree": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", @@ -5764,6 +7686,12 @@ "node": ">=8.0.0" } }, + "node_modules/css-value": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/css-value/-/css-value-0.0.1.tgz", + "integrity": "sha512-FUV3xaJ63buRLgHrLQVlVgQnQdR4yqdLGaDu7g8CQcWjInDfM9plBTPI9FRfpahju1UBSaMckeb2/46ApS/V1Q==", + "dev": true + }, "node_modules/css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", @@ -5798,6 +7726,16 @@ "type": "^1.0.1" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/debounce": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.1.0.tgz", @@ -5943,6 +7881,16 @@ "node": ">=4.0.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/default-browser": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", @@ -6055,6 +8003,21 @@ "node": ">=0.10.0" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-0.0.6.tgz", @@ -6080,6 +8043,13 @@ "node": ">= 0.8" } }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "dev": true, + "license": "ISC" + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -6144,6 +8114,19 @@ "node": ">=0.3.1" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -6186,10 +8169,11 @@ } }, "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -6275,6 +8259,86 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/edge-paths": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/edge-paths/-/edge-paths-3.0.5.tgz", + "integrity": "sha512-sB7vSrDnFa4ezWQk9nZ/n0FdpdUuC6R1EOrlU3DL+bovcNFK28rqu2emmAUjujYEJTWIgQGqgVVWUZXMnc8iWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/which": "^2.0.1", + "which": "^2.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/shirshak55" + } + }, + "node_modules/edgedriver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/edgedriver/-/edgedriver-6.3.0.tgz", + "integrity": "sha512-ggEQL+oEyIcM4nP2QC3AtCQ04o4kDNefRM3hja0odvlPSnsaxiruMxEZ93v3gDCKWYW6BXUr51PPradb+3nffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@wdio/logger": "^9.18.0", + "@zip.js/zip.js": "^2.8.11", + "decamelize": "^6.0.1", + "edge-paths": "^3.0.5", + "fast-xml-parser": "^5.3.3", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "which": "^6.0.0" + }, + "bin": { + "edgedriver": "bin/edgedriver.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/edgedriver/node_modules/decamelize": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-6.0.1.tgz", + "integrity": "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/edgedriver/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/edgedriver/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/editorconfig": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.2.tgz", @@ -6327,9 +8391,9 @@ "dev": true }, "node_modules/electron": { - "version": "39.6.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.6.0.tgz", - "integrity": "sha512-KQK3sJ6JCyymY3HQxV0N/bVBQwKQETRW0N/+OYcrL9H6tZhpmTSaZY3qSxcruWrPIuouvoiP3Vk/JKUpw05ZIw==", + "version": "39.8.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.0.tgz", + "integrity": "sha512-K+f3YelSyh9Q4LgUXuIhLB4kq73LJrqnIbe8ih9vpWi+iSdPebj0w7FRYwILCMDoyBQMFC9LicYHuIPmZzdKlg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6368,6 +8432,33 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -6390,6 +8481,30 @@ "node": ">=10.13.0" } }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/enquirer/node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -6559,6 +8674,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, "node_modules/eslint": { "version": "9.36.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", @@ -6690,6 +8827,32 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^1.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", @@ -6779,6 +8942,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -6856,11 +9033,22 @@ "through": "~2.3.1" } }, - "node_modules/events": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.2.0.tgz", - "integrity": "sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg==", + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.x" } @@ -6888,6 +9076,129 @@ "node": ">=18.0.0" } }, + "node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/execa/node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/execa/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/execa/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/expand-brackets": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", @@ -7377,6 +9688,39 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", + "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.9.0.tgz", @@ -7942,6 +10286,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/geckodriver": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.0.tgz", + "integrity": "sha512-ZRXLa4ZaYTTgUO4Eefw+RsQCleugU2QLb1ME7qTYxxuRj51yAhfnXaItXNs5/vUzfIaDHuZ+YnSF005hfp07nQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@wdio/logger": "^9.18.0", + "@zip.js/zip.js": "^2.8.11", + "decamelize": "^6.0.1", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "modern-tar": "^0.7.2" + }, + "bin": { + "geckodriver": "bin/geckodriver.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/geckodriver/node_modules/decamelize": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-6.0.1.tgz", + "integrity": "sha512-G7Cqgaelq68XHJNGlZ7lrNQyhZGsFqpwtGFexqUv4IQdjKoSYF7ipZ9UuTJZUSQXFj/XaoBLuEVIVqr8EJngEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -7985,6 +10371,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -8033,6 +10432,21 @@ "once": "^1.3.1" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/get-value": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", @@ -8592,6 +11006,13 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true, + "license": "MIT" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -10076,6 +12497,46 @@ "integrity": "sha512-a4u9BeERWGu/S8JiWEAQcdrg9v4QArtP9keViQjGMdff20fBdd8waotXaNmODqBe6uZ3Nafi7K/ho4gCQHV3Ig==", "dev": true }, + "node_modules/htmlfy": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/htmlfy/-/htmlfy-0.8.1.tgz", + "integrity": "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-assert": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", @@ -10346,6 +12807,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -10977,6 +13449,16 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/jose": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", @@ -11559,6 +14041,41 @@ "uc.micro": "^2.0.0" } }, + "node_modules/locate-app": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/locate-app/-/locate-app-2.5.0.tgz", + "integrity": "sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://buymeacoffee.com/hejny" + }, + { + "type": "github", + "url": "https://github.com/hejny/locate-app/blob/main/README.md#%EF%B8%8F-contributing" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@promptbook/utils": "0.69.5", + "type-fest": "4.26.0", + "userhome": "1.0.1" + } + }, + "node_modules/locate-app/node_modules/type-fest": { + "version": "4.26.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.0.tgz", + "integrity": "sha512-OduNjVJsFbifKb57UqZ2EMP1i4u64Xwow3NYXUtBbD4vIwJdQd4+xl8YDou1dlm4DVrtwT/7Ky8z8WyCULVfxw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -11612,6 +14129,34 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.set": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", + "integrity": "sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.zip": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", + "integrity": "sha512-C7IOaBBK/0gMORRBd8OETNx3kmOkgIWIPvyDpZSCTwUrpYmgZwJkjZeOD8ww4xbOUOs4/attY+pciKvadNfFbg==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -11628,6 +14173,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/loglevel-plugin-prefix": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/loglevel-plugin-prefix/-/loglevel-plugin-prefix-0.8.4.tgz", + "integrity": "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -11669,6 +14235,19 @@ "es5-ext": "~0.10.2" } }, + "node_modules/macos-release": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.1.tgz", + "integrity": "sha512-DXqXhEM7gW59OjZO8NIjBCz9AQ1BEMrfiOAl4AYByHCtVHRF4KoGNO8mqQeM8lRCtQe/UnJ4imO/d2HdkKsd+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -11733,10 +14312,11 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -12136,12 +14716,12 @@ } }, "node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, "node_modules/minizlib": { @@ -12156,14 +14736,12 @@ "node": ">= 18" } }, - "node_modules/minizlib/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" }, "node_modules/mixin-deep": { "version": "1.3.2", @@ -12405,6 +14983,16 @@ "node": ">=10" } }, + "node_modules/modern-tar": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.5.tgz", + "integrity": "sha512-YTefgdpKKFgoTDbEUqXqgUJct2OG6/4hs4XWLsxcHkDLj/x/V8WmKIRppPnXP5feQ7d1vuYWSp3qKkxfwaFaxA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -12564,12 +15152,29 @@ "node": ">= 0.6" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/next-tick": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "dev": true }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nise": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.0.tgz", @@ -12677,6 +15282,133 @@ "dev": true, "license": "MIT" }, + "node_modules/node-simctl": { + "version": "7.7.5", + "resolved": "https://registry.npmjs.org/node-simctl/-/node-simctl-7.7.5.tgz", + "integrity": "sha512-lWflzDW9xLuOOvR6mTJ9efbDtO/iSCH6rEGjxFxTV0vGgz5XjoZlW2BkNCCZib0B6Y23tCOiYhYJaMQYB8FKIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@appium/logger": "^1.3.0", + "asyncbox": "^3.0.0", + "bluebird": "^3.5.1", + "lodash": "^4.2.1", + "rimraf": "^5.0.0", + "semver": "^7.0.0", + "source-map-support": "^0.x", + "teen_process": "^2.2.0", + "uuid": "^11.0.1", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=8" + } + }, + "node_modules/node-simctl/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/node-simctl/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-simctl/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-simctl/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-simctl/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-simctl/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/node-simctl/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nopt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", @@ -12846,6 +15578,29 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/nth-check": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", @@ -13068,6 +15823,13 @@ "node": ">=0.10.0" } }, + "node_modules/octokit-pagination-methods": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz", + "integrity": "sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==", + "dev": true, + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -13302,6 +16064,20 @@ "node": ">=0.10.0" } }, + "node_modules/os-name": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-name/-/os-name-3.1.0.tgz", + "integrity": "sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "macos-release": "^2.2.0", + "windows-release": "^3.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -13343,6 +16119,16 @@ "node": ">=8" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -13391,6 +16177,40 @@ "node": ">=6" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -13462,6 +16282,59 @@ "node": ">=0.10.0" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -13906,6 +16779,36 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -14007,6 +16910,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "dev": true, + "license": "MIT" + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -14034,15 +16944,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -14276,6 +17177,39 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -14313,6 +17247,19 @@ "node": ">=0.10.0" } }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, "node_modules/remove-bom-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", @@ -14612,6 +17559,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/resq": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/resq/-/resq-1.11.0.tgz", + "integrity": "sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^2.0.1" + } + }, + "node_modules/resq/node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", + "dev": true, + "license": "MIT" + }, "node_modules/restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -14653,6 +17617,13 @@ "node": ">=0.10.0" } }, + "node_modules/rgb2hex": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/rgb2hex/-/rgb2hex-0.2.5.tgz", + "integrity": "sha512-22MOP1Rh7sAo1BZpDG6R5RFYzR2lYEgwq7HEmyW2qcsOqR2lQKmn+O//xV3YG/0rrhMC6KVX2hU+ZXuaw9a5bw==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -14761,6 +17732,16 @@ } ] }, + "node_modules/safaridriver": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safaridriver/-/safaridriver-1.0.1.tgz", + "integrity": "sha512-jkg4434cYgtrIF2AeY/X0Wmd2W73cK5qIEFE3hDrrQenJH/2SDJIXGvPAigfvQTcE9+H31zkiNHbUqcihEiMRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -14775,6 +17756,36 @@ "ret": "~0.1.10" } }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-regex2/node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -14799,9 +17810,9 @@ } }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14923,13 +17934,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.4.tgz", + "integrity": "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/serve-static": { @@ -15260,6 +18271,24 @@ "integrity": "sha512-Q9VME8WyGkc7pJf6QEkj3wE+2CnvZMI+XJhwdTPR8Z/kWQRXi7boAWLDibRPyHRTUTPx5FaU7MsyrjI3yLB4HA==", "dev": true }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -15487,11 +18516,12 @@ } }, "node_modules/socks-proxy-agent": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", - "integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", "dependencies": { - "agent-base": "^7.1.1", + "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" }, @@ -15547,6 +18577,23 @@ "deprecated": "See https://github.com/lydell/source-map-url#deprecated", "dev": true }, + "node_modules/spacetrim": { + "version": "0.11.59", + "resolved": "https://registry.npmjs.org/spacetrim/-/spacetrim-0.11.59.tgz", + "integrity": "sha512-lLYsktklSRKprreOm7NXReW8YiX2VBjbgmXYEziOoGf/qsJqAEACaDvoTtUOycwjpaSh+bT8eu0KrJn7UNxiCg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://buymeacoffee.com/hejny" + }, + { + "type": "github", + "url": "https://github.com/hejny/spacetrim/blob/main/README.md#%EF%B8%8F-contributing" + } + ], + "license": "Apache-2.0" + }, "node_modules/sparkles": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-1.0.1.tgz", @@ -15612,6 +18659,16 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -15969,6 +19026,16 @@ "node": ">=0.10.0" } }, + "node_modules/strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -15981,6 +19048,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -16153,6 +19233,69 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -16219,15 +19362,6 @@ "streamx": "^2.15.0" } }, - "node_modules/tar/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -16246,6 +19380,23 @@ "node": ">=22" } }, + "node_modules/teen_process": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/teen_process/-/teen_process-2.3.3.tgz", + "integrity": "sha512-NIdeetf/6gyEqLjnzvfgQe7PfipSceq2xDQM2Py2BkBnIIeWh3HRD3vNhulyO5WppfCv9z4mtsEHyq8kdiULTA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bluebird": "^3.7.2", + "lodash": "^4.17.21", + "shell-quote": "^1.8.1", + "source-map-support": "^0.x" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0", + "npm": ">=8" + } + }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -16311,6 +19462,13 @@ "b4a": "^1.6.4" } }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, "node_modules/textextensions": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-1.0.2.tgz", @@ -16662,6 +19820,29 @@ "node": ">=0.6.x" } }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", @@ -16781,9 +19962,9 @@ "dev": true }, "node_modules/typescript": { - "version": "6.0.0-dev.20260130", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.0-dev.20260130.tgz", - "integrity": "sha512-flWwLX5Xzh7to9d46u3LXfVDq9F0L0FtgnsYcx/SksqP05uHBIPnWfB6wWOZphTkb7GRSRKU13X/zBHmbzhXXg==", + "version": "6.0.0-dev.20260306", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.0-dev.20260306.tgz", + "integrity": "sha512-ssxgK3/0yA2LEW23KzSNtnqSL9zDaVGTesx2S3EN+v8kqkPScFTin7S63KfQ4UDZGZGcvBgHCEoEz7t7v2yR8Q==", "dev": true, "license": "Apache-2.0", "bin": { @@ -17078,6 +20259,13 @@ "requires-port": "^1.0.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true, + "license": "MIT" + }, "node_modules/use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -17087,6 +20275,16 @@ "node": ">=0.10.0" } }, + "node_modules/userhome": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/userhome/-/userhome-1.0.1.tgz", + "integrity": "sha512-5cnLm4gseXjAclKowC4IjByaGsjtAoV6PrOQOljplNB54ReUYJP8HdAFq2muHinSDAh09PPX/uXDPfdxRHvuSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -17117,6 +20315,13 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-compile-cache": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", + "integrity": "sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-inspect-profiler": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/v8-inspect-profiler/-/v8-inspect-profiler-0.1.1.tgz", @@ -17365,18 +20570,200 @@ "dev": true, "license": "MIT" }, + "node_modules/wait-port": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz", + "integrity": "sha512-3e04qkoN3LxTMLakdqeWth8nih8usyg+sf1Bgdf9wwUkp05iuK1eSY/QpLvscT/+F/gA89+LpUmmgBtesbqI2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "commander": "^9.3.0", + "debug": "^4.3.4" + }, + "bin": { + "wait-port": "bin/wait-port.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/web-tree-sitter": { "version": "0.20.8", "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.20.8.tgz", "integrity": "sha512-weOVgZ3aAARgdnb220GqYuh7+rZU0Ka9k9yfKtGAzEYMa6GgiCzW9JjQRJyCJakvibQW+dfjJdihjInKuuCAUQ==", "dev": true }, + "node_modules/webdriver": { + "version": "9.24.0", + "resolved": "https://registry.npmjs.org/webdriver/-/webdriver-9.24.0.tgz", + "integrity": "sha512-2R31Ey83NzMsafkl4hdFq6GlIBvOODQMkueLjeRqYAITu3QCYiq9oqBdnWA6CdePuV4dbKlYsKRX0mwMiPclDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.1.0", + "@types/ws": "^8.5.3", + "@wdio/config": "9.24.0", + "@wdio/logger": "9.18.0", + "@wdio/protocols": "9.24.0", + "@wdio/types": "9.24.0", + "@wdio/utils": "9.24.0", + "deepmerge-ts": "^7.0.3", + "https-proxy-agent": "^7.0.6", + "undici": "^6.21.3", + "ws": "^8.8.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/webdriver/node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/webdriver/node_modules/undici": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/webdriverio": { + "version": "9.24.0", + "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-9.24.0.tgz", + "integrity": "sha512-LTJt6Z/iDM0ne/4ytd3BykoPv9CuJ+CAILOzlwFeMGn4Mj02i4Bk2Rg9o/jeJ89f52hnv4OPmNjD0e8nzWAy5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.11.30", + "@types/sinonjs__fake-timers": "^8.1.5", + "@wdio/config": "9.24.0", + "@wdio/logger": "9.18.0", + "@wdio/protocols": "9.24.0", + "@wdio/repl": "9.16.2", + "@wdio/types": "9.24.0", + "@wdio/utils": "9.24.0", + "archiver": "^7.0.1", + "aria-query": "^5.3.0", + "cheerio": "^1.0.0-rc.12", + "css-shorthand-properties": "^1.1.1", + "css-value": "^0.0.1", + "grapheme-splitter": "^1.0.4", + "htmlfy": "^0.8.1", + "is-plain-obj": "^4.1.0", + "jszip": "^3.10.1", + "lodash.clonedeep": "^4.5.0", + "lodash.zip": "^4.2.0", + "query-selector-shadow-dom": "^1.0.1", + "resq": "^1.11.0", + "rgb2hex": "0.2.5", + "serialize-error": "^12.0.0", + "urlpattern-polyfill": "^10.0.0", + "webdriver": "9.24.0" + }, + "engines": { + "node": ">=18.20.0" + }, + "peerDependencies": { + "puppeteer-core": ">=22.x || <=24.x" + }, + "peerDependenciesMeta": { + "puppeteer-core": { + "optional": true + } + } + }, + "node_modules/webdriverio/node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/webdriverio/node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webdriverio/node_modules/serialize-error": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-12.0.0.tgz", + "integrity": "sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^4.31.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -17435,6 +20822,22 @@ "license": "MIT", "optional": true }, + "node_modules/windows-release": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/windows-release/-/windows-release-3.3.3.tgz", + "integrity": "sha512-OSOGH1QYiW5yVor9TtmXKQvt2vjQqbYS+DqmsZw+r7xDwLXEeT3JGW0ZppFmHx4diyXmxt238KFR3N9jzevBRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^1.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -17534,6 +20937,28 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", @@ -17708,6 +21133,94 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/zip-stream/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/zip-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 4aeb079f7e1..e47d04914ba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", - "version": "1.111.0", - "distro": "447d8d5a69fba52fcf6ea15dd29e92dd5dbbd2cd", + "version": "1.112.0", + "distro": "af92bdba4fde517633b51b6706479a636c8d541a", "author": { "name": "Microsoft Corporation" }, @@ -31,6 +31,7 @@ "watch-client": "npm run gulp watch-client", "watch-clientd": "deemon npm run watch-client", "kill-watch-clientd": "deemon --kill npm run watch-client", + "transpile-client": "node build/next/index.ts transpile", "watch-client-transpile": "node build/next/index.ts transpile --watch", "watch-client-transpiled": "deemon npm run watch-client-transpile", "kill-watch-client-transpiled": "deemon --kill npm run watch-client-transpile", @@ -85,7 +86,7 @@ "@parcel/watcher": "^2.5.6", "@playwright/cli": "^0.1.1", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-11", + "@vscode/codicons": "^0.0.45-14", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -100,16 +101,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.167", - "@xterm/addon-image": "^0.10.0-beta.167", - "@xterm/addon-ligatures": "^0.11.0-beta.167", - "@xterm/addon-progress": "^0.3.0-beta.167", - "@xterm/addon-search": "^0.17.0-beta.167", - "@xterm/addon-serialize": "^0.15.0-beta.167", - "@xterm/addon-unicode11": "^0.10.0-beta.167", - "@xterm/addon-webgl": "^0.20.0-beta.166", - "@xterm/headless": "^6.1.0-beta.167", - "@xterm/xterm": "^6.1.0-beta.167", + "@xterm/addon-clipboard": "^0.3.0-beta.180", + "@xterm/addon-image": "^0.10.0-beta.180", + "@xterm/addon-ligatures": "^0.11.0-beta.180", + "@xterm/addon-progress": "^0.3.0-beta.180", + "@xterm/addon-search": "^0.17.0-beta.180", + "@xterm/addon-serialize": "^0.15.0-beta.180", + "@xterm/addon-unicode11": "^0.10.0-beta.180", + "@xterm/addon-webgl": "^0.20.0-beta.179", + "@xterm/headless": "^6.1.0-beta.180", + "@xterm/xterm": "^6.1.0-beta.180", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -152,10 +153,10 @@ "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", - "@typescript/native-preview": "^7.0.0-dev.20260130", - "@vscode/component-explorer": "^0.1.1-16", - "@vscode/component-explorer-cli": "^0.1.1-12", - "@vscode/gulp-electron": "1.40.0", + "@typescript/native-preview": "^7.0.0-dev.20260306", + "@vscode/component-explorer": "^0.1.1-22", + "@vscode/component-explorer-cli": "^0.1.1-18", + "@vscode/gulp-electron": "1.40.1", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", @@ -164,13 +165,14 @@ "@vscode/v8-heap-parser": "^0.1.0", "@vscode/vscode-perf": "^0.0.19", "@webgpu/types": "^0.1.66", + "agent-browser": "^0.16.3", "ansi-colors": "^3.2.3", "asar": "^3.0.3", "chromium-pickle-js": "^0.2.0", "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.6.0", + "electron": "39.8.0", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -220,7 +222,7 @@ "tar": "^7.5.9", "tsec": "0.2.7", "tslib": "^2.6.3", - "typescript": "^6.0.0-dev.20260130", + "typescript": "^6.0.0-dev.20260306", "typescript-eslint": "^8.45.0", "util": "^0.12.4", "xml2js": "^0.5.0", @@ -230,7 +232,8 @@ "node-gyp-build": "4.8.1", "kerberos@2.1.1": { "node-addon-api": "7.1.0" - } + }, + "serialize-javascript": "^7.0.3" }, "repository": { "type": "git", diff --git a/remote/package-lock.json b/remote/package-lock.json index 49b97cb47d0..cb95c26bc12 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -22,16 +22,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.167", - "@xterm/addon-image": "^0.10.0-beta.167", - "@xterm/addon-ligatures": "^0.11.0-beta.167", - "@xterm/addon-progress": "^0.3.0-beta.167", - "@xterm/addon-search": "^0.17.0-beta.167", - "@xterm/addon-serialize": "^0.15.0-beta.167", - "@xterm/addon-unicode11": "^0.10.0-beta.167", - "@xterm/addon-webgl": "^0.20.0-beta.166", - "@xterm/headless": "^6.1.0-beta.167", - "@xterm/xterm": "^6.1.0-beta.167", + "@xterm/addon-clipboard": "^0.3.0-beta.180", + "@xterm/addon-image": "^0.10.0-beta.180", + "@xterm/addon-ligatures": "^0.11.0-beta.180", + "@xterm/addon-progress": "^0.3.0-beta.180", + "@xterm/addon-search": "^0.17.0-beta.180", + "@xterm/addon-serialize": "^0.15.0-beta.180", + "@xterm/addon-unicode11": "^0.10.0-beta.180", + "@xterm/addon-webgl": "^0.20.0-beta.179", + "@xterm/headless": "^6.1.0-beta.180", + "@xterm/xterm": "^6.1.0-beta.180", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -421,9 +421,10 @@ "license": "MIT" }, "node_modules/@tootallnate/once": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-3.0.0.tgz", - "integrity": "sha512-OAdBVB7rlwvLD+DiecSAyVKzKVmSfXbouCyM5I6wHGi4MGXIyFqErg1IvyJ7PI1e+GYZuZh7cCHV/c4LA8SKMw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-3.0.1.tgz", + "integrity": "sha512-VyMVKRrpHTT8PnotUeV8L/mDaMwD5DaAKCFLP73zAqAtvF0FCqky+Ki7BYbFCYQmqFyTe9316Ed5zS70QUR9eg==", + "license": "MIT", "engines": { "node": ">= 10" } @@ -578,30 +579,30 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.167.tgz", - "integrity": "sha512-+JSjagAk6okCaGVYFwkKl8qIBfy+W+h7p/qULIi9cC8QyeswOLaE4GOqY5yuGNQYU+zMlrpgR1ttyp0o6y9LHg==", + "version": "0.3.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.180.tgz", + "integrity": "sha512-8vJ2BN8tot7Qqgl1p0ALXIy/SOvF7nQmh3DEZph6ZARNuV3JUrpF8xdK+lmd25/fm7NFgnGdntLe9k/g2qbAgw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.167.tgz", - "integrity": "sha512-Bxi2oTaX7YM1gup0OSv02n9+tA3P1Ozlu5zyB/ZwSVkepB9FOxCODWD0l3DhWyLGMBqQ+OY/COw5SRxrKyvkNg==", + "version": "0.10.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.180.tgz", + "integrity": "sha512-CGZxW47ZllCqS6n4vVD0lVCz2sq9FqP6czQoCJBStFJho05ioVLy0wQr8XCDJO87wuYvPjJaU3BlsyCDL2xVzQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.167.tgz", - "integrity": "sha512-d+9ANnoz6D4J06CjronVolcG+J0jqUWQXbzciRqQkHq0or5k8PYuIj2DuuyBx/0rOaN7JYN347KQ9iylk+++xA==", + "version": "0.11.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.180.tgz", + "integrity": "sha512-YiCxw7P1rj5TVs25BJT9OiZ9QghNtboBhpYB7KZUOB2aqlCPsyIknf9GMbCrZegDfMMa22FWPyXF4oeZDBmpFg==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -611,67 +612,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.167.tgz", - "integrity": "sha512-8eeaWnp0pnjYaKtOLsXVCE0hTFXS0A2kZCciWp52l6CbNGQsnky4VNWJXKaJrGbS+RHGxT6qWgcB+Mx5ETzZfg==", + "version": "0.3.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.180.tgz", + "integrity": "sha512-73vv8C6Fg8CBhZZUNyX286kaNNd3iwjimSqctPZhN1wPUO4wE9mOp52hP6vCi2Vq8aZvbREmVcAvAIzNx0iNNg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.167.tgz", - "integrity": "sha512-1K6POdu0iCdjtW0Bs2z3IGWpMU4gJypbYxGnecbGnsH86rNRGwAKS0bKwWlHAiUQLlOxSGxTiNbazbrDln03FQ==", + "version": "0.17.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.180.tgz", + "integrity": "sha512-w6dwnHeA9xxmTon2JF6SK1NwfNcmq8LzGPjyzPTwRKleFayHOH88MyIz/HCfgey+AbZRh8gsiA+lvS89/YMcQw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.167.tgz", - "integrity": "sha512-7EK/PN7QaUZcNE+bHmt7ELSNK3OBR2UZEuqNkE/0ooha7KqqI9mxZQG53Yn6wYcmRip34OFW9YF49kbTCkFuBg==", + "version": "0.15.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.180.tgz", + "integrity": "sha512-+lmgiIXKWMdQvxBM5MfEX4QQ6f4F1jz4VhxnIcfuxUodq5gE+aQHC7gA7zT/LE1R7UJKT0t2byNqFb96lVu0QQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.167.tgz", - "integrity": "sha512-uOJCfsMhML8GTesUKqCC4CH2cPH9yCIFnixiwgpcE5eLVrLszXW3tny25S/bu6EM+rfvE4nwIvLNTMrQYYnMFA==", + "version": "0.10.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.180.tgz", + "integrity": "sha512-lStuJCp27ftt7BC7V1poSbPM4J2rluNUybeMn9/D9Kj0bNQYEqMX+cxjYPgVCbgnQIluQAsay8fS2pGiRM4PZA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.166", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.166.tgz", - "integrity": "sha512-SZmz7HDeSMc4O0++x14ma/UWbK/0Ea8AikHw6V5ex/shjrjwbik7Uf2n8FfG2zMYNgBakvCy/SbwDPtQN+IbRQ==", + "version": "0.20.0-beta.179", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.179.tgz", + "integrity": "sha512-mYWo305LkZjK+wUt+27+GHXs8NTV5YCzxRY6l9vWaqd+/5V/JdzyZTTR4gA3Vc4CEQETUrhwAVex5arzjWE/IA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.167.tgz", - "integrity": "sha512-8TokXIwL8UeHhR4mAlUurzrqku5xaDXsikNi0HWpTcPCtZPdntxW36OaHxJmmpuHc8CecdaJehSuhApeW2TuZw==", + "version": "6.1.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.180.tgz", + "integrity": "sha512-ls1zAXWBWmBGclvhduQoNO66ZNFqaWZY+SqlKl76p/02EKoPMVdPQ0VSc+w7G+Il3OZdL+HMyq6of7b/V2nZ8Q==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.167.tgz", - "integrity": "sha512-OOG2gcH9OhEjY+KW3X2s30e1KzaRlynhkF9/oKfb2PNUJBYUdXeww4YAugrz7+nLP8KxCeOdSJrq7VvRzyZrwA==", + "version": "6.1.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.180.tgz", + "integrity": "sha512-EBF41C3OWwmy5XyWbxiWvgjnb1mXvzwLfFlOvRZPnHA/esXjvwF30aJRvpfI8tPxkNlT05zsx908hLv4XwDuRg==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/package.json b/remote/package.json index 2de8217e16d..373bbc43d44 100644 --- a/remote/package.json +++ b/remote/package.json @@ -17,16 +17,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.167", - "@xterm/addon-image": "^0.10.0-beta.167", - "@xterm/addon-ligatures": "^0.11.0-beta.167", - "@xterm/addon-progress": "^0.3.0-beta.167", - "@xterm/addon-search": "^0.17.0-beta.167", - "@xterm/addon-serialize": "^0.15.0-beta.167", - "@xterm/addon-unicode11": "^0.10.0-beta.167", - "@xterm/addon-webgl": "^0.20.0-beta.166", - "@xterm/headless": "^6.1.0-beta.167", - "@xterm/xterm": "^6.1.0-beta.167", + "@xterm/addon-clipboard": "^0.3.0-beta.180", + "@xterm/addon-image": "^0.10.0-beta.180", + "@xterm/addon-ligatures": "^0.11.0-beta.180", + "@xterm/addon-progress": "^0.3.0-beta.180", + "@xterm/addon-search": "^0.17.0-beta.180", + "@xterm/addon-serialize": "^0.15.0-beta.180", + "@xterm/addon-unicode11": "^0.10.0-beta.180", + "@xterm/addon-webgl": "^0.20.0-beta.179", + "@xterm/headless": "^6.1.0-beta.180", + "@xterm/xterm": "^6.1.0-beta.180", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 6767fc211f0..33bd3f8e13b 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,19 +10,19 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-11", + "@vscode/codicons": "^0.0.45-14", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.167", - "@xterm/addon-image": "^0.10.0-beta.167", - "@xterm/addon-ligatures": "^0.11.0-beta.167", - "@xterm/addon-progress": "^0.3.0-beta.167", - "@xterm/addon-search": "^0.17.0-beta.167", - "@xterm/addon-serialize": "^0.15.0-beta.167", - "@xterm/addon-unicode11": "^0.10.0-beta.167", - "@xterm/addon-webgl": "^0.20.0-beta.166", - "@xterm/xterm": "^6.1.0-beta.167", + "@xterm/addon-clipboard": "^0.3.0-beta.180", + "@xterm/addon-image": "^0.10.0-beta.180", + "@xterm/addon-ligatures": "^0.11.0-beta.180", + "@xterm/addon-progress": "^0.3.0-beta.180", + "@xterm/addon-search": "^0.17.0-beta.180", + "@xterm/addon-serialize": "^0.15.0-beta.180", + "@xterm/addon-unicode11": "^0.10.0-beta.180", + "@xterm/addon-webgl": "^0.20.0-beta.179", + "@xterm/xterm": "^6.1.0-beta.180", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.45-11", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-11.tgz", - "integrity": "sha512-fLjx4i7pfSYJJzzmQ6tZnshWWSLYUfg8Ru6xNRBWRSFj8yZkuuXEZGMxju4mt/tuu8Y/gjhEGmIVmVC16fg+yQ==", + "version": "0.0.45-14", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-14.tgz", + "integrity": "sha512-EdrK2NnxNGluUm9ZlU1C5VTLfG1cpO4C0CCXloS+8bDuTbidE1qtwaF5lHPcoDE102WBqBzWA09nVKFoN8RSOA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { @@ -100,30 +100,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.167.tgz", - "integrity": "sha512-+JSjagAk6okCaGVYFwkKl8qIBfy+W+h7p/qULIi9cC8QyeswOLaE4GOqY5yuGNQYU+zMlrpgR1ttyp0o6y9LHg==", + "version": "0.3.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.180.tgz", + "integrity": "sha512-8vJ2BN8tot7Qqgl1p0ALXIy/SOvF7nQmh3DEZph6ZARNuV3JUrpF8xdK+lmd25/fm7NFgnGdntLe9k/g2qbAgw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.167.tgz", - "integrity": "sha512-Bxi2oTaX7YM1gup0OSv02n9+tA3P1Ozlu5zyB/ZwSVkepB9FOxCODWD0l3DhWyLGMBqQ+OY/COw5SRxrKyvkNg==", + "version": "0.10.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.180.tgz", + "integrity": "sha512-CGZxW47ZllCqS6n4vVD0lVCz2sq9FqP6czQoCJBStFJho05ioVLy0wQr8XCDJO87wuYvPjJaU3BlsyCDL2xVzQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.167.tgz", - "integrity": "sha512-d+9ANnoz6D4J06CjronVolcG+J0jqUWQXbzciRqQkHq0or5k8PYuIj2DuuyBx/0rOaN7JYN347KQ9iylk+++xA==", + "version": "0.11.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.180.tgz", + "integrity": "sha512-YiCxw7P1rj5TVs25BJT9OiZ9QghNtboBhpYB7KZUOB2aqlCPsyIknf9GMbCrZegDfMMa22FWPyXF4oeZDBmpFg==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -133,58 +133,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.167.tgz", - "integrity": "sha512-8eeaWnp0pnjYaKtOLsXVCE0hTFXS0A2kZCciWp52l6CbNGQsnky4VNWJXKaJrGbS+RHGxT6qWgcB+Mx5ETzZfg==", + "version": "0.3.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.180.tgz", + "integrity": "sha512-73vv8C6Fg8CBhZZUNyX286kaNNd3iwjimSqctPZhN1wPUO4wE9mOp52hP6vCi2Vq8aZvbREmVcAvAIzNx0iNNg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.167.tgz", - "integrity": "sha512-1K6POdu0iCdjtW0Bs2z3IGWpMU4gJypbYxGnecbGnsH86rNRGwAKS0bKwWlHAiUQLlOxSGxTiNbazbrDln03FQ==", + "version": "0.17.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.180.tgz", + "integrity": "sha512-w6dwnHeA9xxmTon2JF6SK1NwfNcmq8LzGPjyzPTwRKleFayHOH88MyIz/HCfgey+AbZRh8gsiA+lvS89/YMcQw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.167.tgz", - "integrity": "sha512-7EK/PN7QaUZcNE+bHmt7ELSNK3OBR2UZEuqNkE/0ooha7KqqI9mxZQG53Yn6wYcmRip34OFW9YF49kbTCkFuBg==", + "version": "0.15.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.180.tgz", + "integrity": "sha512-+lmgiIXKWMdQvxBM5MfEX4QQ6f4F1jz4VhxnIcfuxUodq5gE+aQHC7gA7zT/LE1R7UJKT0t2byNqFb96lVu0QQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.167.tgz", - "integrity": "sha512-uOJCfsMhML8GTesUKqCC4CH2cPH9yCIFnixiwgpcE5eLVrLszXW3tny25S/bu6EM+rfvE4nwIvLNTMrQYYnMFA==", + "version": "0.10.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.180.tgz", + "integrity": "sha512-lStuJCp27ftt7BC7V1poSbPM4J2rluNUybeMn9/D9Kj0bNQYEqMX+cxjYPgVCbgnQIluQAsay8fS2pGiRM4PZA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.166", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.166.tgz", - "integrity": "sha512-SZmz7HDeSMc4O0++x14ma/UWbK/0Ea8AikHw6V5ex/shjrjwbik7Uf2n8FfG2zMYNgBakvCy/SbwDPtQN+IbRQ==", + "version": "0.20.0-beta.179", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.179.tgz", + "integrity": "sha512-mYWo305LkZjK+wUt+27+GHXs8NTV5YCzxRY6l9vWaqd+/5V/JdzyZTTR4gA3Vc4CEQETUrhwAVex5arzjWE/IA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.167" + "@xterm/xterm": "^6.1.0-beta.180" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.167", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.167.tgz", - "integrity": "sha512-OOG2gcH9OhEjY+KW3X2s30e1KzaRlynhkF9/oKfb2PNUJBYUdXeww4YAugrz7+nLP8KxCeOdSJrq7VvRzyZrwA==", + "version": "6.1.0-beta.180", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.180.tgz", + "integrity": "sha512-EBF41C3OWwmy5XyWbxiWvgjnb1mXvzwLfFlOvRZPnHA/esXjvwF30aJRvpfI8tPxkNlT05zsx908hLv4XwDuRg==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index 29fb7392340..b71c4b2a597 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,19 +5,19 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-11", + "@vscode/codicons": "^0.0.45-14", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.167", - "@xterm/addon-image": "^0.10.0-beta.167", - "@xterm/addon-ligatures": "^0.11.0-beta.167", - "@xterm/addon-progress": "^0.3.0-beta.167", - "@xterm/addon-search": "^0.17.0-beta.167", - "@xterm/addon-serialize": "^0.15.0-beta.167", - "@xterm/addon-unicode11": "^0.10.0-beta.167", - "@xterm/addon-webgl": "^0.20.0-beta.166", - "@xterm/xterm": "^6.1.0-beta.167", + "@xterm/addon-clipboard": "^0.3.0-beta.180", + "@xterm/addon-image": "^0.10.0-beta.180", + "@xterm/addon-ligatures": "^0.11.0-beta.180", + "@xterm/addon-progress": "^0.3.0-beta.180", + "@xterm/addon-search": "^0.17.0-beta.180", + "@xterm/addon-serialize": "^0.15.0-beta.180", + "@xterm/addon-unicode11": "^0.10.0-beta.180", + "@xterm/addon-webgl": "^0.20.0-beta.179", + "@xterm/xterm": "^6.1.0-beta.180", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", diff --git a/resources/win32/VisualElementsManifest.xml b/resources/win32/VisualElementsManifest.xml index 40efd0a396e..5ad1283cd7e 100644 --- a/resources/win32/VisualElementsManifest.xml +++ b/resources/win32/VisualElementsManifest.xml @@ -2,8 +2,8 @@ diff --git a/src/main.ts b/src/main.ts index ec2e45c31d2..42f599c9b37 100644 --- a/src/main.ts +++ b/src/main.ts @@ -342,7 +342,7 @@ function configureCommandlineSwitchesSync(cliArgs: NativeParsedArgs) { app.commandLine.appendSwitch('disable-blink-features', blinkFeaturesToDisable); // Support JS Flags - const jsFlags = getJSFlags(cliArgs); + const jsFlags = getJSFlags(cliArgs, argvConfig); if (jsFlags) { app.commandLine.appendSwitch('js-flags', jsFlags); } @@ -374,6 +374,7 @@ interface IArgvConfig { readonly 'use-inmemory-secretstorage'?: boolean; readonly 'enable-rdp-display-tracking'?: boolean; readonly 'remote-debugging-port'?: string; + readonly 'js-flags'?: string; } function readArgvConfigSync(): IArgvConfig { @@ -537,7 +538,7 @@ function configureCrashReporter(): void { }); } -function getJSFlags(cliArgs: NativeParsedArgs): string | null { +function getJSFlags(cliArgs: NativeParsedArgs, argvConfig: IArgvConfig): string | null { const jsFlags: string[] = []; // Add any existing JS flags we already got from the command line @@ -545,6 +546,11 @@ function getJSFlags(cliArgs: NativeParsedArgs): string | null { jsFlags.push(cliArgs['js-flags']); } + // Add JS flags from runtime arguments (argv.json) + if (typeof argvConfig['js-flags'] === 'string' && argvConfig['js-flags']) { + jsFlags.push(argvConfig['js-flags']); + } + if (process.platform === 'linux') { // Fix cppgc crash on Linux with 16KB page size. // Refs https://issues.chromium.org/issues/378017037 diff --git a/src/tsconfig.vscode-dts.json b/src/tsconfig.vscode-dts.json index b83f686e4f3..fae0ce15c38 100644 --- a/src/tsconfig.vscode-dts.json +++ b/src/tsconfig.vscode-dts.json @@ -1,7 +1,7 @@ { "compilerOptions": { "noEmit": true, - "module": "None", + "module": "preserve", "experimentalDecorators": false, "noImplicitReturns": true, "noImplicitOverride": true, diff --git a/src/typings/base-common.d.ts b/src/typings/base-common.d.ts index 56e9a6a799d..9028abb2975 100644 --- a/src/typings/base-common.d.ts +++ b/src/typings/base-common.d.ts @@ -25,7 +25,7 @@ declare global { function setTimeout(handler: string | Function, timeout?: number, ...arguments: any[]): Timeout; function clearTimeout(timeout: Timeout | undefined): void; - function setInterval(callback: (...args: any[]) => void, delay?: number, ...args: any[]): Timeout; + function setInterval(callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]): Timeout; function clearInterval(timeout: Timeout | undefined): void; diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index e3f20d96726..52f99538b11 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -66,6 +66,25 @@ export interface MarkdownSanitizerConfig { readonly remoteImageIsAllowed?: (uri: URI) => boolean; } +/** + * Returns a human-readable tooltip string for a link href. + * For file:// URIs, converts to a decoded OS file system path to avoid + * showing raw URL-encoded paths (e.g. "C:\Users\..." instead of "file:///c%3A/Users/..."). + */ +function getLinkTitle(href: string): string { + try { + const parsed = URI.parse(href); + if (parsed.scheme === Schemas.file) { + const path = parsed.fsPath; + const fragment = parsed.fragment; + return escapeDoubleQuotes(fragment ? `${path}#${fragment}` : path); + } + } catch { + // fall through + } + return ''; +} + const defaultMarkedRenderers = Object.freeze({ image: ({ href, title, text }: marked.Tokens.Image): string => { let dimensions: string[] = []; @@ -104,6 +123,12 @@ const defaultMarkedRenderers = Object.freeze({ title = typeof title === 'string' ? escapeDoubleQuotes(removeMarkdownEscapes(title)) : ''; href = removeMarkdownEscapes(href); + // For file:// URIs without an explicit title, show the decoded OS path instead of + // the raw URL-encoded URI (e.g. display "C:\Users\..." instead of "file:///c%3A/Users/...") + if (!title && href.startsWith(`${Schemas.file}:`)) { + title = getLinkTitle(href); + } + // HTML Encode href href = href.replace(/&/g, '&') .replace(/ .dialog-icon.codicon { - flex: 0 0 48px; - height: 48px; - font-size: 48px; + flex: 0 0 24px; + height: 24px; + font-size: 24px; } .monaco-dialog-box.align-vertical .dialog-message-row > .dialog-icon.codicon { @@ -76,12 +84,17 @@ align-self: baseline; } +.monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container { + align-self: stretch; /* fill row height so overflow-y scrolling works */ +} + /** Dialog: Message/Footer Container */ .monaco-dialog-box .dialog-message-row .dialog-message-container, .monaco-dialog-box .dialog-footer-row { display: flex; flex-direction: column; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; text-overflow: ellipsis; user-select: text; -webkit-user-select: text; @@ -95,7 +108,7 @@ .monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container, .monaco-dialog-box:not(.align-vertical) .dialog-footer-row { - padding-left: 24px; + padding-left: 12px; } .monaco-dialog-box.align-vertical .dialog-message-row .dialog-message-container, @@ -111,20 +124,20 @@ /** Dialog: Message */ .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message { - line-height: 22px; - font-size: 18px; + font-size: 14px; + font-weight: 600; flex: 1; /* let the message always grow */ white-space: normal; word-wrap: break-word; /* never overflow long words, but break to next line */ - min-height: 48px; /* matches icon height */ - margin-bottom: 8px; + min-height: 22px; + margin-bottom: 4px; display: flex; align-items: center; } /** Dialog: Details */ .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message-detail { - line-height: 22px; + line-height: 20px; flex: 1; /* let the message always grow */ } @@ -167,12 +180,8 @@ align-items: center; padding-right: 1px; overflow: hidden; /* buttons row should never overflow */ -} - -.monaco-dialog-box > .dialog-buttons-row { - display: flex; white-space: nowrap; - padding: 20px 10px 10px; + padding: 20px 0px 0px; } /** Dialog: Buttons */ @@ -196,8 +205,8 @@ .monaco-dialog-box > .dialog-buttons-row > .dialog-buttons > .monaco-button { overflow: hidden; text-overflow: ellipsis; - margin: 4px 5px; /* allows button focus outline to be visible */ - outline-offset: 2px !important; + margin: 4px; /* allows button focus outline to be visible */ + outline-offset: 1px !important; } .monaco-dialog-box.align-vertical > .dialog-buttons-row > .dialog-buttons > .monaco-button { @@ -240,5 +249,5 @@ } .monaco-dialog-modal-block .dialog-shadow { - border-radius: var(--vscode-cornerRadius-large); + border-radius: var(--vscode-cornerRadius-xLarge); } diff --git a/src/vs/base/browser/ui/dropdown/dropdown.css b/src/vs/base/browser/ui/dropdown/dropdown.css index 0e2d6bdd9f5..7c70f376b14 100644 --- a/src/vs/base/browser/ui/dropdown/dropdown.css +++ b/src/vs/base/browser/ui/dropdown/dropdown.css @@ -22,6 +22,7 @@ .monaco-dropdown .dropdown-menu { border-radius: var(--vscode-cornerRadius-large); + box-shadow: var(--vscode-shadow-lg); } .monaco-dropdown-with-primary { diff --git a/src/vs/base/browser/ui/inputbox/inputBox.css b/src/vs/base/browser/ui/inputbox/inputBox.css index 827a19f29b4..dc5e637f6ee 100644 --- a/src/vs/base/browser/ui/inputbox/inputBox.css +++ b/src/vs/base/browser/ui/inputbox/inputBox.css @@ -103,4 +103,5 @@ background-repeat: no-repeat; width: 16px; height: 16px; + color: var(--vscode-icon-foreground); } diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 6e29b67c503..5c62e99faf8 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -152,8 +152,8 @@ export class ExternalElementsDragAndDropData implements IDragAndDropData { export class NativeDragAndDropData implements IDragAndDropData { - readonly types: any[]; - readonly files: any[]; + readonly types: unknown[]; + readonly files: unknown[]; constructor() { this.types = []; diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index bd6298d0485..500b0614ade 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -322,13 +322,11 @@ export class Menu extends ActionBar { const bgColor = style.backgroundColor ?? ''; const border = style.borderColor ? `1px solid ${style.borderColor}` : ''; const borderRadius = 'var(--vscode-cornerRadius-large)'; - const shadow = style.shadowColor ? `0 2px 8px ${style.shadowColor}` : ''; scrollElement.style.outline = border; scrollElement.style.borderRadius = borderRadius; scrollElement.style.color = fgColor; scrollElement.style.backgroundColor = bgColor; - scrollElement.style.boxShadow = shadow; } override getContainer(): HTMLElement { @@ -1241,6 +1239,9 @@ ${formatRule(Codicon.menuSubmenu)} border: none; animation: fadeIn 0.083s linear; -webkit-app-region: no-drag; + box-shadow: var(--vscode-shadow-lg${style.shadowColor ? `, 0 0 12px ${style.shadowColor}` : ''}); + border-radius: var(--vscode-cornerRadius-large); + overflow: hidden; } .context-view.monaco-menu-container :focus, diff --git a/src/vs/base/browser/ui/selectBox/selectBoxCustom.css b/src/vs/base/browser/ui/selectBox/selectBoxCustom.css index 62f42240629..769ba3a08aa 100644 --- a/src/vs/base/browser/ui/selectBox/selectBoxCustom.css +++ b/src/vs/base/browser/ui/selectBox/selectBoxCustom.css @@ -7,7 +7,7 @@ display: none; box-sizing: border-box; border-radius: var(--vscode-cornerRadius-large); - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); } .monaco-select-box-dropdown-container > .select-box-details-pane > .select-box-description-markdown * { diff --git a/src/vs/base/common/actions.ts b/src/vs/base/common/actions.ts index 6d3e3f2b3db..18641db33f9 100644 --- a/src/vs/base/common/actions.ts +++ b/src/vs/base/common/actions.ts @@ -220,6 +220,24 @@ export class Separator implements IAction { return out; } + /** + * Removes leading, trailing, and consecutive duplicate separators in-place and returns the actions. + */ + public static clean(actions: IAction[]): IAction[] { + while (actions.length > 0 && actions[0].id === Separator.ID) { + actions.shift(); + } + while (actions.length > 0 && actions[actions.length - 1].id === Separator.ID) { + actions.pop(); + } + for (let i = actions.length - 2; i >= 0; i--) { + if (actions[i].id === Separator.ID && actions[i + 1].id === Separator.ID) { + actions.splice(i + 1, 1); + } + } + return actions; + } + static readonly ID = 'vs.actions.separator'; readonly id: string = Separator.ID; diff --git a/src/vs/base/common/async.ts b/src/vs/base/common/async.ts index 3dcfa0c5130..e5aaa424876 100644 --- a/src/vs/base/common/async.ts +++ b/src/vs/base/common/async.ts @@ -1098,15 +1098,15 @@ export class IntervalTimer implements IDisposable { } } -export class RunOnceScheduler implements IDisposable { +export class RunOnceScheduler any = () => any> implements IDisposable { - protected runner: ((...args: unknown[]) => void) | null; + protected runner: Runner | null; private timeoutToken: Timeout | undefined; private timeout: number; private timeoutHandler: () => void; - constructor(runner: (...args: any[]) => void, delay: number) { + constructor(runner: Runner, delay: number) { this.timeoutToken = undefined; this.runner = runner; this.timeout = delay; @@ -1246,7 +1246,7 @@ export class ProcessTimeRunOnceScheduler { } } -export class RunOnceWorker extends RunOnceScheduler { +export class RunOnceWorker extends RunOnceScheduler<(units: T[]) => void> { private units: T[] = []; diff --git a/src/vs/base/common/decorators.ts b/src/vs/base/common/decorators.ts index 7510ffcec1f..74d2e56f51e 100644 --- a/src/vs/base/common/decorators.ts +++ b/src/vs/base/common/decorators.ts @@ -45,7 +45,7 @@ export function memoize(_target: Object, key: string, descriptor: PropertyDescri } const memoizeKey = `$memoize$${key}`; - descriptor[fnKey!] = function (...args: any[]) { + descriptor[fnKey!] = function (this: any, ...args: unknown[]) { if (!this.hasOwnProperty(memoizeKey)) { Object.defineProperty(this, memoizeKey, { configurable: false, @@ -54,8 +54,7 @@ export function memoize(_target: Object, key: string, descriptor: PropertyDescri value: fn.apply(this, args) }); } - // eslint-disable-next-line local/code-no-any-casts - return (this as any)[memoizeKey]; + return this[memoizeKey]; }; } diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index 09d0ed7c530..a9d495ab6b0 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -707,7 +707,7 @@ export namespace Event { * Creates an {@link Event} from a node event emitter. */ export function fromNodeEventEmitter(emitter: NodeEventEmitter, eventName: string, map: (...args: any[]) => T = id => id): Event { - const fn = (...args: any[]) => result.fire(map(...args)); + const fn = (...args: unknown[]) => result.fire(map(...args)); const onFirstListenerAdd = () => emitter.on(eventName, fn); const onLastListenerRemove = () => emitter.removeListener(eventName, fn); const result = new Emitter({ onWillAddFirstListener: onFirstListenerAdd, onDidRemoveLastListener: onLastListenerRemove }); @@ -724,7 +724,7 @@ export namespace Event { * Creates an {@link Event} from a DOM event emitter. */ export function fromDOMEventEmitter(emitter: DOMEventEmitter, eventName: string, map: (...args: any[]) => T = id => id): Event { - const fn = (...args: any[]) => result.fire(map(...args)); + const fn = (...args: unknown[]) => result.fire(map(...args)); const onFirstListenerAdd = () => emitter.addEventListener(eventName, fn); const onLastListenerRemove = () => emitter.removeEventListener(eventName, fn); const result = new Emitter({ onWillAddFirstListener: onFirstListenerAdd, onDidRemoveLastListener: onLastListenerRemove }); diff --git a/src/vs/base/common/htmlContent.ts b/src/vs/base/common/htmlContent.ts index 070279f045a..16049d7e6f7 100644 --- a/src/vs/base/common/htmlContent.ts +++ b/src/vs/base/common/htmlContent.ts @@ -210,9 +210,9 @@ export function createMarkdownLink(text: string, href: string, title?: string, e return `[${escapeTokens ? escapeMarkdownSyntaxTokens(text) : text}](${href}${title ? ` "${escapeMarkdownSyntaxTokens(title)}"` : ''})`; } -export function createMarkdownCommandLink(command: { title: string; id: string; arguments?: unknown[]; tooltip?: string }, escapeTokens = true): string { +export function createMarkdownCommandLink(command: { text: string; id: string; arguments?: unknown[]; tooltip: string }, escapeTokens = true): string { const uri = createCommandUri(command.id, ...(command.arguments || [])).toString(); - return createMarkdownLink(command.title, uri, command.tooltip, escapeTokens); + return createMarkdownLink(command.text, uri, command.tooltip, escapeTokens); } export function createCommandUri(commandId: string, ...commandArgs: unknown[]): URI { diff --git a/src/vs/base/common/jsonRpcProtocol.ts b/src/vs/base/common/jsonRpcProtocol.ts index 67c4ed4fc4d..35d7144ba82 100644 --- a/src/vs/base/common/jsonRpcProtocol.ts +++ b/src/vs/base/common/jsonRpcProtocol.ts @@ -43,6 +43,7 @@ export interface IJsonRpcErrorResponse { } export type JsonRpcMessage = IJsonRpcRequest | IJsonRpcNotification | IJsonRpcSuccessResponse | IJsonRpcErrorResponse; +export type JsonRpcResponse = IJsonRpcSuccessResponse | IJsonRpcErrorResponse; interface IPendingRequest { promise: DeferredPromise; @@ -122,15 +123,31 @@ export class JsonRpcProtocol extends Disposable { }) as Promise; } - public async handleMessage(message: JsonRpcMessage | JsonRpcMessage[]): Promise { + /** + * Handles one or more incoming JSON-RPC messages. + * + * Returns an array of JSON-RPC response objects generated for any incoming + * requests in the message(s). Notifications and responses to our own + * outgoing requests do not produce return values. For batch inputs, the + * returned responses are in the same order as the corresponding requests. + * + * Note: responses are also emitted via the `_send` callback, so callers + * that rely on the return value should not re-send them. + */ + public async handleMessage(message: JsonRpcMessage | JsonRpcMessage[]): Promise { if (Array.isArray(message)) { + const replies: JsonRpcResponse[] = []; for (const single of message) { - await this._handleMessage(single); + const reply = await this._handleMessage(single); + if (reply) { + replies.push(reply); + } } - return; + return replies; } - await this._handleMessage(message); + const reply = await this._handleMessage(message); + return reply ? [reply] : []; } public cancelPendingRequest(id: JsonRpcId): void { @@ -152,22 +169,25 @@ export class JsonRpcProtocol extends Disposable { } } - private async _handleMessage(message: JsonRpcMessage): Promise { + private async _handleMessage(message: JsonRpcMessage): Promise { if (isJsonRpcResponse(message)) { if (hasKey(message, { result: true })) { this._handleResult(message); } else { this._handleError(message); } + return undefined; } if (isJsonRpcRequest(message)) { - await this._handleRequest(message); + return this._handleRequest(message); } if (isJsonRpcNotification(message)) { this._handlers.handleNotification?.(message); } + + return undefined; } private _handleResult(response: IJsonRpcSuccessResponse): void { @@ -192,17 +212,18 @@ export class JsonRpcProtocol extends Disposable { } } - private async _handleRequest(request: IJsonRpcRequest): Promise { + private async _handleRequest(request: IJsonRpcRequest): Promise { if (!this._handlers.handleRequest) { - this._send({ + const response: IJsonRpcErrorResponse = { jsonrpc: '2.0', id: request.id, error: { code: JsonRpcProtocol.MethodNotFound, message: `Method not found: ${request.method}`, } - }); - return; + }; + this._send(response); + return response; } const cts = new CancellationTokenSource(); @@ -211,14 +232,17 @@ export class JsonRpcProtocol extends Disposable { try { const resultOrThenable = this._handlers.handleRequest(request, cts.token); const result = isThenable(resultOrThenable) ? await resultOrThenable : resultOrThenable; - this._send({ + const response: IJsonRpcSuccessResponse = { jsonrpc: '2.0', id: request.id, result, - }); + }; + this._send(response); + return response; } catch (error) { + let response: IJsonRpcErrorResponse; if (error instanceof JsonRpcError) { - this._send({ + response = { jsonrpc: '2.0', id: request.id, error: { @@ -226,17 +250,19 @@ export class JsonRpcProtocol extends Disposable { message: error.message, data: error.data, } - }); + }; } else { - this._send({ + response = { jsonrpc: '2.0', id: request.id, error: { code: JsonRpcProtocol.InternalError, message: error instanceof Error ? error.message : 'Internal error', } - }); + }; } + this._send(response); + return response; } finally { cts.dispose(true); } diff --git a/src/vs/base/common/lifecycle.ts b/src/vs/base/common/lifecycle.ts index 630edb097f2..a75cbb1cce3 100644 --- a/src/vs/base/common/lifecycle.ts +++ b/src/vs/base/common/lifecycle.ts @@ -720,7 +720,7 @@ export class AsyncReferenceCollection { constructor(private referenceCollection: ReferenceCollection>) { } - async acquire(key: string, ...args: any[]): Promise> { + async acquire(key: string, ...args: unknown[]): Promise> { const ref = this.referenceCollection.acquire(key, ...args); try { diff --git a/src/vs/base/common/observableInternal/index.ts b/src/vs/base/common/observableInternal/index.ts index af010d118c3..95347c3088b 100644 --- a/src/vs/base/common/observableInternal/index.ts +++ b/src/vs/base/common/observableInternal/index.ts @@ -7,7 +7,7 @@ export { observableValueOpts } from './observables/observableValueOpts.js'; export { autorun, autorunDelta, autorunHandleChanges, autorunOpts, autorunWithStore, autorunWithStoreHandleChanges, autorunIterableDelta, autorunSelfDisposable } from './reactions/autorun.js'; -export { type IObservable, type IObservableWithChange, type IObserver, type IReader, type ISettable, type ISettableObservable, type ITransaction } from './base.js'; +export { type IObservable, type IObservableWithChange, type IObserver, type IReader, type ISettable, type IReaderWithStore, type ISettableObservable, type ITransaction } from './base.js'; export { disposableObservableValue } from './observables/observableValue.js'; export { derived, derivedDisposable, derivedHandleChanges, derivedOpts, derivedWithSetter, derivedWithStore } from './observables/derived.js'; export { type IDerivedReader } from './observables/derivedImpl.js'; diff --git a/src/vs/base/common/observableInternal/logging/debugger/rpc.ts b/src/vs/base/common/observableInternal/logging/debugger/rpc.ts index d19da1fe159..c4d392bca69 100644 --- a/src/vs/base/common/observableInternal/logging/debugger/rpc.ts +++ b/src/vs/base/common/observableInternal/logging/debugger/rpc.ts @@ -72,7 +72,7 @@ export class SimpleTypedRpcConnection { const requests = new Proxy({}, { get: (target, key: string) => { - return async (...args: any[]) => { + return async (...args: unknown[]) => { const result = await this._channel.sendRequest([key, args] satisfies OutgoingMessage); if (result.type === 'error') { throw result.value; @@ -85,7 +85,7 @@ export class SimpleTypedRpcConnection { const notifications = new Proxy({}, { get: (target, key: string) => { - return (...args: any[]) => { + return (...args: unknown[]) => { this._channel.sendNotification([key, args] satisfies OutgoingMessage); }; } diff --git a/src/vs/base/parts/ipc/common/ipc.ts b/src/vs/base/parts/ipc/common/ipc.ts index 9d62cc8d5fa..663b7f541ee 100644 --- a/src/vs/base/parts/ipc/common/ipc.ts +++ b/src/vs/base/parts/ipc/common/ipc.ts @@ -1209,10 +1209,10 @@ export namespace ProxyChannel { } // Function - return async function (...args: any[]) { + return async function (...args: unknown[]) { // Add context if any - let methodArgs: any[]; + let methodArgs: unknown[]; if (options && !isUndefinedOrNull(options.context)) { methodArgs = [options.context, ...args]; } else { diff --git a/src/vs/base/parts/ipc/electron-main/ipcMain.ts b/src/vs/base/parts/ipc/electron-main/ipcMain.ts index 0137b8924eb..267c15b7125 100644 --- a/src/vs/base/parts/ipc/electron-main/ipcMain.ts +++ b/src/vs/base/parts/ipc/electron-main/ipcMain.ts @@ -25,7 +25,7 @@ class ValidatedIpcMain implements Event.NodeEventEmitter { // Remember the wrapped listener so that later we can // properly implement `removeListener`. - const wrappedListener = (event: electron.IpcMainEvent, ...args: any[]) => { + const wrappedListener = (event: electron.IpcMainEvent, ...args: unknown[]) => { if (this.validateEvent(channel, event)) { listener(event, ...args); } @@ -43,7 +43,7 @@ class ValidatedIpcMain implements Event.NodeEventEmitter { * only the next time a message is sent to `channel`, after which it is removed. */ once(channel: string, listener: ipcMainListener): this { - electron.ipcMain.once(channel, (event: electron.IpcMainEvent, ...args: any[]) => { + electron.ipcMain.once(channel, (event: electron.IpcMainEvent, ...args: unknown[]) => { if (this.validateEvent(channel, event)) { listener(event, ...args); } @@ -69,7 +69,7 @@ class ValidatedIpcMain implements Event.NodeEventEmitter { * provided to the renderer process. Please refer to #24427 for details. */ handle(channel: string, listener: (event: electron.IpcMainInvokeEvent, ...args: any[]) => Promise): this { - electron.ipcMain.handle(channel, (event: electron.IpcMainInvokeEvent, ...args: any[]) => { + electron.ipcMain.handle(channel, (event: electron.IpcMainInvokeEvent, ...args: unknown[]) => { if (this.validateEvent(channel, event)) { return listener(event, ...args); } diff --git a/src/vs/base/test/browser/markdownRenderer.test.ts b/src/vs/base/test/browser/markdownRenderer.test.ts index cdfbe914fa9..16373be2101 100644 --- a/src/vs/base/test/browser/markdownRenderer.test.ts +++ b/src/vs/base/test/browser/markdownRenderer.test.ts @@ -307,6 +307,36 @@ suite('MarkdownRenderer', () => { assert.strictEqual(result.innerHTML, `

text bar

`); }); + test('Should use decoded file path as title for file:// links', () => { + const fileUri = URI.file('/home/user/project/lib.d.ts'); + const md = new MarkdownString(`[log](${fileUri.toString()})`, {}); + + const result = store.add(renderMarkdown(md)).element; + const anchor = result.querySelector('a')!; + assert.ok(anchor); + assert.strictEqual(anchor.title, fileUri.fsPath); + }); + + test('Should include fragment in title for file:// links with line numbers', () => { + const fileUri = URI.file('/home/user/project/lib.d.ts'); + const md = new MarkdownString(`[log](${fileUri.toString()}#L42)`, {}); + + const result = store.add(renderMarkdown(md)).element; + const anchor = result.querySelector('a')!; + assert.ok(anchor); + assert.strictEqual(anchor.title, `${fileUri.fsPath}#L42`); + }); + + test('Should not override explicit title for file:// links', () => { + const fileUri = URI.file('/home/user/project/lib.d.ts'); + const md = new MarkdownString(`[log](${fileUri.toString()} "Go to definition")`, {}); + + const result = store.add(renderMarkdown(md)).element; + const anchor = result.querySelector('a')!; + assert.ok(anchor); + assert.strictEqual(anchor.title, 'Go to definition'); + }); + suite('PlaintextMarkdownRender', () => { test('test code, blockquote, heading, list, listitem, paragraph, table, tablerow, tablecell, strong, em, br, del, text are rendered plaintext', () => { diff --git a/src/vs/base/test/common/jsonRpcProtocol.test.ts b/src/vs/base/test/common/jsonRpcProtocol.test.ts index 4a167d2cc8a..9a000e35f48 100644 --- a/src/vs/base/test/common/jsonRpcProtocol.test.ts +++ b/src/vs/base/test/common/jsonRpcProtocol.test.ts @@ -39,7 +39,7 @@ suite('JsonRpcProtocol', () => { const requestPromise = protocol.sendRequest({ method: 'echo', params: { value: 'ok' } }); const outgoingRequest = sentMessages[0] as IJsonRpcRequest; - await protocol.handleMessage({ + const replies = await protocol.handleMessage({ jsonrpc: '2.0', id: outgoingRequest.id, result: 'done' @@ -47,6 +47,7 @@ suite('JsonRpcProtocol', () => { const result = await requestPromise; assert.strictEqual(result, 'done'); + assert.deepStrictEqual(replies, []); }); test('sendRequest rejects on error response', async () => { @@ -107,20 +108,22 @@ suite('JsonRpcProtocol', () => { test('handleRequest responds with method not found without handler', async () => { const { protocol, sentMessages } = createProtocol(); - await protocol.handleMessage({ + const replies = await protocol.handleMessage({ jsonrpc: '2.0', id: 7, method: 'unknown' }); - assert.deepStrictEqual(sentMessages, [{ + const expected = [{ jsonrpc: '2.0', id: 7, error: { code: -32601, message: 'Method not found: unknown' } - }]); + }]; + assert.deepStrictEqual(sentMessages, expected); + assert.deepStrictEqual(replies, expected); }); test('handleRequest responds with result and passes cancellation token', async () => { @@ -134,7 +137,7 @@ suite('JsonRpcProtocol', () => { } }); - await protocol.handleMessage({ + const replies = await protocol.handleMessage({ jsonrpc: '2.0', id: 9, method: 'compute' @@ -142,27 +145,29 @@ suite('JsonRpcProtocol', () => { assert.ok(receivedToken); assert.strictEqual(wasCanceledDuringHandler, false); - assert.deepStrictEqual(sentMessages, [{ + const expected = [{ jsonrpc: '2.0', id: 9, result: 'compute:ok' - }]); + }]; + assert.deepStrictEqual(sentMessages, expected); + assert.deepStrictEqual(replies, expected); }); - test('handleRequest serializes JsonRpcError', async () => { + test('handleRequest serializes JsonRpcError and returns it', async () => { const { protocol, sentMessages } = createProtocol({ handleRequest: () => { throw new JsonRpcError(88, 'bad request', { detail: true }); } }); - await protocol.handleMessage({ + const replies = await protocol.handleMessage({ jsonrpc: '2.0', id: 'a', method: 'boom' }); - assert.deepStrictEqual(sentMessages, [{ + const expected = [{ jsonrpc: '2.0', id: 'a', error: { @@ -170,30 +175,34 @@ suite('JsonRpcProtocol', () => { message: 'bad request', data: { detail: true } } - }]); + }]; + assert.deepStrictEqual(sentMessages, expected); + assert.deepStrictEqual(replies, expected); }); - test('handleRequest maps unknown errors to internal error', async () => { + test('handleRequest maps unknown errors to internal error and returns it', async () => { const { protocol, sentMessages } = createProtocol({ handleRequest: () => { throw new Error('unexpected'); } }); - await protocol.handleMessage({ + const replies = await protocol.handleMessage({ jsonrpc: '2.0', id: 'b', method: 'explode' }); - assert.deepStrictEqual(sentMessages, [{ + const expected = [{ jsonrpc: '2.0', id: 'b', error: { code: -32603, message: 'unexpected' } - }]); + }]; + assert.deepStrictEqual(sentMessages, expected); + assert.deepStrictEqual(replies, expected); }); test('handleMessage processes batch sequentially', async () => { @@ -225,8 +234,9 @@ suite('JsonRpcProtocol', () => { assert.deepStrictEqual(sequence, ['request:start']); gate.complete(); - await handlingPromise; + const replies = await handlingPromise; assert.deepStrictEqual(sequence, ['request:start', 'request:end', 'notification']); + assert.deepStrictEqual(replies, [{ jsonrpc: '2.0', id: 1, result: true }]); }); }); diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 78641318c5f..33ec9f60f2d 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -41,7 +41,6 @@ import { ipcBrowserViewChannelName } from '../../platform/browserView/common/bro import { ipcBrowserViewGroupChannelName } from '../../platform/browserView/common/browserViewGroup.js'; import { BrowserViewMainService, IBrowserViewMainService } from '../../platform/browserView/electron-main/browserViewMainService.js'; import { BrowserViewGroupMainService, IBrowserViewGroupMainService } from '../../platform/browserView/electron-main/browserViewGroupMainService.js'; -import { BrowserViewCDPProxyServer, IBrowserViewCDPProxyServer } from '../../platform/browserView/electron-main/browserViewCDPProxyServer.js'; import { NativeParsedArgs } from '../../platform/environment/common/argv.js'; import { IEnvironmentMainService } from '../../platform/environment/electron-main/environmentMainService.js'; import { isLaunchedFromCli } from '../../platform/environment/node/argvHelper.js'; @@ -1063,7 +1062,6 @@ export class CodeApplication extends Disposable { services.set(INativeBrowserElementsMainService, new SyncDescriptor(NativeBrowserElementsMainService, undefined, false /* proxied to other processes */)); // Browser View - services.set(IBrowserViewCDPProxyServer, new SyncDescriptor(BrowserViewCDPProxyServer, undefined, true)); services.set(IBrowserViewMainService, new SyncDescriptor(BrowserViewMainService, undefined, false /* proxied to other processes */)); services.set(IBrowserViewGroupMainService, new SyncDescriptor(BrowserViewGroupMainService, undefined, false /* proxied to other processes */)); @@ -1288,7 +1286,7 @@ export class CodeApplication extends Disposable { // MCP const mcpDiscoveryChannel = ProxyChannel.fromService(accessor.get(INativeMcpDiscoveryHelperService), disposables); mainProcessElectronServer.registerChannel(NativeMcpDiscoveryHelperChannelName, mcpDiscoveryChannel); - const mcpGatewayChannel = this._register(new McpGatewayChannel(mainProcessElectronServer, accessor.get(IMcpGatewayService))); + const mcpGatewayChannel = this._register(new McpGatewayChannel(mainProcessElectronServer, accessor.get(IMcpGatewayService), accessor.get(ILoggerMainService))); mainProcessElectronServer.registerChannel(McpGatewayChannelName, mcpGatewayChannel); // Logger diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index e112b958d73..7f5cd7c26e9 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -134,9 +134,7 @@ import { IMcpGalleryManifestService } from '../../../platform/mcp/common/mcpGall import { McpGalleryManifestIPCService } from '../../../platform/mcp/common/mcpGalleryManifestServiceIpc.js'; import { IMeteredConnectionService } from '../../../platform/meteredConnection/common/meteredConnection.js'; import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js'; -import { IPlaywrightService } from '../../../platform/browserView/common/playwrightService.js'; -import { PlaywrightService } from '../../../platform/browserView/node/playwrightService.js'; -import { IBrowserViewGroupRemoteService, BrowserViewGroupRemoteService } from '../../../platform/browserView/node/browserViewGroupRemoteService.js'; +import { PlaywrightChannel } from '../../../platform/browserView/node/playwrightChannel.js'; class SharedProcessMain extends Disposable implements IClientConnectionFilter { @@ -404,10 +402,6 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // Web Content Extractor services.set(ISharedWebContentExtractorService, new SyncDescriptor(SharedWebContentExtractorService)); - // Playwright - services.set(IBrowserViewGroupRemoteService, new SyncDescriptor(BrowserViewGroupRemoteService)); - services.set(IPlaywrightService, new SyncDescriptor(PlaywrightService)); - return new InstantiationService(services); } @@ -476,7 +470,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { this.server.registerChannel('sharedWebContentExtractor', webContentExtractorChannel); // Playwright - const playwrightChannel = ProxyChannel.fromService(accessor.get(IPlaywrightService), this._store); + const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService))); this.server.registerChannel('playwright', playwrightChannel); } diff --git a/src/vs/editor/browser/viewParts/minimap/minimap.css b/src/vs/editor/browser/viewParts/minimap/minimap.css index 35bb6e3b717..e6ced7d3dc8 100644 --- a/src/vs/editor/browser/viewParts/minimap/minimap.css +++ b/src/vs/editor/browser/viewParts/minimap/minimap.css @@ -25,7 +25,7 @@ background: var(--vscode-minimapSlider-activeBackground); } .monaco-editor .minimap-shadow-visible { - box-shadow: var(--vscode-scrollbar-shadow) -6px 0 6px -6px inset; + box-shadow: var(--vscode-shadow-md); } .monaco-editor .minimap-shadow-hidden { position: absolute; @@ -61,3 +61,7 @@ .monaco-editor .minimap { z-index: 5; } + +.monaco-editor .minimap canvas { + opacity: 0.9; +} diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index bf796436167..313793845dd 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -1744,6 +1744,10 @@ export interface IEditorFindOptions { * Controls whether the search result and diff result automatically restarts from the beginning (or the end) when no further matches can be found */ loop?: boolean; + /** + * Controls whether to close the Find Widget after an explicit find navigation command lands on a match. + */ + closeOnResult?: boolean; /** * @internal * Controls how the find widget search history should be stored @@ -1772,6 +1776,7 @@ class EditorFind extends BaseEditorOption(input.history, this.defaultValue.history, ['never', 'workspace']), replaceHistory: stringSet<'never' | 'workspace'>(input.replaceHistory, this.defaultValue.replaceHistory, ['never', 'workspace']), }; @@ -3761,7 +3772,7 @@ class EditorQuickSuggestions extends BaseEditorOption(); for (const change of changes) { switch (change.kind) { case PendingChangeKind.Remove: - this._doRemoveCustomLineHeight(change.decorationId, stagedInserts); + this._doRemoveCustomLineHeight(change.decorationId, stagedIdMap); break; case PendingChangeKind.InsertOrChange: - this._doInsertOrChangeCustomLineHeight(change.decorationId, change.startLineNumber, change.endLineNumber, change.lineHeight, stagedInserts); + this._doInsertOrChangeCustomLineHeight(change.decorationId, change.startLineNumber, change.endLineNumber, change.lineHeight, stagedInserts, stagedIdMap); break; case PendingChangeKind.LinesDeleted: - this._flushStagedDecorationChanges(stagedInserts); + this._flushStagedDecorationChanges(stagedInserts, stagedIdMap); this._doLinesDeleted(change.fromLineNumber, change.toLineNumber); break; case PendingChangeKind.LinesInserted: - this._flushStagedDecorationChanges(stagedInserts); - this._doLinesInserted(change.fromLineNumber, change.toLineNumber, stagedInserts); + this._flushStagedDecorationChanges(stagedInserts, stagedIdMap); + this._doLinesInserted(change.fromLineNumber, change.toLineNumber, stagedInserts, stagedIdMap); break; } } - this._flushStagedDecorationChanges(stagedInserts); + this._flushStagedDecorationChanges(stagedInserts, stagedIdMap); } - private _doRemoveCustomLineHeight(decorationID: string, stagedInserts: CustomLine[]): void { + private _doRemoveCustomLineHeight(decorationID: string, stagedIdMap: ArrayMap): void { const customLines = this._decorationIDToCustomLine.get(decorationID); if (customLines) { this._decorationIDToCustomLine.delete(decorationID); @@ -176,32 +177,42 @@ export class LineHeightsManager { this._invalidIndex = Math.min(this._invalidIndex, customLine.index); } } - for (let i = stagedInserts.length - 1; i >= 0; i--) { - if (stagedInserts[i].decorationId === decorationID) { - stagedInserts.splice(i, 1); + const stagedLines = stagedIdMap.get(decorationID); + if (stagedLines) { + stagedIdMap.delete(decorationID); + for (const line of stagedLines) { + line.deleted = true; } } } - private _doInsertOrChangeCustomLineHeight(decorationId: string, startLineNumber: number, endLineNumber: number, lineHeight: number, stagedInserts: CustomLine[]): void { - this._doRemoveCustomLineHeight(decorationId, stagedInserts); + private _doInsertOrChangeCustomLineHeight(decorationId: string, startLineNumber: number, endLineNumber: number, lineHeight: number, stagedInserts: CustomLine[], stagedIdMap: ArrayMap): void { + this._doRemoveCustomLineHeight(decorationId, stagedIdMap); for (let lineNumber = startLineNumber; lineNumber <= endLineNumber; lineNumber++) { const customLine = new CustomLine(decorationId, -1, lineNumber, lineHeight, 0); stagedInserts.push(customLine); + stagedIdMap.add(decorationId, customLine); } } - private _flushStagedDecorationChanges(stagedInserts: CustomLine[]): void { + private _flushStagedDecorationChanges(stagedInserts: CustomLine[], stagedIdMap: ArrayMap): void { if (stagedInserts.length === 0 && this._invalidIndex === Infinity) { return; } for (const pendingChange of stagedInserts) { + if (pendingChange.deleted) { + continue; + } const candidateInsertionIndex = this._binarySearchOverOrderedCustomLinesArray(pendingChange.lineNumber); const insertionIndex = candidateInsertionIndex >= 0 ? candidateInsertionIndex : -(candidateInsertionIndex + 1); this._orderedCustomLines.splice(insertionIndex, 0, pendingChange); this._invalidIndex = Math.min(this._invalidIndex, insertionIndex); } stagedInserts.length = 0; + stagedIdMap.clear(); + if (this._invalidIndex === Infinity) { + return; + } const newDecorationIDToSpecialLine = new ArrayMap(); const newOrderedSpecialLines: CustomLine[] = []; @@ -358,7 +369,7 @@ export class LineHeightsManager { } } - private _doLinesInserted(fromLineNumber: number, toLineNumber: number, stagedInserts: CustomLine[]): void { + private _doLinesInserted(fromLineNumber: number, toLineNumber: number, stagedInserts: CustomLine[], stagedIdMap: ArrayMap): void { const insertCount = toLineNumber - fromLineNumber + 1; const candidateStartIndexOfInsertion = this._binarySearchOverOrderedCustomLinesArray(fromLineNumber); let startIndexOfInsertion: number; @@ -411,7 +422,7 @@ export class LineHeightsManager { } for (const dec of toReAdd) { - this._doInsertOrChangeCustomLineHeight(dec.decorationId, dec.startLineNumber, dec.endLineNumber, dec.lineHeight, stagedInserts); + this._doInsertOrChangeCustomLineHeight(dec.decorationId, dec.startLineNumber, dec.endLineNumber, dec.lineHeight, stagedInserts, stagedIdMap); } } } @@ -475,4 +486,8 @@ class ArrayMap { delete(key: K): void { this._map.delete(key); } + + clear(): void { + this._map.clear(); + } } diff --git a/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts b/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts index 45473f77c4d..72ea2bcd16b 100644 --- a/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts +++ b/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts @@ -38,7 +38,10 @@ export class MinimapTokensColorTracker extends Disposable { private _updateColorMap(): void { const colorMap = TokenizationRegistry.getColorMap(); if (!colorMap) { - this._colors = [RGBA8.Empty]; + this._colors = []; + for (let i = 0; i <= ColorId.DefaultBackground; i++) { + this._colors[i] = RGBA8.Empty; + } this._backgroundIsLight = true; return; } diff --git a/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.css b/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.css index 496b989268e..cce094ae6dc 100644 --- a/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.css +++ b/src/vs/editor/contrib/dropOrPasteInto/browser/postEditWidget.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .post-edit-widget { - box-shadow: 0 0 8px 2px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); border: 1px solid var(--vscode-widget-border, transparent); border-radius: 4px; color: var(--vscode-button-foreground); diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index ec2ee490e19..3260daed640 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -725,11 +725,28 @@ async function matchFindAction(editor: ICodeEditor, next: boolean): Promise { + const previousSelection = controller.editor.getSelection(); const result = next ? controller.moveToNextMatch() : controller.moveToPrevMatch(); + + let landedOnMatch = false; if (result) { + const currentSelection = controller.editor.getSelection(); + if (!previousSelection && currentSelection) { + landedOnMatch = true; + } else if (previousSelection && currentSelection && !previousSelection.equalsSelection(currentSelection)) { + landedOnMatch = true; + } + } + + if (landedOnMatch) { controller.editor.pushUndoStop(); + if (shouldCloseOnResult && wasFindWidgetVisible && controller.isFindInputFocused()) { + controller.closeFindWidget(); + } return true; } return false; diff --git a/src/vs/editor/contrib/find/browser/findOptionsWidget.css b/src/vs/editor/contrib/find/browser/findOptionsWidget.css index 2188fa9fa9d..ac9f95c4e34 100644 --- a/src/vs/editor/contrib/find/browser/findOptionsWidget.css +++ b/src/vs/editor/contrib/find/browser/findOptionsWidget.css @@ -6,6 +6,6 @@ .monaco-editor .findOptionsWidget { background-color: var(--vscode-editorWidget-background); color: var(--vscode-editorWidget-foreground); - box-shadow: 0 0 8px 2px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); border: 2px solid var(--vscode-contrastBorder); } diff --git a/src/vs/editor/contrib/find/browser/findWidget.css b/src/vs/editor/contrib/find/browser/findWidget.css index cbe6028775c..62c6056c1d9 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.css +++ b/src/vs/editor/contrib/find/browser/findWidget.css @@ -7,7 +7,7 @@ .monaco-editor .find-widget { position: absolute; z-index: 35; - height: 33px; + height: 34px; overflow: hidden; line-height: 19px; transition: transform 200ms linear; @@ -15,7 +15,7 @@ margin-top: 4px; box-sizing: border-box; transform: translateY(calc(-100% - 10px)); /* shadow (10px) */ - box-shadow: 0 0 8px 2px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); color: var(--vscode-editorWidget-foreground); border: 1px solid var(--vscode-widget-border); border-radius: var(--vscode-cornerRadius-large); diff --git a/src/vs/editor/contrib/find/test/browser/findController.test.ts b/src/vs/editor/contrib/find/test/browser/findController.test.ts index ed0383148f9..1823dd31e1a 100644 --- a/src/vs/editor/contrib/find/test/browser/findController.test.ts +++ b/src/vs/editor/contrib/find/test/browser/findController.test.ts @@ -292,6 +292,67 @@ suite('FindController', () => { }); }); + test('editor.find.closeOnResult: closes find widget when a match is found from explicit navigation', async () => { + await withAsyncTestCodeEditor([ + 'ABC', + 'ABC', + 'XYZ', + ], { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + findState.change({ searchString: 'ABC' }, true); + await editor.runAction(NextMatchFindAction); + + assert.strictEqual(findState.isRevealed, false); + findController.dispose(); + }); + }); + + test('editor.find.closeOnResult: keeps find widget open when no match is found', async () => { + await withAsyncTestCodeEditor([ + 'ABC', + 'DEF', + 'XYZ', + ], { serviceCollection: serviceCollection, find: { closeOnResult: true } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + findState.change({ searchString: 'NO_MATCH' }, true); + await editor.runAction(NextMatchFindAction); + + assert.strictEqual(findState.matchesCount, 0); + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); + + test('editor.find.closeOnResult: disabled keeps find widget open after navigation', async () => { + await withAsyncTestCodeEditor([ + 'ABC', + 'ABC', + 'XYZ', + ], { serviceCollection: serviceCollection, find: { closeOnResult: false } }, async (editor, _, instantiationService) => { + const findController = editor.registerAndInstantiateContribution(TestFindController.ID, TestFindController); + const findState = findController.getState(); + + await executeAction(instantiationService, editor, StartFindAction); + assert.strictEqual(findState.isRevealed, true); + + findState.change({ searchString: 'ABC' }, true); + await editor.runAction(NextMatchFindAction); + + assert.strictEqual(findState.isRevealed, true); + findController.dispose(); + }); + }); + test('issue #9043: Clear search scope when find widget is hidden', async () => { await withAsyncTestCodeEditor([ 'var x = (3 * 5)', diff --git a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css index 422e073e5e7..2182ed732e6 100644 --- a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css +++ b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.css @@ -7,16 +7,39 @@ padding: 2px 4px; color: var(--vscode-foreground); background-color: var(--vscode-editorWidget-background); - border-radius: 6px; + border-radius: var(--vscode-cornerRadius-medium); border: 1px solid var(--vscode-contrastBorder); display: flex; align-items: center; justify-content: center; gap: 4px; z-index: 10; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); overflow: hidden; + &.single-button { + background-color: transparent; + border-width: 0; + padding: 0; + overflow: visible; + + .action-item > .action-label, + .action-item > .action-label.codicon:not(.separator) { + height: 28px; + line-height: 28px; + border-radius: var(--vscode-cornerRadius-medium); + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + } + + .action-item > .action-label { + padding: 0 8px; + } + + .action-item > .action-label.codicon:not(.separator) { + width: 28px; + } + } + .actions-container { gap: 4px; } @@ -25,7 +48,7 @@ padding: 4px 6px; font-size: 11px; line-height: 14px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); } .action-item > .action-label.codicon:not(.separator) { @@ -50,3 +73,16 @@ background-color: var(--vscode-button-hoverBackground) !important; } } + +.hc-black .floating-menu-overlay-widget.single-button, +.hc-light .floating-menu-overlay-widget.single-button { + border-width: 1px; + border-style: solid; + border-color: var(--vscode-contrastBorder); + background-color: var(--vscode-editorWidget-background); + padding: 0; + .action-item > .action-label, + .action-item > .action-label.codicon:not(.separator) { + box-shadow: none; + } +} diff --git a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts index 1a530186e66..5e7be22f374 100644 --- a/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts +++ b/src/vs/editor/contrib/floatingMenu/browser/floatingMenu.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Separator } from '../../../../base/common/actions.js'; import { h } from '../../../../base/browser/dom.js'; import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { getActionBarActions, MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { autorun, constObservable, derived, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; +import { getActionBarActions, MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -75,25 +76,27 @@ export class FloatingEditorToolbarWidget extends Disposable { const menu = this._register(menuService.createMenu(_menuId, _scopedContextKeyService)); const menuGroupsObs = observableFromEvent(this, menu.onDidChange, () => menu.getActions()); - const menuPrimaryActionIdObs = derived(reader => { + const menuPrimaryActionsObs = derived(reader => { const menuGroups = menuGroupsObs.read(reader); - const { primary } = getActionBarActions(menuGroups, () => true); - return primary.length > 0 ? primary[0].id : undefined; + return primary.filter(a => a.id !== Separator.ID); }); - this.hasActions = derived(reader => menuGroupsObs.read(reader).length > 0); + this.hasActions = derived(reader => menuPrimaryActionsObs.read(reader).length > 0); this.element = h('div.floating-menu-overlay-widget').root; this._register(toDisposable(() => this.element.remove())); - // Set height explicitly to ensure that the floating menu element - // is rendered in the lower right corner at the correct position. - this.element.style.height = '26px'; - this._register(autorun(reader => { - const hasActions = this.hasActions.read(reader); - const menuPrimaryActionId = menuPrimaryActionIdObs.read(reader); + const primaryActions = menuPrimaryActionsObs.read(reader); + const hasActions = primaryActions.length > 0; + const menuPrimaryActionId = hasActions ? primaryActions[0].id : undefined; + + const isSingleButton = primaryActions.length === 1; + this.element.classList.toggle('single-button', isSingleButton); + // Set height explicitly to ensure that the floating menu element + // is rendered in the lower right corner at the correct position. + this.element.style.height = isSingleButton ? '28px' : '26px'; if (!hasActions) { return; diff --git a/src/vs/editor/contrib/hover/browser/hover.css b/src/vs/editor/contrib/hover/browser/hover.css index b33ea5e76bc..269ee853c7e 100644 --- a/src/vs/editor/contrib/hover/browser/hover.css +++ b/src/vs/editor/contrib/hover/browser/hover.css @@ -11,11 +11,13 @@ border: 1px solid var(--vscode-editorHoverWidget-border); border-radius: var(--vscode-cornerRadius-large); box-sizing: content-box; + background-color: var(--vscode-editorHoverWidget-background); } .monaco-editor .monaco-resizable-hover > .monaco-hover { border: none; - border-radius: unset; + border-radius: inherit; + overflow: hidden; } .monaco-editor .monaco-hover { @@ -23,6 +25,7 @@ border-radius: var(--vscode-cornerRadius-large); color: var(--vscode-editorHoverWidget-foreground); background-color: var(--vscode-editorHoverWidget-background); + box-shadow: var(--vscode-shadow-hover); } .monaco-editor .monaco-hover a { @@ -34,6 +37,7 @@ } .monaco-editor .monaco-hover .hover-row { + border-radius: var(--vscode-cornerRadius-large); display: flex; } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts index dcd8adce63d..54986566312 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/suggestWidgetAdapter.ts @@ -20,6 +20,7 @@ import { CompletionItem } from '../../../suggest/browser/suggest.js'; import { SuggestController } from '../../../suggest/browser/suggestController.js'; import { ObservableCodeEditor } from '../../../../browser/observableCodeEditor.js'; import { observableFromEvent } from '../../../../../base/common/observable.js'; +import { EditorOption } from '../../../../common/config/editorOptions.js'; export class SuggestWidgetAdaptor extends Disposable { private isSuggestWidgetVisible: boolean = false; @@ -149,6 +150,17 @@ export class SuggestWidgetAdaptor extends Disposable { return undefined; } + // When offWhenInlineCompletions is active, don't expose the selected + // suggest item to the inline completions model so that it does not + // trigger an inline completion request while the suggest widget is open + const quickSuggestions = this.editor.getOption(EditorOption.quickSuggestions); + if (typeof quickSuggestions === 'object' + && (quickSuggestions.other === 'offWhenInlineCompletions' + || quickSuggestions.comments === 'offWhenInlineCompletions' + || quickSuggestions.strings === 'offWhenInlineCompletions')) { + return undefined; + } + const focusedItem = suggestController.widget.value.getFocusedItem(); const position = this.editor.getPosition(); const model = this.editor.getModel(); diff --git a/src/vs/editor/contrib/parameterHints/browser/parameterHints.css b/src/vs/editor/contrib/parameterHints/browser/parameterHints.css index bf54d22d60e..d613456e162 100644 --- a/src/vs/editor/contrib/parameterHints/browser/parameterHints.css +++ b/src/vs/editor/contrib/parameterHints/browser/parameterHints.css @@ -14,6 +14,7 @@ background-color: var(--vscode-editorHoverWidget-background); border: 1px solid var(--vscode-editorHoverWidget-border); border-radius: var(--vscode-cornerRadius-large); + box-shadow: var(--vscode-shadow-lg); } .hc-black .monaco-editor .parameter-hints-widget, diff --git a/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css b/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css index f49973bfea3..eb777d9ac7f 100644 --- a/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css +++ b/src/vs/editor/contrib/peekView/browser/media/peekViewWidget.css @@ -3,6 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.monaco-editor .peekview-widget { + box-shadow: var(--vscode-shadow-hover); +} + .monaco-editor .peekview-widget .head { box-sizing: border-box; display: flex; diff --git a/src/vs/editor/contrib/rename/browser/renameWidget.css b/src/vs/editor/contrib/rename/browser/renameWidget.css index acd375f2afb..730bf8895b8 100644 --- a/src/vs/editor/contrib/rename/browser/renameWidget.css +++ b/src/vs/editor/contrib/rename/browser/renameWidget.css @@ -7,6 +7,7 @@ z-index: 100; color: inherit; border-radius: 4px; + box-shadow: var(--vscode-shadow-hover); } .monaco-editor .rename-box.preview { diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScroll.css b/src/vs/editor/contrib/stickyScroll/browser/stickyScroll.css index ecc59245dec..1cd84683e70 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScroll.css +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScroll.css @@ -7,7 +7,7 @@ overflow: hidden; border-bottom: 1px solid var(--vscode-editorStickyScroll-border); width: 100%; - box-shadow: var(--vscode-editorStickyScroll-shadow) 0 4px 2px -2px; + box-shadow: var(--vscode-shadow-md); z-index: 4; right: initial !important; margin-left: '0px'; diff --git a/src/vs/editor/contrib/suggest/browser/media/suggest.css b/src/vs/editor/contrib/suggest/browser/media/suggest.css index 2d9fd8b1f7c..70f27a8fa86 100644 --- a/src/vs/editor/contrib/suggest/browser/media/suggest.css +++ b/src/vs/editor/contrib/suggest/browser/media/suggest.css @@ -11,6 +11,7 @@ display: flex; flex-direction: column; border-radius: var(--vscode-cornerRadius-large); + box-shadow: var(--vscode-shadow-lg); } .monaco-editor .suggest-widget.message { diff --git a/src/vs/editor/contrib/suggest/browser/suggestModel.ts b/src/vs/editor/contrib/suggest/browser/suggestModel.ts index 30c1276d5c4..595b7ef51c9 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestModel.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestModel.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { TimeoutTimer } from '../../../../base/common/async.js'; +import { TimeoutTimer, disposableTimeout } from '../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -30,8 +30,10 @@ import { ILanguageFeaturesService } from '../../../common/services/languageFeatu import { FuzzyScoreOptions } from '../../../../base/common/filters.js'; import { assertType } from '../../../../base/common/types.js'; import { InlineCompletionContextKeys } from '../../inlineCompletions/browser/controller/inlineCompletionContextKeys.js'; +import { getInlineCompletionsController } from '../../inlineCompletions/browser/controller/common.js'; import { SnippetController2 } from '../../snippet/browser/snippetController2.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { autorun } from '../../../../base/common/observable.js'; export interface ICancelEvent { readonly retrigger: boolean; @@ -134,6 +136,7 @@ export class SuggestModel implements IDisposable { private readonly _toDispose = new DisposableStore(); private readonly _triggerCharacterListener = new DisposableStore(); private readonly _triggerQuickSuggest = new TimeoutTimer(); + private _waitForInlineCompletions: DisposableStore | undefined; private _triggerState: SuggestTriggerOptions | undefined = undefined; private _requestToken?: CancellationTokenSource; @@ -209,6 +212,7 @@ export class SuggestModel implements IDisposable { dispose(): void { dispose(this._triggerCharacterListener); dispose([this._onDidCancel, this._onDidSuggest, this._onDidTrigger, this._triggerQuickSuggest]); + this._waitForInlineCompletions?.dispose(); this._toDispose.dispose(); this._completionDisposables.dispose(); this.cancel(); @@ -310,8 +314,11 @@ export class SuggestModel implements IDisposable { } cancel(retrigger: boolean = false): void { + this._triggerQuickSuggest.cancel(); + this._waitForInlineCompletions?.dispose(); + this._waitForInlineCompletions = undefined; + if (this._triggerState !== undefined) { - this._triggerQuickSuggest.cancel(); this._requestToken?.cancel(); this._requestToken = undefined; this._triggerState = undefined; @@ -391,6 +398,10 @@ export class SuggestModel implements IDisposable { this.cancel(); + // Cancel any in-flight wait for inline completions from a previous cycle + this._waitForInlineCompletions?.dispose(); + this._waitForInlineCompletions = undefined; + this._triggerQuickSuggest.cancelAndSet(() => { if (this._triggerState !== undefined) { return; @@ -409,16 +420,19 @@ export class SuggestModel implements IDisposable { return; } + let waitForInlineCompletions = false; if (!QuickSuggestionsOptions.isAllOn(config)) { // Check the type of the token that triggered this model.tokenization.tokenizeIfCheap(pos.lineNumber); const lineTokens = model.tokenization.getLineTokens(pos.lineNumber); const tokenType = lineTokens.getStandardTokenType(lineTokens.findTokenIndexAtOffset(Math.max(pos.column - 1 - 1, 0))); - if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'on') { - if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'offWhenInlineCompletions' - || (this._languageFeaturesService.inlineCompletionsProvider.has(model) && this._editor.getOption(EditorOption.inlineSuggest).enabled)) { - return; - } + const value = QuickSuggestionsOptions.valueFor(config, tokenType); + if (value === 'off' || value === 'inline') { + return; + } + if (value === 'offWhenInlineCompletions') { + waitForInlineCompletions = this._languageFeaturesService.inlineCompletionsProvider.has(model) + && this._editor.getOption(EditorOption.inlineSuggest).enabled; } } @@ -431,12 +445,73 @@ export class SuggestModel implements IDisposable { return; } - // we made it till here -> trigger now - this.trigger({ auto: true }); + if (waitForInlineCompletions) { + // Wait for inline completions to resolve before deciding + this._waitForInlineCompletionsAndTrigger(model, pos); + } else { + this.trigger({ auto: true }); + } }, this._editor.getOption(EditorOption.quickSuggestionsDelay)); } + private _waitForInlineCompletionsAndTrigger(initialModel: ITextModel, initialPosition: Position): void { + const initialModelVersion = initialModel.getVersionId(); + const inlineController = getInlineCompletionsController(this._editor); + const inlineModel = inlineController?.model.get(); + if (!inlineModel) { + this.trigger({ auto: true }); + return; + } + + const state = inlineModel.state.get(); + if (state?.inlineSuggestion) { + // Inline completions are already showing - suppress + return; + } + + const store = new DisposableStore(); + this._waitForInlineCompletions = store; + + const triggerAndCleanUp = (doTrigger: boolean) => { + store.dispose(); + if (this._waitForInlineCompletions === store) { + this._waitForInlineCompletions = undefined; + } + if (this._triggerState !== undefined) { + return; + } + if (!doTrigger) { + return; + } + const currentModel = this._editor.getModel(); + const currentPosition = this._editor.getPosition(); + if (currentModel === initialModel + && currentModel.getVersionId() === initialModelVersion + && currentPosition?.equals(initialPosition) + && this._editor.hasWidgetFocus() + ) { + this.trigger({ auto: true }); + } + }; + + // Race: observe inline completions state vs 750ms timeout + disposableTimeout(() => { + triggerAndCleanUp(true); + inlineModel.stop('automatic'); + }, 750, store); + + store.add(autorun(reader => { + const status = inlineModel.status.read(reader); + const currentState = inlineModel.state.read(reader); + if (!currentState && status === 'loading') { + // Still loading + return; + } + triggerAndCleanUp(!currentState); + })); + } + private _refilterCompletionItems(): void { assertType(this._editor.hasModel()); assertType(this._triggerState !== undefined); diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts index ff465706c0c..d99d63f7c98 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts @@ -25,7 +25,7 @@ import { SuggestController } from '../../browser/suggestController.js'; import { ISuggestMemoryService } from '../../browser/suggestMemory.js'; import { LineContext, SuggestModel } from '../../browser/suggestModel.js'; import { ISelectedSuggestion } from '../../browser/suggestWidget.js'; -import { createTestCodeEditor, ITestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; +import { createTestCodeEditor, ITestCodeEditor, withAsyncTestCodeEditor } from '../../../../test/browser/testCodeEditor.js'; import { createModelServices, createTextModel, instantiateTextModel } from '../../../../test/common/testTextModel.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; @@ -42,6 +42,15 @@ import { getSnippetSuggestSupport, setSnippetSuggestSupport } from '../../browse import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { timeout } from '../../../../../base/common/async.js'; +import { InlineCompletionsController } from '../../../inlineCompletions/browser/controller/inlineCompletionsController.js'; +import { InlineSuggestionsView } from '../../../inlineCompletions/browser/view/inlineSuggestionsView.js'; +import { IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { IMenuService, IMenu } from '../../../../../platform/actions/common/actions.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IEditorWorkerService } from '../../../../common/services/editorWorker.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { ModifierKeyEmitter } from '../../../../../base/browser/dom.js'; function createMockEditor(model: TextModel, languageFeaturesService: ILanguageFeaturesService): ITestCodeEditor { @@ -1230,11 +1239,11 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); }); - test('offWhenInlineCompletions - suppresses quick suggest when inline provider exists', function () { + test('offWhenInlineCompletions - allows quick suggest when inline provider returns empty results', function () { disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); - // Register a dummy inline completions provider + // Register a dummy inline completions provider that returns no items const inlineProvider: InlineCompletionsProvider = { provideInlineCompletions: () => ({ items: [] }), disposeInlineCompletions: () => { } @@ -1244,20 +1253,12 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { return withOracle((suggestOracle, editor) => { editor.updateOptions({ quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' } }); - return new Promise((resolve, reject) => { - const unexpectedSuggestSub = suggestOracle.onDidSuggest(() => { - unexpectedSuggestSub.dispose(); - reject(new Error('Quick suggestions should not have been triggered')); - }); - + // Without an InlineCompletionsController, the fallback triggers immediately + return assertEvent(suggestOracle.onDidSuggest, () => { editor.setPosition({ lineNumber: 1, column: 4 }); editor.trigger('keyboard', Handler.Type, { text: 'd' }); - - // Wait for the quick suggest delay to pass without triggering - setTimeout(() => { - unexpectedSuggestSub.dispose(); - resolve(); - }, 200); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); }); }); }); @@ -1336,7 +1337,7 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); }); - test('string shorthand - "offWhenInlineCompletions" suppresses when inline provider exists', function () { + test('string shorthand - "offWhenInlineCompletions" allows quick suggest when inline provider returns empty', function () { return runWithFakedTimers({ useFakeTimers: true }, () => { disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); @@ -1347,24 +1348,202 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); return withOracle((suggestOracle, editor) => { - // Use string shorthand — applies to all token types + // Use string shorthand - applies to all token types editor.updateOptions({ quickSuggestions: 'offWhenInlineCompletions' }); - return new Promise((resolve, reject) => { - const sub = suggestOracle.onDidSuggest(() => { - sub.dispose(); - reject(new Error('Quick suggestions should have been suppressed by offWhenInlineCompletions shorthand')); - }); - + // Without InlineCompletionsController, the fallback triggers immediately + return assertEvent(suggestOracle.onDidSuggest, () => { editor.setPosition({ lineNumber: 1, column: 4 }); editor.trigger('keyboard', Handler.Type, { text: 'd' }); - - setTimeout(() => { - sub.dispose(); - resolve(); - }, 200); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); }); }); }); }); }); + +suite('SuggestModel - offWhenInlineCompletions with InlineCompletionsController', function () { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const completionProvider: CompletionItemProvider = { + _debugDisplayName: 'test', + provideCompletionItems(doc, pos): CompletionList { + const wordUntil = doc.getWordUntilPosition(pos); + return { + incomplete: false, + suggestions: [{ + label: doc.getWordUntilPosition(pos).word, + kind: CompletionItemKind.Property, + insertText: 'foofoo', + range: new Range(pos.lineNumber, wordUntil.startColumn, pos.lineNumber, wordUntil.endColumn) + }] + }; + } + }; + + async function withSuggestModelAndInlineCompletions( + text: string, + inlineProvider: InlineCompletionsProvider, + callback: (suggestModel: SuggestModel, editor: ITestCodeEditor) => Promise, + ): Promise { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const disposableStore = new DisposableStore(); + try { + const languageFeaturesService = new LanguageFeaturesService(); + disposableStore.add(languageFeaturesService.completionProvider.register({ pattern: '**' }, completionProvider)); + disposableStore.add(languageFeaturesService.inlineCompletionsProvider.register({ pattern: '**' }, inlineProvider)); + + const serviceCollection = new ServiceCollection( + [ILanguageFeaturesService, languageFeaturesService], + [ITelemetryService, NullTelemetryService], + [ILogService, new NullLogService()], + [IStorageService, disposableStore.add(new InMemoryStorageService())], + [IKeybindingService, new MockKeybindingService()], + [IEditorWorkerService, new class extends mock() { + override computeWordRanges() { + return Promise.resolve({}); + } + }], + [ISuggestMemoryService, new class extends mock() { + override memorize(): void { } + override select(): number { return 0; } + }], + [IMenuService, new class extends mock() { + override createMenu() { + return new class extends mock() { + override onDidChange = Event.None; + override dispose() { } + }; + } + }], + [ILabelService, new class extends mock() { }], + [IWorkspaceContextService, new class extends mock() { }], + [IEnvironmentService, new class extends mock() { + override isBuilt: boolean = true; + override isExtensionDevelopment: boolean = false; + }], + [IAccessibilitySignalService, new class extends mock() { + override async playSignal() { } + override isSoundEnabled() { return false; } + }], + [IDefaultAccountService, new class extends mock() { + override onDidChangeDefaultAccount = Event.None; + override getDefaultAccount = async () => null; + override setDefaultAccountProvider = () => { }; + }], + ); + + await withAsyncTestCodeEditor(text, { serviceCollection }, async (editor, _editorViewModel, instantiationService) => { + instantiationService.stubInstance(InlineSuggestionsView, { + dispose: () => { } + }); + editor.registerAndInstantiateContribution(SnippetController2.ID, SnippetController2); + editor.registerAndInstantiateContribution(InlineCompletionsController.ID, InlineCompletionsController); + + editor.hasWidgetFocus = () => true; + editor.updateOptions({ + quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' }, + }); + + const suggestModel = disposableStore.add( + editor.invokeWithinContext(accessor => accessor.get(IInstantiationService).createInstance(SuggestModel, editor)) + ); + + await callback(suggestModel, editor); + }); + } finally { + disposableStore.dispose(); + ModifierKeyEmitter.disposeInstance(); + } + }); + } + + test('suppresses quick suggest when inline completions are showing ghost text', async function () { + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: (model, pos) => { + // Return a completion that extends the current word - must be visible at cursor + const word = model.getWordAtPosition(pos); + if (!word) { return { items: [] }; } + return { + items: [{ + insertText: word.word + 'Suffix', + range: new Range(pos.lineNumber, word.startColumn, pos.lineNumber, word.endColumn), + }] + }; + }, + disposeInlineCompletions: () => { } + }; + + await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => { + let didSuggest = false; + const sub = suggestModel.onDidSuggest(() => { didSuggest = true; }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + await timeout(200); + + sub.dispose(); + assert.strictEqual(didSuggest, false, 'Quick suggestions should have been suppressed when inline completions are showing'); + }); + }); + + test('allows quick suggest when inline completions resolve with no results', async function () { + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + + await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => { + let didSuggest = false; + const sub = suggestModel.onDidSuggest(e => { + didSuggest = true; + assert.strictEqual(e.triggerOptions.auto, true); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + await timeout(200); + + sub.dispose(); + assert.strictEqual(didSuggest, true, 'Quick suggestions should have been triggered after inline completions resolved empty'); + }); + }); + + test('allows quick suggest when inlineSuggest is disabled even with provider', async function () { + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: (model, pos) => { + const word = model.getWordAtPosition(pos); + if (!word) { return { items: [] }; } + return { + items: [{ + insertText: word.word + 'Suffix', + range: new Range(pos.lineNumber, word.startColumn, pos.lineNumber, word.endColumn), + }] + }; + }, + disposeInlineCompletions: () => { } + }; + + await withSuggestModelAndInlineCompletions('abc def', inlineProvider, async (suggestModel, editor) => { + editor.updateOptions({ inlineSuggest: { enabled: false } }); + + let didSuggest = false; + const sub = suggestModel.onDidSuggest(e => { + didSuggest = true; + assert.strictEqual(e.triggerOptions.auto, true); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + await timeout(200); + + sub.dispose(); + assert.strictEqual(didSuggest, true, 'Quick suggestions should have been triggered when inlineSuggest is disabled'); + }); + }); +}); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index fc9c2da70f5..5b0047e74a3 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4255,6 +4255,10 @@ declare namespace monaco.editor { * Controls whether the search result and diff result automatically restarts from the beginning (or the end) when no further matches can be found */ loop?: boolean; + /** + * Controls whether to close the Find Widget after an explicit find navigation command lands on a match. + */ + closeOnResult?: boolean; } export type GoToLocationValues = 'peek' | 'gotoAndPeek' | 'goto'; @@ -5259,7 +5263,7 @@ declare namespace monaco.editor { export const EditorOptions: { acceptSuggestionOnCommitCharacter: IEditorOption; acceptSuggestionOnEnter: IEditorOption; - accessibilitySupport: IEditorOption; + accessibilitySupport: IEditorOption; accessibilityPageSize: IEditorOption; allowOverflow: IEditorOption; allowVariableLineHeights: IEditorOption; @@ -5322,7 +5326,7 @@ declare namespace monaco.editor { foldingMaximumRegions: IEditorOption; unfoldOnClickAfterEndOfLine: IEditorOption; fontFamily: IEditorOption; - fontInfo: IEditorOption; + fontInfo: IEditorOption; fontLigatures2: IEditorOption; fontSize: IEditorOption; fontWeight: IEditorOption; @@ -5362,7 +5366,7 @@ declare namespace monaco.editor { pasteAs: IEditorOption>>; parameterHints: IEditorOption>>; peekWidgetDefaultFocus: IEditorOption; - placeholder: IEditorOption; + placeholder: IEditorOption; definitionLinkOpensInPeek: IEditorOption; quickSuggestions: IEditorOption; quickSuggestionsDelay: IEditorOption; diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index a871c7a2b8f..5a6cf722ccc 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -359,6 +359,11 @@ export interface IActionListOptions { */ readonly filterPlaceholder?: string; + /** + * Optional actions shown in the filter row, to the right of the input. + */ + readonly filterActions?: readonly IAction[]; + /** * Section IDs that should be collapsed by default. */ @@ -369,6 +374,12 @@ export interface IActionListOptions { */ readonly minWidth?: number; + /** + * When true, descriptions are rendered as subtext below the title + * instead of inline to the right. + */ + readonly descriptionBelow?: boolean; + /** @@ -383,7 +394,7 @@ export class ActionList extends Disposable { private readonly _list: List>; - private readonly _actionLineHeight = 24; + private readonly _actionLineHeight: number; private readonly _headerLineHeight = 24; private readonly _separatorLineHeight = 8; @@ -431,6 +442,10 @@ export class ActionList extends Disposable { super(); this.domNode = document.createElement('div'); this.domNode.classList.add('actionList'); + if (this._options?.descriptionBelow) { + this.domNode.classList.add('description-below'); + } + this._actionLineHeight = this._options?.descriptionBelow ? 48 : 24; // Initialize collapsed sections if (this._options?.collapsedByDefault) { @@ -506,13 +521,21 @@ export class ActionList extends Disposable { if (this._options?.showFilter) { this._filterContainer = document.createElement('div'); this._filterContainer.className = 'action-list-filter'; + const filterRow = dom.append(this._filterContainer, dom.$('.action-list-filter-row')); this._filterInput = document.createElement('input'); this._filterInput.type = 'text'; this._filterInput.className = 'action-list-filter-input'; this._filterInput.placeholder = this._options?.filterPlaceholder ?? localize('actionList.filter.placeholder', "Search..."); this._filterInput.setAttribute('aria-label', localize('actionList.filter.ariaLabel', "Filter items")); - this._filterContainer.appendChild(this._filterInput); + filterRow.appendChild(this._filterInput); + + const filterActions = this._options?.filterActions ?? []; + if (filterActions.length > 0) { + const filterActionsContainer = dom.append(filterRow, dom.$('.action-list-filter-actions')); + const filterActionBar = this._register(new ActionBar(filterActionsContainer)); + filterActionBar.push(filterActions, { icon: true, label: false }); + } this._register(dom.addDisposableListener(this._filterInput, 'input', () => { this._filterText = this._filterInput!.value; @@ -787,7 +810,7 @@ export class ActionList extends Disposable { availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; } - const viewportMaxHeight = Math.floor(targetWindow.innerHeight * 0.4); + const viewportMaxHeight = Math.floor(targetWindow.innerHeight * 0.6); const maxHeight = Math.min(Math.max(availableHeight, this._actionLineHeight * 3 + filterHeight), viewportMaxHeight); const height = Math.min(listHeight + filterHeight, maxHeight); return height - filterHeight; diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 96fc6cbf506..8957a85b01f 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -15,7 +15,7 @@ background-color: var(--vscode-menu-background); color: var(--vscode-menu-foreground); padding: 4px; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); } .context-view-block { @@ -119,7 +119,7 @@ .action-widget .monaco-list-row.action { display: flex; - gap: 6px; + gap: 8px; align-items: center; } @@ -217,6 +217,31 @@ font-size: 12px; } +/* Description below mode — shows descriptions as subtext under the title */ +.action-widget .description-below .monaco-list .monaco-list-row.action { + flex-wrap: wrap; + align-content: center; + padding-top: 6px; + padding-right: 2px; + + .title { + line-height: 14px; + } + + .description { + display: block !important; + width: 100%; + margin-left: 0; + padding-left: 20px; + font-size: 11px; + line-height: 14px; + opacity: 0.8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + /* Item toolbar - shows on hover/focus */ .action-widget .monaco-list-row.action .action-list-item-toolbar { display: none; @@ -240,7 +265,13 @@ /* Filter input */ .action-widget .action-list-filter { - padding: 2px 2px 4px 2px + padding: 2px 2px 4px 2px; +} + +.action-widget .action-list-filter-row { + display: flex; + align-items: center; + gap: 4px; } .action-widget .action-list-filter:first-child { @@ -253,6 +284,7 @@ .action-widget .action-list-filter-input { width: 100%; + flex: 1; box-sizing: border-box; padding: 4px 8px; border: 1px solid var(--vscode-input-border, transparent); @@ -269,3 +301,12 @@ .action-widget .action-list-filter-input::placeholder { color: var(--vscode-input-placeholderForeground); } + +.action-widget .action-list-filter-actions .action-label { + padding: 3px; + border-radius: 3px; +} + +.action-widget .action-list-filter-actions .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 2b2022fb435..82e6ff581ba 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -170,6 +170,7 @@ export class ActionWidgetDropdown extends BaseDropdown { action.run(); }, onHide: () => { + this.hide(); if (isHTMLElement(previouslyFocusedElement)) { previouslyFocusedElement.focus(); } @@ -221,6 +222,8 @@ export class ActionWidgetDropdown extends BaseDropdown { getWidgetRole: () => 'menu', }; + super.show(); + this.actionWidgetService.show( this._options.label ?? '', false, diff --git a/src/vs/platform/actions/browser/toolbar.ts b/src/vs/platform/actions/browser/toolbar.ts index 9304d12db48..e44cdb4eae0 100644 --- a/src/vs/platform/actions/browser/toolbar.ts +++ b/src/vs/platform/actions/browser/toolbar.ts @@ -184,7 +184,8 @@ export class WorkbenchToolBar extends ToolBar { // coalesce turns Array into IAction[] coalesceInPlace(primary); coalesceInPlace(extraSecondary); - super.setActions(primary, Separator.join(extraSecondary, secondary)); + + super.setActions(Separator.clean(primary), Separator.join(extraSecondary, secondary)); // add context menu for toggle and configure keybinding actions if (toggleActions.length > 0 || primary.length > 0) { diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 1b3e9d595c8..5c1cf404a4c 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -89,6 +89,7 @@ export class MenuId { static readonly EditorContextShare = new MenuId('EditorContextShare'); static readonly EditorTitle = new MenuId('EditorTitle'); static readonly ModalEditorTitle = new MenuId('ModalEditorTitle'); + static readonly ModalEditorEditorTitle = new MenuId('ModalEditorEditorTitle'); static readonly CompactWindowEditorTitle = new MenuId('CompactWindowEditorTitle'); static readonly EditorTitleRun = new MenuId('EditorTitleRun'); static readonly EditorTitleContext = new MenuId('EditorTitleContext'); @@ -255,10 +256,13 @@ export class MenuId { static readonly ChatExecute = new MenuId('ChatExecute'); static readonly ChatExecuteQueue = new MenuId('ChatExecuteQueue'); static readonly ChatInput = new MenuId('ChatInput'); + static readonly ChatInputSecondary = new MenuId('ChatInputSecondary'); static readonly ChatInputSide = new MenuId('ChatInputSide'); static readonly ChatModePicker = new MenuId('ChatModePicker'); static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar'); static readonly ChatEditingSessionChangesToolbar = new MenuId('ChatEditingSessionChangesToolbar'); + static readonly ChatEditingSessionApplySubmenu = new MenuId('ChatEditingSessionApplySubmenu'); + static readonly ChatEditingSessionChangesVersionsSubmenu = new MenuId('ChatEditingSessionChangesVersionsSubmenu'); static readonly ChatEditingEditorContent = new MenuId('ChatEditingEditorContent'); static readonly ChatEditingEditorHunk = new MenuId('ChatEditingEditorHunk'); static readonly ChatEditingDeletedNotebookCell = new MenuId('ChatEditingDeletedNotebookCell'); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 499b0ef5cb9..fe4f22d727d 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -7,6 +7,29 @@ import { Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { URI } from '../../../base/common/uri.js'; +const commandPrefix = 'workbench.action.browser'; +export enum BrowserViewCommandId { + Open = `${commandPrefix}.open`, + NewTab = `${commandPrefix}.newTab`, + GoBack = `${commandPrefix}.goBack`, + GoForward = `${commandPrefix}.goForward`, + Reload = `${commandPrefix}.reload`, + HardReload = `${commandPrefix}.hardReload`, + FocusUrlInput = `${commandPrefix}.focusUrlInput`, + AddElementToChat = `${commandPrefix}.addElementToChat`, + AddConsoleLogsToChat = `${commandPrefix}.addConsoleLogsToChat`, + ToggleDevTools = `${commandPrefix}.toggleDevTools`, + OpenExternal = `${commandPrefix}.openExternal`, + ClearGlobalStorage = `${commandPrefix}.clearGlobalStorage`, + ClearWorkspaceStorage = `${commandPrefix}.clearWorkspaceStorage`, + ClearEphemeralStorage = `${commandPrefix}.clearEphemeralStorage`, + OpenSettings = `${commandPrefix}.openSettings`, + ShowFind = `${commandPrefix}.showFind`, + HideFind = `${commandPrefix}.hideFind`, + FindNext = `${commandPrefix}.findNext`, + FindPrevious = `${commandPrefix}.findPrevious`, +} + export interface IBrowserViewBounds { windowId: number; x: number; @@ -14,6 +37,7 @@ export interface IBrowserViewBounds { width: number; height: number; zoomFactor: number; + cornerRadius: number; } export interface IBrowserViewCaptureScreenshotOptions { @@ -286,4 +310,10 @@ export interface IBrowserViewService { * @param id The browser view identifier */ clearStorage(id: string): Promise; + + /** + * Update the keybinding accelerators used in browser view context menus. + * @param keybindings A map of command ID to accelerator label + */ + updateKeybindings(keybindings: { [commandId: string]: string }): Promise; } diff --git a/src/vs/platform/browserView/common/browserViewGroup.ts b/src/vs/platform/browserView/common/browserViewGroup.ts index 0f43b98c8b0..0851ac7ffe4 100644 --- a/src/vs/platform/browserView/common/browserViewGroup.ts +++ b/src/vs/platform/browserView/common/browserViewGroup.ts @@ -5,6 +5,7 @@ import { Event } from '../../../base/common/event.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; +import { CDPEvent, CDPRequest, CDPResponse } from './cdp/types.js'; export const ipcBrowserViewGroupChannelName = 'browserViewGroup'; @@ -27,10 +28,11 @@ export interface IBrowserViewGroup extends IDisposable { readonly onDidAddView: Event; readonly onDidRemoveView: Event; readonly onDidDestroy: Event; + readonly onCDPMessage: Event; addView(viewId: string): Promise; removeView(viewId: string): Promise; - getDebugWebSocketEndpoint(): Promise; + sendCDPMessage(msg: CDPRequest): Promise; } /** @@ -48,12 +50,14 @@ export interface IBrowserViewGroupService { onDynamicDidAddView(groupId: string): Event; onDynamicDidRemoveView(groupId: string): Event; onDynamicDidDestroy(groupId: string): Event; + onDynamicCDPMessage(groupId: string): Event; /** * Create a new browser view group. + * @param windowId The ID of the primary window the group should be associated with. * @returns The id of the newly created group. */ - createGroup(): Promise; + createGroup(windowId: number): Promise; /** * Destroy a browser view group. @@ -78,9 +82,9 @@ export interface IBrowserViewGroupService { removeViewFromGroup(groupId: string, viewId: string): Promise; /** - * Get a short-lived CDP WebSocket endpoint URL for a specific group. - * The returned URL contains a single-use token. + * Send a CDP message to a group's browser proxy. * @param groupId The group identifier. + * @param message The CDP request. */ - getDebugWebSocketEndpoint(groupId: string): Promise; + sendCDPMessage(groupId: string, message: CDPRequest): Promise; } diff --git a/src/vs/platform/browserView/common/cdp/proxy.ts b/src/vs/platform/browserView/common/cdp/proxy.ts index 85dc5f6d52d..86b3f4af1a5 100644 --- a/src/vs/platform/browserView/common/cdp/proxy.ts +++ b/src/vs/platform/browserView/common/cdp/proxy.ts @@ -6,7 +6,7 @@ import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { ICDPTarget, CDPEvent, CDPError, CDPServerError, CDPMethodNotFoundError, CDPInvalidParamsError, ICDPConnection, CDPTargetInfo, ICDPBrowserTarget } from './types.js'; +import { ICDPTarget, CDPRequest, CDPResponse, CDPEvent, CDPError, CDPErrorCode, CDPServerError, CDPMethodNotFoundError, CDPInvalidParamsError, ICDPConnection, CDPTargetInfo, ICDPBrowserTarget } from './types.js'; /** * CDP protocol handler for browser-level connections. @@ -95,22 +95,29 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { for (const target of this.browserTarget.getTargets()) { void this._targets.register(target); } + + // Mirror typed events to the onMessage channel + this._register(this._onEvent.event(event => { + this._onMessage.fire(event); + })); } // #region Public API - // Events to external client (ICDPConnection) + // Events to external clients private readonly _onEvent = this._register(new Emitter()); readonly onEvent: Event = this._onEvent.event; private readonly _onClose = this._register(new Emitter()); readonly onClose: Event = this._onClose.event; + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage: Event = this._onMessage.event; /** - * Send a CDP message and await the result. + * Send a CDP command and await the result. * Browser-level handlers (Browser.*, Target.*) are checked first. * Other commands are routed to the page session identified by sessionId. */ - async sendMessage(method: string, params: unknown = {}, sessionId?: string): Promise { + async sendCommand(method: string, params: unknown = {}, sessionId?: string): Promise { try { // Browser-level command handling if ( @@ -131,7 +138,7 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { throw new CDPServerError(`Session not found: ${sessionId}`); } - const result = await connection.sendMessage(method, params); + const result = await connection.sendCommand(method, params); return result ?? {}; } catch (error) { if (error instanceof CDPError) { @@ -141,6 +148,27 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { } } + /** + * Accept a CDP request from a message-based transport (WebSocket, IPC, etc.), route it, + * and deliver the response or error via {@link onMessage}. + */ + async sendMessage({ id, method, params, sessionId }: CDPRequest): Promise { + return this.sendCommand(method, params, sessionId) + .then(result => { + this._onMessage.fire({ id, result, sessionId }); + }) + .catch((error: Error) => { + this._onMessage.fire({ + id, + error: { + code: error instanceof CDPError ? error.code : CDPErrorCode.ServerError, + message: error.message || 'Unknown error' + }, + sessionId + }); + }); + } + // #endregion // #region CDP Commands @@ -206,7 +234,7 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { } private async handleTargetGetTargets() { - return { targetInfos: this._targets.getAllInfos() }; + return { targetInfos: Array.from(this._targets.getAllInfos()) }; } private async handleTargetGetTargetInfo({ targetId }: { targetId?: string } = {}) { diff --git a/src/vs/platform/browserView/common/cdp/types.ts b/src/vs/platform/browserView/common/cdp/types.ts index 603467e3ed2..6fbfd30e26f 100644 --- a/src/vs/platform/browserView/common/cdp/types.ts +++ b/src/vs/platform/browserView/common/cdp/types.ts @@ -151,7 +151,7 @@ export interface ICDPBrowserTarget extends ICDPTarget { /** Get all available targets */ getTargets(): IterableIterator; /** Create a new target in the specified browser context */ - createTarget(url: string, browserContextId?: string): Promise; + createTarget(url: string, browserContextId?: string, windowId?: number): Promise; /** Activate a target (bring to foreground) */ activateTarget(target: ICDPTarget): Promise; /** Close a target */ @@ -187,5 +187,5 @@ export interface ICDPConnection extends IDisposable { * @param sessionId Optional session ID for targeting a specific session * @returns Promise resolving to the result or rejecting with a CDPError */ - sendMessage(method: string, params?: unknown, sessionId?: string): Promise; + sendCommand(method: string, params?: unknown, sessionId?: string): Promise; } diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 11f6f39b592..e08d161db21 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -11,15 +11,16 @@ import { VSBuffer } from '../../../base/common/buffer.js'; import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId } from '../common/browserView.js'; import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; -import { IBaseWindow, ICodeWindow } from '../../window/electron-main/window.js'; +import { ICodeWindow } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; -import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { isMacintosh } from '../../../base/common/platform.js'; import { BrowserViewUri } from '../common/browserViewUri.js'; import { BrowserViewDebugger } from './browserViewDebugger.js'; import { ILogService } from '../../log/common/log.js'; import { ICDPTarget, ICDPConnection, CDPTargetInfo } from '../common/cdp/types.js'; import { BrowserSession } from './browserSession.js'; +import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; +import { hasKey } from '../../../base/common/types.js'; /** Key combinations that are used in system-level shortcuts. */ const nativeShortcuts = new Set([ @@ -47,7 +48,7 @@ export class BrowserView extends Disposable implements ICDPTarget { private _lastUserGestureTimestamp: number = -Infinity; private _debugger: BrowserViewDebugger; - private _window: IBaseWindow | undefined; + private _window: ICodeWindow | IAuxiliaryWindow | undefined; private _isSendingKeyEvent = false; private _isDisposed = false; @@ -88,6 +89,7 @@ export class BrowserView extends Disposable implements ICDPTarget { public readonly id: string, public readonly session: BrowserSession, createChildView: (options?: Electron.WebContentsViewConstructorOptions) => BrowserView, + openContextMenu: (view: BrowserView, params: Electron.ContextMenuParams) => void, options: Electron.WebContentsViewConstructorOptions | undefined, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService, @@ -150,6 +152,10 @@ export class BrowserView extends Disposable implements ICDPTarget { }; }); + this._view.webContents.on('context-menu', (_event, params) => { + openContextMenu(this, params); + }); + this._view.webContents.on('destroyed', () => { this.dispose(); }); @@ -376,7 +382,7 @@ export class BrowserView extends Disposable implements ICDPTarget { */ layout(bounds: IBrowserViewBounds): void { if (this._window?.win?.id !== bounds.windowId) { - const newWindow = this.windowById(bounds.windowId); + const newWindow = this._windowById(bounds.windowId); if (newWindow) { this._window?.win?.contentView.removeChildView(this._view); this._window = newWindow; @@ -385,6 +391,7 @@ export class BrowserView extends Disposable implements ICDPTarget { } this._view.webContents.setZoomFactor(bounds.zoomFactor); + this._view.setBorderRadius(Math.round(bounds.cornerRadius * bounds.zoomFactor)); this._view.setBounds({ x: Math.round(bounds.x * bounds.zoomFactor), y: Math.round(bounds.y * bounds.zoomFactor), @@ -473,8 +480,7 @@ export class BrowserView extends Disposable implements ICDPTarget { async captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise { const quality = options?.quality ?? 80; const image = await this._view.webContents.capturePage(options?.rect, { - stayHidden: true, - stayAwake: true + stayHidden: true }); const buffer = image.toJPEG(quality); const screenshot = VSBuffer.wrap(buffer); @@ -574,6 +580,22 @@ export class BrowserView extends Disposable implements ICDPTarget { return this._view; } + /** + * Get the hosting Electron window for this view, if any. + * This can be an auxiliary window, depending on where the view is currently hosted. + */ + getElectronWindow(): Electron.BrowserWindow | undefined { + return this._window?.win ?? undefined; + } + + /** + * Get the main code window hosting this browser view, if any. This is used for routing commands from the browser view to the correct window. + * If the browser view is hosted in an auxiliary window, this will return the parent code window of that auxiliary window. + */ + getTopCodeWindow(): ICodeWindow | undefined { + return this._window && hasKey(this._window, { parentId: true }) ? this._codeWindowById(this._window.parentId) : undefined; + } + // ============ ICDPTarget implementation ============ /** @@ -661,11 +683,11 @@ export class BrowserView extends Disposable implements ICDPTarget { return true; } - private windowById(windowId: number | undefined): ICodeWindow | IAuxiliaryWindow | undefined { - return this.codeWindowById(windowId) ?? this.auxiliaryWindowById(windowId); + private _windowById(windowId: number | undefined): ICodeWindow | IAuxiliaryWindow | undefined { + return this._codeWindowById(windowId) ?? this._auxiliaryWindowById(windowId); } - private codeWindowById(windowId: number | undefined): ICodeWindow | undefined { + private _codeWindowById(windowId: number | undefined): ICodeWindow | undefined { if (typeof windowId !== 'number') { return undefined; } @@ -673,7 +695,7 @@ export class BrowserView extends Disposable implements ICDPTarget { return this.windowsMainService.getWindowById(windowId); } - private auxiliaryWindowById(windowId: number | undefined): IAuxiliaryWindow | undefined { + private _auxiliaryWindowById(windowId: number | undefined): IAuxiliaryWindow | undefined { if (typeof windowId !== 'number') { return undefined; } diff --git a/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts b/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts deleted file mode 100644 index 30ad512c042..00000000000 --- a/src/vs/platform/browserView/electron-main/browserViewCDPProxyServer.ts +++ /dev/null @@ -1,269 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { ILogService } from '../../log/common/log.js'; -import type * as http from 'http'; -import { AddressInfo, Socket } from 'net'; -import { upgradeToISocket } from '../../../base/parts/ipc/node/ipc.net.js'; -import { generateUuid } from '../../../base/common/uuid.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; -import { CDPBrowserProxy } from '../common/cdp/proxy.js'; -import { CDPEvent, CDPRequest, CDPError, CDPErrorCode, ICDPBrowserTarget, ICDPConnection } from '../common/cdp/types.js'; -import { disposableTimeout } from '../../../base/common/async.js'; -import { ISocket } from '../../../base/parts/ipc/common/ipc.net.js'; -import { createDecorator } from '../../instantiation/common/instantiation.js'; - -export const IBrowserViewCDPProxyServer = createDecorator('browserViewCDPProxyServer'); - -export interface IBrowserViewCDPProxyServer { - readonly _serviceBrand: undefined; - - /** - * Returns a debug endpoint with a short-lived, single-use token for a specific browser target. - */ - getWebSocketEndpointForTarget(target: ICDPBrowserTarget): Promise; - - /** - * Unregister a previously registered browser target. - */ - removeTarget(target: ICDPBrowserTarget): Promise; -} - -/** - * WebSocket server that provides CDP debugging for browser views. - * - * Manages a registry of {@link ICDPBrowserTarget} instances, each reachable - * at its own `/devtools/browser/{id}` WebSocket endpoint. - */ -export class BrowserViewCDPProxyServer extends Disposable implements IBrowserViewCDPProxyServer { - declare readonly _serviceBrand: undefined; - - private server: http.Server | undefined; - private port: number | undefined; - - private readonly tokens = this._register(new TokenManager()); - private readonly targets = new Map(); - - constructor( - @ILogService private readonly logService: ILogService - ) { - super(); - } - - /** - * Register a browser target and return a WebSocket endpoint URL for it. - * The target is reachable at `/devtools/browser/{targetId}`. - */ - async getWebSocketEndpointForTarget(target: ICDPBrowserTarget): Promise { - await this.ensureServerStarted(); - - const targetInfo = await target.getTargetInfo(); - const targetId = targetInfo.targetId; - - // Register (or re-register) the target - this.targets.set(targetId, target); - - const token = await this.tokens.issueToken(targetId); - return `ws://localhost:${this.port}/devtools/browser/${targetId}?token=${token}`; - } - - /** - * Unregister a previously registered browser target. - */ - async removeTarget(target: ICDPBrowserTarget): Promise { - const targetInfo = await target.getTargetInfo(); - this.targets.delete(targetInfo.targetId); - } - - private async ensureServerStarted(): Promise { - if (this.server) { - return; - } - - const http = await import('http'); - this.server = http.createServer(); - - await new Promise((resolve, reject) => { - // Only listen on localhost to prevent external access - this.server!.listen(0, '127.0.0.1', () => resolve()); - this.server!.once('error', reject); - }); - - const address = this.server.address() as AddressInfo; - this.port = address.port; - - this.server.on('request', (req, res) => this.handleHttpRequest(req, res)); - this.server.on('upgrade', (req: http.IncomingMessage, socket: Socket) => this.handleWebSocketUpgrade(req, socket)); - } - - private async handleHttpRequest(_req: http.IncomingMessage, res: http.ServerResponse): Promise { - this.logService.debug(`[BrowserViewDebugProxy] HTTP request at ${_req.url}`); - // No support for HTTP endpoints for now. - res.writeHead(404); - res.end(); - } - - private handleWebSocketUpgrade(req: http.IncomingMessage, socket: Socket): void { - const [pathname, params] = (req.url || '').split('?'); - - const browserMatch = pathname.match(/^\/devtools\/browser\/([^/?]+)$/); - - this.logService.debug(`[BrowserViewDebugProxy] WebSocket upgrade requested: ${pathname}`); - - if (!browserMatch) { - this.logService.warn(`[BrowserViewDebugProxy] Rejecting WebSocket on unknown path: ${pathname}`); - socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); - socket.end(); - return; - } - - const targetId = browserMatch[1]; - - const token = new URLSearchParams(params).get('token'); - const tokenTargetId = token && this.tokens.consumeToken(token); - if (!tokenTargetId || tokenTargetId !== targetId) { - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); - socket.end(); - return; - } - - const target = this.targets.get(targetId); - if (!target) { - this.logService.warn(`[BrowserViewDebugProxy] Browser target not found: ${targetId}`); - socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); - socket.end(); - return; - } - - this.logService.debug(`[BrowserViewDebugProxy] WebSocket connected: ${pathname}`); - - const upgraded = upgradeToISocket(req, socket, { - debugLabel: 'browser-view-cdp-' + generateUuid(), - enableMessageSplitting: false, - }); - - if (!upgraded) { - return; - } - - const proxy = new CDPBrowserProxy(target); - const disposables = this.wireWebSocket(upgraded, proxy); - this._register(disposables); - this._register(upgraded); - } - - /** - * Wire a WebSocket (ISocket) to an ICDPConnection bidirectionally. - * Returns a DisposableStore that cleans up all subscriptions. - */ - private wireWebSocket(upgraded: ISocket, connection: ICDPConnection): DisposableStore { - const disposables = new DisposableStore(); - - // Socket -> Connection: parse JSON, call sendMessage, write response/error - disposables.add(upgraded.onData((rawData: VSBuffer) => { - try { - const message = rawData.toString(); - const { id, method, params, sessionId } = JSON.parse(message) as CDPRequest; - this.logService.debug(`[BrowserViewDebugProxy] <- ${message}`); - connection.sendMessage(method, params, sessionId) - .then((result: unknown) => { - const response = { id, result, sessionId }; - const responseStr = JSON.stringify(response); - this.logService.debug(`[BrowserViewDebugProxy] -> ${responseStr}`); - upgraded.write(VSBuffer.fromString(responseStr)); - }) - .catch((error: Error) => { - const response = { - id, - error: { - code: error instanceof CDPError ? error.code : CDPErrorCode.ServerError, - message: error.message || 'Unknown error' - }, - sessionId - }; - const responseStr = JSON.stringify(response); - this.logService.debug(`[BrowserViewDebugProxy] -> ${responseStr}`); - upgraded.write(VSBuffer.fromString(responseStr)); - }); - } catch (error) { - this.logService.error('[BrowserViewDebugProxy] Error parsing message:', error); - upgraded.end(); - } - })); - - // Connection -> Socket: serialize events and write - disposables.add(connection.onEvent((event: CDPEvent) => { - const eventStr = JSON.stringify(event); - this.logService.debug(`[BrowserViewDebugProxy] -> ${eventStr}`); - upgraded.write(VSBuffer.fromString(eventStr)); - })); - - // Connection close -> close socket - disposables.add(connection.onClose(() => { - this.logService.debug(`[BrowserViewDebugProxy] WebSocket closing`); - upgraded.end(); - })); - - // Socket closed -> cleanup - disposables.add(upgraded.onClose(() => { - this.logService.debug(`[BrowserViewDebugProxy] WebSocket closed`); - connection.dispose(); - disposables.dispose(); - })); - - return disposables; - } - - override dispose(): void { - if (this.server) { - this.server.close(); - this.server = undefined; - } - - super.dispose(); - } -} - -class TokenManager extends Disposable { - /** Map of currently valid single-use tokens to their associated details. */ - private readonly tokens = new Map(); - - /** - * Creates a short-lived, single-use token bound to a specific target. - * The token is revoked once consumed or after 30 seconds. - */ - async issueToken(details: TDetails): Promise { - const token = this.makeToken(); - this.tokens.set(token, { details: Object.freeze(details), expiresAt: Date.now() + 30_000 }); - this._register(disposableTimeout(() => this.tokens.delete(token), 30_000)); - return token; - } - - /** - * Consume a token. Returns the details it was issued with, or - * `undefined` if the token is invalid or expired. - */ - consumeToken(token: string): TDetails | undefined { - if (!token) { - return undefined; - } - const info = this.tokens.get(token); - if (!info) { - return undefined; - } - this.tokens.delete(token); - return Date.now() <= info.expiresAt ? info.details : undefined; - } - - private makeToken(): string { - const bytes = crypto.getRandomValues(new Uint8Array(32)); - const binary = Array.from(bytes).map(b => String.fromCharCode(b)).join(''); - const base64 = btoa(binary); - const urlSafeToken = base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); - - return urlSafeToken; - } -} diff --git a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts index 6e2a837d2f7..c0bdb734ef7 100644 --- a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts +++ b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts @@ -188,7 +188,7 @@ class DebugSession extends Disposable implements ICDPConnection { super(); } - async sendMessage(method: string, params?: unknown, _sessionId?: string): Promise { + async sendCommand(method: string, params?: unknown, _sessionId?: string): Promise { // This crashes Electron. Don't pass it through. if (method === 'Emulation.setDeviceMetricsOverride') { return Promise.resolve({}); diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index d7d59c27018..beed4f6042e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -6,10 +6,9 @@ import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { BrowserView } from './browserView.js'; -import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; +import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget, CDPRequest, CDPResponse, CDPEvent } from '../common/cdp/types.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; import { IBrowserViewGroup, IBrowserViewGroupViewEvent } from '../common/browserViewGroup.js'; -import { IBrowserViewCDPProxyServer } from './browserViewCDPProxyServer.js'; import { IBrowserViewMainService } from './browserViewMainService.js'; /** @@ -49,8 +48,8 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I constructor( readonly id: string, - @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService, - @IBrowserViewCDPProxyServer private readonly cdpProxyServer: IBrowserViewCDPProxyServer, + private readonly windowId: number, + @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService ) { super(); } @@ -127,12 +126,12 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I return this.views.values(); } - async createTarget(url: string, browserContextId?: string): Promise { + async createTarget(url: string, browserContextId?: string, windowId = this.windowId): Promise { if (browserContextId && !this.knownContextIds.has(browserContextId)) { throw new Error(`Unknown browser context ${browserContextId}`); } - const target = await this.browserViewMainService.createTarget(url, browserContextId); + const target = await this.browserViewMainService.createTarget(url, browserContextId, windowId); if (target instanceof BrowserView) { await this.addView(target.id); } @@ -188,19 +187,26 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I // #region CDP endpoint - /** - * Get a WebSocket endpoint URL for connecting to this group's CDP - * session. The URL contains a short-lived, single-use token. - */ - async getDebugWebSocketEndpoint(): Promise { - return this.cdpProxyServer.getWebSocketEndpointForTarget(this); + private _debugger: CDPBrowserProxy | undefined; + get debugger(): CDPBrowserProxy { + if (!this._debugger) { + this._debugger = this._register(new CDPBrowserProxy(this)); + } + return this._debugger; + } + + async sendCDPMessage(msg: CDPRequest): Promise { + return this.debugger.sendMessage(msg); + } + + get onCDPMessage(): Event { + return this.debugger.onMessage; } // #endregion override dispose(): void { this._onDidDestroy.fire(); - this.cdpProxyServer.removeTarget(this); super.dispose(); } } diff --git a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts index 20dd6331c0e..c34bfa16b9d 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroupMainService.ts @@ -9,6 +9,7 @@ import { createDecorator, IInstantiationService } from '../../instantiation/comm import { generateUuid } from '../../../base/common/uuid.js'; import { IBrowserViewGroupService, IBrowserViewGroupViewEvent } from '../common/browserViewGroup.js'; import { BrowserViewGroup } from './browserViewGroup.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; export const IBrowserViewGroupMainService = createDecorator('browserViewGroupMainService'); @@ -33,9 +34,9 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV super(); } - async createGroup(): Promise { + async createGroup(windowId: number): Promise { const id = generateUuid(); - const group = this.instantiationService.createInstance(BrowserViewGroup, id); + const group = this.instantiationService.createInstance(BrowserViewGroup, id, windowId); this.groups.set(id, group); // Auto-cleanup when the group disposes itself @@ -58,8 +59,8 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV return this._getGroup(groupId).removeView(viewId); } - async getDebugWebSocketEndpoint(groupId: string): Promise { - return this._getGroup(groupId).getDebugWebSocketEndpoint(); + async sendCDPMessage(groupId: string, message: CDPRequest): Promise { + return this._getGroup(groupId).debugger.sendMessage(message); } onDynamicDidAddView(groupId: string): Event { @@ -74,6 +75,10 @@ export class BrowserViewGroupMainService extends Disposable implements IBrowserV return this._getGroup(groupId).onDidDestroy; } + onDynamicCDPMessage(groupId: string): Event { + return this._getGroup(groupId).debugger.onMessage; + } + /** * Get a group or throw if not found. */ diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 3959717fb1d..313b3f416de 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,7 +6,8 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewService, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId } from '../common/browserView.js'; +import { clipboard, Menu, MenuItem } from 'electron'; import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -17,8 +18,13 @@ import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { BrowserSession } from './browserSession.js'; import { IProductService } from '../../product/common/productService.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; -import { logBrowserOpen } from '../common/browserViewTelemetry.js'; +import { IntegratedBrowserOpenSource, logBrowserOpen } from '../common/browserViewTelemetry.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { localize } from '../../../nls.js'; +import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; +import { ITextEditorOptions } from '../../editor/common/editor.js'; +import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -40,6 +46,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } private readonly browserViews = this._register(new DisposableMap()); + private _keybindings: { [commandId: string]: string } = Object.create(null); // ICDPBrowserTarget events private readonly _onTargetCreated = this._register(new Emitter()); @@ -53,38 +60,12 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa @IInstantiationService private readonly instantiationService: IInstantiationService, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IProductService private readonly productService: IProductService, - @ITelemetryService private readonly telemetryService: ITelemetryService + @ITelemetryService private readonly telemetryService: ITelemetryService, + @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService ) { super(); } - /** - * Create a browser view backed by the given {@link BrowserSession}. - */ - private createBrowserView(id: string, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { - if (this.browserViews.has(id)) { - throw new Error(`Browser view with id ${id} already exists`); - } - - const view = this.instantiationService.createInstance( - BrowserView, - id, - browserSession, - // Recursive factory for nested windows (child views share the same session) - (childOptions) => this.createBrowserView(generateUuid(), browserSession, childOptions), - options - ); - this.browserViews.set(id, view); - - this._onTargetCreated.fire(view); - Event.once(view.onDidClose)(() => { - this._onTargetDestroyed.fire(view); - this.browserViews.deleteAndDispose(id); - }); - - return view; - } - async getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise { if (this.browserViews.has(id)) { // Note: scope will be ignored if the view already exists. @@ -158,22 +139,15 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this.browserViews.values(); } - async createTarget(url: string, browserContextId?: string): Promise { - const targetId = generateUuid(); - const browserSession = browserContextId && BrowserSession.get(browserContextId) || BrowserSession.getOrCreateEphemeral(targetId); + async createTarget(url: string, browserContextId?: string, windowId?: number): Promise { + const browserSession = browserContextId ? BrowserSession.get(browserContextId) : undefined; - // Create the browser view (fires onTargetCreated) - const view = this.createBrowserView(targetId, browserSession); - - logBrowserOpen(this.telemetryService, 'cdpCreated'); - - // Request the workbench to open the editor - this.windowsMainService.sendToFocused('vscode:runAction', { - id: '_workbench.open', - args: [BrowserViewUri.forUrl(url, targetId), [undefined, { preserveFocus: true }], undefined] + return this.openNew(url, { + session: browserSession, + windowId, + editorOptions: { preserveFocus: true }, + source: 'cdpCreated' }); - - return view; } async activateTarget(target: ICDPTarget): Promise { @@ -366,4 +340,182 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa ); await browserSession.electronSession.clearData(); } + + async updateKeybindings(keybindings: { [commandId: string]: string }): Promise { + this._keybindings = keybindings; + } + + /** + * Create a browser view backed by the given {@link BrowserSession}. + */ + private createBrowserView(id: string, browserSession: BrowserSession, options?: Electron.WebContentsViewConstructorOptions): BrowserView { + if (this.browserViews.has(id)) { + throw new Error(`Browser view with id ${id} already exists`); + } + + const view = this.instantiationService.createInstance( + BrowserView, + id, + browserSession, + // Recursive factory for nested windows (child views share the same session) + (childOptions) => this.createBrowserView(generateUuid(), browserSession, childOptions), + (v, params) => this.showContextMenu(v, params), + options + ); + this.browserViews.set(id, view); + + this._onTargetCreated.fire(view); + Event.once(view.onDidClose)(() => { + this._onTargetDestroyed.fire(view); + this.browserViews.deleteAndDispose(id); + }); + + return view; + } + + private async openNew( + url: string, + { + session, + windowId, + editorOptions, + source + }: { + session: BrowserSession | undefined; + windowId: number | undefined; + editorOptions: ITextEditorOptions; + source: IntegratedBrowserOpenSource; + } + ): Promise { + const targetId = generateUuid(); + const view = this.createBrowserView(targetId, session || BrowserSession.getOrCreateEphemeral(targetId)); + + const window = windowId !== undefined ? this.windowsMainService.getWindowById(windowId) : this.windowsMainService.getFocusedWindow(); + if (!window) { + throw new Error(`Window ${windowId} not found`); + } + + + logBrowserOpen(this.telemetryService, source); + + // Request the workbench to open the editor + window.sendWhenReady('vscode:runAction', CancellationToken.None, { + id: '_workbench.open', + args: [BrowserViewUri.forUrl(url, targetId), [undefined, editorOptions], undefined] + }); + + return view; + } + + private showContextMenu(view: BrowserView, params: Electron.ContextMenuParams): void { + const win = view.getElectronWindow(); + if (!win) { + return; + } + const webContents = view.webContents; + if (webContents.isDestroyed()) { + return; + } + const menu = new Menu(); + + if (params.linkURL) { + menu.append(new MenuItem({ + label: localize('browser.contextMenu.openLinkInNewTab', 'Open Link in New Tab'), + click: () => { + void this.openNew(params.linkURL, { + session: view.session, + windowId: view.getTopCodeWindow()?.id, + editorOptions: { preserveFocus: true, inactive: true }, + source: 'browserLinkBackground' + }); + } + })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.openLinkInExternalBrowser', 'Open Link in External Browser'), + click: () => { void this.nativeHostMainService.openExternal(undefined, params.linkURL); } + })); + menu.append(new MenuItem({ type: 'separator' })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.copyLink', 'Copy Link'), + click: () => { + clipboard.write({ + text: params.linkURL, + html: `${htmlAttributeEncodeValue(params.linkText || params.linkURL)}` + }); + } + })); + } + + if (params.hasImageContents && params.srcURL) { + if (menu.items.length > 0) { + menu.append(new MenuItem({ type: 'separator' })); + } + menu.append(new MenuItem({ + label: localize('browser.contextMenu.openImageInNewTab', 'Open Image in New Tab'), + click: () => { + void this.openNew(params.srcURL!, { + session: view.session, + windowId: view.getTopCodeWindow()?.id, + editorOptions: { preserveFocus: true, inactive: true }, + source: 'browserLinkBackground' + }); + } + })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.copyImage', 'Copy Image'), + click: () => { view.webContents.copyImageAt(params.x, params.y); } + })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.copyImageUrl', 'Copy Image URL'), + click: () => { clipboard.writeText(params.srcURL!); } + })); + } + + if (params.isEditable) { + menu.append(new MenuItem({ role: 'cut', enabled: params.editFlags.canCut })); + menu.append(new MenuItem({ role: 'copy', enabled: params.editFlags.canCopy })); + menu.append(new MenuItem({ role: 'paste', enabled: params.editFlags.canPaste })); + menu.append(new MenuItem({ role: 'pasteAndMatchStyle', enabled: params.editFlags.canPaste })); + menu.append(new MenuItem({ role: 'selectAll', enabled: params.editFlags.canSelectAll })); + } else if (params.selectionText) { + menu.append(new MenuItem({ role: 'copy' })); + } + + // Add navigation items as defaults + if (menu.items.length === 0) { + if (webContents.navigationHistory.canGoBack()) { + menu.append(new MenuItem({ + label: localize('browser.contextMenu.back', 'Back'), + accelerator: this._keybindings[BrowserViewCommandId.GoBack], + click: () => webContents.navigationHistory.goBack() + })); + } + if (webContents.navigationHistory.canGoForward()) { + menu.append(new MenuItem({ + label: localize('browser.contextMenu.forward', 'Forward'), + accelerator: this._keybindings[BrowserViewCommandId.GoForward], + click: () => webContents.navigationHistory.goForward() + })); + } + menu.append(new MenuItem({ + label: localize('browser.contextMenu.reload', 'Reload'), + accelerator: this._keybindings[BrowserViewCommandId.Reload], + click: () => webContents.reload() + })); + } + + menu.append(new MenuItem({ type: 'separator' })); + menu.append(new MenuItem({ + label: localize('browser.contextMenu.inspect', 'Inspect'), + click: () => webContents.inspectElement(params.x, params.y) + })); + + const viewBounds = view.getWebContentsView().getBounds(); + menu.popup({ + window: win, + x: viewBounds.x + params.x, + y: viewBounds.y + params.y, + sourceType: params.menuSourceType + }); + } } diff --git a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts index b4aaffb612d..063a5b158b5 100644 --- a/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts +++ b/src/vs/platform/browserView/node/browserViewGroupRemoteService.ts @@ -6,11 +6,9 @@ import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { createDecorator } from '../../instantiation/common/instantiation.js'; import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; import { IBrowserViewGroup, IBrowserViewGroupService, IBrowserViewGroupViewEvent, ipcBrowserViewGroupChannelName } from '../common/browserViewGroup.js'; - -export const IBrowserViewGroupRemoteService = createDecorator('browserViewGroupRemoteService'); +import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; /** * Remote-process service for managing browser view groups. @@ -22,12 +20,11 @@ export const IBrowserViewGroupRemoteService = createDecorator; + createGroup(windowId: number): Promise; } /** @@ -66,8 +63,12 @@ class RemoteBrowserViewGroup extends Disposable implements IBrowserViewGroup { return this.groupService.removeViewFromGroup(this.id, viewId); } - async getDebugWebSocketEndpoint(): Promise { - return this.groupService.getDebugWebSocketEndpoint(this.id); + async sendCDPMessage(msg: CDPRequest): Promise { + return this.groupService.sendCDPMessage(this.id, msg); + } + + get onCDPMessage(): Event { + return this.groupService.onDynamicCDPMessage(this.id); } override dispose(fromService = false): void { @@ -79,20 +80,18 @@ class RemoteBrowserViewGroup extends Disposable implements IBrowserViewGroup { } export class BrowserViewGroupRemoteService implements IBrowserViewGroupRemoteService { - declare readonly _serviceBrand: undefined; - private readonly _groupService: IBrowserViewGroupService; private readonly _groups = new Map(); constructor( - @IMainProcessService mainProcessService: IMainProcessService, + mainProcessService: IMainProcessService, ) { const channel = mainProcessService.getChannel(ipcBrowserViewGroupChannelName); this._groupService = ProxyChannel.toService(channel); } - async createGroup(): Promise { - const id = await this._groupService.createGroup(); + async createGroup(windowId: number): Promise { + const id = await this._groupService.createGroup(windowId); return this._wrap(id); } diff --git a/src/vs/platform/browserView/node/playwrightChannel.ts b/src/vs/platform/browserView/node/playwrightChannel.ts new file mode 100644 index 00000000000..ca3e83c00a2 --- /dev/null +++ b/src/vs/platform/browserView/node/playwrightChannel.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; +import { ILogService } from '../../log/common/log.js'; +import { BrowserViewGroupRemoteService } from './browserViewGroupRemoteService.js'; +import { PlaywrightService } from './playwrightService.js'; + +/** + * IPC channel for the Playwright service. + * + * Each connected window gets its own {@link PlaywrightService}, + * keyed by the opaque IPC connection context. The client sends an + * `__initialize` call with its numeric window ID before any other + * method calls, which eagerly creates the instance. When a window + * disconnects the instance is automatically disposed. + */ +export class PlaywrightChannel extends Disposable implements IServerChannel { + + private readonly _instances = this._register(new DisposableMap()); + private readonly browserViewGroupRemoteService: BrowserViewGroupRemoteService; + + constructor( + ipcServer: IPCServer, + mainProcessService: IMainProcessService, + private readonly logService: ILogService, + ) { + super(); + this.browserViewGroupRemoteService = new BrowserViewGroupRemoteService(mainProcessService); + this._register(ipcServer.onDidRemoveConnection(c => { + this._instances.deleteAndDispose(c.ctx); + })); + } + + listen(ctx: string, event: string): Event { + const instance = this._instances.get(ctx); + if (!instance) { + throw new Error(`Window not initialized for context: ${ctx}`); + } + const source = (instance as unknown as Record>)[event]; + if (typeof source !== 'function') { + throw new Error(`Event not found: ${event}`); + } + return source as Event; + } + + call(ctx: string, command: string, arg?: unknown): Promise { + // Handle the one-time initialization call that creates the instance + if (command === '__initialize') { + if (typeof arg !== 'number') { + throw new Error(`Invalid argument for __initialize: expected window ID as number, got ${typeof arg}`); + } + if (!this._instances.has(ctx)) { + const windowId = arg as number; + this._instances.set(ctx, new PlaywrightService(windowId, this.browserViewGroupRemoteService, this.logService)); + } + return Promise.resolve(undefined as T); + } + + const instance = this._instances.get(ctx); + if (!instance) { + throw new Error(`Window not initialized for context: ${ctx}`); + } + + const target = (instance as unknown as Record)[command]; + if (typeof target !== 'function') { + throw new Error(`Method not found: ${command}`); + } + + const methodArgs = Array.isArray(arg) ? arg : []; + let res = target.apply(instance, methodArgs); + if (!(res instanceof Promise)) { + res = Promise.resolve(res); + } + return res; + } +} diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 0a55710ec61..8abf560a5c3 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -11,10 +11,24 @@ import { IPlaywrightService } from '../common/playwrightService.js'; import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; import { IBrowserViewGroup } from '../common/browserViewGroup.js'; import { PlaywrightTab } from './playwrightTab.js'; +import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; // eslint-disable-next-line local/code-import-patterns import type { Browser, BrowserContext, Page } from 'playwright-core'; +interface PlaywrightTransport { + send(s: CDPRequest): void; + close(): void; // Note: calling close is expected to issue onclose at some point. + onmessage?: (message: CDPResponse | CDPEvent) => void; + onclose?: (reason?: string) => void; +} + +declare module 'playwright-core' { + interface BrowserType { + _connectOverCDPTransport(transport: PlaywrightTransport): Promise; + } +} + /** * Shared-process implementation of {@link IPlaywrightService}. * @@ -32,8 +46,9 @@ export class PlaywrightService extends Disposable implements IPlaywrightService private _initPromise: Promise | undefined; constructor( - @IBrowserViewGroupRemoteService private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, - @ILogService private readonly logService: ILogService, + private readonly windowId: number, + private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, + private readonly logService: ILogService, ) { super(); this._pages = this._register(new PlaywrightPageManager(logService)); @@ -76,12 +91,21 @@ export class PlaywrightService extends Disposable implements IPlaywrightService this._initPromise = (async () => { try { this.logService.debug('[PlaywrightService] Creating browser view group'); - const group = await this.browserViewGroupRemoteService.createGroup(); + const group = await this.browserViewGroupRemoteService.createGroup(this.windowId); this.logService.debug('[PlaywrightService] Connecting to browser via CDP'); const playwright = await import('playwright-core'); - const endpoint = await group.getDebugWebSocketEndpoint(); - const browser = await playwright.chromium.connectOverCDP(endpoint); + const sub = group.onCDPMessage(msg => transport.onmessage?.(msg)); + const transport: PlaywrightTransport = { + close() { + sub.dispose(); + this.onclose?.(); + }, + send(message) { + void group.sendCDPMessage(message); + } + }; + const browser = await playwright.chromium._connectOverCDPTransport(transport); this.logService.debug('[PlaywrightService] Connected to browser'); diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index f1477768f39..d0751cb4bc9 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -334,6 +334,16 @@ export interface IModalEditorPartOptions { */ readonly maximized?: boolean; + /** + * Size of the modal editor part unless it is maximized. + */ + readonly size?: { readonly width: number; readonly height: number }; + + /** + * Position of the modal editor part unless it is maximized. + */ + readonly position?: { readonly left: number; readonly top: number }; + /** * The navigation context for navigating between items * within this modal editor. Pass `undefined` to clear. diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index fb796867b8b..00e09a016ac 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -45,7 +45,7 @@ const _allApiProposals = { }, chatDebug: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatDebug.d.ts', - version: 2 + version: 3 }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', @@ -60,7 +60,7 @@ const _allApiProposals = { }, chatParticipantPrivate: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts', - version: 14 + version: 15 }, chatPromptFiles: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts', diff --git a/src/vs/platform/hover/browser/hover.css b/src/vs/platform/hover/browser/hover.css index 597738d3069..9a6b49d73fe 100644 --- a/src/vs/platform/hover/browser/hover.css +++ b/src/vs/platform/hover/browser/hover.css @@ -17,7 +17,11 @@ border: 1px solid var(--vscode-editorHoverWidget-border); border-radius: 5px; color: var(--vscode-editorHoverWidget-foreground); - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-hover); +} + +.monaco-hover.workbench-hover.with-pointer { + border-radius: 3px; } .monaco-hover.workbench-hover .monaco-action-bar .action-item .codicon { diff --git a/src/vs/platform/hover/browser/hoverService.ts b/src/vs/platform/hover/browser/hoverService.ts index 6779e7e1c5e..cfb53e2e686 100644 --- a/src/vs/platform/hover/browser/hoverService.ts +++ b/src/vs/platform/hover/browser/hoverService.ts @@ -248,6 +248,7 @@ export class HoverService extends Disposable implements IHoverService { } private _createHover(options: IHoverOptions, skipLastFocusedUpdate?: boolean): ICreateHoverResult | undefined { + this._currentDelayedHover?.dispose(); this._currentDelayedHover = undefined; if (options.content === '') { diff --git a/src/vs/platform/hover/browser/hoverWidget.ts b/src/vs/platform/hover/browser/hoverWidget.ts index 41c8723608a..28af860840d 100644 --- a/src/vs/platform/hover/browser/hoverWidget.ts +++ b/src/vs/platform/hover/browser/hoverWidget.ts @@ -138,6 +138,9 @@ export class HoverWidget extends Widget implements IHoverWidget { if (options.appearance?.compact) { this._hover.containerDomNode.classList.add('workbench-hover', 'compact'); } + if (this._hoverPointer) { + this._hover.containerDomNode.classList.add('with-pointer'); + } if (options.additionalClasses) { this._hover.containerDomNode.classList.add(...options.additionalClasses); } diff --git a/src/vs/platform/instantiation/common/extensions.ts b/src/vs/platform/instantiation/common/extensions.ts index 517a8cc2a3a..e59cc837cc6 100644 --- a/src/vs/platform/instantiation/common/extensions.ts +++ b/src/vs/platform/instantiation/common/extensions.ts @@ -26,7 +26,7 @@ export function registerSingleton(id: Serv export function registerSingleton(id: ServiceIdentifier, descriptor: SyncDescriptor): void; export function registerSingleton(id: ServiceIdentifier, ctorOrDescriptor: { new(...services: Services): T } | SyncDescriptor, supportsDelayedInstantiation?: boolean | InstantiationType): void { if (!(ctorOrDescriptor instanceof SyncDescriptor)) { - ctorOrDescriptor = new SyncDescriptor(ctorOrDescriptor as new (...args: any[]) => T, [], Boolean(supportsDelayedInstantiation)); + ctorOrDescriptor = new SyncDescriptor(ctorOrDescriptor as new (...args: unknown[]) => T, [], Boolean(supportsDelayedInstantiation)); } _registry.push([id, ctorOrDescriptor]); diff --git a/src/vs/platform/mcp/common/mcpManagement.ts b/src/vs/platform/mcp/common/mcpManagement.ts index 9c2b7e73d90..834068a98b6 100644 --- a/src/vs/platform/mcp/common/mcpManagement.ts +++ b/src/vs/platform/mcp/common/mcpManagement.ts @@ -10,13 +10,14 @@ import { IIterativePager } from '../../../base/common/paging.js'; import { URI } from '../../../base/common/uri.js'; import { SortBy, SortOrder } from '../../extensionManagement/common/extensionManagement.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import { IMcpServerConfiguration, IMcpServerVariable } from './mcpPlatformTypes.js'; +import { IMcpSandboxConfiguration, IMcpServerConfiguration, IMcpServerVariable } from './mcpPlatformTypes.js'; export type InstallSource = 'gallery' | 'local'; export interface ILocalMcpServer { readonly name: string; readonly config: IMcpServerConfiguration; + readonly rootSandbox?: IMcpSandboxConfiguration; readonly version?: string; readonly mcpResource: URI; readonly location?: URI; diff --git a/src/vs/platform/mcp/common/mcpManagementService.ts b/src/vs/platform/mcp/common/mcpManagementService.ts index 5f72d29a8fe..ec10b0f0ea9 100644 --- a/src/vs/platform/mcp/common/mcpManagementService.ts +++ b/src/vs/platform/mcp/common/mcpManagementService.ts @@ -22,7 +22,7 @@ import { ILogService } from '../../log/common/log.js'; import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; import { IUserDataProfilesService } from '../../userDataProfile/common/userDataProfile.js'; import { DidUninstallMcpServerEvent, IGalleryMcpServer, ILocalMcpServer, IMcpGalleryService, IMcpManagementService, IMcpServerInput, IGalleryMcpServerConfiguration, InstallMcpServerEvent, InstallMcpServerResult, RegistryType, UninstallMcpServerEvent, InstallOptions, UninstallOptions, IInstallableMcpServer, IAllowedMcpServersService, IMcpServerArgument, IMcpServerKeyValueInput, McpServerConfigurationParseResult } from './mcpManagement.js'; -import { IMcpServerVariable, McpServerVariableType, IMcpServerConfiguration, McpServerType } from './mcpPlatformTypes.js'; +import { IMcpSandboxConfiguration, IMcpServerVariable, McpServerVariableType, IMcpServerConfiguration, McpServerType } from './mcpPlatformTypes.js'; import { IMcpResourceScannerService, McpResourceTarget } from './mcpResourceScannerService.js'; export interface ILocalMcpServerInfo { @@ -358,7 +358,7 @@ export abstract class AbstractMcpResourceManagementService extends AbstractCommo const scannedMcpServers = await this.mcpResourceScannerService.scanMcpServers(this.mcpResource, this.target); if (scannedMcpServers.servers) { await Promise.allSettled(Object.entries(scannedMcpServers.servers).map(async ([name, scannedServer]) => { - const server = await this.scanLocalServer(name, scannedServer); + const server = await this.scanLocalServer(name, scannedServer, scannedMcpServers.sandbox); local.set(name, server); })); } @@ -426,7 +426,7 @@ export abstract class AbstractMcpResourceManagementService extends AbstractCommo return Array.from(this.local.values()); } - protected async scanLocalServer(name: string, config: IMcpServerConfiguration): Promise { + protected async scanLocalServer(name: string, config: IMcpServerConfiguration, rootSandbox?: IMcpSandboxConfiguration): Promise { let mcpServerInfo = await this.getLocalServerInfo(name, config); if (!mcpServerInfo) { mcpServerInfo = { name, version: config.version, galleryUrl: isString(config.gallery) ? config.gallery : undefined }; @@ -435,6 +435,7 @@ export abstract class AbstractMcpResourceManagementService extends AbstractCommo return { name, config, + rootSandbox, mcpResource: this.mcpResource, version: mcpServerInfo.version, location: mcpServerInfo.location, diff --git a/src/vs/platform/mcp/common/mcpPlatformTypes.ts b/src/vs/platform/mcp/common/mcpPlatformTypes.ts index 985d17f1dc7..dc4fb38172e 100644 --- a/src/vs/platform/mcp/common/mcpPlatformTypes.ts +++ b/src/vs/platform/mcp/common/mcpPlatformTypes.ts @@ -58,7 +58,6 @@ export interface IMcpStdioServerConfiguration extends ICommonMcpServerConfigurat readonly envFile?: string; readonly cwd?: string; readonly sandboxEnabled?: boolean; - readonly sandbox?: IMcpSandboxConfiguration; readonly dev?: IMcpDevModeConfig; } diff --git a/src/vs/platform/mcp/common/mcpResourceScannerService.ts b/src/vs/platform/mcp/common/mcpResourceScannerService.ts index e0c67bb8b83..151238228b5 100644 --- a/src/vs/platform/mcp/common/mcpResourceScannerService.ts +++ b/src/vs/platform/mcp/common/mcpResourceScannerService.ts @@ -188,7 +188,7 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc if (servers.length > 0) { userMcpServers.servers = {}; for (const [serverName, server] of servers) { - userMcpServers.servers[serverName] = this.sanitizeServer(server, scannedMcpServers.sandbox); + userMcpServers.servers[serverName] = this.sanitizeServer(server); } } return userMcpServers; @@ -203,14 +203,14 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc if (servers.length > 0) { scannedMcpServers.servers = {}; for (const [serverName, config] of servers) { - const serverConfig = this.sanitizeServer(config, scannedMcpServers.sandbox); + const serverConfig = this.sanitizeServer(config); scannedMcpServers.servers[serverName] = serverConfig; } } return scannedMcpServers; } - private sanitizeServer(serverOrConfig: IOldScannedMcpServer | Mutable, sandbox?: IMcpSandboxConfiguration): IMcpServerConfiguration { + private sanitizeServer(serverOrConfig: IOldScannedMcpServer | Mutable): IMcpServerConfiguration { let server: IMcpServerConfiguration; if ((serverOrConfig).config) { const oldScannedMcpServer = serverOrConfig; @@ -226,11 +226,6 @@ export class McpResourceScannerService extends Disposable implements IMcpResourc if (server.type === undefined || (server.type !== McpServerType.REMOTE && server.type !== McpServerType.LOCAL)) { (>server).type = (server).command ? McpServerType.LOCAL : McpServerType.REMOTE; } - - if (sandbox && server.type === McpServerType.LOCAL) { - (>server).sandbox = sandbox; - } - return server; } diff --git a/src/vs/platform/mcp/common/modelContextProtocolApps.ts b/src/vs/platform/mcp/common/modelContextProtocolApps.ts index 4569e8f25ac..86b891514e2 100644 --- a/src/vs/platform/mcp/common/modelContextProtocolApps.ts +++ b/src/vs/platform/mcp/common/modelContextProtocolApps.ts @@ -17,6 +17,7 @@ export namespace McpApps { | MCP.ReadResourceRequest | MCP.PingRequest | (McpUiOpenLinkRequest & MCP.JSONRPCRequest) + | (McpUiDownloadFileRequest & MCP.JSONRPCRequest) | (McpUiUpdateModelContextRequest & MCP.JSONRPCRequest) | (McpUiMessageRequest & MCP.JSONRPCRequest) | (McpUiRequestDisplayModeRequest & MCP.JSONRPCRequest) @@ -37,6 +38,7 @@ export namespace McpApps { | McpApps.McpUiInitializeResult | McpUiMessageResult | McpUiOpenLinkResult + | McpUiDownloadFileResult | McpUiRequestDisplayModeResult; export type HostNotification = @@ -223,6 +225,33 @@ export namespace McpApps { [key: string]: unknown; } + /** + * @description Request to download one or more files through the host. + * Uses standard MCP resource types: EmbeddedResource for inline content + * and ResourceLink for references the host resolves via resources/read. + */ + export interface McpUiDownloadFileRequest { + method: "ui/download-file"; + params: { + /** @description Resources to download, either inline or as links for the host to resolve. */ + contents: (MCP.EmbeddedResource | MCP.ResourceLink)[]; + }; + } + + /** + * @description Result from a download file request. + * @see {@link McpUiDownloadFileRequest} + */ + export interface McpUiDownloadFileResult { + /** @description True if the host rejected or failed to process the download. */ + isError?: boolean; + /** + * Index signature required for MCP SDK `Protocol` class compatibility. + * Note: The schema intentionally omits this to enforce strict validation. + */ + [key: string]: unknown; + } + /** * @description Request to send a message to the host's chat interface. * @see {@link app.App.sendMessage} for the method that sends this request @@ -528,6 +557,8 @@ export namespace McpApps { updateModelContext?: McpUiSupportedContentBlockModalities; /** @description Host supports receiving content messages (ui/message) from the View. */ message?: McpUiSupportedContentBlockModalities; + /** @description Host supports file downloads (ui/download-file) from the View. */ + downloadFile?: {}; } /** @@ -734,4 +765,6 @@ export namespace McpApps { "ui/request-display-mode"; export const UPDATE_MODEL_CONTEXT_METHOD: McpUiUpdateModelContextRequest["method"] = "ui/update-model-context"; + export const DOWNLOAD_FILE_METHOD: McpUiDownloadFileRequest["method"] = + "ui/download-file"; } diff --git a/src/vs/platform/mcp/node/mcpGatewayChannel.ts b/src/vs/platform/mcp/node/mcpGatewayChannel.ts index 0b0ce1edb0a..78d0e93a3f0 100644 --- a/src/vs/platform/mcp/node/mcpGatewayChannel.ts +++ b/src/vs/platform/mcp/node/mcpGatewayChannel.ts @@ -6,6 +6,7 @@ import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { ILoggerService } from '../../log/common/log.js'; import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates, IMcpGatewayService, McpGatewayToolBrokerChannelName } from '../common/mcpGateway.js'; import { MCP } from '../common/modelContextProtocol.js'; @@ -19,10 +20,14 @@ export class McpGatewayChannel extends Disposable implements IServerCh constructor( private readonly _ipcServer: IPCServer, - @IMcpGatewayService private readonly mcpGatewayService: IMcpGatewayService + @IMcpGatewayService private readonly mcpGatewayService: IMcpGatewayService, + @ILoggerService private readonly _loggerService: ILoggerService, ) { super(); - this._register(_ipcServer.onDidRemoveConnection(c => mcpGatewayService.disposeGatewaysForClient(c.ctx))); + this._register(_ipcServer.onDidRemoveConnection(c => { + this._loggerService.getLogger('mcpGateway')?.info(`[McpGateway][Channel] Client disconnected: ${c.ctx}, cleaning up gateways`); + mcpGatewayService.disposeGatewaysForClient(c.ctx); + })); } listen(_ctx: TContext, _event: string): Event { @@ -30,6 +35,9 @@ export class McpGatewayChannel extends Disposable implements IServerCh } async call(ctx: TContext, command: string, args?: unknown): Promise { + const logger = this._loggerService.getLogger('mcpGateway'); + logger?.debug(`[McpGateway][Channel] IPC call: ${command} from client ${ctx}`); + switch (command) { case 'createGateway': { const brokerChannel = ipcChannelForContext(this._ipcServer, ctx); @@ -42,9 +50,11 @@ export class McpGatewayChannel extends Disposable implements IServerCh readResource: (serverIndex, uri) => brokerChannel.call('readResource', { serverIndex, uri }), listResourceTemplates: () => brokerChannel.call('listResourceTemplates'), }); + logger?.info(`[McpGateway][Channel] Gateway created: ${result.gatewayId} for client ${ctx}`); return result as T; } case 'disposeGateway': { + logger?.info(`[McpGateway][Channel] Disposing gateway: ${args as string} for client ${ctx}`); await this.mcpGatewayService.disposeGateway(args as string); return undefined as T; } diff --git a/src/vs/platform/mcp/node/mcpGatewayService.ts b/src/vs/platform/mcp/node/mcpGatewayService.ts index 8225b3fffe8..6131c7677da 100644 --- a/src/vs/platform/mcp/node/mcpGatewayService.ts +++ b/src/vs/platform/mcp/node/mcpGatewayService.ts @@ -9,7 +9,7 @@ import { JsonRpcMessage, JsonRpcProtocol } from '../../../base/common/jsonRpcPro import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { ILogService } from '../../log/common/log.js'; +import { ILogger, ILoggerService } from '../../log/common/log.js'; import { IMcpGatewayInfo, IMcpGatewayService, IMcpGatewayToolInvoker } from '../common/mcpGateway.js'; import { isInitializeMessage, McpGatewaySession } from './mcpGatewaySession.js'; @@ -28,11 +28,14 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService /** Maps gatewayId to clientId for tracking ownership */ private readonly _gatewayToClient = new Map(); private _serverStartPromise: Promise | undefined; + private readonly _logger: ILogger; constructor( - @ILogService private readonly _logService: ILogService, + @ILoggerService loggerService: ILoggerService, ) { super(); + this._logger = this._register(loggerService.createLogger('mcpGateway', { name: 'MCP Gateway', logLevel: 'always' })); + this._logger.info('[McpGatewayService] Initialized'); } async createGateway(clientId: unknown, toolInvoker?: IMcpGatewayToolInvoker): Promise { @@ -51,15 +54,16 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService throw new Error('[McpGatewayService] Tool invoker is required to create gateway'); } - const gateway = new McpGatewayRoute(gatewayId, this._logService, toolInvoker); + const gateway = new McpGatewayRoute(gatewayId, this._logger, toolInvoker); this._gateways.set(gatewayId, gateway); + this._logger.info(`[McpGatewayService] Active gateways: ${this._gateways.size}`); // Track client ownership if clientId provided (for cleanup on disconnect) if (clientId) { this._gatewayToClient.set(gatewayId, clientId); - this._logService.info(`[McpGatewayService] Created gateway at http://127.0.0.1:${this._port}/gateway/${gatewayId} for client ${clientId}`); + this._logger.info(`[McpGatewayService] Created gateway at http://127.0.0.1:${this._port}/gateway/${gatewayId} for client ${clientId}`); } else { - this._logService.warn(`[McpGatewayService] Created gateway without client tracking at http://127.0.0.1:${this._port}/gateway/${gatewayId}`); + this._logger.warn(`[McpGatewayService] Created gateway without client tracking at http://127.0.0.1:${this._port}/gateway/${gatewayId}`); } const address = URI.parse(`http://127.0.0.1:${this._port}/gateway/${gatewayId}`); @@ -73,14 +77,14 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService async disposeGateway(gatewayId: string): Promise { const gateway = this._gateways.get(gatewayId); if (!gateway) { - this._logService.warn(`[McpGatewayService] Attempted to dispose unknown gateway: ${gatewayId}`); + this._logger.warn(`[McpGatewayService] Attempted to dispose unknown gateway: ${gatewayId}`); return; } gateway.dispose(); this._gateways.delete(gatewayId); this._gatewayToClient.delete(gatewayId); - this._logService.info(`[McpGatewayService] Disposed gateway: ${gatewayId}`); + this._logger.info(`[McpGatewayService] Disposed gateway: ${gatewayId} (remaining: ${this._gateways.size})`); // If no more gateways, shut down the server if (this._gateways.size === 0) { @@ -98,7 +102,7 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService } if (gatewaysToDispose.length > 0) { - this._logService.info(`[McpGatewayService] Disposing ${gatewaysToDispose.length} gateway(s) for disconnected client ${clientId}`); + this._logger.info(`[McpGatewayService] Disposing ${gatewaysToDispose.length} gateway(s) for disconnected client ${clientId}: [${gatewaysToDispose.join(', ')}]`); for (const gatewayId of gatewaysToDispose) { this._gateways.get(gatewayId)?.dispose(); @@ -156,19 +160,19 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService } clearTimeout(portTimeout); - this._logService.info(`[McpGatewayService] Server started on port ${this._port}`); + this._logger.info(`[McpGatewayService] Server started on port ${this._port}`); deferredPromise.complete(); }); this._server.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { - this._logService.warn('[McpGatewayService] Port in use, retrying with random port...'); + this._logger.warn('[McpGatewayService] Port in use, retrying with random port...'); // Try with a random port this._server!.listen(0, '127.0.0.1'); return; } clearTimeout(portTimeout); - this._logService.error(`[McpGatewayService] Server error: ${err}`); + this._logger.error(`[McpGatewayService] Server error: ${err}`); deferredPromise.error(err); }); @@ -183,13 +187,13 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService return; } - this._logService.info('[McpGatewayService] Stopping server (no more gateways)'); + this._logger.info('[McpGatewayService] Stopping server (no more gateways)'); this._server.close(err => { if (err) { - this._logService.error(`[McpGatewayService] Error closing server: ${err}`); + this._logger.error(`[McpGatewayService] Error closing server: ${err}`); } else { - this._logService.info('[McpGatewayService] Server stopped'); + this._logger.info('[McpGatewayService] Server stopped'); } }); @@ -201,6 +205,8 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService const url = new URL(req.url!, `http://${req.headers.host}`); const pathParts = url.pathname.split('/').filter(Boolean); + this._logger.debug(`[McpGatewayService] ${req.method} ${url.pathname} (active gateways: ${this._gateways.size})`); + // Expected path: /gateway/{gatewayId} if (pathParts.length >= 2 && pathParts[0] === 'gateway') { const gatewayId = pathParts[1]; @@ -213,11 +219,13 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService } // Not found + this._logger.warn(`[McpGatewayService] ${req.method} ${url.pathname}: gateway not found`); res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Gateway not found' })); } override dispose(): void { + this._logger.info(`[McpGatewayService] Disposing service (gateways: ${this._gateways.size})`); this._stopServer(); for (const gateway of this._gateways.values()) { gateway.dispose(); @@ -237,13 +245,15 @@ class McpGatewayRoute extends Disposable { constructor( public readonly gatewayId: string, - private readonly _logService: ILogService, + private readonly _logger: ILogger, private readonly _toolInvoker: IMcpGatewayToolInvoker, ) { super(); } handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + this._logger.debug(`[McpGateway][route ${this.gatewayId}] ${req.method} request (sessions: ${this._sessions.size})`); + if (req.method === 'POST') { void this._handlePost(req, res); return; @@ -263,6 +273,7 @@ class McpGatewayRoute extends Disposable { } public override dispose(): void { + this._logger.info(`[McpGateway][route ${this.gatewayId}] Disposing route (sessions: ${this._sessions.size})`); for (const session of this._sessions.values()) { session.dispose(); } @@ -283,6 +294,7 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.info(`[McpGateway][route ${this.gatewayId}] Deleting session ${sessionId}`); session.dispose(); this._sessions.delete(sessionId); res.writeHead(204); @@ -302,6 +314,7 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.info(`[McpGateway][route ${this.gatewayId}] SSE connection requested for session ${sessionId}`); session.attachSseClient(req, res); } @@ -312,10 +325,13 @@ class McpGatewayRoute extends Disposable { return; } + this._logger.debug(`[McpGateway][route ${this.gatewayId}] Handling POST`); + let message: JsonRpcMessage | JsonRpcMessage[]; try { message = JSON.parse(body) as JsonRpcMessage | JsonRpcMessage[]; } catch (error) { + this._logger.warn(`[McpGateway][route ${this.gatewayId}] JSON parse error: ${error instanceof Error ? error.message : String(error)}`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(JsonRpcProtocol.createParseError('Parse error', error instanceof Error ? error.message : String(error)))); return; @@ -336,15 +352,18 @@ class McpGatewayRoute extends Disposable { }; if (responses.length === 0) { + this._logger.debug(`[McpGateway][route ${this.gatewayId}] POST response: 202 (no content)`); res.writeHead(202, headers); res.end(); return; } + const responseBody = JSON.stringify(Array.isArray(message) ? responses : responses[0]); + this._logger.debug(`[McpGateway][route ${this.gatewayId}] POST response: 200, body: ${responseBody}`); res.writeHead(200, headers); - res.end(JSON.stringify(Array.isArray(message) ? responses : responses[0])); + res.end(responseBody); } catch (error) { - this._logService.error('[McpGatewayService] Failed handling gateway request', error); + this._logger.error('[McpGatewayService] Failed handling gateway request', error); this._respondHttpError(res, 500, 'Internal server error'); } } @@ -353,6 +372,7 @@ class McpGatewayRoute extends Disposable { if (headerSessionId) { const existing = this._sessions.get(headerSessionId); if (!existing) { + this._logger.warn(`[McpGateway][route ${this.gatewayId}] Session not found: ${headerSessionId}`); this._respondHttpError(res, 404, 'Session not found'); return undefined; } @@ -366,7 +386,8 @@ class McpGatewayRoute extends Disposable { } const sessionId = generateUuid(); - const session = new McpGatewaySession(sessionId, this._logService, () => { + this._logger.info(`[McpGateway][route ${this.gatewayId}] Creating new session ${sessionId}`); + const session = new McpGatewaySession(sessionId, this._logger, () => { this._sessions.delete(sessionId); }, this._toolInvoker); this._sessions.set(sessionId, session); @@ -374,6 +395,7 @@ class McpGatewayRoute extends Disposable { } private _respondHttpError(res: http.ServerResponse, statusCode: number, error: string): void { + this._logger.debug(`[McpGateway][route ${this.gatewayId}] HTTP error response: ${statusCode} ${error}`); res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: statusCode, message: error } } satisfies JsonRpcMessage)); } diff --git a/src/vs/platform/mcp/node/mcpGatewaySession.ts b/src/vs/platform/mcp/node/mcpGatewaySession.ts index 836d6571e3b..20f6d23dc71 100644 --- a/src/vs/platform/mcp/node/mcpGatewaySession.ts +++ b/src/vs/platform/mcp/node/mcpGatewaySession.ts @@ -6,15 +6,22 @@ import type * as http from 'http'; import { IJsonRpcNotification, IJsonRpcRequest, - isJsonRpcNotification, isJsonRpcResponse, JsonRpcError, JsonRpcMessage, JsonRpcProtocol + isJsonRpcNotification, isJsonRpcResponse, JsonRpcError, JsonRpcMessage, JsonRpcProtocol, JsonRpcResponse } from '../../../base/common/jsonRpcProtocol.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { hasKey } from '../../../base/common/types.js'; -import { ILogService } from '../../log/common/log.js'; +import { ILogger } from '../../log/common/log.js'; import { IMcpGatewayToolInvoker } from '../common/mcpGateway.js'; import { MCP } from '../common/modelContextProtocol.js'; const MCP_LATEST_PROTOCOL_VERSION = '2025-11-25'; +const MCP_SUPPORTED_PROTOCOL_VERSIONS = [ + '2025-11-25', + '2025-06-18', + '2025-03-26', + '2024-11-05', + '2024-10-07', +]; const MCP_INVALID_REQUEST = -32600; const MCP_METHOD_NOT_FOUND = -32601; const MCP_INVALID_PARAMS = -32602; @@ -72,14 +79,12 @@ function encodeResourceUrisInContent(content: MCP.ContentBlock[], serverIndex: n export class McpGatewaySession extends Disposable { private readonly _rpc: JsonRpcProtocol; private readonly _sseClients = new Set(); - private readonly _pendingResponses: JsonRpcMessage[] = []; - private _isCollectingPostResponses = false; private _lastEventId = 0; private _isInitialized = false; constructor( public readonly id: string, - private readonly _logService: ILogService, + private readonly _logService: ILogger, private readonly _onDidDispose: () => void, private readonly _toolInvoker: IMcpGatewayToolInvoker, ) { @@ -98,6 +103,7 @@ export class McpGatewaySession extends Disposable { return; } + this._logService.info(`[McpGateway][session ${this.id}] Tools changed, notifying client`); this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); })); @@ -106,6 +112,7 @@ export class McpGatewaySession extends Disposable { return; } + this._logService.info(`[McpGateway][session ${this.id}] Resources changed, notifying client`); this._rpc.sendNotification({ method: 'notifications/resources/list_changed' }); })); } @@ -119,25 +126,20 @@ export class McpGatewaySession extends Disposable { res.write(': connected\n\n'); this._sseClients.add(res); + this._logService.info(`[McpGateway][session ${this.id}] SSE client attached (total: ${this._sseClients.size})`); res.on('close', () => { this._sseClients.delete(res); + this._logService.info(`[McpGateway][session ${this.id}] SSE client detached (total: ${this._sseClients.size})`); }); } - public async handleIncoming(message: JsonRpcMessage | JsonRpcMessage[]): Promise { - this._pendingResponses.length = 0; - this._isCollectingPostResponses = true; - try { - await this._rpc.handleMessage(message); - return [...this._pendingResponses]; - } finally { - this._isCollectingPostResponses = false; - this._pendingResponses.length = 0; - } + public async handleIncoming(message: JsonRpcMessage | JsonRpcMessage[]): Promise { + return this._rpc.handleMessage(message); } public override dispose(): void { + this._logService.info(`[McpGateway][session ${this.id}] Disposing session (SSE clients: ${this._sseClients.size})`); for (const client of this._sseClients) { if (!client.destroyed) { client.end(); @@ -150,13 +152,12 @@ export class McpGatewaySession extends Disposable { private _handleOutgoingMessage(message: JsonRpcMessage): void { if (isJsonRpcResponse(message)) { - if (this._isCollectingPostResponses) { - this._pendingResponses.push(message); - } + this._logService.debug(`[McpGateway][session ${this.id}] --> response: ${JSON.stringify(message)}`); return; } if (isJsonRpcNotification(message)) { + this._logService.debug(`[McpGateway][session ${this.id}] --> notification: ${(message as IJsonRpcNotification).method}`); this._broadcastSse(message); return; } @@ -166,11 +167,13 @@ export class McpGatewaySession extends Disposable { private _broadcastSse(message: JsonRpcMessage): void { if (this._sseClients.size === 0) { + this._logService.debug(`[McpGateway][session ${this.id}] No SSE clients to broadcast to, dropping message`); return; } const payload = JSON.stringify(message); const eventId = String(++this._lastEventId); + this._logService.debug(`[McpGateway][session ${this.id}] Broadcasting SSE event id=${eventId} to ${this._sseClients.size}`); const lines = payload.split(/\r?\n/g); const data = [ `id: ${eventId}`, @@ -191,11 +194,14 @@ export class McpGatewaySession extends Disposable { } private async _handleRequest(request: IJsonRpcRequest): Promise { + this._logService.debug(`[McpGateway][session ${this.id}] <-- request: ${request.method} (id=${String(request.id)})`); + if (request.method === 'initialize') { - return this._handleInitialize(); + return this._handleInitialize(request); } if (!this._isInitialized) { + this._logService.warn(`[McpGateway][session ${this.id}] Rejected request '${request.method}': session not initialized`); throw new JsonRpcError(MCP_INVALID_REQUEST, 'Session is not initialized'); } @@ -213,21 +219,37 @@ export class McpGatewaySession extends Disposable { case 'resources/templates/list': return this._handleListResourceTemplates(); default: + this._logService.warn(`[McpGateway][session ${this.id}] Unknown method: ${request.method}`); throw new JsonRpcError(MCP_METHOD_NOT_FOUND, `Method not found: ${request.method}`); } } private _handleNotification(notification: IJsonRpcNotification): void { + this._logService.debug(`[McpGateway][session ${this.id}] <-- notification: ${notification.method}`); + if (notification.method === 'notifications/initialized') { this._isInitialized = true; + this._logService.info(`[McpGateway][session ${this.id}] Session initialized`); this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); this._rpc.sendNotification({ method: 'notifications/resources/list_changed' }); } } - private _handleInitialize(): MCP.InitializeResult { + private _handleInitialize(request: IJsonRpcRequest): MCP.InitializeResult { + const params = typeof request.params === 'object' && request.params ? request.params as Record : undefined; + const clientVersion = typeof params?.protocolVersion === 'string' ? params.protocolVersion : undefined; + const clientInfo = params?.clientInfo as { name?: string; version?: string } | undefined; + const negotiatedVersion = clientVersion && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion) + ? clientVersion + : MCP_LATEST_PROTOCOL_VERSION; + + this._logService.info(`[McpGateway] Initialize: client=${clientInfo?.name ?? 'unknown'}/${clientInfo?.version ?? '?'}, clientProtocol=${clientVersion ?? '(none)'}, negotiated=${negotiatedVersion}`); + if (clientVersion && clientVersion !== negotiatedVersion) { + this._logService.warn(`[McpGateway] Client requested unsupported protocol version '${clientVersion}', falling back to '${negotiatedVersion}'`); + } + return { - protocolVersion: MCP_LATEST_PROTOCOL_VERSION, + protocolVersion: negotiatedVersion, capabilities: { tools: { listChanged: true, @@ -257,21 +279,27 @@ export class McpGatewaySession extends Disposable { ? params.arguments as Record : {}; + this._logService.debug(`[McpGateway][session ${this.id}] Calling tool '${params.name}' with args: ${JSON.stringify(argumentsValue)}`); + try { const { result, serverIndex } = await this._toolInvoker.callTool(params.name, argumentsValue); + this._logService.debug(`[McpGateway][session ${this.id}] Tool '${params.name}' completed (isError=${result.isError ?? false}, content blocks=${result.content.length})`); return { ...result, content: encodeResourceUrisInContent(result.content, serverIndex), }; } catch (error) { - this._logService.error('[McpGatewayService] Tool call invocation failed', error); + this._logService.error(`[McpGateway][session ${this.id}] Tool '${params.name}' invocation failed`, error); throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); } } private _handleListTools(): unknown { return this._toolInvoker.listTools() - .then(tools => ({ tools })); + .then(tools => { + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${tools.length} tool(s): [${tools.map(t => t.name).join(', ')}]`); + return { tools }; + }); } private async _handleListResources(): Promise { @@ -285,6 +313,7 @@ export class McpGatewaySession extends Disposable { }); } } + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${allResources.length} resource(s) from ${serverResults.length} server(s)`); return { resources: allResources }; } @@ -295,8 +324,10 @@ export class McpGatewaySession extends Disposable { } const { serverIndex, originalUri } = decodeGatewayResourceUri(params.uri); + this._logService.debug(`[McpGateway][session ${this.id}] Reading resource '${originalUri}' from server ${serverIndex}`); try { const result = await this._toolInvoker.readResource(serverIndex, originalUri); + this._logService.debug(`[McpGateway][session ${this.id}] Resource read returned ${result.contents.length} content(s)`); return { contents: result.contents.map(content => ({ ...content, @@ -304,7 +335,7 @@ export class McpGatewaySession extends Disposable { })), }; } catch (error) { - this._logService.error('[McpGatewayService] Resource read failed', error); + this._logService.error(`[McpGateway][session ${this.id}] Resource read failed for '${originalUri}'`, error); throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); } } @@ -320,6 +351,7 @@ export class McpGatewaySession extends Disposable { }); } } + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${allTemplates.length} resource template(s) from ${serverResults.length} server(s)`); return { resourceTemplates: allTemplates }; } } diff --git a/src/vs/platform/mcp/test/common/mcpManagementService.test.ts b/src/vs/platform/mcp/test/common/mcpManagementService.test.ts index 78ee3299652..7245ffd376b 100644 --- a/src/vs/platform/mcp/test/common/mcpManagementService.test.ts +++ b/src/vs/platform/mcp/test/common/mcpManagementService.test.ts @@ -4,14 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { VSBuffer } from '../../../../base/common/buffer.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { AbstractCommonMcpManagementService } from '../../common/mcpManagementService.js'; -import { IGalleryMcpServer, IGalleryMcpServerConfiguration, IInstallableMcpServer, ILocalMcpServer, InstallOptions, RegistryType, TransportType, UninstallOptions } from '../../common/mcpManagement.js'; -import { McpServerType, McpServerVariableType, IMcpServerVariable } from '../../common/mcpPlatformTypes.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { AbstractCommonMcpManagementService, AbstractMcpResourceManagementService } from '../../common/mcpManagementService.js'; +import { IGalleryMcpServer, IGalleryMcpServerConfiguration, IInstallableMcpServer, ILocalMcpServer, IMcpGalleryService, InstallOptions, RegistryType, TransportType, UninstallOptions } from '../../common/mcpManagement.js'; +import { IMcpSandboxConfiguration, McpServerType, McpServerVariableType, IMcpServerConfiguration, IMcpServerVariable } from '../../common/mcpPlatformTypes.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; +import { ConfigurationTarget } from '../../../configuration/common/configuration.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; +import { McpResourceScannerService } from '../../common/mcpResourceScannerService.js'; +import { UriIdentityService } from '../../../uriIdentity/common/uriIdentityService.js'; class TestMcpManagementService extends AbstractCommonMcpManagementService { @@ -42,6 +50,44 @@ class TestMcpManagementService extends AbstractCommonMcpManagementService { } } +class TestMcpResourceManagementService extends AbstractMcpResourceManagementService { + constructor(mcpResource: URI, fileService: FileService, uriIdentityService: UriIdentityService, mcpResourceScannerService: McpResourceScannerService) { + super( + mcpResource, + ConfigurationTarget.USER, + {} as IMcpGalleryService, + fileService, + uriIdentityService, + new NullLogService(), + mcpResourceScannerService, + ); + } + + public reload(): Promise { + return this.updateLocal(); + } + + override canInstall(_server: IGalleryMcpServer | IInstallableMcpServer): true | IMarkdownString { + throw new Error('Not supported'); + } + + protected override getLocalServerInfo(_name: string, _mcpServerConfig: IMcpServerConfiguration) { + return Promise.resolve(undefined); + } + + protected override installFromUri(_uri: URI): Promise { + throw new Error('Not supported'); + } + + override installFromGallery(_server: IGalleryMcpServer, _options?: InstallOptions): Promise { + throw new Error('Not supported'); + } + + override updateMetadata(_local: ILocalMcpServer, _server: IGalleryMcpServer): Promise { + throw new Error('Not supported'); + } +} + suite('McpManagementService - getMcpServerConfigurationFromManifest', () => { let service: TestMcpManagementService; @@ -1073,3 +1119,74 @@ suite('McpManagementService - getMcpServerConfigurationFromManifest', () => { }); }); }); + +suite('McpResourceManagementService', () => { + const mcpResource = URI.from({ scheme: Schemas.inMemory, path: '/mcp.json' }); + let disposables: DisposableStore; + let fileService: FileService; + let service: TestMcpResourceManagementService; + + setup(async () => { + disposables = new DisposableStore(); + fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + const uriIdentityService = disposables.add(new UriIdentityService(fileService)); + const scannerService = disposables.add(new McpResourceScannerService(fileService, uriIdentityService)); + service = disposables.add(new TestMcpResourceManagementService(mcpResource, fileService, uriIdentityService, scannerService)); + + await fileService.writeFile(mcpResource, VSBuffer.fromString(JSON.stringify({ + sandbox: { + network: { allowedDomains: ['example.com'] } + }, + servers: { + test: { + type: 'stdio', + command: 'node', + sandboxEnabled: true + } + } + }, null, '\t'))); + }); + + teardown(() => { + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('fires update when root sandbox changes', async () => { + const initial = await service.getInstalled(); + assert.strictEqual(initial.length, 1); + assert.deepStrictEqual(initial[0].rootSandbox, { + network: { allowedDomains: ['example.com'] } + }); + + let updateCount = 0; + const updatePromise = new Promise(resolve => disposables.add(service.onDidUpdateMcpServers(e => { + assert.strictEqual(e.length, 1); + updateCount++; + resolve(); + }))); + + const updatedSandbox: IMcpSandboxConfiguration = { + network: { allowedDomains: ['changed.example.com'] } + }; + + await fileService.writeFile(mcpResource, VSBuffer.fromString(JSON.stringify({ + sandbox: updatedSandbox, + servers: { + test: { + type: 'stdio', + command: 'node', + sandboxEnabled: true + } + } + }, null, '\t'))); + await service.reload(); + await updatePromise; + const updated = await service.getInstalled(); + + assert.strictEqual(updateCount, 1); + assert.deepStrictEqual(updated[0].rootSandbox, updatedSandbox); + }); +}); diff --git a/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts b/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts index 98712bb9681..d30d166d09f 100644 --- a/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts +++ b/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts @@ -112,6 +112,145 @@ suite('McpGatewaySession', () => { onDidChangeResources.dispose(); }); + test('negotiates to older protocol version when client requests it', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-negotiate-1', new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + }, + }); + + assert.strictEqual(responses.length, 1); + const response = responses[0] as IJsonRpcSuccessResponse; + assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-03-26'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('negotiates to each supported protocol version', async () => { + const supportedVersions = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; + for (const version of supportedVersions) { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession(`session-ver-${version}`, new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: version, capabilities: {} }, + }); + + const response = responses[0] as IJsonRpcSuccessResponse; + assert.strictEqual( + (response.result as { protocolVersion: string }).protocolVersion, + version, + `Expected server to negotiate to ${version}` + ); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + } + }); + + test('falls back to latest version for unsupported client version', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-negotiate-2', new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2099-01-01', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0.0' }, + }, + }); + + assert.strictEqual(responses.length, 1); + const response = responses[0] as IJsonRpcSuccessResponse; + assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('falls back to latest version when no params provided', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-negotiate-3', new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + }); + + assert.strictEqual(responses.length, 1); + const response = responses[0] as IJsonRpcSuccessResponse; + assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('falls back to latest version when protocolVersion is not a string', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-negotiate-4', new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: 42, + capabilities: {}, + }, + }); + + assert.strictEqual(responses.length, 1); + const response = responses[0] as IJsonRpcSuccessResponse; + assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25'); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + + test('initialize response includes server info and capabilities', async () => { + const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); + const session = new McpGatewaySession('session-init-caps', new NullLogService(), () => { }, invoker); + + const responses = await session.handleIncoming({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {} }, + }); + + const result = (responses[0] as IJsonRpcSuccessResponse).result as MCP.InitializeResult; + assert.deepStrictEqual(result, { + protocolVersion: '2025-03-26', + capabilities: { + tools: { listChanged: true }, + resources: { listChanged: true }, + }, + serverInfo: { + name: 'VS Code MCP Gateway', + version: '1.0.0', + }, + }); + session.dispose(); + onDidChangeTools.dispose(); + onDidChangeResources.dispose(); + }); + test('rejects non-initialize requests before initialized notification', async () => { const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-2', new NullLogService(), () => { }, invoker); diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index bfad8086272..aa48f0b90f2 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -10,6 +10,7 @@ left: 50%; -webkit-app-region: no-drag; border-radius: var(--vscode-cornerRadius-xLarge); + box-shadow: var(--vscode-shadow-xl); } .quick-input-titlebar { @@ -307,6 +308,8 @@ .quick-input-list .quick-input-list-entry .quick-input-list-separator { margin-right: 4px; + font-size: var(--vscode-bodyFontSize-xSmall); + color: var(--vscode-descriptionForeground); /* separate from keybindings or actions */ } diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index 8e5283ef9ad..162d90de81b 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -922,13 +922,12 @@ export class QuickInputController extends Disposable { private updateStyles() { if (this.ui) { const { - quickInputTitleBackground, quickInputBackground, quickInputForeground, widgetBorder, widgetShadow, + quickInputTitleBackground, quickInputBackground, quickInputForeground, widgetBorder, } = this.styles.widget; this.ui.titleBar.style.backgroundColor = quickInputTitleBackground ?? ''; this.ui.container.style.backgroundColor = quickInputBackground ?? ''; this.ui.container.style.color = quickInputForeground ?? ''; this.ui.container.style.border = widgetBorder ? `1px solid ${widgetBorder}` : ''; - this.ui.container.style.boxShadow = widgetShadow ? `0 0 8px 2px ${widgetShadow}` : ''; this.ui.list.style(this.styles.list); this.ui.tree.tree.style(this.styles.list); diff --git a/src/vs/platform/quickinput/browser/tree/quickTree.ts b/src/vs/platform/quickinput/browser/tree/quickTree.ts index e506021a058..3c9a6146948 100644 --- a/src/vs/platform/quickinput/browser/tree/quickTree.ts +++ b/src/vs/platform/quickinput/browser/tree/quickTree.ts @@ -104,6 +104,11 @@ export class QuickTree extends QuickInput implements I this.ui.inputBox.setFocus(); } + reveal(element: T): void { + this.ui.tree.tree.reveal(element); + this.ui.tree.tree.setFocus([element]); + } + override show() { if (!this.visible) { const visibilities: Visibilities = { diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index 9426be48e2f..04d91e66aa4 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -1172,6 +1172,12 @@ export interface IQuickTree extends IQuickInput { */ focusOnInput(): void; + /** + * Reveals and focuses a specific item in the tree. + * @param element The item to reveal and focus. + */ + reveal(element: T): void; + /** * Focus a particular item in the list. Used internally for keyboard navigation. * @param focus The focus behavior. diff --git a/src/vs/platform/quickinput/test/browser/quickinput.test.ts b/src/vs/platform/quickinput/test/browser/quickinput.test.ts index 217fef39906..db57dedbffa 100644 --- a/src/vs/platform/quickinput/test/browser/quickinput.test.ts +++ b/src/vs/platform/quickinput/test/browser/quickinput.test.ts @@ -66,8 +66,7 @@ suite('QuickInput', () => { // https://github.com/microsoft/vscode/issues/147543 instantiationService.stub(IConfigurationService, new TestConfigurationService()); instantiationService.stub(IAccessibilityService, new TestAccessibilityService()); instantiationService.stub(IListService, store.add(new ListService())); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(ILayoutService, { activeContainer: fixture, onDidLayoutContainer: Event.None } as any); + instantiationService.stub(ILayoutService, { _serviceBrand: undefined, activeContainer: fixture, onDidLayoutContainer: Event.None }); instantiationService.stub(IContextViewService, store.add(instantiationService.createInstance(ContextViewService))); instantiationService.stub(IContextKeyService, store.add(instantiationService.createInstance(ContextKeyService))); instantiationService.stub(IKeybindingService, { diff --git a/src/vs/platform/remote/browser/browserSocketFactory.ts b/src/vs/platform/remote/browser/browserSocketFactory.ts index eafeef861a1..d558e4eef58 100644 --- a/src/vs/platform/remote/browser/browserSocketFactory.ts +++ b/src/vs/platform/remote/browser/browserSocketFactory.ts @@ -43,7 +43,7 @@ export interface IWebSocket { readonly onError: Event; traceSocketEvent?(type: SocketDiagnosticsEventType, data?: VSBuffer | Uint8Array | ArrayBuffer | ArrayBufferView | unknown): void; - send(data: ArrayBuffer | ArrayBufferView): void; + send(data: ArrayBuffer | ArrayBufferView): void; close(): void; } @@ -182,7 +182,7 @@ class BrowserWebSocket extends Disposable implements IWebSocket { })); } - send(data: ArrayBuffer | ArrayBufferView): void { + send(data: ArrayBuffer | ArrayBufferView): void { if (this._isClosed) { // Refuse to write data to closed WebSocket... return; @@ -254,7 +254,7 @@ class BrowserSocket implements ISocket { } public write(buffer: VSBuffer): void { - this.socket.send(buffer.buffer); + this.socket.send(buffer.buffer as Uint8Array); } public end(): void { diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index dc7c596c346..44e53e09a64 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -122,6 +122,7 @@ export const enum TerminalSettingId { FontLigaturesFallbackLigatures = 'terminal.integrated.fontLigatures.fallbackLigatures', EnableKittyKeyboardProtocol = 'terminal.integrated.enableKittyKeyboardProtocol', EnableWin32InputMode = 'terminal.integrated.enableWin32InputMode', + ExperimentalAiProfileGrouping = 'terminal.integrated.experimental.aiProfileGrouping', AllowInUntrustedWorkspace = 'terminal.integrated.allowInUntrustedWorkspace', // Developer/debug settings diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index 60563ff6487..e684180e35d 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -110,6 +110,7 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess private _isPtyPaused: boolean = false; private _unacknowledgedCharCount: number = 0; + private _writeQueue: Promise = Promise.resolve(); get exitMessage(): string | undefined { return this._exitMessage; } get currentTitle(): string { return this._windowsShellHelper?.shellTitle || this._currentTitle; } @@ -468,12 +469,32 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess this._logService.trace('node-pty.IPty#write', data, isBinary); if (isBinary) { this._ptyProcess!.write(Buffer.from(data, 'binary')); + } else if (isMacintosh && data.length > 512 && data.includes('\r')) { + // macOS PTY has a ~1024-byte canonical-mode input buffer. Multiline + // input exceeding this causes writes to block or corrupt due to + // backpressure from the shell's line editor echoing characters. + // https://github.com/microsoft/vscode/issues/296955 + this._writeChunked(data); } else { this._ptyProcess!.write(data); } this._childProcessMonitor?.handleInput(); } + private _writeChunked(data: string): void { + this._writeQueue = this._writeQueue.then(async () => { + for (let i = 0; i < data.length; i += 512) { + if (this._store.isDisposed) { + return; + } + this._ptyProcess!.write(data.slice(i, i + 512)); + if (i + 512 < data.length) { + await timeout(5); + } + } + }); + } + sendSignal(signal: string): void { if (this._store.isDisposed || !this._ptyProcess) { return; diff --git a/src/vs/platform/terminal/test/node/terminalProcess.test.ts b/src/vs/platform/terminal/test/node/terminalProcess.test.ts new file mode 100644 index 00000000000..b5aed3fed0a --- /dev/null +++ b/src/vs/platform/terminal/test/node/terminalProcess.test.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { deepStrictEqual } from 'assert'; +import { tmpdir } from 'os'; +import * as path from '../../../../base/common/path.js'; +import * as fs from 'fs'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { ITerminalProcessOptions, ITerminalLaunchError } from '../../common/terminal.js'; +import { TerminalProcess } from '../../node/terminalProcess.js'; +import { isWindows } from '../../../../base/common/platform.js'; + +const processOptions: ITerminalProcessOptions = { + shellIntegration: { enabled: false, suggestEnabled: false, nonce: '' }, + windowsUseConptyDll: false, + environmentVariableCollections: undefined, + workspaceFolder: undefined, + isScreenReaderOptimized: false +}; + +/** + * Build a multiline shell command that writes its content to a file. + * The command writes numbered lines to a temp file so we can verify + * the entire payload was received intact by the shell. + */ +function buildMultilineCommand(lineCount: number, outputFile: string): { command: string; expectedLines: string[] } { + const lines: string[] = []; + for (let i = 1; i <= lineCount; i++) { + // Pad line number, add filler to make each line ~55 chars + const line = `L${String(i).padStart(2, '0')} ${'a'.repeat(51)}`; + lines.push(line); + } + // Use cat heredoc to write content to a file — this exercises multiline PTY input + const command = `cat > ${outputFile} << 'TESTEOF'\n${lines.join('\n')}\nTESTEOF\n`; + return { command, expectedLines: lines }; +} + +// These tests spawn real PTY processes and are macOS/Linux only +(isWindows ? suite.skip : suite)('TerminalProcess - multiline write', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let outputDir: string; + + setup(() => { + outputDir = fs.mkdtempSync(path.join(tmpdir(), 'vscode-pty-test-')); + }); + + teardown(() => { + fs.rmSync(outputDir, { recursive: true, force: true }); + }); + + async function runMultilineTest(lineCount: number): Promise { + const outputFile = path.join(outputDir, `output-${lineCount}.txt`); + const { command, expectedLines } = buildMultilineCommand(lineCount, outputFile); + + const terminalProcess = store.add(new TerminalProcess( + { executable: '/bin/bash', args: ['--norc', '--noprofile', '-i'] }, + outputDir, + 80, + 24, + { ...process.env } as Record, + { ...process.env } as Record, + processOptions, + new NullLogService(), + { applicationName: 'vscode' } as IProductService + )); + + const result = await terminalProcess.start(); + const error = result as ITerminalLaunchError | undefined; + if (error?.message) { + throw new Error(`Failed to start terminal: ${error.message}`); + } + + // Wait for shell to produce output (prompt), indicating it's ready for input + await new Promise(resolve => { + const timeout = setTimeout(() => { + listener.dispose(); + resolve(); + }, 10000); + const listener = terminalProcess.onProcessData(() => { + clearTimeout(timeout); + listener.dispose(); + resolve(); + }); + }); + + // Send the multiline command — newlines are converted to \r for PTY + const ptyData = command.replace(/\n/g, '\r'); + terminalProcess.input(ptyData); + + // Wait for the command to execute and write the file + const maxWait = 10000; + const start = Date.now(); + while (Date.now() - start < maxWait) { + await new Promise(resolve => setTimeout(resolve, 200)); + if (fs.existsSync(outputFile)) { + // Give a moment for the write to flush + await new Promise(resolve => setTimeout(resolve, 200)); + break; + } + } + + // Shut down and wait for the process to exit + const exitPromise = new Promise(resolve => { + const listener = terminalProcess.onProcessExit(() => { + listener.dispose(); + resolve(); + }); + }); + terminalProcess.shutdown(true); + await exitPromise; + + if (!fs.existsSync(outputFile)) { + throw new Error(`Output file was not created — terminal likely got stuck (command was ${command.length} bytes)`); + } + + const actualContent = fs.readFileSync(outputFile, 'utf-8'); + const actualLines = actualContent.trimEnd().split('\n'); + deepStrictEqual(actualLines, expectedLines); + } + + test('small multiline command (10 lines, ~700 bytes)', async function () { + this.timeout(15000); + await runMultilineTest(10); + }); + + test('medium multiline command (20 lines, ~1300 bytes)', async function () { + this.timeout(15000); + await runMultilineTest(20); + }); + + test.skip('large multiline command (500 lines, ~32KB)', async function () { + this.timeout(30000); + await runMultilineTest(500); + }); +}); diff --git a/src/vs/platform/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index 93dd62df97e..76483ca546e 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -89,6 +89,20 @@ configurationRegistry.registerConfiguration({ localize('actionable', "The status bar entry is shown when an action is required (e.g., download, install, or restart)."), localize('detailed', "The status bar entry is shown for all update states including progress.") ] + }, + 'update.titleBar': { + type: 'string', + enum: ['none', 'actionable', 'detailed'], + default: 'none', + scope: ConfigurationScope.APPLICATION, + tags: ['experimental'], + experiment: { mode: 'startup' }, + description: localize('titleBar', "Controls the experimental update title bar entry."), + enumDescriptions: [ + localize('titleBarNone', "The title bar entry is never shown."), + localize('titleBarActionable', "The title bar entry is shown when an action is required (e.g., download, install, or restart)."), + localize('titleBarDetailed', "The title bar entry is shown for all update states including progress.") + ] } } }); diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index cbeb3a60888..bc90a03ad8c 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -59,17 +59,17 @@ export const enum DisablementReason { NotBuilt, DisabledByEnvironment, ManuallyDisabled, + Policy, MissingConfiguration, InvalidConfiguration, RunningAsAdmin, - EmbeddedApp, } export type Uninitialized = { type: StateType.Uninitialized }; export type Disabled = { type: StateType.Disabled; reason: DisablementReason }; -export type Idle = { type: StateType.Idle; updateType: UpdateType; error?: string }; +export type Idle = { type: StateType.Idle; updateType: UpdateType; error?: string; notAvailable?: boolean }; export type CheckingForUpdates = { type: StateType.CheckingForUpdates; explicit: boolean }; -export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate }; +export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate; canInstall?: boolean }; export type Downloading = { type: StateType.Downloading; update?: IUpdate; explicit: boolean; overwrite: boolean; downloadedBytes?: number; totalBytes?: number; startTime?: number }; export type Downloaded = { type: StateType.Downloaded; update: IUpdate; explicit: boolean; overwrite: boolean }; export type Updating = { type: StateType.Updating; update: IUpdate; currentProgress?: number; maxProgress?: number }; @@ -81,9 +81,9 @@ export type State = Uninitialized | Disabled | Idle | CheckingForUpdates | Avail export const State = { Uninitialized: upcast({ type: StateType.Uninitialized }), Disabled: (reason: DisablementReason): Disabled => ({ type: StateType.Disabled, reason }), - Idle: (updateType: UpdateType, error?: string): Idle => ({ type: StateType.Idle, updateType, error }), + Idle: (updateType: UpdateType, error?: string, notAvailable?: boolean): Idle => ({ type: StateType.Idle, updateType, error, notAvailable }), CheckingForUpdates: (explicit: boolean): CheckingForUpdates => ({ type: StateType.CheckingForUpdates, explicit }), - AvailableForDownload: (update: IUpdate): AvailableForDownload => ({ type: StateType.AvailableForDownload, update }), + AvailableForDownload: (update: IUpdate, canInstall?: boolean): AvailableForDownload => ({ type: StateType.AvailableForDownload, update, canInstall }), Downloading: (update: IUpdate | undefined, explicit: boolean, overwrite: boolean, downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading => ({ type: StateType.Downloading, update, explicit, overwrite, downloadedBytes, totalBytes, startTime }), Downloaded: (update: IUpdate, explicit: boolean, overwrite: boolean): Downloaded => ({ type: StateType.Downloaded, update, explicit, overwrite }), Updating: (update: IUpdate, currentProgress?: number, maxProgress?: number): Updating => ({ type: StateType.Updating, update, currentProgress, maxProgress }), diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 3e5956faa22..c207a036712 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -142,11 +142,18 @@ export abstract class AbstractUpdateService implements IUpdateService { } const updateMode = this.configurationService.getValue<'none' | 'manual' | 'start' | 'default'>('update.mode'); + const updateModeInspection = this.configurationService.inspect<'none' | 'manual' | 'start' | 'default'>('update.mode'); + const policyDisablesUpdates = updateModeInspection.policyValue !== undefined && !this.getProductQuality(updateModeInspection.policyValue); const quality = this.getProductQuality(updateMode); if (!quality) { - this.setState(State.Disabled(DisablementReason.ManuallyDisabled)); - this.logService.info('update#ctor - updates are disabled by user preference'); + if (policyDisablesUpdates) { + this.setState(State.Disabled(DisablementReason.Policy)); + this.logService.info('update#ctor - updates are disabled by policy'); + } else { + this.setState(State.Disabled(DisablementReason.ManuallyDisabled)); + this.logService.info('update#ctor - updates are disabled by user preference'); + } return; } @@ -317,7 +324,7 @@ export abstract class AbstractUpdateService implements IUpdateService { return undefined; } - const url = this.buildUpdateFeedUrl(this.quality, commit ?? this.productService.commit!); + const url = this.buildUpdateFeedUrl(this.quality, commit ?? this.productService.commit!, { internalOrg: this.getInternalOrg() }); if (!url) { return undefined; diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index 842c6766924..a9e46ff937e 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -16,7 +16,7 @@ import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { AvailableForDownload, DisablementReason, IUpdate, State, StateType, UpdateType } from '../common/update.js'; +import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdateURLOptions, UpdateErrorClassification } from './abstractUpdateService.js'; import { INodeProcess } from '../../../base/common/platform.js'; @@ -68,13 +68,15 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau } protected override async initialize(): Promise { + await super.initialize(); + + // In the embedded app we still want to detect available updates via HTTP, + // but we must not wire up Electron's autoUpdater (which auto-downloads). if ((process as INodeProcess).isEmbeddedApp) { - this.setState(State.Disabled(DisablementReason.EmbeddedApp)); - this.logService.info('update#ctor - updates are disabled from embedded app'); + this.logService.info('update#ctor - embedded app: checking for updates without auto-download'); return; } - await super.initialize(); this.onRawError(this.onError, this, this.disposables); this.onRawCheckingForUpdate(this.onCheckingForUpdate, this, this.disposables); this.onRawUpdateAvailable(this.onUpdateAvailable, this, this.disposables); @@ -135,6 +137,13 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau return; } + // In the embedded app, always check without triggering Electron's auto-download. + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.info('update#doCheckForUpdates - embedded app: checking for update without auto-download'); + this.checkForUpdateNoDownload(url, /* canInstall */ false); + return; + } + // When connection is metered and this is not an explicit check, avoid electron call as to not to trigger auto-download. if (!explicit && this.meteredConnectionService.isConnectionMetered) { this.logService.info('update#doCheckForUpdates - checking for update without auto-download because connection is metered'); @@ -148,9 +157,10 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau /** * Manually check the update feed URL without triggering Electron's auto-download. - * Used when connection is metered to show update availability without downloading. + * Used when connection is metered or in the embedded app. + * @param canInstall When false, signals that the update cannot be installed from this app. */ - private async checkForUpdateNoDownload(url: string): Promise { + private async checkForUpdateNoDownload(url: string, canInstall?: boolean): Promise { const headers = getUpdateRequestHeaders(this.productService.version); this.logService.trace('update#checkForUpdateNoDownload - checking update server', { url, headers }); @@ -162,10 +172,11 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau const update = await asJson(context); if (!update || !update.url || !update.version || !update.productVersion) { this.logService.trace('update#checkForUpdateNoDownload - no update available'); - this.setState(State.Idle(UpdateType.Archive)); + const notAvailable = this.state.type === StateType.CheckingForUpdates && this.state.explicit; + this.setState(State.Idle(UpdateType.Archive, undefined, notAvailable || undefined)); } else { this.logService.trace('update#checkForUpdateNoDownload - update available', { version: update.version, productVersion: update.productVersion }); - this.setState(State.AvailableForDownload(update)); + this.setState(State.AvailableForDownload(update, canInstall)); } } catch (err) { this.logService.error('update#checkForUpdateNoDownload - failed to check for update', err); @@ -201,7 +212,8 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau return; } - this.setState(State.Idle(UpdateType.Archive)); + const notAvailable = this.state.explicit; + this.setState(State.Idle(UpdateType.Archive, undefined, notAvailable || undefined)); } protected override async doDownloadUpdate(state: AvailableForDownload): Promise { diff --git a/src/vs/platform/update/electron-main/updateService.linux.ts b/src/vs/platform/update/electron-main/updateService.linux.ts index 3ace29fed5a..4c6c0b76191 100644 --- a/src/vs/platform/update/electron-main/updateService.linux.ts +++ b/src/vs/platform/update/electron-main/updateService.linux.ts @@ -48,7 +48,7 @@ export class LinuxUpdateService extends AbstractUpdateService { .then(asJson) .then(update => { if (!update || !update.url || !update.version || !update.productVersion) { - this.setState(State.Idle(UpdateType.Archive)); + this.setState(State.Idle(UpdateType.Archive, undefined, explicit || undefined)); } else { this.setState(State.AvailableForDownload(update)); } diff --git a/src/vs/platform/update/electron-main/updateService.snap.ts b/src/vs/platform/update/electron-main/updateService.snap.ts index a68a25e577b..ae2df6ac89d 100644 --- a/src/vs/platform/update/electron-main/updateService.snap.ts +++ b/src/vs/platform/update/electron-main/updateService.snap.ts @@ -176,7 +176,7 @@ export class SnapUpdateService extends AbstractUpdateService { if (result) { this.setState(State.Ready({ version: 'something' }, false, false)); } else { - this.setState(State.Idle(UpdateType.Snap)); + this.setState(State.Idle(UpdateType.Snap, undefined, undefined)); } }, err => { this.logService.error(err); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 25535f21252..905928b8d2e 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -99,9 +99,11 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } protected override async initialize(): Promise { + // In the embedded app, skip win32-specific setup (cache paths, telemetry) + // but still run the base initialization to detect available updates. if ((process as INodeProcess).isEmbeddedApp) { - this.setState(State.Disabled(DisablementReason.EmbeddedApp)); - this.logService.info('update#ctor - updates are disabled from embedded app'); + this.logService.info('update#ctor - embedded app: checking for updates without auto-download'); + await super.initialize(); return; } @@ -217,7 +219,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this._overwrite = false; this.setState(State.Ready(this.state.update, this.state.explicit, false)); } else { - this.setState(State.Idle(updateType)); + this.setState(State.Idle(updateType, undefined, explicit || undefined)); } return Promise.resolve(null); } @@ -227,6 +229,13 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return Promise.resolve(null); } + // In the embedded app, signal that an update exists but can't be installed here. + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.info('update#doCheckForUpdates - embedded app: update available, skipping download'); + this.setState(State.AvailableForDownload(update, /* canInstall */ false)); + return Promise.resolve(null); + } + // When connection is metered and this is not an explicit check, // show update is available but don't start downloading if (!explicit && this.meteredConnectionService.isConnectionMetered) { diff --git a/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts index fccddba0156..c1dd46b4c17 100644 --- a/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts +++ b/src/vs/platform/utilityProcess/electron-main/utilityProcess.ts @@ -245,8 +245,12 @@ export class UtilityProcess extends Disposable { const serviceName = `${this.configuration.type}-${this.id}`; const modulePath = FileAccess.asFileUri('bootstrap-fork.js').fsPath; const args = this.configuration.args ?? []; - const execArgv = this.configuration.execArgv ?? []; + const execArgv = [...(this.configuration.execArgv ?? [])]; const allowLoadingUnsignedLibraries = this.configuration.allowLoadingUnsignedLibraries; + const jsFlags = app.commandLine.getSwitchValue('js-flags'); + if (jsFlags) { + execArgv.push(`--js-flags=${jsFlags}`); + } const respondToAuthRequestsFromMainProcess = this.configuration.respondToAuthRequestsFromMainProcess; const stdio = 'pipe'; const env = this.createEnv(configuration); diff --git a/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts b/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts index 50859223195..f4ec49d643b 100644 --- a/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts +++ b/src/vs/platform/webContentExtractor/test/electron-main/cdpAccessibilityDomain.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; -import { AXNode, AXProperty, AXValueType, convertAXTreeToMarkdown } from '../../electron-main/cdpAccessibilityDomain.js'; +import { AXNode, AXProperty, AXPropertyName, AXValueType, convertAXTreeToMarkdown } from '../../electron-main/cdpAccessibilityDomain.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; suite('CDP Accessibility Domain', () => { @@ -17,10 +17,9 @@ suite('CDP Accessibility Domain', () => { return { type, value }; } - function createAXProperty(name: string, value: any, type: AXValueType = 'string'): AXProperty { + function createAXProperty(name: AXPropertyName, value: any, type: AXValueType = 'string'): AXProperty { return { - // eslint-disable-next-line local/code-no-any-casts - name: name as any, + name, value: createAXValue(type, value) }; } diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 7455376e3f4..07551319546 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -11,7 +11,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { FileAccess, Schemas } from '../../../base/common/network.js'; import { getMarks, mark } from '../../../base/common/performance.js'; -import { isTahoeOrNewer, isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; +import { isTahoeOrNewer, isLinux, isMacintosh, isWindows, INodeProcess } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { release } from 'os'; @@ -702,11 +702,16 @@ export class CodeWindow extends BaseWindow implements ICodeWindow { this.windowState = state; this.logService.trace('window#ctor: using window state', state); - const options = instantiationService.invokeFunction(defaultBrowserWindowOptions, this.windowState, undefined, { + const webPreferences: electron.WebPreferences = { preload: FileAccess.asFileUri('vs/base/parts/sandbox/electron-browser/preload.js').fsPath, additionalArguments: [`--vscode-window-config=${this.configObjectUrl.resource.toString()}`], - v8CacheOptions: this.environmentMainService.useCodeCache ? 'bypassHeatCheck' : 'none', - }); + v8CacheOptions: this.environmentMainService.useCodeCache ? 'bypassHeatCheck' : 'none' + }; + if ((process as INodeProcess).isEmbeddedApp) { + webPreferences.backgroundThrottling = false; // disable for sub-app + } + + const options = instantiationService.invokeFunction(defaultBrowserWindowOptions, this.windowState, undefined, webPreferences); // Create the browser window mark('code/willCreateCodeBrowserWindow'); diff --git a/src/vs/server/node/remoteTerminalChannel.ts b/src/vs/server/node/remoteTerminalChannel.ts index a455eba4267..ab6f98f0256 100644 --- a/src/vs/server/node/remoteTerminalChannel.ts +++ b/src/vs/server/node/remoteTerminalChannel.ts @@ -226,7 +226,7 @@ export class RemoteTerminalChannel extends Disposable implements IServerChannel< const activeWorkspaceFolder = args.activeWorkspaceFolder ? reviveWorkspaceFolder(args.activeWorkspaceFolder) : undefined; const activeFileResource = args.activeFileResource ? URI.revive(uriTransformer.transformIncoming(args.activeFileResource)) : undefined; const customVariableResolver = new CustomVariableResolver(baseEnv, workspaceFolders, activeFileResource, args.resolvedVariables, this._extensionManagementService); - const variableResolver = terminalEnvironment.createVariableResolver(activeWorkspaceFolder, process.env, customVariableResolver); + const variableResolver = terminalEnvironment.createVariableResolver(activeWorkspaceFolder, baseEnv, customVariableResolver); // Get the initial cwd const initialCwd = await terminalEnvironment.getCwd(shellLaunchConfig, os.homedir(), variableResolver, activeWorkspaceFolder?.uri, args.configuration['terminal.integrated.cwd'], this._logService); diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index a3309067c7f..e3c8ec0b8d1 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -90,9 +90,21 @@ The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, Sessions overrides `PromptsService` via `AgenticPromptsService` (in `promptsService.ts`): - **Discovery**: `AgenticPromptFilesLocator` scopes workspace folders to the active session's worktree +- **Built-in prompts**: Discovers bundled `.prompt.md` files from `vs/sessions/prompts/` and surfaces them with `PromptsStorage.builtin` storage type +- **User override**: Built-in prompts are omitted when a user or workspace prompt with the same name exists - **Creation targets**: `getSourceFolders()` override replaces VS Code profile user roots with `~/.copilot/{subfolder}` for CLI compatibility - **Hook folders**: Falls back to `.github/hooks` in the active worktree +### Built-in Prompts + +Prompt files bundled with the Sessions app live in `src/vs/sessions/prompts/`. They are: + +- Discovered at runtime via `FileAccess.asFileUri('vs/sessions/prompts')` +- Tagged with `PromptsStorage.builtin` storage type +- Shown in a "Built-in" group in the AI Customization tree view and management editor +- Filtered out when a user/workspace prompt shares the same clean name (override behavior) +- Included in storage filters for prompts and CLI-user types + ### Count Consistency `customizationCounts.ts` uses the **same data sources** as the list widget's `loadItems()`: diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index cd1f5880504..c9bf983b7c9 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -52,7 +52,7 @@ class ToggleSidebarVisibilityAction extends Action2 { }, menu: [ { - id: Menus.TitleBarLeft, + id: Menus.TitleBarLeftLayout, group: 'navigation', order: 0, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) @@ -104,7 +104,7 @@ class ToggleSecondarySidebarVisibilityAction extends Action2 { f1: true, menu: [ { - id: Menus.TitleBarRight, + id: Menus.TitleBarRightLayout, group: 'navigation', order: 10, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) @@ -165,7 +165,7 @@ registerAction2(ToggleSecondarySidebarVisibilityAction); registerAction2(TogglePanelVisibilityAction); // Floating window controls: always-on-top -MenuRegistry.appendMenuItem(Menus.TitleBarRight, { +MenuRegistry.appendMenuItem(Menus.TitleBarRightLayout, { command: { id: 'workbench.action.toggleWindowAlwaysOnTop', title: localize('toggleWindowAlwaysOnTop', "Toggle Always on Top"), diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 6947f2aff49..0a57fa73f7d 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -43,6 +43,30 @@ background-color: var(--vscode-sideBar-background); } +/* ---- Chat Layout ---- */ + +/* Remove max-width from the session container so the scrollbar extends full width */ +.agent-sessions-workbench .interactive-session { + max-width: none; +} + +/* Constrain content items to the same max-width, centered */ +.agent-sessions-workbench .interactive-session .interactive-item-container { + max-width: 950px; + margin: 0 auto; + padding-left: 8px; + padding-right: 8px; + box-sizing: border-box; +} + +.agent-sessions-workbench .interactive-session > .chat-suggest-next-widget { + max-width: 950px; + margin: 0 auto; + padding-left: 8px; + padding-right: 8px; + box-sizing: border-box; +} + /* ---- Chat Input ---- */ .agent-sessions-workbench .interactive-session .chat-input-container { @@ -50,8 +74,11 @@ } .agent-sessions-workbench .interactive-session .interactive-input-part { - margin: 0 8px !important; + width: 100%; + max-width: 950px; + margin: 0 auto !important; display: inherit !important; - /* Align with changes view */ - padding: 4px 0 6px 0 !important; + /* Align with panel (terminal) card margin */ + padding: 4px 8px 6px 8px !important; + box-sizing: border-box; } diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 74ebe982e7d..c322fba968b 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -13,11 +13,10 @@ export const Menus = { CommandCenter: new MenuId('SessionsCommandCenter'), CommandCenterCenter: new MenuId('SessionsCommandCenterCenter'), TitleBarContext: new MenuId('SessionsTitleBarContext'), - TitleBarControlMenu: new MenuId('SessionsTitleBarControlMenu'), - TitleBarLeft: new MenuId('SessionsTitleBarLeft'), - TitleBarCenter: new MenuId('SessionsTitleBarCenter'), - TitleBarRight: new MenuId('SessionsTitleBarRight'), - OpenSubMenu: new MenuId('SessionsOpenSubMenu'), + TitleBarLeftLayout: new MenuId('SessionsTitleBarLeftLayout'), + TitleBarSessionTitle: new MenuId('SessionsTitleBarSessionTitle'), + TitleBarSessionMenu: new MenuId('SessionsTitleBarSessionMenu'), + TitleBarRightLayout: new MenuId('SessionsTitleBarRightLayout'), PanelTitle: new MenuId('SessionsPanelTitle'), SidebarTitle: new MenuId('SessionsSidebarTitle'), AuxiliaryBarTitle: new MenuId('SessionsAuxiliaryBarTitle'), @@ -25,5 +24,4 @@ export const Menus = { SidebarFooter: new MenuId('SessionsSidebarFooter'), SidebarCustomizations: new MenuId('SessionsSidebarCustomizations'), AgentFeedbackEditorContent: new MenuId('AgentFeedbackEditorContent'), - SessionTitleActions: new MenuId('SessionTitleActions'), } as const; diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index c7dbddc3e33..f97118b5419 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -12,8 +12,9 @@ import { INotificationService } from '../../../platform/notification/common/noti import { IStorageService } from '../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { ActiveAuxiliaryContext, AuxiliaryBarFocusContext } from '../../../workbench/common/contextkeys.js'; -import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_BORDER, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; +import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, ACTIVITY_BAR_TOP_ACTIVE_BORDER, ACTIVITY_BAR_TOP_DRAG_AND_DROP_BORDER, ACTIVITY_BAR_TOP_FOREGROUND, ACTIVITY_BAR_TOP_INACTIVE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_BACKGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { sessionsSidebarBorder } from '../../common/theme.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; @@ -105,7 +106,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { { hasTitle: true, trailingSeparator: false, - borderWidth: () => (this.getColor(SIDE_BAR_BORDER) || this.getColor(contrastBorder)) ? 1 : 0, + borderWidth: () => (this.getColor(sessionsSidebarBorder) || this.getColor(contrastBorder)) ? 1 : 0, }, AuxiliaryBarPart.activeViewSettingsKey, ActiveAuxiliaryContext.bindTo(contextKeyService), @@ -141,7 +142,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { // Store background and border as CSS variables for the card styling on .part container.style.setProperty('--part-background', this.getColor(SIDE_BAR_BACKGROUND) || ''); - container.style.setProperty('--part-border-color', this.getColor(SIDE_BAR_BORDER) || this.getColor(contrastBorder) || 'transparent'); + container.style.setProperty('--part-border-color', this.getColor(sessionsSidebarBorder) || this.getColor(contrastBorder) || 'transparent'); container.style.backgroundColor = 'transparent'; container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 6aab26d2631..146ed67fa61 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -8,10 +8,74 @@ height: 100%; align-items: center; order: 0; - flex-grow: 2; + flex-grow: 0; + flex-shrink: 0; + width: auto; justify-content: flex-start; } +.monaco-workbench .part.titlebar > .sessions-titlebar-container.has-center > .titlebar-center { + order: 1; + width: auto; + flex-grow: 0; + flex-shrink: 1; + min-width: 0px; + margin: 0; + justify-content: flex-start; +} + +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left { + width: fit-content; + flex-grow: 0; +} + +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-center { + flex: 1; + max-width: none; +} + +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-center .window-title { + margin: unset; +} + +.agent-sessions-workbench.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right { + order: 2; + width: fit-content; + flex-grow: 0; + justify-content: flex-end; + margin-right: 10px; +} + +/* Session Title Actions Container (before right toolbar) */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container { + display: none; + flex-shrink: 0; + -webkit-app-region: no-drag; + height: 100%; +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container:not(.has-no-actions) { + display: flex; + align-items: center; +} + +/* Separator between session actions and layout actions toolbar */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container:not(.has-no-actions) + .titlebar-layout-actions-container:not(.has-no-actions)::before { + content: ''; + width: 1px; + height: 16px; + margin: 0 4px; + background-color: var(--vscode-disabledForeground); +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container .codicon { + color: inherit; +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container .monaco-action-bar .action-item { + display: flex; +} + /* Left Tool Bar Container */ .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container { display: none; @@ -40,11 +104,8 @@ display: flex; } -/* TODO: Hack to avoid flicker when sidebar becomes visible. - * The contribution swaps the menu item synchronously, but the toolbar - * re-render is async, causing a brief flash. Hide the container via - * CSS when sidebar is visible (nosidebar class is removed synchronously). */ -.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .sessions-titlebar-container > .titlebar-left > .left-toolbar-container { +/* Hide the entire titlebar left when the sidebar is visible */ +.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .sessions-titlebar-container > .titlebar-left { display: none !important; } @@ -52,7 +113,3 @@ .agent-sessions-workbench.mac .part.titlebar .window-controls-container { -webkit-app-region: drag; } - -.agent-sessions-workbench:not(.nosidebar) .part.titlebar > .sessions-titlebar-container > .titlebar-left .window-controls-container { - display: none !important; -} diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts index 867760bd112..2fccc865f15 100644 --- a/src/vs/sessions/browser/parts/panelPart.ts +++ b/src/vs/sessions/browser/parts/panelPart.ts @@ -14,8 +14,9 @@ import { IContextMenuService } from '../../../platform/contextview/browser/conte import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; -import { PANEL_BACKGROUND, PANEL_BORDER, PANEL_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_DRAG_AND_DROP_BORDER, PANEL_TITLE_BADGE_BACKGROUND, PANEL_TITLE_BADGE_FOREGROUND } from '../../../workbench/common/theme.js'; +import { PANEL_BACKGROUND, PANEL_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_INACTIVE_TITLE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_DRAG_AND_DROP_BORDER, PANEL_TITLE_BADGE_BACKGROUND, PANEL_TITLE_BADGE_FOREGROUND } from '../../../workbench/common/theme.js'; import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; +import { sessionsSidebarBorder } from '../../common/theme.js'; import { INotificationService } from '../../../platform/notification/common/notification.js'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { assertReturnsDefined } from '../../../base/common/types.js'; @@ -129,7 +130,7 @@ export class PanelPart extends AbstractPaneCompositePart { // Store background and border as CSS variables for the card styling on .part container.style.setProperty('--part-background', this.getColor(PANEL_BACKGROUND) || ''); - container.style.setProperty('--part-border-color', this.getColor(PANEL_BORDER) || this.getColor(contrastBorder) || 'transparent'); + container.style.setProperty('--part-border-color', this.getColor(sessionsSidebarBorder) || this.getColor(contrastBorder) || 'transparent'); container.style.backgroundColor = 'transparent'; // Clear inline borders - the card appearance uses CSS border-radius instead diff --git a/src/vs/sessions/browser/parts/sidebarPart.ts b/src/vs/sessions/browser/parts/sidebarPart.ts index b9132d4d13e..75eb74869dd 100644 --- a/src/vs/sessions/browser/parts/sidebarPart.ts +++ b/src/vs/sessions/browser/parts/sidebarPart.ts @@ -120,7 +120,7 @@ export class SidebarPart extends AbstractPaneCompositePart { ViewContainerLocation.Sidebar, Extensions.Viewlets, Menus.SidebarTitle, - Menus.TitleBarLeft, + Menus.TitleBarLeftLayout, notificationService, storageService, contextMenuService, diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index fa99c2e21e3..18c2d2867f0 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -185,7 +185,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { // Left toolbar (driven by Menus.TitleBarLeft, rendered after window controls via CSS order) const leftToolbarContainer = append(this.leftContent, $('div.left-toolbar-container')); - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, leftToolbarContainer, Menus.TitleBarLeft, { + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, leftToolbarContainer, Menus.TitleBarLeftLayout, { contextMenu: Menus.TitleBarContext, telemetrySource: 'titlePart.left', hiddenItemStrategy: HiddenItemStrategy.NoHide, @@ -204,13 +204,22 @@ export class TitlebarPart extends Part implements ITitlebarPart { })); // Right toolbar (driven by Menus.TitleBarRight - includes account submenu) - const rightToolbarContainer = prepend(this.rightContent, $('div.action-toolbar-container')); - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, rightToolbarContainer, Menus.TitleBarRight, { + const rightToolbarContainer = prepend(this.rightContent, $('div.titlebar-actions-container.titlebar-layout-actions-container')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, rightToolbarContainer, Menus.TitleBarRightLayout, { contextMenu: Menus.TitleBarContext, telemetrySource: 'titlePart.right', toolbarOptions: { primaryGroup: () => true }, })); + // Session title actions toolbar (before right toolbar) + const sessionActionsContainer = prepend(this.rightContent, $('div.titlebar-actions-container.titlebar-session-actions-container')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, sessionActionsContainer, Menus.TitleBarSessionMenu, { + contextMenu: Menus.TitleBarContext, + hiddenItemStrategy: HiddenItemStrategy.NoHide, + telemetrySource: 'titlePart.sessionActions', + toolbarOptions: { primaryGroup: () => true }, + })); + // Context menu on the titlebar this._register(addDisposableListener(this.rootContainer, EventType.CONTEXT_MENU, e => { EventHelper.stop(e); diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index 24b0d19b0d4..1b34d6c3aec 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -81,6 +81,7 @@ enum LayoutClasses { PANEL_HIDDEN = 'nopanel', AUXILIARYBAR_HIDDEN = 'noauxiliarybar', CHATBAR_HIDDEN = 'nochatbar', + STATUSBAR_HIDDEN = 'nostatusbar', FULLSCREEN = 'fullscreen', MAXIMIZED = 'maximized' } @@ -590,6 +591,8 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { this.getPart(Parts.EDITOR_PART).create(editorPartContainer, { restorePreviousState: false }); mark('code/didCreatePart/workbench.parts.editor'); + this.getPart(Parts.EDITOR_PART).layout(0, 0, 0, 0); // needed to make some view methods work + this.mainContainer.appendChild(editorPartContainer); } @@ -893,6 +896,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { !this.partVisibility.panel ? LayoutClasses.PANEL_HIDDEN : undefined, !this.partVisibility.auxiliaryBar ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, !this.partVisibility.chatBar ? LayoutClasses.CHATBAR_HIDDEN : undefined, + LayoutClasses.STATUSBAR_HIDDEN, // sessions window never has a status bar this.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined ]); } diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 2cd913c75f6..c4d28d53385 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -12,7 +12,7 @@ import { ContextKeyExpr, IContextKeyService } from '../../../../platform/context import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { appendUpdateMenuItems as registerUpdateMenuItems, CONTEXT_UPDATE_STATE } from '../../../../workbench/contrib/update/browser/update.js'; +import { appendUpdateMenuItems as registerUpdateMenuItems } from '../../../../workbench/contrib/update/browser/update.js'; import { Menus } from '../../../browser/menus.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -23,9 +23,14 @@ import { IAction } from '../../../../base/common/actions.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { Downloading, IUpdateService, State, StateType } from '../../../../platform/update/common/update.js'; -import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; -import { sessionsUpdateButtonDownloadingBackground, sessionsUpdateButtonDownloadedBackground } from '../../../common/theme.js'; +import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IHostService } from '../../../../workbench/services/host/browser/host.js'; +import { URI } from '../../../../base/common/uri.js'; +import { UpdateHoverWidget } from './updateHoverWidget.js'; // --- Account Menu Items --- // const AccountMenu = new MenuId('SessionsAccountMenu'); @@ -83,20 +88,29 @@ MenuRegistry.appendMenuItem(AccountMenu, { // Update actions registerUpdateMenuItems(AccountMenu, '3_updates'); -class AccountWidget extends ActionViewItem { +export class AccountWidget extends ActionViewItem { private accountButton: Button | undefined; + private updateButton: Button | undefined; + private readonly updateHoverWidget: UpdateHoverWidget; private readonly viewItemDisposables = this._register(new DisposableStore()); constructor( action: IAction, options: IBaseActionViewItemOptions, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IUpdateService private readonly updateService: IUpdateService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IHoverService private readonly hoverService: IHoverService, + @IProductService private readonly productService: IProductService, + @IOpenerService private readonly openerService: IOpenerService, + @IDialogService private readonly dialogService: IDialogService, + @IHostService private readonly hostService: IHostService, ) { super(undefined, action, { ...options, icon: false, label: false }); + this.updateHoverWidget = new UpdateHoverWidget(this.updateService, this.productService, this.hoverService); } protected override getTooltip(): string | undefined { @@ -121,14 +135,33 @@ class AccountWidget extends ActionViewItem { })); this.accountButton.element.classList.add('account-widget-account-button', 'sidebar-action-button'); + // Update button (right) + const updateContainer = append(container, $('.account-widget-update')); + this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + this.updateButton.element.classList.add('account-widget-update-button', 'sidebar-action-button'); + this.viewItemDisposables.add(this.updateHoverWidget.attachTo(this.updateButton.element)); + this.updateAccountButton(); this.viewItemDisposables.add(this.defaultAccountService.onDidChangeDefaultAccount(() => this.updateAccountButton())); + this.updateUpdateButton(); + this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); this.viewItemDisposables.add(this.accountButton.onDidClick(e => { e?.preventDefault(); e?.stopPropagation(); this.showAccountMenu(this.accountButton!.element); })); + + this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); } private showAccountMenu(anchor: HTMLElement): void { @@ -156,143 +189,100 @@ class AccountWidget extends ActionViewItem { : `$(${Codicon.account.id}) ${localize('signInLabel', "Sign In")}`; } - - override onClick(): void { - // Handled by custom click handlers - } -} - -export class UpdateWidget extends ActionViewItem { - - private updateButton: Button | undefined; - private readonly viewItemDisposables = this._register(new DisposableStore()); - - constructor( - action: IAction, - options: IBaseActionViewItemOptions, - @IUpdateService private readonly updateService: IUpdateService, - ) { - super(undefined, action, { ...options, icon: false, label: false }); - } - - protected override getTooltip(): string | undefined { - return undefined; - } - - override render(container: HTMLElement): void { - super.render(container); - container.classList.add('update-widget', 'sidebar-action'); - - const updateContainer = append(container, $('.update-widget-action')); - this.updateButton = this.viewItemDisposables.add(new Button(updateContainer, { - ...defaultButtonStyles, - secondary: true, - title: false, - supportIcons: true, - buttonSecondaryBackground: 'transparent', - buttonSecondaryHoverBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryBorder: undefined, - })); - this.updateButton.element.classList.add('update-widget-button', 'sidebar-action-button'); - this.viewItemDisposables.add(this.updateButton.onDidClick(() => this.update())); - - this.updateUpdateButton(); - this.viewItemDisposables.add(this.updateService.onStateChange(() => this.updateUpdateButton())); - } - - private isUpdateReady(): boolean { - return this.updateService.state.type === StateType.Ready; - } - - private isUpdatePending(): boolean { - const type = this.updateService.state.type; - return type === StateType.AvailableForDownload - || type === StateType.CheckingForUpdates - || type === StateType.Downloading - || type === StateType.Downloaded - || type === StateType.Updating - || type === StateType.Overwriting; - } - private updateUpdateButton(): void { if (!this.updateButton) { return; } const state = this.updateService.state; - if (this.isUpdatePending() && !this.isUpdateReady()) { - this.updateButton.enabled = false; - this.updateButton.label = `$(${Codicon.loading.id}~spin) ${this.getUpdateProgressMessage(state.type)}`; - this.updateDownloadProgress(state); - } else { + + // In the embedded app, updates are detected but cannot be installed directly. + // Show a hint button to update via VS Code only when an update is actually available. + if (state.type === StateType.AvailableForDownload && state.canInstall === false) { + this.updateButton.element.classList.remove('hidden'); + this.updateButton.element.classList.remove('account-widget-update-button-ready'); + this.updateButton.element.classList.add('account-widget-update-button-hint'); this.updateButton.enabled = true; - this.updateButton.label = `$(${Codicon.debugRestart.id}) ${localize('update', "Update")}`; - - const el = this.updateButton.element; - if (state.type === StateType.Ready) { - const color = asCssVariable(sessionsUpdateButtonDownloadedBackground); - el.style.backgroundImage = `linear-gradient(to right, ${color} 100%, transparent 100%)`; - } else { - // Ensure non-update states (e.g. Idle, Disabled, Uninitialized) do not look like a completed download - el.style.backgroundImage = ''; - } - } - } - - private updateDownloadProgress(state: State): void { - if (!this.updateButton) { + this.updateButton.label = localize('updateAvailable', "Update Available"); + this.updateButton.element.title = localize('updateInVSCodeHover', "Updates are managed by VS Code. Click to open VS Code."); return; } - const el = this.updateButton.element; - - if (state.type === StateType.Downloading) { - const { downloadedBytes, totalBytes } = state as Downloading; - if (downloadedBytes !== undefined && totalBytes && totalBytes > 0) { - const percent = Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)); - const color = asCssVariable(sessionsUpdateButtonDownloadingBackground); - el.style.backgroundImage = `linear-gradient(to right, ${color} ${percent}%, transparent ${percent}%)`; - } else { - // Indeterminate: show a subtle pulsing background - const color = asCssVariable(sessionsUpdateButtonDownloadingBackground); - el.style.backgroundImage = `linear-gradient(to right, ${color} 0%, transparent 100%)`; - } - } else if (state.type === StateType.Downloaded) { - const color = asCssVariable(sessionsUpdateButtonDownloadedBackground); - el.style.backgroundImage = `linear-gradient(to right, ${color} 100%, transparent 100%)`; - } else { - this.clearDownloadProgress(); + if (this.shouldHideUpdateButton(state.type)) { + this.clearUpdateButtonStyling(); + this.updateButton.element.classList.add('hidden'); + return; } + + this.updateButton.element.classList.remove('hidden'); + this.updateButton.element.style.backgroundImage = ''; + this.updateButton.enabled = state.type === StateType.Ready; + this.updateButton.label = this.getUpdateProgressMessage(state.type); + + if (state.type === StateType.Ready) { + this.updateButton.element.classList.add('account-widget-update-button-ready'); + return; + } + + this.updateButton.element.classList.remove('account-widget-update-button-ready'); } - private clearDownloadProgress(): void { + private shouldHideUpdateButton(type: StateType): boolean { + return type === StateType.Uninitialized + || type === StateType.Idle + || type === StateType.Disabled + || type === StateType.CheckingForUpdates; + } + + private clearUpdateButtonStyling(): void { if (this.updateButton) { this.updateButton.element.style.backgroundImage = ''; + this.updateButton.element.classList.remove('account-widget-update-button-ready'); } } private getUpdateProgressMessage(type: StateType): string { switch (type) { - case StateType.CheckingForUpdates: - return localize('checkingForUpdates', "Checking for Updates..."); + case StateType.Ready: + return localize('update', "Update"); + case StateType.AvailableForDownload: case StateType.Downloading: - return localize('downloadingUpdate', "Downloading Update..."); + case StateType.Overwriting: + return localize('downloadingUpdate', "Downloading..."); case StateType.Downloaded: - return localize('installingUpdate', "Installing Update..."); + return localize('installingUpdate', "Installing..."); case StateType.Updating: return localize('updatingApp', "Updating..."); - case StateType.Overwriting: - return localize('overwritingUpdate', "Downloading Update..."); default: return localize('updating', "Updating..."); } } private async update(): Promise { + const state = this.updateService.state; + if (state.type === StateType.AvailableForDownload && state.canInstall === false) { + const { confirmed } = await this.dialogService.confirm({ + message: localize('updateFromVSCode.title', "Update from VS Code"), + detail: localize('updateFromVSCode.detail', "This will close the Sessions app and open VS Code so you can install the update.\n\nLaunch Sessions again after the update is complete."), + primaryButton: localize('updateFromVSCode.open', "Close and Open VS Code"), + }); + if (confirmed) { + await this.openVSCode(); + await this.hostService.close(); + } + return; + } await this.updateService.quitAndInstall(); } + private async openVSCode(): Promise { + await this.openerService.open(URI.from({ + scheme: this.productService.urlProtocol, + query: 'windowId=_blank', + }), { openExternal: true }); + } + + override onClick(): void { // Handled by custom click handlers } @@ -315,11 +305,6 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu return instantiationService.createInstance(AccountWidget, action, options); }, undefined)); - const sessionsUpdateWidgetAction = 'sessions.action.updateWidget'; - this._register(actionViewItemService.register(Menus.SidebarFooter, sessionsUpdateWidgetAction, (action, options) => { - return instantiationService.createInstance(UpdateWidget, action, options); - }, undefined)); - // Register the action with menu item after the view item provider // so the toolbar picks up the custom widget this._register(registerAction2(class extends Action2 { @@ -339,30 +324,6 @@ class AccountWidgetContribution extends Disposable implements IWorkbenchContribu } })); - this._register(registerAction2(class extends Action2 { - constructor() { - super({ - id: sessionsUpdateWidgetAction, - title: localize2('sessionsUpdateWidget', 'Sessions Update'), - menu: { - id: Menus.SidebarFooter, - group: 'navigation', - order: 0, - when: ContextKeyExpr.or( - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Ready), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.AvailableForDownload), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloading), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Downloaded), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Updating), - CONTEXT_UPDATE_STATE.isEqualTo(StateType.Overwriting), - ) - } - }); - } - async run(): Promise { - // Handled by the custom view item - } - })); } } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index 01bdd2c100b..3e852ea53ba 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -3,6 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + min-width: 0; +} + +.account-widget { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + min-width: 0; +} + /* Account Button */ .monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account { overflow: hidden; @@ -10,9 +26,132 @@ flex: 1; } -/* Update Button */ -.monaco-workbench .part.sidebar > .sidebar-footer .update-widget-action { +.account-widget-account { overflow: hidden; min-width: 0; flex: 1; } + +/* Update Button */ +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update { + flex: 0 0 auto; + width: fit-content; + min-width: 0; + overflow: hidden; +} + +.account-widget-update { + flex: 0 0 auto; + width: fit-content; + min-width: 0; + overflow: hidden; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button { + width: auto; + max-width: none; +} + +.account-widget-update .account-widget-update-button { + width: auto; + max-width: none; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.account-widget-update-button-ready { + background-color: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-ready { + background-color: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; +} + +/* Boxed hint style for embedded app update indicator — outlined, no fill */ +.account-widget-update .account-widget-update-button.account-widget-update-button-hint { + background-color: transparent !important; + color: var(--vscode-button-foreground) !important; + border: 1px solid var(--vscode-button-background) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-hint:hover { + background-color: color-mix(in srgb, var(--vscode-button-background) 20%, transparent) !important; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.account-widget-update-button-ready:hover { + background-color: var(--vscode-button-background) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-ready:hover { + background-color: var(--vscode-button-background) !important; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.account-widget-update-button-ready:focus { + background-color: var(--vscode-button-background) !important; +} + +.account-widget-update .account-widget-update-button.account-widget-update-button-ready:focus { + background-color: var(--vscode-button-background) !important; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-update .account-widget-update-button.hidden { + display: none; +} + +.account-widget-update .account-widget-update-button.hidden { + display: none; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.account-widget-account .account-widget-account-button { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > .codicon { + flex: 0 0 auto; +} + +.account-widget-account .account-widget-account-button > .codicon { + flex: 0 0 auto; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > span:last-child { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.account-widget-account .account-widget-account-button > span:last-child { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.monaco-workbench .part.sidebar > .sidebar-footer .account-widget-account .account-widget-account-button > .monaco-button-label { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.account-widget-account .account-widget-account-button > .monaco-button-label { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css new file mode 100644 index 00000000000..6291d8e2922 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/media/updateHoverWidget.css @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.sessions-update-hover { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 200px; + padding: 12px 16px; +} + +.sessions-update-hover-header { + font-weight: 600; + font-size: 13px; +} + +/* Progress bar track */ +.sessions-update-hover-progress-track { + height: 4px; + border-radius: 2px; + background-color: var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.3)); + overflow: hidden; +} + +/* Progress bar fill */ +.sessions-update-hover-progress-fill { + height: 100%; + border-radius: 2px; + background-color: var(--vscode-progressBar-background, #0078d4); + transition: width 0.2s ease; +} + +/* Details grid */ +.sessions-update-hover-grid { + display: grid; + grid-template-columns: auto auto auto auto; + column-gap: 8px; + row-gap: 2px; + font-size: 12px; + align-items: baseline; +} + +.sessions-update-hover-label { + color: var(--vscode-descriptionForeground); +} + +/* Version number emphasis */ +.sessions-update-hover-version { + color: var(--vscode-textLink-foreground); +} + +/* Compact age label */ +.sessions-update-hover-age { + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +/* Commit hashes - subtle */ +.sessions-update-hover-commit { + color: var(--vscode-descriptionForeground); + font-family: var(--monaco-monospace-font); + font-size: 11px; +} diff --git a/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts b/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts new file mode 100644 index 00000000000..fc80636b0a8 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/browser/updateHoverWidget.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { localize } from '../../../../nls.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { Downloading, IUpdate, IUpdateService, State, StateType, Updating } from '../../../../platform/update/common/update.js'; +import './media/updateHoverWidget.css'; + +export class UpdateHoverWidget { + + constructor( + private readonly updateService: IUpdateService, + private readonly productService: IProductService, + private readonly hoverService: IHoverService, + ) { } + + attachTo(target: HTMLElement) { + return this.hoverService.setupDelayedHover( + target, + () => ({ + content: this.createHoverContent(), + position: { hoverPosition: HoverPosition.RIGHT }, + appearance: { showPointer: true } + }), + { groupId: 'sessions-account-update' } + ); + } + + createHoverContent(state: State = this.updateService.state): HTMLElement { + const update = this.getUpdateFromState(state); + const currentVersion = this.productService.version ?? localize('unknownVersion', "Unknown"); + const targetVersion = update?.productVersion ?? update?.version ?? localize('unknownVersion', "Unknown"); + const currentCommit = this.productService.commit; + const targetCommit = update?.version; + const progressPercent = this.getUpdateProgressPercent(state); + + const container = document.createElement('div'); + container.classList.add('sessions-update-hover'); + + // Header: e.g. "Downloading VS Code Insiders" + const header = document.createElement('div'); + header.classList.add('sessions-update-hover-header'); + header.textContent = this.getUpdateHeaderLabel(state.type); + container.appendChild(header); + + // Progress bar + if (progressPercent !== undefined) { + const progressTrack = document.createElement('div'); + progressTrack.classList.add('sessions-update-hover-progress-track'); + const progressFill = document.createElement('div'); + progressFill.classList.add('sessions-update-hover-progress-fill'); + progressFill.style.width = `${progressPercent}%`; + progressTrack.appendChild(progressFill); + container.appendChild(progressTrack); + } + + // Version info grid + const detailsGrid = document.createElement('div'); + detailsGrid.classList.add('sessions-update-hover-grid'); + + const currentDate = this.productService.date ? new Date(this.productService.date) : undefined; + const currentAge = currentDate ? this.formatCompactAge(currentDate.getTime()) : undefined; + const newAge = update?.timestamp ? this.formatCompactAge(update.timestamp) : undefined; + + this.appendGridRow(detailsGrid, localize('updateHoverCurrentVersionLabel', "Current"), currentVersion, currentAge, currentCommit); + this.appendGridRow(detailsGrid, localize('updateHoverNewVersionLabel', "New"), targetVersion, newAge, targetCommit); + + container.appendChild(detailsGrid); + + return container; + } + + private appendGridRow(grid: HTMLElement, label: string, version: string, age?: string, commit?: string): void { + const labelEl = document.createElement('span'); + labelEl.classList.add('sessions-update-hover-label'); + labelEl.textContent = label; + grid.appendChild(labelEl); + + const versionEl = document.createElement('span'); + versionEl.classList.add('sessions-update-hover-version'); + versionEl.textContent = version; + grid.appendChild(versionEl); + + const ageEl = document.createElement('span'); + ageEl.classList.add('sessions-update-hover-age'); + ageEl.textContent = age ?? ''; + grid.appendChild(ageEl); + + const commitEl = document.createElement('span'); + commitEl.classList.add('sessions-update-hover-commit'); + commitEl.textContent = commit ? commit.substring(0, 7) : ''; + grid.appendChild(commitEl); + } + + private formatCompactAge(timestamp: number): string { + const seconds = Math.round((Date.now() - timestamp) / 1000); + if (seconds < 60) { + return localize('compactAgeNow', "now"); + } + const minutes = Math.round(seconds / 60); + if (minutes < 60) { + return localize('compactAgeMinutes', "{0}m ago", minutes); + } + const hours = Math.round(seconds / 3600); + if (hours < 24) { + return localize('compactAgeHours', "{0}h ago", hours); + } + const days = Math.round(seconds / 86400); + if (days < 7) { + return localize('compactAgeDays', "{0}d ago", days); + } + const weeks = Math.round(days / 7); + if (weeks < 5) { + return localize('compactAgeWeeks', "{0}w ago", weeks); + } + const months = Math.round(days / 30); + return localize('compactAgeMonths', "{0}mo ago", months); + } + + private getUpdateFromState(state: State): IUpdate | undefined { + switch (state.type) { + case StateType.AvailableForDownload: + case StateType.Downloaded: + case StateType.Ready: + case StateType.Overwriting: + case StateType.Updating: + return state.update; + case StateType.Downloading: + return state.update; + default: + return undefined; + } + } + + /** + * Returns progress as a percentage (0-100), or undefined if progress is not applicable. + */ + private getUpdateProgressPercent(state: State): number | undefined { + switch (state.type) { + case StateType.Downloading: { + const downloadingState = state as Downloading; + if (downloadingState.downloadedBytes !== undefined && downloadingState.totalBytes && downloadingState.totalBytes > 0) { + return Math.min(100, Math.round((downloadingState.downloadedBytes / downloadingState.totalBytes) * 100)); + } + return 0; + } + case StateType.Updating: { + const updatingState = state as Updating; + if (updatingState.currentProgress !== undefined && updatingState.maxProgress && updatingState.maxProgress > 0) { + return Math.min(100, Math.round((updatingState.currentProgress / updatingState.maxProgress) * 100)); + } + return 0; + } + case StateType.Downloaded: + case StateType.Ready: + return 100; + case StateType.AvailableForDownload: + case StateType.Overwriting: + return 0; + default: + return undefined; + } + } + + private getUpdateHeaderLabel(type: StateType): string { + const productName = this.productService.nameShort; + switch (type) { + case StateType.Ready: + return localize('updateReady', "{0} Update Ready", productName); + case StateType.AvailableForDownload: + return localize('downloadAvailable', "{0} Update Available", productName); + case StateType.Downloading: + case StateType.Overwriting: + return localize('downloadingUpdate', "Downloading {0}", productName); + case StateType.Downloaded: + return localize('installingUpdate', "Installing {0}", productName); + case StateType.Updating: + return localize('updatingApp', "Updating {0}", productName); + default: + return localize('updating', "Updating {0}", productName); + } + } +} diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts new file mode 100644 index 00000000000..143b89aad16 --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/test/browser/accountWidget.fixture.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ICopilotTokenInfo, IDefaultAccount, IPolicyData } from '../../../../../base/common/defaultAccount.js'; +import { Action } from '../../../../../base/common/actions.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IUpdateService, State, UpdateType } from '../../../../../platform/update/common/update.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IHostService } from '../../../../../workbench/services/host/browser/host.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { AccountWidget } from '../../browser/account.contribution.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; +import '../../../../../platform/theme/common/colors/inputColors.js'; + +// Import the CSS +import '../../../../browser/media/sidebarActionButton.css'; +import '../../browser/media/accountWidget.css'; + +const mockUpdate = { version: '1.0.0' }; + +function createMockUpdateService(state: State): IUpdateService { + const onStateChange = new Emitter(); + const service: IUpdateService = { + _serviceBrand: undefined, + state, + onStateChange: onStateChange.event, + checkForUpdates: async () => { }, + downloadUpdate: async () => { }, + applyUpdate: async () => { }, + quitAndInstall: async () => { }, + isLatestVersion: async () => true, + _applySpecificUpdate: async () => { }, + setInternalOrg: async () => { }, + }; + return service; +} + +function createMockDefaultAccountService(accountPromise: Promise): IDefaultAccountService { + const onDidChangeDefaultAccount = new Emitter(); + const onDidChangePolicyData = new Emitter(); + const onDidChangeCopilotTokenInfo = new Emitter(); + const service: IDefaultAccountService = { + _serviceBrand: undefined, + onDidChangeDefaultAccount: onDidChangeDefaultAccount.event, + onDidChangePolicyData: onDidChangePolicyData.event, + onDidChangeCopilotTokenInfo: onDidChangeCopilotTokenInfo.event, + policyData: null, + copilotTokenInfo: null, + getDefaultAccount: () => accountPromise, + getDefaultAccountAuthenticationProvider: () => ({ id: 'github', name: 'GitHub', enterprise: false }), + setDefaultAccountProvider: () => { }, + refresh: () => accountPromise, + signIn: async () => null, + signOut: async () => { }, + }; + return service; +} + +function renderAccountWidget(ctx: ComponentFixtureContext, state: State, accountPromise: Promise): void { + ctx.container.style.padding = '16px'; + ctx.container.style.width = '340px'; + ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const mockUpdateService = createMockUpdateService(state); + const mockAccountService = createMockDefaultAccountService(accountPromise); + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: registerWorkbenchServices, + }); + + const action = ctx.disposableStore.add(new Action('sessions.action.accountWidget', 'Sessions Account')); + const contextMenuService = instantiationService.get(IContextMenuService); + const menuService = instantiationService.get(IMenuService); + const contextKeyService = instantiationService.get(IContextKeyService); + const hoverService = instantiationService.get(IHoverService); + const productService = instantiationService.get(IProductService); + const openerService = instantiationService.get(IOpenerService); + const dialogService = instantiationService.get(IDialogService); + const hostService = instantiationService.get(IHostService); + const widget = new AccountWidget(action, {}, mockAccountService, mockUpdateService, contextMenuService, menuService, contextKeyService, hoverService, productService, openerService, dialogService, hostService); + ctx.disposableStore.add(widget); + widget.render(ctx.container); +} + +const signedInAccount: IDefaultAccount = { + authenticationProvider: { + id: 'github', + name: 'GitHub', + enterprise: false, + }, + accountName: 'avery.long.account.name@example.com', + sessionId: 'session-id', + enterprise: false, +}; + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + LoadingSignedOutNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), new Promise(() => { })), + }), + + SignedOutNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), Promise.resolve(null)), + }), + + SignedInNoUpdate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Idle(UpdateType.Setup), Promise.resolve(signedInAccount)), + }), + + CheckingForUpdatesHidden: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.CheckingForUpdates(true), Promise.resolve(signedInAccount)), + }), + + Ready: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Ready(mockUpdate, true, false), Promise.resolve(signedInAccount)), + }), + + AvailableForDownload: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.AvailableForDownload(mockUpdate), Promise.resolve(signedInAccount)), + }), + + Downloading30Percent: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Downloading(mockUpdate, true, false, 30_000_000, 100_000_000), Promise.resolve(signedInAccount)), + }), + + DownloadedInstalling: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Downloaded(mockUpdate, true, false), Promise.resolve(signedInAccount)), + }), + + Updating: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Updating(mockUpdate), Promise.resolve(signedInAccount)), + }), + + Overwriting: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderAccountWidget(ctx, State.Overwriting(mockUpdate, true), Promise.resolve(signedInAccount)), + }), +}); diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts new file mode 100644 index 00000000000..6ca47e2ee1b --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/test/browser/updateHoverWidget.fixture.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../../base/common/event.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { UpdateHoverWidget } from '../../browser/updateHoverWidget.js'; + +const mockUpdate = { version: 'a1b2c3d4e5f6', productVersion: '1.100.0', timestamp: Date.now() - 2 * 60 * 60 * 1000 }; +const mockUpdateSameVersion = { version: 'a1b2c3d4e5f6', productVersion: '1.99.0', timestamp: Date.now() - 3 * 24 * 60 * 60 * 1000 }; + +function createMockUpdateService(state: State): IUpdateService { + const onStateChange = new Emitter(); + const service: IUpdateService = { + _serviceBrand: undefined, + state, + onStateChange: onStateChange.event, + checkForUpdates: async () => { }, + downloadUpdate: async () => { }, + applyUpdate: async () => { }, + quitAndInstall: async () => { }, + isLatestVersion: async () => true, + _applySpecificUpdate: async () => { }, + setInternalOrg: async () => { }, + }; + return service; +} + +function renderHoverWidget(ctx: ComponentFixtureContext, state: State): void { + ctx.container.style.backgroundColor = 'var(--vscode-editorHoverWidget-background)'; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + }); + + const updateService = createMockUpdateService(state); + const productService = new class extends mock() { + override readonly version = '1.99.0'; + override readonly nameShort = 'VS Code Insiders'; + override readonly commit = 'f0e1d2c3b4a5'; + override readonly date = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + }; + const hoverService = instantiationService.get(IHoverService); + const widget = new UpdateHoverWidget(updateService, productService, hoverService); + ctx.container.appendChild(widget.createHoverContent(state)); +} + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + UpdateHoverReady: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Ready(mockUpdate, true, false)), + }), + + UpdateHoverAvailableForDownload: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.AvailableForDownload(mockUpdate)), + }), + + UpdateHoverDownloading30Percent: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Downloading(mockUpdate, true, false, 30_000_000, 100_000_000)), + }), + + UpdateHoverInstalling: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Downloaded(mockUpdate, true, false)), + }), + + UpdateHoverUpdating: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Updating(mockUpdate, 40, 100)), + }), + + UpdateHoverSameVersion: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderHoverWidget(ctx, State.Ready(mockUpdateSameVersion, true, false)), + }), +}); diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/updateWidget.fixture.ts b/src/vs/sessions/contrib/accountMenu/test/browser/updateWidget.fixture.ts deleted file mode 100644 index a67edb78b11..00000000000 --- a/src/vs/sessions/contrib/accountMenu/test/browser/updateWidget.fixture.ts +++ /dev/null @@ -1,103 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Action } from '../../../../../base/common/actions.js'; -import { Emitter } from '../../../../../base/common/event.js'; -import { IUpdateService, State } from '../../../../../platform/update/common/update.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; -import { UpdateWidget } from '../../browser/account.contribution.js'; - -// Ensure color registrations are loaded -import '../../../../common/theme.js'; -import '../../../../../platform/theme/common/colors/inputColors.js'; - -// Import the CSS -import '../../../../browser/media/sidebarActionButton.css'; -import '../../browser/media/accountWidget.css'; - -const mockUpdate = { version: '1.0.0' }; - -function createMockUpdateService(state: State): IUpdateService { - const onStateChange = new Emitter(); - const service: IUpdateService = { - _serviceBrand: undefined, - state, - onStateChange: onStateChange.event, - checkForUpdates: async () => { }, - downloadUpdate: async () => { }, - applyUpdate: async () => { }, - quitAndInstall: async () => { }, - isLatestVersion: async () => true, - _applySpecificUpdate: async () => { }, - setInternalOrg: async () => { }, - }; - return service; -} - -function renderUpdateWidget(ctx: ComponentFixtureContext, state: State): void { - ctx.container.style.padding = '16px'; - ctx.container.style.width = '300px'; - ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)'; - - const mockService = createMockUpdateService(state); - - const instantiationService = createEditorServices(ctx.disposableStore, { - colorTheme: ctx.theme, - additionalServices: (reg) => { - reg.defineInstance(IUpdateService, mockService); - }, - }); - - const action = ctx.disposableStore.add(new Action('sessions.action.updateWidget', 'Sessions Update')); - const widget = instantiationService.createInstance(UpdateWidget, action, {}); - ctx.disposableStore.add(widget); - widget.render(ctx.container); -} - -export default defineThemedFixtureGroup({ - Ready: defineComponentFixture({ - render: (ctx) => renderUpdateWidget(ctx, State.Ready(mockUpdate, true, false)), - }), - - CheckingForUpdates: defineComponentFixture({ - render: (ctx) => renderUpdateWidget(ctx, State.CheckingForUpdates(true)), - }), - - AvailableForDownload: defineComponentFixture({ - render: (ctx) => renderUpdateWidget(ctx, State.AvailableForDownload(mockUpdate)), - }), - - Downloading0Percent: defineComponentFixture({ - render: (ctx) => renderUpdateWidget(ctx, State.Downloading(mockUpdate, true, false, 0, 100_000_000)), - }), - - Downloading30Percent: defineComponentFixture({ - render: (ctx) => renderUpdateWidget(ctx, State.Downloading(mockUpdate, true, false, 30_000_000, 100_000_000)), - }), - - Downloading65Percent: defineComponentFixture({ - render: (ctx) => renderUpdateWidget(ctx, State.Downloading(mockUpdate, true, false, 65_000_000, 100_000_000)), - }), - - Downloading100Percent: defineComponentFixture({ - render: (ctx) => renderUpdateWidget(ctx, State.Downloading(mockUpdate, true, false, 100_000_000, 100_000_000)), - }), - - DownloadingIndeterminate: defineComponentFixture({ - render: (ctx) => renderUpdateWidget(ctx, State.Downloading(mockUpdate, true, false)), - }), - - Downloaded: defineComponentFixture({ - render: (ctx) => renderUpdateWidget(ctx, State.Downloaded(mockUpdate, true, false)), - }), - - Updating: defineComponentFixture({ - render: (ctx) => renderUpdateWidget(ctx, State.Updating(mockUpdate)), - }), - - Overwriting: defineComponentFixture({ - render: (ctx) => renderUpdateWidget(ctx, State.Overwriting(mockUpdate, true)), - }), -}); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts index 7f5708a85d8..7ff02612cc1 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts @@ -5,7 +5,6 @@ import './agentFeedbackEditorInputContribution.js'; import './agentFeedbackEditorWidgetContribution.js'; -import './agentFeedbackLineDecorationContribution.js'; import './agentFeedbackOverviewRulerContribution.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts index e45f96e488d..04e66cf8dd0 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts @@ -74,6 +74,7 @@ export class AgentFeedbackAttachmentContribution extends Disposable { text: f.text, resourceUri: f.resourceUri, range: f.range, + codeSelection: this._snippetCache.get(f.id), })), value, }; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts index 620cf99ae3d..c16e39d3bde 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts @@ -6,24 +6,32 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { EditorsOrder, IEditorIdentifier } from '../../../../workbench/common/editor.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { GroupsOrder, IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { IAgentFeedbackService } from './agentFeedbackService.js'; -import { getActiveResourceCandidates } from './agentFeedbackEditorUtils.js'; +import { getActiveResourceCandidates, getSessionForResource } from './agentFeedbackEditorUtils.js'; import { Menus } from '../../../browser/menus.js'; +import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; +import { getSessionEditorComments } from './sessionEditorComments.js'; export const submitFeedbackActionId = 'agentFeedbackEditor.action.submit'; export const navigatePreviousFeedbackActionId = 'agentFeedbackEditor.action.navigatePrevious'; export const navigateNextFeedbackActionId = 'agentFeedbackEditor.action.navigateNext'; export const clearAllFeedbackActionId = 'agentFeedbackEditor.action.clearAll'; export const navigationBearingFakeActionId = 'agentFeedbackEditor.navigation.bearings'; +export const hasSessionEditorComments = new RawContextKey('agentFeedbackEditor.hasSessionComments', false); +export const hasSessionAgentFeedback = new RawContextKey('agentFeedbackEditor.hasAgentFeedback', false); abstract class AgentFeedbackEditorAction extends Action2 { @@ -37,16 +45,33 @@ abstract class AgentFeedbackEditorAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const editorService = accessor.get(IEditorService); const agentFeedbackService = accessor.get(IAgentFeedbackService); + const chatEditingService = accessor.get(IChatEditingService); + const agentSessionsService = accessor.get(IAgentSessionsService); + const codeReviewService = accessor.get(ICodeReviewService); - const candidates = getActiveResourceCandidates(editorService.activeEditorPane?.input); - const sessionResource = candidates - .map(candidate => agentFeedbackService.getMostRecentSessionForResource(candidate)) - .find((value): value is URI => !!value); - if (!sessionResource) { - return; + const editorGroupsService = accessor.get(IEditorGroupsService); + + const activePane = editorService.activeEditorPane + ?? editorGroupsService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE).find(g => g.activeEditorPane)?.activeEditorPane + ?? editorService.visibleEditorPanes[0]; + const candidates = getActiveResourceCandidates(activePane?.input); + for (const candidate of candidates) { + const sessionResource = getSessionForResource(candidate, chatEditingService, agentSessionsService) + ?? agentFeedbackService.getMostRecentSessionForResource(candidate); + if (!sessionResource) { + continue; + } + + const comments = getSessionEditorComments( + sessionResource, + agentFeedbackService.getFeedback(sessionResource), + codeReviewService.getReviewState(sessionResource).get(), + codeReviewService.getPRReviewState(sessionResource).get(), + ); + if (comments.length > 0) { + return this.runWithSession(accessor, sessionResource); + } } - - return this.runWithSession(accessor, sessionResource); } abstract runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise | void; @@ -65,7 +90,7 @@ class SubmitFeedbackAction extends AgentFeedbackEditorAction { id: Menus.AgentFeedbackEditorContent, group: 'a_submit', order: 0, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionAgentFeedback), }, }); } @@ -74,9 +99,11 @@ class SubmitFeedbackAction extends AgentFeedbackEditorAction { const chatWidgetService = accessor.get(IChatWidgetService); const agentFeedbackService = accessor.get(IAgentFeedbackService); const editorService = accessor.get(IEditorService); + const logService = accessor.get(ILogService); const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); if (!widget) { + logService.error('[AgentFeedback] Cannot submit feedback: no chat widget found for session', sessionResource.toString()); return; } @@ -114,27 +141,27 @@ class NavigateFeedbackAction extends AgentFeedbackEditorAction { id: Menus.AgentFeedbackEditorContent, group: 'navigate', order: _next ? 2 : 1, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionEditorComments), }, }); } - override runWithSession(accessor: ServicesAccessor, sessionResource: URI): void { + override async runWithSession(accessor: ServicesAccessor, sessionResource: URI): Promise { const agentFeedbackService = accessor.get(IAgentFeedbackService); - const editorService = accessor.get(IEditorService); + const codeReviewService = accessor.get(ICodeReviewService); + const comments = getSessionEditorComments( + sessionResource, + agentFeedbackService.getFeedback(sessionResource), + codeReviewService.getReviewState(sessionResource).get(), + codeReviewService.getPRReviewState(sessionResource).get(), + ); - const feedback = agentFeedbackService.getNextFeedback(sessionResource, this._next); - if (!feedback) { + const comment = agentFeedbackService.getNextNavigableItem(sessionResource, comments, this._next); + if (!comment) { return; } - editorService.openEditor({ - resource: feedback.resourceUri, - options: { - preserveFocus: false, - revealIfVisible: true, - } - }); + await agentFeedbackService.revealSessionComment(sessionResource, comment.id, comment.resourceUri, comment.range); } } @@ -152,7 +179,7 @@ class ClearAllFeedbackAction extends AgentFeedbackEditorAction { id: Menus.AgentFeedbackEditorContent, group: 'a_submit', order: 1, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionAgentFeedback), }, }); } @@ -177,6 +204,6 @@ export function registerAgentFeedbackEditorActions(): void { }, group: 'navigate', order: -1, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, hasSessionEditorComments), }); } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 707a1090912..dc2434cfb45 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -342,15 +342,29 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements return; } + // Don't capture Escape at this level - let it fall through to the input handler if focused + if (e.keyCode === KeyCode.Escape) { + this._hide(); + this._editor.focus(); + return; + } + + // Ctrl+I / Cmd+I explicitly focuses the feedback input + if ((e.ctrlKey || e.metaKey) && e.keyCode === KeyCode.KeyI) { + e.preventDefault(); + e.stopPropagation(); + widget.inputElement.focus(); + return; + } + // Don't focus if any modifier is held (keyboard shortcuts) if (e.ctrlKey || e.altKey || e.metaKey) { return; } - // Don't capture Escape at this level - let it fall through to the input handler if focused - if (e.keyCode === KeyCode.Escape) { - this._hide(); - this._editor.focus(); + // Only auto-focus the input on typing when the document is readonly; + // when editable the user must click or use Ctrl+I to focus. + if (!this._editor.getOption(EditorOption.readOnly)) { return; } @@ -413,6 +427,12 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements })); } + focusInput(): void { + if (this._visible && this._widget) { + this._widget.inputElement.focus(); + } + } + private _addFeedback(): boolean { if (!this._widget) { return false; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts index 56cb43ad934..7780c40acab 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts @@ -18,13 +18,15 @@ import { IWorkbenchContribution } from '../../../../workbench/common/contributio import { EditorGroupView } from '../../../../workbench/browser/parts/editor/editorGroupView.js'; import { IEditorGroup, IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { IAgentFeedbackService } from './agentFeedbackService.js'; -import { navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from './agentFeedbackEditorActions.js'; +import { hasSessionAgentFeedback, hasSessionEditorComments, navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from './agentFeedbackEditorActions.js'; import { assertType } from '../../../../base/common/types.js'; import { localize } from '../../../../nls.js'; import { getActiveResourceCandidates, getSessionForResource } from './agentFeedbackEditorUtils.js'; import { Menus } from '../../../browser/menus.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; +import { getSessionEditorComments, hasAgentFeedbackComments } from './sessionEditorComments.js'; class AgentFeedbackActionViewItem extends ActionViewItem { @@ -54,7 +56,7 @@ class AgentFeedbackActionViewItem extends ActionViewItem { } } -class AgentFeedbackOverlayWidget extends Disposable { +export class AgentFeedbackOverlayWidget extends Disposable { private readonly _domNode: HTMLElement; private readonly _toolbarNode: HTMLElement; @@ -145,6 +147,8 @@ class AgentFeedbackOverlayController { @IAgentSessionsService agentSessionsService: IAgentSessionsService, @IInstantiationService instaService: IInstantiationService, @IChatEditingService chatEditingService: IChatEditingService, + @IContextKeyService contextKeyService: IContextKeyService, + @ICodeReviewService codeReviewService: ICodeReviewService, ) { this._domNode.classList.add('agent-feedback-editor-overlay'); this._domNode.style.position = 'absolute'; @@ -155,6 +159,8 @@ class AgentFeedbackOverlayController { const widget = this._store.add(instaService.createInstance(AgentFeedbackOverlayWidget)); this._domNode.appendChild(widget.getDomNode()); this._store.add(toDisposable(() => this._domNode.remove())); + const hasCommentsContext = hasSessionEditorComments.bindTo(contextKeyService); + const hasAgentFeedbackContext = hasSessionAgentFeedback.bindTo(contextKeyService); const show = () => { if (!container.contains(this._domNode)) { @@ -181,19 +187,35 @@ class AgentFeedbackOverlayController { const candidates = getActiveResourceCandidates(group.activeEditorPane?.input); let navigationBearings = undefined; + let hasAgentFeedback = false; for (const candidate of candidates) { const sessionResource = getSessionForResource(candidate, chatEditingService, agentSessionsService); - if (sessionResource && agentFeedbackService.getFeedback(sessionResource).length > 0) { - navigationBearings = agentFeedbackService.getNavigationBearing(sessionResource); + if (!sessionResource) { + continue; + } + + const comments = getSessionEditorComments( + sessionResource, + agentFeedbackService.getFeedback(sessionResource), + codeReviewService.getReviewState(sessionResource).read(r), + codeReviewService.getPRReviewState(sessionResource).read(r), + ); + if (comments.length > 0) { + navigationBearings = agentFeedbackService.getNavigationBearing(sessionResource, comments); + hasAgentFeedback = hasAgentFeedbackComments(comments); break; } } if (!navigationBearings) { + hasCommentsContext.set(false); + hasAgentFeedbackContext.set(false); hide(); return; } + hasCommentsContext.set(true); + hasAgentFeedbackContext.set(hasAgentFeedback); widget.show(navigationBearings); show(); })); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts index 42e5e404bed..09ccece7717 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts @@ -5,9 +5,11 @@ import './media/agentFeedbackEditorWidget.css'; +import { Action } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; +import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; import { IEditorContribution, IEditorDecorationsCollection, ScrollType } from '../../../../editor/common/editorCommon.js'; import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; @@ -19,46 +21,20 @@ import { Range } from '../../../../editor/common/core/range.js'; import { overviewRulerRangeHighlight } from '../../../../editor/common/core/editorColorRegistry.js'; import { OverviewRulerLane } from '../../../../editor/common/model.js'; import { themeColorFromId } from '../../../../platform/theme/common/themeService.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import * as nls from '../../../../nls.js'; -import { IAgentFeedback, IAgentFeedbackService } from './agentFeedbackService.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { getSessionForResource } from './agentFeedbackEditorUtils.js'; - -/** - * Groups nearby feedback items within a threshold number of lines. - */ -function groupNearbyFeedback(items: readonly IAgentFeedback[], lineThreshold: number = 5): IAgentFeedback[][] { - if (items.length === 0) { - return []; - } - - // Sort by start line number - const sorted = [...items].sort((a, b) => a.range.startLineNumber - b.range.startLineNumber); - - const groups: IAgentFeedback[][] = []; - let currentGroup: IAgentFeedback[] = [sorted[0]]; - - for (let i = 1; i < sorted.length; i++) { - const firstItem = currentGroup[0]; - const currentItem = sorted[i]; - - const verticalSpan = currentItem.range.startLineNumber - firstItem.range.startLineNumber; - - if (verticalSpan <= lineThreshold) { - currentGroup.push(currentItem); - } else { - groups.push(currentGroup); - currentGroup = [currentItem]; - } - } - - if (currentGroup.length > 0) { - groups.push(currentGroup); - } - - return groups; -} +import { ICodeReviewService, IPRReviewState } from '../../codeReview/browser/codeReviewService.js'; +import { getSessionEditorComments, groupNearbySessionEditorComments, ISessionEditorComment, SessionEditorCommentSource, toSessionEditorCommentId } from './sessionEditorComments.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; /** * Widget that displays agent feedback comments for a group of nearby feedback items. @@ -72,7 +48,6 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid private readonly _domNode: HTMLElement; private readonly _headerNode: HTMLElement; private readonly _titleNode: HTMLElement; - private readonly _dismissButton: HTMLElement; private readonly _toggleButton: HTMLElement; private readonly _bodyNode: HTMLElement; private readonly _itemElements = new Map(); @@ -87,9 +62,11 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid constructor( private readonly _editor: ICodeEditor, - private readonly _feedbackItems: readonly IAgentFeedback[], - private readonly _agentFeedbackService: IAgentFeedbackService, + private readonly _commentItems: readonly ISessionEditorComment[], private readonly _sessionResource: URI, + @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + @ICodeReviewService private readonly _codeReviewService: ICodeReviewService, + @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, ) { super(); @@ -115,12 +92,6 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._updateToggleButton(); this._headerNode.appendChild(this._toggleButton); - // Dismiss button - this._dismissButton = $('div.agent-feedback-widget-dismiss'); - this._dismissButton.appendChild(renderIcon(Codicon.close)); - this._dismissButton.title = nls.localize('dismiss', "Dismiss"); - this._headerNode.appendChild(this._dismissButton); - this._domNode.appendChild(this._headerNode); // Body (collapsible) — starts collapsed @@ -155,11 +126,6 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._toggleExpanded(); })); - // Dismiss button click - this._eventStore.add(addDisposableListener(this._dismissButton, 'click', (e) => { - e.stopPropagation(); - this._dismiss(); - })); } private _toggleExpanded(): void { @@ -170,27 +136,8 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid } } - private _dismiss(): void { - // Remove all feedback items in this widget from the service - for (const feedback of this._feedbackItems) { - this._agentFeedbackService.removeFeedback(this._sessionResource, feedback.id); - } - - this._domNode.classList.add('fadeOut'); - - const dispose = () => { - this.dispose(); - }; - - const handle = setTimeout(dispose, 150); - this._domNode.addEventListener('animationend', () => { - clearTimeout(handle); - dispose(); - }, { once: true }); - } - private _updateTitle(): void { - const count = this._feedbackItems.length; + const count = this._commentItems.length; if (count === 1) { this._titleNode.textContent = nls.localize('oneComment', "1 comment"); } else { @@ -213,37 +160,151 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid clearNode(this._bodyNode); this._itemElements.clear(); - for (const feedback of this._feedbackItems) { + for (const comment of this._commentItems) { const item = $('div.agent-feedback-widget-item'); - this._itemElements.set(feedback.id, item); - - // Line indicator - const lineInfo = $('span.agent-feedback-widget-line-info'); - if (feedback.range.startLineNumber === feedback.range.endLineNumber) { - lineInfo.textContent = nls.localize('lineNumber', "Line {0}", feedback.range.startLineNumber); - } else { - lineInfo.textContent = nls.localize('lineRange', "Lines {0}-{1}", feedback.range.startLineNumber, feedback.range.endLineNumber); + item.classList.add(`agent-feedback-widget-item-${comment.source}`); + if (comment.suggestion) { + item.classList.add('agent-feedback-widget-item-suggestion'); } - item.appendChild(lineInfo); + this._itemElements.set(comment.id, item); - // Feedback text - const text = $('span.agent-feedback-widget-text'); - text.textContent = feedback.text; + const itemHeader = $('div.agent-feedback-widget-item-header'); + const itemMeta = $('div.agent-feedback-widget-item-meta'); + + const lineInfo = $('span.agent-feedback-widget-line-info'); + if (comment.range.startLineNumber === comment.range.endLineNumber) { + lineInfo.textContent = nls.localize('lineNumber', "Line {0}", comment.range.startLineNumber); + } else { + lineInfo.textContent = nls.localize('lineRange', "Lines {0}-{1}", comment.range.startLineNumber, comment.range.endLineNumber); + } + itemMeta.appendChild(lineInfo); + + if (comment.source !== SessionEditorCommentSource.AgentFeedback) { + const typeBadge = $('span.agent-feedback-widget-item-type'); + typeBadge.textContent = this._getTypeLabel(comment); + itemMeta.appendChild(typeBadge); + } + + itemHeader.appendChild(itemMeta); + + const actionBarContainer = $('div.agent-feedback-widget-item-actions'); + const actionBar = this._eventStore.add(new ActionBar(actionBarContainer)); + if (comment.canConvertToAgentFeedback) { + actionBar.push(new Action( + 'agentFeedback.widget.convert', + nls.localize('convertComment', "Convert to Agent Feedback"), + ThemeIcon.asClassName(Codicon.check), + true, + () => this._convertToAgentFeedback(comment), + ), { icon: true, label: false }); + } + actionBar.push(new Action( + 'agentFeedback.widget.remove', + nls.localize('removeComment', "Remove"), + ThemeIcon.asClassName(Codicon.close), + true, + () => this._removeComment(comment), + ), { icon: true, label: false }); + itemHeader.appendChild(actionBarContainer); + item.appendChild(itemHeader); + + const text = $('div.agent-feedback-widget-text'); + const rendered = this._markdownRendererService.render(new MarkdownString(comment.text)); + this._eventStore.add(rendered); + text.appendChild(rendered.element); item.appendChild(text); - // Hover handlers for range highlighting + if (comment.suggestion?.edits.length) { + item.appendChild(this._renderSuggestion(comment)); + } + this._eventStore.add(addDisposableListener(item, 'mouseenter', () => { - this._highlightRange(feedback); + this._highlightRange(comment); })); this._eventStore.add(addDisposableListener(item, 'mouseleave', () => { this._rangeHighlightDecoration.clear(); })); + this._eventStore.add(addDisposableListener(item, 'click', e => { + if ((e.target as HTMLElement | null)?.closest('.action-bar')) { + return; + } + this.focusFeedback(comment.id); + this._agentFeedbackService.setNavigationAnchor(this._sessionResource, comment.id); + this._revealComment(comment); + })); + this._bodyNode.appendChild(item); } } + private _getTypeLabel(comment: ISessionEditorComment): string { + if (comment.source === SessionEditorCommentSource.PRReview) { + return nls.localize('prReviewComment', "PR Review"); + } + + if (comment.source === SessionEditorCommentSource.CodeReview) { + return comment.suggestion + ? nls.localize('reviewSuggestion', "Review Suggestion") + : nls.localize('reviewComment', "Review"); + } + + return comment.suggestion + ? nls.localize('feedbackSuggestion', "Feedback Suggestion") + : nls.localize('feedbackComment', "Feedback"); + } + + private _renderSuggestion(comment: ISessionEditorComment): HTMLElement { + const suggestionNode = $('div.agent-feedback-widget-suggestion'); + const title = $('div.agent-feedback-widget-suggestion-title'); + title.textContent = nls.localize('suggestedChange', "Suggested Change"); + suggestionNode.appendChild(title); + + for (const edit of comment.suggestion?.edits ?? []) { + const editNode = $('div.agent-feedback-widget-suggestion-edit'); + const rangeLabel = $('div.agent-feedback-widget-suggestion-range'); + if (edit.range.startLineNumber === edit.range.endLineNumber) { + rangeLabel.textContent = nls.localize('suggestionLineNumber', "Line {0}", edit.range.startLineNumber); + } else { + rangeLabel.textContent = nls.localize('suggestionLineRange', "Lines {0}-{1}", edit.range.startLineNumber, edit.range.endLineNumber); + } + editNode.appendChild(rangeLabel); + + const newText = $('pre.agent-feedback-widget-suggestion-text'); + newText.textContent = edit.newText; + editNode.appendChild(newText); + suggestionNode.appendChild(editNode); + } + + return suggestionNode; + } + + private _removeComment(comment: ISessionEditorComment): void { + if (comment.source === SessionEditorCommentSource.PRReview) { + this._codeReviewService.resolvePRReviewThread(this._sessionResource!, comment.sourceId); + return; + } + if (comment.source === SessionEditorCommentSource.CodeReview) { + this._codeReviewService.removeComment(this._sessionResource, comment.sourceId); + return; + } + + this._agentFeedbackService.removeFeedback(this._sessionResource, comment.sourceId); + } + + private _convertToAgentFeedback(comment: ISessionEditorComment): void { + if (!comment.canConvertToAgentFeedback) { + return; + } + + const feedback = this._agentFeedbackService.addFeedback(this._sessionResource, comment.resourceUri, comment.range, comment.text, comment.suggestion); + this._agentFeedbackService.setNavigationAnchor(this._sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedback.id)); + if (comment.source === SessionEditorCommentSource.CodeReview) { + this._codeReviewService.removeComment(this._sessionResource, comment.sourceId); + } + } + /** * Expand the widget body. */ @@ -277,7 +338,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid el.classList.remove('focused'); } - const feedback = this._feedbackItems.find(f => f.id === feedbackId); + const feedback = this._commentItems.find(f => f.id === feedbackId); if (!feedback) { return; } @@ -300,7 +361,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._rangeHighlightDecoration.clear(); } - private _highlightRange(feedback: IAgentFeedback): void { + private _highlightRange(feedback: ISessionEditorComment): void { const endLineNumber = feedback.range.endLineNumber; const range = new Range( feedback.range.startLineNumber, 1, @@ -333,7 +394,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid * Returns true if this widget contains the given feedback item (by id). */ containsFeedback(feedbackId: string): boolean { - return this._feedbackItems.some(f => f.id === feedbackId); + return this._commentItems.some(f => f.id === feedbackId); } /** @@ -374,8 +435,8 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid */ toggle(show: boolean): void { this._domNode.classList.toggle('visible', show); - if (show && this._feedbackItems.length > 0) { - this.layout(this._feedbackItems[0].range.startLineNumber); + if (show && this._commentItems.length > 0) { + this.layout(this._commentItems[0].range.startLineNumber); } } @@ -411,6 +472,16 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._editor.removeOverlayWidget(this); super.dispose(); } + + private _revealComment(comment: ISessionEditorComment): void { + const range = new Range( + comment.range.startLineNumber, + 1, + comment.range.endLineNumber, + this._editor.getModel()?.getLineMaxColumn(comment.range.endLineNumber) ?? 1, + ); + this._editor.revealRangeInCenterIfOutsideViewport(range, ScrollType.Smooth); + } } /** @@ -430,25 +501,21 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, @IChatEditingService private readonly _chatEditingService: IChatEditingService, @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @ICodeReviewService private readonly _codeReviewService: ICodeReviewService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); - this._store.add(this._agentFeedbackService.onDidChangeFeedback(e => { - if (this._sessionResource && e.sessionResource.toString() === this._sessionResource.toString()) { - this._rebuildWidgets(); - } - })); - this._store.add(this._agentFeedbackService.onDidChangeNavigation(sessionResource => { if (this._sessionResource && sessionResource.toString() === this._sessionResource.toString()) { this._handleNavigation(); } })); - this._store.add(this._editor.onDidChangeModel(() => { - this._resolveSession(); - this._rebuildWidgets(); - })); + const rebuildSignal = observableSignalFromEvent(this, Event.any( + this._agentFeedbackService.onDidChangeFeedback, + this._editor.onDidChangeModel, + )); this._store.add(Event.any(this._editor.onDidScrollChange, this._editor.onDidLayoutChange)(() => { for (const widget of this._widgets) { @@ -456,8 +523,20 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito } })); - this._resolveSession(); - this._rebuildWidgets(); + this._store.add(autorun(reader => { + rebuildSignal.read(reader); + this._resolveSession(); + if (!this._sessionResource) { + this._clearWidgets(); + return; + } + + this._rebuildWidgets( + this._codeReviewService.getReviewState(this._sessionResource).read(reader), + this._codeReviewService.getPRReviewState(this._sessionResource).read(reader), + ); + this._handleNavigation(); + })); } private _resolveSession(): void { @@ -469,9 +548,88 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService); } - private _rebuildWidgets(): void { + private _rebuildWidgets( + reviewState = this._sessionResource ? this._codeReviewService.getReviewState(this._sessionResource).get() : undefined, + prReviewState: IPRReviewState | undefined = this._sessionResource ? this._codeReviewService.getPRReviewState(this._sessionResource).get() : undefined, + ): void { this._clearWidgets(); + if (!this._sessionResource || !reviewState) { + return; + } + + const model = this._editor.getModel(); + if (!model) { + return; + } + + const comments = getSessionEditorComments( + this._sessionResource, + this._agentFeedbackService.getFeedback(this._sessionResource), + reviewState, + prReviewState, + ); + const fileComments = this._getCommentsForModel(model.uri, comments); + if (fileComments.length === 0) { + return; + } + + const groups = groupNearbySessionEditorComments(fileComments, 5); + + for (const group of groups) { + const widget = this._instantiationService.createInstance(AgentFeedbackEditorWidget, this._editor, group, this._sessionResource); + this._widgets.push(widget); + + widget.layout(group[0].range.startLineNumber); + } + } + + private _getCommentsForModel(resourceUri: URI, comments: readonly ISessionEditorComment[]): readonly ISessionEditorComment[] { + const change = this._getSessionChangeForResource(resourceUri); + if (!change) { + return comments.filter(comment => isEqual(comment.resourceUri, resourceUri)); + } + + if (!this._isCurrentOrModifiedResource(change, resourceUri)) { + return []; + } + + return comments.filter(comment => comment.resourceUri.fsPath === resourceUri.fsPath); + } + + private _getSessionChangeForResource(resourceUri: URI): IChatSessionFileChange | IChatSessionFileChange2 | undefined { + if (!this._sessionResource) { + return undefined; + } + + const changes = this._agentSessionsService.getSession(this._sessionResource)?.changes; + if (!(changes instanceof Array)) { + return undefined; + } + + return changes.find(change => this._changeMatchesFsPath(change, resourceUri)); + } + + private _changeMatchesFsPath(change: IChatSessionFileChange | IChatSessionFileChange2, resourceUri: URI): boolean { + if (isIChatSessionFileChange2(change)) { + return change.uri.fsPath === resourceUri.fsPath + || change.modifiedUri?.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath; + } + + return change.modifiedUri.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath; + } + + private _isCurrentOrModifiedResource(change: IChatSessionFileChange | IChatSessionFileChange2, resourceUri: URI): boolean { + if (isIChatSessionFileChange2(change)) { + return isEqual(change.uri, resourceUri) || (change.modifiedUri ? isEqual(change.modifiedUri, resourceUri) : false); + } + + return isEqual(change.modifiedUri, resourceUri); + } + + private _handleNavigation(): void { if (!this._sessionResource) { return; } @@ -481,39 +639,29 @@ class AgentFeedbackEditorWidgetContribution extends Disposable implements IEdito return; } - const allFeedback = this._agentFeedbackService.getFeedback(this._sessionResource); - // Filter to feedback items belonging to this editor's file - const fileFeedback = allFeedback.filter(f => f.resourceUri.toString() === model.uri.toString()); - if (fileFeedback.length === 0) { - return; - } - - const groups = groupNearbyFeedback(fileFeedback, 5); - - for (const group of groups) { - const widget = new AgentFeedbackEditorWidget(this._editor, group, this._agentFeedbackService, this._sessionResource); - this._widgets.push(widget); - - widget.layout(group[0].range.startLineNumber); - } - } - - private _handleNavigation(): void { - if (!this._sessionResource) { - return; - } - - const bearing = this._agentFeedbackService.getNavigationBearing(this._sessionResource); + const comments = getSessionEditorComments( + this._sessionResource, + this._agentFeedbackService.getFeedback(this._sessionResource), + this._codeReviewService.getReviewState(this._sessionResource).get(), + this._codeReviewService.getPRReviewState(this._sessionResource).get(), + ); + const bearing = this._agentFeedbackService.getNavigationBearing(this._sessionResource, comments); if (bearing.activeIdx < 0) { return; } - const allFeedback = this._agentFeedbackService.getFeedback(this._sessionResource); - const activeFeedback = allFeedback[bearing.activeIdx]; + const activeFeedback = comments[bearing.activeIdx]; if (!activeFeedback) { return; } + if (this._getCommentsForModel(model.uri, [activeFeedback]).length === 0) { + for (const widget of this._widgets) { + widget.collapse(); + } + return; + } + // Expand the widget containing the active feedback, collapse all others for (const widget of this._widgets) { if (widget.containsFeedback(activeFeedback.id)) { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts index 9b38ad3c63b..ea4414455e5 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts @@ -15,10 +15,8 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { basename } from '../../../../base/common/path.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { IRange } from '../../../../editor/common/core/range.js'; import { URI } from '../../../../base/common/uri.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; import { localize } from '../../../../nls.js'; import { FileKind } from '../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -44,7 +42,7 @@ interface IFeedbackCommentElement { readonly id: string; readonly text: string; readonly resourceUri: URI; - readonly range: IRange; + readonly codeSelection?: string; } type FeedbackTreeElement = IFeedbackFileElement | IFeedbackCommentElement; @@ -151,7 +149,6 @@ class FeedbackCommentRenderer implements ITreeRenderer(); - - constructor( - private readonly _editor: ICodeEditor, - @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, - @IChatEditingService private readonly _chatEditingService: IChatEditingService, - @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, - ) { - super(); - - this._store.add(this._agentFeedbackService.onDidChangeFeedback(() => this._updateFeedbackLines())); - this._store.add(this._editor.onDidChangeModel(() => this._onModelChanged())); - this._store.add(this._editor.onMouseMove((e: IEditorMouseEvent) => this._onMouseMove(e))); - this._store.add(this._editor.onMouseLeave(() => this._updateHintDecoration(-1))); - this._store.add(this._editor.onMouseDown((e: IEditorMouseEvent) => this._onMouseDown(e))); - - this._resolveSession(); - this._updateFeedbackLines(); - } - - private _onModelChanged(): void { - this._updateHintDecoration(-1); - this._resolveSession(); - this._updateFeedbackLines(); - } - - private _resolveSession(): void { - const model = this._editor.getModel(); - if (!model) { - this._sessionResource = undefined; - return; - } - this._sessionResource = getSessionForResource(model.uri, this._chatEditingService, this._agentSessionsService); - } - - private _updateFeedbackLines(): void { - if (!this._sessionResource) { - this._feedbackLines.clear(); - return; - } - - const feedbackItems = this._agentFeedbackService.getFeedback(this._sessionResource); - const lines = new Set(); - - for (const item of feedbackItems) { - const model = this._editor.getModel(); - if (!model || item.resourceUri.toString() !== model.uri.toString()) { - continue; - } - - lines.add(item.range.startLineNumber); - } - - this._feedbackLines = lines; - } - - private _onMouseMove(e: IEditorMouseEvent): void { - if (!this._sessionResource) { - this._updateHintDecoration(-1); - return; - } - - const isLineDecoration = e.target.type === MouseTargetType.GUTTER_LINE_DECORATIONS && !e.target.detail.isAfterLines; - const isContentArea = e.target.type === MouseTargetType.CONTENT_TEXT || e.target.type === MouseTargetType.CONTENT_EMPTY; - if (e.target.position - && (isLineDecoration || isContentArea) - && !this._feedbackLines.has(e.target.position.lineNumber) - ) { - this._updateHintDecoration(e.target.position.lineNumber); - } else { - this._updateHintDecoration(-1); - } - } - - private _updateHintDecoration(line: number): void { - if (line === this._hintLine) { - return; - } - - this._hintLine = line; - this._editor.changeDecorations(accessor => { - if (this._hintDecorationId) { - accessor.removeDecoration(this._hintDecorationId); - this._hintDecorationId = null; - } - if (line !== -1) { - this._hintDecorationId = accessor.addDecoration( - new Range(line, 1, line, 1), - addFeedbackHintDecoration, - ); - } - }); - } - - private _onMouseDown(e: IEditorMouseEvent): void { - if (!e.target.position - || e.target.type !== MouseTargetType.GUTTER_LINE_DECORATIONS - || e.target.detail.isAfterLines - || !this._sessionResource - ) { - return; - } - - const lineNumber = e.target.position.lineNumber; - - // Lines with existing feedback - do nothing - if (this._feedbackLines.has(lineNumber)) { - return; - } - - // Select the line content and focus the editor - const model = this._editor.getModel(); - if (!model) { - return; - } - - const startColumn = model.getLineFirstNonWhitespaceColumn(lineNumber); - const endColumn = model.getLineLastNonWhitespaceColumn(lineNumber); - if (startColumn === 0 || endColumn === 0) { - // Empty line - select the whole line range - this._editor.setSelection(new Selection(lineNumber, model.getLineMaxColumn(lineNumber), lineNumber, 1)); - } else { - this._editor.setSelection(new Selection(lineNumber, endColumn, lineNumber, startColumn)); - } - this._editor.focus(); - } - - override dispose(): void { - this._updateHintDecoration(-1); - super.dispose(); - } -} - -registerEditorContribution(AgentFeedbackLineDecorationContribution.ID, AgentFeedbackLineDecorationContribution, EditorContributionInstantiation.Eventually); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 550c1d961b1..c92a4ab67c4 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -11,11 +11,14 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { generateUuid } from '../../../../base/common/uuid.js'; import { isEqual } from '../../../../base/common/resources.js'; import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { agentSessionContainsResource, editingEntriesContainResource } from '../../../../workbench/contrib/chat/browser/sessionResourceMatching.js'; -import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ICodeReviewSuggestion } from '../../codeReview/browser/codeReviewService.js'; // --- Types -------------------------------------------------------------------- @@ -25,6 +28,11 @@ export interface IAgentFeedback { readonly resourceUri: URI; readonly range: IRange; readonly sessionResource: URI; + readonly suggestion?: ICodeReviewSuggestion; +} + +export interface INavigableSessionComment { + readonly id: string; } export interface IAgentFeedbackChangeEvent { @@ -50,7 +58,7 @@ export interface IAgentFeedbackService { /** * Add a feedback item for the given session. */ - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback; + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion): IAgentFeedback; /** * Remove a single feedback item. @@ -72,15 +80,23 @@ export interface IAgentFeedbackService { */ revealFeedback(sessionResource: URI, feedbackId: string): Promise; + /** + * Open an editor for the given session comment (feedback or code-review) at its range + * and set it as the navigation anchor. + */ + revealSessionComment(sessionResource: URI, commentId: string, resourceUri: URI, range: IRange): Promise; + /** * Navigate to next/previous feedback item in a session. */ getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined; + getNextNavigableItem(sessionResource: URI, items: readonly T[], next: boolean): T | undefined; + setNavigationAnchor(sessionResource: URI, itemId: string | undefined): void; /** * Get the current navigation bearings for a session. */ - getNavigationBearing(sessionResource: URI): IAgentFeedbackNavigationBearing; + getNavigationBearing(sessionResource: URI, items?: readonly INavigableSessionComment[]): IAgentFeedbackNavigationBearing; /** * Clear all feedback items for a session (e.g., after sending). @@ -91,7 +107,7 @@ export interface IAgentFeedbackService { * Add a feedback item and then submit the feedback. Waits for the * attachment to be updated in the chat widget before submitting. */ - addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string): Promise; + addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion): Promise; } // --- Implementation ----------------------------------------------------------- @@ -117,11 +133,12 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe @IEditorService private readonly _editorService: IEditorService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @ICommandService private readonly _commandService: ICommandService, + @ILogService private readonly _logService: ILogService, ) { super(); } - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string): IAgentFeedback { + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion): IAgentFeedback { const key = sessionResource.toString(); let feedbackItems = this._feedbackBySession.get(key); if (!feedbackItems) { @@ -135,6 +152,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe resourceUri, range, sessionResource, + suggestion, }; // Insert at the correct sorted position. @@ -260,50 +278,131 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe if (!feedback) { return; } - await this._editorService.openEditor({ - resource: feedback.resourceUri, - options: { - preserveFocus: false, - revealIfVisible: true, - } - }); - setTimeout(() => { - this._navigationAnchorBySession.set(key, feedbackId); - this._onDidChangeNavigation.fire(sessionResource); - }, 50); // delay to ensure editor has revealed the correct position before firing navigation event + await this.revealSessionComment(sessionResource, feedbackId, feedback.resourceUri, feedback.range); + } + + async revealSessionComment(sessionResource: URI, commentId: string, resourceUri: URI, range: IRange): Promise { + const selection = { startLineNumber: range.startLineNumber, startColumn: range.startColumn }; + const sessionChange = this._getSessionChange(resourceUri, this._agentSessionsService.getSession(sessionResource)?.changes); + + if (sessionChange?.isDeletion && sessionChange.originalUri) { + await this._editorService.openEditor({ + resource: sessionChange.originalUri, + options: { + modal: {}, + preserveFocus: false, + revealIfVisible: true, + selection, + } + }, MODAL_GROUP); + } else if (sessionChange?.originalUri) { + await this._editorService.openEditor({ + original: { resource: sessionChange.originalUri }, + modified: { resource: sessionChange.modifiedUri }, + options: { + modal: {}, + preserveFocus: false, + revealIfVisible: true, + selection, + } + }, MODAL_GROUP); + } else { + await this._editorService.openEditor({ + resource: sessionChange?.modifiedUri ?? resourceUri, + options: { + modal: {}, + preserveFocus: false, + revealIfVisible: true, + selection, + } + }, MODAL_GROUP); + } + + this.setNavigationAnchor(sessionResource, commentId); + } + + private _getSessionChange(resourceUri: URI, changes: readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[] | { + readonly files: number; + readonly insertions: number; + readonly deletions: number; + } | undefined): { originalUri?: URI; modifiedUri: URI; isDeletion: boolean } | undefined { + if (!(changes instanceof Array)) { + return undefined; + } + + const matchingChange = changes.find(change => this._changeContainsResource(change, resourceUri)); + if (!matchingChange) { + return undefined; + } + + if (isIChatSessionFileChange2(matchingChange)) { + return { + originalUri: matchingChange.originalUri, + modifiedUri: matchingChange.modifiedUri ?? matchingChange.uri, + isDeletion: matchingChange.modifiedUri === undefined, + }; + } + + return { + originalUri: matchingChange.originalUri, + modifiedUri: matchingChange.modifiedUri, + isDeletion: false, + }; + } + + private _changeContainsResource(change: IChatSessionFileChange | IChatSessionFileChange2, resourceUri: URI): boolean { + if (isIChatSessionFileChange2(change)) { + return change.uri.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath + || change.modifiedUri?.fsPath === resourceUri.fsPath; + } + + return change.modifiedUri.fsPath === resourceUri.fsPath + || change.originalUri?.fsPath === resourceUri.fsPath; } getNextFeedback(sessionResource: URI, next: boolean): IAgentFeedback | undefined { + return this.getNextNavigableItem(sessionResource, this.getFeedback(sessionResource), next); + } + + getNextNavigableItem(sessionResource: URI, items: readonly T[], next: boolean): T | undefined { const key = sessionResource.toString(); - const feedbackItems = this._feedbackBySession.get(key); - if (!feedbackItems?.length) { + if (!items.length) { this._navigationAnchorBySession.delete(key); return undefined; } const anchorId = this._navigationAnchorBySession.get(key); - let anchorIndex = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; + let anchorIndex = anchorId ? items.findIndex(item => item.id === anchorId) : -1; if (anchorIndex < 0 && !next) { anchorIndex = 0; } const nextIndex = next - ? (anchorIndex + 1) % feedbackItems.length - : (anchorIndex - 1 + feedbackItems.length) % feedbackItems.length; + ? (anchorIndex + 1) % items.length + : (anchorIndex - 1 + items.length) % items.length; - const feedback = feedbackItems[nextIndex]; - this._navigationAnchorBySession.set(key, feedback.id); - this._onDidChangeNavigation.fire(sessionResource); - return feedback; + const item = items[nextIndex]; + this.setNavigationAnchor(sessionResource, item.id); + return item; } - getNavigationBearing(sessionResource: URI): IAgentFeedbackNavigationBearing { + setNavigationAnchor(sessionResource: URI, itemId: string | undefined): void { + const key = sessionResource.toString(); + if (itemId) { + this._navigationAnchorBySession.set(key, itemId); + } else { + this._navigationAnchorBySession.delete(key); + } + this._onDidChangeNavigation.fire(sessionResource); + } + + getNavigationBearing(sessionResource: URI, items: readonly INavigableSessionComment[] = this._feedbackBySession.get(sessionResource.toString()) ?? []): IAgentFeedbackNavigationBearing { const key = sessionResource.toString(); - const feedbackItems = this._feedbackBySession.get(key) ?? []; const anchorId = this._navigationAnchorBySession.get(key); - const activeIdx = anchorId ? feedbackItems.findIndex(item => item.id === anchorId) : -1; - return { activeIdx, totalCount: feedbackItems.length }; + const activeIdx = anchorId ? items.findIndex(item => item.id === anchorId) : -1; + return { activeIdx, totalCount: items.length }; } clearFeedback(sessionResource: URI): void { @@ -315,8 +414,8 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe this._onDidChangeFeedback.fire({ sessionResource, feedbackItems: [] }); } - async addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string): Promise { - this.addFeedback(sessionResource, resourceUri, range, text); + async addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion): Promise { + this.addFeedback(sessionResource, resourceUri, range, text, suggestion); // Wait for the attachment contribution to update the chat widget's attachment model const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); @@ -330,10 +429,14 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe ); } } else { - // This should not normally happen, but if the widget isn't found, wait a bit to give it a chance to initialize before submitting. + this._logService.error('[AgentFeedback] addFeedbackAndSubmit: no chat widget found for session, feedback may not be submitted correctly', sessionResource.toString()); await new Promise(resolve => setTimeout(resolve, 100)); } - await this._commandService.executeCommand('agentFeedbackEditor.action.submit'); + try { + await this._commandService.executeCommand('agentFeedbackEditor.action.submit'); + } catch (err) { + this._logService.error('[AgentFeedback] Failed to execute submit feedback command', err); + } } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css index f2361debfc6..b467ff7f7aa 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css @@ -8,7 +8,7 @@ z-index: 10000; background-color: var(--vscode-panel-background); border: 1px solid var(--vscode-agentFeedbackInputWidget-border, var(--vscode-input-border, var(--vscode-widget-border))); - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); border-radius: 8px; padding: 4px; display: flex; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css index 1acdbe228ce..766e481b9eb 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorOverlay.css @@ -8,13 +8,13 @@ color: var(--vscode-foreground); background-color: var(--vscode-editorWidget-background); border-radius: 6px; - border: 1px solid var(--vscode-contrastBorder); + border: 1px solid var(--vscode-editorHoverWidget-border); display: flex; align-items: center; justify-content: center; gap: 4px; z-index: 10; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); overflow: hidden; } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css index 3ca674d2cf4..938da413b02 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css @@ -11,7 +11,7 @@ background-color: var(--vscode-editorWidget-background); border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); border-radius: 8px; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); font-size: 12px; line-height: 1.4; opacity: 0; @@ -113,24 +113,6 @@ } /* Dismiss button */ -.agent-feedback-widget-dismiss { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - border-radius: 4px; - cursor: pointer; - color: var(--vscode-foreground); - opacity: 0.7; - transition: opacity 0.1s; -} - -.agent-feedback-widget-dismiss:hover { - opacity: 1; - background-color: var(--vscode-toolbar-hoverBackground); -} - /* Body - collapsible */ .agent-feedback-widget-body { transition: max-height 0.2s ease-in-out, padding 0.2s ease-in-out; @@ -152,6 +134,7 @@ border-bottom: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border)); cursor: pointer; position: relative; + gap: 6px; } .agent-feedback-widget-item:last-child { @@ -167,12 +150,70 @@ color: var(--vscode-list-activeSelectionForeground); } +.agent-feedback-widget-item-codeReview { + box-shadow: inset 2px 0 0 var(--vscode-editorWarning-foreground); +} + +.agent-feedback-widget-item-prReview { + box-shadow: inset 2px 0 0 var(--vscode-editorInfo-foreground); +} + +.agent-feedback-widget-item-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.agent-feedback-widget-item-meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-wrap: wrap; +} + +.agent-feedback-widget-item-actions { + margin-left: auto; + flex: 0 0 auto; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.agent-feedback-widget-item:hover .agent-feedback-widget-item-actions { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.agent-feedback-widget-item-type { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.2px; + background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 25%, transparent); + color: var(--vscode-descriptionForeground); +} + +.agent-feedback-widget-item-codeReview .agent-feedback-widget-item-type { + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 22%, transparent); + color: var(--vscode-editorWarning-foreground); +} + +.agent-feedback-widget-item-prReview .agent-feedback-widget-item-type { + background: color-mix(in srgb, var(--vscode-editorInfo-foreground) 22%, transparent); + color: var(--vscode-editorInfo-foreground); +} + /* Line info */ .agent-feedback-widget-line-info { font-size: 10px; font-weight: 600; color: var(--vscode-descriptionForeground); - margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; } @@ -183,6 +224,62 @@ word-wrap: break-word; } +.agent-feedback-widget-text .rendered-markdown p { + margin: 0; +} + +.agent-feedback-widget-text .rendered-markdown code { + font-family: var(--monaco-monospace-font); + font-size: 11px; + padding: 1px 4px; + border-radius: 3px; + background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 25%, transparent); +} + +.agent-feedback-widget-suggestion { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-editorWidget-border, var(--vscode-widget-border)) 12%, transparent); +} + +.agent-feedback-widget-item-codeReview .agent-feedback-widget-suggestion { + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 10%, transparent); +} + +.agent-feedback-widget-item-prReview .agent-feedback-widget-suggestion { + background: color-mix(in srgb, var(--vscode-editorInfo-foreground) 10%, transparent); +} + +.agent-feedback-widget-suggestion-title, +.agent-feedback-widget-suggestion-range { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--vscode-descriptionForeground); +} + +.agent-feedback-widget-suggestion-edit { + display: flex; + flex-direction: column; + gap: 4px; +} + +.agent-feedback-widget-suggestion-text { + margin: 0; + padding: 6px 8px; + border-radius: 4px; + overflow-x: auto; + white-space: pre-wrap; + font-family: monospace; + font-size: 11px; + line-height: 1.45; + background: color-mix(in srgb, var(--vscode-editor-background) 65%, transparent); +} + /* Gutter decoration for range indicator on hover */ .agent-feedback-widget-range-glyph { margin-left: 8px; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css deleted file mode 100644 index 6f503b0143f..00000000000 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackLineDecoration.css +++ /dev/null @@ -1,29 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.monaco-editor .agent-feedback-line-decoration, -.monaco-editor .agent-feedback-add-hint { - border-radius: 3px; - display: flex !important; - align-items: center; - justify-content: center; - background-color: var(--vscode-editorHoverWidget-background); - cursor: pointer; - border: 1px solid var(--vscode-editorHoverWidget-border); - box-sizing: border-box; -} - -.monaco-editor .agent-feedback-line-decoration:hover, -.monaco-editor .agent-feedback-add-hint:hover { - background-color: var(--vscode-editorHoverWidget-border); -} - -.monaco-editor .agent-feedback-add-hint { - opacity: 0.7; -} - -.monaco-editor .agent-feedback-add-hint:hover { - opacity: 1; -} diff --git a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts new file mode 100644 index 00000000000..ef756423d42 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRange, Range } from '../../../../editor/common/core/range.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IAgentFeedback } from './agentFeedbackService.js'; +import { CodeReviewStateKind, ICodeReviewComment, ICodeReviewState, ICodeReviewSuggestion, IPRReviewComment, IPRReviewState, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; + +export const enum SessionEditorCommentSource { + AgentFeedback = 'agentFeedback', + CodeReview = 'codeReview', + PRReview = 'prReview', +} + +export interface ISessionEditorComment { + readonly id: string; + readonly sourceId: string; + readonly source: SessionEditorCommentSource; + readonly sessionResource: URI; + readonly resourceUri: URI; + readonly range: IRange; + readonly text: string; + readonly suggestion?: ICodeReviewSuggestion; + readonly severity?: string; + readonly canConvertToAgentFeedback: boolean; +} + +export function getCodeReviewComments(reviewState: ICodeReviewState): readonly ICodeReviewComment[] { + return reviewState.kind === CodeReviewStateKind.Result ? reviewState.comments : []; +} + +export function getPRReviewComments(prReviewState: IPRReviewState | undefined): readonly IPRReviewComment[] { + return prReviewState?.kind === PRReviewStateKind.Loaded ? prReviewState.comments : []; +} + +export function getSessionEditorComments( + sessionResource: URI, + agentFeedbackItems: readonly IAgentFeedback[], + reviewState: ICodeReviewState, + prReviewState?: IPRReviewState, +): readonly ISessionEditorComment[] { + const comments: ISessionEditorComment[] = []; + + for (const item of agentFeedbackItems) { + comments.push({ + id: toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, item.id), + sourceId: item.id, + source: SessionEditorCommentSource.AgentFeedback, + sessionResource, + resourceUri: item.resourceUri, + range: item.range, + text: item.text, + suggestion: item.suggestion, + canConvertToAgentFeedback: false, + }); + } + + for (const item of getCodeReviewComments(reviewState)) { + comments.push({ + id: toSessionEditorCommentId(SessionEditorCommentSource.CodeReview, item.id), + sourceId: item.id, + source: SessionEditorCommentSource.CodeReview, + sessionResource, + resourceUri: item.uri, + range: item.range, + text: item.body, + suggestion: item.suggestion, + severity: item.severity, + canConvertToAgentFeedback: true, + }); + } + + for (const item of getPRReviewComments(prReviewState)) { + comments.push({ + id: toSessionEditorCommentId(SessionEditorCommentSource.PRReview, item.id), + sourceId: item.id, + source: SessionEditorCommentSource.PRReview, + sessionResource, + resourceUri: item.uri, + range: item.range, + text: item.body, + canConvertToAgentFeedback: true, + }); + } + + comments.sort(compareSessionEditorComments); + return comments; +} + +export function compareSessionEditorComments(a: ISessionEditorComment, b: ISessionEditorComment): number { + return a.resourceUri.toString().localeCompare(b.resourceUri.toString()) + || Range.compareRangesUsingStarts(Range.lift(a.range), Range.lift(b.range)) + || a.source.localeCompare(b.source) + || a.sourceId.localeCompare(b.sourceId); +} + +export function groupNearbySessionEditorComments(items: readonly ISessionEditorComment[], lineThreshold: number = 5): ISessionEditorComment[][] { + if (items.length === 0) { + return []; + } + + const sorted = [...items].sort(compareSessionEditorComments); + const groups: ISessionEditorComment[][] = []; + let currentGroup: ISessionEditorComment[] = [sorted[0]]; + + for (let i = 1; i < sorted.length; i++) { + const firstItem = currentGroup[0]; + const currentItem = sorted[i]; + + const sameResource = currentItem.resourceUri.toString() === firstItem.resourceUri.toString(); + const verticalSpan = currentItem.range.startLineNumber - firstItem.range.startLineNumber; + + if (sameResource && verticalSpan <= lineThreshold) { + currentGroup.push(currentItem); + } else { + groups.push(currentGroup); + currentGroup = [currentItem]; + } + } + + groups.push(currentGroup); + return groups; +} + +export function getResourceEditorComments(resourceUri: URI, comments: readonly ISessionEditorComment[]): readonly ISessionEditorComment[] { + const resource = resourceUri.toString(); + return comments.filter(comment => comment.resourceUri.toString() === resource); +} + +export function toSessionEditorCommentId(source: SessionEditorCommentSource, sourceId: string): string { + return `${source}:${sourceId}`; +} + +export function hasAgentFeedbackComments(comments: readonly ISessionEditorComment[]): boolean { + return comments.some(comment => comment.source === SessionEditorCommentSource.AgentFeedback); +} diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts new file mode 100644 index 00000000000..2314f52bc38 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorOverlayWidget.fixture.ts @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { toAction } from '../../../../../base/common/actions.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IMenu, IMenuActionOptions, IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { AgentFeedbackOverlayWidget } from '../../browser/agentFeedbackEditorOverlay.js'; +import { clearAllFeedbackActionId, navigateNextFeedbackActionId, navigatePreviousFeedbackActionId, navigationBearingFakeActionId, submitFeedbackActionId } from '../../browser/agentFeedbackEditorActions.js'; + +interface INavigationBearings { + readonly activeIdx: number; + readonly totalCount: number; +} + +interface IFixtureOptions { + readonly navigationBearings: INavigationBearings; + readonly hasAgentFeedbackActions?: boolean; +} + +class FixtureMenuService implements IMenuService { + constructor(private readonly _hasAgentFeedbackActions: boolean) { + } + + declare readonly _serviceBrand: undefined; + + createMenu(_id: MenuId): IMenu { + const navigateActions = [ + toAction({ id: navigationBearingFakeActionId, label: 'Navigation Status', run: () => { } }), + toAction({ id: navigatePreviousFeedbackActionId, label: 'Previous', class: 'codicon codicon-arrow-up', run: () => { } }), + toAction({ id: navigateNextFeedbackActionId, label: 'Next', class: 'codicon codicon-arrow-down', run: () => { } }), + ] as unknown as (MenuItemAction | SubmenuItemAction)[]; + + const submitActions = this._hasAgentFeedbackActions + ? [ + toAction({ id: submitFeedbackActionId, label: 'Submit', class: 'codicon codicon-send', run: () => { } }), + toAction({ id: clearAllFeedbackActionId, label: 'Clear', class: 'codicon codicon-clear-all', run: () => { } }), + ] as unknown as (MenuItemAction | SubmenuItemAction)[] + : []; + + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => submitActions.length > 0 + ? [ + ['navigate', navigateActions], + ['a_submit', submitActions], + ] + : [ + ['navigate', navigateActions], + ], + }; + } + + getMenuActions(_id: MenuId, _contextKeyService: unknown, _options?: IMenuActionOptions) { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + +function renderWidget(context: ComponentFixtureContext, options: IFixtureOptions): void { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.classList.add('monaco-workbench'); + context.container.style.width = '420px'; + context.container.style.height = '64px'; + context.container.style.padding = '12px'; + context.container.style.background = 'var(--vscode-editor-background)'; + + const instantiationService = createEditorServices(scopedDisposables, { + colorTheme: context.theme, + additionalServices: reg => { + reg.defineInstance(IMenuService, new FixtureMenuService(options.hasAgentFeedbackActions ?? true)); + registerWorkbenchServices(reg); + }, + }); + + const widget = scopedDisposables.add(instantiationService.createInstance(AgentFeedbackOverlayWidget)); + widget.show(options.navigationBearings); + context.container.appendChild(widget.getDomNode()); +} + +export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { + ZeroOfZero: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: -1, totalCount: 0 }, + hasAgentFeedbackActions: false, + }), + }), + + SingleFeedback: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 0, totalCount: 1 }, + }), + }), + + FirstOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: -1, totalCount: 3 }, + }), + }), + + ReviewOnlyTwoComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 0, totalCount: 2 }, + hasAgentFeedbackActions: false, + }), + }), + + MiddleOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 1, totalCount: 3 }, + }), + }), + + MixedFourComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 2, totalCount: 4 }, + hasAgentFeedbackActions: true, + }), + }), + + LastOfThree: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + navigationBearings: { activeIdx: 2, totalCount: 3 }, + }), + }), +}); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts new file mode 100644 index 00000000000..3beeee9243d --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts @@ -0,0 +1,413 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { Color } from '../../../../../base/common/color.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { IRange } from '../../../../../editor/common/core/range.js'; +import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; +import { IAgentFeedback, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { AgentFeedbackEditorWidget } from '../../browser/agentFeedbackEditorWidgetContribution.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { CodeReviewStateKind, ICodeReviewService, ICodeReviewState, ICodeReviewSuggestion, IPRReviewState, PRReviewStateKind } from '../../../codeReview/browser/codeReviewService.js'; +import { ISessionEditorComment, SessionEditorCommentSource } from '../../browser/sessionEditorComments.js'; + +const sessionResource = URI.parse('vscode-agent-session://fixture/session-1'); +const fileResource = URI.parse('inmemory://model/agent-feedback-widget.ts'); + +const sampleCode = [ + 'function alpha() {', + '\tconst first = 1;', + '\treturn first;', + '}', + '', + 'function beta() {', + '\tconst second = 2;', + '\tconst third = second + 1;', + '\treturn third;', + '}', + '', + 'function gamma() {', + '\tconst done = true;', + '\treturn done;', + '}', +].join('\n'); + +interface IFixtureOptions { + readonly expanded?: boolean; + readonly focusedCommentId?: string; + readonly hidden?: boolean; + readonly commentItems: readonly ISessionEditorComment[]; +} + +function createRange(startLineNumber: number, endLineNumber: number = startLineNumber): IRange { + return { + startLineNumber, + startColumn: 1, + endLineNumber, + endColumn: 1, + }; +} + +function createFeedbackComment(id: string, text: string, startLineNumber: number, endLineNumber: number = startLineNumber, suggestion?: ICodeReviewSuggestion): ISessionEditorComment { + return { + id: `agentFeedback:${id}`, + sourceId: id, + source: SessionEditorCommentSource.AgentFeedback, + sessionResource, + resourceUri: fileResource, + range: createRange(startLineNumber, endLineNumber), + text, + suggestion, + canConvertToAgentFeedback: false, + }; +} + +function createReviewComment(id: string, text: string, startLineNumber: number, endLineNumber: number = startLineNumber, suggestion?: ICodeReviewSuggestion): ISessionEditorComment { + const range: IRange = { + startLineNumber, + startColumn: 1, + endLineNumber, + endColumn: 1, + }; + + return { + id: `codeReview:${id}`, + sourceId: id, + source: SessionEditorCommentSource.CodeReview, + text, + resourceUri: fileResource, + range, + sessionResource, + suggestion, + severity: 'warning', + canConvertToAgentFeedback: true, + }; +} + +function createPRReviewComment(id: string, text: string, startLineNumber: number, endLineNumber: number = startLineNumber): ISessionEditorComment { + return { + id: `prReview:${id}`, + sourceId: id, + source: SessionEditorCommentSource.PRReview, + text, + resourceUri: fileResource, + range: createRange(startLineNumber, endLineNumber), + sessionResource, + canConvertToAgentFeedback: true, + }; +} + +function createMockAgentFeedbackService(): IAgentFeedbackService { + return new class extends mock() { + override readonly onDidChangeFeedback = Event.None; + override readonly onDidChangeNavigation = Event.None; + + override addFeedback(): IAgentFeedback { + throw new Error('Not implemented for fixture'); + } + + override removeFeedback(): void { } + + override getFeedback(): readonly IAgentFeedback[] { + return []; + } + + override getMostRecentSessionForResource(): URI | undefined { + return undefined; + } + + override async revealFeedback(): Promise { } + + override getNextFeedback(): IAgentFeedback | undefined { + return undefined; + } + + override getNavigationBearing() { + return { activeIdx: -1, totalCount: 0 }; + } + + override getNextNavigableItem() { + return undefined; + } + + override setNavigationAnchor(): void { } + + override clearFeedback(): void { } + + override async addFeedbackAndSubmit(): Promise { } + }(); +} + +function createMockCodeReviewService(): ICodeReviewService { + return new class extends mock() { + private readonly _state = observableValue('fixture.reviewState', { kind: CodeReviewStateKind.Idle }); + + override getReviewState() { + return this._state; + } + + override hasReview(): boolean { + return false; + } + + override requestReview(): void { } + + override removeComment(): void { } + + override dismissReview(): void { } + + private readonly _prState = observableValue('fixture.prReviewState', { kind: PRReviewStateKind.None }); + + override getPRReviewState() { + return this._prState; + } + + override async resolvePRReviewThread(): Promise { } + }(); +} + +function ensureTokenColorMap(): void { + if (TokenizationRegistry.getColorMap()?.length) { + return; + } + + const colorMap = [ + Color.fromHex('#000000'), + Color.fromHex('#d4d4d4'), + Color.fromHex('#9cdcfe'), + Color.fromHex('#ce9178'), + Color.fromHex('#b5cea8'), + Color.fromHex('#4fc1ff'), + Color.fromHex('#c586c0'), + Color.fromHex('#569cd6'), + Color.fromHex('#dcdcaa'), + Color.fromHex('#f44747'), + ]; + + TokenizationRegistry.setColorMap(colorMap); +} + +function renderWidget(context: ComponentFixtureContext, options: IFixtureOptions): void { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.style.width = '760px'; + context.container.style.height = '420px'; + context.container.style.border = '1px solid var(--vscode-editorWidget-border)'; + context.container.style.background = 'var(--vscode-editor-background)'; + + ensureTokenColorMap(); + + const agentFeedbackService = createMockAgentFeedbackService(); + const codeReviewService = createMockCodeReviewService(); + const instantiationService = createEditorServices(scopedDisposables, { + colorTheme: context.theme, + additionalServices: reg => { + reg.defineInstance(IAgentFeedbackService, agentFeedbackService); + reg.defineInstance(ICodeReviewService, codeReviewService); + reg.define(IMarkdownRendererService, MarkdownRendererService); + }, + }); + const model = scopedDisposables.add(createTextModel(instantiationService, sampleCode, fileResource, 'typescript')); + + const editorOptions: ICodeEditorWidgetOptions = { + contributions: [], + }; + + const editor = scopedDisposables.add(instantiationService.createInstance( + CodeEditorWidget, + context.container, + { + automaticLayout: true, + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 13, + lineHeight: 20, + }, + editorOptions + )); + + editor.setModel(model); + + const widget = scopedDisposables.add(instantiationService.createInstance( + AgentFeedbackEditorWidget, + editor, + options.commentItems, + sessionResource, + )); + + widget.layout(options.commentItems[0].range.startLineNumber); + + if (options.expanded) { + widget.expand(); + } + + if (options.focusedCommentId) { + widget.focusFeedback(options.focusedCommentId); + } + + if (options.hidden) { + const domNode = widget.getDomNode(); + domNode.style.transition = 'none'; + domNode.style.animation = 'none'; + widget.toggle(false); + } +} + +const singleFeedback = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), +]; + +const groupedFeedback = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), + createFeedbackComment('f-2', 'This return statement can be simplified.', 3), + createFeedbackComment('f-3', 'Consider documenting why this branch is needed.', 6, 8), +]; + +const reviewOnly = [ + createReviewComment('r-1', 'Handle the null case before returning here.', 7), + createReviewComment('r-2', 'This branch needs a stronger explanation.', 8), +]; + +const mixedComments = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), + createReviewComment('r-1', 'This should be extracted into a helper.', 3), + createFeedbackComment('f-2', 'Consider renaming this for readability.', 4), +]; + +const reviewSuggestion: ICodeReviewSuggestion = { + edits: [ + { range: createRange(8), oldText: '\tconst third = second + 1;', newText: '\tconst third = second + computeOffset();' }, + ], +}; + +const suggestionMix = [ + createReviewComment('r-3', 'Prefer using the helper so the intent is explicit.', 8, 8, reviewSuggestion), + createFeedbackComment('f-3', 'Keep the helper name aligned with the domain concept.', 9), +]; + +const prReviewOnly = [ + createPRReviewComment('pr-1', 'This variable should be renamed to match our naming conventions.', 2), + createPRReviewComment('pr-2', 'Please add error handling for the edge case when second is zero.', 7, 8), +]; + +const allSourcesMixed = [ + createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), + createPRReviewComment('pr-1', 'Our style guide says to use descriptive names here.', 3), + createReviewComment('r-1', 'This should be extracted into a helper.', 6), + createPRReviewComment('pr-2', 'This logic duplicates what we have in utils.ts — consider reusing.', 8, 9), +]; + +export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { + CollapsedSingleComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: singleFeedback, + }), + }), + + ExpandedSingleComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: singleFeedback, + expanded: true, + }), + }), + + CollapsedMultiComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + }), + }), + + ExpandedMultiComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + expanded: true, + }), + }), + + ExpandedFocusedFeedback: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: groupedFeedback, + expanded: true, + focusedCommentId: 'agentFeedback:f-2', + }), + }), + + ExpandedReviewOnly: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: reviewOnly, + expanded: true, + }), + }), + + ExpandedMixedComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + expanded: true, + }), + }), + + ExpandedFocusedReviewComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + expanded: true, + focusedCommentId: 'codeReview:r-1', + }), + }), + + ExpandedReviewSuggestion: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: suggestionMix, + expanded: true, + }), + }), + + ExpandedPRReviewOnly: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: prReviewOnly, + expanded: true, + }), + }), + + ExpandedAllSourcesMixed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: allSourcesMixed, + expanded: true, + }), + }), + + ExpandedFocusedPRReview: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: allSourcesMixed, + expanded: true, + focusedCommentId: 'prReview:pr-2', + }), + }), + + HiddenWidget: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderWidget(context, { + commentItems: mixedComments, + hidden: true, + }), + }), +}); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts new file mode 100644 index 00000000000..f7bb4f0aa5b --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { CodeReviewStateKind, ICodeReviewState, IPRReviewState, PRReviewStateKind } from '../../../codeReview/browser/codeReviewService.js'; +import { getResourceEditorComments, getSessionEditorComments, groupNearbySessionEditorComments, hasAgentFeedbackComments, SessionEditorCommentSource } from '../../browser/sessionEditorComments.js'; + +type ICodeReviewResultState = Extract; + +suite('SessionEditorComments', () => { + const session = URI.parse('test://session/1'); + const fileA = URI.parse('file:///a.ts'); + const fileB = URI.parse('file:///b.ts'); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function reviewState(comments: ICodeReviewResultState['comments']): ICodeReviewState { + return { + kind: CodeReviewStateKind.Result, + version: 'v1', + comments, + }; + } + + test('merges and sorts feedback and review comments by resource and range', () => { + const comments = getSessionEditorComments(session, [ + { id: 'feedback-b', text: 'feedback b', resourceUri: fileB, range: new Range(8, 1, 8, 1), sessionResource: session }, + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(12, 1, 12, 1), sessionResource: session }, + ], reviewState([ + { id: 'review-a', uri: fileA, range: new Range(3, 1, 3, 1), body: 'review a', kind: 'issue', severity: 'warning' }, + { id: 'review-b', uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', kind: 'issue', severity: 'warning' }, + ])); + + assert.deepStrictEqual(comments.map(comment => `${comment.resourceUri.path}:${comment.range.startLineNumber}:${comment.source}`), [ + '/a.ts:3:codeReview', + '/a.ts:12:agentFeedback', + '/b.ts:2:codeReview', + '/b.ts:8:agentFeedback', + ]); + }); + + test('groups nearby comments only within the same resource', () => { + const comments = getSessionEditorComments(session, [ + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(10, 1, 10, 1), sessionResource: session }, + ], reviewState([ + { id: 'review-a', uri: fileA, range: new Range(13, 1, 13, 1), body: 'review a', kind: 'issue', severity: 'warning' }, + { id: 'review-b', uri: fileB, range: new Range(11, 1, 11, 1), body: 'review b', kind: 'issue', severity: 'warning' }, + ])); + + const groups = groupNearbySessionEditorComments(comments, 5); + assert.strictEqual(groups.length, 2); + assert.deepStrictEqual(groups[0].map(comment => `${comment.resourceUri.path}:${comment.range.startLineNumber}:${comment.source}`), [ + '/a.ts:10:agentFeedback', + '/a.ts:13:codeReview', + ]); + assert.deepStrictEqual(groups[1].map(comment => `${comment.resourceUri.path}:${comment.range.startLineNumber}:${comment.source}`), [ + '/b.ts:11:codeReview', + ]); + }); + + test('preserves review suggestion metadata and capability flags', () => { + const comments = getSessionEditorComments(session, [], reviewState([ + { + id: 'review-suggestion', + uri: fileA, + range: new Range(7, 1, 7, 1), + body: 'prefer a constant', + kind: 'suggestion', + severity: 'info', + suggestion: { + edits: [{ range: new Range(7, 1, 7, 10), oldText: 'let value', newText: 'const value' }], + }, + }, + ])); + + assert.strictEqual(comments.length, 1); + assert.strictEqual(comments[0].source, SessionEditorCommentSource.CodeReview); + assert.strictEqual(comments[0].canConvertToAgentFeedback, true); + assert.strictEqual(comments[0].suggestion?.edits[0].newText, 'const value'); + }); + + test('filters resource comments and detects authored feedback presence', () => { + const comments = getSessionEditorComments(session, [ + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(1, 1, 1, 1), sessionResource: session }, + ], reviewState([ + { id: 'review-b', uri: fileB, range: new Range(2, 1, 2, 1), body: 'review b', kind: 'issue', severity: 'warning' }, + ])); + + assert.strictEqual(hasAgentFeedbackComments(comments), true); + assert.deepStrictEqual(getResourceEditorComments(fileA, comments).map(comment => comment.source), [SessionEditorCommentSource.AgentFeedback]); + assert.deepStrictEqual(getResourceEditorComments(fileB, comments).map(comment => comment.source), [SessionEditorCommentSource.CodeReview]); + }); + + test('includes PR review comments when prReviewState is loaded', () => { + const prState: IPRReviewState = { + kind: PRReviewStateKind.Loaded, + comments: [ + { id: 'pr-thread-1', uri: fileA, range: new Range(5, 1, 5, 1), body: 'Please fix this', author: 'reviewer' }, + { id: 'pr-thread-2', uri: fileB, range: new Range(1, 1, 1, 1), body: 'Looks wrong', author: 'reviewer' }, + ], + }; + + const comments = getSessionEditorComments(session, [], reviewState([]), prState); + assert.strictEqual(comments.length, 2); + assert.deepStrictEqual(comments.map(c => `${c.resourceUri.path}:${c.range.startLineNumber}:${c.source}`), [ + '/a.ts:5:prReview', + '/b.ts:1:prReview', + ]); + assert.strictEqual(comments[0].canConvertToAgentFeedback, true); + }); + + test('merges PR review comments with other sources sorted correctly', () => { + const prState: IPRReviewState = { + kind: PRReviewStateKind.Loaded, + comments: [ + { id: 'pr-thread-1', uri: fileA, range: new Range(7, 1, 7, 1), body: 'PR comment', author: 'reviewer' }, + ], + }; + + const comments = getSessionEditorComments(session, [ + { id: 'feedback-a', text: 'feedback a', resourceUri: fileA, range: new Range(3, 1, 3, 1), sessionResource: session }, + ], reviewState([ + { id: 'review-a', uri: fileA, range: new Range(10, 1, 10, 1), body: 'review', kind: 'issue', severity: 'warning' }, + ]), prState); + + assert.strictEqual(comments.length, 3); + assert.deepStrictEqual(comments.map(c => `${c.range.startLineNumber}:${c.source}`), [ + '3:agentFeedback', + '7:prReview', + '10:codeReview', + ]); + }); + + test('omits PR review comments when prReviewState is not loaded', () => { + const prState: IPRReviewState = { kind: PRReviewStateKind.None }; + const comments = getSessionEditorComments(session, [], reviewState([]), prState); + assert.strictEqual(comments.length, 0); + }); +}); diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts index bf959abffad..2bcd3717a81 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts @@ -24,10 +24,12 @@ import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyn import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, instructionsIcon, mcpServerIcon, pluginIcon, promptIcon, skillIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IEditorService, MODAL_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; const $ = DOM.$; @@ -67,6 +69,8 @@ export class AICustomizationOverviewView extends ViewPane { @IPromptsService private readonly promptsService: IPromptsService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, + @IMcpService private readonly mcpService: IMcpService, + @IAgentPluginService private readonly agentPluginService: IAgentPluginService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -76,6 +80,8 @@ export class AICustomizationOverviewView extends ViewPane { { id: AICustomizationManagementSection.Skills, label: localize('skills', "Skills"), icon: skillIcon, count: 0 }, { id: AICustomizationManagementSection.Instructions, label: localize('instructions', "Instructions"), icon: instructionsIcon, count: 0 }, { id: AICustomizationManagementSection.Prompts, label: localize('prompts', "Prompts"), icon: promptIcon, count: 0 }, + { id: AICustomizationManagementSection.McpServers, label: localize('mcpServers', "MCP Servers"), icon: mcpServerIcon, count: 0 }, + { id: AICustomizationManagementSection.Plugins, label: localize('plugins', "Plugins"), icon: pluginIcon, count: 0 }, ); // Listen to changes @@ -173,6 +179,26 @@ export class AICustomizationOverviewView extends ViewPane { } })); + // Update MCP server count reactively + const mcpSection = this.sections.find(s => s.id === AICustomizationManagementSection.McpServers); + if (mcpSection) { + this._register(autorun(reader => { + const servers = this.mcpService.servers.read(reader); + mcpSection.count = servers.length; + this.updateCountElements(); + })); + } + + // Update plugin count reactively + const pluginSection = this.sections.find(s => s.id === AICustomizationManagementSection.Plugins); + if (pluginSection) { + this._register(autorun(reader => { + const plugins = this.agentPluginService.plugins.read(reader); + pluginSection.count = plugins.length; + this.updateCountElements(); + })); + } + this.updateCountElements(); } diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts index ef3874912b6..e68286ee17c 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -27,12 +27,16 @@ import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/ import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IPromptsService, PromptsStorage, IAgentSkill, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { agentIcon, extensionIcon, instructionsIcon, pluginIcon, promptIcon, skillIcon, userIcon, workspaceIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, extensionIcon, instructionsIcon, mcpServerIcon, pluginIcon, promptIcon, skillIcon, userIcon, workspaceIcon, builtinIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; +import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { AICustomizationPromptsStorage, BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; +import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; +import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; import { IAsyncDataSource, ITreeNode, ITreeRenderer, ITreeContextMenuEvent } from '../../../../base/browser/ui/tree/tree.js'; import { FuzzyScore } from '../../../../base/common/filters.js'; import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; -import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IEditorService, MODAL_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; @@ -77,7 +81,7 @@ interface IAICustomizationGroupItem { readonly type: 'group'; readonly id: string; readonly label: string; - readonly storage: PromptsStorage; + readonly storage: AICustomizationPromptsStorage; readonly promptType: PromptsType; readonly icon: ThemeIcon; } @@ -91,11 +95,22 @@ interface IAICustomizationFileItem { readonly uri: URI; readonly name: string; readonly description?: string; - readonly storage: PromptsStorage; + readonly storage: AICustomizationPromptsStorage; readonly promptType: PromptsType; } -type AICustomizationTreeItem = IAICustomizationTypeItem | IAICustomizationGroupItem | IAICustomizationFileItem; +/** + * Represents a link item that navigates to the management editor. + */ +interface IAICustomizationLinkItem { + readonly type: 'link'; + readonly id: string; + readonly label: string; + readonly icon: ThemeIcon; + readonly section: AICustomizationManagementSection; +} + +type AICustomizationTreeItem = IAICustomizationTypeItem | IAICustomizationGroupItem | IAICustomizationFileItem | IAICustomizationLinkItem; //#endregion @@ -109,6 +124,7 @@ class AICustomizationTreeDelegate implements IListVirtualDelegate { +class AICustomizationCategoryRenderer implements ITreeRenderer { readonly templateId = 'category'; renderTemplate(container: HTMLElement): ICategoryTemplateData { @@ -145,7 +161,7 @@ class AICustomizationCategoryRenderer implements ITreeRenderer, _index: number, templateData: ICategoryTemplateData): void { + renderElement(node: ITreeNode, _index: number, templateData: ICategoryTemplateData): void { templateData.icon.className = 'icon'; templateData.icon.classList.add(...ThemeIcon.asClassNameArray(node.element.icon)); templateData.label.textContent = node.element.label; @@ -219,7 +235,7 @@ class AICustomizationFileRenderer implements ITreeRenderer; + files?: Map; } /** @@ -248,6 +264,9 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource item.storage === PromptsStorage.local); const userItems = allItems.filter(item => item.storage === PromptsStorage.user); const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); + const builtinItems = allItems.filter(item => item.storage === BUILTIN_STORAGE); - cached.files = new Map([ + cached.files = new Map([ [PromptsStorage.local, workspaceItems], [PromptsStorage.user, userItems], [PromptsStorage.extension, extensionItems], + [BUILTIN_STORAGE, builtinItems], ]); const itemCount = allItems.length; @@ -365,6 +393,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource 0) { groups.push(this.createGroupItem(promptType, PromptsStorage.local, workspaceItems.length)); @@ -375,6 +404,9 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource 0) { groups.push(this.createGroupItem(promptType, PromptsStorage.extension, extensionItems.length)); } + if (builtinItems.length > 0) { + groups.push(this.createGroupItem(promptType, BUILTIN_STORAGE, builtinItems.length)); + } return groups; } @@ -382,26 +414,29 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource = { + private createGroupItem(promptType: PromptsType, storage: AICustomizationPromptsStorage, count: number): IAICustomizationGroupItem { + const storageLabels: Record = { [PromptsStorage.local]: localize('workspaceWithCount', "Workspace ({0})", count), [PromptsStorage.user]: localize('userWithCount', "User ({0})", count), [PromptsStorage.extension]: localize('extensionsWithCount', "Extensions ({0})", count), [PromptsStorage.plugin]: localize('pluginsWithCount', "Plugins ({0})", count), + [BUILTIN_STORAGE]: localize('builtinWithCount', "Built-in ({0})", count), }; - const storageIcons: Record = { + const storageIcons: Record = { [PromptsStorage.local]: workspaceIcon, [PromptsStorage.user]: userIcon, [PromptsStorage.extension]: extensionIcon, [PromptsStorage.plugin]: pluginIcon, + [BUILTIN_STORAGE]: builtinIcon, }; - const storageSuffixes: Record = { + const storageSuffixes: Record = { [PromptsStorage.local]: 'workspace', [PromptsStorage.user]: 'user', [PromptsStorage.extension]: 'extensions', [PromptsStorage.plugin]: 'plugins', + [BUILTIN_STORAGE]: 'builtin', }; return { @@ -418,7 +453,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource { + private async getFilesForStorageAndType(storage: AICustomizationPromptsStorage, promptType: PromptsType): Promise { const cached = this.cache.get(promptType); // For skills, use the cached skills data @@ -549,7 +584,7 @@ export class AICustomizationViewPane extends ViewPane { }, accessibilityProvider: { getAriaLabel: (element: AICustomizationTreeItem) => { - if (element.type === 'category') { + if (element.type === 'category' || element.type === 'link') { return element.label; } if (element.type === 'group') { @@ -573,12 +608,18 @@ export class AICustomizationViewPane extends ViewPane { } )); - // Handle double-click to open file - this.treeDisposables.add(this.tree.onDidOpen(e => { + // Handle double-click to open file or navigate to section + this.treeDisposables.add(this.tree.onDidOpen(async e => { if (e.element && e.element.type === 'file') { this.editorService.openEditor({ - resource: e.element.uri + resource: e.element.uri, }); + } else if (e.element && e.element.type === 'link') { + const input = AICustomizationManagementEditorInput.getOrCreate(); + const editor = await this.editorService.openEditor(input, { pinned: true }, MODAL_GROUP); + if (editor instanceof AICustomizationManagementEditor) { + editor.selectSectionById(e.element.section); + } } })); diff --git a/src/vs/sessions/contrib/applyToParentRepo/browser/applyToParentRepo.contribution.ts b/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts similarity index 50% rename from src/vs/sessions/contrib/applyToParentRepo/browser/applyToParentRepo.contribution.ts rename to src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts index 3b5ce530a7b..1c02efa68e6 100644 --- a/src/vs/sessions/contrib/applyToParentRepo/browser/applyToParentRepo.contribution.ts +++ b/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts @@ -9,40 +9,29 @@ import { Schemas } from '../../../../base/common/network.js'; import { autorun } from '../../../../base/common/observable.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { localize, localize2 } from '../../../../nls.js'; -import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; -import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { isEqualOrParent, joinPath, relativePath } from '../../../../base/common/resources.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { URI } from '../../../../base/common/uri.js'; -/** - * Normalizes a URI to the `file` scheme so that path comparisons work - * even when the source URI uses a different scheme (e.g. `github-remote-file`). - */ -function toFileUri(uri: URI): URI { - return uri.scheme === 'file' ? uri : URI.file(uri.path); -} - const hasWorktreeAndRepositoryContextKey = new RawContextKey('agentSessionHasWorktreeAndRepository', false, { type: 'boolean', description: localize('agentSessionHasWorktreeAndRepository', "True when the active agent session has both a worktree and a parent repository.") }); -class ApplyToParentRepoContribution extends Disposable implements IWorkbenchContribution { +class ApplyChangesToParentRepoContribution extends Disposable implements IWorkbenchContribution { - static readonly ID = 'sessions.contrib.applyToParentRepo'; + static readonly ID = 'sessions.contrib.applyChangesToParentRepo'; constructor( @IContextKeyService contextKeyService: IContextKeyService, @@ -50,32 +39,39 @@ class ApplyToParentRepoContribution extends Disposable implements IWorkbenchCont ) { super(); - const contextKey = hasWorktreeAndRepositoryContextKey.bindTo(contextKeyService); + const worktreeAndRepoKey = hasWorktreeAndRepositoryContextKey.bindTo(contextKeyService); this._register(autorun(reader => { const activeSession = sessionManagementService.activeSession.read(reader); const hasWorktreeAndRepo = !!activeSession?.worktree && !!activeSession?.repository; - contextKey.set(hasWorktreeAndRepo); + worktreeAndRepoKey.set(hasWorktreeAndRepo); })); } } -class ApplyToParentRepoAction extends Action2 { - static readonly ID = 'chatEditing.applyToParentRepo'; +class ApplyChangesToParentRepoAction extends Action2 { + static readonly ID = 'chatEditing.applyChangesToParentRepo'; constructor() { super({ - id: ApplyToParentRepoAction.ID, - title: localize2('applyToParentRepo', 'Apply to Parent Repo'), + id: ApplyChangesToParentRepoAction.ID, + title: localize2('applyChangesToParentRepo', 'Apply Changes to Parent Repository'), icon: Codicon.desktopDownload, category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(IsSessionsWindowContext, hasWorktreeAndRepositoryContextKey, ChatContextKeys.hasAgentSessionChanges), + precondition: ContextKeyExpr.and( + IsSessionsWindowContext, + hasWorktreeAndRepositoryContextKey, + ), menu: [ { - id: MenuId.ChatEditingSessionChangesToolbar, + id: MenuId.ChatEditingSessionApplySubmenu, group: 'navigation', - order: 4, - when: ContextKeyExpr.and(IsSessionsWindowContext, hasWorktreeAndRepositoryContextKey, ChatContextKeys.hasAgentSessionChanges), + order: 2, + when: ContextKeyExpr.and( + ContextKeyExpr.false(), + IsSessionsWindowContext, + hasWorktreeAndRepositoryContextKey + ), }, ], }); @@ -83,8 +79,7 @@ class ApplyToParentRepoAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const sessionManagementService = accessor.get(ISessionsManagementService); - const agentSessionsService = accessor.get(IAgentSessionsService); - const fileService = accessor.get(IFileService); + const commandService = accessor.get(ICommandService); const notificationService = accessor.get(INotificationService); const logService = accessor.get(ILogService); const openerService = accessor.get(IOpenerService); @@ -98,55 +93,8 @@ class ApplyToParentRepoAction extends Action2 { const worktreeRoot = activeSession.worktree; const repoRoot = activeSession.repository; - const agentSession = agentSessionsService.getSession(activeSession.resource); - const changes = agentSession?.changes; - if (!changes || !(changes instanceof Array)) { - return; - } - - let copiedCount = 0; - let deletedCount = 0; - let errorCount = 0; - - for (const change of changes) { - try { - const modifiedUri = isIChatSessionFileChange2(change) - ? change.modifiedUri ?? change.uri - : change.modifiedUri; - const isDeletion = isIChatSessionFileChange2(change) - ? change.modifiedUri === undefined - : false; - - if (isDeletion) { - const originalUri = change.originalUri; - if (originalUri && isEqualOrParent(toFileUri(originalUri), worktreeRoot)) { - const relPath = relativePath(worktreeRoot, toFileUri(originalUri)); - if (relPath) { - const targetUri = joinPath(repoRoot, relPath); - if (await fileService.exists(targetUri)) { - await fileService.del(targetUri); - deletedCount++; - } - } - } - } else { - if (isEqualOrParent(toFileUri(modifiedUri), worktreeRoot)) { - const relPath = relativePath(worktreeRoot, toFileUri(modifiedUri)); - if (relPath) { - const targetUri = joinPath(repoRoot, relPath); - await fileService.copy(modifiedUri, targetUri, true); - copiedCount++; - } - } - } - } catch (err) { - logService.error('[ApplyToParentRepo] Failed to apply change', err); - errorCount++; - } - } - const openFolderAction = toAction({ - id: 'applyToParentRepo.openFolder', + id: 'applyChangesToParentRepo.openFolder', label: localize('openInVSCode', "Open in VS Code"), run: () => { const scheme = productService.quality === 'stable' @@ -168,26 +116,57 @@ class ApplyToParentRepoAction extends Action2 { } }); - const totalApplied = copiedCount + deletedCount; - if (errorCount > 0) { + try { + // Get the worktree branch name. Since the worktree and parent repo + // share the same git object store, the parent can directly reference + // this branch for a merge. + const worktreeBranch = await commandService.executeCommand( + '_git.revParseAbbrevRef', + worktreeRoot.fsPath + ); + + if (!worktreeBranch) { + notificationService.notify({ + severity: Severity.Warning, + message: localize('applyChangesNoBranch', "Could not determine worktree branch name."), + }); + return; + } + + // Merge the worktree branch into the parent repo. + // This is idempotent: if already merged, git says "Already up to date." + // If new commits exist, they're brought in. Handles partial applies naturally. + const result = await commandService.executeCommand('_git.mergeBranch', repoRoot.fsPath, worktreeBranch); + if (!result) { + logService.warn('[ApplyChangesToParentRepo] No result from merge command'); + } else { + notificationService.notify({ + severity: Severity.Info, + message: typeof result === 'string' && result.startsWith('Already up to date') + ? localize('alreadyUpToDate', 'Parent repository is up to date with worktree.') + : localize('applyChangesSuccess', 'Applied changes to parent repository.'), + actions: { primary: [openFolderAction] } + }); + } + } catch (err) { + logService.error('[ApplyChangesToParentRepo] Failed to apply changes', err); notificationService.notify({ severity: Severity.Warning, - message: totalApplied === 1 - ? localize('applyToParentRepoPartial1', "Applied 1 file to parent repo with {0} error(s).", errorCount) - : localize('applyToParentRepoPartialN', "Applied {0} files to parent repo with {1} error(s).", totalApplied, errorCount), - actions: { primary: [openFolderAction] } - }); - } else if (totalApplied > 0) { - notificationService.notify({ - severity: Severity.Info, - message: totalApplied === 1 - ? localize('applyToParentRepoSuccess1', "Applied 1 file to parent repo.") - : localize('applyToParentRepoSuccessN', "Applied {0} files to parent repo.", totalApplied), + message: localize('applyChangesConflict', "Failed to apply changes to parent repo. The parent repo may have diverged — resolve conflicts manually."), actions: { primary: [openFolderAction] } }); } } } -registerAction2(ApplyToParentRepoAction); -registerWorkbenchContribution2(ApplyToParentRepoContribution.ID, ApplyToParentRepoContribution, WorkbenchPhase.AfterRestored); +registerAction2(ApplyChangesToParentRepoAction); +registerWorkbenchContribution2(ApplyChangesToParentRepoContribution.ID, ApplyChangesToParentRepoContribution, WorkbenchPhase.AfterRestored); + +// Register the apply submenu in the session changes toolbar +MenuRegistry.appendMenuItem(MenuId.ChatEditingSessionChangesToolbar, { + submenu: MenuId.ChatEditingSessionApplySubmenu, + title: localize2('applyActions', 'Apply Actions'), + group: 'navigation', + order: 1, + when: ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.hasAgentSessionChanges), +}); diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts similarity index 100% rename from src/vs/sessions/contrib/changesView/browser/changesView.contribution.ts rename to src/vs/sessions/contrib/changes/browser/changesView.contribution.ts diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts similarity index 69% rename from src/vs/sessions/contrib/changesView/browser/changesView.ts rename to src/vs/sessions/contrib/changes/browser/changesView.ts index 1ab3ac27e54..ffb41b25c95 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -13,15 +13,15 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, observableFromPromise, observableValue } from '../../../../base/common/observable.js'; import { basename, dirname } from '../../../../base/common/path.js'; -import { isEqual } from '../../../../base/common/resources.js'; +import { extUriBiasedIgnorePathCase, isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; -import { localize } from '../../../../nls.js'; +import { localize, localize2 } from '../../../../nls.js'; import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { MenuId, Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -44,20 +44,26 @@ import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/la import { ViewPane, IViewPaneOptions, ViewAction } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { chatEditingWidgetFileStateContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; import { IEditorService, MODAL_GROUP, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; +import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; +import { IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { CIStatusWidget } from './ciStatusWidget.js'; const $ = dom.$; @@ -65,6 +71,7 @@ const $ = dom.$; export const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; +const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run'; // --- View Mode @@ -75,6 +82,17 @@ export const enum ChangesViewMode { const changesViewModeContextKey = new RawContextKey('changesViewMode', ChangesViewMode.List); +// --- Versions Mode + +const enum ChangesVersionMode { + AllChanges = 'allChanges', + LastTurn = 'lastTurn', + Uncommitted = 'uncommitted' +} + +const changesVersionModeContextKey = new RawContextKey('sessions.changesVersionMode', ChangesVersionMode.AllChanges); +const hasUncommittedChangesContextKey = new RawContextKey('sessions.hasUncommittedChanges', false); + // --- List Item type ChangeType = 'added' | 'modified' | 'deleted'; @@ -88,6 +106,7 @@ interface IChangesFileItem { readonly changeType: ChangeType; readonly linesAdded: number; readonly linesRemoved: number; + readonly reviewCommentCount: number; } interface IChangesFolderItem { @@ -99,6 +118,8 @@ interface IChangesFolderItem { interface IActiveSession { readonly resource: URI; readonly sessionType: string; + readonly repository: URI | undefined; + readonly worktree: URI | undefined; } type ChangesTreeElement = IChangesFileItem | IChangesFolderItem; @@ -203,6 +224,7 @@ export class ChangesViewPane extends ViewPane { private actionsContainer: HTMLElement | undefined; private tree: WorkbenchCompressibleObjectTree | undefined; + private ciStatusWidget: CIStatusWidget | undefined; private readonly renderDisposables = this._register(new DisposableStore()); @@ -224,10 +246,24 @@ export class ChangesViewPane extends ViewPane { this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); } + // Version mode (all changes, last turn, uncommitted) + private readonly versionModeObs = observableValue(this, ChangesVersionMode.AllChanges); + private readonly versionModeContextKey: IContextKey; + + setVersionMode(mode: ChangesVersionMode): void { + if (this.versionModeObs.get() === mode) { + return; + } + this.versionModeObs.set(mode, undefined); + this.versionModeContextKey.set(mode); + } + // Track the active session used by this view private readonly activeSession: IObservableWithChange; private readonly activeSessionFileCountObs: IObservableWithChange; private readonly activeSessionHasChangesObs: IObservableWithChange; + private readonly activeSessionRepositoryChangesObs: IObservableWithChange; + private readonly activeSessionRepositoryObs: IObservableWithChange | undefined>; get activeSessionHasChanges(): IObservable { return this.activeSessionHasChangesObs; @@ -254,7 +290,9 @@ export class ChangesViewPane extends ViewPane { @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, @IStorageService private readonly storageService: IStorageService, - @ICommandService private readonly commandService: ICommandService, + @ICodeReviewService private readonly codeReviewService: ICodeReviewService, + @IGitService private readonly gitService: IGitService, + @IGitHubService private readonly gitHubService: IGitHubService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -265,6 +303,10 @@ export class ChangesViewPane extends ViewPane { this.viewModeContextKey = changesViewModeContextKey.bindTo(contextKeyService); this.viewModeContextKey.set(initialMode); + // Version mode + this.versionModeContextKey = changesVersionModeContextKey.bindTo(contextKeyService); + this.versionModeContextKey.set(ChangesVersionMode.AllChanges); + // Track active session from sessions management service this.activeSession = derivedOpts({ equalsFn: (a, b) => isEqual(a?.resource, b?.resource), @@ -276,16 +318,52 @@ export class ChangesViewPane extends ViewPane { return { resource: activeSession.resource, + repository: activeSession.repository, + worktree: activeSession.worktree, sessionType: getChatSessionType(activeSession.resource), }; }).recomputeInitiallyAndOnChange(this._store); + // Track active session repository changes + this.activeSessionRepositoryObs = derived(reader => { + const activeSessionWorktree = this.activeSession.read(reader)?.worktree; + if (!activeSessionWorktree) { + return undefined; + } + + return observableFromPromise(this.gitService.openRepository(activeSessionWorktree)); + }); + + this.activeSessionRepositoryChangesObs = derived(reader => { + const repository = this.activeSessionRepositoryObs.read(reader)?.read(reader); + if (!repository) { + return undefined; + } + + const state = repository.value?.state.read(reader); + const headCommit = state?.HEAD?.commit; + return (state?.workingTreeChanges ?? []).map(change => { + const isDeletion = change.modifiedUri === undefined; + const isAddition = change.originalUri === undefined; + const fileUri = change.modifiedUri ?? change.uri; + return { + type: 'file', + uri: fileUri, + originalUri: isDeletion || !headCommit ? change.originalUri + : fileUri.with({ scheme: 'git', query: JSON.stringify({ path: fileUri.fsPath, ref: headCommit }) }), + state: ModifiedFileEntryState.Accepted, + isDeletion, + changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', + reviewCommentCount: 0, + linesAdded: 0, + linesRemoved: 0, + } satisfies IChangesFileItem; + }); + }); + this.activeSessionFileCountObs = this.createActiveSessionFileCountObservable(); this.activeSessionHasChangesObs = this.activeSessionFileCountObs.map(fileCount => fileCount > 0).recomputeInitiallyAndOnChange(this._store); - // Setup badge tracking - this.registerBadgeTracking(); - // Set chatSessionType on the view's context key service so ViewTitle // menu items can use it in their `when` clauses. Update reactively // when the active session changes. @@ -296,14 +374,6 @@ export class ChangesViewPane extends ViewPane { })); } - private registerBadgeTracking(): void { - // Update badge when file count changes - this._register(autorun(reader => { - const fileCount = this.activeSessionFileCountObs.read(reader); - this.updateBadge(fileCount); - })); - } - private createActiveSessionFileCountObservable(): IObservableWithChange { const activeSessionResource = this.activeSession.map(a => a?.resource); @@ -390,6 +460,9 @@ export class ChangesViewPane extends ViewPane { // List container this.listContainer = dom.append(this.contentContainer, $('.chat-editing-session-list')); + // CI Status widget beneath the card + this.ciStatusWidget = this._register(this.instantiationService.createInstance(CIStatusWidget, this.bodyContainer)); + this._register(this.onDidChangeBodyVisibility(visible => { if (visible) { this.onVisible(); @@ -451,6 +524,7 @@ export class ChangesViewPane extends ViewPane { changeType: isDeletion ? 'deleted' : 'modified', linesAdded, linesRemoved, + reviewCommentCount: 0, }); } @@ -476,37 +550,139 @@ export class ChangesViewPane extends ViewPane { return model?.changes instanceof Array ? model.changes : Iterable.empty(); }); + const reviewCommentCountByFileObs = derived(reader => { + const sessionResource = activeSessionResource.read(reader); + const sessionChanges = [...sessionFileChangesObs.read(reader)]; + + if (!sessionResource) { + return new Map(); + } + + const result = new Map(); + const prReviewState = this.codeReviewService.getPRReviewState(sessionResource).read(reader); + if (prReviewState.kind === PRReviewStateKind.Loaded) { + for (const comment of prReviewState.comments) { + const uriKey = comment.uri.fsPath; + result.set(uriKey, (result.get(uriKey) ?? 0) + 1); + } + } + + if (sessionChanges.length === 0) { + return result; + } + + const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); + const reviewVersion = getCodeReviewVersion(reviewFiles); + const reviewState = this.codeReviewService.getReviewState(sessionResource).read(reader); + + if (reviewState.kind !== CodeReviewStateKind.Result || reviewState.version !== reviewVersion) { + return result; + } + + for (const comment of reviewState.comments) { + const uriKey = comment.uri.fsPath; + result.set(uriKey, (result.get(uriKey) ?? 0) + 1); + } + + return result; + }); + // Convert session file changes to list items (cloud/background sessions) - const sessionFilesObs = derived(reader => - [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { + const sessionFilesObs = derived(reader => { + const reviewCommentCountByFile = reviewCommentCountByFileObs.read(reader); + + return [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { const isDeletion = entry.modifiedUri === undefined; const isAddition = entry.originalUri === undefined; + const uri = isIChatSessionFileChange2(entry) + ? entry.modifiedUri ?? entry.uri + : entry.modifiedUri; return { type: 'file', - uri: isIChatSessionFileChange2(entry) - ? entry.modifiedUri ?? entry.uri - : entry.modifiedUri, + uri, originalUri: entry.originalUri, state: ModifiedFileEntryState.Accepted, isDeletion, changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', linesAdded: entry.insertions, linesRemoved: entry.deletions, + reviewCommentCount: reviewCommentCountByFile.get(uri.fsPath) ?? 0, }; - }) - ); + }); + }); + + // Create observable for last turn changes using diffBetweenWithStats + // Reactively computes the diff between HEAD^ and HEAD. Memoize the diff observable so + // that we only recompute it when the HEAD commit id actually changes. + const headCommitObs = derived(reader => { + const repository = this.activeSessionRepositoryObs.read(reader)?.read(reader)?.value; + return repository?.state.read(reader)?.HEAD?.commit; + }); + + const lastTurnChangesObs = derived(reader => { + const repository = this.activeSessionRepositoryObs.read(reader)?.read(reader)?.value; + const headCommit = headCommitObs.read(reader); + if (!repository || !headCommit) { + return undefined; + } + + return observableFromPromise(repository.diffBetweenWithStats(`${headCommit}^`, headCommit)); + }); // Combine both entry sources for display const combinedEntriesObs = derived(reader => { + const headCommit = headCommitObs.read(reader); const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); - return [...editEntries, ...sessionFiles]; + const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? []; + const versionMode = this.versionModeObs.read(reader); + + let sourceEntries: IChangesFileItem[]; + if (versionMode === ChangesVersionMode.Uncommitted) { + sourceEntries = repositoryFiles; + } else if (versionMode === ChangesVersionMode.LastTurn) { + const lastTurn = lastTurnChangesObs.read(reader); + const diffChanges = lastTurn?.read(reader).value ?? []; + const parentRef = headCommit ? `${headCommit}^` : ''; + sourceEntries = diffChanges.map(change => { + const isDeletion = change.modifiedUri === undefined; + const isAddition = change.originalUri === undefined; + const fileUri = change.modifiedUri ?? change.uri; + const originalUri = isAddition ? change.originalUri + : headCommit ? fileUri.with({ scheme: 'git', query: JSON.stringify({ path: fileUri.fsPath, ref: parentRef }) }) + : change.originalUri; + return { + type: 'file', + uri: fileUri, + originalUri, + state: ModifiedFileEntryState.Accepted, + isDeletion, + changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', + linesAdded: change.insertions, + linesRemoved: change.deletions, + reviewCommentCount: 0, + } satisfies IChangesFileItem; + }); + } else { + sourceEntries = [...editEntries, ...sessionFiles, ...repositoryFiles]; + } + + const resources = new Set(); + const entries: IChangesFileItem[] = []; + for (const item of sourceEntries) { + if (!resources.has(item.uri.fsPath)) { + resources.add(item.uri.fsPath); + entries.push(item); + } + } + return entries.sort((a, b) => extUriBiasedIgnorePathCase.compare(a.uri, b.uri)); }); // Calculate stats from combined entries const topLevelStats = derived(reader => { const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); + const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? []; const entries = combinedEntriesObs.read(reader); let added = 0, removed = 0; @@ -517,7 +693,7 @@ export class ChangesViewPane extends ViewPane { } const files = entries.length; - const isSessionMenu = editEntries.length === 0 && sessionFiles.length > 0; + const isSessionMenu = editEntries.length === 0 && (sessionFiles.length > 0 || repositoryFiles.length > 0); return { files, added, removed, isSessionMenu }; }); @@ -561,26 +737,77 @@ export class ChangesViewPane extends ViewPane { return files > 0; })); - // Check if a PR exists when the active session changes + // Also bind to the ViewPane's scoped context key service so the ViewTitle menu can evaluate it + this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, r => { + const { files } = topLevelStats.read(r); + return files > 0; + })); + + // Track whether there are uncommitted (working tree) changes + this.renderDisposables.add(bindContextKey(hasUncommittedChangesContextKey, this.scopedContextKeyService, r => { + const repositoryFiles = this.activeSessionRepositoryChangesObs.read(r); + return (repositoryFiles?.length ?? 0) > 0; + })); + + // Set context key for merge base branch protection + const isMergeBaseBranchProtectedContextKey = scopedContextKeyService.createKey('sessions.isMergeBaseBranchProtected', false); + this.renderDisposables.add(autorun(reader => { + const repository = this.activeSessionRepositoryObs.read(reader)?.read(reader).value; + const state = repository?.state.read(reader); + isMergeBaseBranchProtectedContextKey.set(state?.HEAD?.base?.isProtected === true); + })); + + // Set context key for PR state from session metadata + const hasOpenPullRequestKey = scopedContextKeyService.createKey('sessions.hasOpenPullRequest', false); this.renderDisposables.add(autorun(reader => { const sessionResource = activeSessionResource.read(reader); + sessionsChangedSignal.read(reader); if (sessionResource) { const metadata = this.agentSessionsService.getSession(sessionResource)?.metadata; - this.commandService.executeCommand('github.checkOpenPullRequest', sessionResource, metadata).catch(() => { /* ignore */ }); + hasOpenPullRequestKey.set(!!metadata?.pullRequestUrl); + } else { + hasOpenPullRequestKey.set(false); } })); this.renderDisposables.add(autorun(reader => { const { isSessionMenu, added, removed } = topLevelStats.read(reader); const sessionResource = activeSessionResource.read(reader); + sessionsChangedSignal.read(reader); // Re-evaluate when session metadata changes (e.g. pullRequestUrl) const menuId = isSessionMenu ? MenuId.ChatEditingSessionChangesToolbar : MenuId.ChatEditingWidgetToolbar; + // Read code review state to update the button label dynamically + let reviewCommentCount: number | undefined; + let codeReviewLoading = false; + if (sessionResource) { + const prReviewState = this.codeReviewService.getPRReviewState(sessionResource).read(reader); + const prReviewCommentCount = prReviewState.kind === PRReviewStateKind.Loaded ? prReviewState.comments.length : 0; + const sessionChanges = this.agentSessionsService.getSession(sessionResource)?.changes; + if (sessionChanges instanceof Array && sessionChanges.length > 0) { + const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); + const reviewVersion = getCodeReviewVersion(reviewFiles); + const reviewState = this.codeReviewService.getReviewState(sessionResource).read(reader); + if (reviewState.kind === CodeReviewStateKind.Loading && reviewState.version === reviewVersion) { + codeReviewLoading = true; + } else { + const codeReviewCommentCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === reviewVersion ? reviewState.comments.length : 0; + const totalReviewCommentCount = codeReviewCommentCount + prReviewCommentCount; + if (totalReviewCommentCount > 0) { + reviewCommentCount = totalReviewCommentCount; + } + } + } else if (prReviewCommentCount > 0) { + reviewCommentCount = prReviewCommentCount; + } + } + reader.store.add(scopedInstantiationService.createInstance( MenuWorkbenchButtonBar, this.actionsContainer!, menuId, { telemetrySource: 'changesView', + disableWhileRunning: isSessionMenu, menuOptions: isSessionMenu && sessionResource ? { args: [sessionResource, this.agentSessionsService.getSession(sessionResource)?.metadata] } : { shouldForwardArgs: true }, @@ -592,15 +819,27 @@ export class ChangesViewPane extends ViewPane { ); return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'working-set-diff-stats', customLabel: diffStatsLabel }; } - if (action.id === 'github.createPullRequest' || action.id === 'github.openPullRequest') { - return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'flex-grow' }; - } - if (action.id === 'chatEditing.applyToParentRepo') { + if (action.id === RUN_SESSION_CODE_REVIEW_ACTION_ID) { + if (codeReviewLoading) { + return { showIcon: true, showLabel: true, isSecondary: true, customLabel: '$(loading~spin)', customClass: 'code-review-loading' }; + } + if (reviewCommentCount !== undefined) { + return { showIcon: true, showLabel: true, isSecondary: true, customLabel: String(reviewCommentCount), customClass: 'code-review-comments' }; + } return { showIcon: true, showLabel: false, isSecondary: true }; } if (action.id === 'chatEditing.synchronizeChanges') { return { showIcon: true, showLabel: true, isSecondary: true }; } + if (action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR') { + return { showIcon: true, showLabel: true, isSecondary: false }; + } + if (action.id === 'github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR') { + return { showIcon: true, showLabel: false, isSecondary: true }; + } + if (action.id === 'github.copilot.chat.mergeCopilotCLIAgentSessionChanges.merge') { + return { showIcon: true, showLabel: true, isSecondary: false }; + } return undefined; } } @@ -618,6 +857,11 @@ export class ChangesViewPane extends ViewPane { dom.setVisibility(!hasEntries, this.welcomeContainer!); })); + // Update badge when file count changes + this.renderDisposables.add(autorun(reader => { + this.updateBadge(topLevelStats.read(reader).files); + })); + // Update summary text (line counts only, file count is shown in badge) if (this.summaryContainer) { dom.clearNode(this.summaryContainer); @@ -743,6 +987,33 @@ export class ChangesViewPane extends ViewPane { })); } + // Bind CI status widget to active session's PR CI model + if (this.ciStatusWidget) { + const activeSessionResourceObs = derived(this, reader => this.sessionManagementService.activeSession.read(reader)?.resource); + const ciModelObs = derived(this, reader => { + const session = this.sessionManagementService.activeSession.read(reader); + if (!session) { + return undefined; + } + const context = this.sessionManagementService.getGitHubContextForSession(session.resource); + if (!context || context.prNumber === undefined) { + return undefined; + } + // Use the PR's headRef from the PR model to get CI checks + const prModel = this.gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + const pr = prModel.pullRequest.read(reader); + if (!pr) { + // Trigger a refresh if PR data isn't loaded yet + prModel.refresh(); + return undefined; + } + const ciModel = this.gitHubService.getPullRequestCI(context.owner, context.repo, pr.headRef); + ciModel.refresh(); + return ciModel; + }); + this.renderDisposables.add(this.ciStatusWidget.bind(ciModelObs, activeSessionResourceObs)); + } + // Update tree data with combined entries this.renderDisposables.add(autorun(reader => { const entries = combinedEntriesObs.read(reader); @@ -790,8 +1061,10 @@ export class ChangesViewPane extends ViewPane { const overviewHeight = this.overviewContainer?.offsetHeight ?? 0; const containerPadding = 8; // 4px top + 4px bottom from .chat-editing-session-container const containerBorder = 2; // 1px top + 1px bottom border + const ciWidgetHeight = this.ciStatusWidget?.element.offsetHeight ?? 0; + const ciWidgetMargin = ciWidgetHeight > 0 ? 8 : 0; // margin-top on CI widget - const usedHeight = bodyPadding + actionsHeight + actionsMargin + overviewHeight + containerPadding + containerBorder; + const usedHeight = bodyPadding + actionsHeight + actionsMargin + overviewHeight + containerPadding + containerBorder + ciWidgetHeight + ciWidgetMargin; const availableHeight = Math.max(0, bodyHeight - usedHeight); // Limit height to the content so the tree doesn't exceed its items @@ -861,6 +1134,7 @@ interface IChangesTreeTemplate { readonly templateDisposables: DisposableStore; readonly toolbar: MenuWorkbenchToolBar | undefined; readonly contextKeyService: IContextKeyService | undefined; + readonly reviewCommentsBadge: HTMLElement; readonly decorationBadge: HTMLElement; readonly addedSpan: HTMLElement; readonly removedSpan: HTMLElement; @@ -883,6 +1157,9 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer, _index: number, templateData: IChangesTreeTemplate): void { @@ -935,6 +1212,7 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer 0) { + templateData.reviewCommentsBadge.style.display = ''; + templateData.reviewCommentsBadge.className = 'changes-review-comments-badge'; + templateData.reviewCommentsBadge.replaceChildren( + dom.$('.codicon.codicon-comment-unresolved'), + dom.$('span', undefined, `${data.reviewCommentCount}`) + ); + } else { + templateData.reviewCommentsBadge.style.display = 'none'; + templateData.reviewCommentsBadge.replaceChildren(); + } + // Update decoration badge (A/M/D) const badge = templateData.decorationBadge; badge.className = 'changes-decoration-badge'; @@ -998,6 +1288,7 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer { registerAction2(SetChangesListViewModeAction); registerAction2(SetChangesTreeViewModeAction); + +// --- Versions Submenu + +MenuRegistry.appendMenuItem(MenuId.ViewTitle, { + submenu: MenuId.ChatEditingSessionChangesVersionsSubmenu, + title: localize2('versionsActions', 'Versions'), + icon: Codicon.versions, + group: 'navigation', + order: 9, + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', CHANGES_VIEW_ID), IsSessionsWindowContext, ChatContextKeys.hasAgentSessionChanges), +}); + +class AllChangesAction extends Action2 { + constructor() { + super({ + id: 'chatEditing.versionsAllChanges', + title: localize2('chatEditing.versionsAllChanges', 'All Changes'), + category: CHAT_CATEGORY, + toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.AllChanges), + menu: [{ + id: MenuId.ChatEditingSessionChangesVersionsSubmenu, + group: '1_changes', + order: 1, + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); + view?.setVersionMode(ChangesVersionMode.AllChanges); + } +} +registerAction2(AllChangesAction); + +class LastTurnChangesAction extends Action2 { + constructor() { + super({ + id: 'chatEditing.versionsLastTurnChanges', + title: localize2('chatEditing.versionsLastTurnChanges', "Last Turn's Changes"), + category: CHAT_CATEGORY, + toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.LastTurn), + menu: [{ + id: MenuId.ChatEditingSessionChangesVersionsSubmenu, + group: '1_changes', + order: 2, + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); + view?.setVersionMode(ChangesVersionMode.LastTurn); + } +} +registerAction2(LastTurnChangesAction); + +class UncommittedChangesAction extends Action2 { + constructor() { + super({ + id: 'chatEditing.versionsUncommittedChanges', + title: localize2('chatEditing.versionsUncommittedChanges', 'Uncommitted Changes'), + category: CHAT_CATEGORY, + toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.Uncommitted), + precondition: hasUncommittedChangesContextKey, + menu: [{ + id: MenuId.ChatEditingSessionChangesVersionsSubmenu, + group: '2_uncommitted', + order: 1, + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); + view?.setVersionMode(ChangesVersionMode.Uncommitted); + } +} +registerAction2(UncommittedChangesAction); diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts new file mode 100644 index 00000000000..9ca4eeb503d --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { observableFromEvent } from '../../../../base/common/observable.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, IAction2Options, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { hasValidDiff } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CHANGES_VIEW_ID } from './changesView.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; + +import { activeSessionHasChangesContextKey } from '../common/changes.js'; + +const openChangesViewActionOptions: IAction2Options = { + id: 'workbench.action.agentSessions.openChangesView', + title: localize2('openChangesView', "Changes"), + icon: Codicon.diffMultiple, + f1: false, +}; + +class OpenChangesViewAction extends Action2 { + + static readonly ID = openChangesViewActionOptions.id; + + constructor() { + super(openChangesViewActionOptions); + } + + async run(accessor: ServicesAccessor): Promise { + const viewsService = accessor.get(IViewsService); + await viewsService.openView(CHANGES_VIEW_ID, true); + } +} + +registerAction2(OpenChangesViewAction); + +class ChangesViewActionsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.changesViewActions'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + @IAgentSessionsService agentSessionsService: IAgentSessionsService, + ) { + super(); + + // Bind context key: true when the active session has changes + const sessionsChanged = observableFromEvent(this, agentSessionsService.model.onDidChangeSessions, () => { }); + this._register(bindContextKey(activeSessionHasChangesContextKey, contextKeyService, reader => { + sessionManagementService.activeSession.read(reader); + sessionsChanged.read(reader); + const activeSession = sessionManagementService.getActiveSession(); + if (!activeSession) { + return false; + } + const agentSession = agentSessionsService.getSession(activeSession.resource); + return !!agentSession?.changes && hasValidDiff(agentSession.changes); + })); + } +} + +registerWorkbenchContribution2(ChangesViewActionsContribution.ID, ChangesViewActionsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts new file mode 100644 index 00000000000..78023d8bff4 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts @@ -0,0 +1,519 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/ciStatusWidget.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IListRenderer, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { Action } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { spinningLoading } from '../../../../platform/theme/common/iconRegistry.js'; +import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, IGitHubCICheck } from '../../github/common/types.js'; +import { GitHubPullRequestCIModel } from '../../github/browser/models/githubPullRequestCIModel.js'; + +const $ = dom.$; + +const enum CICheckGroup { + Running, + Pending, + Failed, + Successful, +} + +interface ICICheckListItem { + readonly check: IGitHubCICheck; + readonly group: CICheckGroup; +} + +interface ICICheckCounts { + readonly running: number; + readonly pending: number; + readonly failed: number; + readonly successful: number; +} + +class CICheckListDelegate implements IListVirtualDelegate { + static readonly ITEM_HEIGHT = 24; + + getHeight(_element: ICICheckListItem): number { + return CICheckListDelegate.ITEM_HEIGHT; + } + + getTemplateId(_element: ICICheckListItem): string { + return CICheckListRenderer.TEMPLATE_ID; + } +} + +interface ICICheckTemplateData { + readonly row: HTMLElement; + readonly label: IResourceLabel; + readonly actionBar: ActionBar; + readonly templateDisposables: DisposableStore; + readonly elementDisposables: DisposableStore; +} + +class CICheckListRenderer implements IListRenderer { + static readonly TEMPLATE_ID = 'ciCheck'; + readonly templateId = CICheckListRenderer.TEMPLATE_ID; + + constructor( + private readonly _labels: ResourceLabels, + private readonly _openerService: IOpenerService, + ) { } + + renderTemplate(container: HTMLElement): ICICheckTemplateData { + const templateDisposables = new DisposableStore(); + const row = dom.append(container, $('.ci-status-widget-check')); + + const labelContainer = dom.append(row, $('.ci-status-widget-check-label')); + const label = templateDisposables.add(this._labels.create(labelContainer, { supportIcons: true })); + + const actionBarContainer = dom.append(row, $('.ci-status-widget-check-actions')); + const actionBar = templateDisposables.add(new ActionBar(actionBarContainer)); + + return { + row, + label, + actionBar, + templateDisposables, + elementDisposables: templateDisposables.add(new DisposableStore()), + }; + } + + renderElement(element: ICICheckListItem, _index: number, templateData: ICICheckTemplateData): void { + templateData.elementDisposables.clear(); + templateData.actionBar.clear(); + + templateData.row.className = `ci-status-widget-check ${getCheckStatusClass(element.check)}`; + + const title = localize('ci.checkTitle', "{0}: {1}", element.check.name, getCheckStateLabel(element.check)); + templateData.label.setResource({ + name: element.check.name, + resource: URI.from({ scheme: 'github-check', path: `/${element.check.id}/${element.check.name}` }), + }, { + icon: getCheckIcon(element.check), + title, + }); + + const actions: Action[] = []; + + if (element.check.detailsUrl) { + actions.push(templateData.elementDisposables.add(new Action( + 'ci.openOnGitHub', + localize('ci.openOnGitHub', "Open on GitHub"), + ThemeIcon.asClassName(Codicon.linkExternal), + true, + async () => { + await this._openerService.open(URI.parse(element.check.detailsUrl!)); + }, + ))); + } + + templateData.actionBar.push(actions, { icon: true, label: false }); + } + + disposeElement(_element: ICICheckListItem, _index: number, templateData: ICICheckTemplateData): void { + templateData.elementDisposables.clear(); + templateData.actionBar.clear(); + } + + disposeTemplate(templateData: ICICheckTemplateData): void { + templateData.templateDisposables.dispose(); + } +} + +/** + * A collapsible widget that shows the CI status of a PR. + * Rendered beneath the changes tree in the changes view. + */ +export class CIStatusWidget extends Disposable { + + private readonly _domNode: HTMLElement; + private readonly _headerNode: HTMLElement; + private readonly _titleNode: HTMLElement; + private readonly _titleLabel: IResourceLabel; + private readonly _headerActionBarContainer: HTMLElement; + private readonly _headerActionBar: ActionBar; + private readonly _twistieNode: HTMLElement; + private readonly _bodyNode: HTMLElement; + private readonly _list: WorkbenchList; + private readonly _labels: ResourceLabels; + private readonly _headerActionDisposables = this._register(new DisposableStore()); + + private _collapsed = true; + private _model: GitHubPullRequestCIModel | undefined; + private _sessionResource: URI | undefined; + + get element(): HTMLElement { + return this._domNode; + } + + constructor( + container: HTMLElement, + @IOpenerService private readonly _openerService: IOpenerService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + this._labels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + + this._domNode = dom.append(container, $('.ci-status-widget')); + this._domNode.style.display = 'none'; + + // Header (always visible) + this._headerNode = dom.append(this._domNode, $('.ci-status-widget-header')); + this._titleNode = dom.append(this._headerNode, $('.ci-status-widget-title')); + this._titleLabel = this._register(this._labels.create(this._titleNode, { supportIcons: true })); + this._headerActionBarContainer = dom.append(this._headerNode, $('.ci-status-widget-header-actions')); + this._headerActionBar = this._register(new ActionBar(this._headerActionBarContainer)); + this._headerActionBarContainer.style.display = 'none'; + this._register(dom.addDisposableListener(this._headerActionBarContainer, dom.EventType.CLICK, e => { + e.preventDefault(); + e.stopPropagation(); + })); + this._twistieNode = dom.append(this._headerNode, $('.ci-status-widget-twistie')); + this._updateTwistie(); + + this._register(dom.addDisposableListener(this._headerNode, 'click', () => this._toggle())); + + // Body (collapsible list of checks) + this._bodyNode = dom.append(this._domNode, $('.ci-status-widget-body')); + this._bodyNode.style.display = 'none'; + + const listContainer = $('.ci-status-widget-list'); + this._list = this._register(this._instantiationService.createInstance( + WorkbenchList, + 'CIStatusWidget', + listContainer, + new CICheckListDelegate(), + [new CICheckListRenderer(this._labels, this._openerService)], + { + multipleSelectionSupport: false, + openOnSingleClick: false, + accessibilityProvider: { + getWidgetAriaLabel: () => localize('ci.checksListAriaLabel', "Checks"), + getAriaLabel: item => localize('ci.checkAriaLabel', "{0}, {1}", item.check.name, getCheckStateLabel(item.check)), + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: item => item.check.name, + }, + }, + )); + this._bodyNode.appendChild(this._list.getHTMLElement()); + } + + /** + * Bind to a CI model. When `ciModel` is undefined, the widget hides. + * Returns a disposable that stops observation. + */ + bind(ciModel: IObservable, sessionResource: IObservable): IDisposable { + return autorun(reader => { + const model = ciModel.read(reader); + this._sessionResource = sessionResource.read(reader); + this._model = model; + if (!model) { + this._renderBody([]); + this._renderHeaderActions([]); + this._domNode.style.display = 'none'; + return; + } + + const checks = model.checks.read(reader); + const overallStatus = model.overallStatus.read(reader); + + if (checks.length === 0) { + this._renderBody([]); + this._renderHeaderActions([]); + this._domNode.style.display = 'none'; + return; + } + + this._domNode.style.display = ''; + this._renderHeader(checks, overallStatus); + this._renderHeaderActions(getFailedChecks(checks)); + this._renderBody(sortChecks(checks)); + }); + } + + private _toggle(): void { + this._collapsed = !this._collapsed; + this._bodyNode.style.display = this._collapsed ? 'none' : ''; + this._updateTwistie(); + } + + private _updateTwistie(): void { + dom.clearNode(this._twistieNode); + this._twistieNode.appendChild(renderIcon(this._collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + } + + private _renderHeader(checks: readonly IGitHubCICheck[], overallStatus: GitHubCIOverallStatus): void { + const { icon, className } = getHeaderIconAndClass(checks, overallStatus); + this._titleNode.className = `ci-status-widget-title ${className}`; + + const summary = getChecksSummary(checks); + const title = localize('ci.headerTitle', "Checks: {0}", summary); + this._titleLabel.setResource({ + name: title, + resource: URI.from({ scheme: 'github-checks', path: '/summary' }), + }, { + icon: icon, + title, + }); + } + + private _renderHeaderActions(failedChecks: readonly IGitHubCICheck[]): void { + this._headerActionDisposables.clear(); + this._headerActionBar.clear(); + + if (failedChecks.length === 0) { + this._headerActionBarContainer.style.display = 'none'; + return; + } + + const fixChecksAction = this._headerActionDisposables.add(new Action( + 'ci.fixChecks', + localize('ci.fixChecks', "Fix Checks"), + ThemeIcon.asClassName(Codicon.sparkle), + true, + async () => { + await this._sendFixChecksPrompt(failedChecks); + }, + )); + + this._headerActionBar.push([fixChecksAction], { icon: true, label: false }); + this._headerActionBarContainer.style.display = 'flex'; + } + + private _renderBody(checks: readonly ICICheckListItem[]): void { + const height = checks.length * CICheckListDelegate.ITEM_HEIGHT; + this._list.getHTMLElement().style.height = `${height}px`; + this._list.layout(height); + this._list.splice(0, this._list.length, checks); + } + + private async _sendFixChecksPrompt(failedChecks: readonly IGitHubCICheck[]): Promise { + const model = this._model; + const sessionResource = this._sessionResource; + if (!model || !sessionResource || failedChecks.length === 0) { + return; + } + + const failedCheckDetails = await Promise.all(failedChecks.map(async check => { + const annotations = await model.getCheckRunAnnotations(check.id); + return { + check, + annotations, + }; + })); + + const prompt = buildFixChecksPrompt(failedCheckDetails); + const chatWidget = this._chatWidgetService.getWidgetBySessionResource(sessionResource) + ?? await this._chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); + if (!chatWidget) { + return; + } + + await chatWidget.acceptInput(prompt, { noCommandDetection: true }); + } +} + +function sortChecks(checks: readonly IGitHubCICheck[]): ICICheckListItem[] { + return [...checks] + .sort(compareChecks) + .map(check => ({ check, group: getCheckGroup(check) })); +} + +function compareChecks(a: IGitHubCICheck, b: IGitHubCICheck): number { + const groupDiff = getCheckGroup(a) - getCheckGroup(b); + if (groupDiff !== 0) { + return groupDiff; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); +} + +function getCheckGroup(check: IGitHubCICheck): CICheckGroup { + switch (check.status) { + case GitHubCheckStatus.InProgress: + return CICheckGroup.Running; + case GitHubCheckStatus.Queued: + return CICheckGroup.Pending; + case GitHubCheckStatus.Completed: + return isFailedConclusion(check.conclusion) ? CICheckGroup.Failed : CICheckGroup.Successful; + } +} + +function getCheckCounts(checks: readonly IGitHubCICheck[]): ICICheckCounts { + let running = 0; + let pending = 0; + let failed = 0; + let successful = 0; + + for (const check of checks) { + switch (getCheckGroup(check)) { + case CICheckGroup.Running: + running++; + break; + case CICheckGroup.Pending: + pending++; + break; + case CICheckGroup.Failed: + failed++; + break; + case CICheckGroup.Successful: + successful++; + break; + } + } + + return { running, pending, failed, successful }; +} + +function getFailedChecks(checks: readonly IGitHubCICheck[]): readonly IGitHubCICheck[] { + return checks.filter(check => getCheckGroup(check) === CICheckGroup.Failed); +} + +function getChecksSummary(checks: readonly IGitHubCICheck[]): string { + const counts = getCheckCounts(checks); + const parts: string[] = []; + + if (counts.running > 0) { + parts.push(counts.running === 1 + ? localize('ci.oneRunning', "1 running") + : localize('ci.manyRunning', "{0} running", counts.running)); + } + + if (counts.pending > 0) { + parts.push(counts.pending === 1 + ? localize('ci.onePending', "1 pending") + : localize('ci.manyPending', "{0} pending", counts.pending)); + } + + if (counts.failed > 0) { + parts.push(counts.failed === 1 + ? localize('ci.oneFailed', "1 failed") + : localize('ci.manyFailed', "{0} failed", counts.failed)); + } + + if (counts.successful > 0) { + parts.push(counts.successful === 1 + ? localize('ci.oneSuccessful', "1 successful") + : localize('ci.manySuccessful', "{0} successful", counts.successful)); + } + + return parts.join(', '); +} + +function buildFixChecksPrompt(failedChecks: ReadonlyArray<{ check: IGitHubCICheck; annotations: string }>): string { + const sections = failedChecks.map(({ check, annotations }) => { + const parts = [ + `Check: ${check.name}`, + `Status: ${getCheckStateLabel(check)}`, + `Conclusion: ${check.conclusion ?? 'unknown'}`, + ]; + + if (check.detailsUrl) { + parts.push(`Details: ${check.detailsUrl}`); + } + + parts.push('', 'Annotations and output:', annotations || 'No output available for this check run.'); + return parts.join('\n'); + }); + + return [ + 'Please fix the failed CI checks for this session immediately.', + 'Use the failed check information below, including annotations and check output, to identify the root causes and make the necessary code changes.', + 'Focus on resolving these CI failures. Avoid unrelated changes unless they are required to fix the checks.', + '', + 'Failed CI checks:', + '', + sections.join('\n\n---\n\n'), + ].join('\n'); +} + +function getHeaderIconAndClass(checks: readonly IGitHubCICheck[], overallStatus: GitHubCIOverallStatus): { icon: ThemeIcon; className: string } { + const counts = getCheckCounts(checks); + if (counts.running > 0) { + return { icon: spinningLoading, className: 'ci-status-running' }; + } + + switch (overallStatus) { + case GitHubCIOverallStatus.Success: + return { icon: Codicon.passFilled, className: 'ci-status-success' }; + case GitHubCIOverallStatus.Failure: + return { icon: Codicon.error, className: 'ci-status-failure' }; + case GitHubCIOverallStatus.Pending: + return { icon: Codicon.circle, className: 'ci-status-pending' }; + default: + return { icon: Codicon.circleFilled, className: 'ci-status-neutral' }; + } +} + +function getCheckIcon(check: IGitHubCICheck): ThemeIcon { + switch (check.status) { + case GitHubCheckStatus.InProgress: + return spinningLoading; + case GitHubCheckStatus.Queued: + return Codicon.circle; + case GitHubCheckStatus.Completed: + switch (check.conclusion) { + case GitHubCheckConclusion.Success: + return Codicon.passFilled; + case GitHubCheckConclusion.Failure: + case GitHubCheckConclusion.TimedOut: + case GitHubCheckConclusion.ActionRequired: + return Codicon.error; + case GitHubCheckConclusion.Cancelled: + return Codicon.circleSlash; + case GitHubCheckConclusion.Skipped: + return Codicon.debugStepOver; + default: + return Codicon.circleFilled; + } + } +} + +function getCheckStatusClass(check: IGitHubCICheck): string { + switch (getCheckGroup(check)) { + case CICheckGroup.Running: + return 'ci-status-running'; + case CICheckGroup.Pending: + return 'ci-status-pending'; + case CICheckGroup.Failed: + return 'ci-status-failure'; + case CICheckGroup.Successful: + return 'ci-status-success'; + } +} + +function getCheckStateLabel(check: IGitHubCICheck): string { + switch (getCheckGroup(check)) { + case CICheckGroup.Running: + return localize('ci.runningState', "running"); + case CICheckGroup.Pending: + return localize('ci.pendingState', "pending"); + case CICheckGroup.Failed: + return localize('ci.failedState', "failed"); + case CICheckGroup.Successful: + return localize('ci.successfulState', "successful"); + } +} + +function isFailedConclusion(conclusion: GitHubCheckConclusion | undefined): boolean { + return conclusion === GitHubCheckConclusion.Failure + || conclusion === GitHubCheckConclusion.TimedOut + || conclusion === GitHubCheckConclusion.ActionRequired; +} diff --git a/src/vs/sessions/contrib/changesView/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css similarity index 82% rename from src/vs/sessions/contrib/changesView/browser/media/changesView.css rename to src/vs/sessions/contrib/changes/browser/media/changesView.css index 1300b886cbc..1b187233832 100644 --- a/src/vs/sessions/contrib/changesView/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -104,7 +104,6 @@ .changes-view-body .chat-editing-session-actions.outside-card .monaco-button { height: 26px; padding: 4px 14px; - border-radius: 4px; font-size: 12px; line-height: 18px; } @@ -114,6 +113,29 @@ flex: 1; } +/* ButtonWithDropdown container grows to fill available space */ +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown { + flex: 1; + display: flex; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button { + flex: 1; + box-sizing: border-box; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button-dropdown-separator { + flex: 0; +} + +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button.monaco-dropdown-button { + flex: 0 0 auto; + padding: 4px; + width: auto; + min-width: 0; + border-radius: 0px 4px 4px 0px; +} + .changes-view-body .chat-editing-session-actions.outside-card .monaco-button.secondary.monaco-text-button.codicon { padding: 4px 8px; font-size: 16px !important; @@ -136,6 +158,10 @@ color: var(--vscode-button-secondaryForeground); } +.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button.secondary.monaco-text-button { + border-radius: 4px 0px 0px 4px; +} + .changes-view-body .chat-editing-session-actions .monaco-button.secondary:hover { background-color: var(--vscode-button-secondaryHoverBackground); color: var(--vscode-button-secondaryForeground); @@ -213,6 +239,19 @@ font-size: 11px; } +.changes-view-body .chat-editing-session-list .changes-review-comments-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + margin-right: 6px; + color: var(--vscode-descriptionForeground); +} + +.changes-view-body .chat-editing-session-list .changes-review-comments-badge .codicon { + font-size: 12px; +} + .changes-view-body .chat-editing-session-list .working-set-lines-added { color: var(--vscode-chat-linesAddedForeground); } @@ -235,3 +274,9 @@ .changes-view-body .chat-editing-session-actions .monaco-button .working-set-lines-removed { color: var(--vscode-chat-linesRemovedForeground); } + +.changes-view-body .chat-editing-session-actions .monaco-button.code-review-comments, +.changes-view-body .chat-editing-session-actions .monaco-button.code-review-loading { + padding-left: 4px; + padding-right: 4px; +} diff --git a/src/vs/sessions/contrib/changesView/browser/media/changesViewActions.css b/src/vs/sessions/contrib/changes/browser/media/changesViewActions.css similarity index 100% rename from src/vs/sessions/contrib/changesView/browser/media/changesViewActions.css rename to src/vs/sessions/contrib/changes/browser/media/changesViewActions.css diff --git a/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css new file mode 100644 index 00000000000..2acaa1fa18d --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* CI Status Widget - beneath the changes tree */ +.ci-status-widget { + margin-top: 8px; + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 4px; + background-color: var(--vscode-editor-background); + overflow: hidden; + font-size: 12px; +} + +/* Header - always visible, clickable */ +.ci-status-widget-header { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 4px; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + min-height: 22px; +} + +.ci-status-widget-header:hover { + background-color: var(--vscode-list-hoverBackground); +} + +/* Title - single line, overflow ellipsis */ +.ci-status-widget-title { + flex: 1; + overflow: hidden; + color: var(--vscode-foreground); +} + +.ci-status-widget-title .monaco-icon-label { + width: 100%; +} + +.ci-status-widget-title .monaco-icon-label-container, +.ci-status-widget-title .monaco-icon-name-container { + display: block; + overflow: hidden; +} + +.ci-status-widget-title .label-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ci-status-widget-header-actions { + flex: 0 0 auto; + display: none; + align-items: center; + margin-left: auto; +} + +.ci-status-widget-header-actions .monaco-action-bar { + display: flex; + align-items: center; +} + +.ci-status-widget-header-actions .action-item .action-label { + width: 16px; + height: 16px; +} + +/* Twistie icon on the right */ +.ci-status-widget-twistie { + flex: 0 0 auto; + display: flex; + align-items: center; + color: var(--vscode-foreground); + opacity: 0.7; +} + +/* Body - collapsible list */ +.ci-status-widget-body { + border-top: 1px solid var(--vscode-input-border, transparent); +} + +.ci-status-widget-list { + background-color: transparent; +} + +.ci-status-widget-list > .monaco-list, +.ci-status-widget-list > .monaco-list > .monaco-scrollable-element { + background-color: transparent; +} + +/* Individual check row */ +.ci-status-widget-check { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 4px; + height: 100%; + width: 100%; + box-sizing: border-box; + min-width: 0; +} + +.ci-status-widget-list .monaco-list-row:hover .ci-status-widget-check, +.ci-status-widget-list .monaco-list-row.focused .ci-status-widget-check, +.ci-status-widget-list .monaco-list-row.selected .ci-status-widget-check { + background-color: var(--vscode-list-hoverBackground); +} + +.ci-status-widget-check-label { + display: flex; + flex: 1; + min-width: 0; + overflow: hidden; +} + + +.ci-status-widget-check-label .monaco-icon-label { + display: flex; + flex: 1; + min-width: 0; + width: 100%; +} + +.ci-status-widget-check-label .monaco-icon-label-container, +.ci-status-widget-check-label .monaco-icon-name-container { + display: block; + min-width: 0; + overflow: hidden; +} + +.ci-status-widget-check-label .label-name { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-foreground); +} + +.ci-status-widget-title.ci-status-success .monaco-icon-label::before, +.ci-status-widget-check.ci-status-success .monaco-icon-label::before { + color: var(--vscode-testing-iconPassed, #73c991); +} + +.ci-status-widget-title.ci-status-failure .monaco-icon-label::before, +.ci-status-widget-check.ci-status-failure .monaco-icon-label::before { + color: var(--vscode-testing-iconFailed, #f14c4c); +} + +.ci-status-widget-title.ci-status-running .monaco-icon-label::before, +.ci-status-widget-check.ci-status-running .monaco-icon-label::before, +.ci-status-widget-title.ci-status-pending .monaco-icon-label::before, +.ci-status-widget-check.ci-status-pending .monaco-icon-label::before { + color: var(--vscode-testing-iconQueued, var(--vscode-editorWarning-foreground)); +} + +.ci-status-widget-title.ci-status-neutral .monaco-icon-label::before, +.ci-status-widget-check.ci-status-neutral .monaco-icon-label::before { + color: var(--vscode-descriptionForeground); +} + +/* Actions - float to the right, visible on hover */ +.ci-status-widget-check-actions { + display: none; + flex: 0 0 auto; + flex-shrink: 0; + margin-left: auto; +} + +.ci-status-widget-list .monaco-list-row:hover .ci-status-widget-check-actions, +.ci-status-widget-list .monaco-list-row.focused .ci-status-widget-check-actions, +.ci-status-widget-list .monaco-list-row.selected .ci-status-widget-check-actions, +.ci-status-widget-check:hover .ci-status-widget-check-actions { + display: flex; +} + +.ci-status-widget-check-actions .monaco-action-bar { + display: flex; + align-items: center; +} + +.ci-status-widget-check-actions .action-bar .action-item .action-label { + width: 16px; + height: 16px; +} diff --git a/src/vs/sessions/contrib/changesView/browser/toggleChangesView.ts b/src/vs/sessions/contrib/changes/browser/toggleChangesView.ts similarity index 98% rename from src/vs/sessions/contrib/changesView/browser/toggleChangesView.ts rename to src/vs/sessions/contrib/changes/browser/toggleChangesView.ts index e7371a4f73d..abc40780aa6 100644 --- a/src/vs/sessions/contrib/changesView/browser/toggleChangesView.ts +++ b/src/vs/sessions/contrib/changes/browser/toggleChangesView.ts @@ -110,7 +110,7 @@ export class ToggleChangesViewContribution extends Disposable { private syncAuxiliaryBarVisibility(hasChanges: boolean): void { if (hasChanges) { - this.viewsService.openView(CHANGES_VIEW_ID, true); + this.viewsService.openView(CHANGES_VIEW_ID, false); } else { this.layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); } diff --git a/src/vs/sessions/contrib/changesView/common/changes.ts b/src/vs/sessions/contrib/changes/common/changes.ts similarity index 100% rename from src/vs/sessions/contrib/changesView/common/changes.ts rename to src/vs/sessions/contrib/changes/common/changes.ts diff --git a/src/vs/sessions/contrib/changesView/browser/changesViewActions.ts b/src/vs/sessions/contrib/changesView/browser/changesViewActions.ts deleted file mode 100644 index 1a06ef09fb5..00000000000 --- a/src/vs/sessions/contrib/changesView/browser/changesViewActions.ts +++ /dev/null @@ -1,180 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import './media/changesViewActions.css'; -import { $, reset } from '../../../../base/browser/dom.js'; -import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize, localize2 } from '../../../../nls.js'; -import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { Action2, IAction2Options, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { getAgentChangesSummary, hasValidDiff } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; -import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; -import { Menus } from '../../../browser/menus.js'; -import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; -import { CHANGES_VIEW_ID } from './changesView.js'; -import { IAction } from '../../../../base/common/actions.js'; -import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; - -import { activeSessionHasChangesContextKey } from '../common/changes.js'; - -const openChangesViewActionOptions: IAction2Options = { - id: 'workbench.action.agentSessions.openChangesView', - title: localize2('openChangesView', "Changes"), - icon: Codicon.diffMultiple, - f1: false, - menu: { - id: Menus.SessionTitleActions, - order: 1, - when: ContextKeyExpr.equals(activeSessionHasChangesContextKey.key, true), - }, -}; - -class OpenChangesViewAction extends Action2 { - - static readonly ID = openChangesViewActionOptions.id; - - constructor() { - super(openChangesViewActionOptions); - } - - async run(accessor: ServicesAccessor): Promise { - const viewsService = accessor.get(IViewsService); - await viewsService.openView(CHANGES_VIEW_ID, true); - } -} - -registerAction2(OpenChangesViewAction); - -/** - * Custom action view item that renders the changes summary as: - * [diff-icon] +insertions -deletions - */ -class ChangesActionViewItem extends BaseActionViewItem { - - private _container: HTMLElement | undefined; - private readonly _renderDisposables = this._register(new DisposableStore()); - - constructor( - action: IAction, - options: IBaseActionViewItemOptions | undefined, - @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, - @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, - @IHoverService private readonly hoverService: IHoverService, - ) { - super(undefined, action, options); - - this._register(autorun(reader => { - this.sessionManagementService.activeSession.read(reader); - this._updateLabel(); - })); - - this._register(this.agentSessionsService.model.onDidChangeSessions(() => { - this._updateLabel(); - })); - } - - override render(container: HTMLElement): void { - super.render(container); - this._container = container; - container.classList.add('changes-action-view-item'); - this._updateLabel(); - } - - private _updateLabel(): void { - if (!this._container) { - return; - } - - this._renderDisposables.clear(); - reset(this._container); - - const activeSession = this.sessionManagementService.getActiveSession(); - if (!activeSession) { - this._container.style.display = 'none'; - return; - } - - const agentSession = this.agentSessionsService.getSession(activeSession.resource); - const changes = agentSession?.changes; - - if (!changes || !hasValidDiff(changes)) { - this._container.style.display = 'none'; - return; - } - - const summary = getAgentChangesSummary(changes); - if (!summary) { - this._container.style.display = 'none'; - return; - } - - this._container.style.display = ''; - - // Diff icon - const iconEl = $('span.changes-action-icon' + ThemeIcon.asCSSSelector(Codicon.diffMultiple)); - this._container.appendChild(iconEl); - - // Insertions - const addedEl = $('span.changes-action-added'); - addedEl.textContent = `+${summary.insertions}`; - this._container.appendChild(addedEl); - - // Deletions - const removedEl = $('span.changes-action-removed'); - removedEl.textContent = `-${summary.deletions}`; - this._container.appendChild(removedEl); - - // Hover - this._renderDisposables.add(this.hoverService.setupManagedHover( - getDefaultHoverDelegate('mouse'), - this._container, - localize('agentSessions.viewChanges', "View All Changes") - )); - } -} - -class ChangesViewActionsContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.changesViewActions'; - - constructor( - @IActionViewItemService actionViewItemService: IActionViewItemService, - @IInstantiationService instantiationService: IInstantiationService, - @IContextKeyService contextKeyService: IContextKeyService, - @ISessionsManagementService sessionManagementService: ISessionsManagementService, - @IAgentSessionsService agentSessionsService: IAgentSessionsService, - ) { - super(); - - this._register(actionViewItemService.register(Menus.SessionTitleActions, OpenChangesViewAction.ID, (action, options) => { - return instantiationService.createInstance(ChangesActionViewItem, action, options); - })); - - // Bind context key: true when the active session has changes - const sessionsChanged = observableFromEvent(this, agentSessionsService.model.onDidChangeSessions, () => { }); - this._register(bindContextKey(activeSessionHasChangesContextKey, contextKeyService, reader => { - sessionManagementService.activeSession.read(reader); - sessionsChanged.read(reader); - const activeSession = sessionManagementService.getActiveSession(); - if (!activeSession) { - return false; - } - const agentSession = agentSessionsService.getSession(activeSession.resource); - return !!agentSession?.changes && hasValidDiff(agentSession.changes); - })); - } -} - -registerWorkbenchContribution2(ChangesViewActionsContribution.ID, ChangesViewActionsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 2f1b25038ee..1f0fd2142ad 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -4,20 +4,32 @@ *--------------------------------------------------------------------------------------------*/ import { derived, IObservable, observableValue, ISettableObservable } from '../../../../base/common/observable.js'; -import { joinPath } from '../../../../base/common/resources.js'; +import { joinPath, relativePath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { IAICustomizationWorkspaceService, AICustomizationManagementSection, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; -import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IAICustomizationWorkspaceService, AICustomizationManagementSection, IStorageSourceFilter, applyStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IChatPromptSlashCommand, IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { CustomizationCreatorService } from '../../../../workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { localize } from '../../../../nls.js'; /** * Agent Sessions override of IAICustomizationWorkspaceService. * Delegates to ISessionsManagementService to provide the active session's * worktree/repository as the project root, and supports worktree commit. + * + * Customization files are always committed to the main repository so they + * persist across worktrees. When a worktree is active the file is also + * copied into the worktree and committed there so the running session + * picks it up immediately. */ export class SessionsAICustomizationWorkspaceService implements IAICustomizationWorkspaceService { declare readonly _serviceBrand: undefined; @@ -44,7 +56,12 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization constructor( @ISessionsManagementService private readonly sessionsService: ISessionsManagementService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IPromptsService private readonly promptsService: IPromptsService, @IPathService pathService: IPathService, + @ICommandService private readonly commandService: ICommandService, + @ILogService private readonly logService: ILogService, + @IFileService private readonly fileService: IFileService, + @INotificationService private readonly notificationService: INotificationService, ) { const userHome = pathService.userHome({ preferLocal: true }); this._cliUserRoots = [ @@ -53,7 +70,7 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization joinPath(userHome, '.agents'), ]; this._cliUserFilter = { - sources: [PromptsStorage.local, PromptsStorage.user], + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, BUILTIN_STORAGE], includedUserFileRoots: this._cliUserRoots, }; @@ -96,16 +113,16 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization AICustomizationManagementSection.Instructions, AICustomizationManagementSection.Prompts, AICustomizationManagementSection.Hooks, - // TODO: Re-enable MCP Servers once CLI MCP configuration is unified with VS Code - // AICustomizationManagementSection.McpServers, + AICustomizationManagementSection.McpServers, + AICustomizationManagementSection.Plugins, ]; private static readonly _hooksFilter: IStorageSourceFilter = { - sources: [PromptsStorage.local], + sources: [PromptsStorage.local, PromptsStorage.plugin], }; private static readonly _allUserRootsFilter: IStorageSourceFilter = { - sources: [PromptsStorage.local, PromptsStorage.user], + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, BUILTIN_STORAGE], }; getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { @@ -120,15 +137,149 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization return this._cliUserFilter; } - /** - * Returns the CLI-accessible user directories (~/.copilot, ~/.claude, ~/.agents). - */ readonly isSessionsWindow = true; - async commitFiles(projectRoot: URI, fileUris: URI[]): Promise { + /** + * Commits customization files. Always commits to the main repository + * so the change persists across worktrees. When a worktree is active + * the file is also committed there so the session sees it immediately. + */ + async commitFiles(_projectRoot: URI, fileUris: URI[]): Promise { const session = this.sessionsService.getActiveSession(); - if (session) { - await this.sessionsService.commitWorktreeFiles(session, fileUris); + if (!session?.repository) { + return; + } + + for (const fileUri of fileUris) { + await this.commitFileToRepos(fileUri, session.repository, session.worktree); + } + } + + /** + * Commits the deletion of files that have already been removed from disk. + * Always stages + commits the removal in the main repository, and also + * in the worktree if one is active. + */ + async deleteFiles(_projectRoot: URI, fileUris: URI[]): Promise { + const session = this.sessionsService.getActiveSession(); + if (!session?.repository) { + return; + } + + for (const fileUri of fileUris) { + await this.commitDeletionToRepos(fileUri, session.repository, session.worktree); + } + } + + /** + * Computes the repository-relative path for a file. The file may be + * located under the worktree or the repository root. + */ + private getRelativePath(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): string | undefined { + // Try worktree first (when active, files are written under it) + if (worktreeUri) { + const rel = relativePath(worktreeUri, fileUri); + if (rel) { + return rel; + } + } + return relativePath(repositoryUri, fileUri); + } + + /** + * Commits a single file to the main repository and optionally the worktree. + * Copies the file content between trees when needed. + */ + private async commitFileToRepos(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): Promise { + const relPath = this.getRelativePath(fileUri, repositoryUri, worktreeUri); + if (!relPath) { + return; + } + + const repoFileUri = URI.joinPath(repositoryUri, relPath); + + // 1. Always commit to main repository + try { + if (repoFileUri.toString() !== fileUri.toString()) { + const content = await this.fileService.readFile(fileUri); + await this.fileService.writeFile(repoFileUri, content.value); + } + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToRepository', + { repositoryUri, fileUri: repoFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit to repository:', error); + if (worktreeUri) { + this.notificationService.notify({ + severity: Severity.Warning, + message: localize('commitToRepoFailed', "Your customization was saved to this session's worktree, but we couldn't apply it to the default branch. You may need to apply it manually."), + }); + } + } + + // 2. Also commit to the worktree if active + if (worktreeUri) { + const worktreeFileUri = URI.joinPath(worktreeUri, relPath); + try { + if (worktreeFileUri.toString() !== fileUri.toString()) { + const content = await this.fileService.readFile(fileUri); + await this.fileService.writeFile(worktreeFileUri, content.value); + } + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToWorktree', + { worktreeUri, fileUri: worktreeFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit to worktree:', error); + } + } + } + + /** + * Commits the deletion of a file to the main repository and optionally + * the worktree. The file is already deleted from disk before this is called; + * `git add` on a deleted path stages the removal. + */ + private async commitDeletionToRepos(fileUri: URI, repositoryUri: URI, worktreeUri: URI | undefined): Promise { + const relPath = this.getRelativePath(fileUri, repositoryUri, worktreeUri); + if (!relPath) { + return; + } + + const repoFileUri = URI.joinPath(repositoryUri, relPath); + + // 1. Delete from main repository if it exists there, then commit + try { + if (await this.fileService.exists(repoFileUri)) { + await this.fileService.del(repoFileUri, { useTrash: true, recursive: true }); + } + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToRepository', + { repositoryUri, fileUri: repoFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit deletion to repository:', error); + if (worktreeUri) { + this.notificationService.notify({ + severity: Severity.Warning, + message: localize('deleteFromRepoFailed', "Your customization was removed from this session's worktree, but we couldn't apply the change to the default branch. You may need to remove it manually."), + }); + } + } + + // 2. Also commit the deletion in the worktree if active + if (worktreeUri) { + const worktreeFileUri = URI.joinPath(worktreeUri, relPath); + try { + // The file may already be deleted from the worktree by the caller + await this.commandService.executeCommand( + 'github.copilot.cli.sessions.commitToWorktree', + { worktreeUri, fileUri: worktreeFileUri } + ); + } catch (error) { + this.logService.error('[SessionsAICustomizationWorkspaceService] Failed to commit deletion to worktree:', error); + } } } @@ -136,4 +287,12 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization const creator = this.instantiationService.createInstance(CustomizationCreatorService); await creator.createWithAI(type); } + + async getFilteredPromptSlashCommands(token: CancellationToken): Promise { + const allCommands = await this.promptsService.getPromptSlashCommands(token); + return allCommands.filter(cmd => { + const filter = this.getStorageSourceFilter(cmd.promptPath.type); + return applyStorageSourceFilter([cmd.promptPath], filter).length > 0; + }); + } } diff --git a/src/vs/sessions/contrib/chat/browser/branchPicker.ts b/src/vs/sessions/contrib/chat/browser/branchPicker.ts index e12427b28ca..0e4ff4ce967 100644 --- a/src/vs/sessions/contrib/chat/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/branchPicker.ts @@ -12,8 +12,6 @@ import { IActionWidgetService } from '../../../../platform/actionWidget/browser/ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { INewSession } from './newSession.js'; - const COPILOT_WORKTREE_PATTERN = 'copilot-worktree-'; const FILTER_THRESHOLD = 10; @@ -31,7 +29,7 @@ interface IBranchItem { export class BranchPicker extends Disposable { private _selectedBranch: string | undefined; - private _newSession: INewSession | undefined; + private _preferredBranch: string | undefined; private _branches: string[] = []; private readonly _onDidChange = this._register(new Emitter()); @@ -48,19 +46,19 @@ export class BranchPicker extends Disposable { return this._selectedBranch; } + /** + * Sets a preferred branch to select when branches are loaded. + */ + setPreferredBranch(branch: string | undefined): void { + this._preferredBranch = branch; + } + constructor( @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, ) { super(); } - /** - * Sets the new session that this picker writes to. - */ - setNewSession(session: INewSession | undefined): void { - this._newSession = session; - } - /** * Sets the git repository and loads its branches. * When undefined, the picker is shown disabled. @@ -70,7 +68,7 @@ export class BranchPicker extends Disposable { this._selectedBranch = undefined; if (!repository) { - this._newSession?.setBranch(undefined); + this._onDidChange.fire(undefined); this._setLoading(false); this._updateTriggerLabel(); return; @@ -85,8 +83,11 @@ export class BranchPicker extends Disposable { .filter((name): name is string => !!name) .filter(name => !name.includes(COPILOT_WORKTREE_PATTERN)); - // Select active branch, main, master, or the first branch by default - const defaultBranch = this._branches.find(b => b === repository.state.get().HEAD?.name) + // Select preferred branch (from draft), active branch, main, master, or the first branch + const preferred = this._preferredBranch; + this._preferredBranch = undefined; + const defaultBranch = (preferred ? this._branches.find(b => b === preferred) : undefined) + ?? this._branches.find(b => b === repository.state.get().HEAD?.name) ?? this._branches.find(b => b === 'main') ?? this._branches.find(b => b === 'master') ?? this._branches[0]; @@ -185,7 +186,6 @@ export class BranchPicker extends Disposable { private _selectBranch(branch: string): void { if (this._selectedBranch !== branch) { this._selectedBranch = branch; - this._newSession?.setBranch(branch); this._onDidChange.fire(branch); this._updateTriggerLabel(); } diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 74b536c6947..5e49081cc2b 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -47,11 +47,12 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { id: OpenSessionWorktreeInVSCodeAction.ID, title: localize2('openInVSCode', 'Open in VS Code'), icon: Codicon.vscodeInsiders, + precondition: IsActiveSessionBackgroundProviderContext, menu: [{ - id: Menus.TitleBarRight, + id: Menus.TitleBarSessionMenu, group: 'navigation', order: 10, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext) + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext), }] }); } @@ -92,29 +93,6 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { } registerAction2(OpenSessionWorktreeInVSCodeAction); -// Disabled placeholder shown in the titlebar when the active session does not support opening in VS Code -class OpenSessionWorktreeInVSCodeNotAvailableAction extends Action2 { - constructor() { - super({ - id: 'chat.openSessionWorktreeInVSCode.notAvailable', - title: localize2('openInVSCode', 'Open in VS Code'), - tooltip: localize('openInVSCodeNotAvailableTooltip', "Open in VS Code is not available for this session type"), - icon: Codicon.vscodeInsiders, - precondition: ContextKeyExpr.false(), - menu: [{ - id: Menus.TitleBarRight, - group: 'navigation', - order: 10, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext.toNegated()) - }] - }); - } - - override run(): void { } -} - -registerAction2(OpenSessionWorktreeInVSCodeNotAvailableAction); - class NewChatInSessionsWindowAction extends Action2 { constructor() { diff --git a/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts b/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts new file mode 100644 index 00000000000..fcda250ebc9 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts @@ -0,0 +1,179 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IPromptsService, PromptsStorage, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; + +const PROMPT_SECTIONS: { section: AICustomizationManagementSection; type: PromptsType }[] = [ + { section: AICustomizationManagementSection.Agents, type: PromptsType.agent }, + { section: AICustomizationManagementSection.Skills, type: PromptsType.skill }, + { section: AICustomizationManagementSection.Instructions, type: PromptsType.instructions }, + { section: AICustomizationManagementSection.Prompts, type: PromptsType.prompt }, + { section: AICustomizationManagementSection.Hooks, type: PromptsType.hook }, +]; + +class CustomizationsDebugLogContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.customizationsDebugLog'; + + private readonly _logger: ILogger; + + constructor( + @ILoggerService loggerService: ILoggerService, + @IPromptsService private readonly _promptsService: IPromptsService, + @IAICustomizationWorkspaceService private readonly _workspaceService: IAICustomizationWorkspaceService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IMcpService private readonly _mcpService: IMcpService, + ) { + super(); + this._logger = this._register(loggerService.createLogger('customizationsDebug', { name: 'Customizations Debug' })); + + this._register(this._promptsService.onDidChangeCustomAgents(() => this._logSnapshot())); + this._register(this._promptsService.onDidChangeSlashCommands(() => this._logSnapshot())); + this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._logSnapshot())); + this._register(autorun(reader => { + this._workspaceService.activeProjectRoot.read(reader); + this._logSnapshot(); + })); + this._register(autorun(reader => { + this._mcpService.servers.read(reader); + this._logSnapshot(); + })); + } + + private _pendingSnapshot: Promise | undefined; + private _snapshotDirty = false; + + private _logSnapshot(): void { + if (this._pendingSnapshot) { + this._snapshotDirty = true; + return; + } + this._pendingSnapshot = this._doLogSnapshot().finally(() => { + this._pendingSnapshot = undefined; + if (this._snapshotDirty) { + this._snapshotDirty = false; + this._logSnapshot(); + } + }); + } + + private async _doLogSnapshot(): Promise { + const root = this._workspaceService.getActiveProjectRoot()?.fsPath ?? '(none)'; + + this._logger.info(''); + this._logger.info('=== Customizations Snapshot ==='); + this._logger.info(` Root: ${root}`); + this._logger.info(` Sections: ${this._workspaceService.managementSections.join(', ')}`); + this._logger.info(''); + + // Header + this._logger.info(` ${'Section'.padEnd(16)} ${'Local'.padStart(6)} ${'User'.padStart(6)} ${'Ext'.padStart(6)} ${'Total'.padStart(7)}`); + this._logger.info(` ${'--------'.padEnd(16)} ${'-----'.padStart(6)} ${'----'.padStart(6)} ${'---'.padStart(6)} ${'-----'.padStart(7)}`); + + for (const { section, type } of PROMPT_SECTIONS) { + const filter = this._workspaceService.getStorageSourceFilter(type); + await this._logSectionRow(section, type, filter); + } + + this._logger.info(''); + + // Details per section + for (const { section, type } of PROMPT_SECTIONS) { + const filter = this._workspaceService.getStorageSourceFilter(type); + await this._logSectionDetails(section, type, filter); + } + + // MCP Servers + this._logMcpServers(); + } + + private _logMcpServers(): void { + const servers = this._mcpService.servers.get(); + this._logger.info(` -- MCP Servers (${servers.length}) --`); + if (servers.length === 0) { + this._logger.info(' (none registered)'); + } + for (const server of servers) { + const state = server.connectionState.get(); + const stateStr = state?.state ?? 'unknown'; + this._logger.info(` ${server.definition.label} [${stateStr}] id=${server.definition.id}`); + } + this._logger.info(''); + } + + private async _logSectionRow(section: AICustomizationManagementSection, type: PromptsType, filter: IStorageSourceFilter): Promise { + try { + const [localFiles, userFiles, extensionFiles] = await Promise.all([ + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.local, CancellationToken.None), + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.user, CancellationToken.None), + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.extension, CancellationToken.None), + ]); + const all: IPromptPath[] = [...localFiles, ...userFiles, ...extensionFiles]; + const filtered = applyStorageSourceFilter(all, filter); + const local = filtered.filter(f => f.storage === PromptsStorage.local).length; + const user = filtered.filter(f => f.storage === PromptsStorage.user).length; + const ext = filtered.filter(f => f.storage === PromptsStorage.extension).length; + + this._logger.info(` ${section.padEnd(16)} ${String(local).padStart(6)} ${String(user).padStart(6)} ${String(ext).padStart(6)} ${String(filtered.length).padStart(7)}`); + } catch { + this._logger.info(` ${section.padEnd(16)} (error)`); + } + } + + private async _logSectionDetails(section: AICustomizationManagementSection, type: PromptsType, filter: IStorageSourceFilter): Promise { + try { + // Source folders - where we look for files + const sourceFolders = await this._promptsService.getSourceFolders(type); + if (sourceFolders.length > 0) { + this._logger.info(` -- ${section} --`); + this._logger.info(` Search paths:`); + for (const sf of sourceFolders) { + this._logger.info(` [${sf.storage}] ${sf.uri.fsPath}`); + } + } + + const [localFiles, userFiles, extensionFiles] = await Promise.all([ + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.local, CancellationToken.None), + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.user, CancellationToken.None), + this._promptsService.listPromptFilesForStorage(type, PromptsStorage.extension, CancellationToken.None), + ]); + const all: IPromptPath[] = [...localFiles, ...userFiles, ...extensionFiles]; + const filtered = applyStorageSourceFilter(all, filter); + + if (filtered.length > 0) { + if (sourceFolders.length === 0) { + this._logger.info(` -- ${section} --`); + } + this._logger.info(` Filter: sources=[${filter.sources.join(', ')}]${filter.includedUserFileRoots ? `, roots=[${filter.includedUserFileRoots.map(r => r.fsPath).join(', ')}]` : ''}`); + this._logger.info(` Found ${filtered.length} item(s):`); + for (const f of filtered) { + this._logger.info(` [${f.storage}] ${f.uri.fsPath}`); + } + } + + if (sourceFolders.length > 0 || filtered.length > 0) { + this._logger.info(''); + } + } catch { + // already logged in row + } + } +} + +registerWorkbenchContribution2( + CustomizationsDebugLogContribution.ID, + CustomizationsDebugLogContribution, + WorkbenchPhase.AfterRestored, +); diff --git a/src/vs/sessions/contrib/chat/browser/folderPicker.ts b/src/vs/sessions/contrib/chat/browser/folderPicker.ts index dbc253e1cbf..26d7626d546 100644 --- a/src/vs/sessions/contrib/chat/browser/folderPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/folderPicker.ts @@ -15,9 +15,7 @@ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../ import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { INewSession } from './newSession.js'; const STORAGE_KEY_LAST_FOLDER = 'agentSessions.lastPickedFolder'; const STORAGE_KEY_RECENT_FOLDERS = 'agentSessions.recentlyPickedFolders'; @@ -43,7 +41,6 @@ export class FolderPicker extends Disposable { private _selectedFolderUri: URI | undefined; private _recentlyPickedFolders: URI[] = []; - private _newSession: INewSession | undefined; private _triggerElement: HTMLElement | undefined; private readonly _renderDisposables = this._register(new DisposableStore()); @@ -52,18 +49,9 @@ export class FolderPicker extends Disposable { return this._selectedFolderUri; } - /** - * Sets the pending session that this picker writes to. - * When the user selects a folder, it calls `setRepoUri` on the session. - */ - setNewSession(session: INewSession | undefined): void { - this._newSession = session; - } - constructor( @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, @IStorageService private readonly storageService: IStorageService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @IFileDialogService private readonly fileDialogService: IFileDialogService, @ICommandService private readonly commandService: ICommandService, ) { @@ -124,7 +112,7 @@ export class FolderPicker extends Disposable { return; } - const currentFolderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + const currentFolderUri = this._selectedFolderUri; const items = this._buildItems(currentFolderUri); const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD; @@ -162,7 +150,7 @@ export class FolderPicker extends Disposable { } /** - * Programmatically set the selected folder. + * Programmatically set the selected folder (e.g. restoring draft state). */ setSelectedFolder(folderUri: URI): void { this._selectFolder(folderUri); @@ -181,7 +169,6 @@ export class FolderPicker extends Disposable { this._addToRecentlyPickedFolders(folderUri); this.storageService.store(STORAGE_KEY_LAST_FOLDER, folderUri.toString(), StorageScope.PROFILE, StorageTarget.MACHINE); this._updateTriggerLabel(this._triggerElement); - this._newSession?.setRepoUri(folderUri); this._onDidSelectFolder.fire(folderUri); } @@ -271,6 +258,20 @@ export class FolderPicker extends Disposable { return items; } + /** + * Removes a folder from the recently picked list and storage. + */ + removeFromRecents(folderUri: URI): void { + this._recentlyPickedFolders = this._recentlyPickedFolders.filter(f => !isEqual(f, folderUri)); + this.storageService.store(STORAGE_KEY_RECENT_FOLDERS, JSON.stringify(this._recentlyPickedFolders.map(f => f.toString())), StorageScope.PROFILE, StorageTarget.MACHINE); + // If this was the last picked folder, clear it + if (this._selectedFolderUri && isEqual(this._selectedFolderUri, folderUri)) { + this._selectedFolderUri = undefined; + this.storageService.remove(STORAGE_KEY_LAST_FOLDER, StorageScope.PROFILE); + this._updateTriggerLabel(this._triggerElement); + } + } + private _removeFolder(folderUri: URI): void { this._recentlyPickedFolders = this._recentlyPickedFolders.filter(f => !isEqual(f, folderUri)); this.storageService.store(STORAGE_KEY_RECENT_FOLDERS, JSON.stringify(this._recentlyPickedFolders.map(f => f.toString())), StorageScope.PROFILE, StorageTarget.MACHINE); @@ -282,7 +283,7 @@ export class FolderPicker extends Disposable { } dom.clearNode(trigger); - const folderUri = this._selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + const folderUri = this._selectedFolderUri; const label = folderUri ? basename(folderUri) : localize('pickFolder', "Pick Folder"); dom.append(trigger, renderIcon(Codicon.folder)); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 247b4cdae06..b9e7bbe6a02 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -115,6 +115,11 @@ color: var(--vscode-icon-foreground); background: transparent !important; border: none !important; + cursor: pointer; +} + +.sessions-chat-send-button .monaco-button.disabled { + cursor: default; } .sessions-chat-send-button .monaco-button:not(.disabled):hover { @@ -227,6 +232,29 @@ padding: 0 3px; } +.sessions-chat-attachment-pill .monaco-icon-label { + gap: 4px; +} + +.sessions-chat-attachment-pill .monaco-icon-label::before { + height: auto; + padding: 0 0 0 2px; + line-height: 100% !important; + align-self: center; +} + +.sessions-chat-attachment-pill .monaco-icon-label .monaco-icon-label-container { + display: flex; +} + +.sessions-chat-attachment-pill .monaco-icon-label .monaco-icon-label-container .monaco-highlighted-label { + display: inline-flex; + align-items: center; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + .sessions-chat-attachment-remove { display: flex; align-items: center; diff --git a/src/vs/sessions/contrib/chat/browser/modePicker.ts b/src/vs/sessions/contrib/chat/browser/modePicker.ts new file mode 100644 index 00000000000..d7cc8df61b3 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/modePicker.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ChatMode, IChatMode, IChatModeService } from '../../../../workbench/contrib/chat/common/chatModes.js'; +import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js'; +import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { Target } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { AICustomizationManagementCommands } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; + +interface IModePickerItem { + readonly kind: 'mode'; + readonly mode: IChatMode; +} + +interface IConfigurePickerItem { + readonly kind: 'configure'; +} + +type ModePickerItem = IModePickerItem | IConfigurePickerItem; + +/** + * A self-contained widget for selecting a chat mode (Agent, custom agents) + * for local/Background sessions. Shows only modes whose target matches + * the Background session type's customAgentTarget. + */ +export class ModePicker extends Disposable { + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private _triggerElement: HTMLElement | undefined; + private _slotElement: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + + private _selectedMode: IChatMode = ChatMode.Agent; + + get selectedMode(): IChatMode { + return this._selectedMode; + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IChatModeService private readonly chatModeService: IChatModeService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + + this._register(this.chatModeService.onDidChangeChatModes(() => { + // Refresh the trigger label when available chat modes change + if (this._triggerElement) { + this._updateTriggerLabel(); + } + })); + } + + /** + * Sets the git repository. When the repository changes, resets the selected mode + * back to the default Agent mode. + */ + setRepository(repository: IGitRepository | undefined): void { + this._selectedMode = ChatMode.Agent; + this._updateTriggerLabel(); + } + + /** + * Renders the mode picker trigger button into the given container. + */ + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._slotElement = slot; + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + trigger.setAttribute('aria-label', localize('sessions.modePicker.ariaLabel', "Select chat mode")); + this._triggerElement = trigger; + + this._updateTriggerLabel(); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this._showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this._showPicker(); + } + })); + + return slot; + } + + /** + * Shows or hides the picker. + */ + setVisible(visible: boolean): void { + if (this._slotElement) { + this._slotElement.style.display = visible ? '' : 'none'; + } + } + + private _getAvailableModes(): IChatMode[] { + const customAgentTarget = this.chatSessionsService.getCustomAgentTargetForSessionType(AgentSessionProviders.Background); + const effectiveTarget = customAgentTarget && customAgentTarget !== Target.Undefined ? customAgentTarget : Target.GitHubCopilot; + const modes = this.chatModeService.getModes(); + + // Always include the default Agent mode + const result: IChatMode[] = [ChatMode.Agent]; + + // Add custom modes matching the target and visible to users + for (const mode of modes.custom) { + const target = mode.target.get(); + if (target === effectiveTarget || target === Target.Undefined) { + const visibility = mode.visibility?.get(); + if (visibility && !visibility.userInvocable) { + continue; + } + result.push(mode); + } + } + + return result; + } + + private _showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const modes = this._getAvailableModes(); + + const items = this._buildItems(modes); + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: (item) => { + this.actionWidgetService.hide(); + if (item.kind === 'mode') { + this._selectMode(item.mode); + } else { + this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor); + } + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'localModePicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('modePicker.ariaLabel', "Mode Picker"), + }, + ); + } + + private _buildItems(modes: IChatMode[]): IActionListItem[] { + const items: IActionListItem[] = []; + + // Default Agent mode + const agentMode = modes[0]; + items.push({ + kind: ActionListItemKind.Action, + label: agentMode.label.get(), + group: { title: '', icon: this._selectedMode.id === agentMode.id ? Codicon.check : Codicon.blank }, + item: { kind: 'mode', mode: agentMode }, + }); + + // Custom modes (with separator if any exist) + const customModes = modes.slice(1); + if (customModes.length > 0) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + for (const mode of customModes) { + items.push({ + kind: ActionListItemKind.Action, + label: mode.label.get(), + group: { title: '', icon: this._selectedMode.id === mode.id ? Codicon.check : Codicon.blank }, + item: { kind: 'mode', mode }, + }); + } + } + + // Configure Custom Agents action + items.push({ kind: ActionListItemKind.Separator, label: '' }); + items.push({ + kind: ActionListItemKind.Action, + label: localize('configureCustomAgents', "Configure Custom Agents..."), + group: { title: '', icon: Codicon.blank }, + item: { kind: 'configure' }, + }); + + return items; + } + + private _selectMode(mode: IChatMode): void { + this._selectedMode = mode; + this._updateTriggerLabel(); + this._onDidChange.fire(mode); + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + + const icon = this._selectedMode.icon.get(); + if (icon) { + dom.append(this._triggerElement, renderIcon(icon)); + } + + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = this._selectedMode.label.get(); + dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); + + const modes = this._getAvailableModes(); + this._slotElement?.classList.toggle('disabled', modes.length <= 1); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index a7440dfc691..9acac072639 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -18,13 +18,18 @@ import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; -import { IFileService } from '../../../../platform/files/common/files.js'; +import { FileKind, IFileService } from '../../../../platform/files/common/files.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { ILanguageService } from '../../../../editor/common/languages/language.js'; +import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js'; import { basename } from '../../../../base/common/resources.js'; import { Schemas } from '../../../../base/common/network.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../workbench/browser/labels.js'; import { IChatRequestVariableEntry, OmittedState } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { isLocation } from '../../../../editor/common/languages.js'; @@ -63,6 +68,8 @@ export class NewChatContextAttachments extends Disposable { this._onDidChangeContext.fire(); } + private readonly _resourceLabels: ResourceLabels; + constructor( @IQuickInputService private readonly quickInputService: IQuickInputService, @ITextModelService private readonly textModelService: ITextModelService, @@ -73,8 +80,12 @@ export class NewChatContextAttachments extends Disposable { @ISearchService private readonly searchService: ISearchService, @IConfigurationService private readonly configurationService: IConfigurationService, @IOpenerService private readonly openerService: IOpenerService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IModelService private readonly modelService: IModelService, + @ILanguageService private readonly languageService: ILanguageService, ) { super(); + this._resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); } // --- Rendering --- @@ -90,6 +101,7 @@ export class NewChatContextAttachments extends Disposable { } this._renderDisposables.clear(); + this._resourceLabels.clear(); dom.clearNode(this._container); if (this._attachedContext.length === 0) { @@ -98,17 +110,30 @@ export class NewChatContextAttachments extends Disposable { } this._container.style.display = ''; + this._container.classList.add('show-file-icons'); for (const entry of this._attachedContext) { const pill = dom.append(this._container, dom.$('.sessions-chat-attachment-pill')); pill.tabIndex = 0; pill.role = 'button'; - const icon = entry.kind === 'image' ? Codicon.fileMedia : entry.kind === 'directory' ? Codicon.folder : Codicon.file; - dom.append(pill, renderIcon(icon)); - dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); + const resource = URI.isUri(entry.value) ? entry.value : isLocation(entry.value) ? entry.value.uri : undefined; + if (entry.kind === 'image') { + dom.append(pill, renderIcon(Codicon.fileMedia)); + dom.append(pill, dom.$('span.sessions-chat-attachment-name', undefined, entry.name)); + } else { + const label = this._resourceLabels.create(pill, { supportIcons: true }); + this._renderDisposables.add(label); + if (resource) { + label.setFile(resource, { + fileKind: entry.kind === 'directory' ? FileKind.FOLDER : FileKind.FILE, + hidePath: true, + }); + } else { + label.setLabel(entry.name); + } + } // Click to open the resource - const resource = URI.isUri(entry.value) ? entry.value : isLocation(entry.value) ? entry.value.uri : undefined; if (resource) { pill.style.cursor = 'pointer'; this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { @@ -390,7 +415,7 @@ export class NewChatContextAttachments extends Disposable { return searchResult.results.map(result => ({ label: basename(result.resource), description: this.labelService.getUriLabel(result.resource, { relative: true }), - iconClass: ThemeIcon.asClassName(Codicon.file), + iconClasses: getIconClasses(this.modelService, this.languageService, result.resource, FileKind.FILE), id: result.resource.toString(), } satisfies IQuickPickItem)); } catch { @@ -434,7 +459,7 @@ export class NewChatContextAttachments extends Disposable { picks.push({ label: child.name, description: this.labelService.getUriLabel(child.resource, { relative: true }), - iconClass: ThemeIcon.asClassName(Codicon.file), + iconClasses: getIconClasses(this.modelService, this.languageService, child.resource, FileKind.FILE), id: child.resource.toString(), }); } diff --git a/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts new file mode 100644 index 00000000000..b5c8ee1730a --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import Severity from '../../../../base/common/severity.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; + +// Track whether warnings have been shown this VS Code session +const shownWarnings = new Set(); + +interface IPermissionItem { + readonly level: ChatPermissionLevel; + readonly label: string; + readonly icon: ThemeIcon; + readonly checked: boolean; +} + +/** + * A permission picker for the new-session welcome view. + * Shows Default Approvals and Bypass Approvals options (no Autopilot for CLI sessions). + */ +export class NewChatPermissionPicker extends Disposable { + + private readonly _onDidChangeLevel = this._register(new Emitter()); + readonly onDidChangeLevel: Event = this._onDidChangeLevel.event; + + private _currentLevel: ChatPermissionLevel = ChatPermissionLevel.Default; + private _triggerElement: HTMLElement | undefined; + private _container: HTMLElement | undefined; + private readonly _renderDisposables = this._register(new DisposableStore()); + + get permissionLevel(): ChatPermissionLevel { + return this._currentLevel; + } + + constructor( + @IActionWidgetService private readonly actionWidgetService: IActionWidgetService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, + ) { + super(); + } + + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); + this._container = slot; + this._renderDisposables.add({ dispose: () => slot.remove() }); + + const trigger = dom.append(slot, dom.$('a.action-label')); + trigger.tabIndex = 0; + trigger.role = 'button'; + this._triggerElement = trigger; + + this._updateTriggerLabel(trigger); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { + dom.EventHelper.stop(e, true); + this.showPicker(); + })); + + this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + dom.EventHelper.stop(e, true); + this.showPicker(); + } + })); + + return slot; + } + + setVisible(visible: boolean): void { + if (this._container) { + this._container.style.display = visible ? '' : 'none'; + } + } + + showPicker(): void { + if (!this._triggerElement || this.actionWidgetService.isVisible) { + return; + } + + const policyRestricted = this.configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; + + const items: IActionListItem[] = [ + { + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.shield }, + item: { + level: ChatPermissionLevel.Default, + label: localize('permissions.default', "Default Approvals"), + icon: Codicon.shield, + checked: this._currentLevel === ChatPermissionLevel.Default, + }, + label: localize('permissions.default', "Default Approvals"), + description: localize('permissions.default.subtext', "Copilot uses your configured settings"), + disabled: false, + }, + { + kind: ActionListItemKind.Action, + group: { kind: ActionListItemKind.Header, title: '', icon: Codicon.warning }, + item: { + level: ChatPermissionLevel.AutoApprove, + label: localize('permissions.autoApprove', "Bypass Approvals"), + icon: Codicon.warning, + checked: this._currentLevel === ChatPermissionLevel.AutoApprove, + }, + label: localize('permissions.autoApprove', "Bypass Approvals"), + description: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"), + disabled: policyRestricted, + }, + ]; + + const triggerElement = this._triggerElement; + const delegate: IActionListDelegate = { + onSelect: async (item) => { + this.actionWidgetService.hide(); + await this._selectLevel(item.level); + }, + onHide: () => { triggerElement.focus(); }, + }; + + this.actionWidgetService.show( + 'permissionPicker', + false, + items, + delegate, + this._triggerElement, + undefined, + [], + { + getAriaLabel: (item) => item.label ?? '', + getWidgetAriaLabel: () => localize('permissionPicker.ariaLabel', "Permission Picker"), + }, + ); + } + + private async _selectLevel(level: ChatPermissionLevel): Promise { + if (level === ChatPermissionLevel.AutoApprove && !shownWarnings.has(ChatPermissionLevel.AutoApprove)) { + const result = await this.dialogService.prompt({ + type: Severity.Warning, + message: localize('permissions.autoApprove.warning.title', "Enable Bypass Approvals?"), + buttons: [ + { + label: localize('permissions.autoApprove.warning.confirm', "Enable"), + run: () => true + }, + { + label: localize('permissions.autoApprove.warning.cancel', "Cancel"), + run: () => false + }, + ], + custom: { + icon: Codicon.warning, + markdownDetails: [{ + markdown: new MarkdownString(localize('permissions.autoApprove.warning.detail', "Bypass Approvals will auto-approve all tool calls without asking for confirmation. This includes file edits, terminal commands, and external tool calls.")), + }], + }, + }); + if (result.result !== true) { + return; + } + shownWarnings.add(ChatPermissionLevel.AutoApprove); + } + + this._currentLevel = level; + this._updateTriggerLabel(this._triggerElement); + this._onDidChangeLevel.fire(level); + } + + private _updateTriggerLabel(trigger: HTMLElement | undefined): void { + if (!trigger) { + return; + } + + dom.clearNode(trigger); + const icon = this._currentLevel === ChatPermissionLevel.AutoApprove ? Codicon.warning : Codicon.shield; + const label = this._currentLevel === ChatPermissionLevel.AutoApprove + ? localize('permissions.autoApprove.label', "Bypass Approvals") + : localize('permissions.default.label', "Default Approvals"); + + dom.append(trigger, renderIcon(icon)); + const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = label; + dom.append(trigger, renderIcon(Codicon.chevronDown)); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 247501dfc64..da219eb37c0 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -11,7 +11,7 @@ import { toAction } from '../../../../base/common/actions.js'; import { Emitter } from '../../../../base/common/event.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { observableValue } from '../../../../base/common/observable.js'; +import { autorun, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; @@ -49,6 +49,7 @@ import { EnhancedModelPickerActionItem } from '../../../../workbench/contrib/cha import { IChatInputPickerOptions } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; @@ -56,12 +57,13 @@ import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { FolderPicker } from './folderPicker.js'; import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; -import { IsolationModePicker, SessionTargetPicker } from './sessionTargetPicker.js'; +import { IsolationMode, IsolationModePicker, SessionTargetPicker } from './sessionTargetPicker.js'; import { BranchPicker } from './branchPicker.js'; import { SyncIndicator } from './syncIndicator.js'; import { INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js'; import { RepoPicker } from './repoPicker.js'; import { CloudModelPicker } from './modelPicker.js'; +import { ModePicker } from './modePicker.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; import { SlashCommandHandler } from './slashCommands.js'; import { IChatModelInputState } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; @@ -69,12 +71,21 @@ import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/co import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; import { ChatHistoryNavigator } from '../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; +import { NewChatPermissionPicker } from './newChatPermissionPicker.js'; import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; const MIN_EDITOR_HEIGHT = 50; const MAX_EDITOR_HEIGHT = 200; +interface IDraftState extends IChatModelInputState { + target?: AgentSessionProviders; + isolationMode?: IsolationMode; + branch?: string; + folderUri?: string; + repo?: string; +} + // #region --- Chat Welcome Widget --- /** @@ -138,9 +149,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { private _inputSlot: HTMLElement | undefined; private readonly _folderPicker: FolderPicker; private _folderPickerContainer: HTMLElement | undefined; + private readonly _permissionPicker: NewChatPermissionPicker; private readonly _repoPicker: RepoPicker; private _repoPickerContainer: HTMLElement | undefined; private readonly _cloudModelPicker: CloudModelPicker; + private readonly _modePicker: ModePicker; private readonly _toolbarPickerWidgets = new Map(); private readonly _toolbarPickerDisposables = this._register(new DisposableStore()); private readonly _optionEmitters = new Map>(); @@ -152,6 +165,16 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { // Slash commands private _slashCommandHandler: SlashCommandHandler | undefined; + // Input state + private _draftState: IDraftState | undefined = { + inputText: '', + attachments: [], + mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, + selectedModel: undefined, + selections: [], + contrib: {} + }; + // Input history private readonly _history: ChatHistoryNavigator; private _historyNavigationBackwardsEnablement!: IHistoryNavigationContext['historyNavigationBackwardsEnablement']; @@ -166,17 +189,19 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { @IContextKeyService private readonly contextKeyService: IContextKeyService, @ILogService private readonly logService: ILogService, @IHoverService private readonly hoverService: IHoverService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @IGitService private readonly gitService: IGitService, @IStorageService private readonly storageService: IStorageService, + @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { super(); this._history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); this._folderPicker = this._register(this.instantiationService.createInstance(FolderPicker)); + this._permissionPicker = this._register(this.instantiationService.createInstance(NewChatPermissionPicker)); this._repoPicker = this._register(this.instantiationService.createInstance(RepoPicker)); this._cloudModelPicker = this._register(this.instantiationService.createInstance(CloudModelPicker)); + this._modePicker = this._register(this.instantiationService.createInstance(ModePicker)); this._targetPicker = this._register(new SessionTargetPicker(options.allowedTargets, this._resolveDefaultTarget(options))); this._isolationModePicker = this._register(this.instantiationService.createInstance(IsolationModePicker)); this._branchPicker = this._register(this.instantiationService.createInstance(BranchPicker)); @@ -188,8 +213,10 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._createNewSession(); const isLocal = target === AgentSessionProviders.Background; this._isolationModePicker.setVisible(isLocal); + this._permissionPicker.setVisible(isLocal); this._branchPicker.setVisible(isLocal); this._syncIndicator.setVisible(isLocal); + this._updateDraftState(); this._focusEditor(); })); @@ -199,22 +226,56 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { })); this._register(this._branchPicker.onDidChange((branch) => { + this._newSession.value?.setBranch(branch); this._syncIndicator.setBranch(branch); + this._updateDraftState(); this._focusEditor(); })); - this._register(this._folderPicker.onDidSelectFolder(() => { + this._register(this._folderPicker.onDidSelectFolder(async (folderUri) => { + const trusted = await this._requestFolderTrust(folderUri); + if (trusted) { + this._newSession.value?.setRepoUri(folderUri); + } + this._updateDraftState(); this._focusEditor(); })); - this._register(this._isolationModePicker.onDidChange(() => { + this._register(this._isolationModePicker.onDidChange((mode) => { + this._newSession.value?.setIsolationMode(mode); + this._branchPicker.setVisible(mode === 'worktree'); + this._syncIndicator.setVisible(mode === 'worktree'); + this._updateDraftState(); this._focusEditor(); })); + // When mode changes, update the session + this._register(this._modePicker.onDidChange((mode) => { + this._newSession.value?.setMode(mode); + this._focusEditor(); + })); + + this._register(this._repoPicker.onDidSelectRepo((repoId) => { + if (this._targetPicker.selectedTarget !== AgentSessionProviders.Background) { + this._newSession.value?.setRepoUri(this._getRepoUri(repoId)); + } + this._updateDraftState(); + })); + // When language models change (e.g., extension activates), reinitialize if no model selected this._register(this.languageModelsService.onDidChangeLanguageModels(() => { this._initDefaultModel(); })); + + // Update input state when attachments or model change + this._register(this._contextAttachments.onDidChangeContext(() => { + this._updateDraftState(); + this._focusEditor(); + })); + this._register(autorun(reader => { + this._currentLanguageModel.read(reader); + this._updateDraftState(); + })); } // --- Rendering --- @@ -257,15 +318,18 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { const isolationContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-local-mode')); this._isolationModePicker.render(isolationContainer); dom.append(isolationContainer, dom.$('.sessions-chat-local-mode-spacer')); + this._permissionPicker.render(isolationContainer); const branchContainer = dom.append(isolationContainer, dom.$('.sessions-chat-local-mode-right')); this._branchPicker.render(branchContainer); this._syncIndicator.render(branchContainer); - // Set initial visibility based on default target + // Set initial visibility based on default target and isolation mode const isLocal = this._targetPicker.selectedTarget === AgentSessionProviders.Background; + const isWorktree = this._isolationModePicker.isolationMode === 'worktree'; this._isolationModePicker.setVisible(isLocal); - this._branchPicker.setVisible(isLocal); - this._syncIndicator.setVisible(isLocal); + this._permissionPicker.setVisible(isLocal); + this._branchPicker.setVisible(isLocal && isWorktree); + this._syncIndicator.setVisible(isLocal && isWorktree); // Render target buttons & extension pickers this._renderOptionGroupPickers(); @@ -290,7 +354,16 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { private async _createNewSession(): Promise { const target = this._targetPicker.selectedTarget; - const defaultRepoUri = this._folderPicker.selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + let defaultRepoUri = this._folderPicker.selectedFolderUri; + + // For local targets, request workspace trust before creating the session + if (target === AgentSessionProviders.Background && defaultRepoUri) { + const trusted = await this._requestFolderTrust(defaultRepoUri); + if (!trusted) { + defaultRepoUri = undefined; + } + } + const resource = getResourceForNewChatSession({ type: target, position: this._options.sessionPosition ?? ChatSessionPosition.Sidebar, @@ -308,16 +381,18 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { private _setNewSession(session: INewSession): void { this._newSession.value = session; - // Wire pickers to the new session + // Wire pickers to the new session and disconnect inactive ones const target = this._targetPicker.selectedTarget; if (target === AgentSessionProviders.Background) { - this._folderPicker.setNewSession(session); - this._isolationModePicker.setNewSession(session); - this._branchPicker.setNewSession(session); - } - - if (target === AgentSessionProviders.Cloud) { - this._repoPicker.setNewSession(session); + session.setIsolationMode(this._isolationModePicker.isolationMode); + if (this._branchPicker.selectedBranch) { + session.setBranch(this._branchPicker.selectedBranch); + } + } else { + const selectedRepo = this._repoPicker.selectedRepo; + if (selectedRepo) { + session.setRepoUri(this._getRepoUri(selectedRepo)); + } } // Set the current model on the session (for local sessions) @@ -326,6 +401,9 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { session.setModelId(currentModel.identifier); } + // Set the current mode on the session (for local sessions) + session.setMode(this._modePicker.selectedMode); + // Open repository for the session's repoUri if (session.repoUri) { this._openRepository(session.repoUri); @@ -367,6 +445,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._branchPicker.setRepository(undefined); this._isolationModePicker.setRepository(undefined); this._syncIndicator.setRepository(undefined); + this._modePicker.setRepository(undefined); this.gitService.openRepository(folderUri).then(repository => { if (cts.token.isCancellationRequested) { @@ -377,6 +456,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._isolationModePicker.setRepository(repository); this._branchPicker.setRepository(repository); this._syncIndicator.setRepository(repository); + this._modePicker.setRepository(repository); }).catch(e => { if (cts.token.isCancellationRequested) { return; @@ -387,6 +467,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._isolationModePicker.setRepository(undefined); this._branchPicker.setRepository(undefined); this._syncIndicator.setRepository(undefined); + this._modePicker.setRepository(undefined); }); } @@ -517,6 +598,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._slashCommandHandler = this._register(this.instantiationService.createInstance(SlashCommandHandler, this._editor)); this._register(this._editor.onDidChangeModelContent(() => { + this._updateDraftState(); this._updateSendButtonState(); })); } @@ -527,10 +609,15 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { private _createAttachButton(container: HTMLElement): void { const attachButton = dom.append(container, dom.$('.sessions-chat-attach-button')); + const attachButtonLabel = localize('addContext', "Add Context..."); attachButton.tabIndex = 0; attachButton.role = 'button'; - attachButton.title = localize('addContext', "Add Context..."); - attachButton.ariaLabel = localize('addContext', "Add Context..."); + attachButton.ariaLabel = attachButtonLabel; + this._register(this.hoverService.setupDelayedHover(attachButton, { + content: attachButtonLabel, + position: { hoverPosition: HoverPosition.BELOW }, + appearance: { showPointer: true } + })); dom.append(attachButton, renderIcon(Codicon.add)); this._register(dom.addDisposableListener(attachButton, dom.EventType.CLICK, () => { this._contextAttachments.showPicker(this._getContextFolderUri()); @@ -545,22 +632,26 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { const target = this._targetPicker.selectedTarget; if (target === AgentSessionProviders.Background) { - return this._folderPicker.selectedFolderUri ?? this.workspaceContextService.getWorkspace().folders[0]?.uri; + return this._folderPicker.selectedFolderUri; } // For cloud targets, use the repo picker's selection const selectedRepo = this._repoPicker.selectedRepo; if (selectedRepo && selectedRepo.includes('/')) { - return URI.from({ - scheme: GITHUB_REMOTE_FILE_SCHEME, - authority: 'github', - path: `/${selectedRepo}/HEAD`, - }); + return this._getRepoUri(selectedRepo); } return undefined; } + private _getRepoUri(repoId: string): URI { + return URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: `/${repoId}/HEAD`, + }); + } + private _createBottomToolbar(container: HTMLElement): void { const toolbar = dom.append(container, dom.$('.sessions-chat-toolbar')); @@ -570,6 +661,10 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._localModelPickerContainer = dom.append(toolbar, dom.$('.sessions-chat-model-picker')); this._createLocalModelPicker(this._localModelPickerContainer); + // Local mode picker + this._modePicker.render(toolbar); + this._modePicker.setVisible(false); + // Remote model picker (action list dropdown) this._cloudModelPicker.render(toolbar); this._cloudModelPicker.setVisible(false); @@ -615,7 +710,8 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._focusEditor(); }, getModels: () => this._getAvailableModels(), - canManageModels: () => false, + useGroupedModelPicker: () => true, + showManageModelsAction: () => false, }; const pickerOptions: IChatInputPickerOptions = { @@ -691,10 +787,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { if (this._extensionPickersLeftContainer) { this._extensionPickersLeftContainer.style.display = 'block'; } - // Show local model picker, hide remote + // Show local model and mode pickers, hide remote if (this._localModelPickerContainer) { this._localModelPickerContainer.style.display = ''; } + this._modePicker.setVisible(true); this._cloudModelPicker.setVisible(false); } @@ -710,10 +807,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._folderPickerContainer.style.display = 'none'; } - // Show remote model picker, hide local + // Show remote model picker, hide local pickers if (this._localModelPickerContainer) { this._localModelPickerContainer.style.display = 'none'; } + this._modePicker.setVisible(false); this._cloudModelPicker.setSession(session); this._cloudModelPicker.setVisible(true); @@ -790,7 +888,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { const action = toAction({ id: optionGroup.id, label: optionGroup.name, run: () => { } }); const widget = this.instantiationService.createInstance( optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, - action, initialState, itemDelegate + action, initialState, itemDelegate, undefined ); this._toolbarPickerDisposables.add(widget); @@ -848,9 +946,8 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { if (this._history.isAtStart()) { return; } - const state = this._getInputState(); - if (state.inputText || state.attachments.length) { - this._history.overlay(state); + if (this._draftState?.inputText || this._draftState?.attachments.length) { + this._history.overlay(this._draftState); } this._navigateHistory(true); } @@ -859,21 +956,26 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { if (this._history.isAtEnd()) { return; } - const state = this._getInputState(); - if (state.inputText || state.attachments.length) { - this._history.overlay(state); + if (this._draftState?.inputText || this._draftState?.attachments.length) { + this._history.overlay(this._draftState); } this._navigateHistory(false); } - private _getInputState(): IChatModelInputState { - return { + private _updateDraftState(): void { + const attachments = [...this._contextAttachments.attachments]; + this._draftState = { inputText: this._editor?.getModel()?.getValue() ?? '', - attachments: [...this._contextAttachments.attachments], + attachments, mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, selectedModel: this._currentLanguageModel.get(), selections: this._editor?.getSelections() ?? [], contrib: {}, + target: this._targetPicker.selectedTarget, + isolationMode: this._isolationModePicker.isolationMode, + branch: this._branchPicker.selectedBranch, + folderUri: this._folderPicker.selectedFolderUri?.toString(), + repo: this._repoPicker.selectedRepo, }; } @@ -907,7 +1009,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } private async _send(options?: { openNewAfterSend?: boolean }): Promise { - const query = this._editor.getModel()?.getValue().trim(); + let query = this._editor.getModel()?.getValue().trim(); const session = this._newSession.value; if (!query || !session || this._sending) { return; @@ -927,12 +1029,20 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { return; } + // Expand prompt/skill slash commands into a CLI-friendly reference + const expanded = this._slashCommandHandler?.tryExpandPromptSlashCommand(query); + if (expanded) { + query = expanded; + } + session.setQuery(query); session.setAttachedContext( this._contextAttachments.attachments.length > 0 ? [...this._contextAttachments.attachments] : undefined ); - this._history.append(this._getInputState()); + if (this._draftState) { + this._history.append(this._draftState); + } this._clearDraftState(); this._sending = true; @@ -940,22 +1050,26 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._updateSendButtonState(); this._updateInputLoadingState(); - this.sessionsManagementService.sendRequestForNewSession( - session.resource, - options?.openNewAfterSend ? { openNewSessionView: true } : undefined - ).then(() => { - // Release ref without disposing - the service owns disposal - this._newSession.clearAndLeak(); + + try { + await this.sessionsManagementService.sendRequestForNewSession( + session.resource, + { + ...options?.openNewAfterSend ? { openNewSessionView: true } : {}, + permissionLevel: this._permissionPicker.permissionLevel, + } + ); this._newSessionListener.clear(); this._contextAttachments.clear(); - }, e => { + } catch (e) { this.logService.error('Failed to send request:', e); - }).finally(() => { - this._sending = false; - this._editor.updateOptions({ readOnly: false }); - this._updateSendButtonState(); - this._updateInputLoadingState(); - }); + } + + + this._sending = false; + this._editor.updateOptions({ readOnly: false }); + this._updateSendButtonState(); + this._updateInputLoadingState(); } /** @@ -978,6 +1092,23 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } } + private async _requestFolderTrust(folderUri: URI): Promise { + const trusted = await this.workspaceTrustRequestService.requestResourcesTrust({ + uri: folderUri, + message: localize('trustFolderMessage', "An agent session will be able to read files, run commands, and make changes in this folder."), + }); + if (!trusted) { + this._folderPicker.removeFromRecents(folderUri); + const previousFolderUri = this._newSession.value?.repoUri; + if (previousFolderUri) { + this._folderPicker.setSelectedFolder(previousFolderUri); + } else { + this._folderPicker.clearSelection(); + } + } + return !!trusted; + } + private _resolveDefaultTarget(options: INewChatWidgetOptions): AgentSessionProviders { const draft = this._getDraftState(); @@ -1001,10 +1132,23 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._currentLanguageModel.set(model, undefined); } } + if (draft.isolationMode) { + this._isolationModePicker.setPreferredIsolationMode(draft.isolationMode); + this._isolationModePicker.setIsolationMode(draft.isolationMode); + } + if (draft.branch) { + this._branchPicker.setPreferredBranch(draft.branch); + } + if (draft.folderUri) { + try { this._folderPicker.setSelectedFolder(URI.parse(draft.folderUri)); } catch { /* ignore */ } + } + if (draft.repo) { + this._repoPicker.setSelectedRepo(draft.repo); + } } } - private _getDraftState(): (IChatModelInputState & { target?: AgentSessionProviders }) | undefined { + private _getDraftState(): IDraftState | undefined { const raw = this.storageService.get(STORAGE_KEY_DRAFT_STATE, StorageScope.WORKSPACE); if (!raw) { return undefined; @@ -1017,21 +1161,36 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } private _clearDraftState(): void { - this.storageService.remove(STORAGE_KEY_DRAFT_STATE, StorageScope.WORKSPACE); + // Preserve picker preferences so they survive widget recreation + const target = this._targetPicker.selectedTarget; + const isLocal = target === AgentSessionProviders.Background; + const preserved: IDraftState = { + inputText: '', + attachments: [], + mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, + selectedModel: this._draftState?.selectedModel, + selections: [], + contrib: {}, + target, + isolationMode: isLocal ? this._isolationModePicker.isolationMode : undefined, + branch: isLocal ? this._branchPicker.selectedBranch : undefined, + folderUri: isLocal ? this._folderPicker.selectedFolderUri?.toString() : undefined, + repo: isLocal ? undefined : this._repoPicker.selectedRepo, + }; + this._draftState = preserved; + this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(preserved), StorageScope.WORKSPACE, StorageTarget.MACHINE); } saveState(): void { - const inputState = this._getInputState(); - const state = { - ...inputState, - attachments: inputState.attachments.map(IChatRequestVariableEntry.toExport), - target: this._targetPicker.selectedTarget, - }; - this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(state), StorageScope.WORKSPACE, StorageTarget.MACHINE); + if (this._draftState) { + const state = { + ...this._draftState, + attachments: this._draftState.attachments.map(IChatRequestVariableEntry.toExport), + }; + this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(state), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } } - // --- Layout --- - layout(_height: number, _width: number): void { this._editor?.layout(); } @@ -1118,6 +1277,11 @@ export class NewChatViewPane extends ViewPane { override saveState(): void { this._widget?.saveState(); } + + override dispose(): void { + this._widget?.saveState(); + super.dispose(); + } } // #endregion diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index f8211f04300..232f56251cc 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -13,8 +13,9 @@ import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browse import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { IChatMode } from '../../../../workbench/contrib/chat/common/chatModes.js'; -export type NewSessionChangeType = 'repoUri' | 'isolationMode' | 'branch' | 'options' | 'disabled'; +export type NewSessionChangeType = 'repoUri' | 'isolationMode' | 'branch' | 'options' | 'disabled' | 'agent'; /** * Represents a resolved option group with its current selected value. @@ -36,6 +37,7 @@ export interface INewSession extends IDisposable { readonly isolationMode: IsolationMode; readonly branch: string | undefined; readonly modelId: string | undefined; + readonly mode: IChatMode | undefined; readonly query: string | undefined; readonly attachedContext: IChatRequestVariableEntry[] | undefined; readonly selectedOptions: ReadonlyMap; @@ -45,6 +47,7 @@ export interface INewSession extends IDisposable { setIsolationMode(mode: IsolationMode): void; setBranch(branch: string | undefined): void; setModelId(modelId: string | undefined): void; + setMode(mode: IChatMode | undefined): void; setQuery(query: string): void; setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void; setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void; @@ -53,6 +56,7 @@ export interface INewSession extends IDisposable { const REPOSITORY_OPTION_ID = 'repository'; const BRANCH_OPTION_ID = 'branch'; const ISOLATION_OPTION_ID = 'isolation'; +const AGENT_OPTION_ID = 'agent'; /** * Local new session for Background agent sessions. @@ -65,6 +69,7 @@ export class LocalNewSession extends Disposable implements INewSession { private _isolationMode: IsolationMode = 'worktree'; private _branch: string | undefined; private _modelId: string | undefined; + private _mode: IChatMode | undefined; private _query: string | undefined; private _attachedContext: IChatRequestVariableEntry[] | undefined; @@ -78,6 +83,7 @@ export class LocalNewSession extends Disposable implements INewSession { get isolationMode(): IsolationMode { return this._isolationMode; } get branch(): string | undefined { return this._branch; } get modelId(): string | undefined { return this._modelId; } + get mode(): IChatMode | undefined { return this._mode; } get query(): string | undefined { return this._query; } get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } get disabled(): boolean { @@ -134,6 +140,15 @@ export class LocalNewSession extends Disposable implements INewSession { this._modelId = modelId; } + setMode(mode: IChatMode | undefined): void { + if (this._mode?.id !== mode?.id) { + this._mode = mode; + this._onDidChange.fire('agent'); + const modeName = mode?.isBuiltin ? undefined : mode?.name.get(); + this.setOption(AGENT_OPTION_ID, modeName ?? ''); + } + } + setQuery(query: string): void { this._query = query; } @@ -179,6 +194,7 @@ export class RemoteNewSession extends Disposable implements INewSession { get isolationMode(): IsolationMode { return 'worktree'; } get branch(): string | undefined { return undefined; } get modelId(): string | undefined { return this._modelId; } + get mode(): IChatMode | undefined { return undefined; } get query(): string | undefined { return this._query; } get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } get disabled(): boolean { @@ -230,6 +246,11 @@ export class RemoteNewSession extends Disposable implements INewSession { this._modelId = modelId; } + setMode(_mode: IChatMode | undefined): void { + // Intentionally a no-op: remote sessions do not support client-side mode selection. + // Any mode or behavior differences are determined by the remote session provider/server. + } + setQuery(query: string): void { this._query = query; } diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts index 3a7af9380b2..45db75ebadd 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -8,21 +8,28 @@ import { PromptFilesLocator } from '../../../../workbench/contrib/chat/common/pr import { Event } from '../../../../base/common/event.js'; import { basename, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { FileAccess } from '../../../../base/common/network.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; -import { HOOKS_SOURCE_FOLDER } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; +import { HOOKS_SOURCE_FOLDER, getCleanPromptName } from '../../../../workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptPath, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE, IBuiltinPromptPath } from '../../chat/common/builtinPromptsStorage.js'; import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { ISearchService } from '../../../../workbench/services/search/common/search.js'; import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +/** URI root for built-in prompts bundled with the Sessions app. */ +export const BUILTIN_PROMPTS_URI = FileAccess.asFileUri('vs/sessions/prompts'); + export class AgenticPromptsService extends PromptsService { private _copilotRoot: URI | undefined; + private _builtinPromptsCache: Map> | undefined; protected override createPromptFilesLocator(): PromptFilesLocator { return this.instantiationService.createInstance(AgenticPromptFilesLocator); @@ -36,6 +43,76 @@ export class AgenticPromptsService extends PromptsService { return this._copilotRoot; } + /** + * Returns built-in prompt files bundled with the Sessions app. + */ + private async getBuiltinPromptFiles(type: PromptsType): Promise { + if (type !== PromptsType.prompt) { + return []; + } + + if (!this._builtinPromptsCache) { + this._builtinPromptsCache = new Map(); + } + + let cached = this._builtinPromptsCache.get(type); + if (!cached) { + cached = this.discoverBuiltinPrompts(type); + this._builtinPromptsCache.set(type, cached); + } + return cached; + } + + private async discoverBuiltinPrompts(type: PromptsType): Promise { + const fileService = this.instantiationService.invokeFunction(accessor => accessor.get(IFileService)); + const promptsDir = FileAccess.asFileUri('vs/sessions/prompts'); + try { + const stat = await fileService.resolve(promptsDir); + if (!stat.children) { + return []; + } + return stat.children + .filter(child => !child.isDirectory && child.name.endsWith('.prompt.md')) + .map(child => ({ uri: child.resource, storage: BUILTIN_STORAGE, type })); + } catch { + return []; + } + } + + /** + * Override to include built-in prompts and filter out those overridden + * by user or workspace prompts with the same name. + */ + public override async listPromptFiles(type: PromptsType, token: CancellationToken): Promise { + const baseResults = await super.listPromptFiles(type, token); + const builtinPrompts = await this.getBuiltinPromptFiles(type); + if (builtinPrompts.length === 0) { + return baseResults; + } + + // Collect names of user/workspace prompts to detect overrides + const overriddenNames = new Set(); + for (const p of baseResults) { + if (p.storage === PromptsStorage.local || p.storage === PromptsStorage.user) { + overriddenNames.add(getCleanPromptName(p.uri)); + } + } + + const nonOverridden = builtinPrompts.filter( + p => !overriddenNames.has(getCleanPromptName(p.uri)) + ); + // Built-in items use BUILTIN_STORAGE ('builtin') which is not in the + // core IPromptPath union but is handled by the sessions UI layer. + return [...baseResults, ...nonOverridden] as readonly IPromptPath[]; + } + + public override async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise { + if (storage === BUILTIN_STORAGE) { + return this.getBuiltinPromptFiles(type) as Promise; + } + return super.listPromptFilesForStorage(type, storage, token); + } + /** * Override to use ~/.copilot as the user-level source folder for creation, * instead of the VS Code profile's promptsHome. @@ -124,13 +201,15 @@ class AgenticPromptFilesLocator extends PromptFilesLocator { /** * Returns the subfolder name under ~/.copilot/ for a given customization type. * Used to determine the CLI-accessible user creation target. + * + * Prompts are a VS Code concept and use the standard profile promptsHome, + * so they are intentionally excluded here. */ function getCliUserSubfolder(type: PromptsType): string | undefined { switch (type) { case PromptsType.instructions: return 'instructions'; case PromptsType.skill: return 'skills'; case PromptsType.agent: return 'agents'; - case PromptsType.prompt: return 'prompts'; default: return undefined; } } diff --git a/src/vs/sessions/contrib/chat/browser/repoPicker.ts b/src/vs/sessions/contrib/chat/browser/repoPicker.ts index bcd4506ea3a..0ac9de839be 100644 --- a/src/vs/sessions/contrib/chat/browser/repoPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/repoPicker.ts @@ -13,9 +13,6 @@ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../ import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { INewSession } from './newSession.js'; -import { URI } from '../../../../base/common/uri.js'; -import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; const STORAGE_KEY_LAST_REPO = 'agentSessions.lastPickedRepo'; @@ -42,9 +39,7 @@ export class RepoPicker extends Disposable { private _triggerElement: HTMLElement | undefined; private readonly _renderDisposables = this._register(new DisposableStore()); - private _browseGeneration = 0; - private _newSession: INewSession | undefined; private _selectedRepo: IRepoItem | undefined; private _recentlyPickedRepos: IRepoItem[] = []; @@ -76,18 +71,6 @@ export class RepoPicker extends Disposable { } catch { /* ignore */ } } - /** - * Sets the pending session that this picker writes to. - * If a repository is already selected, notifies the session. - */ - setNewSession(session: INewSession | undefined): void { - this._newSession = session; - this._browseGeneration++; - if (session && this._selectedRepo) { - this._setRepo(this._selectedRepo); - } - } - /** * Renders the repo picker trigger button into the given container. * Returns the container element. @@ -180,15 +163,13 @@ export class RepoPicker extends Disposable { this._addToRecentlyPicked(item); this.storageService.store(STORAGE_KEY_LAST_REPO, JSON.stringify(item), StorageScope.PROFILE, StorageTarget.MACHINE); this._updateTriggerLabel(); - this._setRepo(item); this._onDidSelectRepo.fire(item.id); } private async _browseForRepo(): Promise { - const generation = this._browseGeneration; try { const result: string | undefined = await this.commandService.executeCommand(OPEN_REPO_COMMAND); - if (result && generation === this._browseGeneration) { + if (result) { this._selectRepo({ id: result, name: result }); } } catch { @@ -270,8 +251,4 @@ export class RepoPicker extends Disposable { dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); } - private _setRepo(repo: IRepoItem): void { - this._newSession?.setRepoUri(URI.parse(`${GITHUB_REMOTE_FILE_SCHEME}://github/${repo.id}/HEAD`)); - } - } diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts index e5c024cb2ba..a1cd803058a 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -5,11 +5,14 @@ import { equals } from '../../../../base/common/arrays.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun, derivedOpts, IObservable } from '../../../../base/common/observable.js'; import { localize, localize2 } from '../../../../nls.js'; import { MenuId, registerAction2, Action2, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { SessionsCategories } from '../../../common/categories.js'; @@ -26,6 +29,7 @@ export const RunScriptDropdownMenuId = MenuId.for('AgentSessionsRunScriptDropdow // Action IDs const RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; +const RUN_SCRIPT_ACTION_PRIMARY_ID = 'workbench.action.agentSessions.runScriptPrimary'; const CONFIGURE_DEFAULT_RUN_ACTION_ID = 'workbench.action.agentSessions.configureDefaultRunAction'; function getTaskDisplayLabel(task: ITaskEntry): string { @@ -62,6 +66,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr constructor( @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, + @IKeybindingService private readonly _keybindingService: IKeybindingService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @ISessionsConfigurationService private readonly _sessionsConfigService: ISessionsConfigurationService, ) { @@ -94,6 +99,40 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr private _registerActions(): void { const that = this; + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: RUN_SCRIPT_ACTION_PRIMARY_ID, + title: { value: localize('runPrimaryScript', 'Run Primary Script'), original: 'Run Primary Script' }, + icon: Codicon.play, + category: SessionsCategories.Sessions, + f1: true, + }); + } + + async run(): Promise { + const activeState = that._activeRunState.get(); + if (!activeState) { + return; + } + + const { tasks, session, lastRunTaskLabel } = activeState; + if (tasks.length === 0) { + const task = await that._showConfigureQuickPick(session); + if (task) { + await that._sessionsConfigService.runTask(task, session); + } + return; + } + + const mruIndex = lastRunTaskLabel !== undefined + ? tasks.findIndex(t => t.label === lastRunTaskLabel) + : -1; + const primaryTask = tasks[mruIndex >= 0 ? mruIndex : 0]; + await that._sessionsConfigService.runTask(primaryTask, session); + } + })); + this._register(autorun(reader => { const activeState = this._activeRunState.read(reader); if (!activeState) { @@ -112,13 +151,15 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr for (let i = 0; i < tasks.length; i++) { const task = tasks[i]; const actionId = `${RUN_SCRIPT_ACTION_ID}.${i}`; + const isPrimary = i === (mruIndex >= 0 ? mruIndex : 0); reader.store.add(registerAction2(class extends Action2 { constructor() { super({ id: actionId, title: getTaskDisplayLabel(task), - tooltip: localize('runActionTooltip', "Run '{0}' in terminal", getTaskDisplayLabel(task)), + tooltip: !isPrimary ? localize('runActionTooltip', "Run '{0}' in terminal", getTaskDisplayLabel(task)) + : localize('runActionTooltipKeybinding', "Run '{0}' in terminal ({1})", getTaskDisplayLabel(task), that._keybindingService.lookupKeybinding(RUN_SCRIPT_ACTION_PRIMARY_ID)?.getLabel() ?? ''), icon: Codicon.play, category: SessionsCategories.Sessions, menu: [{ @@ -154,18 +195,20 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr } async run(): Promise { - await that._showConfigureQuickPick(session); + const task = await that._showConfigureQuickPick(session); + if (task) { + await that._sessionsConfigService.runTask(task, session); + } } })); })); } - private async _showConfigureQuickPick(session: IActiveSessionItem): Promise { + private async _showConfigureQuickPick(session: IActiveSessionItem): Promise { const nonSessionTasks = await this._sessionsConfigService.getNonSessionTasks(session); if (nonSessionTasks.length === 0) { // No existing tasks, go straight to custom command input - await this._showCustomCommandInput(session); - return; + return this._showCustomCommandInput(session); } interface ITaskPickItem extends IQuickPickItem { @@ -198,38 +241,36 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr }); if (!picked) { - return; + return undefined; } const pickedItem = picked as ITaskPickItem; if (pickedItem.task) { // Existing task — set inSessions: true await this._sessionsConfigService.addTaskToSessions(pickedItem.task, session, pickedItem.source ?? 'workspace'); + return pickedItem.task; } else { // Custom command path - await this._showCustomCommandInput(session); + return this._showCustomCommandInput(session); } } - private async _showCustomCommandInput(session: IActiveSessionItem): Promise { + private async _showCustomCommandInput(session: IActiveSessionItem): Promise { const command = await this._quickInputService.input({ placeHolder: localize('enterCommandPlaceholder', "Enter command (e.g., npm run dev)"), prompt: localize('enterCommandPrompt', "This command will be run as a task in the integrated terminal") }); if (!command) { - return; + return undefined; } const target = await this._pickStorageTarget(session); if (!target) { - return; + return undefined; } - const newTask = await this._sessionsConfigService.createAndAddTask(command, session, target); - if (newTask) { - await this._sessionsConfigService.runTask(newTask, session); - } + return this._sessionsConfigService.createAndAddTask(command, session, target); } private async _pickStorageTarget(session: IActiveSessionItem): Promise { @@ -285,7 +326,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr } // Register the Run split button submenu on the workbench title bar (background sessions only) -MenuRegistry.appendMenuItem(Menus.TitleBarRight, { +MenuRegistry.appendMenuItem(Menus.TitleBarSessionMenu, { submenu: RunScriptDropdownMenuId, isSplitButton: true, title: localize2('run', "Run"), @@ -305,7 +346,7 @@ class RunScriptNotAvailableAction extends Action2 { icon: Codicon.play, precondition: ContextKeyExpr.false(), menu: [{ - id: Menus.TitleBarRight, + id: Menus.TitleBarSessionMenu, group: 'navigation', order: 8, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext.toNegated()) @@ -317,3 +358,13 @@ class RunScriptNotAvailableAction extends Action2 { } registerAction2(RunScriptNotAvailableAction); + +// Register F5 keybinding at module level to ensure it's in the registry +// before the keybinding resolver is cached. The command handler is +// registered later by RunScriptContribution. +KeybindingsRegistry.registerKeybindingRule({ + id: RUN_SCRIPT_ACTION_PRIMARY_ID, + primary: KeyCode.F5, + weight: KeybindingWeight.WorkbenchContrib + 100, + when: IsAuxiliaryWindowContext.toNegated() +}); diff --git a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts index 9b3de3cff05..68d175b8fbe 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts @@ -14,7 +14,6 @@ import { IActionWidgetService } from '../../../../platform/actionWidget/browser/ import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IGitRepository } from '../../../../workbench/contrib/git/common/gitService.js'; -import { INewSession } from './newSession.js'; // #region --- Session Target Picker --- @@ -137,7 +136,7 @@ export type IsolationMode = 'worktree' | 'workspace'; export class IsolationModePicker extends Disposable { private _isolationMode: IsolationMode = 'worktree'; - private _newSession: INewSession | undefined; + private _preferredIsolationMode: IsolationMode | undefined; private _repository: IGitRepository | undefined; private readonly _onDidChange = this._register(new Emitter()); @@ -157,13 +156,6 @@ export class IsolationModePicker extends Disposable { super(); } - /** - * Sets the pending session that this picker writes to. - */ - setNewSession(session: INewSession | undefined): void { - this._newSession = session; - } - /** * Sets the git repository. When undefined, worktree option is hidden * and isolation mode falls back to 'workspace'. @@ -171,8 +163,11 @@ export class IsolationModePicker extends Disposable { setRepository(repository: IGitRepository | undefined): void { this._repository = repository; if (repository) { - this._setMode('worktree'); + const preferred = this._preferredIsolationMode; + this._preferredIsolationMode = undefined; + this._setMode(preferred ?? this._isolationMode); } else if (this._isolationMode === 'worktree') { + this._preferredIsolationMode ??= this._isolationMode; this._setMode('workspace'); } this._updateTriggerLabel(); @@ -207,6 +202,20 @@ export class IsolationModePicker extends Disposable { })); } + /** + * Sets a preferred isolation mode to apply when a repository is set. + */ + setPreferredIsolationMode(mode: IsolationMode): void { + this._preferredIsolationMode = mode; + } + + /** + * Programmatically set the isolation mode. + */ + setIsolationMode(mode: IsolationMode): void { + this._setMode(mode); + } + /** * Shows or hides the picker. */ @@ -263,7 +272,6 @@ export class IsolationModePicker extends Disposable { private _setMode(mode: IsolationMode): void { if (this._isolationMode !== mode) { this._isolationMode = mode; - this._newSession?.setIsolationMode(mode); this._onDidChange.fire(mode); this._updateTriggerLabel(); } diff --git a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts index f1a859d4aad..a36bed73da5 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsConfigurationService.ts @@ -30,10 +30,11 @@ export interface ITaskEntry { readonly script?: string; readonly type?: string; readonly command?: string; + readonly args?: CommandString[]; readonly inSessions?: boolean; - readonly windows?: { command?: string }; - readonly osx?: { command?: string }; - readonly linux?: { command?: string }; + readonly windows?: { command?: string; args?: CommandString[] }; + readonly osx?: { command?: string; args?: CommandString[] }; + readonly linux?: { command?: string; args?: CommandString[] }; readonly [key: string]: unknown; } @@ -293,21 +294,42 @@ export class SessionsConfigurationService extends Disposable implements ISession if (!task.script) { return undefined; } - if (task.path) { - return `npm --prefix ${task.path} run ${task.script}`; - } - return `npm run ${task.script}`; + const base = task.path + ? `npm --prefix ${task.path} run ${task.script}` + : `npm run ${task.script}`; + return this._appendArgs(base, task.args); } + + let command: string | undefined; + let platformArgs: CommandString[] | undefined; + if (isWindows && task.windows?.command) { - return task.windows.command; + command = task.windows.command; + platformArgs = task.windows.args; + } else if (isMacintosh && task.osx?.command) { + command = task.osx.command; + platformArgs = task.osx.args; + } else if (!isWindows && !isMacintosh && task.linux?.command) { + command = task.linux.command; + platformArgs = task.linux.args; + } else { + command = task.command; } - if (isMacintosh && task.osx?.command) { - return task.osx.command; + + // Platform-specific args override task-level args + const args = platformArgs ?? task.args; + return this._appendArgs(command, args); + } + + private _appendArgs(command: string | undefined, args: CommandString[] | undefined): string | undefined { + if (!command) { + return undefined; } - if (!isWindows && !isMacintosh && task.linux?.command) { - return task.linux.command; + if (!args || args.length === 0) { + return command; } - return task.command; + const resolvedArgs = args.map(a => CommandString.value(a)).join(' '); + return `${command} ${resolvedArgs}`; } private _ensureFileWatch(folder: URI): void { diff --git a/src/vs/sessions/contrib/chat/browser/slashCommands.ts b/src/vs/sessions/contrib/chat/browser/slashCommands.ts index a2b6d2dc343..4cf481f915e 100644 --- a/src/vs/sessions/contrib/chat/browser/slashCommands.ts +++ b/src/vs/sessions/contrib/chat/browser/slashCommands.ts @@ -21,6 +21,9 @@ import { inputPlaceholderForeground } from '../../../../platform/theme/common/co import { localize } from '../../../../nls.js'; import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js'; import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IChatPromptSlashCommand, IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; /** * Static command ID used by completion items to trigger immediate slash command execution, @@ -57,6 +60,7 @@ export class SlashCommandHandler extends Disposable { private static _slashDecosRegistered = false; private readonly _slashCommands: ISessionsSlashCommandData[] = []; + private _cachedPromptCommands: readonly IChatPromptSlashCommand[] = []; constructor( private readonly _editor: CodeEditorWidget, @@ -64,23 +68,34 @@ export class SlashCommandHandler extends Disposable { @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, @IThemeService private readonly themeService: IThemeService, + @IAICustomizationWorkspaceService private readonly aiCustomizationWorkspaceService: IAICustomizationWorkspaceService, + @IPromptsService private readonly promptsService: IPromptsService, ) { super(); this._registerSlashCommands(); this._registerCompletions(); this._registerDecorations(); + this._refreshPromptCommands(); + this._register(this.promptsService.onDidChangeSlashCommands(() => this._refreshPromptCommands())); } clearInput(): void { this._editor.getModel()?.setValue(''); } + private _refreshPromptCommands(): void { + this.aiCustomizationWorkspaceService.getFilteredPromptSlashCommands(CancellationToken.None).then(commands => { + this._cachedPromptCommands = commands; + this._updateDecorations(); + }, () => { /* swallow errors from stale refresh */ }); + } + /** * Attempts to parse and execute a slash command from the input. * Returns `true` if a command was handled. */ tryExecuteSlashCommand(query: string): boolean { - const match = query.match(/^\/(\w+)\s*(.*)/s); + const match = query.match(/^\/([\w\p{L}\d_\-\.:]+)\s*(.*)/su); if (!match) { return false; } @@ -95,6 +110,30 @@ export class SlashCommandHandler extends Disposable { return true; } + /** + * If the query starts with a prompt/skill slash command (e.g. `/my-prompt args`), + * expands it into a CLI-friendly markdown reference so the agent can locate the + * file. Returns `undefined` when the query is not a prompt slash command. + */ + tryExpandPromptSlashCommand(query: string): string | undefined { + const match = query.match(/^\/([\w\p{L}\d_\-\.:]+)\s*(.*)/su); + if (!match) { + return undefined; + } + + const commandName = match[1]; + const promptCommand = this._cachedPromptCommands.find(c => c.name === commandName); + if (!promptCommand) { + return undefined; + } + + const args = match[2]?.trim() ?? ''; + const uri = promptCommand.promptPath.uri; + const typeLabel = promptCommand.promptPath.type === PromptsType.skill ? 'skill' : 'prompt file'; + const expanded = `Use the ${typeLabel} located at [${promptCommand.name}](${uri.toString()}).`; + return args ? `${expanded} ${args}` : expanded; + } + private _registerSlashCommands(): void { const openSection = (section: AICustomizationManagementSection) => () => this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, section); @@ -154,7 +193,7 @@ export class SlashCommandHandler extends Disposable { private _updateDecorations(): void { const model = this._editor.getModel(); const value = model?.getValue() ?? ''; - const match = value.match(/^\/(\w+)\s?/); + const match = value.match(/^\/([\w\p{L}\d_\-\.:]+)\s?/u); if (!match) { this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, []); @@ -164,7 +203,8 @@ export class SlashCommandHandler extends Disposable { const commandName = match[1]; const slashCommand = this._slashCommands.find(c => c.command === commandName); - if (!slashCommand) { + const promptCommand = this._cachedPromptCommands.find(c => c.name === commandName); + if (!slashCommand && !promptCommand) { this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, []); this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []); return; @@ -179,13 +219,14 @@ export class SlashCommandHandler extends Disposable { // Show the command description as a placeholder after the command const restOfInput = value.slice(match[0].length).trim(); - if (!restOfInput && slashCommand.detail) { + const detail = slashCommand?.detail ?? promptCommand?.description; + if (!restOfInput && detail) { const placeholderCol = match[0].length + 1; const placeholderDeco: IDecorationOptions[] = [{ range: { startLineNumber: 1, startColumn: placeholderCol, endLineNumber: 1, endColumn: model!.getLineMaxColumn(1) }, renderOptions: { after: { - contentText: slashCommand.detail, + contentText: detail, color: this._getPlaceholderColor(), } } @@ -238,6 +279,44 @@ export class SlashCommandHandler extends Disposable { }; } })); + + // Dynamic completions for individual prompt/skill files (filtered to match + // what the sessions customizations view shows). + this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, { + _debugDisplayName: 'sessionsPromptSlashCommands', + triggerCharacters: ['/'], + provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => { + const range = this._computeCompletionRanges(model, position, /\/[\p{L}0-9_.:-]*/gu); + if (!range) { + return null; + } + + const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn)); + if (textBefore.trim() !== '') { + return null; + } + + const promptCommands = await this.aiCustomizationWorkspaceService.getFilteredPromptSlashCommands(token); + const userInvocable = promptCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); + if (userInvocable.length === 0) { + return null; + } + + return { + suggestions: userInvocable.map((c, i): CompletionItem => { + const label = `/${c.name}`; + return { + label: { label, description: c.description }, + insertText: `${label} `, + documentation: c.description, + range, + sortText: 'b'.repeat(i + 1), + kind: CompletionItemKind.Text, + }; + }) + }; + } + })); } private _computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp): { insert: Range; replace: Range } | undefined { diff --git a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts new file mode 100644 index 00000000000..fb3efadd52f --- /dev/null +++ b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; + +/** + * Extended storage type for AI Customization that includes built-in prompts + * shipped with the application, alongside the core `PromptsStorage` values. + */ +export type AICustomizationPromptsStorage = PromptsStorage | 'builtin'; + +/** + * Storage type discriminator for built-in prompts shipped with the application. + */ +export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; + +/** + * Prompt path for built-in prompts bundled with the Sessions app. + */ +export interface IBuiltinPromptPath { + readonly uri: URI; + readonly storage: AICustomizationPromptsStorage; + readonly type: PromptsType; +} diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts index f1f2706807a..d127cff33e9 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts @@ -396,6 +396,54 @@ suite('SessionsConfigurationService', () => { assert.strictEqual(createdTerminals[1].name, 'test'); }); + test('runTask appends args to shell command', async () => { + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + const task: ITaskEntry = { label: 'build', type: 'shell', command: 'dotnet', args: ['build', '--configuration', 'Release'], inSessions: true }; + + await service.runTask(task, session); + + assert.strictEqual(sentCommands.length, 1); + assert.strictEqual(sentCommands[0].command, 'dotnet build --configuration Release'); + }); + + test('runTask appends args to npm task', async () => { + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + const task: ITaskEntry = { label: 'test', type: 'npm', script: 'test', args: ['--', '--coverage'], inSessions: true }; + + await service.runTask(task, session); + + assert.strictEqual(sentCommands.length, 1); + assert.strictEqual(sentCommands[0].command, 'npm run test -- --coverage'); + }); + + test('runTask resolves CommandString objects in args', async () => { + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + const task: ITaskEntry = { + label: 'build', type: 'shell', command: 'gcc', + args: [ + { value: '-o', quoting: 'escape' as const }, + 'output.exe', + 'main.c', + ], + inSessions: true + }; + + await service.runTask(task, session); + + assert.strictEqual(sentCommands.length, 1); + assert.strictEqual(sentCommands[0].command, 'gcc -o output.exe main.c'); + }); + + test('runTask sends only command when args is empty', async () => { + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + const task: ITaskEntry = { label: 'build', type: 'shell', command: 'make', args: [], inSessions: true }; + + await service.runTask(task, session); + + assert.strictEqual(sentCommands.length, 1); + assert.strictEqual(sentCommands[0].command, 'make'); + }); + test('runTask creates different terminals for same command in different worktrees', async () => { const wt1 = URI.parse('file:///worktree1'); const wt2 = URI.parse('file:///worktree2'); diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts new file mode 100644 index 00000000000..75a9ffaad22 --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { CodeReviewService, CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from './codeReviewService.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; +import { getSessionEditorComments } from '../../agentFeedback/browser/sessionEditorComments.js'; + +registerSingleton(ICodeReviewService, CodeReviewService, InstantiationType.Delayed); + +const canRunSessionCodeReviewContextKey = new RawContextKey('sessions.canRunCodeReview', true, { + type: 'boolean', + description: localize('sessions.canRunCodeReview', "True when a new code review can be started for the active session version."), +}); + +function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disposable { + class RunSessionCodeReviewAction extends Action2 { + static readonly ID = 'sessions.codeReview.run'; + + constructor() { + super({ + id: RunSessionCodeReviewAction.ID, + title: localize('sessions.runCodeReview', "Run Code Review"), + tooltip, + category: CHAT_CATEGORY, + icon, + precondition: canRunSessionCodeReviewContextKey, + menu: [ + { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 7, + when: ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.hasAgentSessionChanges), + }, + ], + }); + } + + override async run(accessor: ServicesAccessor, sessionResource?: URI): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const agentSessionsService = accessor.get(IAgentSessionsService); + const codeReviewService = accessor.get(ICodeReviewService); + const agentFeedbackService = accessor.get(IAgentFeedbackService); + + const resource = URI.isUri(sessionResource) + ? sessionResource + : sessionManagementService.getActiveSession()?.resource; + if (!resource) { + return; + } + + const session = agentSessionsService.getSession(resource); + if (!(session?.changes instanceof Array) || session.changes.length === 0) { + return; + } + + const files = getCodeReviewFilesFromSessionChanges(session.changes); + const version = getCodeReviewVersion(files); + + // If there are existing comments (code review or PR review), navigate to the first one + const reviewState = codeReviewService.getReviewState(resource).get(); + const prReviewState = codeReviewService.getPRReviewState(resource).get(); + const codeReviewCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === version ? reviewState.comments.length : 0; + const prReviewCount = prReviewState.kind === PRReviewStateKind.Loaded ? prReviewState.comments.length : 0; + + if (codeReviewCount > 0 || prReviewCount > 0) { + const comments = getSessionEditorComments( + resource, + agentFeedbackService.getFeedback(resource), + reviewState, + prReviewState, + ); + const first = agentFeedbackService.getNextNavigableItem(resource, comments, true); + if (first) { + await agentFeedbackService.revealSessionComment(resource, first.id, first.resourceUri, first.range); + } + return; + } + + + codeReviewService.requestReview(resource, version, files); + } + } + + return registerAction2(RunSessionCodeReviewAction) as Disposable; +} + +class CodeReviewToolbarContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.codeReviewToolbar'; + + private readonly _actionRegistration = this._register(new MutableDisposable()); + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + @ISessionsManagementService private readonly _sessionManagementService: ISessionsManagementService, + @ICodeReviewService private readonly _codeReviewService: ICodeReviewService, + ) { + super(); + + const canRunCodeReviewContext = canRunSessionCodeReviewContextKey.bindTo(contextKeyService); + const sessionsChangedSignal = observableFromEvent(this, this._agentSessionsService.model.onDidChangeSessions, () => undefined); + + this._register(autorun(reader => { + const activeSession = this._sessionManagementService.activeSession.read(reader); + sessionsChangedSignal.read(reader); + this._actionRegistration.clear(); + + const sessionResource = activeSession?.resource; + if (!sessionResource) { + canRunCodeReviewContext.set(false); + this._actionRegistration.value = registerSessionCodeReviewAction(localize('sessions.runCodeReview.noSession', "No active session available for code review."), Codicon.codeReview); + return; + } + + const session = this._agentSessionsService.getSession(sessionResource); + if (!(session?.changes instanceof Array) || session.changes.length === 0) { + canRunCodeReviewContext.set(false); + this._actionRegistration.value = registerSessionCodeReviewAction(localize('sessions.runCodeReview.noChanges', "No changes available for code review."), Codicon.codeReview); + return; + } + + const files = getCodeReviewFilesFromSessionChanges(session.changes); + const version = getCodeReviewVersion(files); + const reviewState = this._codeReviewService.getReviewState(sessionResource).read(reader); + const prReviewState = this._codeReviewService.getPRReviewState(sessionResource).read(reader); + + const codeReviewCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === version ? reviewState.comments.length : 0; + const prReviewCount = prReviewState.kind === PRReviewStateKind.Loaded ? prReviewState.comments.length : 0; + const totalCommentCount = codeReviewCount + prReviewCount; + + let canRunCodeReview = true; + let tooltip = localize('sessions.runCodeReview.tooltip.default', "Run Code Review"); + let icon = Codicon.codeReview; + + if (reviewState.kind === CodeReviewStateKind.Loading && reviewState.version === version) { + canRunCodeReview = false; + tooltip = localize('sessions.runCodeReview.tooltip.loading', "Creating code review..."); + icon = Codicon.commentDraft; + } else if (totalCommentCount > 0) { + canRunCodeReview = true; + icon = Codicon.commentUnresolved; + tooltip = totalCommentCount === 1 + ? localize('sessions.runCodeReview.tooltip.oneUnresolved', "1 review comment unresolved.") + : localize('sessions.runCodeReview.tooltip.manyUnresolved', "{0} review comments unresolved.", totalCommentCount); + } else if (reviewState.kind === CodeReviewStateKind.Result && reviewState.version === version) { + canRunCodeReview = false; + tooltip = localize('sessions.runCodeReview.tooltip.allResolved', "All review comments have been addressed."); + icon = Codicon.comment; + } + + canRunCodeReviewContext.set(canRunCodeReview); + this._actionRegistration.value = registerSessionCodeReviewAction(tooltip, icon); + })); + } +} + +registerWorkbenchContribution2(CodeReviewToolbarContribution.ID, CodeReviewToolbarContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts new file mode 100644 index 00000000000..689c409ffe4 --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts @@ -0,0 +1,662 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { IRange, Range } from '../../../../editor/common/core/range.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { hash } from '../../../../base/common/hash.js'; +import { hasKey } from '../../../../base/common/types.js'; +import { IChatSessionFileChange, IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; + +// --- Types ------------------------------------------------------------------- + +export interface ICodeReviewComment { + readonly id: string; + readonly uri: URI; + readonly range: IRange; + readonly body: string; + readonly kind: string; + readonly severity: string; + readonly suggestion?: ICodeReviewSuggestion; +} + +export interface ICodeReviewSuggestion { + readonly edits: readonly ICodeReviewSuggestionChange[]; +} + +export interface ICodeReviewSuggestionChange { + readonly range: IRange; + readonly newText: string; + readonly oldText: string; +} + +export interface ICodeReviewFile { + readonly currentUri: URI; + readonly baseUri?: URI; +} + +export function getCodeReviewFilesFromSessionChanges(changes: readonly (IChatSessionFileChange | IChatSessionFileChange2)[]): readonly ICodeReviewFile[] { + return changes.map(change => { + if (isIChatSessionFileChange2(change)) { + return { + currentUri: change.modifiedUri ?? change.uri, + baseUri: change.originalUri, + }; + } + + return { + currentUri: change.modifiedUri, + baseUri: change.originalUri, + }; + }); +} + +export function getCodeReviewVersion(files: readonly ICodeReviewFile[]): string { + const stableFileList = files + .map(file => `${file.currentUri.toString()}|${file.baseUri?.toString() ?? ''}`) + .sort(); + + return `v1:${stableFileList.length}:${hash(stableFileList)}`; +} + +export const enum CodeReviewStateKind { + Idle = 'idle', + Loading = 'loading', + Result = 'result', + Error = 'error', +} + +export type ICodeReviewState = + | { readonly kind: CodeReviewStateKind.Idle } + | { readonly kind: CodeReviewStateKind.Loading; readonly version: string } + | { readonly kind: CodeReviewStateKind.Result; readonly version: string; readonly comments: readonly ICodeReviewComment[] } + | { readonly kind: CodeReviewStateKind.Error; readonly version: string; readonly reason: string }; + +// --- PR Review Types --------------------------------------------------------- + +export const enum PRReviewStateKind { + None = 'none', + Loading = 'loading', + Loaded = 'loaded', + Error = 'error', +} + +export type IPRReviewState = + | { readonly kind: PRReviewStateKind.None } + | { readonly kind: PRReviewStateKind.Loading } + | { readonly kind: PRReviewStateKind.Loaded; readonly comments: readonly IPRReviewComment[] } + | { readonly kind: PRReviewStateKind.Error; readonly reason: string }; + +export interface IPRReviewComment { + readonly id: string; + readonly uri: URI; + readonly range: IRange; + readonly body: string; + readonly author: string; +} + +/** Shape of a single comment as returned by the code review command. */ +interface IRawCodeReviewComment { + readonly uri: IRawCodeReviewUri; + readonly range: IRawCodeReviewRange; + readonly body?: string; + readonly kind?: string; + readonly severity?: string; + readonly suggestion?: IRawCodeReviewSuggestion; +} + +type IRawCodeReviewUri = URI | UriComponents | string; + +interface IRawCodeReviewPosition { + readonly line?: number; + readonly character?: number; +} + +interface IRawCodeReviewRangeWithPositions { + readonly start?: IRawCodeReviewPosition; + readonly end?: IRawCodeReviewPosition; +} + +interface IRawCodeReviewRangeWithLines { + readonly startLine?: number; + readonly startColumn?: number; + readonly endLine?: number; + readonly endColumn?: number; +} + +type IRawCodeReviewRangeTuple = readonly [IRawCodeReviewPosition, IRawCodeReviewPosition]; + +type IRawCodeReviewRange = IRange | IRawCodeReviewRangeWithPositions | IRawCodeReviewRangeWithLines | IRawCodeReviewRangeTuple; + +interface IRawCodeReviewSuggestion { + readonly edits: readonly IRawCodeReviewSuggestionChange[]; +} + +interface IRawCodeReviewSuggestionChange { + readonly range: IRawCodeReviewRange; + readonly newText: string; + readonly oldText: string; +} + +// --- Service Interface ------------------------------------------------------- + +export const ICodeReviewService = createDecorator('codeReviewService'); + +export interface ICodeReviewService { + readonly _serviceBrand: undefined; + + /** + * Get the observable review state for a session. + */ + getReviewState(sessionResource: URI): IObservable; + + /** + * Synchronously check if a completed review exists for the given session+version. + */ + hasReview(sessionResource: URI, version: string): boolean; + + /** + * Request a code review for the given session. The review is associated with + * a version string (fingerprint of changed files). If a review is already in + * progress or completed for this version, this is a no-op. + */ + requestReview(sessionResource: URI, version: string, files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[]): void; + + /** + * Remove a single comment from the review results. + */ + removeComment(sessionResource: URI, commentId: string): void; + + /** + * Dismiss/clear the review for a session entirely. + */ + dismissReview(sessionResource: URI): void; + + /** + * Get the observable PR review state for a session. + * Returns unresolved review comments from the PR associated with the session. + */ + getPRReviewState(sessionResource: URI): IObservable; + + /** + * Resolve a PR review thread on GitHub and remove it from local state. + */ + resolvePRReviewThread(sessionResource: URI, threadId: string): Promise; +} + +// --- Storage Types ----------------------------------------------------------- + +interface IStoredCodeReview { + readonly version: string; + readonly comments: readonly IStoredCodeReviewComment[]; +} + +interface IStoredCodeReviewComment { + readonly id: string; + readonly uri: UriComponents; + readonly range: IRange; + readonly body: string; + readonly kind: string; + readonly severity: string; + readonly suggestion?: ICodeReviewSuggestion; +} + +// --- Implementation ---------------------------------------------------------- + +interface ISessionReviewData { + readonly state: ReturnType>; +} + +interface IPRSessionReviewData { + readonly state: ReturnType>; + readonly disposables: DisposableStore; + initialized: boolean; +} + +function isRawCodeReviewRangeWithPositions(range: IRawCodeReviewRange): range is IRawCodeReviewRangeWithPositions { + return typeof range === 'object' && range !== null && hasKey(range, { start: true, end: true }); +} + +function isRawCodeReviewRangeTuple(range: IRawCodeReviewRange): range is IRawCodeReviewRangeTuple { + return Array.isArray(range) && range.length >= 2; +} + +function normalizeCodeReviewUri(uri: IRawCodeReviewUri): URI { + return typeof uri === 'string' ? URI.parse(uri) : URI.revive(uri); +} + +function normalizeCodeReviewRange(range: IRawCodeReviewRange): IRange { + if (Range.isIRange(range)) { + return Range.lift(range); + } + + if (isRawCodeReviewRangeTuple(range)) { + const [start, end] = range; + return new Range( + (start.line ?? 0) + 1, + (start.character ?? 0) + 1, + (end.line ?? start.line ?? 0) + 1, + (end.character ?? start.character ?? 0) + 1, + ); + } + + if (isRawCodeReviewRangeWithPositions(range) && range.start && range.end) { + return new Range( + (range.start.line ?? 0) + 1, + (range.start.character ?? 0) + 1, + (range.end.line ?? range.start.line ?? 0) + 1, + (range.end.character ?? range.start.character ?? 0) + 1, + ); + } + + const lineRange = range as IRawCodeReviewRangeWithLines; + return new Range( + (lineRange.startLine ?? 0) + 1, + (lineRange.startColumn ?? 0) + 1, + (lineRange.endLine ?? lineRange.startLine ?? 0) + 1, + (lineRange.endColumn ?? lineRange.startColumn ?? 0) + 1, + ); +} + +function normalizeCodeReviewSuggestion(suggestion: IRawCodeReviewSuggestion | undefined): ICodeReviewSuggestion | undefined { + if (!suggestion) { + return undefined; + } + + return { + edits: suggestion.edits.map(edit => ({ + range: normalizeCodeReviewRange(edit.range), + newText: edit.newText, + oldText: edit.oldText, + })), + }; +} + +export class CodeReviewService extends Disposable implements ICodeReviewService { + + declare readonly _serviceBrand: undefined; + + private static readonly _STORAGE_KEY = 'codeReview.reviews'; + + private readonly _reviewsBySession = new Map(); + private readonly _prReviewBySession = new Map(); + + constructor( + @ICommandService private readonly _commandService: ICommandService, + @ILogService private readonly _logService: ILogService, + @IStorageService private readonly _storageService: IStorageService, + @IGitHubService private readonly _gitHubService: IGitHubService, + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, + ) { + super(); + this._loadFromStorage(); + this._registerSessionListeners(); + + this._register(autorun(reader => { + const activeSession = this._sessionsManagementService.activeSession.read(reader); + if (activeSession) { + this._ensurePRReviewInitialized(activeSession.resource); + } + })); + + this._register(this._agentSessionsService.model.onDidChangeSessions(() => { + for (const session of this._agentSessionsService.model.sessions) { + if (!session.isArchived()) { + this._ensurePRReviewInitialized(session.resource); + } + } + })); + + this._register(this._agentSessionsService.model.onDidChangeSessionArchivedState(e => { + if (e.isArchived()) { + this._disposePRReview(e.resource); + } + })); + } + + getReviewState(sessionResource: URI): IObservable { + return this._getOrCreateData(sessionResource).state; + } + + hasReview(sessionResource: URI, version: string): boolean { + const data = this._reviewsBySession.get(sessionResource.toString()); + if (!data) { + return false; + } + const state = data.state.get(); + return state.kind === CodeReviewStateKind.Result && state.version === version; + } + + requestReview(sessionResource: URI, version: string, files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[]): void { + const data = this._getOrCreateData(sessionResource); + const currentState = data.state.get(); + + // Don't re-request if already loading or completed for this version + if (currentState.kind === CodeReviewStateKind.Loading && currentState.version === version) { + return; + } + if (currentState.kind === CodeReviewStateKind.Result && currentState.version === version) { + return; + } + + data.state.set({ kind: CodeReviewStateKind.Loading, version }, undefined); + + this._executeReview(sessionResource, version, files, data); + } + + removeComment(sessionResource: URI, commentId: string): void { + const data = this._reviewsBySession.get(sessionResource.toString()); + if (!data) { + return; + } + + const state = data.state.get(); + if (state.kind !== CodeReviewStateKind.Result) { + return; + } + + const filtered = state.comments.filter(c => c.id !== commentId); + data.state.set({ kind: CodeReviewStateKind.Result, version: state.version, comments: filtered }, undefined); + this._saveToStorage(); + } + + dismissReview(sessionResource: URI): void { + const data = this._reviewsBySession.get(sessionResource.toString()); + if (data) { + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + this._saveToStorage(); + } + } + + private _getOrCreateData(sessionResource: URI): ISessionReviewData { + const key = sessionResource.toString(); + let data = this._reviewsBySession.get(key); + if (!data) { + data = { + state: observableValue(`codeReview.state.${key}`, { kind: CodeReviewStateKind.Idle }), + }; + this._reviewsBySession.set(key, data); + } + return data; + } + + private async _executeReview( + sessionResource: URI, + version: string, + files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[], + data: ISessionReviewData, + ): Promise { + try { + const result: { type: string; comments?: IRawCodeReviewComment[]; reason?: string } | undefined = + await this._commandService.executeCommand('chat.internal.codeReview.run', { + files: files.map(f => ({ + currentUri: f.currentUri, + baseUri: f.baseUri, + })), + }); + + // Check if version is still current (hasn't been dismissed or replaced) + const currentState = data.state.get(); + if (currentState.kind !== CodeReviewStateKind.Loading || currentState.version !== version) { + return; + } + + if (!result || result.type === 'cancelled') { + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + return; + } + + if (result.type === 'error') { + data.state.set({ kind: CodeReviewStateKind.Error, version, reason: result.reason ?? 'Unknown error' }, undefined); + return; + } + + if (result.type === 'success') { + const comments: ICodeReviewComment[] = (result.comments ?? []).map((raw) => ({ + id: generateUuid(), + uri: normalizeCodeReviewUri(raw.uri), + range: normalizeCodeReviewRange(raw.range), + body: raw.body ?? '', + kind: raw.kind ?? '', + severity: raw.severity ?? '', + suggestion: normalizeCodeReviewSuggestion(raw.suggestion), + })); + + transaction(tx => { + data.state.set({ kind: CodeReviewStateKind.Result, version, comments }, tx); + }); + this._saveToStorage(); + } + } catch (err) { + const currentState = data.state.get(); + if (currentState.kind === CodeReviewStateKind.Loading && currentState.version === version) { + data.state.set({ kind: CodeReviewStateKind.Error, version, reason: String(err) }, undefined); + } + } + } + + private _loadFromStorage(): void { + const raw = this._storageService.get(CodeReviewService._STORAGE_KEY, StorageScope.WORKSPACE); + if (!raw) { + return; + } + + try { + const stored: Record = JSON.parse(raw); + for (const [key, review] of Object.entries(stored)) { + const comments: ICodeReviewComment[] = review.comments.map(c => ({ + id: c.id, + uri: URI.revive(c.uri), + range: c.range, + body: c.body, + kind: c.kind, + severity: c.severity, + suggestion: c.suggestion, + })); + const data = this._getOrCreateData(URI.parse(key)); + data.state.set({ kind: CodeReviewStateKind.Result, version: review.version, comments }, undefined); + } + } catch { + // Corrupted storage data — ignore + } + } + + private _saveToStorage(): void { + const stored: Record = {}; + for (const [key, data] of this._reviewsBySession) { + const state = data.state.get(); + if (state.kind === CodeReviewStateKind.Result) { + stored[key] = { + version: state.version, + comments: state.comments.map(c => ({ + id: c.id, + uri: c.uri.toJSON(), + range: c.range, + body: c.body, + kind: c.kind, + severity: c.severity, + suggestion: c.suggestion, + })), + }; + } + } + + if (Object.keys(stored).length === 0) { + this._storageService.remove(CodeReviewService._STORAGE_KEY, StorageScope.WORKSPACE); + } else { + this._storageService.store(CodeReviewService._STORAGE_KEY, JSON.stringify(stored), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + } + + private _registerSessionListeners(): void { + // Clean up when a session is archived + this._register(this._agentSessionsService.onDidChangeSessionArchivedState(session => { + if (session.isArchived()) { + const key = session.resource.toString(); + const data = this._reviewsBySession.get(key); + if (data) { + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + this._saveToStorage(); + } + } + })); + + // Clean up when session changes make a review version outdated + this._register(this._agentSessionsService.model.onDidChangeSessions(() => { + let changed = false; + for (const [key, data] of this._reviewsBySession) { + const state = data.state.get(); + if (state.kind !== CodeReviewStateKind.Result) { + continue; + } + + const session = this._agentSessionsService.getSession(URI.parse(key)); + if (!session) { + // Session no longer exists — clean up + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + changed = true; + continue; + } + + if (!(session.changes instanceof Array) || session.changes.length === 0) { + // Session has no file-level changes — clean up + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + changed = true; + continue; + } + + const files = getCodeReviewFilesFromSessionChanges(session.changes); + const currentVersion = getCodeReviewVersion(files); + if (state.version !== currentVersion) { + // Version mismatch — review is stale + data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); + changed = true; + } + } + + if (changed) { + this._saveToStorage(); + } + })); + } + + getPRReviewState(sessionResource: URI): IObservable { + return this._getOrCreatePRReviewData(sessionResource).state; + } + + async resolvePRReviewThread(sessionResource: URI, threadId: string): Promise { + const context = this._sessionsManagementService.getGitHubContextForSession(sessionResource); + if (context?.prNumber !== undefined) { + const prModel = this._gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + try { + await prModel.resolveThread(threadId); + } catch (err) { + this._logService.warn('[CodeReviewService] Failed to resolve PR thread on GitHub:', err); + } + } + + // Remove from local state regardless of GitHub success + const data = this._prReviewBySession.get(sessionResource.toString()); + if (data) { + const currentState = data.state.get(); + if (currentState.kind === PRReviewStateKind.Loaded) { + const filtered = currentState.comments.filter(c => c.id !== threadId); + data.state.set({ kind: PRReviewStateKind.Loaded, comments: filtered }, undefined); + } + } + } + + private _getOrCreatePRReviewData(sessionResource: URI): IPRSessionReviewData { + const key = sessionResource.toString(); + let data = this._prReviewBySession.get(key); + if (!data) { + data = { + state: observableValue(`prReview.state.${key}`, { kind: PRReviewStateKind.None }), + disposables: new DisposableStore(), + initialized: false, + }; + this._prReviewBySession.set(key, data); + } + return data; + } + + private _ensurePRReviewInitialized(sessionResource: URI): void { + const data = this._getOrCreatePRReviewData(sessionResource); + if (data.initialized) { + return; + } + + const context = this._sessionsManagementService.getGitHubContextForSession(sessionResource); + if (!context || context.prNumber === undefined) { + return; + } + + data.initialized = true; + data.state.set({ kind: PRReviewStateKind.Loading }, undefined); + + const prModel = this._gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + + // Watch the PR model's review threads and map to local state + data.disposables.add(autorun(reader => { + const threads = prModel.reviewThreads.read(reader); + const comments: IPRReviewComment[] = []; + + for (const thread of threads) { + if (thread.isResolved) { + continue; + } + const fileUri = this._sessionsManagementService.resolveSessionFileUri(sessionResource, thread.path); + if (!fileUri) { + continue; + } + const line = thread.line ?? 1; + const firstComment = thread.comments[0]; + comments.push({ + id: String(thread.id), + uri: fileUri, + range: new Range(line, 1, line, 1), + body: firstComment?.body ?? '', + author: firstComment?.author.login ?? '', + }); + } + + data.state.set({ kind: PRReviewStateKind.Loaded, comments }, undefined); + })); + + // Start polling and initial fetch + prModel.refreshThreads().catch(err => { + this._logService.error('[CodeReviewService] Failed to fetch PR review threads:', err); + data.state.set({ kind: PRReviewStateKind.Error, reason: String(err) }, undefined); + }); + prModel.startPolling(); + } + + private _disposePRReview(sessionResource: URI): void { + const key = sessionResource.toString(); + const data = this._prReviewBySession.get(key); + if (data) { + data.disposables.dispose(); + this._prReviewBySession.delete(key); + } + } + + override dispose(): void { + for (const data of this._prReviewBySession.values()) { + data.disposables.dispose(); + } + this._prReviewBySession.clear(); + super.dispose(); + } +} diff --git a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts new file mode 100644 index 00000000000..193a55925b2 --- /dev/null +++ b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts @@ -0,0 +1,961 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IAgentSession, IAgentSessionsModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { CodeReviewService, CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService } from '../../browser/codeReviewService.js'; +import { IGitHubService } from '../../../github/browser/githubService.js'; +import { IActiveSessionItem, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; + +suite('CodeReviewService', () => { + + const store = new DisposableStore(); + let instantiationService: TestInstantiationService; + let service: ICodeReviewService; + let commandService: MockCommandService; + let storageService: InMemoryStorageService; + let agentSessionsService: MockAgentSessionsService; + + let session: URI; + let fileA: URI; + let fileB: URI; + + class MockCommandService implements ICommandService { + declare readonly _serviceBrand: undefined; + readonly onWillExecuteCommand = Event.None; + readonly onDidExecuteCommand = Event.None; + + result: unknown = undefined; + lastCommandId: string | undefined; + lastArgs: unknown[] | undefined; + executeDeferred: { resolve: (v: unknown) => void; reject: (e: unknown) => void } | undefined; + + async executeCommand(commandId: string, ...args: unknown[]): Promise { + this.lastCommandId = commandId; + this.lastArgs = args; + + if (this.executeDeferred) { + return await new Promise((resolve, reject) => { + this.executeDeferred = { resolve: resolve as (v: unknown) => void, reject }; + }); + } + + return this.result as T; + } + + /** + * Configure the mock to defer execution until manually resolved/rejected. + */ + deferNextExecution(): void { + this.executeDeferred = undefined; + const self = this; + const originalResult = this.result; + + // Override executeCommand for next call to capture the deferred promise + const origExecute = this.executeCommand.bind(this); + this.executeCommand = async function (commandId: string, ...args: unknown[]): Promise { + self.lastCommandId = commandId; + self.lastArgs = args; + + return new Promise((resolve, reject) => { + self.executeDeferred = { resolve: resolve as (v: unknown) => void, reject }; + }); + } as typeof origExecute; + + // Restore after use + this._restoreExecute = () => { + this.executeCommand = origExecute; + this.result = originalResult; + }; + } + + private _restoreExecute: (() => void) | undefined; + + resolveExecution(value: unknown): void { + this.executeDeferred?.resolve(value); + this.executeDeferred = undefined; + this._restoreExecute?.(); + } + + rejectExecution(error: unknown): void { + this.executeDeferred?.reject(error); + this.executeDeferred = undefined; + this._restoreExecute?.(); + } + } + + class MockAgentSessionsService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeSessionArchivedState: Emitter; + readonly onDidChangeSessionArchivedState: Event; + private readonly _onDidChangeSessions: Emitter; + readonly model: IAgentSessionsModel; + private readonly _sessions = new Map(); + + constructor(disposables: DisposableStore) { + this._onDidChangeSessionArchivedState = disposables.add(new Emitter()); + this.onDidChangeSessionArchivedState = this._onDidChangeSessionArchivedState.event; + this._onDidChangeSessions = disposables.add(new Emitter()); + this.model = { + onWillResolve: Event.None, + onDidResolve: Event.None, + onDidChangeSessions: this._onDidChangeSessions.event, + onDidChangeSessionArchivedState: this._onDidChangeSessionArchivedState.event, + resolved: true, + sessions: [], + getSession: (resource: URI) => this._sessions.get(resource.toString()), + resolve: async () => { }, + }; + } + + getSession(resource: URI): IAgentSession | undefined { + return this._sessions.get(resource.toString()); + } + + setSession(resource: URI, changes?: readonly IChatSessionFileChange2[], archived = false): IAgentSession { + let _archived = archived; + const session = { + resource, + changes, + isArchived: () => _archived, + setArchived: (v: boolean) => { _archived = v; }, + isRead: () => true, + setRead: () => { }, + } as unknown as IAgentSession; + this._sessions.set(resource.toString(), session); + return session; + } + + updateSessionChanges(resource: URI, changes: readonly IChatSessionFileChange2[] | undefined): void { + const session = this._sessions.get(resource.toString()) as Record | undefined; + if (session) { + session.changes = changes; + } + } + + removeSession(resource: URI): void { + this._sessions.delete(resource.toString()); + } + + fireSessionArchivedState(session: IAgentSession): void { + this._onDidChangeSessionArchivedState.fire(session); + } + + fireSessionsChanged(): void { + this._onDidChangeSessions.fire(); + } + } + + setup(() => { + instantiationService = store.add(new TestInstantiationService()); + + commandService = new MockCommandService(); + instantiationService.stub(ICommandService, commandService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IGitHubService, new class extends mock() { }()); + instantiationService.stub(ISessionsManagementService, new class extends mock() { + override readonly activeSession = observableValue('test.activeSession', undefined); + }()); + + storageService = store.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); + + agentSessionsService = new MockAgentSessionsService(store); + instantiationService.stub(IAgentSessionsService, agentSessionsService); + + service = store.add(instantiationService.createInstance(CodeReviewService)); + session = URI.parse('test://session/1'); + fileA = URI.parse('file:///a.ts'); + fileB = URI.parse('file:///b.ts'); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + // --- getReviewState --- + + test('initial state is idle', () => { + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('getReviewState returns the same observable for the same session', () => { + const obs1 = service.getReviewState(session); + const obs2 = service.getReviewState(session); + assert.strictEqual(obs1, obs2); + }); + + test('getReviewState returns different observables for different sessions', () => { + const session2 = URI.parse('test://session/2'); + const obs1 = service.getReviewState(session); + const obs2 = service.getReviewState(session2); + assert.notStrictEqual(obs1, obs2); + }); + + // --- hasReview --- + + test('hasReview returns false when no review exists', () => { + assert.strictEqual(service.hasReview(session, 'v1'), false); + }); + + test('hasReview returns false when review is for a different version', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Wait for async command to complete + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + assert.strictEqual(service.hasReview(session, 'v2'), false); + }); + + test('hasReview returns true after successful review', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + }); + + // --- requestReview --- + + test('requestReview transitions to loading state', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + if (state.kind === CodeReviewStateKind.Loading) { + assert.strictEqual(state.version, 'v1'); + } + + // Resolve to avoid leaking + commandService.resolveExecution({ type: 'success', comments: [] }); + }); + + test('requestReview calls command with correct arguments', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [ + { currentUri: fileA, baseUri: fileB }, + { currentUri: fileB }, + ]); + + await tick(); + + assert.strictEqual(commandService.lastCommandId, 'chat.internal.codeReview.run'); + const args = commandService.lastArgs?.[0] as { files: { currentUri: URI; baseUri?: URI }[] }; + assert.strictEqual(args.files.length, 2); + assert.strictEqual(args.files[0].currentUri.toString(), fileA.toString()); + assert.strictEqual(args.files[0].baseUri?.toString(), fileB.toString()); + assert.strictEqual(args.files[1].currentUri.toString(), fileB.toString()); + assert.strictEqual(args.files[1].baseUri, undefined); + }); + + test('requestReview with success populates comments', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: fileA, + range: new Range(1, 1, 5, 1), + body: 'Bug found', + kind: 'bug', + severity: 'high', + }, + { + uri: fileB, + range: new Range(10, 1, 15, 1), + body: 'Style issue', + kind: 'style', + severity: 'low', + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }, { currentUri: fileB }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.comments.length, 2); + assert.strictEqual(state.comments[0].body, 'Bug found'); + assert.strictEqual(state.comments[0].kind, 'bug'); + assert.strictEqual(state.comments[0].severity, 'high'); + assert.strictEqual(state.comments[0].uri.toString(), fileA.toString()); + assert.strictEqual(state.comments[1].body, 'Style issue'); + } + }); + + test('requestReview with error transitions to error state', async () => { + commandService.result = { type: 'error', reason: 'Auth failed' }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Error); + if (state.kind === CodeReviewStateKind.Error) { + assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.reason, 'Auth failed'); + } + }); + + test('requestReview with cancelled result transitions to idle', async () => { + commandService.result = { type: 'cancelled' }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('requestReview with undefined result transitions to idle', async () => { + commandService.result = undefined; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('requestReview with thrown error transitions to error state', async () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + commandService.rejectExecution(new Error('Network error')); + + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Error); + if (state.kind === CodeReviewStateKind.Error) { + assert.ok(state.reason.includes('Network error')); + } + }); + + test('requestReview is a no-op when loading for the same version', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Attempt to request again for the same version + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Should still be loading (not re-triggered) + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + + commandService.resolveExecution({ type: 'success', comments: [] }); + }); + + test('requestReview is a no-op when result exists for the same version', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + // Attempt to request again + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // Should still have the result + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + }); + + test('requestReview for a new version replaces loading state', async () => { + // Start v1 review — it will complete immediately with empty result + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + + // Request v2 — since v1 is a different version, it should proceed + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'v2 comment' }] }; + service.requestReview(session, 'v2', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.version, 'v2'); + assert.strictEqual(state.comments.length, 1); + assert.strictEqual(state.comments[0].body, 'v2 comment'); + } + + // v1 is no longer valid + assert.strictEqual(service.hasReview(session, 'v1'), false); + }); + + // --- removeComment --- + + test('removeComment removes a specific comment', async () => { + commandService.result = { + type: 'success', + comments: [ + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }, + { uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' }, + { uri: fileB, range: new Range(10, 1, 10, 1), body: 'comment3' }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }, { currentUri: fileB }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind !== CodeReviewStateKind.Result) { return; } + + const commentToRemove = state.comments[1]; + service.removeComment(session, commentToRemove.id); + + const newState = service.getReviewState(session).get(); + assert.strictEqual(newState.kind, CodeReviewStateKind.Result); + if (newState.kind === CodeReviewStateKind.Result) { + assert.strictEqual(newState.comments.length, 2); + assert.strictEqual(newState.comments[0].body, 'comment1'); + assert.strictEqual(newState.comments[1].body, 'comment3'); + } + }); + + test('removeComment is a no-op for unknown comment id', async () => { + commandService.result = { + type: 'success', + comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + service.removeComment(session, 'nonexistent-id'); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments.length, 1); + } + }); + + test('removeComment is a no-op when no review exists', () => { + // Should not throw + service.removeComment(session, 'some-id'); + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + test('removeComment is a no-op when state is not result', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + // State is loading — removeComment should be ignored + service.removeComment(session, 'some-id'); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + + commandService.resolveExecution({ type: 'success', comments: [] }); + }); + + test('removeComment preserves version in result', async () => { + commandService.result = { + type: 'success', + comments: [ + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }, + { uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind !== CodeReviewStateKind.Result) { return; } + + service.removeComment(session, state.comments[0].id); + + const newState = service.getReviewState(session).get(); + if (newState.kind === CodeReviewStateKind.Result) { + assert.strictEqual(newState.version, 'v1'); + } + }); + + // --- dismissReview --- + + test('dismissReview resets to idle', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result); + + service.dismissReview(session); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + }); + + test('dismissReview while loading resets to idle', () => { + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Loading); + + service.dismissReview(session); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + + // Resolve the pending command — should be ignored since dismissed + commandService.resolveExecution({ type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'late' }] }); + }); + + test('dismissReview is a no-op when no data exists', () => { + // Should not throw + service.dismissReview(session); + }); + + test('hasReview returns false after dismissReview', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.hasReview(session, 'v1'), true); + + service.dismissReview(session); + + assert.strictEqual(service.hasReview(session, 'v1'), false); + }); + + // --- Isolation between sessions --- + + test('different sessions are independent', async () => { + const session2 = URI.parse('test://session/2'); + + commandService.result = { + type: 'success', + comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'session1 comment' }], + }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + commandService.result = { + type: 'success', + comments: [{ uri: fileB, range: new Range(2, 1, 2, 1), body: 'session2 comment' }], + }; + service.requestReview(session2, 'v2', [{ currentUri: fileB }]); + await tick(); + + const state1 = service.getReviewState(session).get(); + const state2 = service.getReviewState(session2).get(); + + assert.strictEqual(state1.kind, CodeReviewStateKind.Result); + assert.strictEqual(state2.kind, CodeReviewStateKind.Result); + + if (state1.kind === CodeReviewStateKind.Result && state2.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state1.comments[0].body, 'session1 comment'); + assert.strictEqual(state2.comments[0].body, 'session2 comment'); + } + + // Dismissing session1 doesn't affect session2 + service.dismissReview(session); + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + assert.strictEqual(service.getReviewState(session2).get().kind, CodeReviewStateKind.Result); + }); + + // --- Comment parsing --- + + test('comments with string URIs are parsed correctly', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: 'file:///parsed.ts', + range: new Range(1, 1, 1, 1), + body: 'parsed comment', + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].uri.toString(), 'file:///parsed.ts'); + } + }); + + test('comments with missing optional fields get defaults', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: fileA, + range: new Range(1, 1, 1, 1), + // body, kind, severity omitted + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].body, ''); + assert.strictEqual(state.comments[0].kind, ''); + assert.strictEqual(state.comments[0].severity, ''); + assert.strictEqual(state.comments[0].suggestion, undefined); + } + }); + + test('comments normalize VS Code API style ranges', async () => { + commandService.result = { + type: 'success', + comments: [ + { + uri: fileA, + range: { + start: { line: 4, character: 2 }, + end: { line: 6, character: 5 }, + }, + body: 'normalized comment', + suggestion: { + edits: [ + { + range: { + start: { line: 8, character: 1 }, + end: { line: 8, character: 9 }, + }, + oldText: 'let value', + newText: 'const value', + }, + ], + }, + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.deepStrictEqual(state.comments[0].range, new Range(5, 3, 7, 6)); + assert.deepStrictEqual(state.comments[0].suggestion?.edits[0].range, new Range(9, 2, 9, 10)); + } + }); + + test('comments normalize serialized URIs and tuple ranges from API payloads', async () => { + const serializedUri = JSON.parse(JSON.stringify(URI.parse('git:/c%3A/Code/vscode.worktrees/copilot-worktree-2026-03-04T14-44-38/src/vs/sessions/contrib/changesView/test/browser/codeReviewService.test.ts?%7B%22path%22%3A%22c%3A%5C%5CCode%5C%5Cvscode.worktrees%5C%5Ccopilot-worktree-2026-03-04T14-44-38%5C%5Csrc%5C%5Cvs%5C%5Csessions%5C%5Ccontrib%5C%5CchangesView%5C%5Ctest%5C%5Cbrowser%5C%5CcodeReviewService.test.ts%22%2C%22ref%22%3A%22copilot-worktree-2026-03-04T14-44-38%22%7D'))); + + commandService.result = { + type: 'success', + comments: [ + { + uri: serializedUri, + range: [ + { line: 72, character: 2 }, + { line: 72, character: 3 }, + ], + body: 'tuple range comment', + kind: 'bug', + severity: 'medium', + }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].uri.toString(), URI.revive(serializedUri).toString()); + assert.deepStrictEqual(state.comments[0].range, new Range(73, 3, 73, 4)); + } + }); + + test('each comment gets a unique id', async () => { + commandService.result = { + type: 'success', + comments: [ + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'a' }, + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'b' }, + ], + }; + + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind === CodeReviewStateKind.Result) { + assert.notStrictEqual(state.comments[0].id, state.comments[1].id); + } + }); + + // --- Observable reactivity --- + + test('observable fires on state transitions', async () => { + const states: string[] = []; + const obs = service.getReviewState(session); + + // Collect initial state + states.push(obs.get().kind); + + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + states.push(obs.get().kind); + + commandService.resolveExecution({ type: 'success', comments: [] }); + await tick(); + states.push(obs.get().kind); + + service.dismissReview(session); + states.push(obs.get().kind); + + assert.deepStrictEqual(states, [ + CodeReviewStateKind.Idle, + CodeReviewStateKind.Loading, + CodeReviewStateKind.Result, + CodeReviewStateKind.Idle, + ]); + }); + + // --- Storage persistence --- + + test('review results are persisted to storage', async () => { + commandService.result = { + type: 'success', + comments: [{ uri: fileA, range: new Range(1, 1, 5, 1), body: 'Persisted comment', kind: 'bug', severity: 'high' }], + }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const raw = storageService.get('codeReview.reviews', StorageScope.WORKSPACE); + assert.ok(raw, 'Storage should contain review data'); + const stored = JSON.parse(raw!); + const reviewData = stored[session.toString()]; + assert.ok(reviewData); + assert.strictEqual(reviewData.version, 'v1'); + assert.strictEqual(reviewData.comments.length, 1); + assert.strictEqual(reviewData.comments[0].body, 'Persisted comment'); + }); + + test('reviews are restored from storage on service creation', async () => { + commandService.result = { + type: 'success', + comments: [{ uri: fileA, range: new Range(1, 1, 5, 1), body: 'Restored comment', kind: 'bug', severity: 'high' }], + }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + // Create a second service with the same storage + const service2 = store.add(instantiationService.createInstance(CodeReviewService)); + const state = service2.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.comments.length, 1); + assert.strictEqual(state.comments[0].body, 'Restored comment'); + assert.strictEqual(state.comments[0].uri.toString(), fileA.toString()); + assert.deepStrictEqual(state.comments[0].range, { startLineNumber: 1, startColumn: 1, endLineNumber: 5, endColumn: 1 }); + } + }); + + test('suggestions are persisted and restored correctly', async () => { + commandService.result = { + type: 'success', + comments: [{ + uri: fileA, + range: new Range(1, 1, 5, 1), + body: 'suggestion comment', + suggestion: { + edits: [{ + range: new Range(2, 1, 3, 10), + oldText: 'let x = 1;', + newText: 'const x = 1;', + }], + }, + }], + }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const service2 = store.add(instantiationService.createInstance(CodeReviewService)); + const state = service2.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].suggestion?.edits.length, 1); + assert.strictEqual(state.comments[0].suggestion?.edits[0].oldText, 'let x = 1;'); + assert.strictEqual(state.comments[0].suggestion?.edits[0].newText, 'const x = 1;'); + } + }); + + test('removeComment updates storage', async () => { + commandService.result = { + type: 'success', + comments: [ + { uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment1' }, + { uri: fileA, range: new Range(5, 1, 5, 1), body: 'comment2' }, + ], + }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const state = service.getReviewState(session).get(); + if (state.kind !== CodeReviewStateKind.Result) { return; } + + service.removeComment(session, state.comments[0].id); + + const raw = storageService.get('codeReview.reviews', StorageScope.WORKSPACE); + const stored = JSON.parse(raw!); + assert.strictEqual(stored[session.toString()].comments.length, 1); + assert.strictEqual(stored[session.toString()].comments[0].body, 'comment2'); + }); + + test('dismissReview removes session from storage', async () => { + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'c' }] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.ok(storageService.get('codeReview.reviews', StorageScope.WORKSPACE)); + + service.dismissReview(session); + + assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined); + }); + + test('corrupted storage is handled gracefully', () => { + storageService.store('codeReview.reviews', 'not-valid-json{{{', StorageScope.WORKSPACE, StorageTarget.MACHINE); + + const service2 = store.add(instantiationService.createInstance(CodeReviewService)); + const state = service2.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Idle); + }); + + // --- Session lifecycle cleanup --- + + test('archived session reviews are cleaned up', async () => { + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result); + + const mockSession = agentSessionsService.setSession(session, undefined, true); + agentSessionsService.fireSessionArchivedState(mockSession); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined); + }); + + test('non-archived session change does not clean up review', async () => { + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + const mockSession = agentSessionsService.setSession(session, undefined, false); + agentSessionsService.fireSessionArchivedState(mockSession); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result); + }); + + test('session with changed version has review cleaned up', async () => { + const changes: IChatSessionFileChange2[] = [ + { uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 }, + ]; + agentSessionsService.setSession(session, changes); + + const files = getCodeReviewFilesFromSessionChanges(changes); + const version = getCodeReviewVersion(files); + + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'stale comment' }] }; + service.requestReview(session, version, files); + await tick(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result); + + const newChanges: IChatSessionFileChange2[] = [ + { uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 }, + { uri: fileB, modifiedUri: fileB, insertions: 2, deletions: 0 }, + ]; + agentSessionsService.updateSessionChanges(session, newChanges); + agentSessionsService.fireSessionsChanged(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + assert.strictEqual(storageService.get('codeReview.reviews', StorageScope.WORKSPACE), undefined); + }); + + test('session that no longer exists has review cleaned up', async () => { + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'orphaned comment' }] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Result); + + agentSessionsService.fireSessionsChanged(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + }); + + test('session with no changes has review cleaned up', async () => { + agentSessionsService.setSession(session, [ + { uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 }, + ]); + + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'comment' }] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + agentSessionsService.updateSessionChanges(session, undefined); + agentSessionsService.fireSessionsChanged(); + + assert.strictEqual(service.getReviewState(session).get().kind, CodeReviewStateKind.Idle); + }); + + test('session with matching version keeps review intact', async () => { + const changes: IChatSessionFileChange2[] = [ + { uri: fileA, modifiedUri: fileA, insertions: 1, deletions: 0 }, + ]; + agentSessionsService.setSession(session, changes); + + const files = getCodeReviewFilesFromSessionChanges(changes); + const version = getCodeReviewVersion(files); + + commandService.result = { type: 'success', comments: [{ uri: fileA, range: new Range(1, 1, 1, 1), body: 'valid comment' }] }; + service.requestReview(session, version, files); + await tick(); + + agentSessionsService.fireSessionsChanged(); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments[0].body, 'valid comment'); + } + }); +}); + +function tick(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index dc83de217d1..22e2a3bd28d 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -8,14 +8,22 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; Registry.as(Extensions.Configuration).registerDefaultConfigurations([{ overrides: { + 'chat.experimentalSessionsWindowOverride': true, + 'chat.hookFilesLocations': { + '.claude/settings.local.json': false, + '.claude/settings.json': false, + '~/.claude/settings.json': false, + }, 'chat.agent.maxRequests': 1000, 'chat.customizationsMenu.userStoragePath': '~/.copilot', 'chat.viewSessions.enabled': false, 'chat.implicitContext.suggestedContext': false, + 'chat.implicitContext.enabled': { 'panel': 'never' }, + 'chat.tools.terminal.enableAutoApprove': true, + 'github.copilot.chat.githubMcpServer.enabled': true, 'breadcrumbs.enabled': false, - 'diffEditor.renderSideBySide': false, 'diffEditor.hideUnchangedRegions.enabled': true, 'extensions.ignoreRecommendations': true, @@ -23,9 +31,15 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'files.autoSave': 'afterDelay', 'git.autofetch': true, + 'git.branchRandomName.enable': true, 'git.detectWorktrees': false, 'git.showProgress': false, + 'github.copilot.enable': { + 'markdown': true, + 'plaintext': true, + }, + 'github.copilot.chat.claudeCode.enabled': true, 'github.copilot.chat.cli.branchSupport.enabled': true, 'github.copilot.chat.languageContext.typescript.enabled': true, @@ -36,13 +50,15 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'terminal.integrated.initialHint': false, + 'workbench.editor.doubleClickTabToToggleEditorGroupSizes': 'maximize', 'workbench.editor.restoreEditors': false, - 'workbench.editor.showTabs': 'single', 'workbench.startupEditor': 'none', 'workbench.tips.enabled': false, 'workbench.layoutControl.type': 'toggles', 'workbench.editor.useModal': 'all', 'workbench.panel.showLabels': false, + 'workbench.colorTheme': 'Experimental Dark', + 'search.quickOpen.includeHistory': false, 'window.menuStyle': 'custom', 'window.dialogStyle': 'custom', diff --git a/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts index ea04197d42d..5f6d33c46a3 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts @@ -138,7 +138,13 @@ export class GitHubFileSystemProvider extends Disposable implements IFileSystemP // --- GitHub API private async getAuthToken(): Promise { - const sessions = await this.authenticationService.getSessions('github', ['repo'], { silent: true }) ?? await this.authenticationService.getSessions('github', ['repo'], { createIfNone: true }); + let sessions = await this.authenticationService.getSessions('github', [], { silent: true }); + if (!sessions || sessions.length === 0) { + sessions = await this.authenticationService.getSessions('github', [], { createIfNone: true }); + } + if (!sessions || sessions.length === 0) { + throw createFileSystemProviderError('No GitHub authentication sessions available', FileSystemProviderErrorCode.Unavailable); + } return sessions[0].accessToken ?? ''; } diff --git a/src/vs/sessions/contrib/gitSync/browser/gitSync.contribution.ts b/src/vs/sessions/contrib/git/browser/git.contribution.ts similarity index 99% rename from src/vs/sessions/contrib/gitSync/browser/gitSync.contribution.ts rename to src/vs/sessions/contrib/git/browser/git.contribution.ts index 2aed42bfd4c..9aaaac7acb3 100644 --- a/src/vs/sessions/contrib/gitSync/browser/gitSync.contribution.ts +++ b/src/vs/sessions/contrib/git/browser/git.contribution.ts @@ -73,6 +73,7 @@ class GitSyncContribution extends Disposable implements IWorkbenchContribution { const behind = head.behind ?? 0; const hasSyncChanges = ahead > 0 || behind > 0; contextKey.set(hasSyncChanges); + this._syncActionDisposable.clear(); this._syncActionDisposable.value = registerSyncAction(behind, ahead, isSyncing, (syncing) => { this._isSyncing.set(syncing, undefined); }); diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts new file mode 100644 index 00000000000..d667c6a3a3e --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubPRCIFetcher.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, IGitHubCICheck } from '../../common/types.js'; +import { GitHubApiClient } from '../githubApiClient.js'; + +//#region GitHub API response types + +interface IGitHubCheckRunResponse { + readonly id: number; + readonly name: string; + readonly status: string; + readonly conclusion: string | null; + readonly started_at: string | null; + readonly completed_at: string | null; + readonly details_url: string | null; +} + +interface IGitHubCheckRunsListResponse { + readonly total_count: number; + readonly check_runs: readonly IGitHubCheckRunResponse[]; +} + +interface IGitHubCheckRunAnnotationResponse { + readonly path: string; + readonly start_line: number; + readonly end_line: number; + readonly annotation_level: string; + readonly message: string; + readonly title: string | null; +} + +interface IGitHubCheckRunDetailResponse { + readonly id: number; + readonly name: string; + readonly details_url: string | null; + readonly app: { + readonly slug: string; + } | null; + readonly output: { + readonly title: string | null; + readonly summary: string | null; + readonly text: string | null; + readonly annotations_count: number; + }; +} + +//#endregion + +/** + * Stateless fetcher for GitHub CI check data (check runs, check suites). + * All methods return raw typed data with no caching or state. + */ +export class GitHubPRCIFetcher { + + constructor( + private readonly _apiClient: GitHubApiClient, + ) { } + + async getCheckRuns(owner: string, repo: string, ref: string): Promise { + const data = await this._apiClient.request( + 'GET', + `/repos/${e(owner)}/${e(repo)}/commits/${e(ref)}/check-runs`, + ); + return data.check_runs.map(mapCheckRun); + } + + /** + * Get logs/output for a specific check run. + * + * Tries multiple sources in order: + * 1. The check run's own output fields (title, summary, text) — set by the + * check run creator via the Checks API. + * 2. Annotations attached to the check run. + * 3. GitHub Actions job logs (only works for GitHub Actions workflows). + */ + async getCheckRunAnnotations(owner: string, repo: string, checkRunId: number): Promise { + const sections: string[] = []; + let detail: IGitHubCheckRunDetailResponse | undefined; + + // 1. Fetch check run detail for output fields + try { + detail = await this._apiClient.request( + 'GET', + `/repos/${e(owner)}/${e(repo)}/check-runs/${checkRunId}`, + ); + const output = detail.output; + if (output.title) { + sections.push(`# ${output.title}`); + } + if (output.summary) { + sections.push(output.summary); + } + if (output.text) { + sections.push(output.text); + } + } catch { + // Ignore — output may not be available + } + + // 2. Fetch annotations + try { + const annotations = await this._apiClient.request( + 'GET', + `/repos/${e(owner)}/${e(repo)}/check-runs/${checkRunId}/annotations`, + ); + if (annotations.length > 0) { + sections.push( + annotations.map(a => + `[${a.annotation_level}] ${a.path}:${a.start_line}${a.end_line !== a.start_line ? `-${a.end_line}` : ''} ${a.title ? `(${a.title}) ` : ''}${a.message}` + ).join('\n') + ); + } + } catch { + // Ignore — annotations may not be available + } + + if (sections.length > 0) { + return sections.join('\n\n'); + } + + return 'No output available for this check run.'; + } +} + +//#region Helpers + +function e(value: string): string { + return encodeURIComponent(value); +} + +function mapCheckRun(data: IGitHubCheckRunResponse): IGitHubCICheck { + return { + id: data.id, + name: data.name, + status: mapCheckStatus(data.status), + conclusion: data.conclusion ? mapCheckConclusion(data.conclusion) : undefined, + startedAt: data.started_at ?? undefined, + completedAt: data.completed_at ?? undefined, + detailsUrl: data.details_url ?? undefined, + }; +} + +function mapCheckStatus(status: string): GitHubCheckStatus { + switch (status) { + case 'queued': return GitHubCheckStatus.Queued; + case 'in_progress': return GitHubCheckStatus.InProgress; + case 'completed': return GitHubCheckStatus.Completed; + default: return GitHubCheckStatus.Queued; + } +} + +function mapCheckConclusion(conclusion: string): GitHubCheckConclusion { + switch (conclusion) { + case 'success': return GitHubCheckConclusion.Success; + case 'failure': return GitHubCheckConclusion.Failure; + case 'neutral': return GitHubCheckConclusion.Neutral; + case 'cancelled': return GitHubCheckConclusion.Cancelled; + case 'skipped': return GitHubCheckConclusion.Skipped; + case 'timed_out': return GitHubCheckConclusion.TimedOut; + case 'action_required': return GitHubCheckConclusion.ActionRequired; + case 'stale': return GitHubCheckConclusion.Stale; + default: return GitHubCheckConclusion.Neutral; + } +} + +/** + * Compute an overall CI status from a list of check runs. + */ +export function computeOverallCIStatus(checks: readonly IGitHubCICheck[]): GitHubCIOverallStatus { + if (checks.length === 0) { + return GitHubCIOverallStatus.Neutral; + } + + let hasFailure = false; + let hasPending = false; + + for (const check of checks) { + if (check.status !== GitHubCheckStatus.Completed) { + hasPending = true; + continue; + } + if (check.conclusion === GitHubCheckConclusion.Failure || + check.conclusion === GitHubCheckConclusion.TimedOut || + check.conclusion === GitHubCheckConclusion.ActionRequired) { + hasFailure = true; + } + } + + if (hasFailure) { + return GitHubCIOverallStatus.Failure; + } + if (hasPending) { + return GitHubCIOverallStatus.Pending; + } + return GitHubCIOverallStatus.Success; +} + +//#endregion diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts new file mode 100644 index 00000000000..5711a3d9ff8 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubPRFetcher.ts @@ -0,0 +1,362 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + GitHubPullRequestState, + IGitHubPRComment, + IGitHubPRReviewThread, + IGitHubPullRequest, + IGitHubPullRequestMergeability, + IGitHubUser, + IMergeBlocker, + MergeBlockerKind, +} from '../../common/types.js'; +import { GitHubApiClient } from '../githubApiClient.js'; + +//#region GitHub API response types + +interface IGitHubPRResponse { + readonly number: number; + readonly title: string; + readonly body: string | null; + readonly state: 'open' | 'closed'; + readonly draft: boolean; + readonly user: { readonly login: string; readonly avatar_url: string }; + readonly head: { readonly ref: string }; + readonly base: { readonly ref: string }; + readonly created_at: string; + readonly updated_at: string; + readonly merged_at: string | null; + readonly mergeable: boolean | null; + readonly mergeable_state: string; + readonly merged: boolean; +} + +interface IGitHubReviewResponse { + readonly id: number; + readonly user: { readonly login: string; readonly avatar_url: string }; + readonly state: string; + readonly submitted_at: string; +} + +interface IGitHubReviewCommentResponse { + readonly id: number; + readonly body: string; + readonly user: { readonly login: string; readonly avatar_url: string }; + readonly created_at: string; + readonly updated_at: string; + readonly path: string; + readonly line: number | null; + readonly original_line: number | null; + readonly in_reply_to_id?: number; +} + +interface IGitHubIssueCommentResponse { + readonly id: number; + readonly body: string | null; + readonly user: { readonly login: string; readonly avatar_url: string }; + readonly created_at: string; + readonly updated_at: string; +} + +interface IGitHubGraphQLPullRequestReviewThreadsResponse { + readonly repository: { + readonly pullRequest: { + readonly reviewThreads: { + readonly nodes: readonly IGitHubGraphQLReviewThreadNode[]; + }; + } | null; + } | null; +} + +interface IGitHubGraphQLReviewThreadNode { + readonly id: string; + readonly isResolved: boolean; + readonly path: string; + readonly line: number | null; + readonly comments: { + readonly nodes: readonly IGitHubGraphQLReviewCommentNode[]; + }; +} + +interface IGitHubGraphQLReviewCommentNode { + readonly databaseId: number | null; + readonly body: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly path: string | null; + readonly line: number | null; + readonly originalLine: number | null; + readonly replyTo: { readonly databaseId: number | null } | null; + readonly author: { readonly login: string; readonly avatarUrl: string } | null; +} + +interface IGitHubGraphQLResolveReviewThreadResponse { + readonly resolveReviewThread: { + readonly thread: { + readonly isResolved: boolean; + } | null; + } | null; +} + +//#endregion + +const GET_REVIEW_THREADS_QUERY = [ + 'query GetReviewThreads($owner: String!, $repo: String!, $prNumber: Int!) {', + ' repository(owner: $owner, name: $repo) {', + ' pullRequest(number: $prNumber) {', + ' reviewThreads(first: 100) {', + ' nodes {', + ' id', + ' isResolved', + ' path', + ' line', + ' comments(first: 100) {', + ' nodes {', + ' databaseId', + ' body', + ' createdAt', + ' updatedAt', + ' path', + ' line', + ' originalLine', + ' replyTo {', + ' databaseId', + ' }', + ' author {', + ' login', + ' avatarUrl', + ' }', + ' }', + ' }', + ' }', + ' }', + ' }', + ' }', + '}', +].join('\n'); + +const RESOLVE_REVIEW_THREAD_MUTATION = [ + 'mutation ResolveReviewThread($threadId: ID!) {', + ' resolveReviewThread(input: { threadId: $threadId }) {', + ' thread {', + ' isResolved', + ' }', + ' }', + '}', +].join('\n'); + +/** + * Stateless fetcher for GitHub pull request data. + * Handles all PR-related REST API calls including reviews, comments, and mergeability. + */ +export class GitHubPRFetcher { + + constructor( + private readonly _apiClient: GitHubApiClient, + ) { } + + async getPullRequest(owner: string, repo: string, prNumber: number): Promise { + const data = await this._apiClient.request( + 'GET', + `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}`, + ); + return mapPullRequest(data); + } + + async getMergeability(owner: string, repo: string, prNumber: number): Promise { + const [pr, reviews] = await Promise.all([ + this._apiClient.request('GET', `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}`), + this._apiClient.request('GET', `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}/reviews`), + ]); + + const blockers: IMergeBlocker[] = []; + + // Draft + if (pr.draft) { + blockers.push({ kind: MergeBlockerKind.Draft, description: 'Pull request is a draft' }); + } + + // Merge conflicts + if (pr.mergeable === false) { + blockers.push({ kind: MergeBlockerKind.Conflicts, description: 'Pull request has merge conflicts' }); + } + + // Changes requested — check most recent review per reviewer + const latestReviewByUser = new Map(); + for (const review of reviews) { + if (review.state === 'APPROVED' || review.state === 'CHANGES_REQUESTED' || review.state === 'DISMISSED') { + latestReviewByUser.set(review.user.login, review.state); + } + } + const hasChangesRequested = [...latestReviewByUser.values()].some(s => s === 'CHANGES_REQUESTED'); + if (hasChangesRequested) { + blockers.push({ kind: MergeBlockerKind.ChangesRequested, description: 'Changes have been requested' }); + } + + // Approval needed — check mergeable_state + if (pr.mergeable_state === 'blocked') { + const hasApproval = [...latestReviewByUser.values()].some(s => s === 'APPROVED'); + if (!hasApproval) { + blockers.push({ kind: MergeBlockerKind.ApprovalNeeded, description: 'Approval is required' }); + } + } + + // CI failures — mergeable_state 'unstable' indicates check failures + if (pr.mergeable_state === 'unstable') { + blockers.push({ kind: MergeBlockerKind.CIFailed, description: 'CI checks have failed' }); + } + + return { + canMerge: blockers.length === 0 && pr.mergeable !== false && pr.state === 'open', + blockers, + }; + } + + async getReviewThreads(owner: string, repo: string, prNumber: number): Promise { + const data = await this._apiClient.graphql( + GET_REVIEW_THREADS_QUERY, + { owner, repo, prNumber }, + ); + + const reviewThreads = data.repository?.pullRequest?.reviewThreads.nodes; + if (!reviewThreads) { + throw new Error(`Pull request not found: ${owner}/${repo}#${prNumber}`); + } + + return reviewThreads.map(mapReviewThread); + } + + async postReviewComment( + owner: string, + repo: string, + prNumber: number, + body: string, + inReplyTo: number, + ): Promise { + const data = await this._apiClient.request( + 'POST', + `/repos/${e(owner)}/${e(repo)}/pulls/${prNumber}/comments`, + { body, in_reply_to: inReplyTo }, + ); + return mapReviewComment(data); + } + + async postIssueComment( + owner: string, + repo: string, + prNumber: number, + body: string, + ): Promise { + const data = await this._apiClient.request( + 'POST', + `/repos/${e(owner)}/${e(repo)}/issues/${prNumber}/comments`, + { body }, + ); + return { + id: data.id, + body: data.body ?? '', + author: mapUser(data.user), + createdAt: data.created_at, + updatedAt: data.updated_at, + path: undefined, + line: undefined, + threadId: String(data.id), + inReplyToId: undefined, + }; + } + + async resolveThread(_owner: string, _repo: string, threadId: string): Promise { + const data = await this._apiClient.graphql( + RESOLVE_REVIEW_THREAD_MUTATION, + { threadId }, + ); + + if (!data.resolveReviewThread?.thread?.isResolved) { + throw new Error(`Failed to resolve review thread ${threadId}`); + } + } +} + +//#region Helpers + +function e(value: string): string { + return encodeURIComponent(value); +} + +function mapUser(user: { readonly login: string; readonly avatar_url: string }): IGitHubUser { + return { login: user.login, avatarUrl: user.avatar_url }; +} + +function mapPullRequest(data: IGitHubPRResponse): IGitHubPullRequest { + let state: GitHubPullRequestState; + if (data.merged) { + state = GitHubPullRequestState.Merged; + } else if (data.state === 'closed') { + state = GitHubPullRequestState.Closed; + } else { + state = GitHubPullRequestState.Open; + } + + return { + number: data.number, + title: data.title, + body: data.body ?? '', + state, + author: mapUser(data.user), + headRef: data.head.ref, + baseRef: data.base.ref, + isDraft: data.draft, + createdAt: data.created_at, + updatedAt: data.updated_at, + mergedAt: data.merged_at ?? undefined, + mergeable: data.mergeable ?? undefined, + mergeableState: data.mergeable_state, + }; +} + +function mapReviewComment(data: IGitHubReviewCommentResponse): IGitHubPRComment { + return { + id: data.id, + body: data.body, + author: mapUser(data.user), + createdAt: data.created_at, + updatedAt: data.updated_at, + path: data.path, + line: data.line ?? data.original_line ?? undefined, + threadId: String(data.in_reply_to_id ?? data.id), + inReplyToId: data.in_reply_to_id, + }; +} + +function mapReviewThread(thread: IGitHubGraphQLReviewThreadNode): IGitHubPRReviewThread { + return { + id: thread.id, + isResolved: thread.isResolved, + path: thread.path, + line: thread.line ?? undefined, + comments: thread.comments.nodes.flatMap(comment => mapGraphQLReviewComment(comment, thread)), + }; +} + +function mapGraphQLReviewComment(comment: IGitHubGraphQLReviewCommentNode, thread: IGitHubGraphQLReviewThreadNode): readonly IGitHubPRComment[] { + if (comment.databaseId === null || comment.author === null) { + return []; + } + + return [{ + id: comment.databaseId, + body: comment.body, + author: { login: comment.author.login, avatarUrl: comment.author.avatarUrl }, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + path: comment.path ?? thread.path, + line: comment.line ?? comment.originalLine ?? thread.line ?? undefined, + threadId: thread.id, + inReplyToId: comment.replyTo?.databaseId ?? undefined, + }]; +} + +//#endregion diff --git a/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts b/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts new file mode 100644 index 00000000000..2b57fbd3db3 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/fetchers/githubRepositoryFetcher.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IGitHubRepository } from '../../common/types.js'; +import { GitHubApiClient } from '../githubApiClient.js'; + +interface IGitHubRepoResponse { + readonly name: string; + readonly full_name: string; + readonly owner: { readonly login: string }; + readonly default_branch: string; + readonly private: boolean; + readonly description: string | null; +} + +/** + * Stateless fetcher for GitHub repository data. + * All methods return raw typed data with no caching or state. + */ +export class GitHubRepositoryFetcher { + + constructor( + private readonly _apiClient: GitHubApiClient, + ) { } + + async getRepository(owner: string, repo: string): Promise { + const data = await this._apiClient.request( + 'GET', + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + ); + return { + owner: data.owner.login, + name: data.name, + fullName: data.full_name, + defaultBranch: data.default_branch, + isPrivate: data.private, + description: data.description ?? '', + }; + } +} diff --git a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts b/src/vs/sessions/contrib/github/browser/github.contribution.ts similarity index 57% rename from src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts rename to src/vs/sessions/contrib/github/browser/github.contribution.ts index 99549752c8a..af80df37852 100644 --- a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts +++ b/src/vs/sessions/contrib/github/browser/github.contribution.ts @@ -3,9 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { HoldToSpeak } from './inlineChatActions.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { GitHubService, IGitHubService } from './githubService.js'; -// start and hold for voice - -registerAction2(HoldToSpeak); +registerSingleton(IGitHubService, GitHubService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/github/browser/githubApiClient.ts b/src/vs/sessions/contrib/github/browser/githubApiClient.ts new file mode 100644 index 00000000000..e5279b02196 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/githubApiClient.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IRequestService, asJson } from '../../../../platform/request/common/request.js'; +import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; + +const LOG_PREFIX = '[GitHubApiClient]'; +const GITHUB_API_BASE = 'https://api.github.com'; +const GITHUB_GRAPHQL_ENDPOINT = `${GITHUB_API_BASE}/graphql`; + +interface IGitHubGraphQLError { + readonly message: string; +} + +interface IGitHubGraphQLResponse { + readonly data?: T; + readonly errors?: readonly IGitHubGraphQLError[]; +} + +export class GitHubApiError extends Error { + constructor( + message: string, + readonly statusCode: number, + readonly rateLimitRemaining: number | undefined, + ) { + super(message); + this.name = 'GitHubApiError'; + } +} + +/** + * Low-level GitHub REST API client. Handles authentication, + * request construction, and error classification. + * + * This class is stateless with respect to domain data — it only + * manages auth tokens and raw HTTP communication. + */ +export class GitHubApiClient extends Disposable { + + constructor( + @IRequestService private readonly _requestService: IRequestService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + async request(method: string, path: string, body?: unknown): Promise { + return this._request(method, `${GITHUB_API_BASE}${path}`, path, 'application/vnd.github.v3+json', body); + } + + async graphql(query: string, variables?: Record): Promise { + const response = await this._request>( + 'POST', + GITHUB_GRAPHQL_ENDPOINT, + '/graphql', + 'application/vnd.github+json', + { query, variables }, + ); + + if (response.errors?.length) { + throw new GitHubApiError( + response.errors.map(error => error.message).join('; '), + 200, + undefined, + ); + } + + if (!response.data) { + throw new GitHubApiError('GitHub GraphQL response did not include data', 200, undefined); + } + + return response.data; + } + + private async _request(method: string, url: string, pathForLogging: string, accept: string, body?: unknown): Promise { + const token = await this._getAuthToken(); + + this._logService.trace(`${LOG_PREFIX} ${method} ${pathForLogging}`); + + const response = await this._requestService.request({ + type: method, + url, + headers: { + 'Authorization': `token ${token}`, + 'Accept': accept, + 'User-Agent': 'VSCode-Sessions-GitHub', + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + }, + data: body !== undefined ? JSON.stringify(body) : undefined, + }, CancellationToken.None); + + const rateLimitRemaining = parseRateLimitHeader(response.res.headers?.['x-ratelimit-remaining']); + if (rateLimitRemaining !== undefined && rateLimitRemaining < 100) { + this._logService.warn(`${LOG_PREFIX} GitHub API rate limit low: ${rateLimitRemaining} remaining`); + } + + const statusCode = response.res.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + const errorBody = await asJson<{ message?: string }>(response).catch(() => undefined); + throw new GitHubApiError( + errorBody?.message ?? `GitHub API request failed: ${method} ${pathForLogging} (${statusCode})`, + statusCode, + rateLimitRemaining, + ); + } + + if (statusCode === 204) { + return undefined as unknown as T; + } + + const data = await asJson(response); + if (!data) { + throw new GitHubApiError( + `Failed to parse response for ${method} ${pathForLogging}`, + statusCode, + rateLimitRemaining, + ); + } + + return data; + } + + private async _getAuthToken(): Promise { + let sessions = await this._authenticationService.getSessions('github', [], { silent: true }); + if (!sessions || sessions.length === 0) { + sessions = await this._authenticationService.getSessions('github', [], { createIfNone: true }); + } + if (!sessions || sessions.length === 0) { + throw new Error('No GitHub authentication sessions available'); + } + return sessions[0].accessToken ?? ''; + } +} + +function parseRateLimitHeader(value: string | string[] | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const str = Array.isArray(value) ? value[0] : value; + const parsed = parseInt(str, 10); + return isNaN(parsed) ? undefined : parsed; +} diff --git a/src/vs/sessions/contrib/github/browser/githubService.ts b/src/vs/sessions/contrib/github/browser/githubService.ts new file mode 100644 index 00000000000..ac6a5ab7de8 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/githubService.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { GitHubApiClient } from './githubApiClient.js'; +import { GitHubRepositoryFetcher } from './fetchers/githubRepositoryFetcher.js'; +import { GitHubPRFetcher } from './fetchers/githubPRFetcher.js'; +import { GitHubPRCIFetcher } from './fetchers/githubPRCIFetcher.js'; +import { GitHubRepositoryModel } from './models/githubRepositoryModel.js'; +import { GitHubPullRequestModel } from './models/githubPullRequestModel.js'; +import { GitHubPullRequestCIModel } from './models/githubPullRequestCIModel.js'; + +export interface IGitHubService { + readonly _serviceBrand: undefined; + + /** + * Get or create a reactive model for a GitHub repository. + * The model is cached by owner/repo key and disposed when the service is disposed. + */ + getRepository(owner: string, repo: string): GitHubRepositoryModel; + + /** + * Get or create a reactive model for a GitHub pull request. + * The model is cached by owner/repo/prNumber key and disposed when the service is disposed. + */ + getPullRequest(owner: string, repo: string, prNumber: number): GitHubPullRequestModel; + + /** + * Get or create a reactive model for CI checks on a pull request head ref. + * The model is cached by owner/repo/headRef key and disposed when the service is disposed. + */ + getPullRequestCI(owner: string, repo: string, headRef: string): GitHubPullRequestCIModel; +} + +export const IGitHubService = createDecorator('sessionsGitHubService'); + +const LOG_PREFIX = '[GitHubService]'; + +export class GitHubService extends Disposable implements IGitHubService { + + declare readonly _serviceBrand: undefined; + + private readonly _apiClient: GitHubApiClient; + private readonly _repoFetcher: GitHubRepositoryFetcher; + private readonly _prFetcher: GitHubPRFetcher; + private readonly _ciFetcher: GitHubPRCIFetcher; + + private readonly _repositories = this._register(new DisposableMap()); + private readonly _pullRequests = this._register(new DisposableMap()); + private readonly _ciModels = this._register(new DisposableMap()); + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + this._apiClient = this._register(instantiationService.createInstance(GitHubApiClient)); + this._repoFetcher = new GitHubRepositoryFetcher(this._apiClient); + this._prFetcher = new GitHubPRFetcher(this._apiClient); + this._ciFetcher = new GitHubPRCIFetcher(this._apiClient); + } + + getRepository(owner: string, repo: string): GitHubRepositoryModel { + const key = `${owner}/${repo}`; + let model = this._repositories.get(key); + if (!model) { + this._logService.trace(`${LOG_PREFIX} Creating repository model for ${key}`); + model = new GitHubRepositoryModel(owner, repo, this._repoFetcher, this._logService); + this._repositories.set(key, model); + } + return model; + } + + getPullRequest(owner: string, repo: string, prNumber: number): GitHubPullRequestModel { + const key = `${owner}/${repo}/${prNumber}`; + let model = this._pullRequests.get(key); + if (!model) { + this._logService.trace(`${LOG_PREFIX} Creating PR model for ${key}`); + model = new GitHubPullRequestModel(owner, repo, prNumber, this._prFetcher, this._logService); + this._pullRequests.set(key, model); + } + return model; + } + + getPullRequestCI(owner: string, repo: string, headRef: string): GitHubPullRequestCIModel { + const key = `${owner}/${repo}/${headRef}`; + let model = this._ciModels.get(key); + if (!model) { + this._logService.trace(`${LOG_PREFIX} Creating CI model for ${key}`); + model = new GitHubPullRequestCIModel(owner, repo, headRef, this._ciFetcher, this._logService); + this._ciModels.set(key, model); + } + return model; + } +} diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts new file mode 100644 index 00000000000..6a1dd490aaf --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestCIModel.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { GitHubCIOverallStatus, IGitHubCICheck } from '../../common/types.js'; +import { computeOverallCIStatus, GitHubPRCIFetcher } from '../fetchers/githubPRCIFetcher.js'; + +const LOG_PREFIX = '[GitHubPullRequestCIModel]'; +const DEFAULT_POLL_INTERVAL_MS = 60_000; + +/** + * Reactive model for CI check status on a pull request head ref. + * Wraps fetcher data in observables and supports periodic polling. + */ +export class GitHubPullRequestCIModel extends Disposable { + + private readonly _checks = observableValue(this, []); + readonly checks: IObservable = this._checks; + + private readonly _overallStatus = observableValue(this, GitHubCIOverallStatus.Neutral); + readonly overallStatus: IObservable = this._overallStatus; + + private readonly _pollScheduler: RunOnceScheduler; + private _disposed = false; + + constructor( + readonly owner: string, + readonly repo: string, + readonly headRef: string, + private readonly _fetcher: GitHubPRCIFetcher, + private readonly _logService: ILogService, + ) { + super(); + + this._pollScheduler = this._register(new RunOnceScheduler(() => this._poll(), DEFAULT_POLL_INTERVAL_MS)); + } + + /** + * Refresh all CI check data. + */ + async refresh(): Promise { + try { + const checks = await this._fetcher.getCheckRuns(this.owner, this.repo, this.headRef); + this._checks.set(checks, undefined); + this._overallStatus.set(computeOverallCIStatus(checks), undefined); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to refresh CI checks for ${this.owner}/${this.repo}@${this.headRef}:`, err); + } + } + + /** + * Get annotations (structured logs) for a specific check run. + */ + async getCheckRunAnnotations(checkRunId: number): Promise { + return this._fetcher.getCheckRunAnnotations(this.owner, this.repo, checkRunId); + } + + /** + * Start periodic polling. Each cycle refreshes CI check data. + */ + startPolling(intervalMs: number = DEFAULT_POLL_INTERVAL_MS): void { + this._pollScheduler.cancel(); + this._pollScheduler.schedule(intervalMs); + } + + /** + * Stop periodic polling. + */ + stopPolling(): void { + this._pollScheduler.cancel(); + } + + private async _poll(): Promise { + await this.refresh(); + // Re-schedule if not disposed (RunOnceScheduler is one-shot) + if (!this._disposed) { + this._pollScheduler.schedule(); + } + } + + override dispose(): void { + this._disposed = true; + super.dispose(); + } +} diff --git a/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts new file mode 100644 index 00000000000..8c5a667460c --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/models/githubPullRequestModel.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../../base/common/async.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IGitHubPRComment, IGitHubPRReviewThread, IGitHubPullRequest, IGitHubPullRequestMergeability } from '../../common/types.js'; +import { GitHubPRFetcher } from '../fetchers/githubPRFetcher.js'; + +const LOG_PREFIX = '[GitHubPullRequestModel]'; +const DEFAULT_POLL_INTERVAL_MS = 30_000; + +/** + * Reactive model for a GitHub pull request. Wraps fetcher data in + * observables, supports on-demand refresh, and can poll periodically. + */ +export class GitHubPullRequestModel extends Disposable { + + private readonly _pullRequest = observableValue(this, undefined); + readonly pullRequest: IObservable = this._pullRequest; + + private readonly _mergeability = observableValue(this, undefined); + readonly mergeability: IObservable = this._mergeability; + + private readonly _reviewThreads = observableValue(this, []); + readonly reviewThreads: IObservable = this._reviewThreads; + + private readonly _pollScheduler: RunOnceScheduler; + private _disposed = false; + + constructor( + readonly owner: string, + readonly repo: string, + readonly prNumber: number, + private readonly _fetcher: GitHubPRFetcher, + private readonly _logService: ILogService, + ) { + super(); + + this._pollScheduler = this._register(new RunOnceScheduler(() => this._poll(), DEFAULT_POLL_INTERVAL_MS)); + } + + /** + * Refresh all PR data: pull request info, mergeability, and review threads. + */ + async refresh(): Promise { + await Promise.all([ + this._refreshPullRequest(), + this._refreshMergeability(), + this._refreshThreads(), + ]); + } + + /** + * Refresh only the review threads. + */ + async refreshThreads(): Promise { + await this._refreshThreads(); + } + + /** + * Post a reply to an existing review thread and refresh threads. + */ + async postReviewComment(body: string, inReplyTo: number): Promise { + const comment = await this._fetcher.postReviewComment(this.owner, this.repo, this.prNumber, body, inReplyTo); + await this._refreshThreads(); + return comment; + } + + /** + * Post a top-level issue comment on the PR. + */ + async postIssueComment(body: string): Promise { + return this._fetcher.postIssueComment(this.owner, this.repo, this.prNumber, body); + } + + /** + * Resolve a review thread and refresh the thread list. + */ + async resolveThread(threadId: string): Promise { + await this._fetcher.resolveThread(this.owner, this.repo, threadId); + await this._refreshThreads(); + } + + /** + * Start periodic polling. Each cycle refreshes all PR data. + */ + startPolling(intervalMs: number = DEFAULT_POLL_INTERVAL_MS): void { + this._pollScheduler.cancel(); + this._pollScheduler.schedule(intervalMs); + } + + /** + * Stop periodic polling. + */ + stopPolling(): void { + this._pollScheduler.cancel(); + } + + private async _poll(): Promise { + await this.refresh(); + // Re-schedule for next poll cycle (RunOnceScheduler is one-shot) + if (!this._disposed) { + this._pollScheduler.schedule(); + } + } + + override dispose(): void { + this._disposed = true; + super.dispose(); + } + + private async _refreshPullRequest(): Promise { + try { + const data = await this._fetcher.getPullRequest(this.owner, this.repo, this.prNumber); + this._pullRequest.set(data, undefined); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to refresh PR #${this.prNumber}:`, err); + } + } + + private async _refreshMergeability(): Promise { + try { + const data = await this._fetcher.getMergeability(this.owner, this.repo, this.prNumber); + this._mergeability.set(data, undefined); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to refresh mergeability for PR #${this.prNumber}:`, err); + } + } + + private async _refreshThreads(): Promise { + try { + const data = await this._fetcher.getReviewThreads(this.owner, this.repo, this.prNumber); + this._reviewThreads.set(data, undefined); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to refresh threads for PR #${this.prNumber}:`, err); + } + } +} diff --git a/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts b/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts new file mode 100644 index 00000000000..9e2c368a329 --- /dev/null +++ b/src/vs/sessions/contrib/github/browser/models/githubRepositoryModel.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IGitHubRepository } from '../../common/types.js'; +import { GitHubRepositoryFetcher } from '../fetchers/githubRepositoryFetcher.js'; + +const LOG_PREFIX = '[GitHubRepositoryModel]'; + +/** + * Reactive model for a GitHub repository. Wraps fetcher data + * in observables and supports on-demand refresh. + */ +export class GitHubRepositoryModel extends Disposable { + + private readonly _repository = observableValue(this, undefined); + readonly repository: IObservable = this._repository; + + constructor( + readonly owner: string, + readonly repo: string, + private readonly _fetcher: GitHubRepositoryFetcher, + private readonly _logService: ILogService, + ) { + super(); + } + + async refresh(): Promise { + try { + const data = await this._fetcher.getRepository(this.owner, this.repo); + this._repository.set(data, undefined); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to refresh repository ${this.owner}/${this.repo}:`, err); + } + } +} diff --git a/src/vs/sessions/contrib/github/common/types.ts b/src/vs/sessions/contrib/github/common/types.ts new file mode 100644 index 00000000000..bc447c21c90 --- /dev/null +++ b/src/vs/sessions/contrib/github/common/types.ts @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//#region Session Context + +/** + * GitHub context derived from an active session, providing + * the owner/repo and optionally the PR number. + */ +export interface IGitHubSessionContext { + readonly owner: string; + readonly repo: string; + readonly prNumber: number | undefined; +} + +//#endregion + +//#region Repository + +export interface IGitHubRepository { + readonly owner: string; + readonly name: string; + readonly fullName: string; + readonly defaultBranch: string; + readonly isPrivate: boolean; + readonly description: string; +} + +//#endregion + +//#region Pull Request + +export const enum GitHubPullRequestState { + Open = 'open', + Closed = 'closed', + Merged = 'merged', +} + +export interface IGitHubUser { + readonly login: string; + readonly avatarUrl: string; +} + +export interface IGitHubPullRequest { + readonly number: number; + readonly title: string; + readonly body: string; + readonly state: GitHubPullRequestState; + readonly author: IGitHubUser; + readonly headRef: string; + readonly baseRef: string; + readonly isDraft: boolean; + readonly createdAt: string; + readonly updatedAt: string; + readonly mergedAt: string | undefined; + readonly mergeable: boolean | undefined; + readonly mergeableState: string; +} + +export const enum MergeBlockerKind { + ChangesRequested = 'changesRequested', + CIFailed = 'ciFailed', + ApprovalNeeded = 'approvalNeeded', + Conflicts = 'conflicts', + Draft = 'draft', + Unknown = 'unknown', +} + +export interface IMergeBlocker { + readonly kind: MergeBlockerKind; + readonly description: string; +} + +export interface IGitHubPullRequestMergeability { + readonly canMerge: boolean; + readonly blockers: readonly IMergeBlocker[]; +} + +//#endregion + +//#region Review Comments & Threads + +export interface IGitHubPRComment { + readonly id: number; + readonly body: string; + readonly author: IGitHubUser; + readonly createdAt: string; + readonly updatedAt: string; + /** File path the comment is attached to (undefined for issue-level comments). */ + readonly path: string | undefined; + /** Line number in the diff the comment is attached to. */ + readonly line: number | undefined; + /** The id of the thread this comment belongs to. */ + readonly threadId: string; + /** Whether this is a reply to another comment in the thread. */ + readonly inReplyToId: number | undefined; +} + +export interface IGitHubPRReviewThread { + readonly id: string; + readonly isResolved: boolean; + readonly path: string; + readonly line: number | undefined; + readonly comments: readonly IGitHubPRComment[]; +} + +//#endregion + +//#region CI Checks + +export const enum GitHubCheckStatus { + Queued = 'queued', + InProgress = 'in_progress', + Completed = 'completed', +} + +export const enum GitHubCheckConclusion { + Success = 'success', + Failure = 'failure', + Neutral = 'neutral', + Cancelled = 'cancelled', + Skipped = 'skipped', + TimedOut = 'timed_out', + ActionRequired = 'action_required', + Stale = 'stale', +} + +export interface IGitHubCICheck { + readonly id: number; + readonly name: string; + readonly status: GitHubCheckStatus; + readonly conclusion: GitHubCheckConclusion | undefined; + readonly startedAt: string | undefined; + readonly completedAt: string | undefined; + readonly detailsUrl: string | undefined; +} + +export const enum GitHubCIOverallStatus { + Pending = 'pending', + Success = 'success', + Failure = 'failure', + Neutral = 'neutral', +} + +//#endregion diff --git a/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts new file mode 100644 index 00000000000..baa4318b991 --- /dev/null +++ b/src/vs/sessions/contrib/github/test/browser/githubFetchers.test.ts @@ -0,0 +1,446 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { GitHubPRFetcher } from '../../browser/fetchers/githubPRFetcher.js'; +import { GitHubPRCIFetcher, computeOverallCIStatus } from '../../browser/fetchers/githubPRCIFetcher.js'; +import { GitHubRepositoryFetcher } from '../../browser/fetchers/githubRepositoryFetcher.js'; +import { GitHubApiClient, GitHubApiError } from '../../browser/githubApiClient.js'; +import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, GitHubPullRequestState, MergeBlockerKind } from '../../common/types.js'; + +class MockApiClient { + + private _nextResponse: unknown; + private _nextError: Error | undefined; + readonly requestCalls: { method: string; path: string; body?: unknown }[] = []; + readonly graphqlCalls: { query: string; variables?: Record }[] = []; + + setNextResponse(data: unknown): void { + this._nextResponse = data; + this._nextError = undefined; + } + + setNextError(error: Error): void { + this._nextError = error; + this._nextResponse = undefined; + } + + async request(_method: string, _path: string, _body?: unknown): Promise { + this.requestCalls.push({ method: _method, path: _path, body: _body }); + if (this._nextError) { + throw this._nextError; + } + return this._nextResponse as T; + } + + async graphql(query: string, variables?: Record): Promise { + this.graphqlCalls.push({ query, variables }); + if (this._nextError) { + throw this._nextError; + } + return this._nextResponse as T; + } +} + +suite('GitHubRepositoryFetcher', () => { + + const store = new DisposableStore(); + let mockApi: MockApiClient; + let fetcher: GitHubRepositoryFetcher; + + setup(() => { + mockApi = new MockApiClient(); + fetcher = new GitHubRepositoryFetcher(mockApi as unknown as GitHubApiClient); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getRepository returns mapped data', async () => { + mockApi.setNextResponse({ + name: 'vscode', + full_name: 'microsoft/vscode', + owner: { login: 'microsoft' }, + default_branch: 'main', + private: false, + description: 'Visual Studio Code', + }); + + const repo = await fetcher.getRepository('microsoft', 'vscode'); + assert.deepStrictEqual(repo, { + owner: 'microsoft', + name: 'vscode', + fullName: 'microsoft/vscode', + defaultBranch: 'main', + isPrivate: false, + description: 'Visual Studio Code', + }); + assert.strictEqual(mockApi.requestCalls[0].path, '/repos/microsoft/vscode'); + }); + + test('getRepository handles null description', async () => { + mockApi.setNextResponse({ + name: 'test', + full_name: 'owner/test', + owner: { login: 'owner' }, + default_branch: 'main', + private: true, + description: null, + }); + + const repo = await fetcher.getRepository('owner', 'test'); + assert.strictEqual(repo.description, ''); + }); + + test('getRepository propagates API errors', async () => { + mockApi.setNextError(new GitHubApiError('Not found', 404, undefined)); + await assert.rejects( + () => fetcher.getRepository('owner', 'nonexistent'), + (err: Error) => err instanceof GitHubApiError && (err as GitHubApiError).statusCode === 404, + ); + }); +}); + +suite('GitHubPRFetcher', () => { + + const store = new DisposableStore(); + let mockApi: MockApiClient; + let fetcher: GitHubPRFetcher; + + setup(() => { + mockApi = new MockApiClient(); + fetcher = new GitHubPRFetcher(mockApi as unknown as GitHubApiClient); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getPullRequest maps open PR', async () => { + mockApi.setNextResponse(makePRResponse({ state: 'open', merged: false, draft: false })); + + const pr = await fetcher.getPullRequest('owner', 'repo', 1); + assert.strictEqual(pr.state, GitHubPullRequestState.Open); + assert.strictEqual(pr.isDraft, false); + assert.strictEqual(pr.number, 1); + assert.strictEqual(pr.title, 'Test PR'); + }); + + test('getPullRequest maps merged PR', async () => { + mockApi.setNextResponse(makePRResponse({ state: 'closed', merged: true, draft: false })); + + const pr = await fetcher.getPullRequest('owner', 'repo', 1); + assert.strictEqual(pr.state, GitHubPullRequestState.Merged); + assert.ok(pr.mergedAt); + }); + + test('getPullRequest maps closed PR', async () => { + mockApi.setNextResponse(makePRResponse({ state: 'closed', merged: false, draft: false })); + + const pr = await fetcher.getPullRequest('owner', 'repo', 1); + assert.strictEqual(pr.state, GitHubPullRequestState.Closed); + }); + + test('getReviewThreads returns GraphQL thread metadata', async () => { + mockApi.setNextResponse(makeGraphQLReviewThreadsResponse([ + makeGraphQLReviewThread({ + id: 'thread-a', + path: 'src/a.ts', + line: 10, + isResolved: false, + comments: [ + makeGraphQLReviewComment({ databaseId: 100, path: 'src/a.ts', line: 10 }), + makeGraphQLReviewComment({ databaseId: 101, path: 'src/a.ts', line: 10, replyToDatabaseId: 100 }), + ], + }), + makeGraphQLReviewThread({ + id: 'thread-b', + path: 'src/b.ts', + line: 20, + isResolved: true, + comments: [makeGraphQLReviewComment({ databaseId: 200, path: 'src/b.ts', line: 20 })], + }), + ])); + + const threads = await fetcher.getReviewThreads('owner', 'repo', 1); + assert.strictEqual(threads.length, 2); + + const thread1 = threads.find(t => t.id === 'thread-a')!; + assert.ok(thread1); + assert.strictEqual(thread1.comments.length, 2); + assert.strictEqual(thread1.path, 'src/a.ts'); + assert.strictEqual(thread1.line, 10); + assert.strictEqual(thread1.comments[0].threadId, 'thread-a'); + + const thread2 = threads.find(t => t.id === 'thread-b')!; + assert.ok(thread2); + assert.strictEqual(thread2.comments.length, 1); + assert.strictEqual(thread2.path, 'src/b.ts'); + assert.strictEqual(thread2.isResolved, true); + }); + + test('resolveThread uses GraphQL mutation', async () => { + mockApi.setNextResponse({ + resolveReviewThread: { + thread: { + isResolved: true, + }, + }, + }); + + await fetcher.resolveThread('owner', 'repo', 'thread-a'); + assert.strictEqual(mockApi.graphqlCalls.length, 1); + assert.deepStrictEqual(mockApi.graphqlCalls[0].variables, { threadId: 'thread-a' }); + }); + + test('getMergeability detects draft blocker', async () => { + // getMergeability makes two requests (PR then reviews) + // Use a counter to return different responses + let callCount = 0; + const originalRequest = mockApi.request.bind(mockApi); + mockApi.request = async function (_method: string, _path: string, _body?: unknown): Promise { + if (callCount++ === 0) { + return makePRResponse({ state: 'open', merged: false, draft: true, mergeable: true, mergeable_state: 'clean' }) as T; + } + return [] as unknown as T; + }; + + const result = await fetcher.getMergeability('owner', 'repo', 1); + assert.strictEqual(result.canMerge, false); + assert.ok(result.blockers.some(b => b.kind === MergeBlockerKind.Draft)); + + // Restore + mockApi.request = originalRequest; + }); + + test('getMergeability detects conflicts blocker', async () => { + let callCount = 0; + const originalRequest = mockApi.request.bind(mockApi); + mockApi.request = async function (): Promise { + if (callCount++ === 0) { + return makePRResponse({ state: 'open', merged: false, draft: false, mergeable: false, mergeable_state: 'dirty' }) as T; + } + return [] as unknown as T; + }; + + const result = await fetcher.getMergeability('owner', 'repo', 1); + assert.strictEqual(result.canMerge, false); + assert.ok(result.blockers.some(b => b.kind === MergeBlockerKind.Conflicts)); + + mockApi.request = originalRequest; + }); + + test('getMergeability detects changes requested blocker', async () => { + let callCount = 0; + const originalRequest = mockApi.request.bind(mockApi); + mockApi.request = async function (): Promise { + if (callCount++ === 0) { + return makePRResponse({ state: 'open', merged: false, draft: false, mergeable: true, mergeable_state: 'clean' }) as T; + } + return [ + { id: 1, user: { login: 'reviewer', avatar_url: '' }, state: 'CHANGES_REQUESTED', submitted_at: '2024-01-01T00:00:00Z' }, + ] as unknown as T; + }; + + const result = await fetcher.getMergeability('owner', 'repo', 1); + assert.strictEqual(result.canMerge, false); + assert.ok(result.blockers.some(b => b.kind === MergeBlockerKind.ChangesRequested)); + + mockApi.request = originalRequest; + }); +}); + +suite('GitHubPRCIFetcher', () => { + + const store = new DisposableStore(); + let mockApi: MockApiClient; + let fetcher: GitHubPRCIFetcher; + + setup(() => { + mockApi = new MockApiClient(); + fetcher = new GitHubPRCIFetcher(mockApi as unknown as GitHubApiClient); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getCheckRuns maps check runs', async () => { + mockApi.setNextResponse({ + total_count: 2, + check_runs: [ + { id: 1, name: 'build', status: 'completed', conclusion: 'success', started_at: '2024-01-01T00:00:00Z', completed_at: '2024-01-01T00:10:00Z', details_url: 'https://example.com/1' }, + { id: 2, name: 'test', status: 'in_progress', conclusion: null, started_at: '2024-01-01T00:00:00Z', completed_at: null, details_url: null }, + ], + }); + + const checks = await fetcher.getCheckRuns('owner', 'repo', 'abc123'); + assert.strictEqual(checks.length, 2); + assert.deepStrictEqual(checks[0], { + id: 1, + name: 'build', + status: GitHubCheckStatus.Completed, + conclusion: GitHubCheckConclusion.Success, + startedAt: '2024-01-01T00:00:00Z', + completedAt: '2024-01-01T00:10:00Z', + detailsUrl: 'https://example.com/1', + }); + assert.strictEqual(checks[1].conclusion, undefined); + }); + + test('getCheckRunAnnotations returns formatted annotations', async () => { + mockApi.setNextResponse([ + { path: 'src/a.ts', start_line: 10, end_line: 10, annotation_level: 'failure', message: 'type error', title: 'TS2345' }, + { path: 'src/b.ts', start_line: 5, end_line: 8, annotation_level: 'warning', message: 'unused var', title: null }, + ]); + + const result = await fetcher.getCheckRunAnnotations('owner', 'repo', 1); + assert.ok(result.includes('[failure] src/a.ts:10')); + assert.ok(result.includes('(TS2345)')); + assert.ok(result.includes('[warning] src/b.ts:5-8')); + }); +}); + +suite('computeOverallCIStatus', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns neutral for empty checks', () => { + assert.strictEqual(computeOverallCIStatus([]), GitHubCIOverallStatus.Neutral); + }); + + test('returns success when all completed successfully', () => { + const checks = [ + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Success }), + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Neutral }), + ]; + assert.strictEqual(computeOverallCIStatus(checks), GitHubCIOverallStatus.Success); + }); + + test('returns failure when any check failed', () => { + const checks = [ + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Success }), + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Failure }), + ]; + assert.strictEqual(computeOverallCIStatus(checks), GitHubCIOverallStatus.Failure); + }); + + test('returns pending when any check is in progress', () => { + const checks = [ + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Success }), + makeCheck({ status: GitHubCheckStatus.InProgress, conclusion: undefined }), + ]; + assert.strictEqual(computeOverallCIStatus(checks), GitHubCIOverallStatus.Pending); + }); + + test('failure takes precedence over pending', () => { + const checks = [ + makeCheck({ status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Failure }), + makeCheck({ status: GitHubCheckStatus.InProgress, conclusion: undefined }), + ]; + assert.strictEqual(computeOverallCIStatus(checks), GitHubCIOverallStatus.Failure); + }); +}); + + +//#region Test Helpers + +function makePRResponse(overrides: { + state: 'open' | 'closed'; + merged: boolean; + draft: boolean; + mergeable?: boolean | null; + mergeable_state?: string; +}): unknown { + return { + number: 1, + title: 'Test PR', + body: 'Test body', + state: overrides.state, + draft: overrides.draft, + user: { login: 'author', avatar_url: 'https://example.com/avatar' }, + head: { ref: 'feature-branch' }, + base: { ref: 'main' }, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + merged_at: overrides.merged ? '2024-01-02T00:00:00Z' : null, + mergeable: overrides.mergeable ?? true, + mergeable_state: overrides.mergeable_state ?? 'clean', + merged: overrides.merged, + }; +} + +function makeGraphQLReviewThreadsResponse(threads: readonly ReturnType[]): unknown { + return { + repository: { + pullRequest: { + reviewThreads: { + nodes: threads, + }, + }, + }, + }; +} + +function makeGraphQLReviewThread(overrides: Partial<{ + id: string; + isResolved: boolean; + path: string; + line: number; + comments: readonly ReturnType[]; +}> = {}): unknown { + return { + id: overrides.id ?? 'thread-1', + isResolved: overrides.isResolved ?? false, + path: overrides.path ?? 'src/a.ts', + line: overrides.line ?? 10, + comments: { + nodes: overrides.comments ?? [makeGraphQLReviewComment()], + }, + }; +} + +function makeGraphQLReviewComment(overrides: Partial<{ + databaseId: number; + body: string; + path: string; + line: number; + replyToDatabaseId: number; +}> = {}): unknown { + return { + databaseId: overrides.databaseId ?? 100, + body: overrides.body ?? 'Test comment', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + path: overrides.path ?? 'src/a.ts', + line: overrides.line ?? 10, + originalLine: overrides.line ?? 10, + replyTo: overrides.replyToDatabaseId !== undefined ? { databaseId: overrides.replyToDatabaseId } : null, + author: { + login: 'reviewer', + avatarUrl: 'https://example.com/avatar', + }, + }; +} + +function makeCheck(overrides: { + status: GitHubCheckStatus; + conclusion: GitHubCheckConclusion | undefined; +}): { id: number; name: string; status: GitHubCheckStatus; conclusion: GitHubCheckConclusion | undefined; startedAt: string | undefined; completedAt: string | undefined; detailsUrl: string | undefined } { + return { + id: 1, + name: 'test-check', + status: overrides.status, + conclusion: overrides.conclusion, + startedAt: undefined, + completedAt: undefined, + detailsUrl: undefined, + }; +} + +//#endregion diff --git a/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts new file mode 100644 index 00000000000..24ce18ead0b --- /dev/null +++ b/src/vs/sessions/contrib/github/test/browser/githubModels.test.ts @@ -0,0 +1,279 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { GitHubPullRequestModel } from '../../browser/models/githubPullRequestModel.js'; +import { GitHubPullRequestCIModel } from '../../browser/models/githubPullRequestCIModel.js'; +import { GitHubRepositoryModel } from '../../browser/models/githubRepositoryModel.js'; +import { GitHubPRFetcher } from '../../browser/fetchers/githubPRFetcher.js'; +import { GitHubPRCIFetcher } from '../../browser/fetchers/githubPRCIFetcher.js'; +import { GitHubRepositoryFetcher } from '../../browser/fetchers/githubRepositoryFetcher.js'; +import { GitHubCIOverallStatus, GitHubCheckConclusion, GitHubCheckStatus, GitHubPullRequestState, IGitHubCICheck, IGitHubPRComment, IGitHubPRReviewThread, IGitHubPullRequest, IGitHubPullRequestMergeability, IGitHubRepository } from '../../common/types.js'; + +//#region Mock Fetchers + +class MockRepositoryFetcher { + nextResult: IGitHubRepository | undefined; + + async getRepository(_owner: string, _repo: string): Promise { + if (!this.nextResult) { + throw new Error('No mock result'); + } + return this.nextResult; + } +} + +class MockPRFetcher { + nextPR: IGitHubPullRequest | undefined; + nextMergeability: IGitHubPullRequestMergeability | undefined; + nextThreads: IGitHubPRReviewThread[] = []; + postReviewCommentCalls: { body: string; inReplyTo: number }[] = []; + postIssueCommentCalls: { body: string }[] = []; + + async getPullRequest(_owner: string, _repo: string, _prNumber: number): Promise { + if (!this.nextPR) { + throw new Error('No mock PR'); + } + return this.nextPR; + } + + async getMergeability(_owner: string, _repo: string, _prNumber: number): Promise { + if (!this.nextMergeability) { + throw new Error('No mock mergeability'); + } + return this.nextMergeability; + } + + async getReviewThreads(_owner: string, _repo: string, _prNumber: number): Promise { + return this.nextThreads; + } + + async postReviewComment(_owner: string, _repo: string, _prNumber: number, body: string, inReplyTo: number): Promise { + this.postReviewCommentCalls.push({ body, inReplyTo }); + return makeComment(999, body); + } + + async postIssueComment(_owner: string, _repo: string, _prNumber: number, body: string): Promise { + this.postIssueCommentCalls.push({ body }); + return makeComment(998, body); + } + + async resolveThread(): Promise { + throw new Error('Not implemented'); + } +} + +class MockCIFetcher { + nextChecks: IGitHubCICheck[] = []; + + async getCheckRuns(_owner: string, _repo: string, _ref: string): Promise { + return this.nextChecks; + } + + async getCheckRunAnnotations(_owner: string, _repo: string, _checkRunId: number): Promise { + return 'mock annotations'; + } +} + +//#endregion + +suite('GitHubRepositoryModel', () => { + + const store = new DisposableStore(); + let mockFetcher: MockRepositoryFetcher; + const logService = new NullLogService(); + + setup(() => { + mockFetcher = new MockRepositoryFetcher(); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('initial state is undefined', () => { + const model = store.add(new GitHubRepositoryModel('owner', 'repo', mockFetcher as unknown as GitHubRepositoryFetcher, logService)); + assert.strictEqual(model.repository.get(), undefined); + }); + + test('refresh populates repository observable', async () => { + const model = store.add(new GitHubRepositoryModel('owner', 'repo', mockFetcher as unknown as GitHubRepositoryFetcher, logService)); + mockFetcher.nextResult = { + owner: 'owner', + name: 'repo', + fullName: 'owner/repo', + defaultBranch: 'main', + isPrivate: false, + description: 'test', + }; + + await model.refresh(); + assert.deepStrictEqual(model.repository.get(), mockFetcher.nextResult); + }); + + test('refresh handles errors gracefully', async () => { + const model = store.add(new GitHubRepositoryModel('owner', 'repo', mockFetcher as unknown as GitHubRepositoryFetcher, logService)); + // No nextResult set, will throw + await model.refresh(); + assert.strictEqual(model.repository.get(), undefined); + }); +}); + +suite('GitHubPullRequestModel', () => { + + const store = new DisposableStore(); + let mockFetcher: MockPRFetcher; + const logService = new NullLogService(); + + setup(() => { + mockFetcher = new MockPRFetcher(); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('initial state has empty observables', () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + assert.strictEqual(model.pullRequest.get(), undefined); + assert.strictEqual(model.mergeability.get(), undefined); + assert.deepStrictEqual(model.reviewThreads.get(), []); + }); + + test('refresh populates all observables', async () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + mockFetcher.nextPR = makePR(); + mockFetcher.nextMergeability = { canMerge: true, blockers: [] }; + mockFetcher.nextThreads = [makeThread('thread-100', 'src/a.ts')]; + + await model.refresh(); + assert.strictEqual(model.pullRequest.get()?.number, 1); + assert.strictEqual(model.mergeability.get()?.canMerge, true); + assert.strictEqual(model.reviewThreads.get().length, 1); + }); + + test('refreshThreads only updates threads', async () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + mockFetcher.nextThreads = [makeThread('thread-100', 'src/a.ts'), makeThread('thread-200', 'src/b.ts')]; + + await model.refreshThreads(); + assert.strictEqual(model.pullRequest.get(), undefined); // not refreshed + assert.strictEqual(model.reviewThreads.get().length, 2); + }); + + test('postReviewComment calls fetcher and refreshes threads', async () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + mockFetcher.nextThreads = []; + + const comment = await model.postReviewComment('LGTM', 100); + assert.strictEqual(comment.body, 'LGTM'); + assert.strictEqual(mockFetcher.postReviewCommentCalls.length, 1); + assert.strictEqual(mockFetcher.postReviewCommentCalls[0].body, 'LGTM'); + }); + + test('postIssueComment calls fetcher', async () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + + const comment = await model.postIssueComment('Great work!'); + assert.strictEqual(comment.body, 'Great work!'); + assert.strictEqual(mockFetcher.postIssueCommentCalls.length, 1); + }); + + test('polling can be started and stopped', () => { + const model = store.add(new GitHubPullRequestModel('owner', 'repo', 1, mockFetcher as unknown as GitHubPRFetcher, logService)); + // Just ensure no errors; actual polling behavior is timer-based + model.startPolling(60_000); + model.stopPolling(); + }); +}); + +suite('GitHubPullRequestCIModel', () => { + + const store = new DisposableStore(); + let mockFetcher: MockCIFetcher; + const logService = new NullLogService(); + + setup(() => { + mockFetcher = new MockCIFetcher(); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('initial state is empty', () => { + const model = store.add(new GitHubPullRequestCIModel('owner', 'repo', 'abc', mockFetcher as unknown as GitHubPRCIFetcher, logService)); + assert.deepStrictEqual(model.checks.get(), []); + assert.strictEqual(model.overallStatus.get(), GitHubCIOverallStatus.Neutral); + }); + + test('refresh populates checks and computes overall status', async () => { + const model = store.add(new GitHubPullRequestCIModel('owner', 'repo', 'abc', mockFetcher as unknown as GitHubPRCIFetcher, logService)); + mockFetcher.nextChecks = [ + { id: 1, name: 'build', status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Success, startedAt: undefined, completedAt: undefined, detailsUrl: undefined }, + { id: 2, name: 'test', status: GitHubCheckStatus.Completed, conclusion: GitHubCheckConclusion.Failure, startedAt: undefined, completedAt: undefined, detailsUrl: undefined }, + ]; + + await model.refresh(); + assert.strictEqual(model.checks.get().length, 2); + assert.strictEqual(model.overallStatus.get(), GitHubCIOverallStatus.Failure); + }); + + test('getCheckRunAnnotations delegates to fetcher', async () => { + const model = store.add(new GitHubPullRequestCIModel('owner', 'repo', 'abc', mockFetcher as unknown as GitHubPRCIFetcher, logService)); + const result = await model.getCheckRunAnnotations(1); + assert.strictEqual(result, 'mock annotations'); + }); +}); + + +//#region Test Helpers + +function makePR(): IGitHubPullRequest { + return { + number: 1, + title: 'Test PR', + body: 'Test body', + state: GitHubPullRequestState.Open, + author: { login: 'author', avatarUrl: '' }, + headRef: 'feature', + baseRef: 'main', + isDraft: false, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + mergedAt: undefined, + mergeable: true, + mergeableState: 'clean', + }; +} + +function makeThread(id: string, path: string): IGitHubPRReviewThread { + return { + id, + isResolved: false, + path, + line: 10, + comments: [makeComment(100, `Comment on ${path}`, id)], + }; +} + +function makeComment(id: number, body: string, threadId: string = String(id)): IGitHubPRComment { + return { + id, + body, + author: { login: 'reviewer', avatarUrl: '' }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + path: undefined, + line: undefined, + threadId, + inReplyToId: undefined, + }; +} + +//#endregion diff --git a/src/vs/sessions/contrib/github/test/browser/githubService.test.ts b/src/vs/sessions/contrib/github/test/browser/githubService.test.ts new file mode 100644 index 00000000000..71f6e64130a --- /dev/null +++ b/src/vs/sessions/contrib/github/test/browser/githubService.test.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { GitHubService } from '../../browser/githubService.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../../fileTreeView/browser/githubFileSystemProvider.js'; +import { IActiveSessionItem } from '../../../sessions/browser/sessionsManagementService.js'; + +suite('GitHubService', () => { + + const store = new DisposableStore(); + let service: GitHubService; + + setup(() => { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ILogService, new NullLogService()); + + service = store.add(instantiationService.createInstance(GitHubService)); + }); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getRepository returns cached model for same key', () => { + const model1 = service.getRepository('owner', 'repo'); + const model2 = service.getRepository('owner', 'repo'); + assert.strictEqual(model1, model2); + }); + + test('getRepository returns different models for different repos', () => { + const model1 = service.getRepository('owner', 'repo1'); + const model2 = service.getRepository('owner', 'repo2'); + assert.notStrictEqual(model1, model2); + }); + + test('getPullRequest returns cached model for same key', () => { + const model1 = service.getPullRequest('owner', 'repo', 1); + const model2 = service.getPullRequest('owner', 'repo', 1); + assert.strictEqual(model1, model2); + }); + + test('getPullRequest returns different models for different PRs', () => { + const model1 = service.getPullRequest('owner', 'repo', 1); + const model2 = service.getPullRequest('owner', 'repo', 2); + assert.notStrictEqual(model1, model2); + }); + + test('getPullRequestCI returns cached model for same key', () => { + const model1 = service.getPullRequestCI('owner', 'repo', 'abc123'); + const model2 = service.getPullRequestCI('owner', 'repo', 'abc123'); + assert.strictEqual(model1, model2); + }); + + test('getPullRequestCI returns different models for different refs', () => { + const model1 = service.getPullRequestCI('owner', 'repo', 'abc'); + const model2 = service.getPullRequestCI('owner', 'repo', 'def'); + assert.notStrictEqual(model1, model2); + }); + + test('disposing service does not throw', () => { + service.getRepository('owner', 'repo'); + service.getPullRequest('owner', 'repo', 1); + service.getPullRequestCI('owner', 'repo', 'abc'); + + // Disposing the service should not throw and should clean up models + assert.doesNotThrow(() => service.dispose()); + }); +}); + +suite('getGitHubContext', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function makeSession(overrides: Partial): IActiveSessionItem { + return { + resource: URI.parse('test://session/1'), + isUntitled: false, + label: 'Test Session', + repository: undefined, + worktree: undefined, + worktreeBranchName: undefined, + providerType: 'copilot-cloud-agent', + ...overrides, + }; + } + + test('parses owner/repo from github-remote-file URI', () => { + const session = makeSession({ + repository: URI.from({ + scheme: GITHUB_REMOTE_FILE_SCHEME, + authority: 'github', + path: '/microsoft/vscode/main' + }), + }); + + const parts = session.repository!.path.split('/').filter(Boolean); + assert.strictEqual(parts.length >= 2, true); + assert.strictEqual(decodeURIComponent(parts[0]), 'microsoft'); + assert.strictEqual(decodeURIComponent(parts[1]), 'vscode'); + }); + + test('parses PR number from pullRequestUrl', () => { + const url = 'https://github.com/microsoft/vscode/pull/12345'; + const match = /\/pull\/(\d+)/.exec(url); + assert.ok(match); + assert.strictEqual(parseInt(match![1], 10), 12345); + }); + + test('parses owner/repo from repositoryNwo', () => { + const nwo = 'microsoft/vscode'; + const parts = nwo.split('/'); + assert.strictEqual(parts.length, 2); + assert.strictEqual(parts[0], 'microsoft'); + assert.strictEqual(parts[1], 'vscode'); + }); + + test('returns undefined for non-GitHub file URI', () => { + const session = makeSession({ + repository: URI.file('/local/path/to/repo'), + }); + + // file:// scheme is not github-remote-file + assert.notStrictEqual(session.repository!.scheme, GITHUB_REMOTE_FILE_SCHEME); + }); +}); diff --git a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts b/src/vs/sessions/contrib/logs/browser/logs.contribution.ts index b401f4e70ee..8a562ce4977 100644 --- a/src/vs/sessions/contrib/logs/browser/logs.contribution.ts +++ b/src/vs/sessions/contrib/logs/browser/logs.contribution.ts @@ -5,11 +5,8 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { localize, localize2 } from '../../../../nls.js'; -import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { SessionsCategories } from '../../../common/categories.js'; -import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; @@ -17,13 +14,9 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { IViewContainersRegistry, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; import { OutputViewPane } from '../../../../workbench/contrib/output/browser/outputView.js'; import { OUTPUT_VIEW_ID } from '../../../../workbench/services/output/common/output.js'; -import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; -import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; const SESSIONS_LOGS_CONTAINER_ID = 'workbench.sessions.panel.logsContainer'; -const CONTEXT_SESSIONS_SHOW_LOGS = new RawContextKey('sessionsShowLogs', false); - const logsViewIcon = registerIcon('sessions-logs-view-icon', Codicon.output, localize('sessionsLogsViewIcon', 'View icon of the logs view in the sessions window.')); class RegisterLogsViewContainerContribution implements IWorkbenchContribution { @@ -32,9 +25,7 @@ class RegisterLogsViewContainerContribution implements IWorkbenchContribution { constructor( @IContextKeyService contextKeyService: IContextKeyService, - @IEnvironmentService environmentService: IEnvironmentService, ) { - CONTEXT_SESSIONS_SHOW_LOGS.bindTo(contextKeyService).set(!environmentService.isBuilt); const viewContainerRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); @@ -68,28 +59,9 @@ class RegisterLogsViewContainerContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(OutputViewPane), canToggleVisibility: true, canMoveView: false, - when: CONTEXT_SESSIONS_SHOW_LOGS, windowVisibility: WindowVisibility.Sessions, }], logsViewContainer); } } registerWorkbenchContribution2(RegisterLogsViewContainerContribution.ID, RegisterLogsViewContainerContribution, WorkbenchPhase.BlockStartup); - -// Command: Sessions: Show Logs -registerAction2(class extends Action2 { - constructor() { - super({ - id: 'workbench.sessions.action.showLogs', - title: localize2('sessionsShowLogs', "Show Logs"), - category: SessionsCategories.Sessions, - f1: true, - }); - } - async run(accessor: ServicesAccessor): Promise { - const contextKeyService = accessor.get(IContextKeyService); - const viewsService = accessor.get(IViewsService); - CONTEXT_SESSIONS_SHOW_LOGS.bindTo(contextKeyService).set(true); - await viewsService.openView(OUTPUT_VIEW_ID, true); - } -}); diff --git a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts new file mode 100644 index 00000000000..58c814528b3 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../browser/media/sidebarActionButton.css'; +import './media/customizationsToolbar.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { Menus } from '../../../browser/menus.js'; +import { getCustomizationTotalCount } from './customizationCounts.js'; +import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; + +const $ = DOM.$; + +const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; + +export interface IAICustomizationShortcutsWidgetOptions { + readonly onDidToggleCollapse?: () => void; +} + +export class AICustomizationShortcutsWidget extends Disposable { + + constructor( + container: HTMLElement, + options: IAICustomizationShortcutsWidgetOptions | undefined, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStorageService private readonly storageService: IStorageService, + @IPromptsService private readonly promptsService: IPromptsService, + @IMcpService private readonly mcpService: IMcpService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, + @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + ) { + super(); + + this._render(container, options); + } + + private _render(parent: HTMLElement, options: IAICustomizationShortcutsWidgetOptions | undefined): void { + // Get initial collapsed state + const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false); + + const container = DOM.append(parent, $('.ai-customization-toolbar')); + if (isCollapsed) { + container.classList.add('collapsed'); + } + + // Header (clickable to toggle) + const header = DOM.append(container, $('.ai-customization-header')); + header.classList.toggle('collapsed', isCollapsed); + + const headerButtonContainer = DOM.append(header, $('.customization-link-button-container')); + const headerButton = this._register(new Button(headerButtonContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + headerButton.element.classList.add('customization-link-button', 'sidebar-action-button'); + headerButton.element.setAttribute('aria-expanded', String(!isCollapsed)); + headerButton.label = localize('customizations', "CUSTOMIZATIONS"); + + const chevronContainer = DOM.append(headerButton.element, $('span.customization-link-counts')); + const chevron = DOM.append(chevronContainer, $('.ai-customization-chevron')); + const headerTotalCount = DOM.append(chevronContainer, $('span.ai-customization-header-total.hidden')); + chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Toolbar container + const toolbarContainer = DOM.append(container, $('.ai-customization-toolbar-content.sidebar-action-list')); + + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarCustomizations, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + telemetrySource: 'sidebarCustomizations', + })); + + let updateCountRequestId = 0; + const updateHeaderTotalCount = async () => { + const requestId = ++updateCountRequestId; + const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService, this.agentPluginService); + if (requestId !== updateCountRequestId) { + return; + } + + headerTotalCount.classList.toggle('hidden', totalCount === 0); + headerTotalCount.textContent = `${totalCount}`; + }; + + this._register(this.promptsService.onDidChangeCustomAgents(() => updateHeaderTotalCount())); + this._register(this.promptsService.onDidChangeSlashCommands(() => updateHeaderTotalCount())); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => updateHeaderTotalCount())); + this._register(autorun(reader => { + this.mcpService.servers.read(reader); + updateHeaderTotalCount(); + })); + this._register(autorun(reader => { + this.workspaceService.activeProjectRoot.read(reader); + updateHeaderTotalCount(); + })); + updateHeaderTotalCount(); + + // Toggle collapse on header click + const transitionListener = this._register(new MutableDisposable()); + const toggleCollapse = () => { + const collapsed = container.classList.toggle('collapsed'); + header.classList.toggle('collapsed', collapsed); + this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER); + headerButton.element.setAttribute('aria-expanded', String(!collapsed)); + chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown)); + chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Re-layout after the transition + transitionListener.value = DOM.addDisposableListener(toolbarContainer, 'transitionend', () => { + transitionListener.clear(); + options?.onDidToggleCollapse?.(); + }); + }; + + this._register(headerButton.onDidClick(() => toggleCollapse())); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts index fac485cf511..88d932c7ee5 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts @@ -7,21 +7,28 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { isEqualOrParent } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { parseHooksFromFile } from '../../../../workbench/contrib/chat/common/promptSyntax/hookCompatibility.js'; +import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { parse as parseJSONC } from '../../../../base/common/jsonc.js'; export interface ISourceCounts { readonly workspace: number; readonly user: number; readonly extension: number; + readonly builtin: number; } -const storageToCountKey: Partial> = { +const storageToCountKey: Partial> = { [PromptsStorage.local]: 'workspace', [PromptsStorage.user]: 'user', [PromptsStorage.extension]: 'extension', + [BUILTIN_STORAGE]: 'builtin', }; export function getSourceCountsTotal(counts: ISourceCounts, filter: IStorageSourceFilter): number { @@ -45,6 +52,7 @@ export async function getSourceCounts( filter: IStorageSourceFilter, workspaceContextService: IWorkspaceContextService, workspaceService: IAICustomizationWorkspaceService, + fileService?: IFileService, ): Promise { const items: { storage: PromptsStorage; uri: URI }[] = []; @@ -88,6 +96,28 @@ export async function getSourceCounts( uri: file.uri, }); } + } else if (promptType === PromptsType.hook && fileService) { + // Must match loadItems: parse individual hooks from each file + const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); + const activeRoot = workspaceService.getActiveProjectRoot(); + for (const hookFile of hookFiles) { + try { + const content = await fileService.readFile(hookFile.uri); + const json = parseJSONC(content.value.toString()); + const { hooks } = parseHooksFromFile(hookFile.uri, json, activeRoot, ''); + if (hooks.size > 0) { + for (const [, entry] of hooks) { + for (let i = 0; i < entry.hooks.length; i++) { + items.push({ storage: hookFile.storage, uri: hookFile.uri }); + } + } + } else { + items.push({ storage: hookFile.storage, uri: hookFile.uri }); + } + } catch { + items.push({ storage: hookFile.storage, uri: hookFile.uri }); + } + } } else { // hooks and anything else: uses listPromptFiles const files = await promptsService.listPromptFiles(promptType, CancellationToken.None); @@ -102,6 +132,7 @@ export async function getSourceCounts( workspace: filtered.filter(i => i.storage === PromptsStorage.local).length, user: filtered.filter(i => i.storage === PromptsStorage.user).length, extension: filtered.filter(i => i.storage === PromptsStorage.extension).length, + builtin: filtered.filter(i => i.storage === BUILTIN_STORAGE).length, }; } @@ -110,6 +141,7 @@ export async function getCustomizationTotalCount( mcpService: IMcpService, workspaceService: IAICustomizationWorkspaceService, workspaceContextService: IWorkspaceContextService, + agentPluginService?: IAgentPluginService, ): Promise { const types: PromptsType[] = [PromptsType.agent, PromptsType.skill, PromptsType.instructions, PromptsType.prompt, PromptsType.hook]; const results = await Promise.all(types.map(type => { @@ -117,5 +149,6 @@ export async function getCustomizationTotalCount( return getSourceCounts(promptsService, type, filter, workspaceContextService, workspaceService) .then(counts => getSourceCountsTotal(counts, filter)); })); - return results.reduce((sum, n) => sum + n, 0) + mcpService.servers.get().length; + const pluginCount = agentPluginService?.plugins.get().length ?? 0; + return results.reduce((sum, n) => sum + n, 0) + mcpService.servers.get().length + pluginCount; } diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index 382c1b38051..07c6cf93903 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -15,34 +15,37 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; -import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { Menus } from '../../../browser/menus.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, instructionsIcon, mcpServerIcon, pluginIcon, promptIcon, skillIcon, hookIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../base/common/actions.js'; import { $, append } from '../../../../base/browser/dom.js'; import { autorun } from '../../../../base/common/observable.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { ISessionsManagementService } from './sessionsManagementService.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { getSourceCounts, getSourceCountsTotal, ISourceCounts } from './customizationCounts.js'; +import { getSourceCounts, getSourceCountsTotal } from './customizationCounts.js'; import { IEditorService, MODAL_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; -interface ICustomizationItemConfig { +export interface ICustomizationItemConfig { readonly id: string; readonly label: string; readonly icon: ThemeIcon; readonly section: AICustomizationManagementSection; readonly promptType?: PromptsType; - readonly getCount?: (languageModelsService: ILanguageModelsService, mcpService: IMcpService) => Promise; + readonly isMcp?: boolean; + readonly isPlugins?: boolean; } -const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ +export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ { id: 'sessions.customization.agents', label: localize('agents', "Agents"), @@ -78,14 +81,27 @@ const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ section: AICustomizationManagementSection.Hooks, promptType: PromptsType.hook, }, - // TODO: Re-enable MCP Servers once CLI MCP configuration is unified with VS Code + { + id: 'sessions.customization.mcpServers', + label: localize('mcpServers', "MCP Servers"), + icon: mcpServerIcon, + section: AICustomizationManagementSection.McpServers, + isMcp: true, + }, + { + id: 'sessions.customization.plugins', + label: localize('plugins', "Plugins"), + icon: pluginIcon, + section: AICustomizationManagementSection.Plugins, + isPlugins: true, + }, ]; /** * Custom ActionViewItem for each customization link in the toolbar. * Renders icon + label + source count badges, matching the sidebar footer style. */ -class CustomizationLinkViewItem extends ActionViewItem { +export class CustomizationLinkViewItem extends ActionViewItem { private readonly _viewItemDisposables: DisposableStore; private _button: Button | undefined; @@ -101,6 +117,8 @@ class CustomizationLinkViewItem extends ActionViewItem { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, @IAICustomizationWorkspaceService private readonly _workspaceService: IAICustomizationWorkspaceService, + @IFileService private readonly _fileService: IFileService, + @IAgentPluginService private readonly _agentPluginService: IAgentPluginService, ) { super(undefined, action, { ...options, icon: false, label: false }); this._viewItemDisposables = this._register(new DisposableStore()); @@ -144,6 +162,10 @@ class CustomizationLinkViewItem extends ActionViewItem { this._mcpService.servers.read(reader); this._updateCounts(); })); + this._viewItemDisposables.add(autorun(reader => { + this._agentPluginService.plugins.read(reader); + this._updateCounts(); + })); this._viewItemDisposables.add(this._workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateCounts())); this._viewItemDisposables.add(autorun(reader => { this._activeSessionService.activeSession.read(reader); @@ -166,50 +188,22 @@ class CustomizationLinkViewItem extends ActionViewItem { if (this._config.promptType) { const type = this._config.promptType; const filter = this._workspaceService.getStorageSourceFilter(type); - const counts = await getSourceCounts(this._promptsService, type, filter, this._workspaceContextService, this._workspaceService); + const counts = await getSourceCounts(this._promptsService, type, filter, this._workspaceContextService, this._workspaceService, this._fileService); if (requestId !== this._updateCountsRequestId) { return; } - this._renderSourceCounts(this._countContainer, counts); - } else if (this._config.getCount) { - const count = await this._config.getCount(this._languageModelsService, this._mcpService); - if (requestId !== this._updateCountsRequestId) { - return; - } - this._renderSimpleCount(this._countContainer, count); + const total = getSourceCountsTotal(counts, filter); + this._renderTotalCount(this._countContainer, total); + } else if (this._config.isMcp) { + const total = this._mcpService.servers.get().length; + this._renderTotalCount(this._countContainer, total); + } else if (this._config.isPlugins) { + const total = this._agentPluginService.plugins.get().length; + this._renderTotalCount(this._countContainer, total); } } - private _renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { - container.textContent = ''; - const type = this._config.promptType; - const filter = type ? this._workspaceService.getStorageSourceFilter(type) : this._workspaceService.getStorageSourceFilter(PromptsType.prompt); - const total = getSourceCountsTotal(counts, filter); - container.classList.toggle('hidden', total === 0); - if (total === 0) { - return; - } - - const visibleSourcesSet = new Set(filter.sources); - const sources: { storage: PromptsStorage; count: number; icon: ThemeIcon; title: string }[] = [ - { storage: PromptsStorage.local, count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, - { storage: PromptsStorage.user, count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, - ]; - - for (const source of sources) { - if (source.count === 0 || !visibleSourcesSet.has(source.storage)) { - continue; - } - const badge = append(container, $('span.source-count-badge')); - badge.title = source.title; - const icon = append(badge, $('span.source-count-icon')); - icon.classList.add(...ThemeIcon.asClassNameArray(source.icon)); - const num = append(badge, $('span.source-count-num')); - num.textContent = `${source.count}`; - } - } - - private _renderSimpleCount(container: HTMLElement, count: number): void { + private _renderTotalCount(container: HTMLElement, count: number): void { container.textContent = ''; container.classList.toggle('hidden', count === 0); if (count > 0) { @@ -222,7 +216,7 @@ class CustomizationLinkViewItem extends ActionViewItem { // --- Register actions and view items --- // -class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution { +export class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsCustomizationsToolbar'; diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css index d671775dbd5..bc08fc25eb5 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -2,132 +2,129 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -.agent-sessions-viewpane { - - /* AI Customization section - pinned to bottom */ - .ai-customization-toolbar { - display: flex; - flex-direction: column; - flex-shrink: 0; - border-top: 1px solid var(--vscode-widget-border); - padding: 6px; - } - - /* Make the toolbar, action bar, and items fill full width and stack vertically */ - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-toolbar, - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar { - width: 100%; - } - - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .actions-container { - display: flex; - flex-direction: column; - width: 100%; - } - - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .action-item { - width: 100%; - max-width: 100%; - } - - .ai-customization-toolbar .customization-link-widget { - width: 100%; - } - - /* Customization header - clickable for collapse */ - .ai-customization-toolbar .ai-customization-header { - display: flex; - align-items: center; - -webkit-user-select: none; - user-select: none; - } - - .ai-customization-toolbar .ai-customization-header:not(.collapsed) { - margin-bottom: 4px; - } - - .ai-customization-toolbar .ai-customization-chevron { - flex-shrink: 0; - opacity: 0; - } - - .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:hover .ai-customization-chevron, - .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:focus-within .ai-customization-chevron, - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-chevron, - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-chevron { - opacity: 0.7; - } - - .ai-customization-toolbar .ai-customization-header-total { - display: none; - opacity: 0.7; - font-size: 11px; - line-height: 1; - } - - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:not(:hover):not(:focus-within) .ai-customization-header-total:not(.hidden) { - display: inline; - } - - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-header-total, - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-header-total, - .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button .ai-customization-header-total { - display: none; - } - - /* Button container - fills available space */ - .ai-customization-toolbar .customization-link-button-container { - overflow: hidden; - min-width: 0; - flex: 1; - } - - /* Button needs relative positioning for counts overlay */ - .ai-customization-toolbar .customization-link-button { - position: relative; - } - - /* Counts - floating right inside the button */ - .ai-customization-toolbar .customization-link-counts { - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - display: flex; - align-items: center; - gap: 6px; - } - - .ai-customization-toolbar .customization-link-counts.hidden { - display: none; - } - - .ai-customization-toolbar .source-count-badge { - display: flex; - align-items: center; - gap: 2px; - } - - .ai-customization-toolbar .source-count-icon { - font-size: 12px; - opacity: 0.6; - } - - .ai-customization-toolbar .source-count-num { - font-size: 11px; - color: var(--vscode-descriptionForeground); - opacity: 0.8; - } - - /* Collapsed state */ - .ai-customization-toolbar .ai-customization-toolbar-content { - max-height: 500px; - overflow: hidden; - transition: max-height 0.2s ease-out; - } - - .ai-customization-toolbar.collapsed .ai-customization-toolbar-content { - max-height: 0; - } +/* AI Customization section - pinned to bottom */ +.ai-customization-toolbar { + display: flex; + flex-direction: column; + flex-shrink: 0; + border-top: 1px solid var(--vscode-widget-border); + padding: 6px; +} + +/* Make the toolbar, action bar, and items fill full width and stack vertically */ +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-toolbar, +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar { + width: 100%; +} + +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .actions-container { + display: flex; + flex-direction: column; + width: 100%; +} + +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .action-item { + width: 100%; + max-width: 100%; +} + +.ai-customization-toolbar .customization-link-widget { + width: 100%; +} + +/* Customization header - clickable for collapse */ +.ai-customization-toolbar .ai-customization-header { + display: flex; + align-items: center; + -webkit-user-select: none; + user-select: none; +} + +.ai-customization-toolbar .ai-customization-header:not(.collapsed) { + margin-bottom: 4px; +} + +.ai-customization-toolbar .ai-customization-chevron { + flex-shrink: 0; + opacity: 0; +} + +.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:hover .ai-customization-chevron, +.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:focus-within .ai-customization-chevron, +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-chevron, +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-chevron { + opacity: 0.7; +} + +.ai-customization-toolbar .ai-customization-header-total { + display: none; + opacity: 0.7; + font-size: 11px; + line-height: 1; +} + +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:not(:hover):not(:focus-within) .ai-customization-header-total:not(.hidden) { + display: inline; +} + +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-header-total, +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-header-total, +.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button .ai-customization-header-total { + display: none; +} + +/* Button container - fills available space */ +.ai-customization-toolbar .customization-link-button-container { + overflow: hidden; + min-width: 0; + flex: 1; +} + +/* Button needs relative positioning for counts overlay */ +.ai-customization-toolbar .customization-link-button { + position: relative; +} + +/* Counts - floating right inside the button */ +.ai-customization-toolbar .customization-link-counts { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 6px; +} + +.ai-customization-toolbar .customization-link-counts.hidden { + display: none; +} + +.ai-customization-toolbar .source-count-badge { + display: flex; + align-items: center; + gap: 2px; +} + +.ai-customization-toolbar .source-count-icon { + font-size: 12px; + opacity: 0.6; +} + +.ai-customization-toolbar .source-count-num { + font-size: 11px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; +} + +/* Collapsed state */ +.ai-customization-toolbar .ai-customization-toolbar-content { + max-height: 500px; + overflow: hidden; + transition: max-height 0.2s ease-out; + padding-bottom: 2px; +} + +.ai-customization-toolbar.collapsed .ai-customization-toolbar-content { + max-height: 0; } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 577c5a482f6..b9f14273091 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -6,13 +6,11 @@ /* Container - button style hover */ .command-center .agent-sessions-titlebar-container { display: flex; - width: 38vw; - max-width: 600px; - display: flex; + width: 100%; flex-direction: row; flex-wrap: nowrap; align-items: center; - justify-content: center; + justify-content: flex-start; padding: 0 10px; height: 22px; border-radius: 4px; @@ -30,28 +28,13 @@ padding: 0 4px; border-radius: 4px; min-width: 0; + max-width: 600px; } .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-pill:hover { background-color: var(--vscode-toolbar-hoverBackground); } -/* Session title actions toolbar */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-actions { - display: flex; - align-items: center; - flex-shrink: 0; -} - -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-actions .actions-container { - height: auto; -} - -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-actions .action-item { - display: flex; - align-items: center; -} - .command-center .agent-sessions-titlebar-container:focus { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; @@ -63,11 +46,9 @@ align-items: center; gap: 6px; min-width: 0; - justify-content: center; + justify-content: flex-start; cursor: pointer; } - -/* Kind icon */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-icon { display: flex; align-items: center; @@ -95,3 +76,19 @@ opacity: 0.5; flex-shrink: 0; } + +/* Changes summary */ +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 3px; +} + +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-added { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-changes-removed { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index f2a5bd0e28e..cfa0f99049f 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -16,15 +16,19 @@ import { ISessionOpenOptions, openSession as openSessionDefault } from '../../.. import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IChatService, IChatSendRequestOptions } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; import { IAgentSession, isAgentSession } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { INewSession, LocalNewSession, RemoteNewSession } from '../../chat/browser/newSession.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { isBuiltinChatMode } from '../../../../workbench/contrib/chat/common/chatModes.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { ILanguageModelToolsService } from '../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; +import { isUntitledChatSession } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; +import { IGitHubSessionContext } from '../../github/common/types.js'; export const IsNewChatSessionContext = new RawContextKey('isNewChatSession', true); @@ -50,6 +54,7 @@ export interface IActiveSessionItem { readonly label: string | undefined; readonly repository: URI | undefined; readonly worktree: URI | undefined; + readonly worktreeBranchName: string | undefined; readonly providerType: string; } @@ -90,13 +95,30 @@ export interface ISessionsManagementService { * When `openNewSessionView` is true, opens a new session view after sending * instead of navigating to the newly created session. */ - sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean }): Promise; + sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean; permissionLevel?: ChatPermissionLevel }): Promise; /** * Commit files in a worktree and refresh the agent sessions model * so the Changes view reflects the update. */ commitWorktreeFiles(session: IActiveSessionItem, fileUris: URI[]): Promise; + + /** + * Derive a GitHub context (owner, repo, prNumber) from an active session. + * Returns `undefined` if the session is not associated with a GitHub repository. + */ + getGitHubContext(session: IActiveSessionItem): IGitHubSessionContext | undefined; + + /** + * Derive a GitHub context from a session resource URI. + * Looks up the agent session internally and resolves repository info. + */ + getGitHubContextForSession(sessionResource: URI): IGitHubSessionContext | undefined; + + /** + * Resolve a relative file path to a full URI based on the session's repository/worktree. + */ + resolveSessionFileUri(sessionResource: URI, relativePath: string): URI | undefined; } export const ISessionsManagementService = createDecorator('sessionsManagementService'); @@ -126,6 +148,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa @IContextKeyService contextKeyService: IContextKeyService, @ICommandService private readonly commandService: ICommandService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, ) { super(); @@ -196,10 +219,10 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } - private getRepositoryFromMetadata(session: IAgentSession): [URI | undefined, URI | undefined] { + private getRepositoryFromMetadata(session: IAgentSession): [URI | undefined, URI | undefined, string | undefined] { const metadata = session.metadata; if (!metadata) { - return [undefined, undefined]; + return [undefined, undefined, undefined]; } if (session.providerType === AgentSessionProviders.Cloud) { @@ -210,12 +233,12 @@ export class SessionsManagementService extends Disposable implements ISessionsMa authority: 'github', path: `/${metadata.owner}/${metadata.name}/${encodeURIComponent(branch)}` }); - return [repositoryUri, undefined]; + return [repositoryUri, undefined, undefined]; } const workingDirectoryPath = metadata?.workingDirectoryPath as string | undefined; if (workingDirectoryPath) { - return [URI.file(workingDirectoryPath), undefined]; + return [URI.file(workingDirectoryPath), undefined, undefined]; } const repositoryPath = metadata?.repositoryPath as string | undefined; @@ -224,9 +247,12 @@ export class SessionsManagementService extends Disposable implements ISessionsMa const worktreePath = metadata?.worktreePath as string | undefined; const worktreePathUri = typeof worktreePath === 'string' ? URI.file(worktreePath) : undefined; + const worktreeBranchName = metadata?.branchName as string | undefined; + return [ URI.isUri(repositoryPathUri) ? repositoryPathUri : undefined, - URI.isUri(worktreePathUri) ? worktreePathUri : undefined]; + URI.isUri(worktreePathUri) ? worktreePathUri : undefined, + worktreeBranchName]; } private getRepositoryFromSessionOption(sessionResource: URI): URI | undefined { @@ -301,7 +327,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.logService.info(`[ActiveSessionService] Active session changed (new): ${sessionResource.toString()}, repository: ${repository?.toString() ?? 'none'}`); } - async sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean }): Promise { + async sendRequestForNewSession(sessionResource: URI, options?: { openNewSessionView?: boolean; permissionLevel?: ChatPermissionLevel }): Promise { const session = this._newSession.value; if (!session) { this.logService.error(`[SessionsManagementService] No new session found for resource: ${sessionResource.toString()}`); @@ -320,15 +346,30 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } const contribution = this.chatSessionsService.getChatSessionContribution(session.target); + + // Resolve mode from session's modeId (falls back to Agent) + const modeKind = session.mode?.kind ?? ChatModeKind.Agent; + const modeIsBuiltin = session.mode ? isBuiltinChatMode(session.mode) : true; + const modeId: 'ask' | 'agent' | 'edit' | 'custom' | undefined = modeIsBuiltin ? modeKind : 'custom'; + + const rawModeInstructions = session.mode?.modeInstructions?.get(); + const modeInstructions = rawModeInstructions ? { + name: session.mode!.name.get(), + content: rawModeInstructions.content, + toolReferences: this.toolsService.toToolReferences(rawModeInstructions.toolReferences), + metadata: rawModeInstructions.metadata, + } : undefined; + const sendOptions: IChatSendRequestOptions = { location: ChatAgentLocation.Chat, userSelectedModelId: session.modelId, modeInfo: { - kind: ChatModeKind.Agent, - isBuiltin: true, - modeInstructions: undefined, - modeId: 'agent', + kind: modeKind, + isBuiltin: modeIsBuiltin, + modeInstructions, + modeId, applyCodeBlockSuggestionId: undefined, + permissionLevel: options?.permissionLevel ?? ChatPermissionLevel.Default, }, agentIdSilent: contribution?.type, attachedContext: session.attachedContext, @@ -348,6 +389,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.openNewSessionView(); } + // Sync the permission level from the welcome picker to the ChatWidget's input part + const permissionLevel = sendOptions.modeInfo?.permissionLevel; + if (permissionLevel) { + const chatWidget = this.chatWidgetService.getWidgetBySessionResource(session.resource); + chatWidget?.input.setPermissionLevel(permissionLevel); + } + // 2. Apply selected model and options to the session const modelRef = this.chatService.acquireExistingSession(session.resource); if (modelRef) { @@ -363,6 +411,13 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } } + // Set the selected mode on the input model so the mode picker reflects it + if (session.mode) { + model.inputModel.setState({ + mode: { id: session.mode.id, kind: session.mode.kind } + }); + } + // Apply selected options (repository, branch, etc.) to the contributed session if (selectedOptions && selectedOptions.size > 0) { const contributedSession = model.contributedChatSession; @@ -413,7 +468,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } if (newSession && !openNewSessionView) { - this.setActiveSession(newSession); + this.setActiveSession(newSession, session); } } @@ -426,18 +481,19 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.isNewChatSessionContext.set(true); } - private setActiveSession(session: IAgentSession | INewSession | undefined): void { + private setActiveSession(session: IAgentSession | INewSession | undefined, pendingSession?: INewSession): void { let activeSessionItem: IActiveSessionItem | undefined; if (session) { if (isAgentSession(session)) { this.lastSelectedSession = session.resource; - const [repository, worktree] = this.getRepositoryFromMetadata(session); + const [repository, worktree, worktreeBranchName] = this.getRepositoryFromMetadata(session); activeSessionItem = { - isUntitled: this.chatService.getSession(session.resource)?.contributedChatSession?.isUntitled ?? true, + isUntitled: isUntitledChatSession(session.resource), label: session.label, resource: session.resource, - repository, + repository: repository ?? pendingSession?.repoUri, worktree, + worktreeBranchName: worktreeBranchName ?? pendingSession?.branch, providerType: session.providerType, }; } else { @@ -447,6 +503,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa resource: session.resource, repository: session.repoUri, worktree: undefined, + worktreeBranchName: undefined, providerType: session.target, }; this._newActiveSessionDisposables.clear(); @@ -458,6 +515,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa resource: session.resource, repository: session.repoUri, worktree: undefined, + worktreeBranchName: undefined, providerType: session.target, }); } @@ -496,6 +554,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa a.resource.toString() === b.resource.toString() && a.repository?.toString() === b.repository?.toString() && a.worktree?.toString() === b.worktree?.toString() && + a.worktreeBranchName === b.worktreeBranchName && a.providerType === b.providerType ); } @@ -514,6 +573,104 @@ export class SessionsManagementService extends Disposable implements ISessionsMa await this.agentSessionsService.model.resolve(AgentSessionProviders.Background); } + getGitHubContext(session: IActiveSessionItem): IGitHubSessionContext | undefined { + // 1. Try parsing a github-remote-file URI (Cloud sessions) + const repoUri = session.repository; + if (repoUri && repoUri.scheme === GITHUB_REMOTE_FILE_SCHEME) { + const parts = repoUri.path.split('/').filter(Boolean); + if (parts.length >= 2) { + const owner = decodeURIComponent(parts[0]); + const repo = decodeURIComponent(parts[1]); + const prNumber = this._parsePRNumberFromSession(session); + return { owner, repo, prNumber }; + } + } + + // 2. Try from agent session metadata (Background sessions) + const agentSession = this.agentSessionsService.model.getSession(session.resource); + if (agentSession?.metadata) { + const metadata = agentSession.metadata; + + // owner + name fields + if (typeof metadata.owner === 'string' && typeof metadata.name === 'string') { + const prNumber = this._parsePRNumberFromSession(session); + return { owner: metadata.owner, repo: metadata.name, prNumber }; + } + + // repositoryNwo: "owner/repo" + if (typeof metadata.repositoryNwo === 'string') { + const parts = (metadata.repositoryNwo as string).split('/'); + if (parts.length === 2) { + const prNumber = this._parsePRNumberFromSession(session); + return { owner: parts[0], repo: parts[1], prNumber }; + } + } + + // pullRequestUrl: "https://github.com/{owner}/{repo}/pull/{number}" + if (typeof metadata.pullRequestUrl === 'string') { + const match = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(metadata.pullRequestUrl as string); + if (match) { + return { owner: match[1], repo: match[2], prNumber: parseInt(match[3], 10) }; + } + } + } + + return undefined; + } + + getGitHubContextForSession(sessionResource: URI): IGitHubSessionContext | undefined { + const agentSession = this.agentSessionsService.model.getSession(sessionResource); + if (!agentSession) { + return undefined; + } + const [repository, worktree] = this.getRepositoryFromMetadata(agentSession); + return this.getGitHubContext({ + resource: sessionResource, + isUntitled: false, + label: agentSession.label, + repository, + worktree, + worktreeBranchName: undefined, + providerType: agentSession.providerType, + }); + } + + resolveSessionFileUri(sessionResource: URI, relativePath: string): URI | undefined { + const agentSession = this.agentSessionsService.model.getSession(sessionResource); + if (!agentSession) { + return undefined; + } + const [repository, worktree] = this.getRepositoryFromMetadata(agentSession); + const baseUri = worktree ?? repository; + if (!baseUri) { + return undefined; + } + return URI.joinPath(baseUri, relativePath); + } + + private _parsePRNumberFromSession(session: IActiveSessionItem): number | undefined { + const agentSession = this.agentSessionsService.model.getSession(session.resource); + const metadata = agentSession?.metadata; + if (!metadata) { + return undefined; + } + + // Direct prNumber field + if (typeof metadata.pullRequestNumber === 'number') { + return metadata.pullRequestNumber as number; + } + + // Parse from pullRequestUrl: https://github.com/{owner}/{repo}/pull/{number} + if (typeof metadata.pullRequestUrl === 'string') { + const match = /\/pull\/(\d+)/.exec(metadata.pullRequestUrl as string); + if (match) { + return parseInt(match[1], 10); + } + } + + return undefined; + } + private loadLastSelectedSession(): URI | undefined { const cached = this.storageService.get(LAST_SELECTED_SESSION_KEY, StorageScope.WORKSPACE); if (!cached) { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 5590f70761a..08ae2d0a1e0 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -4,17 +4,23 @@ *--------------------------------------------------------------------------------------------*/ import './media/sessionsTitleBarWidget.css'; -import { $, addDisposableListener, EventType, reset } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, EventType, getActiveWindow, reset } from '../../../../base/browser/dom.js'; +import { Separator } from '../../../../base/common/actions.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { MarshalledId } from '../../../../base/common/marshallingIds.js'; +import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; import { localize } from '../../../../nls.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IMenuService, MenuId, MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IMarshalledAgentSessionContext, getAgentChangesSummary, hasValidDiff } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { Menus } from '../../../browser/menus.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; @@ -26,7 +32,8 @@ import { AgentSessionsPicker } from '../../../../workbench/contrib/chat/browser/ import { autorun } from '../../../../base/common/observable.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { getAgentSessionProvider, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { basename } from '../../../../base/common/resources.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; @@ -65,6 +72,10 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, @IChatService private readonly chatService: IChatService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IMenuService private readonly menuService: IMenuService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, ) { super(undefined, action, options); @@ -117,9 +128,10 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { const label = this._getActiveSessionLabel(); const icon = this._getActiveSessionIcon(); const repoLabel = this._getRepositoryLabel(); + const changesSummary = this._getChangesSummary(); // Build a render-state key from all displayed data - const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}`; + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${changesSummary?.insertions ?? ''}|${changesSummary?.deletions ?? ''}`; // Skip re-render if state hasn't changed if (this._lastRenderState === renderState) { @@ -164,6 +176,25 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { centerGroup.appendChild(repoEl); } + // Changes summary shown next to the repo + if (changesSummary) { + const separator2 = $('span.agent-sessions-titlebar-separator'); + separator2.textContent = '\u00B7'; + centerGroup.appendChild(separator2); + + const changesEl = $('span.agent-sessions-titlebar-changes'); + + const addedEl = $('span.agent-sessions-titlebar-changes-added'); + addedEl.textContent = `+${changesSummary.insertions}`; + changesEl.appendChild(addedEl); + + const removedEl = $('span.agent-sessions-titlebar-changes-removed'); + removedEl.textContent = `-${changesSummary.deletions}`; + changesEl.appendChild(removedEl); + + centerGroup.appendChild(changesEl); + } + sessionPill.appendChild(centerGroup); // Click handler on pill - show sessions picker @@ -176,17 +207,14 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { e.stopPropagation(); this._showSessionsPicker(); })); + this._dynamicDisposables.add(addDisposableListener(sessionPill, EventType.CONTEXT_MENU, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._showContextMenu(e); + })); this._container.appendChild(sessionPill); - // Session title actions toolbar (rendered next to the session title) - const actionsContainer = $('span.agent-sessions-titlebar-actions'); - this._dynamicDisposables.add(this.instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, Menus.SessionTitleActions, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - toolbarOptions: { primaryGroup: () => true }, - })); - this._container.appendChild(actionsContainer); - // Hover this._dynamicDisposables.add(this.hoverService.setupManagedHover( getDefaultHoverDelegate('mouse'), @@ -233,20 +261,19 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { /** * Get the label of the active chat session. - * Prefers the live model title over the snapshot label from the active session service. - * Falls back to a generic label if no active session is found. */ private _getActiveSessionLabel(): string { const activeSession = this.activeSessionService.getActiveSession(); - if (activeSession?.resource) { - const model = this.chatService.getSession(activeSession.resource); - if (model?.title) { - return model.title; - } + const label = activeSession?.label; + if (label) { + return label; // prefer session label to support renamed sessions } - if (activeSession?.label) { - return activeSession.label; + if (activeSession) { + const activeModel = this.chatService.getSession(activeSession.resource); + if (activeModel?.title) { + return activeModel.title; // fall back to chat model title if available + } } return localize('agentSessions.newSession', "New Session"); @@ -264,6 +291,12 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { // Try to get icon from the agent session model (has provider-resolved icon) const agentSession = this.agentSessionsService.getSession(activeSession.resource); if (agentSession) { + // For background sessions, distinguish worktree vs folder based on metadata + if (agentSession.providerType === AgentSessionProviders.Background) { + const hasWorktree = typeof agentSession.metadata?.worktreePath === 'string'; + return hasWorktree ? Codicon.worktree : Codicon.folder; + } + return agentSession.icon; } @@ -293,6 +326,60 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { return basename(uri); } + private _showContextMenu(e: MouseEvent): void { + const activeSession = this.activeSessionService.getActiveSession(); + if (!activeSession) { + return; + } + + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + if (!agentSession) { + return; + } + + this.chatSessionsService.activateChatSessionItemProvider(agentSession.providerType); + + const contextOverlay: Array<[string, boolean | string]> = [ + [ChatContextKeys.isArchivedAgentSession.key, agentSession.isArchived()], + [ChatContextKeys.isReadAgentSession.key, agentSession.isRead()], + [ChatContextKeys.agentSessionType.key, agentSession.providerType], + ]; + + const menu = this.menuService.createMenu(MenuId.AgentSessionsContext, this.contextKeyService.createOverlay(contextOverlay)); + + const marshalledContext: IMarshalledAgentSessionContext = { + session: agentSession, + sessions: [agentSession], + $mid: MarshalledId.AgentSessionContext, + }; + + this.contextMenuService.showContextMenu({ + getActions: () => Separator.join(...menu.getActions({ arg: marshalledContext, shouldForwardArgs: true }).map(([, actions]) => actions)), + getAnchor: () => new StandardMouseEvent(getActiveWindow(), e), + getActionsContext: () => marshalledContext + }); + + menu.dispose(); + } + + /** + * Get the changes summary for the active session. + */ + private _getChangesSummary(): { insertions: number; deletions: number } | undefined { + const activeSession = this.activeSessionService.getActiveSession(); + if (!activeSession) { + return undefined; + } + + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + const changes = agentSession?.changes; + if (!changes || !hasValidDiff(changes)) { + return undefined; + } + + return getAgentChangesSummary(changes); + } + private _showSessionsPicker(): void { const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined, { overrideSessionOpen: (session, openOptions) => this.activeSessionService.openSession(session.resource, openOptions) @@ -318,14 +405,14 @@ export class SessionsTitleBarContribution extends Disposable implements IWorkben // Register the submenu item in the Agent Sessions command center this._register(MenuRegistry.appendMenuItem(Menus.CommandCenter, { - submenu: Menus.TitleBarControlMenu, + submenu: Menus.TitleBarSessionTitle, title: localize('agentSessionsControl', "Agent Sessions"), order: 101, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.negate(), SessionsWelcomeVisibleContext.negate()) })); // Register a placeholder action so the submenu appears - this._register(MenuRegistry.appendMenuItem(Menus.TitleBarControlMenu, { + this._register(MenuRegistry.appendMenuItem(Menus.TitleBarSessionTitle, { command: { id: FocusAgentSessionsAction.id, title: localize('showSessions', "Show Sessions"), @@ -335,7 +422,7 @@ export class SessionsTitleBarContribution extends Disposable implements IWorkben when: IsAuxiliaryWindowContext.negate() })); - this._register(actionViewItemService.register(Menus.CommandCenter, Menus.TitleBarControlMenu, (action, options) => { + this._register(actionViewItemService.register(Menus.CommandCenter, Menus.TitleBarSessionTitle, (action, options) => { if (!(action instanceof SubmenuItemAction)) { return undefined; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index d4c0b5dbfc7..cb9a8794025 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -3,16 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import '../../../browser/media/sidebarActionButton.css'; -import './media/customizationsToolbar.css'; import './media/sessionsViewPane.css'; import * as DOM from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { EditorsVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -29,9 +25,6 @@ import { localize, localize2 } from '../../../../nls.js'; import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; -import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; @@ -40,26 +33,24 @@ import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ACTION_ID_NEW_CHAT } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { Menus } from '../../../browser/menus.js'; -import { getCustomizationTotalCount } from './customizationCounts.js'; +import { AICustomizationShortcutsWidget } from './aiCustomizationShortcutsWidget.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IHostService } from '../../../../workbench/services/host/browser/host.js'; const $ = DOM.$; export const SessionsViewId = 'agentic.workbench.view.sessionsView'; const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); - -const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; +const IsGroupedByRepositoryContext = new RawContextKey('sessionsView.isGroupedByRepository', false); +const GROUPING_STORAGE_KEY = 'agentSessions.grouping'; export class AgenticSessionsViewPane extends ViewPane { private viewPaneContainer: HTMLElement | undefined; private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; - private aiCustomizationContainer: HTMLElement | undefined; + private currentGrouping: AgentSessionsGrouping = AgentSessionsGrouping.Date; + private isGroupedByRepoKey: ReturnType | undefined; constructor( options: IViewPaneOptions, @@ -73,15 +64,17 @@ export class AgenticSessionsViewPane extends ViewPane { @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @IStorageService private readonly storageService: IStorageService, - @IPromptsService private readonly promptsService: IPromptsService, - @IMcpService private readonly mcpService: IMcpService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, @IHostService private readonly hostService: IHostService, - @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, + @IStorageService private readonly storageService: IStorageService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); + + // Restore persisted grouping + const stored = this.storageService.get(GROUPING_STORAGE_KEY, StorageScope.PROFILE); + if (stored && Object.values(AgentSessionsGrouping).includes(stored as AgentSessionsGrouping)) { + this.currentGrouping = stored as AgentSessionsGrouping; + } } protected override renderBody(parent: HTMLElement): void { @@ -108,10 +101,14 @@ export class AgenticSessionsViewPane extends ViewPane { private createControls(parent: HTMLElement): void { const sessionsContainer = DOM.append(parent, $('.agent-sessions-container')); + // Track grouping state via context key for the toggle button + const isGroupedByRepoKey = this.isGroupedByRepoKey = IsGroupedByRepositoryContext.bindTo(this.contextKeyService); + isGroupedByRepoKey.set(this.currentGrouping === AgentSessionsGrouping.Repository); + // Sessions Filter (actions go to view title bar via menu registration) const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { filterMenuId: SessionsViewFilterSubMenu, - groupResults: () => AgentSessionsGrouping.Date, + groupResults: () => this.currentGrouping, allowedProviders: [AgentSessionProviders.Background, AgentSessionProviders.Cloud], providerLabelOverrides: new Map([ [AgentSessionProviders.Background, localize('chat.session.providerLabel.local', "Local")], @@ -144,6 +141,8 @@ export class AgenticSessionsViewPane extends ViewPane { filter: sessionsFilter, overrideStyles: this.getLocationBasedColors().listOverrideStyles, disableHover: true, + showIsolationIcon: true, + enableApprovalRow: true, getHoverPosition: () => this.getSessionHoverPosition(), trackActiveEditorSession: () => true, collapseOlderSections: () => true, @@ -178,8 +177,14 @@ export class AgenticSessionsViewPane extends ViewPane { })); // AI Customization toolbar (bottom, fixed height) - this.aiCustomizationContainer = DOM.append(sessionsContainer, $('div')); - this.createAICustomizationShortcuts(this.aiCustomizationContainer); + this._register(this.instantiationService.createInstance(AICustomizationShortcutsWidget, sessionsContainer, { + onDidToggleCollapse: () => { + if (this.viewPaneContainer) { + const { offsetHeight, offsetWidth } = this.viewPaneContainer; + this.layoutBody(offsetHeight, offsetWidth); + } + }, + })); } private restoreLastSelectedSession(): void { @@ -189,96 +194,6 @@ export class AgenticSessionsViewPane extends ViewPane { } } - private createAICustomizationShortcuts(container: HTMLElement): void { - // Get initial collapsed state - const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false); - - container.classList.add('ai-customization-toolbar'); - if (isCollapsed) { - container.classList.add('collapsed'); - } - - // Header (clickable to toggle) - const header = DOM.append(container, $('.ai-customization-header')); - header.classList.toggle('collapsed', isCollapsed); - - const headerButtonContainer = DOM.append(header, $('.customization-link-button-container')); - const headerButton = this._register(new Button(headerButtonContainer, { - ...defaultButtonStyles, - secondary: true, - title: false, - supportIcons: true, - buttonSecondaryBackground: 'transparent', - buttonSecondaryHoverBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryBorder: undefined, - })); - headerButton.element.classList.add('customization-link-button', 'sidebar-action-button'); - headerButton.element.setAttribute('aria-expanded', String(!isCollapsed)); - headerButton.label = localize('customizations', "CUSTOMIZATIONS"); - - const chevronContainer = DOM.append(headerButton.element, $('span.customization-link-counts')); - const chevron = DOM.append(chevronContainer, $('.ai-customization-chevron')); - const headerTotalCount = DOM.append(chevronContainer, $('span.ai-customization-header-total.hidden')); - chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); - - // Toolbar container - const toolbarContainer = DOM.append(container, $('.ai-customization-toolbar-content.sidebar-action-list')); - - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarCustomizations, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - toolbarOptions: { primaryGroup: () => true }, - telemetrySource: 'sidebarCustomizations', - })); - - let updateCountRequestId = 0; - const updateHeaderTotalCount = async () => { - const requestId = ++updateCountRequestId; - const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService); - if (requestId !== updateCountRequestId) { - return; - } - - headerTotalCount.classList.toggle('hidden', totalCount === 0); - headerTotalCount.textContent = `${totalCount}`; - }; - - this._register(this.promptsService.onDidChangeCustomAgents(() => updateHeaderTotalCount())); - this._register(this.promptsService.onDidChangeSlashCommands(() => updateHeaderTotalCount())); - this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => updateHeaderTotalCount())); - this._register(autorun(reader => { - this.mcpService.servers.read(reader); - updateHeaderTotalCount(); - })); - this._register(autorun(reader => { - this.workspaceService.activeProjectRoot.read(reader); - updateHeaderTotalCount(); - })); - updateHeaderTotalCount(); - - // Toggle collapse on header click - const transitionListener = this._register(new MutableDisposable()); - const toggleCollapse = () => { - const collapsed = container.classList.toggle('collapsed'); - header.classList.toggle('collapsed', collapsed); - this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER); - headerButton.element.setAttribute('aria-expanded', String(!collapsed)); - chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown)); - chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); - - // Re-layout after the transition so sessions control gets the right height - transitionListener.value = DOM.addDisposableListener(toolbarContainer, 'transitionend', () => { - transitionListener.clear(); - if (this.viewPaneContainer) { - const { offsetHeight, offsetWidth } = this.viewPaneContainer; - this.layoutBody(offsetHeight, offsetWidth); - } - }); - }; - - this._register(headerButton.onDidClick(() => toggleCollapse())); - } - private getSessionHoverPosition(): HoverPosition { const viewLocation = this.viewDescriptorService.getViewLocationById(this.id); const sideBarPosition = this.layoutService.getSideBarPosition(); @@ -314,6 +229,18 @@ export class AgenticSessionsViewPane extends ViewPane { openFind(): void { this.sessionsControl?.openFind(); } + + toggleGroupByRepository(): void { + if (this.currentGrouping === AgentSessionsGrouping.Repository) { + this.currentGrouping = AgentSessionsGrouping.Date; + } else { + this.currentGrouping = AgentSessionsGrouping.Repository; + } + + this.storageService.store(GROUPING_STORAGE_KEY, this.currentGrouping, StorageScope.PROFILE, StorageTarget.USER); + this.isGroupedByRepoKey?.set(this.currentGrouping === AgentSessionsGrouping.Repository); + this.sessionsControl?.update(); + } } // Register Cmd+N / Ctrl+N keybinding for new session in the agent sessions window @@ -323,10 +250,27 @@ KeybindingsRegistry.registerKeybindingRule({ primary: KeyMod.CtrlCmd | KeyCode.KeyN, }); -// Register Cmd+W / Ctrl+W to open new session when the current session is non-empty, +const CLOSE_SESSION_COMMAND_ID = 'agentSession.close'; +registerAction2(class CloseSessionAction extends Action2 { + constructor() { + super({ + id: CLOSE_SESSION_COMMAND_ID, + title: localize2('closeSession', "Close Session"), + f1: true, + precondition: ContextKeyExpr.and(IsNewChatSessionContext.negate(), EditorsVisibleContext.negate()), + category: SessionsCategories.Sessions, + }); + } + override async run(accessor: ServicesAccessor) { + const sessionsService = accessor.get(ISessionsManagementService); + await sessionsService.openNewSessionView(); + } +}); + +// Register Cmd+W / Ctrl+W to close the current session and navigate to the new-session view, // mirroring how Cmd+W closes the active editor in the normal workbench. KeybindingsRegistry.registerKeybindingRule({ - id: ACTION_ID_NEW_CHAT, + id: CLOSE_SESSION_COMMAND_ID, weight: KeybindingWeight.WorkbenchContrib + 1, when: ContextKeyExpr.and(IsNewChatSessionContext.negate(), EditorsVisibleContext.negate()), primary: KeyMod.CtrlCmd | KeyCode.KeyW, @@ -342,6 +286,52 @@ MenuRegistry.appendMenuItem(MenuId.ViewTitle, { when: ContextKeyExpr.equals('view', SessionsViewId) } satisfies ISubmenuItem); +registerAction2(class GroupByRepositoryAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.groupByRepository', + title: localize2('groupByRepository', "Group by Repository"), + icon: Codicon.repo, + category: SessionsCategories.Sessions, + menu: [{ + id: MenuId.ViewTitle, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', SessionsViewId), IsGroupedByRepositoryContext.negate()), + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.toggleGroupByRepository(); + } +}); + +registerAction2(class GroupByDateAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.groupByDate', + title: localize2('groupByDate', "Group by Date"), + icon: Codicon.history, + category: SessionsCategories.Sessions, + menu: [{ + id: MenuId.ViewTitle, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', SessionsViewId), IsGroupedByRepositoryContext), + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.toggleGroupByRepository(); + } +}); + registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { constructor() { super({ diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts new file mode 100644 index 00000000000..0f4a7bf7a28 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -0,0 +1,271 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toAction } from '../../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IActionViewItemFactory, IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { IMenu, IMenuActionOptions, IMenuService, isIMenuItem, MenuId, MenuItemAction, MenuRegistry, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IPromptsService, PromptsStorage } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { IMcpServer, IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAgentPluginService } from '../../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; +import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem } from '../../browser/customizationsToolbar.contribution.js'; +import { IActiveSessionItem, ISessionsManagementService } from '../../browser/sessionsManagementService.js'; +import { Menus } from '../../../../browser/menus.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; +import '../../../../../platform/theme/common/colors/inputColors.js'; + +// ============================================================================ +// One-time menu item registration (module-level). +// MenuRegistry.appendMenuItem does not throw on duplicates, unlike registerAction2 +// which registers global commands and throws on the second call. +// ============================================================================ + +const menuRegistrations = new DisposableStore(); +for (const [index, config] of CUSTOMIZATION_ITEMS.entries()) { + menuRegistrations.add(MenuRegistry.appendMenuItem(Menus.SidebarCustomizations, { + command: { id: config.id, title: config.label }, + group: 'navigation', + order: index + 1, + })); +} + +// ============================================================================ +// FixtureMenuService — reads from MenuRegistry without context-key filtering +// (MockContextKeyService.contextMatchesRules always returns false, which hides +// every item when using the real MenuService.) +// ============================================================================ + +class FixtureMenuService implements IMenuService { + declare readonly _serviceBrand: undefined; + + createMenu(id: MenuId): IMenu { + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => { + const items = MenuRegistry.getMenuItems(id).filter(isIMenuItem); + items.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + const actions = items.map(item => { + const title = typeof item.command.title === 'string' ? item.command.title : item.command.title.value; + return toAction({ id: item.command.id, label: title, run: () => { } }); + }); + return actions.length ? [['navigation', actions as unknown as (MenuItemAction | SubmenuItemAction)[]]] : []; + }, + }; + } + + getMenuActions(_id: MenuId, _contextKeyService: unknown, _options?: IMenuActionOptions) { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + +// ============================================================================ +// Minimal IActionViewItemService that supports register/lookUp +// ============================================================================ + +class FixtureActionViewItemService implements IActionViewItemService { + declare _serviceBrand: undefined; + + private readonly _providers = new Map(); + private readonly _onDidChange = new Emitter(); + readonly onDidChange = this._onDidChange.event; + + register(menu: MenuId, commandId: string | MenuId, provider: IActionViewItemFactory): { dispose(): void } { + const key = `${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`; + this._providers.set(key, provider); + return { dispose: () => { this._providers.delete(key); } }; + } + + lookUp(menu: MenuId, commandId: string | MenuId): IActionViewItemFactory | undefined { + const key = `${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`; + return this._providers.get(key); + } +} + +// ============================================================================ +// Mock helpers +// ============================================================================ + +const defaultFilter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension], +}; + +function createMockPromptsService(): IPromptsService { + return createMockPromptsServiceWithCounts(); +} + +interface ICustomizationCounts { + readonly agents?: number; + readonly skills?: number; + readonly instructions?: number; + readonly prompts?: number; + readonly hooks?: number; +} + +function createMockPromptsServiceWithCounts(counts?: ICustomizationCounts): IPromptsService { + const fakeUri = (prefix: string, i: number) => URI.parse(`file:///mock/${prefix}-${i}.md`); + const fakeItem = (prefix: string, i: number) => ({ uri: fakeUri(prefix, i), storage: PromptsStorage.local }); + + const agents = Array.from({ length: counts?.agents ?? 0 }, (_, i) => ({ + uri: fakeUri('agent', i), + source: { storage: PromptsStorage.local }, + })); + const skills = Array.from({ length: counts?.skills ?? 0 }, (_, i) => fakeItem('skill', i)); + const prompts = Array.from({ length: counts?.prompts ?? 0 }, (_, i) => ({ + promptPath: { uri: fakeUri('prompt', i), storage: PromptsStorage.local, type: PromptsType.prompt }, + })); + const instructions = Array.from({ length: counts?.instructions ?? 0 }, (_, i) => fakeItem('instructions', i)); + const hooks = Array.from({ length: counts?.hooks ?? 0 }, (_, i) => fakeItem('hook', i)); + + return new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeSlashCommands = Event.None; + override async getCustomAgents() { return agents as never[]; } + override async findAgentSkills() { return skills as never[]; } + override async getPromptSlashCommands() { return prompts as never[]; } + override async listPromptFiles(type: PromptsType) { + return (type === PromptsType.hook ? hooks : instructions) as never[]; + } + override async listAgentInstructions() { return [] as never[]; } + }(); +} + +function createMockMcpService(serverCount: number = 0): IMcpService { + const MockServer = mock(); + const servers = observableValue('mockMcpServers', Array.from({ length: serverCount }, () => new MockServer())); + return new class extends mock() { + override readonly servers = servers; + }(); +} + +function createMockWorkspaceService(): IAICustomizationWorkspaceService { + const activeProjectRoot = observableValue('mockActiveProjectRoot', undefined); + return new class extends mock() { + override readonly activeProjectRoot = activeProjectRoot; + override getActiveProjectRoot() { return undefined; } + override getStorageSourceFilter() { return defaultFilter; } + }(); +} + +function createMockWorkspaceContextService(): IWorkspaceContextService { + return new class extends mock() { + override readonly onDidChangeWorkspaceFolders = Event.None; + override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; } + }(); +} + +// ============================================================================ +// Render helper +// ============================================================================ + +function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?: number; collapsed?: boolean; counts?: ICustomizationCounts }): void { + ctx.container.style.width = '300px'; + ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const actionViewItemService = new FixtureActionViewItemService(); + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + // Register overrides BEFORE registerWorkbenchServices so they take priority + reg.defineInstance(IMenuService, new FixtureMenuService()); + reg.defineInstance(IActionViewItemService, actionViewItemService); + registerWorkbenchServices(reg); + // Services needed by AICustomizationShortcutsWidget + reg.defineInstance(IPromptsService, options?.counts ? createMockPromptsServiceWithCounts(options.counts) : createMockPromptsService()); + reg.defineInstance(IMcpService, createMockMcpService(options?.mcpServerCount ?? 0)); + reg.defineInstance(IAICustomizationWorkspaceService, createMockWorkspaceService()); + reg.defineInstance(IWorkspaceContextService, createMockWorkspaceContextService()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = observableValue('mockPlugins', []); + }()); + // Additional services needed by CustomizationLinkViewItem + reg.defineInstance(ILanguageModelsService, new class extends mock() { + override readonly onDidChangeLanguageModels = Event.None; + }()); + reg.defineInstance(ISessionsManagementService, new class extends mock() { + override readonly activeSession = observableValue('activeSession', undefined); + }()); + reg.defineInstance(IFileService, new class extends mock() { + override readonly onDidFilesChange = Event.None; + }()); + }, + }); + + // Register view item factories from the real CustomizationLinkViewItem (per-render, instance-scoped) + for (const config of CUSTOMIZATION_ITEMS) { + ctx.disposableStore.add(actionViewItemService.register(Menus.SidebarCustomizations, config.id, (action, options) => { + return instantiationService.createInstance(CustomizationLinkViewItem, action, options, config); + })); + } + + // Override storage to set initial collapsed state + if (options?.collapsed) { + const storageService = instantiationService.get(IStorageService); + instantiationService.set(IStorageService, new class extends mock() { + override getBoolean(key: string, scope: StorageScope, fallbackValue?: boolean) { + if (key === 'agentSessions.customizationsCollapsed') { + return true; + } + return storageService.getBoolean(key, scope, fallbackValue!); + } + override store() { } + }()); + } + + // Create the widget (uses FixtureMenuService → reads MenuRegistry items registered above) + ctx.disposableStore.add( + instantiationService.createInstance(AICustomizationShortcutsWidget, ctx.container, undefined) + ); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + + Expanded: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx), + }), + + Collapsed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { collapsed: true }), + }), + + WithMcpServers: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { mcpServerCount: 3 }), + }), + + CollapsedWithMcpServers: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { mcpServerCount: 3, collapsed: true }), + }), + + WithCounts: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { + mcpServerCount: 2, + counts: { agents: 2, skills: 30, instructions: 16, prompts: 17, hooks: 4 }, + }), + }), +}); diff --git a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts index 9a3ce3ee942..02d70905b80 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/customizationCounts.test.ts @@ -126,31 +126,31 @@ suite('customizationCounts', () => { suite('getSourceCountsTotal', () => { test('sums only visible sources', () => { - const counts = { workspace: 5, user: 3, extension: 2 }; + const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; const filter: IStorageSourceFilter = { sources: [PromptsStorage.local, PromptsStorage.user] }; assert.strictEqual(getSourceCountsTotal(counts, filter), 8); }); test('returns 0 for empty sources', () => { - const counts = { workspace: 5, user: 3, extension: 2 }; + const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; const filter: IStorageSourceFilter = { sources: [] }; assert.strictEqual(getSourceCountsTotal(counts, filter), 0); }); test('sums all sources', () => { - const counts = { workspace: 5, user: 3, extension: 2 }; + const counts = { workspace: 5, user: 3, extension: 2, builtin: 0 }; const filter: IStorageSourceFilter = { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension] }; assert.strictEqual(getSourceCountsTotal(counts, filter), 10); }); test('handles single source', () => { - const counts = { workspace: 7, user: 0, extension: 0 }; + const counts = { workspace: 7, user: 0, extension: 0, builtin: 0 }; const filter: IStorageSourceFilter = { sources: [PromptsStorage.local] }; assert.strictEqual(getSourceCountsTotal(counts, filter), 7); }); test('ignores plugin storage in totals (not in ISourceCounts)', () => { - const counts = { workspace: 1, user: 1, extension: 1 }; + const counts = { workspace: 1, user: 1, extension: 1, builtin: 0 }; const filter: IStorageSourceFilter = { sources: [PromptsStorage.plugin] }; assert.strictEqual(getSourceCountsTotal(counts, filter), 0); }); @@ -334,7 +334,7 @@ suite('customizationCounts', () => { workspaceService, ); - assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 1 }); + assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 1, builtin: 0 }); }); test('empty agents returns all zeros', async () => { @@ -348,7 +348,7 @@ suite('customizationCounts', () => { contextService, workspaceService, ); - assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0 }); + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); }); }); @@ -386,7 +386,7 @@ suite('customizationCounts', () => { contextService, workspaceService, ); - assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0 }); + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); }); test('skills filtered by storage source filter', async () => { @@ -450,7 +450,7 @@ suite('customizationCounts', () => { contextService, workspaceService, ); - assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 0 }); + assert.deepStrictEqual(counts, { workspace: 1, user: 1, extension: 0, builtin: 0 }); }); test('all skills are excluded from prompt counts', async () => { @@ -469,7 +469,7 @@ suite('customizationCounts', () => { contextService, workspaceService, ); - assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0 }); + assert.deepStrictEqual(counts, { workspace: 0, user: 0, extension: 0, builtin: 0 }); }); }); diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 269d3a63a23..0469553aa77 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -14,13 +14,16 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; +import { TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; /** * Returns the cwd URI for the given session: worktree or repository path for @@ -37,16 +40,14 @@ function getSessionCwd(session: IActiveSessionItem | undefined): URI | undefined /** * Manages terminal instances in the sessions window, ensuring: * - A terminal exists for the active session's worktree (or repository if no worktree). - * - A path→instanceId mapping tracks which terminal belongs to which worktree. + * - Terminals are shown/hidden based on their initial cwd matching the active path. * - All terminals for a worktree are closed when the session is archived. */ export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsTerminal'; - /** Maps worktree/repository fsPath (lower-cased) to the terminal instance id. */ - private readonly _pathToInstanceId = new Map(); - private _lastTargetFsPath: string | undefined; + private _activeKey: string | undefined; constructor( @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @@ -63,6 +64,20 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben this._onActiveSessionChanged(session); })); + // Hide restored terminals from a previous window session that don't + // belong to the current active session. These arrive asynchronously + // during reconnection and would otherwise flash in the foreground. + this._register(this._terminalService.onDidCreateInstance(instance => { + if (instance.shellLaunchConfig.attachPersistentProcess && this._activeKey) { + instance.getInitialCwd().then(cwd => { + if (cwd.toLowerCase() !== this._activeKey) { + this._terminalService.moveToBackground(instance); + this._logService.trace(`[SessionsTerminal] Hid restored terminal ${instance.instanceId} (cwd: ${cwd})`); + } + }); + } + })); + // When a session is archived, close all terminals for its worktree this._register(this._agentSessionsService.model.onDidChangeSessionArchivedState(session => { if (session.isArchived()) { @@ -72,40 +87,28 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben } } })); - - // Clean up mapping when terminals are disposed - this._register(this._terminalService.onDidDisposeInstance(instance => { - for (const [path, id] of this._pathToInstanceId) { - if (id === instance.instanceId) { - this._pathToInstanceId.delete(path); - break; - } - } - })); } /** - * Ensures a terminal exists for the given cwd, reusing an existing one - * from the mapping or creating a new one. Sets it as active and optionally - * focuses it. + * Ensures a terminal exists for the given cwd by scanning all terminal + * instances for a matching initial cwd. If none is found, creates a new + * one. Sets it as active and optionally focuses it. */ - async ensureTerminal(cwd: URI, focus: boolean): Promise { + async ensureTerminal(cwd: URI, focus: boolean): Promise { const key = cwd.fsPath.toLowerCase(); - const existingId = this._pathToInstanceId.get(key); - const existing = existingId !== undefined ? this._terminalService.getInstanceFromId(existingId) : undefined; + let existing = await this._findTerminalsForKey(key); - if (existing) { - this._terminalService.setActiveInstance(existing); - } else { - const instance = await this._terminalService.createTerminal({ config: { cwd } }); - this._pathToInstanceId.set(key, instance.instanceId); - this._terminalService.setActiveInstance(instance); - this._logService.trace(`[SessionsTerminal] Created terminal ${instance.instanceId} for ${cwd.fsPath}`); + if (existing.length === 0) { + existing = [await this._terminalService.createTerminal({ config: { cwd } })]; + this._terminalService.setActiveInstance(existing[0]); + this._logService.trace(`[SessionsTerminal] Created terminal ${existing[0].instanceId} for ${cwd.fsPath}`); } if (focus) { await this._terminalService.focusActiveInstance(); } + + return existing; } private async _onActiveSessionChanged(session: IActiveSessionItem | undefined): Promise { @@ -116,25 +119,123 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben const sessionCwd = getSessionCwd(session); const targetPath = sessionCwd ?? await this._pathService.userHome(); - const targetFsPath = targetPath.fsPath; - if (this._lastTargetFsPath?.toLowerCase() === targetFsPath.toLowerCase()) { + const targetKey = targetPath.fsPath.toLowerCase(); + if (this._activeKey === targetKey) { return; } - this._lastTargetFsPath = targetFsPath; + this._activeKey = targetKey; - await this.ensureTerminal(targetPath, false); + const instances = await this.ensureTerminal(targetPath, false); + + // If the active key changed while we were awaiting, a newer call has + // taken over — skip the visibility update to avoid flicker. + if (this._activeKey !== targetKey) { + return; + } + await this._updateTerminalVisibility(targetKey, instances.map(instance => instance.instanceId)); } - private _closeTerminalsForPath(fsPath: string): void { - const key = fsPath.toLowerCase(); - const instanceId = this._pathToInstanceId.get(key); - if (instanceId !== undefined) { - const instance = this._terminalService.getInstanceFromId(instanceId); - if (instance) { - this._terminalService.safeDisposeTerminal(instance); - this._logService.trace(`[SessionsTerminal] Closed archived terminal ${instanceId}`); + /** + * Finds the first terminal instance whose initial cwd (lower-cased) matches + * the given key. + */ + private async _findTerminalsForKey(key: string): Promise { + const result: ITerminalInstance[] = []; + for (const instance of this._terminalService.instances) { + try { + const cwd = await instance.getInitialCwd(); + if (cwd.toLowerCase() === key) { + result.push(instance); + } + } catch { + // ignore terminals whose cwd cannot be resolved + } + } + return result; + } + + /** + * Shows background terminals whose initial cwd matches the active key and + * hides foreground terminals whose initial cwd does not match. + */ + private async _updateTerminalVisibility(activeKey: string, forceForegroundTerminalIds: number[]): Promise { + const toShow: ITerminalInstance[] = []; + const toHide: ITerminalInstance[] = []; + + for (const instance of [...this._terminalService.instances]) { + let cwd: string | undefined; + try { + cwd = (await instance.getInitialCwd()).toLowerCase(); + } catch { + continue; + } + + const isForeground = this._terminalService.foregroundInstances.includes(instance); + const isForceVisible = forceForegroundTerminalIds.includes(instance.instanceId); + const belongsToActiveSession = cwd === activeKey; + if ((belongsToActiveSession || isForceVisible) && !isForeground) { + toShow.push(instance); + } else if (!belongsToActiveSession && !isForceVisible && isForeground) { + toHide.push(instance); + } + } + + for (const instance of toShow) { + await this._terminalService.showBackgroundTerminal(instance, true); + } + for (const instance of toHide) { + this._terminalService.moveToBackground(instance); + } + + // Set the terminal with the most recent command as active + const foreground = this._terminalService.foregroundInstances; + let mostRecent: ITerminalInstance | undefined; + let mostRecentTimestamp = -1; + for (const instance of foreground) { + const cmdDetection = instance.capabilities.get(TerminalCapability.CommandDetection); + const lastCmd = cmdDetection?.commands.at(-1); + if (lastCmd && lastCmd.timestamp > mostRecentTimestamp) { + mostRecentTimestamp = lastCmd.timestamp; + mostRecent = instance; + } + } + if (mostRecent) { + this._terminalService.setActiveInstance(mostRecent); + } + } + + private async _closeTerminalsForPath(fsPath: string): Promise { + const key = fsPath.toLowerCase(); + for (const instance of [...this._terminalService.instances]) { + try { + const cwd = (await instance.getInitialCwd()).toLowerCase(); + if (cwd === key) { + this._terminalService.safeDisposeTerminal(instance); + this._logService.trace(`[SessionsTerminal] Closed archived terminal ${instance.instanceId}`); + } + } catch { + // ignore + } + } + } + + async dumpTracking(): Promise { + console.log(`[SessionsTerminal] Active key: ${this._activeKey ?? ''}`); + console.log('[SessionsTerminal] === All Terminals ==='); + for (const instance of this._terminalService.instances) { + let cwd = ''; + try { cwd = await instance.getInitialCwd(); } catch { /* ignored */ } + const isForeground = this._terminalService.foregroundInstances.includes(instance); + console.log(` ${instance.instanceId} - ${cwd} - ${isForeground ? 'foreground' : 'background'}`); + } + } + + async showAllTerminals(): Promise { + for (const instance of this._terminalService.instances) { + if (!this._terminalService.foregroundInstances.includes(instance)) { + await this._terminalService.showBackgroundTerminal(instance, true); + this._logService.trace(`[SessionsTerminal] Moved terminal ${instance.instanceId} to foreground`); } - this._pathToInstanceId.delete(key); } } } @@ -149,9 +250,9 @@ class OpenSessionInTerminalAction extends Action2 { title: localize2('openInTerminal', "Open Terminal"), icon: Codicon.terminal, menu: [{ - id: Menus.TitleBarRight, + id: Menus.TitleBarSessionMenu, group: 'navigation', - order: 11, + order: 9, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) }] }); @@ -161,11 +262,49 @@ class OpenSessionInTerminalAction extends Action2 { const contribution = getWorkbenchContribution(SessionsTerminalContribution.ID); const sessionsManagementService = _accessor.get(ISessionsManagementService); const pathService = _accessor.get(IPathService); + const viewsService = _accessor.get(IViewsService); const activeSession = sessionsManagementService.activeSession.get(); const cwd = getSessionCwd(activeSession) ?? await pathService.userHome(); await contribution.ensureTerminal(cwd, true); + viewsService.openView(TERMINAL_VIEW_ID); } } registerAction2(OpenSessionInTerminalAction); + +class DumpTerminalTrackingAction extends Action2 { + + constructor() { + super({ + id: 'agentSession.dumpTerminalTracking', + title: localize2('dumpTerminalTracking', "Dump Terminal Tracking"), + f1: true, + }); + } + + override async run(): Promise { + const contribution = getWorkbenchContribution(SessionsTerminalContribution.ID); + await contribution.dumpTracking(); + } +} + +registerAction2(DumpTerminalTrackingAction); + +class ShowAllTerminalsAction extends Action2 { + + constructor() { + super({ + id: 'agentSession.showAllTerminals', + title: localize2('showAllTerminals', "Show All Terminals"), + f1: true, + }); + } + + override async run(): Promise { + const contribution = getWorkbenchContribution(SessionsTerminalContribution.ID); + await contribution.showAllTerminals(); + } +} + +registerAction2(ShowAllTerminalsAction); diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 5cb061bb85e..0d84c3735df 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -6,13 +6,14 @@ import assert from 'assert'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; -import { Emitter } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { NullLogService, ILogService } from '../../../../../platform/log/common/log.js'; import { ITerminalInstance, ITerminalService } from '../../../../../workbench/contrib/terminal/browser/terminal.js'; +import { ITerminalCapabilityStore, ICommandDetectionCapability, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IAgentSession, IAgentSessionsModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; @@ -51,13 +52,35 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT } as IActiveSessionItem; } -suite('SessionsTerminalContribution', () => { +function makeTerminalInstance(id: number, cwd: string): ITerminalInstance & { _testCommandHistory: { timestamp: number }[] } { + const commandHistory: { timestamp: number }[] = []; + const capabilities = { + get(cap: TerminalCapability) { + if (cap === TerminalCapability.CommandDetection && commandHistory.length > 0) { + return { commands: commandHistory } as unknown as ICommandDetectionCapability; + } + return undefined; + } + } as ITerminalCapabilityStore; + return { + instanceId: id, + isDisposed: false, + getInitialCwd: () => Promise.resolve(cwd), + capabilities, + _testCommandHistory: commandHistory, + } as unknown as ITerminalInstance & { _testCommandHistory: { timestamp: number }[] }; +} + +function addCommandToInstance(instance: ITerminalInstance, timestamp: number): void { + (instance as ITerminalInstance & { _testCommandHistory: { timestamp: number }[] })._testCommandHistory.push({ timestamp }); +} + +suite('SessionsTerminalContribution', () => { const store = new DisposableStore(); let contribution: SessionsTerminalContribution; let activeSessionObs: ReturnType>; let onDidChangeSessionArchivedState: Emitter; - let onDidDisposeInstance: Emitter; let createdTerminals: { cwd: URI }[]; let activeInstanceSet: number[]; @@ -65,6 +88,9 @@ suite('SessionsTerminalContribution', () => { let disposedInstances: ITerminalInstance[]; let nextInstanceId: number; let terminalInstances: Map; + let backgroundedInstances: Set; + let moveToBackgroundCalls: number[]; + let showBackgroundCalls: number[]; setup(() => { createdTerminals = []; @@ -73,12 +99,14 @@ suite('SessionsTerminalContribution', () => { disposedInstances = []; nextInstanceId = 1; terminalInstances = new Map(); + backgroundedInstances = new Set(); + moveToBackgroundCalls = []; + showBackgroundCalls = []; const instantiationService = store.add(new TestInstantiationService()); activeSessionObs = observableValue('activeSession', undefined); onDidChangeSessionArchivedState = store.add(new Emitter()); - onDidDisposeInstance = store.add(new Emitter()); instantiationService.stub(ILogService, new NullLogService()); @@ -87,10 +115,18 @@ suite('SessionsTerminalContribution', () => { }); instantiationService.stub(ITerminalService, new class extends mock() { - override onDidDisposeInstance = onDidDisposeInstance.event; + override onDidCreateInstance = Event.None; + override get instances(): readonly ITerminalInstance[] { + return [...terminalInstances.values()]; + } + override get foregroundInstances(): readonly ITerminalInstance[] { + return [...terminalInstances.values()].filter(i => !backgroundedInstances.has(i.instanceId)); + } override async createTerminal(opts?: any): Promise { const id = nextInstanceId++; - const instance = { instanceId: id } as ITerminalInstance; + const cwdUri: URI | undefined = opts?.config?.cwd; + const cwdStr = cwdUri?.fsPath ?? ''; + const instance = makeTerminalInstance(id, cwdStr); createdTerminals.push({ cwd: opts?.config?.cwd }); terminalInstances.set(id, instance); return instance; @@ -107,6 +143,15 @@ suite('SessionsTerminalContribution', () => { override async safeDisposeTerminal(instance: ITerminalInstance): Promise { disposedInstances.push(instance); terminalInstances.delete(instance.instanceId); + backgroundedInstances.delete(instance.instanceId); + } + override moveToBackground(instance: ITerminalInstance): void { + backgroundedInstances.add(instance.instanceId); + moveToBackgroundCalls.push(instance.instanceId); + } + override async showBackgroundTerminal(instance: ITerminalInstance): Promise { + backgroundedInstances.delete(instance.instanceId); + showBackgroundCalls.push(instance.instanceId); } }); @@ -253,7 +298,7 @@ suite('SessionsTerminalContribution', () => { await contribution.ensureTerminal(cwd, false); assert.strictEqual(createdTerminals.length, 1, 'should reuse the existing terminal'); - assert.strictEqual(activeInstanceSet.length, 2, 'should set active instance both times'); + assert.strictEqual(activeInstanceSet.length, 1, 'should only set active instance on creation'); }); test('ensureTerminal creates new terminal for different path', async () => { @@ -283,6 +328,7 @@ suite('SessionsTerminalContribution', () => { worktreePath: worktreeUri.fsPath, }); onDidChangeSessionArchivedState.fire(session); + await tick(); assert.strictEqual(disposedInstances.length, 1); }); @@ -296,6 +342,7 @@ suite('SessionsTerminalContribution', () => { worktreePath: worktreeUri.fsPath, }); onDidChangeSessionArchivedState.fire(session); + await tick(); assert.strictEqual(disposedInstances.length, 0); }); @@ -306,27 +353,11 @@ suite('SessionsTerminalContribution', () => { const session = makeAgentSession({ isArchived: true }); onDidChangeSessionArchivedState.fire(session); + await tick(); assert.strictEqual(disposedInstances.length, 0); }); - // --- onDidDisposeInstance --- - - test('cleans up path mapping when terminal is disposed externally', async () => { - const cwd = URI.file('/test-cwd'); - await contribution.ensureTerminal(cwd, false); - assert.strictEqual(createdTerminals.length, 1); - - // Simulate external disposal of the terminal - const instanceId = activeInstanceSet[0]; - const instance = terminalInstances.get(instanceId)!; - onDidDisposeInstance.fire(instance); - - // Now ensureTerminal should create a new one since the mapping was cleaned up - await contribution.ensureTerminal(cwd, false); - assert.strictEqual(createdTerminals.length, 2, 'should create a new terminal after the old one was disposed'); - }); - // --- switching back to previously used path reuses terminal --- test('switching back to a previously used background path reuses the existing terminal', async () => { @@ -346,6 +377,166 @@ suite('SessionsTerminalContribution', () => { await tick(); assert.strictEqual(createdTerminals.length, 2, 'should reuse the terminal for cwd1'); }); + + // --- Terminal visibility management (cwd-based) --- + + test('hides terminals from previous session when switching to a new session', async () => { + const cwd1 = URI.file('/cwd1'); + const cwd2 = URI.file('/cwd2'); + + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + assert.strictEqual(createdTerminals.length, 1); + + activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // The first terminal (id=1) should have been moved to background + assert.ok(moveToBackgroundCalls.includes(1), 'terminal for cwd1 should be backgrounded'); + assert.ok(backgroundedInstances.has(1), 'terminal for cwd1 should remain backgrounded'); + }); + + test('shows previously hidden terminals when switching back to their session', async () => { + const cwd1 = URI.file('/cwd1'); + const cwd2 = URI.file('/cwd2'); + + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // Switch back to cwd1 + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // Terminal for cwd1 (id=1) should be shown again + assert.ok(showBackgroundCalls.includes(1), 'terminal for cwd1 should be shown'); + assert.ok(!backgroundedInstances.has(1), 'terminal for cwd1 should be foreground'); + // Terminal for cwd2 (id=2) should now be backgrounded + assert.ok(backgroundedInstances.has(2), 'terminal for cwd2 should be backgrounded'); + }); + + test('only terminals of the active session are visible after multiple switches', async () => { + const cwd1 = URI.file('/cwd1'); + const cwd2 = URI.file('/cwd2'); + const cwd3 = URI.file('/cwd3'); + + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + activeSessionObs.set(makeAgentSession({ worktree: cwd3, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // Only terminal for cwd3 (id=3) should be foreground + assert.ok(backgroundedInstances.has(1), 'terminal for cwd1 should be backgrounded'); + assert.ok(backgroundedInstances.has(2), 'terminal for cwd2 should be backgrounded'); + assert.ok(!backgroundedInstances.has(3), 'terminal for cwd3 should be foreground'); + }); + + test('shows pre-existing terminal with matching cwd instead of creating a new one', async () => { + // Manually add a terminal that already exists with a matching cwd + const cwd = URI.file('/worktree'); + const existingInstance = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + terminalInstances.set(existingInstance.instanceId, existingInstance); + backgroundedInstances.add(existingInstance.instanceId); + + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + assert.strictEqual(createdTerminals.length, 0, 'should reuse existing terminal, not create a new one'); + assert.ok(showBackgroundCalls.includes(existingInstance.instanceId), 'should show the existing terminal'); + }); + + test('hides pre-existing terminal with non-matching cwd when session changes', async () => { + // Manually add a terminal that already exists with a different cwd + const otherInstance = makeTerminalInstance(nextInstanceId++, '/other/path'); + terminalInstances.set(otherInstance.instanceId, otherInstance); + + const cwd = URI.file('/worktree'); + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + assert.ok(moveToBackgroundCalls.includes(otherInstance.instanceId), 'non-matching terminal should be backgrounded'); + }); + + test('ensureTerminal finds a backgrounded terminal instead of creating a new one', async () => { + const cwd = URI.file('/test-cwd'); + await contribution.ensureTerminal(cwd, false); + const instanceId = activeInstanceSet[0]; + + // Manually background it + backgroundedInstances.add(instanceId); + + // ensureTerminal should find it by cwd, not create a new one + const result = await contribution.ensureTerminal(cwd, false); + + assert.strictEqual(createdTerminals.length, 1, 'should not create a new terminal'); + assert.strictEqual(result[0].instanceId, instanceId, 'should return the existing backgrounded terminal'); + }); + + test('visibility is determined by initial cwd, not by stored IDs', async () => { + // Create a terminal externally (not via ensureTerminal) with a known cwd + const cwd1 = URI.file('/cwd1'); + const cwd2 = URI.file('/cwd2'); + const ext1 = makeTerminalInstance(nextInstanceId++, cwd1.fsPath); + const ext2 = makeTerminalInstance(nextInstanceId++, cwd2.fsPath); + terminalInstances.set(ext1.instanceId, ext1); + terminalInstances.set(ext2.instanceId, ext2); + + // Switch to cwd1 — ext1 should stay visible, ext2 should be hidden + activeSessionObs.set(makeAgentSession({ worktree: cwd1, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + assert.ok(!backgroundedInstances.has(ext1.instanceId), 'ext1 should be foreground (matching cwd)'); + assert.ok(backgroundedInstances.has(ext2.instanceId), 'ext2 should be backgrounded (non-matching cwd)'); + + // Switch to cwd2 — ext2 should be shown, ext1 should be hidden + activeSessionObs.set(makeAgentSession({ worktree: cwd2, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + assert.ok(backgroundedInstances.has(ext1.instanceId), 'ext1 should now be backgrounded'); + assert.ok(!backgroundedInstances.has(ext2.instanceId), 'ext2 should now be foreground'); + }); + + // --- Most-recent-command active terminal selection --- + + test('sets the terminal with the most recent command as active after visibility update', async () => { + const cwd = URI.file('/worktree'); + const t1 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + const t2 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + terminalInstances.set(t1.instanceId, t1); + terminalInstances.set(t2.instanceId, t2); + + // t1 ran a command at timestamp 100, t2 at timestamp 200 (more recent) + addCommandToInstance(t1, 100); + addCommandToInstance(t2, 200); + + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // The most recent setActiveInstance call should be for t2 + assert.strictEqual(activeInstanceSet.at(-1), t2.instanceId, 'should set the terminal with the most recent command as active'); + }); + + test('does not change active instance when no terminals have command history', async () => { + const cwd = URI.file('/worktree'); + const t1 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + const t2 = makeTerminalInstance(nextInstanceId++, cwd.fsPath); + terminalInstances.set(t1.instanceId, t1); + terminalInstances.set(t2.instanceId, t2); + + const activeCountBefore = activeInstanceSet.length; + + activeSessionObs.set(makeAgentSession({ worktree: cwd, providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + // No setActiveInstance calls from visibility update since no commands were run + assert.strictEqual(activeInstanceSet.length, activeCountBefore, 'should not call setActiveInstance when no command history exists'); + }); }); function tick(): Promise { diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts index ecbd59a5213..15104c8280e 100644 --- a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -15,10 +15,12 @@ import { URI } from '../../../../base/common/uri.js'; import { autorun } from '../../../../base/common/observable.js'; import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; import { getGitHubRemoteFileDisplayName } from '../../fileTreeView/browser/githubFileSystemProvider.js'; +import { Queue } from '../../../../base/common/async.js'; export class WorkspaceFolderManagementContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.workspaceFolderManagement'; + private queue = this._register(new Queue()); constructor( @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @@ -30,7 +32,7 @@ export class WorkspaceFolderManagementContribution extends Disposable implements super(); this._register(autorun(reader => { const activeSession = this.sessionManagementService.activeSession.read(reader); - this.updateWorkspaceFoldersForSession(activeSession); + this.queue.queue(() => this.updateWorkspaceFoldersForSession(activeSession)); })); } @@ -66,7 +68,7 @@ export class WorkspaceFolderManagementContribution extends Disposable implements if (session.worktree) { return { uri: session.worktree, - name: session.repository ? `${this.uriIdentityService.extUri.basename(session.repository)} (worktree)` : undefined + name: session.repository ? `${this.uriIdentityService.extUri.basename(session.repository)} (${session.worktreeBranchName ?? this.uriIdentityService.extUri.basename(session.worktree)})` : this.uriIdentityService.extUri.basename(session.worktree) }; } diff --git a/src/vs/sessions/electron-browser/sessions.main.ts b/src/vs/sessions/electron-browser/sessions.main.ts index 23f71e9d66a..4cb60de6157 100644 --- a/src/vs/sessions/electron-browser/sessions.main.ts +++ b/src/vs/sessions/electron-browser/sessions.main.ts @@ -67,6 +67,7 @@ import { NativeMenubarControl } from '../../workbench/electron-browser/parts/tit import { IWorkspaceEditingService } from '../../workbench/services/workspaces/common/workspaceEditing.js'; import { ConfigurationService } from '../services/configuration/browser/configurationService.js'; import { SessionsWorkspaceContextService } from '../services/workspace/browser/workspaceContextService.js'; +import { getWorkspaceIdentifier } from '../../workbench/services/workspaces/browser/workspaces.js'; export class SessionsMain extends Disposable { @@ -291,21 +292,23 @@ export class SessionsMain extends Disposable { // // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + const workspaceIdentifier = getWorkspaceIdentifier(environmentService.agentSessionsWorkspace); + const workspaceContextService = new SessionsWorkspaceContextService(workspaceIdentifier, uriIdentityService); + // Workspace - const workspaceContextService = new SessionsWorkspaceContextService(uriIdentityService.extUri.joinPath(uriIdentityService.extUri.dirname(userDataProfilesService.profilesHome), 'agent-sessions.code-workspace'), uriIdentityService); serviceCollection.set(IWorkspaceContextService, workspaceContextService); serviceCollection.set(IWorkspaceEditingService, workspaceContextService); const [configurationService, storageService] = await Promise.all([ - this.createConfigurationService(userDataProfileService, fileService, logService, policyService).then(service => { + this.createConfigurationService(workspaceContextService, userDataProfileService, uriIdentityService, fileService, logService, policyService).then(configurationService => { // Configuration - serviceCollection.set(IWorkbenchConfigurationService, service); + serviceCollection.set(IWorkbenchConfigurationService, configurationService); - return service; + return configurationService; }), - this.createStorageService(workspaceContextService.getWorkspace(), environmentService, userDataProfileService, userDataProfilesService, mainProcessService).then(service => { + this.createStorageService(workspaceIdentifier, environmentService, userDataProfileService, userDataProfilesService, mainProcessService).then(service => { // Storage serviceCollection.set(IStorageService, service); @@ -344,19 +347,21 @@ export class SessionsMain extends Disposable { } private async createConfigurationService( + workspaceContextService: SessionsWorkspaceContextService, userDataProfileService: IUserDataProfileService, + uriIdentityService: IUriIdentityService, fileService: FileService, logService: ILogService, policyService: IPolicyService ): Promise { - const configurationService = new ConfigurationService(userDataProfileService.currentProfile.settingsResource, fileService, policyService, logService); + const configurationService = new ConfigurationService(userDataProfileService, workspaceContextService, uriIdentityService, fileService, policyService, logService); try { await configurationService.initialize(); - return configurationService; } catch (error) { onUnexpectedError(error); - return configurationService; } + + return configurationService; } private async createStorageService(workspace: IAnyWorkspaceIdentifier, environmentService: INativeWorkbenchEnvironmentService, userDataProfileService: IUserDataProfileService, userDataProfilesService: IUserDataProfilesService, mainProcessService: IMainProcessService): Promise { diff --git a/src/vs/sessions/prompts/create-draft-pr.prompt.md b/src/vs/sessions/prompts/create-draft-pr.prompt.md new file mode 100644 index 00000000000..b64c60e1ea7 --- /dev/null +++ b/src/vs/sessions/prompts/create-draft-pr.prompt.md @@ -0,0 +1,11 @@ +--- +description: Create a draft pull request for the current session +--- + + +Use the GitHub MCP server to create a draft pull request — do NOT use the `gh` CLI. + +1. Review all changes in the current session +2. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") +3. Write a description covering what changed, why, and anything reviewers should know +4. Create the draft pull request diff --git a/src/vs/sessions/prompts/create-pr.prompt.md b/src/vs/sessions/prompts/create-pr.prompt.md new file mode 100644 index 00000000000..28cb057aeea --- /dev/null +++ b/src/vs/sessions/prompts/create-pr.prompt.md @@ -0,0 +1,11 @@ +--- +description: Create a pull request for the current session +--- + + +Use the GitHub MCP server to create a pull request — do NOT use the `gh` CLI. + +1. Review all changes in the current session +2. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") +3. Write a description covering what changed, why, and anything reviewers should know +4. Create the pull request diff --git a/src/vs/sessions/services/configuration/browser/configurationService.ts b/src/vs/sessions/services/configuration/browser/configurationService.ts index 3c145277fa4..01fb00152a6 100644 --- a/src/vs/sessions/services/configuration/browser/configurationService.ts +++ b/src/vs/sessions/services/configuration/browser/configurationService.ts @@ -3,25 +3,375 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../../base/common/event.js'; -import { Extensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { ConfigurationService as BaseConfigurationService } from '../../../../platform/configuration/common/configurationService.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../base/common/map.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Queue } from '../../../../base/common/async.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { JSONPath, ParseError, parse } from '../../../../base/common/json.js'; +import { applyEdits, setProperty } from '../../../../base/common/jsonEdit.js'; +import { Edit, FormattingOptions } from '../../../../base/common/jsonFormatter.js'; +import { equals } from '../../../../base/common/objects.js'; +import { distinct, equals as arrayEquals } from '../../../../base/common/arrays.js'; +import { OS, OperatingSystem } from '../../../../base/common/platform.js'; +import { IConfigurationChange, IConfigurationChangeEvent, IConfigurationData, IConfigurationOverrides, IConfigurationUpdateOptions, IConfigurationUpdateOverrides, IConfigurationValue, ConfigurationTarget, isConfigurationOverrides, isConfigurationUpdateOverrides } from '../../../../platform/configuration/common/configuration.js'; +import { ConfigurationChangeEvent, ConfigurationModel } from '../../../../platform/configuration/common/configurationModels.js'; +import { DefaultConfiguration, IPolicyConfiguration, NullPolicyConfiguration, PolicyConfiguration } from '../../../../platform/configuration/common/configurations.js'; +import { Extensions, IConfigurationRegistry, keyFromOverrideIdentifiers } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { IFileService, FileOperationError, FileOperationResult } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IPolicyService, NullPolicyService } from '../../../../platform/policy/common/policy.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { APPLICATION_SCOPES, APPLY_ALL_PROFILES_SETTING, IWorkbenchConfigurationService, RestrictedSettings } from '../../../../workbench/services/configuration/common/configuration.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IWorkspaceContextService, IWorkspaceFoldersChangeEvent, IWorkspaceFolder, WorkbenchState, Workspace } from '../../../../platform/workspace/common/workspace.js'; +import { FolderConfiguration, UserConfiguration } from '../../../../workbench/services/configuration/browser/configuration.js'; +import { APPLICATION_SCOPES, APPLY_ALL_PROFILES_SETTING, FOLDER_CONFIG_FOLDER_NAME, FOLDER_SETTINGS_PATH, IWorkbenchConfigurationService, RestrictedSettings } from '../../../../workbench/services/configuration/common/configuration.js'; +import { Configuration } from '../../../../workbench/services/configuration/common/configurationModels.js'; +import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; -// Import to register contributions +// Import to register configuration contributions import '../../../../workbench/services/configuration/browser/configurationService.js'; -export class ConfigurationService extends BaseConfigurationService implements IWorkbenchConfigurationService { - readonly restrictedSettings: RestrictedSettings = { default: [] }; +export class ConfigurationService extends Disposable implements IWorkbenchConfigurationService { + + declare readonly _serviceBrand: undefined; + + private _configuration: Configuration; + private readonly defaultConfiguration: DefaultConfiguration; + private readonly policyConfiguration: IPolicyConfiguration; + private readonly userConfiguration: UserConfiguration; + private readonly cachedFolderConfigs = this._register(new DisposableMap(new ResourceMap())); + + private readonly _onDidChangeConfiguration = this._register(new Emitter()); + readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; + readonly onDidChangeRestrictedSettings = Event.None; + readonly restrictedSettings: RestrictedSettings = { default: [] }; + + private readonly configurationRegistry = Registry.as(Extensions.Configuration); + + private readonly settingsResource: URI; + private readonly configurationEditing: ConfigurationEditing; + + constructor( + userDataProfileService: IUserDataProfileService, + private readonly workspaceService: IWorkspaceContextService, + private readonly uriIdentityService: IUriIdentityService, + private readonly fileService: IFileService, + policyService: IPolicyService, + private readonly logService: ILogService, + ) { + super(); + + this.settingsResource = userDataProfileService.currentProfile.settingsResource; + this.defaultConfiguration = this._register(new DefaultConfiguration(logService)); + this.policyConfiguration = policyService instanceof NullPolicyService ? new NullPolicyConfiguration() : this._register(new PolicyConfiguration(this.defaultConfiguration, policyService, logService)); + this.userConfiguration = this._register(new UserConfiguration(userDataProfileService.currentProfile.settingsResource, userDataProfileService.currentProfile.tasksResource, userDataProfileService.currentProfile.mcpResource, {}, fileService, uriIdentityService, logService)); + this.configurationEditing = new ConfigurationEditing(fileService, this); + + this._configuration = new Configuration( + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + ConfigurationModel.createEmptyModel(logService), + new ResourceMap(), + ConfigurationModel.createEmptyModel(logService), + new ResourceMap(), + this.workspaceService.getWorkspace() as Workspace, + this.logService + ); + + this._register(this.defaultConfiguration.onDidChangeConfiguration(({ defaults, properties }) => this.onDefaultConfigurationChanged(defaults, properties))); + this._register(this.policyConfiguration.onDidChangeConfiguration(configurationModel => this.onPolicyConfigurationChanged(configurationModel))); + this._register(this.userConfiguration.onDidChangeConfiguration(userConfiguration => this.onUserConfigurationChanged(userConfiguration))); + this._register(this.workspaceService.onWillChangeWorkspaceFolders(e => e.join(this.loadFolderConfigurations(e.changes.added)))); + this._register(this.workspaceService.onDidChangeWorkspaceFolders(e => this.onWorkspaceFoldersChanged(e))); + } + + async initialize(): Promise { + const [defaultModel, policyModel, userModel] = await Promise.all([ + this.defaultConfiguration.initialize(), + this.policyConfiguration.initialize(), + this.userConfiguration.initialize() + ]); + const workspace = this.workspaceService.getWorkspace() as Workspace; + this._configuration = new Configuration( + defaultModel, + policyModel, + ConfigurationModel.createEmptyModel(this.logService), + userModel, + ConfigurationModel.createEmptyModel(this.logService), + ConfigurationModel.createEmptyModel(this.logService), + new ResourceMap(), + ConfigurationModel.createEmptyModel(this.logService), + new ResourceMap(), + workspace, + this.logService + ); + await this.loadFolderConfigurations(workspace.folders); + } + + // #region IWorkbenchConfigurationService + + getConfigurationData(): IConfigurationData { + return this._configuration.toData(); + } + + getValue(): T; + getValue(section: string): T; + getValue(overrides: IConfigurationOverrides): T; + getValue(section: string, overrides: IConfigurationOverrides): T; + getValue(arg1?: unknown, arg2?: unknown): unknown { + const section = typeof arg1 === 'string' ? arg1 : undefined; + const overrides = isConfigurationOverrides(arg1) ? arg1 : isConfigurationOverrides(arg2) ? arg2 : undefined; + return this._configuration.getValue(section, overrides); + } + + updateValue(key: string, value: unknown): Promise; + updateValue(key: string, value: unknown, overrides: IConfigurationOverrides | IConfigurationUpdateOverrides): Promise; + updateValue(key: string, value: unknown, target: ConfigurationTarget): Promise; + updateValue(key: string, value: unknown, overrides: IConfigurationOverrides | IConfigurationUpdateOverrides, target: ConfigurationTarget, options?: IConfigurationUpdateOptions): Promise; + async updateValue(key: string, value: unknown, arg3?: unknown, arg4?: unknown, _options?: IConfigurationUpdateOptions): Promise { + const overrides: IConfigurationUpdateOverrides | undefined = isConfigurationUpdateOverrides(arg3) ? arg3 + : isConfigurationOverrides(arg3) ? { resource: arg3.resource, overrideIdentifiers: arg3.overrideIdentifier ? [arg3.overrideIdentifier] : undefined } : undefined; + const target: ConfigurationTarget | undefined = (overrides ? arg4 : arg3) as ConfigurationTarget | undefined; + + if (overrides?.overrideIdentifiers) { + overrides.overrideIdentifiers = distinct(overrides.overrideIdentifiers); + overrides.overrideIdentifiers = overrides.overrideIdentifiers.length ? overrides.overrideIdentifiers : undefined; + } + + const inspect = this.inspect(key, { resource: overrides?.resource, overrideIdentifier: overrides?.overrideIdentifiers ? overrides.overrideIdentifiers[0] : undefined }); + if (inspect.policyValue !== undefined) { + throw new Error(`Unable to write ${key} because it is configured in system policy.`); + } + + // Remove the setting, if the value is same as default value + if (equals(value, inspect.defaultValue)) { + value = undefined; + } + + if (overrides?.overrideIdentifiers?.length && overrides.overrideIdentifiers.length > 1) { + const overrideIdentifiers = overrides.overrideIdentifiers.sort(); + const existingOverrides = this._configuration.localUserConfiguration.overrides.find(override => arrayEquals([...override.identifiers].sort(), overrideIdentifiers)); + if (existingOverrides) { + overrides.overrideIdentifiers = existingOverrides.identifiers; + } + } + + const path = overrides?.overrideIdentifiers?.length ? [keyFromOverrideIdentifiers(overrides.overrideIdentifiers), key] : [key]; + + const settingsResource = this.getSettingsResource(target, overrides?.resource ?? undefined); + await this.configurationEditing.write(settingsResource, path, value); + await this.reloadConfiguration(); + } + + private getSettingsResource(target: ConfigurationTarget | undefined, resource: URI | undefined): URI { + if (target === ConfigurationTarget.WORKSPACE_FOLDER || target === ConfigurationTarget.WORKSPACE) { + if (resource) { + const folder = this.workspaceService.getWorkspaceFolder(resource); + if (folder) { + return this.uriIdentityService.extUri.joinPath(folder.uri, FOLDER_SETTINGS_PATH); + } + } + } + return this.settingsResource; + } + + inspect(key: string, overrides?: IConfigurationOverrides): IConfigurationValue { + return this._configuration.inspect(key, overrides); + } + + keys(): { default: string[]; policy: string[]; user: string[]; workspace: string[]; workspaceFolder: string[] } { + return this._configuration.keys(); + } + + async reloadConfiguration(_target?: ConfigurationTarget | IWorkspaceFolder): Promise { + const userModel = await this.userConfiguration.initialize(); + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateLocalUserConfiguration(userModel); + + // Reload folder configurations + for (const folder of this.workspaceService.getWorkspace().folders) { + const folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (folderConfiguration) { + const folderModel = await folderConfiguration.loadConfiguration(); + const folderChange = this._configuration.compareAndUpdateFolderConfiguration(folder.uri, folderModel); + change.keys.push(...folderChange.keys); + change.overrides.push(...folderChange.overrides); + } + } + + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.USER); + } + + hasCachedConfigurationDefaultsOverrides(): boolean { + return false; + } + async whenRemoteConfigurationLoaded(): Promise { } + isSettingAppliedForAllProfiles(key: string): boolean { - const scope = Registry.as(Extensions.Configuration).getConfigurationProperties()[key]?.scope; + const scope = this.configurationRegistry.getConfigurationProperties()[key]?.scope; if (scope && APPLICATION_SCOPES.includes(scope)) { return true; } const allProfilesSettings = this.getValue(APPLY_ALL_PROFILES_SETTING) ?? []; return Array.isArray(allProfilesSettings) && allProfilesSettings.includes(key); } + + // #endregion + + // #region Configuration change handlers + + private onDefaultConfigurationChanged(defaults: ConfigurationModel, properties?: string[]): void { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateDefaultConfiguration(defaults, properties); + this._configuration.updateLocalUserConfiguration(this.userConfiguration.reparse()); + for (const folder of this.workspaceService.getWorkspace().folders) { + const folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (folderConfiguration) { + this._configuration.updateFolderConfiguration(folder.uri, folderConfiguration.reparse()); + } + } + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.DEFAULT); + } + + private onPolicyConfigurationChanged(policyConfiguration: ConfigurationModel): void { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdatePolicyConfiguration(policyConfiguration); + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.DEFAULT); + } + + private onUserConfigurationChanged(userConfiguration: ConfigurationModel): void { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateLocalUserConfiguration(userConfiguration); + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.USER); + } + + private onWorkspaceFoldersChanged(e: IWorkspaceFoldersChangeEvent): void { + // Remove configurations for removed folders + const previousData = this._configuration.toData(); + const keys: string[] = []; + const overrides: [string, string[]][] = []; + for (const folder of e.removed) { + const change = this._configuration.compareAndDeleteFolderConfiguration(folder.uri); + keys.push(...change.keys); + overrides.push(...change.overrides); + this.cachedFolderConfigs.deleteAndDispose(folder.uri); + } + if (keys.length || overrides.length) { + this.triggerConfigurationChange({ keys, overrides }, previousData, ConfigurationTarget.WORKSPACE_FOLDER); + } + } + + private onWorkspaceFolderConfigurationChanged(folder: IWorkspaceFolder): void { + const folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (folderConfiguration) { + folderConfiguration.loadConfiguration().then(configurationModel => { + const previousData = this._configuration.toData(); + const change = this._configuration.compareAndUpdateFolderConfiguration(folder.uri, configurationModel); + this.triggerConfigurationChange(change, previousData, ConfigurationTarget.WORKSPACE_FOLDER); + }, onUnexpectedError); + } + } + + private async loadFolderConfigurations(folders: readonly IWorkspaceFolder[]): Promise { + for (const folder of folders) { + let folderConfiguration = this.cachedFolderConfigs.get(folder.uri); + if (!folderConfiguration) { + folderConfiguration = new FolderConfiguration(false, folder, FOLDER_CONFIG_FOLDER_NAME, WorkbenchState.WORKSPACE, true, this.fileService, this.uriIdentityService, this.logService, { needsCaching: () => false, read: async () => '', write: async () => { }, remove: async () => { } }); + folderConfiguration.addRelated(folderConfiguration.onDidChange(() => this.onWorkspaceFolderConfigurationChanged(folder))); + this.cachedFolderConfigs.set(folder.uri, folderConfiguration); + } + const configurationModel = await folderConfiguration.loadConfiguration(); + this._configuration.updateFolderConfiguration(folder.uri, configurationModel); + } + } + + private triggerConfigurationChange(change: IConfigurationChange, previousData: IConfigurationData, target: ConfigurationTarget): void { + if (change.keys.length) { + const workspace = this.workspaceService.getWorkspace() as Workspace; + const event = new ConfigurationChangeEvent(change, { data: previousData, workspace }, this._configuration, workspace, this.logService); + event.source = target; + this._onDidChangeConfiguration.fire(event); + } + } + + // #endregion +} + +class ConfigurationEditing { + + private readonly queue = new Queue(); + + constructor( + private readonly fileService: IFileService, + private readonly configurationService: ConfigurationService, + ) { } + + write(settingsResource: URI, path: JSONPath, value: unknown): Promise { + return this.queue.queue(() => this.doWriteConfiguration(settingsResource, path, value)); + } + + private async doWriteConfiguration(settingsResource: URI, path: JSONPath, value: unknown): Promise { + let content: string; + try { + const fileContent = await this.fileService.readFile(settingsResource); + content = fileContent.value.toString(); + } catch (error) { + if ((error as FileOperationError).fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { + content = '{}'; + } else { + throw error; + } + } + + const parseErrors: ParseError[] = []; + parse(content, parseErrors, { allowTrailingComma: true, allowEmptyContent: true }); + if (parseErrors.length > 0) { + throw new Error('Unable to write into the settings file. Please open the file to correct errors/warnings in the file and try again.'); + } + + const edits = this.getEdits(content, path, value); + content = applyEdits(content, edits); + + await this.fileService.writeFile(settingsResource, VSBuffer.fromString(content)); + } + + private getEdits(content: string, path: JSONPath, value: unknown): Edit[] { + const { tabSize, insertSpaces, eol } = this.formattingOptions; + + if (!path.length) { + const newContent = JSON.stringify(value, null, insertSpaces ? ' '.repeat(tabSize) : '\t'); + return [{ + content: newContent, + length: content.length, + offset: 0 + }]; + } + + return setProperty(content, path, value, { tabSize, insertSpaces, eol }); + } + + private _formattingOptions: Required | undefined; + private get formattingOptions(): Required { + if (!this._formattingOptions) { + let eol = OS === OperatingSystem.Linux || OS === OperatingSystem.Macintosh ? '\n' : '\r\n'; + const configuredEol = this.configurationService.getValue('files.eol', { overrideIdentifier: 'jsonc' }); + if (configuredEol && typeof configuredEol === 'string' && configuredEol !== 'auto') { + eol = configuredEol; + } + this._formattingOptions = { + eol, + insertSpaces: !!this.configurationService.getValue('editor.insertSpaces', { overrideIdentifier: 'jsonc' }), + tabSize: this.configurationService.getValue('editor.tabSize', { overrideIdentifier: 'jsonc' }) + }; + } + return this._formattingOptions; + } } diff --git a/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts new file mode 100644 index 00000000000..fdfd8a4f1e9 --- /dev/null +++ b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts @@ -0,0 +1,339 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { NullPolicyService } from '../../../../../platform/policy/common/policy.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; +import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; +import { joinPath } from '../../../../../base/common/resources.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { UserDataProfilesService } from '../../../../../platform/userDataProfile/common/userDataProfile.js'; +import { UserDataProfileService } from '../../../../../workbench/services/userDataProfile/common/userDataProfileService.js'; +import { FileUserDataProvider } from '../../../../../platform/userData/common/fileUserDataProvider.js'; +import { TestEnvironmentService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ConfigurationService } from '../../browser/configurationService.js'; +import { SessionsWorkspaceContextService } from '../../../workspace/browser/workspaceContextService.js'; +import { getWorkspaceIdentifier } from '../../../../../workbench/services/workspaces/browser/workspaces.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IUserDataProfileService } from '../../../../../workbench/services/userDataProfile/common/userDataProfile.js'; + +const ROOT = URI.file('tests').with({ scheme: 'vscode-tests' }); + +suite('Sessions ConfigurationService', () => { + + let testObject: ConfigurationService; + let workspaceService: SessionsWorkspaceContextService; + let fileService: FileService; + let userDataProfileService: IUserDataProfileService; + const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + suiteSetup(() => { + configurationRegistry.registerConfiguration({ + 'id': '_test_sessions', + 'type': 'object', + 'properties': { + 'sessionsConfigurationService.testSetting': { + 'type': 'string', + 'default': 'defaultValue', + scope: ConfigurationScope.RESOURCE + }, + 'sessionsConfigurationService.machineSetting': { + 'type': 'string', + 'default': 'defaultValue', + scope: ConfigurationScope.MACHINE + }, + 'sessionsConfigurationService.applicationSetting': { + 'type': 'string', + 'default': 'defaultValue', + scope: ConfigurationScope.APPLICATION + }, + } + }); + }); + + setup(async () => { + const logService = new NullLogService(); + fileService = disposables.add(new FileService(logService)); + const fileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(ROOT.scheme, fileSystemProvider)); + + const environmentService = TestEnvironmentService; + const uriIdentityService = disposables.add(new UriIdentityService(fileService)); + const userDataProfilesService = disposables.add(new UserDataProfilesService(environmentService, fileService, uriIdentityService, logService)); + disposables.add(fileService.registerProvider(Schemas.vscodeUserData, disposables.add(new FileUserDataProvider(ROOT.scheme, fileSystemProvider, Schemas.vscodeUserData, userDataProfilesService, uriIdentityService, logService)))); + userDataProfileService = disposables.add(new UserDataProfileService(userDataProfilesService.defaultProfile)); + + const configResource = joinPath(ROOT, 'agent-sessions.code-workspace'); + await fileService.writeFile(configResource, VSBuffer.fromString(JSON.stringify({ folders: [] }))); + + workspaceService = disposables.add(new SessionsWorkspaceContextService(getWorkspaceIdentifier(configResource), uriIdentityService)); + testObject = disposables.add(new ConfigurationService(userDataProfileService, workspaceService, uriIdentityService, fileService, new NullPolicyService(), logService)); + await testObject.initialize(); + }); + + // #region Reading + + test('defaults', () => { + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.machineSetting'), 'defaultValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.applicationSetting'), 'defaultValue'); + }); + + test('user settings override defaults', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'userValue'); + })); + + test('workspace folder settings override user settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'myFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + })); + + test('folder settings are read when folders are added', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'addedFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + })); + + test('folder settings are removed when folders are removed', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'removedFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + await workspaceService.removeFolders([folder]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'defaultValue'); + })); + + test('configuration change event is fired when folders with settings are removed', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'removedFolder2'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await workspaceService.removeFolders([folder]); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'defaultValue'); + })); + + test('configuration change event is fired on user settings change', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + })); + + test('inspect returns correct values per layer', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'inspectFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(userDataProfileService.currentProfile.settingsResource, VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "userValue" }')); + await testObject.reloadConfiguration(); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + + const inspection = testObject.inspect('sessionsConfigurationService.testSetting', { resource: folder }); + assert.strictEqual(inspection.defaultValue, 'defaultValue'); + assert.strictEqual(inspection.userValue, 'userValue'); + assert.strictEqual(inspection.workspaceFolderValue, 'folderValue'); + })); + + test('application settings are not read from workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'appFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.applicationSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.applicationSetting', { resource: folder }), 'defaultValue'); + })); + + test('machine settings are not read from workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'machineFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.machineSetting": "folderValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.machineSetting', { resource: folder }), 'defaultValue'); + })); + + test('folder settings change fires configuration change event', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'changeFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "initialValue" }')); + await workspaceService.addFolders([{ uri: folder }]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'initialValue'); + + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "updatedValue" }')); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'updatedValue'); + })); + + // #endregion + + // #region Writing + + test('updateValue writes to user settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'writtenValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'writtenValue'); + })); + + test('updateValue persists to settings file', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'persistedValue'); + + const content = (await fileService.readFile(userDataProfileService.currentProfile.settingsResource)).value.toString(); + assert.ok(content.includes('"sessionsConfigurationService.testSetting"')); + assert.ok(content.includes('persistedValue')); + })); + + test('updateValue fires change event', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const promise = Event.toPromise(testObject.onDidChangeConfiguration); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'eventValue'); + const event = await promise; + assert.ok(event.affectsConfiguration('sessionsConfigurationService.testSetting')); + })); + + test('updateValue removes setting when value equals default', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'nonDefault'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'nonDefault'); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'defaultValue'); + const content = (await fileService.readFile(userDataProfileService.currentProfile.settingsResource)).value.toString(); + assert.ok(!content.includes('sessionsConfigurationService.testSetting')); + })); + + test('updateValue can update multiple settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'value1'); + await testObject.updateValue('sessionsConfigurationService.machineSetting', 'value2'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'value1'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.machineSetting'), 'value2'); + })); + + test('updateValue with language override', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'langValue', { overrideIdentifier: 'jsonc' }); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { overrideIdentifier: 'jsonc' }), 'langValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + })); + + test('updateValue is reflected in inspect', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + await testObject.updateValue('sessionsConfigurationService.testSetting', 'inspectedValue'); + const inspection = testObject.inspect('sessionsConfigurationService.testSetting'); + assert.strictEqual(inspection.defaultValue, 'defaultValue'); + assert.strictEqual(inspection.userValue, 'inspectedValue'); + })); + + // #endregion + + // #region Workspace Folder - Read and Write + + test('read setting from workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'readFolder'); + await fileService.createFolder(folder); + await fileService.writeFile(joinPath(folder, '.vscode', 'settings.json'), VSBuffer.fromString('{ "sessionsConfigurationService.testSetting": "folderValue" }')); + + await workspaceService.addFolders([{ uri: folder }]); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + })); + + test('write setting to workspace folder', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'writeFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'writtenFolderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'writtenFolderValue'); + })); + + test('write setting to workspace folder persists to folder settings file', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'persistFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'persistedFolderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + const content = (await fileService.readFile(joinPath(folder, '.vscode', 'settings.json'))).value.toString(); + assert.ok(content.includes('"sessionsConfigurationService.testSetting"')); + assert.ok(content.includes('persistedFolderValue')); + })); + + test('write setting to workspace folder does not affect user settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'isolateFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderOnly', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderOnly'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'defaultValue'); + })); + + test('workspace folder setting overrides user setting for resource', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'overrideFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'userValue'); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting'), 'userValue'); + })); + + test('inspect shows workspace folder value after write', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'inspectWriteFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + + await testObject.updateValue('sessionsConfigurationService.testSetting', 'userVal'); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderVal', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + + const inspection = testObject.inspect('sessionsConfigurationService.testSetting', { resource: folder }); + assert.strictEqual(inspection.defaultValue, 'defaultValue'); + assert.strictEqual(inspection.userValue, 'userVal'); + assert.strictEqual(inspection.workspaceFolderValue, 'folderVal'); + })); + + test('removing folder clears its written settings', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const folder = joinPath(ROOT, 'clearFolder'); + await fileService.createFolder(folder); + + await workspaceService.addFolders([{ uri: folder }]); + await testObject.updateValue('sessionsConfigurationService.testSetting', 'folderValue', { resource: folder }, ConfigurationTarget.WORKSPACE_FOLDER); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'folderValue'); + + await workspaceService.removeFolders([folder]); + assert.strictEqual(testObject.getValue('sessionsConfigurationService.testSetting', { resource: folder }), 'defaultValue'); + })); + + // #endregion +}); diff --git a/src/vs/sessions/services/workspace/browser/workspaceContextService.ts b/src/vs/sessions/services/workspace/browser/workspaceContextService.ts index 58b4dfb5889..0e2aab0e8fc 100644 --- a/src/vs/sessions/services/workspace/browser/workspaceContextService.ts +++ b/src/vs/sessions/services/workspace/browser/workspaceContextService.ts @@ -11,16 +11,15 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { Workspace, WorkspaceFolder, IWorkspace, IWorkspaceContextService, IWorkspaceFoldersChangeEvent, IWorkspaceFoldersWillChangeEvent, IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, IWorkspaceFolder, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; import { getWorkspaceIdentifier } from '../../../../workbench/services/workspaces/browser/workspaces.js'; -import { IDidEnterWorkspaceEvent, IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; +import { IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; - export class SessionsWorkspaceContextService extends Disposable implements IWorkspaceContextService, IWorkspaceEditingService { declare readonly _serviceBrand: undefined; readonly onDidChangeWorkbenchState = Event.None; readonly onDidChangeWorkspaceName = Event.None; - readonly onDidEnterWorkspace = Event.None as Event; + readonly onDidEnterWorkspace = Event.None; private readonly _onWillChangeWorkspaceFolders = new Emitter(); readonly onWillChangeWorkspaceFolders = this._onWillChangeWorkspaceFolders.event; @@ -32,11 +31,10 @@ export class SessionsWorkspaceContextService extends Disposable implements IWork private readonly _updateFoldersQueue = this._register(new Queue()); constructor( - sessionsWorkspaceUri: URI, - private readonly uriIdentityService: IUriIdentityService + workspaceIdentifier: IWorkspaceIdentifier, + private readonly uriIdentityService: IUriIdentityService, ) { super(); - const workspaceIdentifier = getWorkspaceIdentifier(sessionsWorkspaceUri); this.workspace = new Workspace(workspaceIdentifier.id, [], false, workspaceIdentifier.configPath, uri => uriIdentityService.extUri.ignorePathCasing(uri)); } @@ -53,7 +51,7 @@ export class SessionsWorkspaceContextService extends Disposable implements IWork } hasWorkspaceData(): boolean { - return false; + return true; } getWorkspaceFolder(resource: URI): IWorkspaceFolder | null { @@ -158,7 +156,8 @@ export class SessionsWorkspaceContextService extends Disposable implements IWork // Update workspace const workspaceIdentifier = getWorkspaceIdentifier(this.workspace.configuration!); - this.workspace = new Workspace(workspaceIdentifier.id, newFolders, false, workspaceIdentifier.configPath, uri => this.uriIdentityService.extUri.ignorePathCasing(uri)); + const workspace = new Workspace(workspaceIdentifier.id, newFolders, false, workspaceIdentifier.configPath, uri => this.uriIdentityService.extUri.ignorePathCasing(uri)); + this.workspace.update(workspace); // Fire did change event this._onDidChangeWorkspaceFolders.fire(changes); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 406ea03e508..f8f88d37215 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -177,7 +177,6 @@ import '../workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribu // Chat import '../workbench/contrib/chat/electron-browser/chat.contribution.js'; -//import '../workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.js'; import './contrib/agentFeedback/browser/agentFeedback.contribution.js'; // Encryption @@ -203,12 +202,15 @@ import './browser/layoutActions.js'; import './contrib/accountMenu/browser/account.contribution.js'; import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; import './contrib/chat/browser/chat.contribution.js'; +import './contrib/chat/browser/customizationsDebugLog.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; -import './contrib/changesView/browser/changesView.contribution.js'; +import './contrib/changes/browser/changesView.contribution.js'; +import './contrib/codeReview/browser/codeReview.contributions.js'; import './contrib/files/browser/files.contribution.js'; -import './contrib/gitSync/browser/gitSync.contribution.js'; -import './contrib/applyToParentRepo/browser/applyToParentRepo.contribution.js'; +import './contrib/git/browser/git.contribution.js'; +import './contrib/github/browser/github.contribution.js'; +import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed import './contrib/configuration/browser/configuration.contribution.js'; diff --git a/src/vs/sessions/test/browser/layoutActions.test.ts b/src/vs/sessions/test/browser/layoutActions.test.ts index 786236ec970..f8362f7d654 100644 --- a/src/vs/sessions/test/browser/layoutActions.test.ts +++ b/src/vs/sessions/test/browser/layoutActions.test.ts @@ -16,7 +16,7 @@ suite('Sessions - Layout Actions', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('always-on-top toggle action is contributed to TitleBarRight', () => { - const items = MenuRegistry.getMenuItems(Menus.TitleBarRight); + const items = MenuRegistry.getMenuItems(Menus.TitleBarRightLayout); const menuItems = items.filter(isIMenuItem); const toggleAlwaysOnTop = menuItems.find(item => item.command.id === 'workbench.action.toggleWindowAlwaysOnTop'); diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 4fdf590d553..08f039daab2 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -32,6 +32,7 @@ import { isValidPromptType } from '../../contrib/chat/common/promptSyntax/prompt import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { ChatRequestAgentPart } from '../../contrib/chat/common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../contrib/chat/common/requestParser/chatRequestParser.js'; +import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../contrib/chat/browser/attachments/chatVariables.js'; import { IChatContentInlineReference, IChatContentReference, IChatFollowup, IChatNotebookEdit, IChatProgress, IChatService, IChatTask, IChatTaskSerialized, IChatWarningMessage } from '../../contrib/chat/common/chatService/chatService.js'; import { IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../contrib/chat/common/constants.js'; @@ -39,8 +40,9 @@ import { ILanguageModelToolsService } from '../../contrib/chat/common/tools/lang import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, ExtHostContext, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; +import { ExtHostChatAgentsShape2, ExtHostContext, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IInstructionDto, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; import { NotebookDto } from './mainThreadNotebookDto.js'; +import { getChatSessionType, isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; interface AgentData { dispose: () => void; @@ -152,6 +154,24 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA // Push the initial active session if there is already a focused widget this._acceptActiveChatSession(this._chatWidgetService.lastFocusedWidget); + + // Push custom agents to ext host + void this._pushCustomAgents(); + this._register(this._promptsService.onDidChangeCustomAgents(() => { + void this._pushCustomAgents(); + })); + + // Push instructions to ext host + void this._pushInstructions(); + this._register(this._promptsService.onDidChangeInstructions(() => { + void this._pushInstructions(); + })); + + // Push skills to ext host + void this._pushSkills(); + this._register(this._promptsService.onDidChangeSkills(() => { + void this._pushSkills(); + })); } private _acceptActiveChatSession(widget: IChatWidget | undefined): void { @@ -160,6 +180,36 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._proxy.$acceptActiveChatSession(isLocal ? sessionResource : undefined); } + private async _pushCustomAgents(): Promise { + try { + const customAgents = await this._promptsService.getCustomAgents(CancellationToken.None); + const dtos: ICustomAgentDto[] = customAgents.map(agent => ({ uri: agent.uri })); + this._proxy.$acceptCustomAgents(dtos); + } catch (error) { + this._logService.error('[chat] Failed to push custom agents to extension host', error); + } + } + + private async _pushInstructions(): Promise { + try { + const instructions = await this._promptsService.getInstructionFiles(CancellationToken.None); + const dtos: IInstructionDto[] = instructions.map(instruction => ({ uri: instruction.uri })); + this._proxy.$acceptInstructions(dtos); + } catch (error) { + this._logService.error('[chat] Failed to push instructions to extension host', error); + } + } + + private async _pushSkills(): Promise { + try { + const skills = await this._promptsService.findAgentSkills(CancellationToken.None) ?? []; + const dtos: ISkillDto[] = skills.map(skill => ({ uri: skill.uri })); + this._proxy.$acceptSkills(dtos); + } catch (error) { + this._logService.error('[chat] Failed to push skills to extension host', error); + } + } + $unregisterAgent(handle: number): void { this._agents.deleteAndDispose(handle); } @@ -198,12 +248,12 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA let chatSessionContext: IChatSessionContextDto | undefined; if (contributedSession) { let chatSessionResource = contributedSession.chatSessionResource; - let isUntitled = contributedSession.isUntitled; + let isUntitled = isUntitledChatSession(chatSessionResource); // For new untitled sessions, invoke the controller's newChatSessionItemHandler // to let the extension create a proper session item before the first request. if (isUntitled) { - const newItem = await this._chatSessionService.createNewChatSessionItem(contributedSession.chatSessionType, request, token); + const newItem = await this._chatSessionService.createNewChatSessionItem(getChatSessionType(contributedSession.chatSessionResource), request, token); if (newItem) { chatSessionResource = newItem.resource; isUntitled = false; @@ -212,9 +262,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA // so subsequent requests don't re-invoke newChatSessionItemHandler // and getChatSessionFromInternalUri returns the real resource. chatSession?.setContributedChatSession({ - chatSessionType: contributedSession.chatSessionType, chatSessionResource, - isUntitled: false, initialSessionOptions: contributedSession.initialSessionOptions, }); @@ -362,6 +410,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA kind: 'usage', promptTokens: progress.promptTokens, completionTokens: progress.completionTokens, + outputBuffer: progress.outputBuffer, promptTokenDetails: progress.promptTokenDetails }); } @@ -459,7 +508,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA return; } - const parsedRequest = this._instantiationService.createInstance(ChatRequestParser).parseChatRequest(widget.viewModel.sessionResource, model.getValue()).parts; + const parsedRequest = this._instantiationService.createInstance(ChatRequestParser).parseChatRequestWithReferences(getDynamicVariablesForWidget(widget), getSelectedToolAndToolSetsForWidget(widget), model.getValue()).parts; const agentPart = parsedRequest.find((part): part is ChatRequestAgentPart => part instanceof ChatRequestAgentPart); const thisAgentId = this._agents.get(handle)?.id; if (agentPart?.agent.id !== thisAgentId) { diff --git a/src/vs/workbench/api/browser/mainThreadChatDebug.ts b/src/vs/workbench/api/browser/mainThreadChatDebug.ts index 82594dcb038..169324d37e7 100644 --- a/src/vs/workbench/api/browser/mainThreadChatDebug.ts +++ b/src/vs/workbench/api/browser/mainThreadChatDebug.ts @@ -5,7 +5,9 @@ import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugService } from '../../contrib/chat/common/chatDebugService.js'; +import { IChatService } from '../../contrib/chat/common/chatService/chatService.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostChatDebugShape, ExtHostContext, IChatDebugEventDto, MainContext, MainThreadChatDebugShape } from '../common/extHost.protocol.js'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; @@ -19,6 +21,7 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb constructor( extHostContext: IExtHostContext, @IChatDebugService private readonly _chatDebugService: IChatDebugService, + @IChatService private readonly _chatService: IChatService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatDebug); @@ -36,6 +39,26 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb }, resolveChatDebugLogEvent: async (eventId, token) => { return this._proxy.$resolveChatDebugLogEvent(handle, eventId, token); + }, + provideChatDebugLogExport: async (sessionResource, token) => { + // Gather core events and session title to pass to the extension. + const coreEventDtos = this._chatDebugService.getEvents(sessionResource) + .filter(e => this._chatDebugService.isCoreEvent(e)) + .map(e => this._serializeEvent(e)); + const sessionTitle = this._chatService.getSessionTitle(sessionResource); + const result = await this._proxy.$exportChatDebugLog(handle, sessionResource, coreEventDtos, sessionTitle, token); + return result?.buffer; + }, + resolveChatDebugLogImport: async (data, token) => { + const result = await this._proxy.$importChatDebugLog(handle, VSBuffer.wrap(data), token); + if (!result) { + return undefined; + } + const uri = URI.revive(result.uri); + if (result.sessionTitle) { + this._chatDebugService.setImportedSessionTitle(uri, result.sessionTitle); + } + return uri; } })); } @@ -58,6 +81,30 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb this._chatDebugService.addProviderEvent(revived); } + private _serializeEvent(event: IChatDebugEvent): IChatDebugEventDto { + const base = { + id: event.id, + sessionResource: event.sessionResource, + created: event.created.getTime(), + parentEventId: event.parentEventId, + }; + + switch (event.kind) { + case 'toolCall': + return { ...base, kind: 'toolCall', toolName: event.toolName, toolCallId: event.toolCallId, input: event.input, output: event.output, result: event.result, durationInMillis: event.durationInMillis }; + case 'modelTurn': + return { ...base, kind: 'modelTurn', model: event.model, requestName: event.requestName, inputTokens: event.inputTokens, outputTokens: event.outputTokens, totalTokens: event.totalTokens, durationInMillis: event.durationInMillis }; + case 'generic': + return { ...base, kind: 'generic', name: event.name, details: event.details, level: event.level, category: event.category }; + case 'subagentInvocation': + return { ...base, kind: 'subagentInvocation', agentName: event.agentName, description: event.description, status: event.status, durationInMillis: event.durationInMillis, toolCallCount: event.toolCallCount, modelTurnCount: event.modelTurnCount }; + case 'userMessage': + return { ...base, kind: 'userMessage', message: event.message, sections: event.sections.map(s => ({ name: s.name, content: s.content })) }; + case 'agentResponse': + return { ...base, kind: 'agentResponse', message: event.message, sections: event.sections.map(s => ({ name: s.name, content: s.content })) }; + } + } + private _reviveEvent(dto: IChatDebugEventDto, sessionResource: URI): IChatDebugEvent { const base = { id: dto.id, diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 42cb2f2ddaa..522e1afba19 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -449,8 +449,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat dispose: () => disposables.dispose(), }); - disposables.add(this._chatSessionsService.registerChatModelChangeListeners( - this._chatService, + disposables.add(this._chatService.registerChatModelChangeListeners( chatSessionType, () => controller.fireOnDidChangeChatSessionItems() )); diff --git a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts index 6ed0a6d0acd..e9579b0a3ec 100644 --- a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts +++ b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts @@ -8,9 +8,9 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { URI } from '../../../base/common/uri.js'; import { GitRepository } from '../../contrib/git/browser/gitService.js'; -import { IGitExtensionDelegate, IGitService, GitRef, GitRefQuery, GitRefType, GitRepositoryState, GitBranch, IGitRepository } from '../../contrib/git/common/gitService.js'; +import { IGitExtensionDelegate, IGitService, GitRef, GitRefQuery, GitRefType, GitRepositoryState, GitBranch, GitChange, GitDiffChange, IGitRepository } from '../../contrib/git/common/gitService.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostContext, ExtHostGitExtensionShape, GitRefTypeDto, GitRepositoryStateDto, MainContext, MainThreadGitExtensionShape } from '../common/extHost.protocol.js'; +import { ExtHostContext, ExtHostGitExtensionShape, GitDiffChangeDto, GitRefTypeDto, GitRepositoryStateDto, MainContext, MainThreadGitExtensionShape } from '../common/extHost.protocol.js'; function toGitRefType(type: GitRefTypeDto): GitRefType { switch (type) { @@ -21,6 +21,16 @@ function toGitRefType(type: GitRefTypeDto): GitRefType { } } +function toGitDiffChange(dto: GitDiffChangeDto): GitDiffChange { + return { + uri: URI.revive(dto.uri), + originalUri: dto.originalUri ? URI.revive(dto.originalUri) : undefined, + modifiedUri: dto.modifiedUri ? URI.revive(dto.modifiedUri) : undefined, + insertions: dto.insertions, + deletions: dto.deletions, + }; +} + function toGitRepositoryState(dto: GitRepositoryStateDto | undefined): GitRepositoryState { return { HEAD: dto?.HEAD ? { @@ -28,10 +38,31 @@ function toGitRepositoryState(dto: GitRepositoryStateDto | undefined): GitReposi name: dto.HEAD.name, commit: dto.HEAD.commit, remote: dto.HEAD.remote, + base: dto.HEAD.base, upstream: dto.HEAD.upstream, ahead: dto.HEAD.ahead, behind: dto.HEAD.behind, } satisfies GitBranch : undefined, + mergeChanges: dto?.mergeChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], + indexChanges: dto?.indexChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], + workingTreeChanges: dto?.workingTreeChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], + untrackedChanges: dto?.untrackedChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], }; } @@ -124,6 +155,16 @@ export class MainThreadGitExtensionService extends Disposable implements MainThr } satisfies GitRef)); } + async diffBetweenWithStats(root: URI, ref1: string, ref2: string, path?: string): Promise { + const handle = this._repositoryHandles.get(root); + if (handle === undefined) { + return []; + } + + const result = await this._proxy.$diffBetweenWithStats(handle, ref1, ref2, path); + return result.map(toGitDiffChange); + } + async $onDidChangeRepository(handle: number): Promise { const repository = this._repositories.get(handle); if (!repository) { diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts index 69a38597269..d4a4edb3cd4 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModelTools.ts @@ -10,6 +10,7 @@ import { ThemeIcon } from '../../../base/common/themables.js'; import { isUriComponents, URI, UriComponents } from '../../../base/common/uri.js'; import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; +import { IProductService } from '../../../platform/product/common/productService.js'; import { toToolSetKey } from '../../contrib/chat/common/tools/languageModelToolsContribution.js'; import { CountTokensCallback, ILanguageModelToolsService, IToolData, IToolInvocation, IToolProgressStep, IToolResult, ToolDataSource, ToolProgress, toolResultHasBuffers, ToolSet } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; @@ -30,6 +31,7 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre extHostContext: IExtHostContext, @ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService, @ILogService private readonly _logService: ILogService, + @IProductService private readonly _productService: IProductService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostLanguageModelTools); @@ -118,8 +120,13 @@ export class MainThreadLanguageModelTools extends Disposable implements MainThre } } - // Convert source from DTO - const source = revive(definition.source); + // Convert source from DTO, matching the isBuiltinTool logic from languageModelToolsContribution + const isBuiltinTool = this._productService.defaultChatAgent?.chatExtensionId + ? ExtensionIdentifier.equals(extensionId, this._productService.defaultChatAgent.chatExtensionId) + : false; + const source: ToolDataSource = isBuiltinTool + ? ToolDataSource.Internal + : revive(definition.source); // Create the tool data const toolData: IToolData = { diff --git a/src/vs/workbench/api/browser/mainThreadManagedSockets.ts b/src/vs/workbench/api/browser/mainThreadManagedSockets.ts index 8bd8f02136a..7b22545502e 100644 --- a/src/vs/workbench/api/browser/mainThreadManagedSockets.ts +++ b/src/vs/workbench/api/browser/mainThreadManagedSockets.ts @@ -4,20 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { VSBuffer } from '../../../base/common/buffer.js'; -import { Emitter } from '../../../base/common/event.js'; -import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; import { ISocket, SocketCloseEventType } from '../../../base/parts/ipc/common/ipc.net.js'; import { ManagedSocket, RemoteSocketHalf, connectManagedSocket } from '../../../platform/remote/common/managedSocket.js'; import { ManagedRemoteConnection, RemoteConnectionType } from '../../../platform/remote/common/remoteAuthorityResolver.js'; import { IRemoteSocketFactoryService, ISocketFactory } from '../../../platform/remote/common/remoteSocketFactoryService.js'; -import { ExtHostContext, ExtHostManagedSocketsShape, MainContext, MainThreadManagedSocketsShape } from '../common/extHost.protocol.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostContext, ExtHostManagedSocketsShape, MainContext, MainThreadManagedSocketsShape } from '../common/extHost.protocol.js'; @extHostNamedCustomer(MainContext.MainThreadManagedSockets) export class MainThreadManagedSockets extends Disposable implements MainThreadManagedSocketsShape { private readonly _proxy: ExtHostManagedSocketsShape; - private readonly _registrations = new Map(); + private readonly _registrations = this._register(new DisposableMap()); private readonly _remoteSockets = new Map(); constructor( @@ -30,6 +30,7 @@ export class MainThreadManagedSockets extends Disposable implements MainThreadMa async $registerSocketFactory(socketFactoryId: number): Promise { const that = this; + const store = new DisposableStore(); const socketFactory = new class implements ISocketFactory { supports(connectTo: ManagedRemoteConnection): boolean { @@ -54,7 +55,7 @@ export class MainThreadManagedSockets extends Disposable implements MainThreadMa MainThreadManagedSocket.connect(socketId, that._proxy, path, query, debugLabel, half) .then( socket => { - socket.onDidDispose(() => that._remoteSockets.delete(socketId)); + store.add(Event.once(socket.onDidDispose)(() => that._remoteSockets.delete(socketId))); resolve(socket); }, err => { @@ -65,12 +66,13 @@ export class MainThreadManagedSockets extends Disposable implements MainThreadMa }); } }; - this._registrations.set(socketFactoryId, this._remoteSocketFactoryService.register(RemoteConnectionType.Managed, socketFactory)); + store.add(this._remoteSocketFactoryService.register(RemoteConnectionType.Managed, socketFactory)); + this._registrations.set(socketFactoryId, store); } async $unregisterSocketFactory(socketFactoryId: number): Promise { - this._registrations.get(socketFactoryId)?.dispose(); + this._registrations.deleteAndDispose(socketFactoryId); } $onDidManagedSocketHaveData(socketId: number, data: VSBuffer): void { @@ -115,7 +117,7 @@ export class MainThreadManagedSocket extends ManagedSocket { this.proxy.$remoteSocketWrite(this.socketId, buffer); } - protected override closeRemote(): void { + protected override closeRemote(): void { this.proxy.$remoteSocketEnd(this.socketId); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index a5dfeb3c1d6..9704f030189 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -351,11 +351,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I // namespace: commands const commands: typeof vscode.commands = { - registerCommand(id: string, command: (...args: any[]) => T | Thenable, thisArgs?: any): vscode.Disposable { + registerCommand(id: string, command: (...args: unknown[]) => T | Thenable, thisArgs?: unknown): vscode.Disposable { return extHostCommands.registerCommand(true, id, command, thisArgs, undefined, extension); }, - registerTextEditorCommand(id: string, callback: (textEditor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args: any[]) => void, thisArg?: any): vscode.Disposable { - return extHostCommands.registerCommand(true, id, (...args: any[]): any => { + registerTextEditorCommand(id: string, callback: (textEditor: vscode.TextEditor, edit: vscode.TextEditorEdit, ...args: unknown[]) => void, thisArg?: unknown): vscode.Disposable { + return extHostCommands.registerCommand(true, id, (...args: unknown[]): any => { const activeTextEditor = extHostEditors.getActiveTextEditor(); if (!activeTextEditor) { extHostLogService.warn('Cannot execute ' + id + ' because there is no active text editor.'); @@ -364,7 +364,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I return activeTextEditor.edit((edit: vscode.TextEditorEdit) => { callback.apply(thisArg, [activeTextEditor, edit, ...args]); - }).then((result) => { if (!result) { extHostLogService.warn('Edits from command ' + id + ' were not applied.'); @@ -374,9 +373,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }); }, undefined, undefined, extension); }, - registerDiffInformationCommand: (id: string, callback: (diff: vscode.LineChange[], ...args: any[]) => any, thisArg?: any): vscode.Disposable => { + registerDiffInformationCommand: (id: string, callback: (diff: vscode.LineChange[], ...args: unknown[]) => any, thisArg?: unknown): vscode.Disposable => { checkProposedApiEnabled(extension, 'diffCommand'); - return extHostCommands.registerCommand(true, id, async (...args: any[]): Promise => { + return extHostCommands.registerCommand(true, id, async (...args: unknown[]): Promise => { const activeTextEditor = extHostDocumentsAndEditors.activeEditor(true); if (!activeTextEditor) { extHostLogService.warn('Cannot execute ' + id + ' because there is no active text editor.'); @@ -387,7 +386,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I callback.apply(thisArg, [diff, ...args]); }, undefined, undefined, extension); }, - executeCommand(id: string, ...args: any[]): Thenable { + executeCommand(id: string, ...args: unknown[]): Thenable { return extHostCommands.executeCommand(id, ...args); }, getCommands(filterInternal: boolean = false): Thenable { @@ -1304,13 +1303,13 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I onDidRenameFiles: (listener, thisArg, disposables) => { return _asExtensionEvent(extHostFileSystemEvent.onDidRenameFile)(listener, thisArg, disposables); }, - onWillCreateFiles: (listener: (e: vscode.FileWillCreateEvent) => any, thisArg?: any, disposables?: vscode.Disposable[]) => { + onWillCreateFiles: (listener: (e: vscode.FileWillCreateEvent) => any, thisArg?: unknown, disposables?: vscode.Disposable[]) => { return _asExtensionEvent(extHostFileSystemEvent.getOnWillCreateFileEvent(extension))(listener, thisArg, disposables); }, - onWillDeleteFiles: (listener: (e: vscode.FileWillDeleteEvent) => any, thisArg?: any, disposables?: vscode.Disposable[]) => { + onWillDeleteFiles: (listener: (e: vscode.FileWillDeleteEvent) => any, thisArg?: unknown, disposables?: vscode.Disposable[]) => { return _asExtensionEvent(extHostFileSystemEvent.getOnWillDeleteFileEvent(extension))(listener, thisArg, disposables); }, - onWillRenameFiles: (listener: (e: vscode.FileWillRenameEvent) => any, thisArg?: any, disposables?: vscode.Disposable[]) => { + onWillRenameFiles: (listener: (e: vscode.FileWillRenameEvent) => any, thisArg?: unknown, disposables?: vscode.Disposable[]) => { return _asExtensionEvent(extHostFileSystemEvent.getOnWillRenameFileEvent(extension))(listener, thisArg, disposables); }, openTunnel: (forward: vscode.TunnelOptions) => { @@ -1626,6 +1625,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, registerChatSessionItemProvider: (chatSessionType: string, provider: vscode.ChatSessionItemProvider) => { checkProposedApiEnabled(extension, 'chatSessionsProvider'); + extHostApiDeprecation.report('chat.registerChatSessionItemProvider', extension, `Please migrate to the new chat session controller API`, { + usageId: chatSessionType + }); return extHostChatSessions.registerChatSessionItemProvider(extension, chatSessionType, provider); }, createChatSessionItemController: (chatSessionType: string, refreshHandler: (token: vscode.CancellationToken) => Thenable) => { @@ -1677,6 +1679,30 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatDebug'); return extHostChatDebug.registerChatDebugLogProvider(provider); }, + get customAgents() { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.customAgents as readonly vscode.ChatResource[]; + }, + onDidChangeCustomAgents: (listener, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.onDidChangeCustomAgents(listener, thisArgs, disposables); + }, + get instructions() { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.instructions as readonly vscode.ChatResource[]; + }, + onDidChangeInstructions: (listener, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.onDidChangeInstructions(listener, thisArgs, disposables); + }, + get skills() { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.skills as readonly vscode.ChatResource[]; + }, + onDidChangeSkills: (listener, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.onDidChangeSkills(listener, thisArgs, disposables); + }, }; // namespace: lm diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0f5821f2662..68defe04023 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1502,6 +1502,8 @@ export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto | export interface ExtHostChatDebugShape { $provideChatDebugLog(handle: number, sessionResource: UriComponents, token: CancellationToken): Promise; $resolveChatDebugLogEvent(handle: number, eventId: string, token: CancellationToken): Promise; + $exportChatDebugLog(handle: number, sessionResource: UriComponents, coreEvents: IChatDebugEventDto[], sessionTitle: string | undefined, token: CancellationToken): Promise; + $importChatDebugLog(handle: number, data: VSBuffer, token: CancellationToken): Promise<{ uri: UriComponents; sessionTitle?: string } | undefined>; } export interface MainThreadChatDebugShape extends IDisposable { @@ -1614,6 +1616,21 @@ export interface ExtHostChatAgentsShape2 { $setRequestTools(requestId: string, tools: UserSelectedTools): void; $setYieldRequested(requestId: string, value: boolean): void; $acceptActiveChatSession(sessionResource: UriComponents | undefined): void; + $acceptCustomAgents(agents: ICustomAgentDto[]): void; + $acceptInstructions(instructions: IInstructionDto[]): void; + $acceptSkills(skills: ISkillDto[]): void; +} + +export interface ICustomAgentDto { + uri: UriComponents; +} + +export interface IInstructionDto { + uri: UriComponents; +} + +export interface ISkillDto { + uri: UriComponents; } export interface IChatParticipantMetadata { participant: string; @@ -2099,7 +2116,7 @@ export interface ExtHostCodeMapperShape { } export interface ExtHostCommandsShape { - $executeContributedCommand(id: string, ...args: any[]): Promise; + $executeContributedCommand(id: string, ...args: unknown[]): Promise; $getContributedCommandMetadata(): Promise<{ [id: string]: string | ICommandMetadataDto }>; } @@ -2428,7 +2445,7 @@ export interface ISuggestDataDto { // Command [ISuggestDataDtoField.commandIdent]?: string; [ISuggestDataDtoField.commandId]?: string; - [ISuggestDataDtoField.commandArguments]?: any[]; + [ISuggestDataDtoField.commandArguments]?: unknown[]; // not-standard x?: ChainedCacheId; } @@ -2513,6 +2530,7 @@ export interface IChatUsageDto { kind: 'usage'; promptTokens: number; completionTokens: number; + outputBuffer?: number; promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]; } @@ -3612,8 +3630,23 @@ export interface GitRefDto { readonly revision: string; } +export interface GitChangeDto { + readonly uri: UriComponents; + readonly originalUri: UriComponents | undefined; + readonly modifiedUri: UriComponents | undefined; +} + +export interface GitDiffChangeDto extends GitChangeDto { + readonly insertions: number; + readonly deletions: number; +} + export interface GitRepositoryStateDto { readonly HEAD?: GitBranchDto; + readonly mergeChanges: readonly GitChangeDto[]; + readonly indexChanges: readonly GitChangeDto[]; + readonly workingTreeChanges: readonly GitChangeDto[]; + readonly untrackedChanges: readonly GitChangeDto[]; } export interface GitBranchDto { @@ -3621,11 +3654,17 @@ export interface GitBranchDto { readonly commit?: string; readonly type: GitRefTypeDto; readonly remote?: string; + readonly base?: GitBaseRefDto; readonly upstream?: GitUpstreamRefDto; readonly ahead?: number; readonly behind?: number; } +export interface GitBaseRefDto { + readonly name: string; + readonly isProtected: boolean; +} + export interface GitUpstreamRefDto { readonly remote: string; readonly name: string; @@ -3637,6 +3676,7 @@ export interface ExtHostGitExtensionShape { $openRepository(root: UriComponents): Promise<{ handle: number; rootUri: UriComponents; state: GitRepositoryStateDto } | undefined>; $getRefs(handle: number, query: GitRefQueryDto, token?: CancellationToken): Promise; $getRepositoryState(handle: number): Promise; + $diffBetweenWithStats(handle: number, ref1: string, ref2: string, path?: string): Promise; } // --- proxy identifiers diff --git a/src/vs/workbench/api/common/extHostApiDeprecationService.ts b/src/vs/workbench/api/common/extHostApiDeprecationService.ts index 9a72ce444c0..da3efe1bad3 100644 --- a/src/vs/workbench/api/common/extHostApiDeprecationService.ts +++ b/src/vs/workbench/api/common/extHostApiDeprecationService.ts @@ -12,7 +12,7 @@ import { IExtHostRpcService } from './extHostRpcService.js'; export interface IExtHostApiDeprecationService { readonly _serviceBrand: undefined; - report(apiId: string, extension: IExtensionDescription, migrationSuggestion: string): void; + report(apiId: string, extension: IExtensionDescription, migrationSuggestion: string, options?: { usageId?: string }): void; } export const IExtHostApiDeprecationService = createDecorator('IExtHostApiDeprecationService'); @@ -31,8 +31,8 @@ export class ExtHostApiDeprecationService implements IExtHostApiDeprecationServi this._telemetryShape = rpc.getProxy(extHostProtocol.MainContext.MainThreadTelemetry); } - public report(apiId: string, extension: IExtensionDescription, migrationSuggestion: string): void { - const key = this.getUsageKey(apiId, extension); + public report(apiId: string, extension: IExtensionDescription, migrationSuggestion: string, options?: { usageId?: string }): void { + const key = this.getUsageKey(apiId, extension, options?.usageId); if (this._reportedUsages.has(key)) { return; } @@ -45,21 +45,25 @@ export class ExtHostApiDeprecationService implements IExtHostApiDeprecationServi type DeprecationTelemetry = { extensionId: string; apiId: string; + usageId: string; }; type DeprecationTelemetryMeta = { extensionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The id of the extension that is using the deprecated API' }; apiId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The id of the deprecated API' }; + usageId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Id identifying the specific usage of the deprecated API' }; owner: 'mjbvz'; comment: 'Helps us gain insights on extensions using deprecated API so we can assist in migration to new API'; }; this._telemetryShape.$publicLog2('extHostDeprecatedApiUsage', { extensionId: extension.identifier.value, apiId: apiId, + usageId: options?.usageId ?? '', }); } - private getUsageKey(apiId: string, extension: IExtensionDescription): string { - return `${apiId}-${extension.identifier.value}`; + private getUsageKey(apiId: string, extension: IExtensionDescription, usageId?: string): string { + const rootKey = `${apiId}-${extension.identifier.value}`; + return usageId ? `${rootKey}-${usageId}` : rootKey; } } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 06553d2cb0f..d91ae3ce3f0 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -22,12 +22,12 @@ import { ILogService } from '../../../platform/log/common/log.js'; import { isChatViewTitleActionContext } from '../../contrib/chat/common/actions/chatActions.js'; import { IChatAgentRequest, IChatAgentResult, IChatAgentResultTimings, UserSelectedTools } from '../../contrib/chat/common/participants/chatAgents.js'; import { ChatAgentVoteDirection, IChatContentReference, IChatFollowup, IChatResponseErrorDetails, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService/chatService.js'; -import { IChatRequestHooks } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { ChatRequestHooks } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IChatAgentProgressShape, IChatProgressDto, IChatSessionContextDto, IExtensionChatAgentMetadata, IMainContext, MainContext, MainThreadChatAgentsShape2 } from './extHost.protocol.js'; +import { ExtHostChatAgentsShape2, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IChatAgentProgressShape, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IExtensionChatAgentMetadata, IInstructionDto, IMainContext, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from './extHost.protocol.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; import { ExtHostDiagnostics } from './extHostDiagnostics.js'; import { ExtHostDocuments } from './extHostDocuments.js'; @@ -440,6 +440,7 @@ export class ChatAgentResponseStream { kind: 'usage', promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, + outputBuffer: usage.outputBuffer, promptTokenDetails: usage.promptTokenDetails }; _report(dto); @@ -456,7 +457,7 @@ interface InFlightChatRequest { requestId: string; extRequest: vscode.ChatRequest; extension: IRelaxedExtensionDescription; - hooks?: IChatRequestHooks; + hooks?: ChatRequestHooks; yieldRequested: boolean; } @@ -487,6 +488,17 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS private readonly _onDidDisposeChatSession = this._register(new Emitter()); readonly onDidDisposeChatSession = this._onDidDisposeChatSession.event; + private readonly _onDidChangeCustomAgents = this._register(new Emitter()); + readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event; + private readonly _onDidChangeInstructions = this._register(new Emitter()); + readonly onDidChangeInstructions = this._onDidChangeInstructions.event; + private readonly _onDidChangeSkills = this._register(new Emitter()); + readonly onDidChangeSkills = this._onDidChangeSkills.event; + + private _customAgents: vscode.ChatResource[] = []; + private _instructions: vscode.ChatResource[] = []; + private _skills: vscode.ChatResource[] = []; + private _activeChatPanelSessionResource: URI | undefined; private readonly _onDidChangeActiveChatPanelSessionResource = this._register(new Emitter()); @@ -496,6 +508,33 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return this._activeChatPanelSessionResource; } + get customAgents(): readonly vscode.ChatResource[] { + return this._customAgents; + } + + get instructions(): readonly vscode.ChatResource[] { + return this._instructions; + } + + get skills(): readonly vscode.ChatResource[] { + return this._skills; + } + + $acceptCustomAgents(agents: ICustomAgentDto[]): void { + this._customAgents = agents.map(a => Object.freeze({ uri: URI.revive(a.uri) })); + this._onDidChangeCustomAgents.fire(); + } + + $acceptInstructions(instructions: IInstructionDto[]): void { + this._instructions = instructions.map(i => Object.freeze({ uri: URI.revive(i.uri) })); + this._onDidChangeInstructions.fire(); + } + + $acceptSkills(skills: ISkillDto[]): void { + this._skills = skills.map(s => Object.freeze({ uri: URI.revive(s.uri) })); + this._onDidChangeSkills.fire(); + } + constructor( mainContext: IMainContext, private readonly _logService: ILogService, diff --git a/src/vs/workbench/api/common/extHostChatDebug.ts b/src/vs/workbench/api/common/extHostChatDebug.ts index 04125f3e551..b83bb2f3cf5 100644 --- a/src/vs/workbench/api/common/extHostChatDebug.ts +++ b/src/vs/workbench/api/common/extHostChatDebug.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { ExtHostChatDebugShape, IChatDebugEventDto, IChatDebugResolvedEventContentDto, MainContext, MainThreadChatDebugShape } from './extHost.protocol.js'; -import { ChatDebugMessageContentType, ChatDebugSubagentStatus, ChatDebugToolCallResult } from './extHostTypes.js'; +import { ChatDebugGenericEvent, ChatDebugLogLevel, ChatDebugMessageContentType, ChatDebugMessageSection, ChatDebugModelTurnEvent, ChatDebugSubagentInvocationEvent, ChatDebugSubagentStatus, ChatDebugToolCallEvent, ChatDebugToolCallResult, ChatDebugUserMessageEvent, ChatDebugAgentResponseEvent } from './extHostTypes.js'; import { IExtHostRpcService } from './extHostRpcService.js'; export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShape { @@ -291,6 +292,106 @@ export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShap } } + private _deserializeEvent(dto: IChatDebugEventDto): vscode.ChatDebugEvent | undefined { + const created = new Date(dto.created); + const sessionResource = dto.sessionResource ? URI.revive(dto.sessionResource) : undefined; + switch (dto.kind) { + case 'toolCall': { + const evt = new ChatDebugToolCallEvent(dto.toolName, created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.toolCallId = dto.toolCallId; + evt.input = dto.input; + evt.output = dto.output; + evt.result = dto.result === 'success' ? ChatDebugToolCallResult.Success + : dto.result === 'error' ? ChatDebugToolCallResult.Error + : undefined; + evt.durationInMillis = dto.durationInMillis; + return evt; + } + case 'modelTurn': { + const evt = new ChatDebugModelTurnEvent(created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.model = dto.model; + evt.inputTokens = dto.inputTokens; + evt.outputTokens = dto.outputTokens; + evt.totalTokens = dto.totalTokens; + evt.durationInMillis = dto.durationInMillis; + return evt; + } + case 'generic': { + const evt = new ChatDebugGenericEvent(dto.name, dto.level as ChatDebugLogLevel, created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.details = dto.details; + evt.category = dto.category; + return evt; + } + case 'subagentInvocation': { + const evt = new ChatDebugSubagentInvocationEvent(dto.agentName, created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.description = dto.description; + evt.status = dto.status === 'running' ? ChatDebugSubagentStatus.Running + : dto.status === 'completed' ? ChatDebugSubagentStatus.Completed + : dto.status === 'failed' ? ChatDebugSubagentStatus.Failed + : undefined; + evt.durationInMillis = dto.durationInMillis; + evt.toolCallCount = dto.toolCallCount; + evt.modelTurnCount = dto.modelTurnCount; + return evt; + } + case 'userMessage': { + const evt = new ChatDebugUserMessageEvent(dto.message, created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.sections = dto.sections.map(s => new ChatDebugMessageSection(s.name, s.content)); + return evt; + } + case 'agentResponse': { + const evt = new ChatDebugAgentResponseEvent(dto.message, created); + evt.id = dto.id; + evt.sessionResource = sessionResource; + evt.parentEventId = dto.parentEventId; + evt.sections = dto.sections.map(s => new ChatDebugMessageSection(s.name, s.content)); + return evt; + } + default: + return undefined; + } + } + + async $exportChatDebugLog(_handle: number, sessionResource: UriComponents, coreEventDtos: IChatDebugEventDto[], sessionTitle: string | undefined, token: CancellationToken): Promise { + if (!this._provider?.provideChatDebugLogExport) { + return undefined; + } + const sessionUri = URI.revive(sessionResource); + const coreEvents = coreEventDtos.map(dto => this._deserializeEvent(dto)).filter((e): e is vscode.ChatDebugEvent => e !== undefined); + const options: vscode.ChatDebugLogExportOptions = { coreEvents, sessionTitle }; + const result = await this._provider.provideChatDebugLogExport(sessionUri, options, token); + if (!result) { + return undefined; + } + return VSBuffer.wrap(result); + } + + async $importChatDebugLog(_handle: number, data: VSBuffer, token: CancellationToken): Promise<{ uri: UriComponents; sessionTitle?: string } | undefined> { + if (!this._provider?.resolveChatDebugLogImport) { + return undefined; + } + const result = await this._provider.resolveChatDebugLogImport(data.buffer, token); + if (!result) { + return undefined; + } + return { uri: result.uri, sessionTitle: result.sessionTitle }; + } + override dispose(): void { for (const store of this._activeProgress.values()) { store.dispose(); diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index d33a86b6f5b..e964a3720e6 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -13,12 +13,11 @@ import { Disposable, DisposableStore, toDisposable } from '../../../base/common/ import { ResourceMap, ResourceSet } from '../../../base/common/map.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import * as objects from '../../../base/common/objects.js'; -import { basename } from '../../../base/common/resources.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; -import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData, IPromptFileVariableEntry, ISymbolVariableEntry, PromptFileVariableKind } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; +import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData, ISymbolVariableEntry, PromptFileVariableKind, toPromptFileVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; @@ -709,19 +708,16 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } satisfies ISymbolVariableEntry; } - if (URI.isUri(value) && ref.name.startsWith(`prompt:`) && - ref.id.startsWith(PromptFileVariableKind.PromptFile) && - ref.id.endsWith(value.toString())) { - return { - id: ref.id, - name: `prompt:${basename(value)}`, - value, - kind: 'promptFile', - modelDescription: 'Prompt instructions file', - isRoot: true, - automaticallyAdded: false, - range, - } satisfies IPromptFileVariableEntry; + if (URI.isUri(value) && ref.name.startsWith(`prompt:`)) { + if (ref.id.startsWith(PromptFileVariableKind.Instruction)) { + return toPromptFileVariableEntry(value, PromptFileVariableKind.Instruction); + } + if (ref.id.startsWith(PromptFileVariableKind.InstructionReference)) { + return toPromptFileVariableEntry(value, PromptFileVariableKind.InstructionReference); + } + if (ref.id.startsWith(PromptFileVariableKind.PromptFile)) { + return toPromptFileVariableEntry(value, PromptFileVariableKind.PromptFile); + } } const isFile = URI.isUri(value) || (value && typeof value === 'object' && 'uri' in value); diff --git a/src/vs/workbench/api/common/extHostCommands.ts b/src/vs/workbench/api/common/extHostCommands.ts index 15c769373b8..92e874dc6c9 100644 --- a/src/vs/workbench/api/common/extHostCommands.ts +++ b/src/vs/workbench/api/common/extHostCommands.ts @@ -425,11 +425,11 @@ export class CommandsConverter implements extHostTypeConverter.Command.ICommands } - getActualCommand(...args: any[]): vscode.Command | undefined { - return this._cache.get(args[0]); + getActualCommand(...args: unknown[]): vscode.Command | undefined { + return this._cache.get(args[0] as string); } - private _executeConvertedCommand(...args: any[]): Promise { + private _executeConvertedCommand(...args: unknown[]): Promise { const actualCmd = this.getActualCommand(...args); this._logService.trace('CommandsConverter#EXECUTE', args[0], actualCmd ? actualCmd.command : 'MISSING'); diff --git a/src/vs/workbench/api/common/extHostConsoleForwarder.ts b/src/vs/workbench/api/common/extHostConsoleForwarder.ts index 02559149900..8bd7fc9a912 100644 --- a/src/vs/workbench/api/common/extHostConsoleForwarder.ts +++ b/src/vs/workbench/api/common/extHostConsoleForwarder.ts @@ -46,13 +46,13 @@ export abstract class AbstractExtHostConsoleForwarder { Object.defineProperty(console, method, { set: () => { }, - get: () => function () { - that._handleConsoleCall(method, severity, original, arguments); + get: () => (...args: unknown[]) => { + that._handleConsoleCall(method, severity, original, args); }, }); } - private _handleConsoleCall(method: 'log' | 'info' | 'warn' | 'error' | 'debug', severity: 'log' | 'warn' | 'error' | 'debug', original: (...args: any[]) => void, args: IArguments): void { + private _handleConsoleCall(method: 'log' | 'info' | 'warn' | 'error' | 'debug', severity: 'log' | 'warn' | 'error' | 'debug', original: (...args: unknown[]) => void, args: unknown[]): void { this._mainThreadConsole.$logExtensionHostMessage({ type: '__$console', severity, @@ -63,7 +63,7 @@ export abstract class AbstractExtHostConsoleForwarder { } } - protected abstract _nativeConsoleLogMessage(method: 'log' | 'info' | 'warn' | 'error' | 'debug', original: (...args: any[]) => void, args: IArguments): void; + protected abstract _nativeConsoleLogMessage(method: 'log' | 'info' | 'warn' | 'error' | 'debug', original: (...args: unknown[]) => void, args: unknown[]): void; } @@ -72,7 +72,7 @@ const MAX_LENGTH = 100000; /** * Prevent circular stringify and convert arguments to real array */ -function safeStringifyArgumentsToArray(args: IArguments, includeStack: boolean): string { +function safeStringifyArgumentsToArray(args: unknown[], includeStack: boolean): string { const argsArray = []; // Massage some arguments with special treatment diff --git a/src/vs/workbench/api/common/extHostGitExtensionService.ts b/src/vs/workbench/api/common/extHostGitExtensionService.ts index 61a64e83bf3..4d1dd2e2acb 100644 --- a/src/vs/workbench/api/common/extHostGitExtensionService.ts +++ b/src/vs/workbench/api/common/extHostGitExtensionService.ts @@ -11,7 +11,7 @@ import { ExtensionIdentifier } from '../../../platform/extensions/common/extensi import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { IExtHostExtensionService } from './extHostExtensionService.js'; import { IExtHostRpcService } from './extHostRpcService.js'; -import { ExtHostGitExtensionShape, GitBranchDto, GitRefDto, GitRefQueryDto, GitRefTypeDto, GitRepositoryStateDto, GitUpstreamRefDto, MainContext, MainThreadGitExtensionShape } from './extHost.protocol.js'; +import { ExtHostGitExtensionShape, GitBaseRefDto, GitBranchDto, GitChangeDto, GitDiffChangeDto, GitRefDto, GitRefQueryDto, GitRefTypeDto, GitRepositoryStateDto, GitUpstreamRefDto, MainContext, MainThreadGitExtensionShape } from './extHost.protocol.js'; import { ResourceMap } from '../../../base/common/map.js'; const GIT_EXTENSION_ID = 'vscode.git'; @@ -31,6 +31,7 @@ function toGitBranchDto(branch: Branch): GitBranchDto { commit: branch.commit, type: toGitRefTypeDto(branch.type), remote: branch.remote, + base: branch.base, upstream: branch.upstream ? toGitUpstreamRefDto(branch.upstream) : undefined, ahead: branch.ahead, behind: branch.behind, @@ -45,25 +46,86 @@ function toGitUpstreamRefDto(upstream: UpstreamRef): GitUpstreamRefDto { }; } +// Status values from the git extension's const enum Status +const enum GitStatus { + INDEX_ADDED = 1, + INDEX_DELETED = 2, + INDEX_RENAMED = 3, + MODIFIED = 5, + DELETED = 6, + UNTRACKED = 7, + INTENT_TO_ADD = 9, + INTENT_TO_RENAME = 10, +} + +function toGitChangeDto(change: Change): GitChangeDto { + switch (change.status) { + // Added: no original + case GitStatus.INDEX_ADDED: + case GitStatus.UNTRACKED: + case GitStatus.INTENT_TO_ADD: + return { uri: change.uri, originalUri: undefined, modifiedUri: change.uri }; + + // Deleted: no modified + case GitStatus.INDEX_DELETED: + case GitStatus.DELETED: + return { uri: change.uri, originalUri: change.uri, modifiedUri: undefined }; + + // Renamed: original is old name, modified is new name + case GitStatus.INDEX_RENAMED: + case GitStatus.INTENT_TO_RENAME: + return { uri: change.uri, originalUri: change.originalUri, modifiedUri: change.renameUri }; + + // Modified and everything else: both original and modified + default: + return { uri: change.uri, originalUri: change.originalUri, modifiedUri: change.uri }; + } +} + +interface DiffChange extends Change { + readonly insertions: number; + readonly deletions: number; +} + interface Repository { readonly rootUri: vscode.Uri; readonly state: RepositoryState; status(): Promise; + getBranchBase(name: string): Promise; getRefs(query: GitRefQuery, token?: vscode.CancellationToken): Promise; + diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise; + isBranchProtected(branch?: Branch): boolean; +} + +interface Change { + readonly uri: vscode.Uri; + readonly originalUri: vscode.Uri; + readonly renameUri: vscode.Uri | undefined; + readonly status: number; } interface RepositoryState { readonly HEAD: Branch | undefined; + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + readonly untrackedChanges: Change[]; readonly onDidChange: Event; } interface Branch extends GitRef { + readonly base?: BaseRef; readonly upstream?: UpstreamRef; readonly ahead?: number; readonly behind?: number; } +interface BaseRef { + readonly name: string; + readonly isProtected: boolean; +} + interface UpstreamRef { readonly remote: string; readonly name: string; @@ -145,13 +207,8 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi const existingHandle = this._repositoryByUri.get(repository.rootUri); if (existingHandle !== undefined) { - return { - handle: existingHandle, - rootUri: repository.rootUri, - state: { - HEAD: repository.state.HEAD ? toGitBranchDto(repository.state.HEAD) : undefined - } - }; + const state = await this._getRepositoryState(repository); + return { handle: existingHandle, rootUri: repository.rootUri, state }; } let repositoryState = repository.state; @@ -175,15 +232,8 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi this._proxy.$onDidChangeRepository(handle); })); - return { - handle, - rootUri: repository.rootUri, - state: { - HEAD: repository.state.HEAD - ? toGitBranchDto(repository.state.HEAD) - : undefined - } - }; + const state = await this._getRepositoryState(repository); + return { handle, rootUri: repository.rootUri, state }; } async $getRefs(handle: number, query: GitRefQueryDto, token?: vscode.CancellationToken): Promise { @@ -225,8 +275,55 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi return undefined; } + return this._getRepositoryState(repository); + } + + private async _getRepositoryState(repository: Repository): Promise { const state = repository.state; - return { HEAD: state.HEAD ? toGitBranchDto(state.HEAD) : undefined }; + + // Base branch + const base = await this._getBranchBase(repository); + + return { + HEAD: state.HEAD ? toGitBranchDto({ ...state.HEAD, base }) : undefined, + mergeChanges: state.mergeChanges.map(toGitChangeDto), + indexChanges: state.indexChanges.map(toGitChangeDto), + workingTreeChanges: state.workingTreeChanges.map(toGitChangeDto), + untrackedChanges: state.untrackedChanges.map(toGitChangeDto), + }; + } + + private async _getBranchBase(repository: Repository): Promise { + const state = repository.state; + if (!state.HEAD?.name) { + return undefined; + } + + const baseBranch = await repository.getBranchBase(state.HEAD.name); + if (!baseBranch?.name) { + return undefined; + } + + const isProtected = repository.isBranchProtected(baseBranch); + return { name: baseBranch.name, isProtected }; + } + + async $diffBetweenWithStats(handle: number, ref1: string, ref2: string, path?: string): Promise { + const repository = this._repositories.get(handle); + if (!repository) { + return []; + } + + try { + const changes = await repository.diffBetweenWithStats(ref1, ref2, path); + return changes.map(c => ({ + ...toGitChangeDto(c), + insertions: c.insertions, + deletions: c.deletions, + })); + } catch { + return []; + } } private async _ensureGitApi(): Promise { diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index 9325afc0185..cc76961ab15 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -364,7 +364,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { } for (const [modelIdentifier, modelData] of this._localModels) { - if (modelData.metadata.isDefaultForLocation[ChatAgentLocation.Chat]) { + if (modelData.metadata.isDefaultForLocation[ChatAgentLocation.Chat] && modelData.metadata.vendor === 'copilot') { defaultModelId = modelIdentifier; break; } diff --git a/src/vs/workbench/api/common/extHostTreeViews.ts b/src/vs/workbench/api/common/extHostTreeViews.ts index fce5e9d47db..c0d05cbb486 100644 --- a/src/vs/workbench/api/common/extHostTreeViews.ts +++ b/src/vs/workbench/api/common/extHostTreeViews.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../nls.js'; import type * as vscode from 'vscode'; import { basename } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; @@ -876,10 +875,14 @@ class ExtHostTreeView extends Disposable { if (duplicateHandle) { const existingElement = this._elements.get(duplicateHandle); if (existingElement) { - if (existingElement !== element) { - throw new Error(localize('treeView.duplicateElement', 'Element with id {0} is already registered', extTreeItem.id)); - } const existingNode = this._nodes.get(existingElement); + if (existingElement !== element) { + // A different element object was registered with the same ID. + // This can happen during concurrent tree operations (e.g., tree + // being switched to while data is updated). Clean up the stale + // element reference before re-registering with the new one. + this._nodes.delete(existingElement); + } if (existingNode) { const newNode = this._createTreeNode(element, extTreeItem, parentNode); this._updateNodeCache(element, newNode, existingNode, parentNode); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 841a35e48d2..9cab6521333 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -47,7 +47,7 @@ import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js' import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatSessionStatus, IChatSessionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; -import { IChatRequestHooks, IHookCommand, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { ChatRequestHooks, IHookCommand, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { IToolInvocationContext, IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import * as chatProvider from '../../contrib/chat/common/languageModels.js'; import { IChatMessageDataPart, IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; @@ -2671,7 +2671,7 @@ export namespace ChatResponseQuestionCarouselPart { type: questionTypeToString(q.type), title: q.title, message: q.message ? MarkdownString.from(q.message) : undefined, - options: q.options, + options: q.options?.map(opt => ({ id: opt.id, label: opt.label, value: String(opt.value) })), defaultValue: q.defaultValue, allowFreeformInput: q.allowFreeformInput })), @@ -2934,6 +2934,7 @@ export namespace ChatToolInvocationPart { pastTenseMessage: part.pastTenseMessage ? MarkdownString.from(part.pastTenseMessage) : undefined, toolSpecificData, subagentInvocationId: part.subAgentInvocationId, + resultDetails }; } @@ -3440,6 +3441,7 @@ export namespace ChatAgentRequest { editedFileEvents: request.editedFileEvents, modeInstructions: request.modeInstructions?.content, modeInstructions2: ChatRequestModeInstructions.to(request.modeInstructions), + permissionLevel: request.permissionLevel, subAgentInvocationId: request.subAgentInvocationId, subAgentName: request.subAgentName, parentRequestId: request.parentRequestId, @@ -3606,6 +3608,7 @@ export namespace ChatRequestModeInstructions { export function to(mode: IChatRequestModeInstructions | undefined): vscode.ChatRequestModeInstructions | undefined { if (mode) { return { + uri: URI.revive(mode.uri), name: mode.name, content: mode.content, toolReferences: ChatLanguageModelToolReferences.to(mode.toolReferences), @@ -4052,6 +4055,7 @@ export namespace McpServerDefinition { command: item.command, env: item.env, envFile: undefined, + sandbox: undefined } ); } @@ -4098,7 +4102,7 @@ export namespace SourceControlInputBoxValidationType { } export namespace ChatRequestHooksConverter { - export function to(hooks: IChatRequestHooks): vscode.ChatRequestHooks { + export function to(hooks: ChatRequestHooks): vscode.ChatRequestHooks { const result: Record = {}; for (const [hookType, commands] of Object.entries(hooks)) { if (!commands || commands.length === 0) { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 56f75132142..bd97ee3ffbb 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -30,7 +30,7 @@ import { SnippetString } from './extHostTypes/snippetString.js'; import { SymbolKind, SymbolTag } from './extHostTypes/symbolInformation.js'; import { TextEdit } from './extHostTypes/textEdit.js'; import { WorkspaceEdit } from './extHostTypes/workspaceEdit.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookTypes.js'; export { CodeActionKind } from './extHostTypes/codeActionKind.js'; export { diff --git a/src/vs/workbench/api/node/extHostCLIServer.ts b/src/vs/workbench/api/node/extHostCLIServer.ts index 70b04f8fdde..14ed9e4f586 100644 --- a/src/vs/workbench/api/node/extHostCLIServer.ts +++ b/src/vs/workbench/api/node/extHostCLIServer.ts @@ -47,7 +47,7 @@ export interface ExtensionManagementPipeArgs { export type PipeCommand = OpenCommandPipeArgs | StatusPipeArgs | OpenExternalCommandPipeArgs | ExtensionManagementPipeArgs; export interface ICommandsExecuter { - executeCommand(id: string, ...args: any[]): Promise; + executeCommand(id: string, ...args: unknown[]): Promise; } export class CLIServerBase { diff --git a/src/vs/workbench/api/node/extHostConsoleForwarder.ts b/src/vs/workbench/api/node/extHostConsoleForwarder.ts index d1568f28642..69ce4029ca0 100644 --- a/src/vs/workbench/api/node/extHostConsoleForwarder.ts +++ b/src/vs/workbench/api/node/extHostConsoleForwarder.ts @@ -24,12 +24,11 @@ export class ExtHostConsoleForwarder extends AbstractExtHostConsoleForwarder { this._wrapStream('stdout', 'log'); } - protected override _nativeConsoleLogMessage(method: 'log' | 'info' | 'warn' | 'error' | 'debug', original: (...args: any[]) => void, args: IArguments) { + protected override _nativeConsoleLogMessage(method: 'log' | 'info' | 'warn' | 'error' | 'debug', original: (...args: unknown[]) => void, args: unknown[]): void { const stream = method === 'error' || method === 'warn' ? process.stderr : process.stdout; this._isMakingConsoleCall = true; stream.write(`\n${NativeLogMarkers.Start}\n`); - // eslint-disable-next-line local/code-no-any-casts - original.apply(console, args as any); + original.apply(console, args); stream.write(`\n${NativeLogMarkers.End}\n`); this._isMakingConsoleCall = false; } diff --git a/src/vs/workbench/api/node/extHostMcpNode.ts b/src/vs/workbench/api/node/extHostMcpNode.ts index 1b961f66ae1..c08fc0af221 100644 --- a/src/vs/workbench/api/node/extHostMcpNode.ts +++ b/src/vs/workbench/api/node/extHostMcpNode.ts @@ -73,6 +73,11 @@ export class NodeExtHostMpcService extends ExtHostMcpService { } } for (const [key, value] of Object.entries(launch.env)) { + // For PATH, we want to append to the existing PATH instead of overwriting it. + if (key.toUpperCase() === 'PATH' && value !== null) { + env[key] = env[key] ? `${env[key]}${path.delimiter}${String(value)}` : String(value); + continue; + } env[key] = value === null ? undefined : String(value); } diff --git a/src/vs/workbench/api/node/extensionHostProcess.ts b/src/vs/workbench/api/node/extensionHostProcess.ts index 50ef42e0a2f..06a165e7ba9 100644 --- a/src/vs/workbench/api/node/extensionHostProcess.ts +++ b/src/vs/workbench/api/node/extensionHostProcess.ts @@ -118,7 +118,7 @@ function patchProcess(allowExit: boolean) { process.env['ELECTRON_RUN_AS_NODE'] = '1'; // eslint-disable-next-line local/code-no-any-casts - process.on = function (event: string, listener: (...args: any[]) => void) { + process.on = function (event: string, listener: (...args: unknown[]) => void) { if (event === 'uncaughtException') { const actualListener = listener; listener = function (...args: unknown[]) { diff --git a/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts b/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts index 40614a76d68..b13989ab91f 100644 --- a/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts +++ b/src/vs/workbench/api/test/browser/extHostDocumentSaveParticipant.test.ts @@ -384,7 +384,7 @@ suite('ExtHostDocumentSaveParticipant', () => { test('Log failing listener', function () { let didLogSomething = false; const participant = new ExtHostDocumentSaveParticipant(new class extends NullLogService { - override error(message: string | Error, ...args: any[]): void { + override error(message: string | Error, ...args: unknown[]): void { didLogSomething = true; } }, documents, mainThreadBulkEdits); diff --git a/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts b/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts index 751a4ff73f9..2ca97fac8a8 100644 --- a/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts +++ b/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts @@ -204,7 +204,7 @@ suite('ExtHostTreeView', function () { }); }); - test('error is thrown if id is not unique', (done) => { + test('duplicate id across siblings is handled gracefully', (done) => { tree['a'] = { 'aa': {}, }; @@ -212,7 +212,6 @@ suite('ExtHostTreeView', function () { 'aa': {}, 'ba': {} }; - let caughtExpectedError = false; store.add(target.onRefresh.event(() => { testObject.$getChildren('testNodeWithIdTreeProvider') .then(elements => { @@ -220,14 +219,54 @@ suite('ExtHostTreeView', function () { assert.deepStrictEqual(actuals, ['1/a', '1/b']); return testObject.$getChildren('testNodeWithIdTreeProvider', ['1/a']) .then(() => testObject.$getChildren('testNodeWithIdTreeProvider', ['1/b'])) - .then(() => assert.fail('Should fail with duplicate id')) - .catch(() => caughtExpectedError = true) - .finally(() => caughtExpectedError ? done() : assert.fail('Expected duplicate id error not thrown.')); - }); + .then(elements => { + // Children of 'b' should include both 'aa' and 'ba' + const children = unBatchChildren(elements)?.map(e => e.handle); + assert.deepStrictEqual(children, ['1/aa', '1/ba']); + done(); + }); + }).catch(done); })); onDidChangeTreeNode.fire(undefined); }); + test('different element instances with same id are replaced gracefully', async () => { + // Simulates the race condition: two concurrent getChildren calls return + // different element objects that map to the same tree item ID. The second + // call should replace the first's registration without error. + let callCount = 0; + const element1 = { key: 'x' }; + const element2 = { key: 'x' }; + + const treeView = testObject.createTreeView('testRaceProvider', { + treeDataProvider: { + getChildren: (): { key: string }[] => { + callCount++; + // Return a different object instance each time + return callCount === 1 ? [element1] : [element2]; + }, + getTreeItem: (element: { key: string }): TreeItem => { + return { label: { label: element.key }, id: 'same-id', collapsibleState: TreeItemCollapsibleState.None }; + }, + onDidChangeTreeData: onDidChangeTreeNode.event, + } + }, extensionsDescription); + + store.add(treeView); + + // First fetch — registers element1 with id 'same-id' + const first = await testObject.$getChildren('testRaceProvider'); + const firstChildren = unBatchChildren(first); + assert.strictEqual(firstChildren?.length, 1); + assert.strictEqual(firstChildren![0].handle, '1/same-id'); + + // Second fetch — different element instance, same id. Should not throw. + const second = await testObject.$getChildren('testRaceProvider'); + const secondChildren = unBatchChildren(second); + assert.strictEqual(secondChildren?.length, 1); + assert.strictEqual(secondChildren![0].handle, '1/same-id'); + }); + test('refresh root', function (done) { store.add(target.onRefresh.event(actuals => { assert.strictEqual(undefined, actuals); diff --git a/src/vs/workbench/api/worker/extHostConsoleForwarder.ts b/src/vs/workbench/api/worker/extHostConsoleForwarder.ts index 01814a7d969..26eec2f9ad3 100644 --- a/src/vs/workbench/api/worker/extHostConsoleForwarder.ts +++ b/src/vs/workbench/api/worker/extHostConsoleForwarder.ts @@ -16,8 +16,7 @@ export class ExtHostConsoleForwarder extends AbstractExtHostConsoleForwarder { super(extHostRpc, initData); } - protected override _nativeConsoleLogMessage(_method: unknown, original: (...args: any[]) => void, args: IArguments) { - // eslint-disable-next-line local/code-no-any-casts - original.apply(console, args as any); + protected override _nativeConsoleLogMessage(_method: unknown, original: (...args: unknown[]) => void, args: unknown[]) { + original.apply(console, args); } } diff --git a/src/vs/workbench/browser/media/style.css b/src/vs/workbench/browser/media/style.css index 7537f9316e0..0d6a2da153b 100644 --- a/src/vs/workbench/browser/media/style.css +++ b/src/vs/workbench/browser/media/style.css @@ -53,6 +53,18 @@ body { z-index: 1; overflow: hidden; color: var(--vscode-foreground); + + /* Elevation shadows */ + --vscode-shadow-sm: 0 0 4px rgba(0, 0, 0, 0.08); + --vscode-shadow-md: 0 0 6px rgba(0, 0, 0, 0.08); + --vscode-shadow-lg: 0 0 12px rgba(0, 0, 0, 0.14); + --vscode-shadow-xl: 0 0 20px rgba(0, 0, 0, 0.15); + --vscode-shadow-hover: 0 0 8px rgba(0, 0, 0, 0.12); + --vscode-shadow-active-tab: 0 8px 12px rgba(0, 0, 0, 0.02); + + /* Panel depth shadows cast onto the editor surface */ + --vscode-shadow-depth-x: 5px 0 10px -4px rgba(0, 0, 0, 0.05); + --vscode-shadow-depth-y: 0 5px 10px -4px rgba(0, 0, 0, 0.04); } .monaco-workbench.web { diff --git a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css index d903883d10a..568a7212980 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css @@ -8,6 +8,15 @@ height: 100%; } +/* Activity Bar - shadow when sidebar is hidden or on the right */ +.monaco-workbench.nosidebar .part.activitybar { + box-shadow: var(--vscode-shadow-md); +} + +.monaco-workbench.activitybar-right .part.activitybar { + box-shadow: var(--vscode-shadow-md); +} + .monaco-workbench .activitybar.bordered::before { content: ''; float: left; diff --git a/src/vs/workbench/browser/parts/editor/breadcrumbsPicker.ts b/src/vs/workbench/browser/parts/editor/breadcrumbsPicker.ts index 198f156d196..db602f7d37a 100644 --- a/src/vs/workbench/browser/parts/editor/breadcrumbsPicker.ts +++ b/src/vs/workbench/browser/parts/editor/breadcrumbsPicker.ts @@ -17,7 +17,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { FileKind, FileSystemProviderCapabilities, IFileService, IFileStat } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { WorkbenchDataTree, WorkbenchAsyncDataTree } from '../../../../platform/list/browser/listService.js'; -import { breadcrumbsPickerBackground, widgetBorder, widgetShadow } from '../../../../platform/theme/common/colorRegistry.js'; +import { breadcrumbsPickerBackground, widgetBorder } from '../../../../platform/theme/common/colorRegistry.js'; import { isWorkspace, isWorkspaceFolder, IWorkspace, IWorkspaceContextService, IWorkspaceFolder } from '../../../../platform/workspace/common/workspace.js'; import { ResourceLabels, IResourceLabel, DEFAULT_LABELS_CONTAINER } from '../../labels.js'; import { BreadcrumbsConfig } from './breadcrumbs.js'; @@ -96,7 +96,7 @@ export abstract class BreadcrumbsPicker { this._treeContainer.style.background = color ? color.toString() : ''; this._treeContainer.style.paddingTop = '2px'; this._treeContainer.style.borderRadius = '3px'; - this._treeContainer.style.boxShadow = `0 0 8px 2px ${this._themeService.getColorTheme().getColor(widgetShadow)}`; + this._treeContainer.style.boxShadow = 'var(--vscode-shadow-lg)'; this._treeContainer.style.border = `1px solid ${this._themeService.getColorTheme().getColor(widgetBorder)}`; this._domNode.appendChild(this._treeContainer); diff --git a/src/vs/workbench/browser/parts/editor/editor.contribution.ts b/src/vs/workbench/browser/parts/editor/editor.contribution.ts index 14235489654..ffadc12ea96 100644 --- a/src/vs/workbench/browser/parts/editor/editor.contribution.ts +++ b/src/vs/workbench/browser/parts/editor/editor.contribution.ts @@ -429,7 +429,7 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_MAXIMIZE MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: TOGGLE_LOCK_GROUP_COMMAND_ID, title: localize('lockGroup', "Lock Group"), toggled: ActiveEditorGroupLockedContext }, group: '8_group_operations', order: 10, when: IsAuxiliaryWindowContext.toNegated() /* already a primary action for aux windows */ }); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: ConfigureEditorAction.ID, title: localize('configureEditors', "Configure Editors") }, group: '9_configure', order: 10 }); -function appendEditorToolItem(primary: ICommandAction, when: ContextKeyExpression | undefined, order: number, alternative?: ICommandAction, precondition?: ContextKeyExpression | undefined, enableInCompactMode?: boolean): void { +function appendEditorToolItem(primary: ICommandAction, when: ContextKeyExpression | undefined, order: number, alternative?: ICommandAction, precondition?: ContextKeyExpression | undefined, enableInCompactMode?: boolean, enableInModalMode?: boolean): void { const item: IMenuItem = { command: { id: primary.id, @@ -455,6 +455,9 @@ function appendEditorToolItem(primary: ICommandAction, when: ContextKeyExpressio if (enableInCompactMode) { MenuRegistry.appendMenuItem(MenuId.CompactWindowEditorTitle, item); } + if (enableInModalMode) { + MenuRegistry.appendMenuItem(MenuId.ModalEditorEditorTitle, item); + } } const SPLIT_ORDER = 100000; // towards the end @@ -601,6 +604,7 @@ appendEditorToolItem( 10, undefined, EditorContextKeys.hasChanges, + true, true ); @@ -616,6 +620,7 @@ appendEditorToolItem( 11, undefined, EditorContextKeys.hasChanges, + true, true ); diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index cedb8c95373..df841939212 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -1517,11 +1517,15 @@ function registerModalEditorCommands(): void { f1: true, icon: Codicon.close, precondition: EditorPartModalContext, - keybinding: { + keybinding: [{ primary: KeyCode.Escape, - weight: KeybindingWeight.WorkbenchContrib + 10, - when: EditorPartModalContext - }, + weight: KeybindingWeight.WorkbenchContrib + 10, // higher when no text editor is focused... + when: EditorContextKeys.focus.toNegated() + }, { + primary: KeyCode.Escape, + weight: KeybindingWeight.EditorContrib - 1, // ...lower to prevent accidental close when text editor is focused + when: EditorContextKeys.focus + }], menu: { id: MenuId.ModalEditorTitle, group: 'navigation', diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index ca2d962ea0d..31f83c70a11 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -34,7 +34,7 @@ import { IBoundarySashes } from '../../../../base/browser/ui/sash/sash.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { EditorPartMaximizedEditorGroupContext, EditorPartMultipleEditorGroupsContext } from '../../../common/contextkeys.js'; +import { EditorPartMaximizedEditorGroupContext, EditorPartMultipleEditorGroupsContext, EditorTabsVisibleContext } from '../../../common/contextkeys.js'; import { mainWindow } from '../../../../base/browser/window.js'; export interface IEditorPartUIState { @@ -168,7 +168,7 @@ export class EditorPart extends Part implements IEditorPart, readonly windowId: number, @IInstantiationService private readonly instantiationService: IInstantiationService, @IThemeService themeService: IThemeService, - @IConfigurationService private readonly configurationService: IConfigurationService, + @IConfigurationService protected readonly configurationService: IConfigurationService, @IStorageService storageService: IStorageService, @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IHostService private readonly hostService: IHostService, @@ -1039,6 +1039,7 @@ export class EditorPart extends Part implements IEditorPart, protected handleContextKeys(): void { const multipleEditorGroupsContext = EditorPartMultipleEditorGroupsContext.bindTo(this.scopedContextKeyService); const maximizedEditorGroupContext = EditorPartMaximizedEditorGroupContext.bindTo(this.scopedContextKeyService); + const editorTabsVisibleContext = EditorTabsVisibleContext.bindTo(this.scopedContextKeyService); const updateContextKeys = () => { const groupCount = this.count; @@ -1055,11 +1056,17 @@ export class EditorPart extends Part implements IEditorPart, } }; + const updateEditorTabsVisibleContext = () => { + editorTabsVisibleContext.set(this.partOptions.showTabs === 'multiple'); + }; + updateContextKeys(); + updateEditorTabsVisibleContext(); this._register(this.onDidAddGroup(() => updateContextKeys())); this._register(this.onDidRemoveGroup(() => updateContextKeys())); this._register(this.onDidChangeGroupMaximized(() => updateContextKeys())); + this._register(this.onDidChangeEditorPartOptions(() => updateEditorTabsVisibleContext())); } private setupDragAndDropSupport(parent: HTMLElement, container: HTMLElement): void { diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index eee5f12fe08..0e9f70f8c22 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -22,7 +22,7 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { IAuxiliaryWindowOpenOptions, IAuxiliaryWindowService } from '../../../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ContextKeyValue, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { getActiveElement, isAncestor, isHTMLElement } from '../../../../base/browser/dom.js'; +import { getActiveElement, IDimension, isAncestor, isHTMLElement } from '../../../../base/browser/dom.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { DeepPartial } from '../../../../base/common/types.js'; @@ -45,8 +45,15 @@ interface IEditorWorkingSetState extends IEditorWorkingSet { readonly auxiliary: IEditorPartsUIState; } +interface IModalEditorPartState { + readonly maximized: boolean; + readonly size?: { readonly width: number; readonly height: number }; + readonly position?: { readonly left: number; readonly top: number }; +} + interface IEditorPartsMemento { 'editorparts.state'?: IEditorPartsUIState; + 'editorparts.modalState'?: IModalEditorPartState; } export class EditorParts extends MultiWindowParts implements IEditorGroupsService, IEditorPartsView { @@ -75,6 +82,13 @@ export class EditorParts extends MultiWindowParts { @@ -169,21 +185,27 @@ export class EditorParts extends MultiWindowParts { + this.modalEditorMaximized = part.maximized; + this.modalEditorSize = part.size; + this.modalEditorPosition = part.position; + this.modalPartInstantiationService = undefined; this.modalEditorPart = undefined; })); - // Track maximized state in memory - disposables.add(part.onDidChangeMaximized(maximized => { - this.modalEditorMaximized = maximized; - })); - // Events this._onDidAddGroup.fire(part.activeGroup); @@ -328,8 +350,10 @@ export class EditorParts extends MultiWindowParts .content .editor-group-container .breadcrumbs-control.hidden { display: none; } diff --git a/src/vs/workbench/browser/parts/editor/media/editorgroupview.css b/src/vs/workbench/browser/parts/editor/media/editorgroupview.css index 8eff5df9389..9f63f8390de 100644 --- a/src/vs/workbench/browser/parts/editor/media/editorgroupview.css +++ b/src/vs/workbench/browser/parts/editor/media/editorgroupview.css @@ -5,6 +5,52 @@ /* Container */ +/* Editor depth shadows - the ::after pseudo-element draws inset shadows on each edge, + * creating the illusion that sidebar, panel, and auxiliarybar float above it. */ +.monaco-workbench.vs .part.editor { + position: relative; +} + +.monaco-workbench.vs .part.editor::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 10; + box-shadow: + inset var(--vscode-shadow-depth-x), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04), + inset 0 calc(-1 * 5px) 10px -4px rgba(0, 0, 0, 0.05); +} + +/* When sidebar is on the right, flip the stronger shadow to the right edge */ +.monaco-workbench.sidebar-right.vs .part.editor::after { + box-shadow: + inset 5px 0 10px -4px rgba(0, 0, 0, 0.04), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.05), + inset 0 calc(-1 * 5px) 10px -4px rgba(0, 0, 0, 0.05); +} + +/* Panel positions: strengthen the shadow on whichever edge faces the panel */ +.monaco-workbench.panel-position-left.vs .part.editor::after { + box-shadow: + inset var(--vscode-shadow-depth-x), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04); +} + +.monaco-workbench.panel-position-right.vs .part.editor::after { + box-shadow: + inset 5px 0 10px -4px rgba(0, 0, 0, 0.04), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.05); +} + +.monaco-workbench.panel-position-top.vs .part.editor::after { + box-shadow: + inset var(--vscode-shadow-depth-x), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04), + inset 0 var(--vscode-shadow-depth-y); +} + .monaco-workbench .part.editor > .content .editor-group-container { height: 100%; } diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index 6be74735630..b5b6e0efd74 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -12,17 +12,20 @@ left: 0; /* z-index for modal editors: above titlebar (2500) but below quick input (2550) and dialogs (2575) */ z-index: 2540; - display: flex; - justify-content: center; - align-items: center; /* Never allow content to escape above the title bar */ overflow: hidden; background: rgba(0, 0, 0, 0.3); + .modal-editor-resizable { + position: absolute; + } + .modal-editor-shadow { - box-shadow: 0 4px 32px var(--vscode-widget-shadow, rgba(0, 0, 0, 0.2)); + box-shadow: var(--vscode-shadow-xl); border-radius: 8px; overflow: hidden; + width: 100%; + height: 100%; } } @@ -30,8 +33,8 @@ .monaco-modal-editor-block .modal-editor-part { display: flex; flex-direction: column; - min-width: 400px; - min-height: 300px; + width: 100%; + height: 100%; background-color: var(--vscode-editor-background); border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); border-radius: 8px; @@ -138,5 +141,13 @@ color: inherit; } } + + .modal-editor-action-separator { + width: 1px; + height: 16px; + margin: 0 4px; + background-color: var(--vscode-titleBar-activeForeground); + opacity: 0.3; + } } } diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index 924d9b33607..4f9477d0865 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -144,6 +144,14 @@ box-shadow: none; } +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { + box-shadow: inset var(--vscode-shadow-active-tab); +} + +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover:not(.active) { + box-shadow: var(--vscode-shadow-sm); +} + .monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container.wrapping .tabs-container > .tab:last-child { margin-right: var(--last-tab-margin-right); /* when tabs wrap, we need a margin away from the absolute positioned editor actions */ } diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index f53f8530a87..db862b6096c 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -4,14 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import './media/modalEditorPart.css'; -import { $, addDisposableListener, append, EventHelper, EventType, hide, isHTMLElement, show } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, Dimension, EventHelper, EventType, hide, IDimension, isHTMLElement, setVisibility, show } from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { prepareActions } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; -import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ResizableHTMLElement } from '../../../../base/browser/ui/resizable/resizable.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar, WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -27,7 +28,6 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { EditorPartModalContext, EditorPartModalMaximizedContext, EditorPartModalNavigationContext } from '../../../common/contextkeys.js'; import { EditorResourceAccessor, SideBySideEditor, Verbosity } from '../../../common/editor.js'; import { ResourceLabel } from '../../labels.js'; -import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; import { mainWindow } from '../../../../base/browser/window.js'; @@ -36,13 +36,65 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { CLOSE_MODAL_EDITOR_COMMAND_ID, MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, MOVE_MODAL_EDITOR_TO_WINDOW_COMMAND_ID, NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID, NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID, TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID } from './editorCommands.js'; import { IModalEditorNavigation, IModalEditorPartOptions } from '../../../../platform/editor/common/editor.js'; +const MODAL_MIN_WIDTH = 400; +const MODAL_MIN_HEIGHT = 300; +const MODAL_MAX_DEFAULT_WIDTH = 1400; +const MODAL_MAX_DEFAULT_HEIGHT = 900; +const MODAL_BORDER_SIZE = 2; // 1px border on each side +const MODAL_HEADER_HEIGHT = 33; // 32px header + 1px border bottom +const MODAL_SNAP_THRESHOLD = 20; +const MODAL_MAXIMIZED_PADDING = 16; + const defaultModalEditorAllowableCommands = new Set([ + + // Application 'workbench.action.quit', 'workbench.action.reloadWindow', - 'workbench.action.closeActiveEditor', - 'workbench.action.closeAllEditors', + 'workbench.action.toggleFullScreen', + + // Quick access + 'workbench.action.gotoSymbol', + 'workbench.action.gotoLine', + + // Zoom + 'workbench.action.zoomIn', + 'workbench.action.zoomOut', + 'workbench.action.zoomReset', + + // File operations 'workbench.action.files.save', 'workbench.action.files.saveAll', + 'workbench.action.files.revert', + + // Close editors + 'workbench.action.closeActiveEditor', + 'workbench.action.closeAllEditors', + 'workbench.action.closeEditorsInGroup', + 'workbench.action.closeUnmodifiedEditors', + + // Settings + 'workbench.action.openSettings', + 'workbench.action.openSettings2', + 'workbench.action.openSettingsJson', + 'workbench.action.openGlobalSettings', + 'workbench.action.openApplicationSettingsJson', + 'workbench.action.openRawDefaultSettings', + 'workbench.action.openWorkspaceSettings', + 'workbench.action.openWorkspaceSettingsFile', + 'workbench.action.openFolderSettings', + 'workbench.action.openFolderSettingsFile', + 'workbench.action.openRemoteSettings', + 'workbench.action.openRemoteSettingsFile', + 'workbench.action.openAccessibilitySettings', + 'workbench.action.configureLanguageBasedSettings', + + // Keybindings + 'workbench.action.openGlobalKeybindings', + 'workbench.action.openDefaultKeybindingsFile', + 'workbench.action.openGlobalKeybindingsFile', + 'workbench.action.openKeyboardLayoutPicker', + + // Modal editor CLOSE_MODAL_EDITOR_COMMAND_ID, MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, MOVE_MODAL_EDITOR_TO_WINDOW_COMMAND_ID, @@ -51,6 +103,8 @@ const defaultModalEditorAllowableCommands = new Set([ NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID, ]); +const USE_MODAL_EDITOR_SETTING = 'workbench.editor.useModal'; + export interface ICreateModalEditorPartResult { readonly part: ModalEditorPartImpl; readonly instantiationService: IInstantiationService; @@ -66,7 +120,7 @@ export class ModalEditorPart { @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IHostService private readonly hostService: IHostService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } @@ -87,18 +141,18 @@ export class ModalEditorPart { } })); + let useModalMode = this.configurationService.getValue(USE_MODAL_EDITOR_SETTING); + disposables.add(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(USE_MODAL_EDITOR_SETTING)) { + useModalMode = this.configurationService.getValue(USE_MODAL_EDITOR_SETTING); + } + })); + disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => { const event = new StandardKeyboardEvent(e); - // Close on Escape - if (event.equals(KeyCode.Escape)) { - EventHelper.stop(event, true); - - editorPart.close(); - } - - // Prevent unsupported commands (not in sessions windows) - else if (!this.environmentService.isSessionsWindow) { + // Prevent unsupported commands unless all editors open in modal + if (useModalMode !== 'all') { const resolved = this.keybindingService.softDispatch(event, this.layoutService.mainContainer); if (resolved.kind === ResultKind.KbFound && resolved.commandId) { if ( @@ -111,7 +165,14 @@ export class ModalEditorPart { } })); - const shadowElement = modalElement.appendChild($('.modal-editor-shadow')); + // Resizable wrapper + const resizableElement = new ResizableHTMLElement(); + disposables.add(toDisposable(() => resizableElement.dispose())); + resizableElement.domNode.classList.add('modal-editor-resizable'); + resizableElement.minSize = new Dimension(MODAL_MIN_WIDTH, MODAL_MIN_HEIGHT); + modalElement.appendChild(resizableElement.domNode); + + const shadowElement = resizableElement.domNode.appendChild($('.modal-editor-shadow')); // Editor part container const titleId = 'modal-editor-title'; @@ -119,7 +180,6 @@ export class ModalEditorPart { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': titleId, - tabIndex: -1 }); shadowElement.appendChild(editorPartContainer); @@ -191,7 +251,31 @@ export class ModalEditorPart { [IEditorService, modalEditorService] ))); - // Create toolbar + // Create editor toolbar + const editorActionsToolbarContainer = append(actionBarContainer, $('div.modal-editor-editor-actions')); + const editorActionsToolbar = disposables.add(scopedInstantiationService.createInstance(WorkbenchToolBar, editorActionsToolbarContainer, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + highlightToggledItems: true, + })); + + const editorActionsSeparator = append(actionBarContainer, $('div.modal-editor-action-separator')); + const editorActionsDisposables = disposables.add(new DisposableStore()); + const updateEditorActions = () => { + editorActionsDisposables.clear(); + + const editorActions = editorPart.activeGroup.createEditorActions(editorActionsDisposables, MenuId.ModalEditorEditorTitle); + editorActionsDisposables.add(editorActions.onDidChange(() => updateEditorActions())); + + const { primary, secondary } = editorActions.actions; + editorActionsToolbar.setActions(prepareActions(primary), prepareActions(secondary)); + + const hasActions = primary.length > 0 || secondary.length > 0; + setVisibility(hasActions, editorActionsSeparator); + }; + disposables.add(Event.runAndSubscribe(modalEditorService.onDidActiveEditorChange, () => updateEditorActions())); + disposables.add(modalEditorService.onDidEditorsChange(() => editorPart.enforceModalPartOptions())); + + // Create global toolbar disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, MenuId.ModalEditorTitle, { hiddenItemStrategy: HiddenItemStrategy.NoHide, highlightToggledItems: true, @@ -219,59 +303,237 @@ export class ModalEditorPart { } else { label.element.clear(); } - - editorPart.notifyActiveEditorChanged(); })); // Handle double-click on header to toggle maximize disposables.add(addDisposableListener(headerElement, EventType.DBLCLICK, e => { EventHelper.stop(e); - editorPart.toggleMaximized(); + editorPart.handleHeaderDoubleClick(); })); + // Handle drag on header to move the modal + const dragDisposables = disposables.add(new DisposableStore()); + let didDrag = false; + disposables.add(addDisposableListener(headerElement, EventType.MOUSE_DOWN, e => { + if (editorPart.maximized) { + return; // no drag when maximized + } - // Layout the modal editor part - const layoutModal = () => { + if (e.button !== 0) { + return; // only left button + } + + // Ignore if target is a button or action + const target = e.target as HTMLElement; + if (target.closest('.monaco-button') || target.closest('.action-item')) { + return; + } + + // Prevent text selection during drag + e.preventDefault(); + + dragDisposables.clear(); + + const startX = e.clientX; + const startY = e.clientY; + const startLeft = parseFloat(resizableElement.domNode.style.left) || 0; + const startTop = parseFloat(resizableElement.domNode.style.top) || 0; + didDrag = false; + + const onMouseMove = (moveEvent: MouseEvent) => { + didDrag = true; + EventHelper.stop(moveEvent, true); + + const containerDimension = this.layoutService.mainContainerDimension; + const titleBarOffset = this.layoutService.mainContainerOffset.top; + const dialogWidth = resizableElement.size.width; + const dialogHeight = resizableElement.size.height; + + // Clamp to window bounds + const minLeft = 0; + const minTop = titleBarOffset; + const maxLeft = Math.max(minLeft, containerDimension.width - dialogWidth); + const maxTop = Math.max(minTop, containerDimension.height - dialogHeight); + + let newLeft = Math.max(minLeft, Math.min(maxLeft, startLeft + (moveEvent.clientX - startX))); + let newTop = Math.max(minTop, Math.min(maxTop, startTop + (moveEvent.clientY - startY))); + + // Snap to center position when close + const centerLeft = (containerDimension.width - dialogWidth) / 2; + const centerTop = Math.max(titleBarOffset, (containerDimension.height - dialogHeight) / 2); + + if (Math.abs(newLeft - centerLeft) < MODAL_SNAP_THRESHOLD && Math.abs(newTop - centerTop) < MODAL_SNAP_THRESHOLD) { + newLeft = centerLeft; + newTop = centerTop; + } + + resizableElement.domNode.style.left = `${newLeft}px`; + resizableElement.domNode.style.top = `${newTop}px`; + }; + + const onMouseUp = (upEvent: MouseEvent) => { + EventHelper.stop(upEvent, true); + dragDisposables.clear(); + + if (didDrag) { + const currentLeft = parseFloat(resizableElement.domNode.style.left) || 0; + const currentTop = parseFloat(resizableElement.domNode.style.top) || 0; + + // Check if snapped to center — if so, clear custom position + const containerDimension = this.layoutService.mainContainerDimension; + const titleBarOffset = this.layoutService.mainContainerOffset.top; + const centerLeft = (containerDimension.width - resizableElement.size.width) / 2; + const centerTop = Math.max(titleBarOffset, (containerDimension.height - resizableElement.size.height) / 2); + + if (Math.abs(currentLeft - centerLeft) < 1 && Math.abs(currentTop - centerTop) < 1) { + editorPart.position = undefined; + } else { + editorPart.position = { left: currentLeft, top: currentTop }; + } + } + }; + + dragDisposables.add(addDisposableListener(mainWindow, EventType.MOUSE_MOVE, onMouseMove, true)); + dragDisposables.add(addDisposableListener(mainWindow, EventType.MOUSE_UP, onMouseUp, true)); + })); + + // Focus active editor when clicking into the title area with no other click target + disposables.add(addDisposableListener(headerElement, EventType.CLICK, e => { + const wasDrag = didDrag; + didDrag = false; + if (wasDrag) { + return; // skip focus after drag + } + + EventHelper.stop(e); + + editorPart.activeGroup.focus(); + })); + + // Handle resize from sashes + let isResizing = false; + let resizeStartLeft = 0; + let resizeStartTop = 0; + let resizeStartSize = Dimension.None; + + disposables.add(resizableElement.onDidWillResize(() => { + isResizing = true; + resizeStartLeft = parseFloat(resizableElement.domNode.style.left) || 0; + resizeStartTop = parseFloat(resizableElement.domNode.style.top) || 0; + resizeStartSize = new Dimension(resizableElement.size.width, resizableElement.size.height); + })); + + disposables.add(resizableElement.onDidResize(e => { + const deltaWidth = e.dimension.width - resizeStartSize.width; + const deltaHeight = e.dimension.height - resizeStartSize.height; + + // Adjust position to keep the opposite edge fixed + if (e.west) { + resizableElement.domNode.style.left = `${resizeStartLeft - deltaWidth}px`; + } + if (e.north) { + resizableElement.domNode.style.top = `${resizeStartTop - deltaHeight}px`; + } + + // Update editor part layout during resize + editorPart.layout(e.dimension.width - MODAL_BORDER_SIZE, e.dimension.height - MODAL_BORDER_SIZE - MODAL_HEADER_HEIGHT, 0, 0); + + if (e.done) { + isResizing = false; + + // Check if size matches the default (from sash double-click reset) + const defaultSize = getDefaultSize(); + if (e.dimension.width === defaultSize.width && e.dimension.height === defaultSize.height) { + editorPart.size = undefined; + editorPart.position = undefined; + layoutModal(); + } else { + editorPart.size = new Dimension(e.dimension.width, e.dimension.height); + editorPart.position = { + left: parseFloat(resizableElement.domNode.style.left) || 0, + top: parseFloat(resizableElement.domNode.style.top) || 0, + }; + } + } + })); + + // Compute default (non-custom, non-maximized) modal size + const getDefaultSize = (): Dimension => { const containerDimension = this.layoutService.mainContainerDimension; const titleBarOffset = this.layoutService.mainContainerOffset.top; const availableHeight = Math.max(containerDimension.height - titleBarOffset, 0); + const targetWidth = containerDimension.width * 0.8; + const targetHeight = availableHeight * 0.8; + const width = Math.min(targetWidth, MODAL_MAX_DEFAULT_WIDTH, containerDimension.width); + const height = Math.min(targetHeight, MODAL_MAX_DEFAULT_HEIGHT, availableHeight); + + return new Dimension(width, height); + }; + + // Layout the modal editor part + const layoutModal = () => { + if (isResizing) { + return; // skip layout during interactive resize + } + + const containerDimension = this.layoutService.mainContainerDimension; + const titleBarOffset = this.layoutService.mainContainerOffset.top; + const availableHeight = Math.max(containerDimension.height - titleBarOffset, 0); + + const defaultSize = getDefaultSize(); let width: number; let height: number; if (editorPart.maximized) { - const horizontalPadding = 16; - const verticalPadding = Math.max(titleBarOffset /* keep away from title bar to prevent clipping issues with WCO */, 16); - width = Math.max(containerDimension.width - horizontalPadding, 0); + const verticalPadding = Math.max(titleBarOffset /* keep away from title bar to prevent clipping issues with WCO */, MODAL_MAXIMIZED_PADDING); + width = Math.max(containerDimension.width - MODAL_MAXIMIZED_PADDING, 0); height = Math.max(availableHeight - verticalPadding, 0); + } else if (editorPart.size) { + width = Math.min(editorPart.size.width, containerDimension.width); + height = Math.min(editorPart.size.height, availableHeight); } else { - const maxWidth = 1400; - const maxHeight = 900; - const targetWidth = containerDimension.width * 0.8; - const targetHeight = availableHeight * 0.8; - width = Math.min(targetWidth, maxWidth, containerDimension.width); - height = Math.min(targetHeight, maxHeight, availableHeight); + width = defaultSize.width; + height = defaultSize.height; } height = Math.min(height, availableHeight); // Ensure the modal never exceeds available height (below the title bar) - editorPartContainer.style.width = `${width}px`; - editorPartContainer.style.height = `${height}px`; + // Update resizable element size and constraints + resizableElement.maxSize = new Dimension(containerDimension.width, availableHeight); + resizableElement.preferredSize = defaultSize; + resizableElement.layout(height, width); - const borderSize = 2; // Account for 1px border on all sides and modal header height - const headerHeight = 32 + 1 /* border bottom */; - editorPart.layout(width - borderSize, height - borderSize - headerHeight, 0, 0); + // Enable/disable sashes based on maximized state + const canResize = !editorPart.maximized; + resizableElement.enableSashes(canResize, canResize, canResize, canResize); + + // Position: use custom position if available (clamped to bounds), otherwise center + if (!editorPart.maximized && editorPart.position) { + const clampedLeft = Math.max(0, Math.min(editorPart.position.left, containerDimension.width - width)); + const clampedTop = Math.max(titleBarOffset, Math.min(editorPart.position.top, titleBarOffset + availableHeight - height)); + resizableElement.domNode.style.left = `${clampedLeft}px`; + resizableElement.domNode.style.top = `${clampedTop}px`; + } else { + const left = (containerDimension.width - width) / 2; + const top = Math.max(titleBarOffset, (containerDimension.height - height) / 2); // center in full window, but clamp to stay below the title bar + resizableElement.domNode.style.left = `${left}px`; + resizableElement.domNode.style.top = `${top}px`; + } + + editorPart.layout(width - MODAL_BORDER_SIZE, height - MODAL_BORDER_SIZE - MODAL_HEADER_HEIGHT, 0, 0); }; disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, layoutModal)); disposables.add(editorPart.onDidChangeMaximized(() => layoutModal())); + disposables.add(editorPart.onDidRequestLayout(() => layoutModal())); // Dim window controls to match the modal overlay this.hostService.setWindowDimmed(mainWindow, true); disposables.add(toDisposable(() => this.hostService.setWindowDimmed(mainWindow, false))); - // Focus the modal - editorPartContainer.focus(); + // Focus + editorPart.activeGroup.focus(); return { part: editorPart, @@ -281,6 +543,11 @@ export class ModalEditorPart { } } +interface IPosition { + left: number; + top: number; +} + class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { private static COUNTER = 1; @@ -291,12 +558,26 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { private readonly _onDidChangeMaximized = this._register(new Emitter()); readonly onDidChangeMaximized = this._onDidChangeMaximized.event; + private readonly _onDidRequestLayout = this._register(new Emitter()); + readonly onDidRequestLayout = this._onDidRequestLayout.event; + private readonly _onDidChangeNavigation = this._register(new Emitter()); readonly onDidChangeNavigation = this._onDidChangeNavigation.event; private _maximized: boolean; get maximized(): boolean { return this._maximized; } + private _size: IDimension | undefined; + get size(): IDimension | undefined { return this._size; } + set size(value: IDimension | undefined) { this._size = value; } + + private _position: IPosition | undefined; + get position(): IPosition | undefined { return this._position; } + set position(value: IPosition | undefined) { this._position = value; } + + private savedSize: IDimension | undefined; + private savedPosition: IPosition | undefined; + private _navigation: IModalEditorNavigation | undefined; get navigation(): IModalEditorNavigation | undefined { return this._navigation; } @@ -321,9 +602,24 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { super(editorPartsView, `workbench.parts.modalEditor.${id}`, localize('modalEditorPart', "Modal Editor Area"), windowId, instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService); this._maximized = options?.maximized ?? false; + this._size = options?.size; + this._position = options?.position; this._navigation = options?.navigation; + // When restoring a maximized state with custom layout, + // initialize saved state so un-maximize can restore it + if (this._maximized) { + this.savedSize = this._size; + this.savedPosition = this._position; + } + this.enforceModalPartOptions(); + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(USE_MODAL_EDITOR_SETTING)) { + this.enforceModalPartOptions(); + } + })); } override create(parent: HTMLElement, options?: object): void { @@ -332,24 +628,23 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { super.create(parent, options); } - private enforceModalPartOptions(): void { + enforceModalPartOptions(): void { + const useModalForAll = this.configurationService.getValue(USE_MODAL_EDITOR_SETTING) === 'all'; const editorCount = this.groups.reduce((count, group) => count + group.count, 0); + const showTabs = useModalForAll && editorCount > 1 ? 'multiple' : 'none'; + this.optionsDisposable.value = this.enforcePartOptions({ - showTabs: editorCount > 1 ? 'multiple' : 'none', + showTabs, enablePreview: true, closeEmptyGroups: true, - tabActionCloseVisibility: editorCount > 1, - editorActionsLocation: 'default', + tabActionCloseVisibility: showTabs !== 'none', + editorActionsLocation: 'hidden', tabHeight: 'default', wrapTabs: false, allowDropIntoGroup: false }); } - notifyActiveEditorChanged(): void { - this.enforceModalPartOptions(); - } - updateOptions(options?: IModalEditorPartOptions): void { if (typeof options?.maximized === 'boolean' && options.maximized !== this._maximized) { this.toggleMaximized(); @@ -363,9 +658,34 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { toggleMaximized(): void { this._maximized = !this._maximized; + if (this._maximized) { + this.savedSize = this._size; + this.savedPosition = this._position; + } else { + this._size = this.savedSize; + this._position = this.savedPosition; + this.savedSize = undefined; + this.savedPosition = undefined; + } + this._onDidChangeMaximized.fire(this._maximized); } + handleHeaderDoubleClick(): void { + if (this._maximized) { + // Clear saved state so that toggleMaximized restores to default + this.savedSize = undefined; + this.savedPosition = undefined; + this.toggleMaximized(); + } else if (this._size) { + this._size = undefined; + this._position = undefined; + this._onDidRequestLayout.fire(); + } else { + this.toggleMaximized(); // maximize + } + } + protected override handleContextKeys(): void { const isModalEditorPartContext = EditorPartModalContext.bindTo(this.scopedContextKeyService); isModalEditorPartContext.set(true); diff --git a/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css b/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css index 9f11489cc35..ee7e8a6a64a 100644 --- a/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css +++ b/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css @@ -12,6 +12,7 @@ overflow: hidden; border: 1px solid var(--vscode-editorWidget-border); border-radius: var(--vscode-cornerRadius-small); + box-shadow: var(--vscode-shadow-lg); } .monaco-workbench.nostatusbar > .notifications-center { diff --git a/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css b/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css index af90372abdc..73764a1003e 100644 --- a/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css +++ b/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css @@ -9,8 +9,6 @@ right: 3px; bottom: 25px; /* 22px status bar height + 3px */ display: none; - overflow: hidden; - box-shadow: 0 0 12px var(--vscode-widget-shadow); border-radius: var(--vscode-cornerRadius-small); } @@ -37,10 +35,6 @@ flex-direction: column; } -.monaco-workbench > .notifications-toasts .notification-toast-container { - overflow: hidden; /* this ensures that the notification toast does not shine through */ -} - .monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast { margin: 4px; /* enables separation and drop shadows around toasts */ transform: translate3d(0px, 100%, 0px); /* move the notification 50px to the bottom (to prevent bleed through) */ @@ -48,6 +42,10 @@ transition: transform 300ms ease-out, opacity 300ms ease-out; } +.monaco-workbench > .notifications-toasts .notifications-list-container { + box-shadow: var(--vscode-shadow-lg); +} + .monaco-workbench > .notifications-toasts .notifications-list-container, .monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast, .monaco-workbench > .notifications-toasts .notification-toast-container > .notification-toast .monaco-scrollable-element, diff --git a/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts b/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts index 76d5680b81f..82f6975b6d8 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsCenter.ts @@ -15,7 +15,6 @@ import { INotificationsCenterController, NotificationActionRunner } from './noti import { NotificationsList } from './notificationsList.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { $, Dimension, isAncestorOfActiveElement } from '../../../../base/browser/dom.js'; -import { widgetShadow } from '../../../../platform/theme/common/colorRegistry.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { localize } from '../../../../nls.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; @@ -390,8 +389,6 @@ export class NotificationsCenter extends Themable implements INotificationsCente override updateStyles(): void { if (this.notificationsCenterContainer && this.notificationsCenterHeader) { - const widgetShadowColor = this.getColor(widgetShadow); - this.notificationsCenterContainer.style.boxShadow = widgetShadowColor ? `0 0 8px 2px ${widgetShadowColor}` : ''; const borderColor = this.getColor(NOTIFICATIONS_CENTER_BORDER); this.notificationsCenterContainer.style.border = borderColor ? `1px solid ${borderColor}` : ''; diff --git a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts index 4aa6355a41b..bd77caab75a 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts @@ -14,7 +14,6 @@ import { Event, Emitter } from '../../../../base/common/event.js'; import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; import { NOTIFICATIONS_TOAST_BORDER, NOTIFICATIONS_BACKGROUND } from '../../../common/theme.js'; import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js'; -import { widgetShadow } from '../../../../platform/theme/common/colorRegistry.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { INotificationsToastController } from './notificationsCommands.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -553,9 +552,6 @@ export class NotificationsToasts extends Themable implements INotificationsToast const backgroundColor = this.getColor(NOTIFICATIONS_BACKGROUND); toast.style.background = backgroundColor ? backgroundColor : ''; - const widgetShadowColor = this.getColor(widgetShadow); - toast.style.boxShadow = widgetShadowColor ? `0 0 8px 2px ${widgetShadowColor}` : ''; - const borderColor = this.getColor(NOTIFICATIONS_TOAST_BORDER); toast.style.border = borderColor ? `1px solid ${borderColor}` : ''; }); diff --git a/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css b/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css index 7faaf9e7f4b..1f3b102ebb1 100644 --- a/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css +++ b/src/vs/workbench/browser/parts/statusbar/media/statusbarpart.css @@ -52,8 +52,7 @@ } .monaco-workbench .part.statusbar > .left-items { - flex-grow: 1; /* left items push right items to the far right end */ -} + flex-grow: 1; /* left items push right items to the far right end */} .monaco-workbench .part.statusbar > .items-container > .statusbar-item { display: inline-block; @@ -85,9 +84,13 @@ border-right: 5px solid transparent; } -.monaco-workbench .part.statusbar > .items-container > .statusbar-item.left.first-visible-item, -.monaco-workbench .part.statusbar > .items-container > .statusbar-item.right.last-visible-item { +.monaco-workbench .part.statusbar > .items-container > .statusbar-item.left.first-visible-item { padding-right: 0; + padding-left: 2px; +} + +.monaco-workbench .part.statusbar > .items-container > .statusbar-item.right.last-visible-item { + padding-right: 2px; padding-left: 0; } diff --git a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css index cf0977721d5..a2c1090f9d1 100644 --- a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css +++ b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css @@ -7,6 +7,7 @@ .monaco-workbench .part.titlebar { display: flex; flex-direction: row; + box-shadow: var(--vscode-shadow-md); } .monaco-workbench.mac .part.titlebar { @@ -176,6 +177,7 @@ height: 22px; width: 38vw; max-width: 600px; + box-shadow: inset var(--vscode-shadow-sm); } .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center .action-item.command-center-quick-pick { diff --git a/src/vs/workbench/common/theme.ts b/src/vs/workbench/common/theme.ts index 143a09c1bf1..7c7df50fd38 100644 --- a/src/vs/workbench/common/theme.ts +++ b/src/vs/workbench/common/theme.ts @@ -549,6 +549,11 @@ export const PANEL_STICKY_SCROLL_BORDER = registerColor('panelStickyScroll.borde export const PANEL_STICKY_SCROLL_SHADOW = registerColor('panelStickyScroll.shadow', scrollbarShadow, localize('panelStickyScrollShadow', "Shadow color of sticky scroll in the panel.")); +// < --- Browser --- > + +export const BROWSER_BORDER = registerColor('browser.border', TAB_BORDER, localize('browserBorder', "Border color for integrated browser pages.")); + + // < --- Profiles --- > export const PROFILE_BADGE_BACKGROUND = registerColor('profileBadge.background', { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 1a551c6c31c..a3af6057701 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -49,6 +49,7 @@ import { logBrowserOpen } from '../../../../platform/browserView/common/browserV import { URI } from '../../../../base/common/uri.js'; import { ChatConfiguration } from '../../chat/common/constants.js'; import { Event } from '../../../../base/common/event.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); @@ -273,7 +274,8 @@ export class BrowserEditor extends EditorPane { @IEditorService private readonly editorService: IEditorService, @IBrowserElementsService private readonly browserElementsService: IBrowserElementsService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @IConfigurationService private readonly configurationService: IConfigurationService + @IConfigurationService private readonly configurationService: IConfigurationService, + @ILayoutService private readonly layoutService: ILayoutService ) { super(BrowserEditor.ID, group, telemetryService, themeService, storageService); } @@ -336,9 +338,13 @@ export class BrowserEditor extends EditorPane { this._browserContainer.tabIndex = 0; // make focusable this._browserContainerWrapper.appendChild(this._browserContainer); + // Create additional wrapper around placeholder contents for applying border radius clipping. + const placeholderContents = $('.browser-placeholder-contents'); + this._browserContainer.appendChild(placeholderContents); + // Create placeholder screenshot (background placeholder when WebContentsView is hidden) this._placeholderScreenshot = $('.browser-placeholder-screenshot'); - this._browserContainer.appendChild(this._placeholderScreenshot); + placeholderContents.appendChild(this._placeholderScreenshot); // Create overlay pause container (hidden by default via CSS) this._overlayPauseContainer = $('.browser-overlay-paused'); @@ -348,16 +354,16 @@ export class BrowserEditor extends EditorPane { overlayPauseMessage.appendChild(this._overlayPauseHeading); overlayPauseMessage.appendChild(this._overlayPauseDetail); this._overlayPauseContainer.appendChild(overlayPauseMessage); - this._browserContainer.appendChild(this._overlayPauseContainer); + placeholderContents.appendChild(this._overlayPauseContainer); // Create error container (hidden by default) this._errorContainer = $('.browser-error-container'); this._errorContainer.style.display = 'none'; - this._browserContainer.appendChild(this._errorContainer); + placeholderContents.appendChild(this._errorContainer); // Create welcome container (shown when no URL is loaded) this._welcomeContainer = this.createWelcomeContainer(); - this._browserContainer.appendChild(this._welcomeContainer); + placeholderContents.appendChild(this._welcomeContainer); this._register(addDisposableListener(this._browserContainer, EventType.FOCUS, (event) => { // When the browser container gets focus, make sure the browser view also gets focused. @@ -374,12 +380,6 @@ export class BrowserEditor extends EditorPane { hasFocus: this._model?.focused ?? false, window: this._model?.focused ? this.window : undefined }))); - - // Automatically call layoutBrowserContainer() when the browser container changes size. - // Be careful to use `ResizeObserver` from the target window to avoid cross-window issues. - const resizeObserver = new this.window.ResizeObserver(() => this.layoutBrowserContainer()); - resizeObserver.observe(this._browserContainer); - this._register(toDisposable(() => resizeObserver.disconnect())); } override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { @@ -399,7 +399,7 @@ export class BrowserEditor extends EditorPane { this._storageScopeContext.set(this._model.storageScope); this._devToolsOpenContext.set(this._model.isDevToolsOpen); - this._updateSharingState(); + this._updateSharingState(true); // Update find widget with new model this._findWidget.rawValue?.setModel(this._model); @@ -411,10 +411,10 @@ export class BrowserEditor extends EditorPane { // Listen for sharing state changes on the model this._inputDisposables.add(this._model.onDidChangeSharedWithAgent(() => { - this._updateSharingState(); + this._updateSharingState(false); })); this._inputDisposables.add(watchForAgentSharingContextChanges(this.contextKeyService)(() => { - this._updateSharingState(); + this._updateSharingState(false); })); // Initialize UI state and context keys from model @@ -510,12 +510,11 @@ export class BrowserEditor extends EditorPane { if (targetWindowId === this.window.vscodeWindowId) { // Update CSS variable for size calculations this._browserContainerWrapper.style.setProperty('--zoom-factor', String(getZoomFactor(this.window))); - this.layoutBrowserContainer(); } })); this.updateErrorDisplay(); - this.layoutBrowserContainer(); + this.layout(); this.updateVisibility(); this.doScreenshot(); @@ -661,11 +660,12 @@ export class BrowserEditor extends EditorPane { return this._model?.url; } - private _updateSharingState(): void { + private _updateSharingState(isInitialState: boolean): void { const sharingEnabled = this.contextKeyService.contextMatchesRules(canShareBrowserWithAgentContext); const isShared = sharingEnabled && !!this._model && this._model.sharedWithAgent; - this._browserContainerWrapper.classList.toggle('shared', isShared); + this._browserContainer.classList.toggle('animate', !isInitialState); + this._browserContainer.classList.toggle('shared', isShared); this._navigationBar.setShared(isShared); } @@ -1163,32 +1163,42 @@ export class BrowserEditor extends EditorPane { } } - override layout(dimension: Dimension, _position?: IDomPosition): void { + override layout(dimension?: Dimension, _position?: IDomPosition): void { // Layout find widget if it exists - this._findWidget.rawValue?.layout(dimension.width); + if (dimension && this._findWidget.rawValue) { + this._findWidget.rawValue.layout(dimension.width); + } + + const whenContainerStylesLoaded = this.layoutService.whenContainerStylesLoaded(this.window); + if (whenContainerStylesLoaded) { + // In floating windows, we need to ensure that the + // container is ready for us to compute certain + // layout related properties. + whenContainerStylesLoaded.then(() => this.layoutBrowserContainer()); + } else { + this.layoutBrowserContainer(); + } } /** - * This should be called whenever .browser-container changes in size, or when - * there could be any elements, such as the command palette, overlapping with it. - * - * Note that we don't call layoutBrowserContainer() from layout() but instead rely on using a ResizeObserver and on - * making direct calls to it. This is because we have seen cases where the getBoundingClientRect() values of - * the .browser-container element are not correct during layout() calls, especially during "Move into New Window" - * and "Copy into New Window" operations into a different monitor. + * Recompute the layout of the browser container and update the model with the new bounds. + * This should generally only be called via layout() to ensure that the container is ready and all necessary styles are loaded. */ - layoutBrowserContainer(): void { + private layoutBrowserContainer(): void { if (this._model) { this.checkOverlays(); const containerRect = this._browserContainer.getBoundingClientRect(); + const cornerRadius = this.window.getComputedStyle(this._browserContainer).borderTopLeftRadius ?? '0'; + void this._model.layout({ windowId: this.group.windowId, x: containerRect.left, y: containerRect.top, width: containerRect.width, height: containerRect.height, - zoomFactor: getZoomFactor(this.window) + zoomFactor: getZoomFactor(this.window), + cornerRadius: parseFloat(cornerRadius) }); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts index aec2add429b..a1ad809a6f3 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts @@ -11,6 +11,7 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { IBrowserViewModel } from '../common/browserView.js'; +import { BrowserViewCommandId } from '../../../../platform/browserView/common/browserView.js'; import { localize } from '../../../../nls.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; @@ -45,9 +46,9 @@ export class BrowserFindWidget extends SimpleFindWidget { showResultCount: true, enableSash: true, initialWidth: 350, - previousMatchActionId: 'workbench.action.browser.findPrevious', - nextMatchActionId: 'workbench.action.browser.findNext', - closeWidgetActionId: 'workbench.action.browser.hideFind' + previousMatchActionId: BrowserViewCommandId.FindPrevious, + nextMatchActionId: BrowserViewCommandId.FindNext, + closeWidgetActionId: BrowserViewCommandId.HideFind }, contextViewService, contextKeyService, hoverService, keybindingService, configurationService, accessibilityService); this._findWidgetVisible = CONTEXT_BROWSER_FIND_WIDGET_VISIBLE.bindTo(contextKeyService); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index b32e1c70ea2..5fd326273c4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -14,7 +14,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_DEVTOOLS_OPEN, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_STORAGE_SCOPE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserEditor.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; import { IBrowserViewWorkbenchService } from '../common/browserView.js'; -import { BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; +import { BrowserViewCommandId, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; @@ -37,7 +37,7 @@ interface IOpenBrowserOptions { class OpenIntegratedBrowserAction extends Action2 { constructor() { super({ - id: 'workbench.action.browser.open', + id: BrowserViewCommandId.Open, title: localize2('browser.openAction', "Open Integrated Browser"), category: BrowserCategory, f1: true @@ -67,7 +67,7 @@ class OpenIntegratedBrowserAction extends Action2 { class NewTabAction extends Action2 { constructor() { super({ - id: 'workbench.action.browser.newTab', + id: BrowserViewCommandId.NewTab, title: localize2('browser.newTabAction', "New Tab"), category: BrowserCategory, f1: true, @@ -97,7 +97,7 @@ class NewTabAction extends Action2 { } class GoBackAction extends Action2 { - static readonly ID = 'workbench.action.browser.goBack'; + static readonly ID = BrowserViewCommandId.GoBack; constructor() { super({ @@ -129,7 +129,7 @@ class GoBackAction extends Action2 { } class GoForwardAction extends Action2 { - static readonly ID = 'workbench.action.browser.goForward'; + static readonly ID = BrowserViewCommandId.GoForward; constructor() { super({ @@ -161,7 +161,7 @@ class GoForwardAction extends Action2 { } class ReloadAction extends Action2 { - static readonly ID = 'workbench.action.browser.reload'; + static readonly ID = BrowserViewCommandId.Reload; constructor() { super({ @@ -199,7 +199,7 @@ class ReloadAction extends Action2 { } class HardReloadAction extends Action2 { - static readonly ID = 'workbench.action.browser.hardReload'; + static readonly ID = BrowserViewCommandId.HardReload; constructor() { super({ @@ -227,7 +227,7 @@ class HardReloadAction extends Action2 { } class FocusUrlInputAction extends Action2 { - static readonly ID = 'workbench.action.browser.focusUrlInput'; + static readonly ID = BrowserViewCommandId.FocusUrlInput; constructor() { super({ @@ -251,7 +251,7 @@ class FocusUrlInputAction extends Action2 { } class AddElementToChatAction extends Action2 { - static readonly ID = 'workbench.action.browser.addElementToChat'; + static readonly ID = BrowserViewCommandId.AddElementToChat; constructor() { const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); @@ -288,7 +288,7 @@ class AddElementToChatAction extends Action2 { } class AddConsoleLogsToChatAction extends Action2 { - static readonly ID = 'workbench.action.browser.addConsoleLogsToChat'; + static readonly ID = BrowserViewCommandId.AddConsoleLogsToChat; constructor() { const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); @@ -316,7 +316,7 @@ class AddConsoleLogsToChatAction extends Action2 { } class ToggleDevToolsAction extends Action2 { - static readonly ID = 'workbench.action.browser.toggleDevTools'; + static readonly ID = BrowserViewCommandId.ToggleDevTools; constructor() { super({ @@ -347,7 +347,7 @@ class ToggleDevToolsAction extends Action2 { } class OpenInExternalBrowserAction extends Action2 { - static readonly ID = 'workbench.action.browser.openExternal'; + static readonly ID = BrowserViewCommandId.OpenExternal; constructor() { super({ @@ -383,7 +383,7 @@ class OpenInExternalBrowserAction extends Action2 { } class ClearGlobalBrowserStorageAction extends Action2 { - static readonly ID = 'workbench.action.browser.clearGlobalStorage'; + static readonly ID = BrowserViewCommandId.ClearGlobalStorage; constructor() { super({ @@ -408,7 +408,7 @@ class ClearGlobalBrowserStorageAction extends Action2 { } class ClearWorkspaceBrowserStorageAction extends Action2 { - static readonly ID = 'workbench.action.browser.clearWorkspaceStorage'; + static readonly ID = BrowserViewCommandId.ClearWorkspaceStorage; constructor() { super({ @@ -433,7 +433,7 @@ class ClearWorkspaceBrowserStorageAction extends Action2 { } class ClearEphemeralBrowserStorageAction extends Action2 { - static readonly ID = 'workbench.action.browser.clearEphemeralStorage'; + static readonly ID = BrowserViewCommandId.ClearEphemeralStorage; constructor() { super({ @@ -460,7 +460,7 @@ class ClearEphemeralBrowserStorageAction extends Action2 { } class OpenBrowserSettingsAction extends Action2 { - static readonly ID = 'workbench.action.browser.openSettings'; + static readonly ID = BrowserViewCommandId.OpenSettings; constructor() { super({ @@ -486,7 +486,7 @@ class OpenBrowserSettingsAction extends Action2 { // Find actions class ShowBrowserFindAction extends Action2 { - static readonly ID = 'workbench.action.browser.showFind'; + static readonly ID = BrowserViewCommandId.ShowFind; constructor() { super({ @@ -515,7 +515,7 @@ class ShowBrowserFindAction extends Action2 { } class HideBrowserFindAction extends Action2 { - static readonly ID = 'workbench.action.browser.hideFind'; + static readonly ID = BrowserViewCommandId.HideFind; constructor() { super({ @@ -540,7 +540,7 @@ class HideBrowserFindAction extends Action2 { } class BrowserFindNextAction extends Action2 { - static readonly ID = 'workbench.action.browser.findNext'; + static readonly ID = BrowserViewCommandId.FindNext; constructor() { super({ @@ -571,7 +571,7 @@ class BrowserFindNextAction extends Action2 { } class BrowserFindPreviousAction extends Action2 { - static readonly ID = 'workbench.action.browser.findPrevious'; + static readonly ID = BrowserViewCommandId.FindPrevious; constructor() { super({ diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index c60a295cd5a..d189013b3ad 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -3,15 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IBrowserViewService, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; +import { BrowserViewCommandId, IBrowserViewService, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; import { IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel } from '../common/browserView.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { Event } from '../../../../base/common/event.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; -export class BrowserViewWorkbenchService implements IBrowserViewWorkbenchService { +/** Command IDs whose accelerators are shown in browser view context menus. */ +const browserViewContextMenuCommands = [ + BrowserViewCommandId.GoBack, + BrowserViewCommandId.GoForward, + BrowserViewCommandId.Reload, +]; + +export class BrowserViewWorkbenchService extends Disposable implements IBrowserViewWorkbenchService { declare readonly _serviceBrand: undefined; private readonly _browserViewService: IBrowserViewService; @@ -20,10 +29,15 @@ export class BrowserViewWorkbenchService implements IBrowserViewWorkbenchService constructor( @IMainProcessService mainProcessService: IMainProcessService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IKeybindingService private readonly keybindingService: IKeybindingService ) { + super(); const channel = mainProcessService.getChannel(ipcBrowserViewChannelName); this._browserViewService = ProxyChannel.toService(channel); + + this.sendKeybindings(); + this._register(this.keybindingService.onDidUpdateKeybindings(() => this.sendKeybindings())); } async getOrCreateBrowserViewModel(id: string): Promise { @@ -67,4 +81,16 @@ export class BrowserViewWorkbenchService implements IBrowserViewWorkbenchService return model; } + + private sendKeybindings(): void { + const keybindings: { [commandId: string]: string } = Object.create(null); + for (const commandId of browserViewContextMenuCommands) { + const binding = this.keybindingService.lookupKeybinding(commandId); + const accelerator = binding?.getElectronAccelerator(); + if (accelerator) { + keybindings[commandId] = accelerator; + } + } + void this._browserViewService.updateKeybindings(keybindings); + } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index db45b5b9b48..d80361715ce 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -3,6 +3,27 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +@property --animation-angle { + syntax: ''; + inherits: false; + initial-value: 135deg; +} + +@property --animation-opacity { + syntax: ''; + inherits: false; + initial-value: 0%; +} + +@keyframes browser-shared-border-spin { + from { + --animation-angle: 135deg; + } + to { + --animation-angle: 495deg; + } +} + .browser-root { display: flex; flex-direction: column; @@ -13,7 +34,6 @@ display: flex; align-items: center; padding: 6px 8px; - border-bottom: 1px solid var(--vscode-widget-border); background-color: var(--vscode-editor-background); flex-shrink: 0; gap: 8px; @@ -27,10 +47,13 @@ .actions-container { gap: 4px; - margin-right: 4px; } } + .browser-actions-toolbar { + margin-right: 4px; + } + .browser-url-container { flex: 1; display: flex; @@ -68,6 +91,12 @@ color: var(--vscode-descriptionForeground); white-space: nowrap; gap: 4px; + outline: none !important; + + &:focus-visible { + outline: 1px solid var(--vscode-focusBorder) !important; + outline-offset: 0px !important; + } .codicon { margin: 0; @@ -101,37 +130,7 @@ flex: 1; min-height: 0; position: relative; - z-index: 0; /* Important: creates a new stacking context for the gradient border trick */ - - &.shared { - &::before { - content: ''; - position: absolute; - top: -2px; - left: 0; - right: 0; - bottom: 0; - z-index: -2; - background: linear-gradient(135deg in lab, - color-mix(in srgb, #51a2ff 100%, transparent), - color-mix(in srgb, #4af0c0 100%, transparent), - color-mix(in srgb, #b44aff 100%, transparent) - ) !important; - pointer-events: none; - } - - &::after { - content: ''; - position: absolute; - top: 1px; - left: 3px; - right: 3px; - bottom: 3px; - z-index: -1; - background-color: var(--vscode-editor-background); - pointer-events: none; - } - } + margin-top: 1px; } .browser-container { @@ -143,12 +142,78 @@ * which would cause visible shifts when swapping between the live * view and the placeholder screenshot. */ - width: round(down, 100% - 4px, calc(1px / var(--zoom-factor, 1))); - height: round(down, 100% - 2px, calc(1px / var(--zoom-factor, 1))); + width: round(down, 100% - 8px, calc(1px / var(--zoom-factor, 1))); + height: round(down, 100% - 4px, calc(1px / var(--zoom-factor, 1))); margin: 0 auto; overflow: visible; + border-radius: var(--vscode-cornerRadius-medium); position: relative; outline: none !important; + z-index: 0; /* Important: creates a new stacking context for the gradient border trick */ + + &::before { + content: ''; + position: absolute; + --animation-angle: 135deg; + --animation-opacity: 0%; + top: -1px; + left: -1px; + right: -1px; + bottom: -1px; + z-index: -2; + border-radius: var(--vscode-cornerRadius-medium); + background: conic-gradient(from var(--animation-angle), + color-mix(in srgb, #b44aff var(--animation-opacity), var(--vscode-browser-border, transparent)), + color-mix(in srgb, #4af0c0 var(--animation-opacity), var(--vscode-browser-border, transparent)), + color-mix(in srgb, #51a2ff var(--animation-opacity), var(--vscode-browser-border, transparent)), + color-mix(in srgb, #4af0c0 var(--animation-opacity), var(--vscode-browser-border, transparent)), + color-mix(in srgb, #b44aff var(--animation-opacity), var(--vscode-browser-border, transparent)) + ); + pointer-events: none; + } + + &.shared::before { + top: -3px; + left: -3px; + right: -3px; + bottom: -3px; + --animation-angle: 495deg; + --animation-opacity: 100%; + } + + &.animate::before { + transition: top 350ms cubic-bezier(0.2, 0, 0, 1), + left 350ms cubic-bezier(0.2, 0, 0, 1), + right 350ms cubic-bezier(0.2, 0, 0, 1), + bottom 350ms cubic-bezier(0.2, 0, 0, 1), + --animation-opacity 350ms cubic-bezier(0.2, 0, 0, 1); + } + + &.shared.animate::before { + animation: browser-shared-border-spin 1500ms cubic-bezier(0, 0.2, 0, 1) 1 forwards, + browser-shared-border-spin 45s linear 1500ms infinite; + } + + &::after { + content: ''; + position: absolute; + top: 1px; + left: 1px; + right: 1px; + bottom: 1px; + z-index: -1; + border-radius: var(--vscode-cornerRadius-medium); + background-color: var(--vscode-editor-background); + pointer-events: none; + } + } + + .browser-placeholder-contents { + width: 100%; + height: 100%; + position: relative; + overflow: hidden; + border-radius: var(--vscode-cornerRadius-medium); } .browser-placeholder-screenshot { @@ -193,7 +258,7 @@ border-radius: 4px; border: 1px solid var(--vscode-editorWidget-border); background-color: var(--vscode-editor-background); - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); max-width: 80%; text-align: center; display: none; @@ -282,8 +347,9 @@ border-radius: 0; border: none; top: 0 !important; - padding: 6px 8px 6px 12px; + padding: 2px 8px 6px 12px; transition: none; + background: none !important; &:not(.visible) { display: none; @@ -291,11 +357,13 @@ .monaco-sash { width: 2px !important; - border-radius: 0; + height: calc(100% - 4px); + border-radius: var(--vscode-cornerRadius-circle); &::before { width: var(--vscode-sash-hover-size); left: calc(50% - (var(--vscode-sash-hover-size) / 2)); + border-radius: var(--vscode-cornerRadius-circle); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts index aabb81819aa..aea84f61e5e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts @@ -69,16 +69,25 @@ export class NavigateBrowserTool implements IToolImpl { invocationMessage: localize('browser.goForward.invocation', "Going forward in browser history"), pastTenseMessage: localize('browser.goForward.past', "Went forward in browser history"), }; - default: + default: { + if (!params.url) { + throw new Error('The "url" parameter is required when type is "url".'); + } + const parsed = URL.parse(params.url); + if (!parsed) { + throw new Error('You must provide a complete, valid URL.'); + } + return { - invocationMessage: localize('browser.navigate.invocation', "Navigating browser to {0}", params.url), - pastTenseMessage: localize('browser.navigate.past', "Navigated browser to {0}", params.url), + invocationMessage: localize('browser.navigate.invocation', "Navigating browser to {0}", parsed.href), + pastTenseMessage: localize('browser.navigate.past', "Navigated browser to {0}", parsed.href), confirmationMessages: { title: localize('browser.navigate.confirmTitle', 'Navigate Browser?'), - message: localize('browser.navigate.confirmMessage', 'This will navigate the browser to {0} and allow the agent to access its contents.', params.url), + message: localize('browser.navigate.confirmMessage', 'This will navigate the browser to {0} and allow the agent to access its contents.', parsed.href), allowAutoConfirm: true, }, }; + } } } @@ -97,12 +106,9 @@ export class NavigateBrowserTool implements IToolImpl { case 'forward': return playwrightInvoke(this.playwrightService, params.pageId, (page) => page.goForward({ waitUntil: 'domcontentloaded' })); default: { - if (!params.url) { - return errorResult('The "url" parameter is required when type is "url".'); - } return playwrightInvoke(this.playwrightService, params.pageId, (page, url) => { return page.goto(url, { waitUntil: 'domcontentloaded' }); - }, params.url); + }, params.url!); } } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index 24420493758..97e764cda78 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -8,7 +8,6 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { errorResult } from './browserToolHelpers.js'; export const OpenPageToolId = 'open_browser_page'; @@ -25,7 +24,7 @@ export const OpenBrowserToolData: IToolData = { properties: { url: { type: 'string', - description: 'The URL to open in the browser.' + description: 'The full URL to open in the browser.' }, }, required: ['url'], @@ -43,12 +42,21 @@ export class OpenBrowserTool implements IToolImpl { async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { const params = context.parameters as IOpenBrowserToolParams; + + if (!params.url) { + throw new Error('The "url" parameter is required.'); + } + const parsed = URL.parse(params.url); + if (!parsed) { + throw new Error('You must provide a complete, valid URL.'); + } + return { - invocationMessage: localize('browser.open.invocation', "Opening browser page at {0}", params.url ?? 'about:blank'), - pastTenseMessage: localize('browser.open.past', "Opened browser page at {0}", params.url ?? 'about:blank'), + invocationMessage: localize('browser.open.invocation', "Opening browser page at {0}", parsed.href), + pastTenseMessage: localize('browser.open.past', "Opened browser page at {0}", parsed.href), confirmationMessages: { title: localize('browser.open.confirmTitle', 'Open Browser Page?'), - message: localize('browser.open.confirmMessage', 'This will open {0} in the integrated browser. The agent will be able to read and interact with its contents.', params.url ?? 'about:blank'), + message: localize('browser.open.confirmMessage', 'This will open {0} in the integrated browser. The agent will be able to read and interact with its contents.', parsed.href), allowAutoConfirm: true, }, }; @@ -56,11 +64,6 @@ export class OpenBrowserTool implements IToolImpl { async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { const params = invocation.parameters as IOpenBrowserToolParams; - - if (!params.url) { - return errorResult('The "url" parameter is required.'); - } - const { pageId, summary } = await this.playwrightService.openPage(params.url); return { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts index 444e2a483b0..d9c819f7ca9 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts @@ -10,7 +10,6 @@ import { BrowserViewUri } from '../../../../../platform/browserView/common/brows import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { errorResult } from './browserToolHelpers.js'; import { IOpenBrowserToolParams, OpenBrowserToolData } from './openBrowserTool.js'; export const OpenBrowserToolNonAgenticData: IToolData = { @@ -26,12 +25,21 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { const params = context.parameters as IOpenBrowserToolParams; + + if (!params.url) { + throw new Error('The "url" parameter is required.'); + } + const parsed = URL.parse(params.url); + if (!parsed) { + throw new Error('You must provide a complete, valid URL.'); + } + return { - invocationMessage: localize('browser.open.nonAgentic.invocation', "Opening browser page at {0}", params.url ?? 'about:blank'), - pastTenseMessage: localize('browser.open.nonAgentic.past', "Opened browser page at {0}", params.url ?? 'about:blank'), + invocationMessage: localize('browser.open.nonAgentic.invocation', "Opening browser page at {0}", parsed.href), + pastTenseMessage: localize('browser.open.nonAgentic.past', "Opened browser page at {0}", parsed.href), confirmationMessages: { title: localize('browser.open.nonAgentic.confirmTitle', 'Open Browser Page?'), - message: localize('browser.open.nonAgentic.confirmMessage', 'This will open {0} in the integrated browser. The agent will not be able to read its contents.', params.url ?? 'about:blank'), + message: localize('browser.open.nonAgentic.confirmMessage', 'This will open {0} in the integrated browser. The agent will not be able to read its contents.', parsed.href), allowAutoConfirm: true, }, }; @@ -40,10 +48,6 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { const params = invocation.parameters as IOpenBrowserToolParams; - if (!params.url) { - return errorResult('The "url" parameter is required.'); - } - logBrowserOpen(this.telemetryService, 'chatTool'); const browserUri = BrowserViewUri.forUrl(params.url); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts index bcc288f07ea..e07efe6265d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts @@ -97,6 +97,7 @@ export class RunPlaywrightCodeTool implements IToolImpl { ], toolResultDetails: { input: params.code.trim(), + inputLanguage: 'javascript', output: result.result ? [{ type: 'embed', isText: true, value: JSON.stringify(result.result, null, 2) }] : [], diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts index 90397262b5d..5b9b0e488d7 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatAccessibilityProvider.ts @@ -54,6 +54,8 @@ export const getToolConfirmationAlert = (accessor: ServicesAccessor, toolInvocat input = JSON.stringify(v.toolSpecificData.extensions); } else if (v.toolSpecificData.kind === 'input') { input = JSON.stringify(v.toolSpecificData.rawInput); + } else if (v.toolSpecificData.kind === 'modifiedFilesConfirmation') { + input = localize('modifiedFilesConfirmationInput', '{0} files', v.toolSpecificData.modifiedFiles.length); } } const titleObj = state.confirmationMessages?.title; diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index 6cad0431d35..f20aabab996 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -17,7 +17,7 @@ import { IStorageService, StorageScope } from '../../../../../platform/storage/c import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { migrateLegacyTerminalToolSpecificData } from '../../common/chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatExtensionsContent, IChatPullRequestContent, IChatSimpleToolInvocationData, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolResourcesInvocationData, ILegacyChatTerminalToolInvocationData, IToolResultOutputDetailsSerialized, isLegacyChatTerminalToolInvocationData } from '../../common/chatService/chatService.js'; +import { IChatExtensionsContent, IChatModifiedFilesConfirmationData, IChatPullRequestContent, IChatSimpleToolInvocationData, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolResourcesInvocationData, ILegacyChatTerminalToolInvocationData, IToolResultOutputDetailsSerialized, isLegacyChatTerminalToolInvocationData } from '../../common/chatService/chatService.js'; import { isResponseVM } from '../../common/model/chatViewModel.js'; import { IToolResultInputOutputDetails, IToolResultOutputDetails, isToolResultInputOutputDetails, isToolResultOutputDetails, toolContentToA11yString } from '../../common/tools/languageModelToolsService.js'; import { ChatTreeItem, IChatWidget, IChatWidgetService } from '../chat.js'; @@ -59,7 +59,7 @@ export class ChatResponseAccessibleView implements IAccessibleViewImplementation } } -type ToolSpecificData = IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatToolResourcesInvocationData; +type ToolSpecificData = IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatToolResourcesInvocationData | IChatModifiedFilesConfirmationData; type ResultDetails = Array | IToolResultInputOutputDetails | IToolResultOutputDetails | IToolResultOutputDetailsSerialized; export const CHAT_ACCESSIBLE_VIEW_INCLUDE_THINKING_STORAGE_KEY = 'chat.accessibleView.includeThinking'; @@ -141,6 +141,16 @@ export function getToolSpecificDataDescription(toolSpecificData: ToolSpecificDat const outputText = toolSpecificData.output; return localize('simpleToolInvocation', "Input: {0}, Output: {1}", inputText, outputText); } + case 'modifiedFilesConfirmation': { + if (toolSpecificData.modifiedFiles.length === 0) { + return ''; + } + + return localize('modifiedFilesConfirmation', "Modified files: {0}", toolSpecificData.modifiedFiles.map(file => { + const revivedUri = URI.revive(file.uri); + return revivedUri.fsPath || revivedUri.path; + }).join(', ')); + } default: return ''; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 98fb8e824f9..a6f6a8952dc 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -368,6 +368,7 @@ abstract class OpenChatGlobalAction extends Action2 { if (opts?.query) { if (opts.isPartialQuery) { + chatWidget.input.showScrollbarUntilAccept(); chatWidget.setInput(opts.query); } else { if (!chatWidget.viewModel) { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts index fb17967129b..6401df034be 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts @@ -21,11 +21,15 @@ import { FileEditorInput } from '../../../files/browser/editors/fileEditorInput. import { NotebookEditorInput } from '../../../notebook/common/notebookEditorInput.js'; import { IChatContextPickService, IChatContextValueItem, IChatContextPickerItem, IChatContextPickerPickItem, IChatContextPicker } from '../attachments/chatContextPickService.js'; import { IChatRequestToolEntry, IChatRequestToolSetEntry, IChatRequestVariableEntry, IImageVariableEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; -import { isToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; -import { IChatWidget } from '../chat.js'; +import { ILanguageModelToolsService, isToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; +import { IChatWidget, IChatWidgetService } from '../chat.js'; import { imageToHash, isImage } from '../widget/input/editor/chatPasteProviders.js'; import { convertBufferToScreenshotVariable } from '../attachments/chatScreenshotContext.js'; import { ChatInstructionsPickerPick } from '../promptSyntax/attachInstructionsAction.js'; +import { createDebugEventsAttachment } from '../chatDebug/chatDebugAttachment.js'; +import { IChatDebugService } from '../../common/chatDebugService.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ITerminalService } from '../../../terminal/browser/terminal.js'; import { URI } from '../../../../../base/common/uri.js'; import { ITerminalCommand, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; @@ -38,9 +42,29 @@ export class ChatContextContributions extends Disposable implements IWorkbenchCo constructor( @IInstantiationService instantiationService: IInstantiationService, @IChatContextPickService contextPickService: IChatContextPickService, + @IChatDebugService chatDebugService: IChatDebugService, + @IContextKeyService contextKeyService: IContextKeyService, + @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, + @IChatWidgetService chatWidgetService: IChatWidgetService, ) { super(); + // Bind at the global context key service level so the tools service can evaluate it. + // Widget-scoped keys are not reliably visible to singleton services during async request processing. + const hasAttachedDebugDataKey = ChatContextKeys.chatSessionHasAttachedDebugData.bindTo(contextKeyService); + this._store.add(chatWidgetService.onDidChangeFocusedSession(() => { + const sessionResource = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; + hasAttachedDebugDataKey.set(!!sessionResource && chatDebugService.hasAttachedDebugData(sessionResource)); + languageModelToolsService.flushToolUpdates(); + })); + this._store.add(chatDebugService.onDidAttachDebugData(sessionResource => { + const focusedSession = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; + if (focusedSession && focusedSession.toString() === sessionResource.toString()) { + hasAttachedDebugDataKey.set(true); + languageModelToolsService.flushToolUpdates(); + } + })); + // ############################################################################################### // // Default context picks/values which are "native" to chat. This is NOT the complete list @@ -54,6 +78,7 @@ export class ChatContextContributions extends Disposable implements IWorkbenchCo this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(OpenEditorContextValuePick))); this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(ClipboardImageContextValuePick))); this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(ScreenshotContextValuePick))); + this._store.add(contextPickService.registerChatContextItem(instantiationService.createInstance(DebugEventsSnapshotContextValuePick))); } } @@ -285,3 +310,28 @@ class ScreenshotContextValuePick implements IChatContextValueItem { return blob && convertBufferToScreenshotVariable(blob); } } + +class DebugEventsSnapshotContextValuePick implements IChatContextValueItem { + + readonly type = 'valuePick'; + readonly icon = Codicon.output; + readonly label = localize('chatContext.debugEventsSnapshot', 'Debug Events Snapshot'); + readonly ordinal = -600; + + constructor( + @IChatDebugService private readonly _chatDebugService: IChatDebugService, + ) { } + + isEnabled(widget: IChatWidget): boolean { + const sessionResource = widget.viewModel?.sessionResource; + return !!sessionResource && this._chatDebugService.getEvents(sessionResource).length > 0; + } + + async asAttachment(widget: IChatWidget): Promise { + const sessionResource = widget.viewModel?.sessionResource; + if (!sessionResource) { + return undefined; + } + return createDebugEventsAttachment(sessionResource, this._chatDebugService); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts index 40827f04060..3ab18f156f5 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts @@ -34,6 +34,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { ChatModel } from '../../common/model/chatModel.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; +import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../attachments/chatVariables.js'; import { ChatSendResult, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; import { ChatAgentLocation } from '../../common/constants.js'; @@ -403,7 +404,7 @@ export class CreateRemoteAgentJobAction { userPrompt = 'implement this.'; } - const attachedContext = widget.input.getAttachedAndImplicitContext(sessionResource); + const attachedContext = widget.input.getAttachedAndImplicitContext(); widget.input.acceptInput(true); // For inline editor mode, add selection or cursor information @@ -479,7 +480,7 @@ export class CreateRemoteAgentJobAction { const requestParser = instantiationService.createInstance(ChatRequestParser); // Add the request to the model first - const parsedRequest = requestParser.parseChatRequest(sessionResource, userPrompt, ChatAgentLocation.Chat); + const parsedRequest = requestParser.parseChatRequestWithReferences(getDynamicVariablesForWidget(widget), getSelectedToolAndToolSetsForWidget(widget), userPrompt, ChatAgentLocation.Chat); const addedRequest = chatModel.addRequest( parsedRequest, { variables: attachedContext.asArray() }, diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts index e0647b11c4a..42d67d3b3a5 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; import { localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { katexContainerClassName, katexContainerLatexAttributeName } from '../../../markdown/common/markedKatexExtension.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatRequestViewModel, IChatResponseViewModel, isChatTreeItem, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; @@ -54,11 +56,20 @@ export function registerChatCopyActions() { title: localize2('interactive.copyItem.label', "Copy"), f1: false, category: CHAT_CATEGORY, - menu: { - id: MenuId.ChatContext, - when: ChatContextKeys.responseIsFiltered.negate(), - group: 'copy', - } + icon: Codicon.copy, + menu: [ + { + id: MenuId.ChatContext, + when: ChatContextKeys.responseIsFiltered.negate(), + group: 'copy', + }, + { + id: MenuId.ChatMessageFooter, + group: 'navigation', + order: 1, + when: ContextKeyExpr.and(ChatContextKeys.isResponse, ChatContextKeys.responseIsFiltered.negate()), + } + ] }); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 29fb309c4db..c49e5c69c32 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -22,15 +22,16 @@ import { KeybindingWeight } from '../../../../../platform/keybinding/common/keyb import { ILogService } from '../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; +import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { getModeNameForTelemetry, IChatMode, IChatModeService } from '../../common/chatModes.js'; import { chatVariableLeader } from '../../common/requestParser/chatParserTypes.js'; import { ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatService } from '../../common/chatService/chatService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, } from '../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { isInClaudeAgentsFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; -import { IChatSessionsService } from '../../common/chatSessionsService.js'; +import { IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; import { getAgentSessionProvider, AgentSessionProviders } from '../agentSessions/agentSessions.js'; import { getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; @@ -191,7 +192,11 @@ const requestInProgressWithoutInput = ContextKeyExpr.and( ); const pendingToolCall = ContextKeyExpr.or( ChatContextKeys.Editing.hasToolConfirmation, - ChatContextKeys.Editing.hasQuestionCarousel, + ContextKeyExpr.and(ChatContextKeys.Editing.hasQuestionCarousel, ChatContextKeys.inputHasText.negate()), +); +const noQuestionCarouselOrHasInput = ContextKeyExpr.or( + ChatContextKeys.Editing.hasQuestionCarousel.negate(), + ChatContextKeys.inputHasText, ); const whenNotInProgress = ChatContextKeys.requestInProgress.negate(); @@ -234,6 +239,7 @@ export class ChatSubmitAction extends SubmitAction { whenNotInProgress, menuCondition, ChatContextKeys.withinEditSessionDiff.negate(), + noQuestionCarouselOrHasInput, ), group: 'navigation', alt: { @@ -447,6 +453,46 @@ export class OpenModelPickerAction extends Action2 { } } } + +export class OpenPermissionPickerAction extends Action2 { + static readonly ID = 'workbench.action.chat.openPermissionPicker'; + + constructor() { + super({ + id: OpenPermissionPickerAction.ID, + title: localize2('interactive.openPermissionPicker.label', "Open Permission Picker"), + tooltip: localize('setPermissionLevel', "Set Permissions"), + category: CHAT_CATEGORY, + f1: false, + precondition: ChatContextKeys.enabled, + menu: { + id: MenuId.ChatInputSecondary, + order: 10, + group: 'navigation', + when: + ContextKeyExpr.and( + ChatContextKeys.enabled, + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask), + ChatContextKeys.inQuickChat.negate(), + ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent.negate(), + ChatContextKeys.lockedCodingAgentId.isEqualTo(AgentSessionProviders.Background), + ), + ) + } + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + if (widget) { + widget.input.openPermissionPicker(); + } + } +} + export class OpenModePickerAction extends Action2 { static readonly ID = 'workbench.action.chat.openModePicker'; @@ -515,6 +561,18 @@ export class OpenSessionTargetPickerAction extends Action2 { ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.inQuickChat.negate(), + ChatContextKeys.chatSessionIsEmpty, + IsSessionsWindowContext), + group: 'navigation', + }, + { + id: MenuId.ChatInputSecondary, + order: 0, + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ChatContextKeys.inQuickChat.negate(), + IsSessionsWindowContext.negate(), ChatContextKeys.chatSessionIsEmpty), group: 'navigation', }, @@ -544,13 +602,14 @@ export class OpenDelegationPickerAction extends Action2 { precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.chatSessionIsEmpty.negate(), ChatContextKeys.currentlyEditingInput.negate(), ChatContextKeys.currentlyEditing.negate()), menu: [ { - id: MenuId.ChatInput, + id: MenuId.ChatInputSecondary, order: 0.5, when: ContextKeyExpr.and( ChatContextKeys.enabled, ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.inQuickChat.negate(), - ChatContextKeys.chatSessionIsEmpty.negate()), + ChatContextKeys.chatSessionIsEmpty.negate() + ), group: 'navigation', }, ] @@ -583,7 +642,18 @@ export class OpenWorkspacePickerAction extends Action2 { order: 0.6, when: ContextKeyExpr.and( ChatContextKeys.inAgentSessionsWelcome, - ChatContextKeys.chatSessionType.isEqualTo('local') + ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + IsSessionsWindowContext + ), + group: 'navigation', + }, + { + id: MenuId.ChatInputSecondary, + order: 0.6, + when: ContextKeyExpr.and( + ChatContextKeys.inAgentSessionsWelcome, + ChatContextKeys.chatSessionType.isEqualTo(localChatSessionType), + IsSessionsWindowContext.negate() ), group: 'navigation', }, @@ -601,7 +671,7 @@ export class ChatSessionPrimaryPickerAction extends Action2 { constructor() { super({ id: ChatSessionPrimaryPickerAction.ID, - title: localize2('interactive.openChatSessionPrimaryPicker.label', "Open Model Picker"), + title: localize2('interactive.openChatSessionPrimaryPicker.label', "Open Primary Session Picker"), category: CHAT_CATEGORY, f1: false, precondition: ChatContextKeys.enabled, @@ -665,7 +735,8 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { constructor() { const notInProgressOrEditing = ContextKeyExpr.and( ContextKeyExpr.or(whenNotInProgress, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.Sent)), - ChatContextKeys.editingRequestType.notEqualsTo(ChatContextKeys.EditingRequestType.QueueOrSteer) + ChatContextKeys.editingRequestType.notEqualsTo(ChatContextKeys.EditingRequestType.Queue), + ChatContextKeys.editingRequestType.notEqualsTo(ChatContextKeys.EditingRequestType.Steer) ); const menuCondition = ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Ask); @@ -688,7 +759,8 @@ export class ChatEditingSessionSubmitAction extends SubmitAction { order: 4, when: ContextKeyExpr.and( notInProgressOrEditing, - menuCondition), + menuCondition, + noQuestionCarouselOrHasInput), group: 'navigation', alt: { id: 'workbench.action.chat.sendToNewChat', @@ -815,7 +887,7 @@ class SendToNewChatAction extends Action2 { // Cancel any in-progress request before clearing if (widget.viewModel) { - chatService.cancelCurrentRequestForSession(widget.viewModel.sessionResource, 'newSessionAction'); + await chatService.cancelCurrentRequestForSession(widget.viewModel.sessionResource, 'newSessionAction'); } if (widget.viewModel?.model) { @@ -875,7 +947,7 @@ export class CancelAction extends Action2 { }); } - run(accessor: ServicesAccessor, ...args: unknown[]) { + async run(accessor: ServicesAccessor, ...args: unknown[]) { const context = args[0] as IChatExecuteActionContext | undefined; const widgetService = accessor.get(IChatWidgetService); const logService = accessor.get(ILogService); @@ -894,7 +966,7 @@ export class CancelAction extends Action2 { const chatService = accessor.get(IChatService); if (widget.viewModel) { - chatService.cancelCurrentRequestForSession(widget.viewModel.sessionResource, 'cancelAction'); + await chatService.cancelCurrentRequestForSession(widget.viewModel.sessionResource, 'cancelAction'); } else { telemetryService.publicLog2(ChatStopCancellationNoopEventName, { source: 'cancelAction', @@ -960,6 +1032,7 @@ export function registerChatExecuteActions() { registerAction2(ToggleChatModeAction); registerAction2(SwitchToNextModelAction); registerAction2(OpenModelPickerAction); + registerAction2(OpenPermissionPickerAction); registerAction2(OpenModePickerAction); registerAction2(OpenSessionTargetPickerAction); registerAction2(OpenDelegationPickerAction); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index 9fefe82c3ac..685ebc58948 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -3,12 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { joinPath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; -import { localize2 } from '../../../../../nls.js'; +import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; +import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { isChatViewTitleActionContext } from '../../common/actions/chatActions.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; @@ -16,6 +22,7 @@ import { IChatDebugService } from '../../common/chatDebugService.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; import { ChatDebugEditorInput } from '../chatDebug/chatDebugEditorInput.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { IChatDebugEditorOptions } from '../chatDebug/chatDebugTypes.js'; /** @@ -67,7 +74,7 @@ export function registerChatOpenAgentDebugPanelAction() { }); } - async run(accessor: ServicesAccessor, context?: URI | unknown): Promise { + async run(accessor: ServicesAccessor, context?: URI | unknown, filter?: string): Promise { const editorService = accessor.get(IEditorService); const chatWidgetService = accessor.get(IChatWidgetService); const chatDebugService = accessor.get(IChatDebugService); @@ -88,7 +95,103 @@ export function registerChatOpenAgentDebugPanelAction() { } chatDebugService.activeSessionResource = sessionResource; - const options: IChatDebugEditorOptions = { pinned: true, sessionResource, viewHint: 'logs' }; + const options: IChatDebugEditorOptions = { pinned: true, sessionResource, viewHint: 'logs', filter }; + await editorService.openEditor(ChatDebugEditorInput.instance, options); + } + }); + + const defaultDebugLogFileName = 'agent-debug-log.json'; + const debugLogFilters = [{ name: localize('chatDebugLog.file.label', "Agent Debug Log"), extensions: ['json'] }]; + + registerAction2(class ExportAgentDebugLogAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.exportAgentDebugLog', + title: localize2('chat.exportAgentDebugLog.label', "Export Agent Debug Log..."), + icon: Codicon.desktopDownload, + f1: true, + category: Categories.Developer, + precondition: ChatContextKeys.enabled, + menu: [{ + id: MenuId.EditorTitle, + group: 'navigation', + when: ActiveEditorContext.isEqualTo(ChatDebugEditorInput.ID), + order: 10 + }], + }); + } + + async run(accessor: ServicesAccessor): Promise { + const chatDebugService = accessor.get(IChatDebugService); + const fileDialogService = accessor.get(IFileDialogService); + const fileService = accessor.get(IFileService); + const notificationService = accessor.get(INotificationService); + + const sessionResource = chatDebugService.activeSessionResource; + if (!sessionResource) { + notificationService.notify({ severity: Severity.Info, message: localize('chatDebugLog.noSession', "No active debug session to export. Navigate to a session first.") }); + return; + } + + const defaultUri = joinPath(await fileDialogService.defaultFilePath(), defaultDebugLogFileName); + const outputPath = await fileDialogService.showSaveDialog({ defaultUri, filters: debugLogFilters }); + if (!outputPath) { + return; + } + + const data = await chatDebugService.exportLog(sessionResource); + if (!data) { + notificationService.notify({ severity: Severity.Warning, message: localize('chatDebugLog.exportFailed', "Export is not supported by the current provider.") }); + return; + } + + await fileService.writeFile(outputPath, VSBuffer.wrap(data)); + } + }); + + registerAction2(class ImportAgentDebugLogAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.importAgentDebugLog', + title: localize2('chat.importAgentDebugLog.label', "Import Agent Debug Log..."), + icon: Codicon.cloudUpload, + f1: true, + category: Categories.Developer, + precondition: ChatContextKeys.enabled, + menu: [{ + id: MenuId.EditorTitle, + group: 'navigation', + when: ActiveEditorContext.isEqualTo(ChatDebugEditorInput.ID), + order: 11 + }], + }); + } + + async run(accessor: ServicesAccessor): Promise { + const chatDebugService = accessor.get(IChatDebugService); + const editorService = accessor.get(IEditorService); + const fileDialogService = accessor.get(IFileDialogService); + const fileService = accessor.get(IFileService); + const notificationService = accessor.get(INotificationService); + + const defaultUri = joinPath(await fileDialogService.defaultFilePath(), defaultDebugLogFileName); + const result = await fileDialogService.showOpenDialog({ + defaultUri, + canSelectFiles: true, + filters: debugLogFilters + }); + if (!result) { + return; + } + + const content = await fileService.readFile(result[0]); + const sessionUri = await chatDebugService.importLog(content.value.buffer); + if (!sessionUri) { + notificationService.notify({ severity: Severity.Warning, message: localize('chatDebugLog.importFailed', "Import is not supported by the current provider.") }); + return; + } + + const options: IChatDebugEditorOptions = { pinned: true, sessionResource: sessionUri, viewHint: 'overview' }; await editorService.openEditor(ChatDebugEditorInput.instance, options); } }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts index 6606748d653..ed1b2f3689a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts @@ -13,15 +13,34 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatRequestQueueKind, IChatService } from '../../common/chatService/chatService.js'; +import { ChatConfiguration } from '../../common/constants.js'; import { isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY } from './chatActions.js'; +const editingQueue = ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.Queue); +const editingSteer = ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.Steer); +const editingQueueOrSteer = ContextKeyExpr.or(editingQueue, editingSteer)!; + const queuingActionsPresent = ContextKeyExpr.and( - ContextKeyExpr.or(ChatContextKeys.requestInProgress, ChatContextKeys.editingRequestType.isEqualTo(ChatContextKeys.EditingRequestType.QueueOrSteer)), + ContextKeyExpr.or(ChatContextKeys.requestInProgress, editingQueueOrSteer), ChatContextKeys.editingRequestType.notEqualsTo(ChatContextKeys.EditingRequestType.Sent), ); +const steerIsDefault = ContextKeyExpr.equals(`config.${ChatConfiguration.RequestQueueingDefaultAction}`, 'steer'); +const queueIsDefault = steerIsDefault.negate(); + +// The effective default respects the editing context: when editing a queued/steer +// message, the default matches that message type regardless of the config setting. +const effectiveDefaultIsQueue = ContextKeyExpr.or( + ContextKeyExpr.and(queueIsDefault, editingQueueOrSteer.negate()), + editingQueue +); +const effectiveDefaultIsSteer = ContextKeyExpr.or( + ContextKeyExpr.and(steerIsDefault, editingQueueOrSteer.negate()), + editingSteer +); + export interface IChatRemovePendingRequestContext { sessionResource: URI; pendingRequestId: string; @@ -52,14 +71,23 @@ export class ChatQueueMessageAction extends Action2 { queuingActionsPresent, ChatContextKeys.inputHasText, ), - keybinding: { + keybinding: [{ when: ContextKeyExpr.and( ChatContextKeys.inChatInput, queuingActionsPresent, + effectiveDefaultIsSteer, ), primary: KeyMod.Alt | KeyCode.Enter, weight: KeybindingWeight.EditorContrib + 1 - }, + }, { + when: ContextKeyExpr.and( + ChatContextKeys.inChatInput, + queuingActionsPresent, + effectiveDefaultIsQueue, + ), + primary: KeyCode.Enter, + weight: KeybindingWeight.EditorContrib + 1 + }], }); } @@ -87,21 +115,30 @@ export class ChatSteerWithMessageAction extends Action2 { id: ChatSteerWithMessageAction.ID, title: localize2('chat.steerWithMessage', "Steer with Message"), tooltip: localize('chat.steerWithMessage.tooltip', "Send this message at the next opportunity, signaling the current request to yield"), - icon: Codicon.arrowRight, + icon: Codicon.arrowUp, f1: false, category: CHAT_CATEGORY, precondition: ContextKeyExpr.and( queuingActionsPresent, ChatContextKeys.inputHasText, ), - keybinding: { + keybinding: [{ when: ContextKeyExpr.and( ChatContextKeys.inChatInput, queuingActionsPresent, + effectiveDefaultIsSteer, ), primary: KeyCode.Enter, weight: KeybindingWeight.EditorContrib + 1 - }, + }, { + when: ContextKeyExpr.and( + ChatContextKeys.inChatInput, + queuingActionsPresent, + effectiveDefaultIsQueue, + ), + primary: KeyMod.Alt | KeyCode.Enter, + weight: KeybindingWeight.EditorContrib + 1 + }], }); } @@ -182,7 +219,7 @@ export class ChatSendPendingImmediatelyAction extends Action2 { }); } - override run(accessor: ServicesAccessor, ...args: unknown[]): void { + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { const chatService = accessor.get(IChatService); const widgetService = accessor.get(IChatWidgetService); const [context] = args; @@ -213,7 +250,7 @@ export class ChatSendPendingImmediatelyAction extends Action2 { ]; chatService.setPendingRequests(context.sessionResource, reordered); - chatService.cancelCurrentRequestForSession(context.sessionResource, 'queueRunNext'); + await chatService.cancelCurrentRequestForSession(context.sessionResource, 'queueRunNext'); chatService.processPendingRequests(context.sessionResource); } } @@ -271,7 +308,7 @@ export function registerChatQueueActions(): void { order: 1, }); MenuRegistry.appendMenuItem(MenuId.ChatExecuteQueue, { - command: { id: ChatSteerWithMessageAction.ID, title: localize2('chat.steerWithMessage', "Steer with Message"), icon: Codicon.arrowRight }, + command: { id: ChatSteerWithMessageAction.ID, title: localize2('chat.steerWithMessage', "Steer with Message"), icon: Codicon.arrowUp }, group: 'navigation', order: 2, }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts index 7afcbdd62aa..a144b617853 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts @@ -121,7 +121,7 @@ export class ConfigureToolsAction extends Action2 { super({ id: ConfigureToolsAction.ID, title: localize('label', "Configure Tools..."), - icon: Codicon.tools, + icon: Codicon.settings, f1: false, category: CHAT_CATEGORY, precondition: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts index 91e60f599de..7d8e0512eaf 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts @@ -22,6 +22,7 @@ import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; import { IMcpServer, IMcpService, IMcpWorkbenchService, McpConnectionState, McpServerCacheState, McpServerEditorTab } from '../../../mcp/common/mcpTypes.js'; import { startServerAndWaitForLiveTools } from '../../../mcp/common/mcpTypesUtils.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; +import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService, IToolData, IToolSet, ToolDataSource, ToolSet } from '../../common/tools/languageModelToolsService.js'; import { ConfigureToolSets } from '../tools/toolSetsContribution.js'; @@ -31,7 +32,7 @@ const enum BucketOrdinal { User, BuiltIn, Mcp, Extension } type BucketPick = IQuickPickItem & { picked: boolean; ordinal: BucketOrdinal; status?: string; toolset?: ToolSet; children: (ToolPick | ToolSetPick)[] }; type ToolSetPick = IQuickPickItem & { picked: boolean; toolset: ToolSet; parent: BucketPick }; type ToolPick = IQuickPickItem & { picked: boolean; tool: IToolData; parent: BucketPick }; -type ActionableButton = IQuickInputButton & { action: () => void }; +type ActionableButton = IQuickInputButton & { action: () => void; keepOpen?: boolean }; // New QuickTree types for tree-based implementation @@ -77,6 +78,7 @@ interface IToolSetTreeItem extends IToolTreeItem { interface IToolTreeItemData extends IToolTreeItem { readonly itemType: 'tool'; readonly tool: IToolData; + buttons?: ActionableButton[]; checked: boolean; } @@ -205,6 +207,7 @@ export async function showToolsPicker( const editorService = accessor.get(IEditorService); const mcpWorkbenchService = accessor.get(IMcpWorkbenchService); const toolsService = accessor.get(ILanguageModelToolsService); + const confirmationService = accessor.get(ILanguageModelToolsConfirmationService); const telemetryService = accessor.get(ITelemetryService); const mcpServerByTool = new Map(); @@ -451,6 +454,38 @@ export async function showToolsPicker( } } } + // Add approval management buttons to tool items that support confirmation + for (const bucket of sortedBuckets) { + const isMcpBucket = bucket.ordinal === BucketOrdinal.Mcp; + const addConfirmationButton = (toolItem: IToolTreeItemData) => { + if (!confirmationService.toolCanManageConfirmation(toolItem.tool)) { + return; + } + const tool = toolItem.tool; + const manageTools = isMcpBucket ? bucket.children.flatMap(c => isToolTreeItem(c) ? [c.tool] : isToolSetTreeItem(c) && c.children ? c.children.filter(isToolTreeItem).map(gc => gc.tool) : []) : [tool]; + const buttons: ActionableButton[] = toolItem.buttons ? [...toolItem.buttons] : []; + buttons.push({ + iconClass: ThemeIcon.asClassName(Codicon.pass), + tooltip: localize('manageToolApproval', "Manage Approval"), + keepOpen: true, + action: () => confirmationService.manageConfirmationPreferences(manageTools, { focusToolId: tool.id }) + }); + toolItem.buttons = buttons; + }; + + for (const child of bucket.children) { + if (isToolTreeItem(child)) { + addConfirmationButton(child); + } else if (isToolSetTreeItem(child) && child.children) { + for (const grandchild of child.children) { + if (isToolTreeItem(grandchild)) { + addConfirmationButton(grandchild); + } + } + } + } + } + if (treeItems.length === 0) { treePicker.placeholder = localize('noTools', "Add tools to chat"); } else { @@ -474,7 +509,8 @@ export async function showToolsPicker( // Handle button triggers store.add(treePicker.onDidTriggerItemButton(e => { if (e.button && typeof (e.button as ActionableButton).action === 'function') { - (e.button as ActionableButton).action(); + const actionableButton = e.button as ActionableButton; + actionableButton.action(); store.dispose(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts b/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts new file mode 100644 index 00000000000..29d788865e8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentPluginActions.ts @@ -0,0 +1,276 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action, IAction, IActionChangeEvent } from '../../../../base/common/actions.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { ActionWithDropdownActionViewItem, IActionWithDropdownActionViewItemOptions } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; +import { IContextMenuProvider } from '../../../../base/browser/contextmenu.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { dirname, joinPath } from '../../../../base/common/resources.js'; +import { ContributionEnablementState, IEnablementModel, isContributionDisabled, isContributionEnabled } from '../common/enablement.js'; +import { IAgentPlugin, IAgentPluginService } from '../common/plugins/agentPluginService.js'; +import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; +import { IMarketplacePluginItem } from './agentPluginEditor/agentPluginItems.js'; +import { buildEnablementContextMenuGroup } from './enablementActions.js'; +import { hasKey } from '../../../../base/common/types.js'; + +//#region Simple actions + +export class InstallPluginAction extends Action { + constructor( + item: IMarketplacePluginItem, + @IPluginInstallService pluginInstallService: IPluginInstallService, + ) { + super('agentPlugin.install', localize('install', "Install"), 'extension-action label prominent install', true, + () => pluginInstallService.installPlugin({ + name: item.name, + description: item.description, + version: '', + source: item.source, + sourceDescriptor: item.sourceDescriptor, + marketplace: item.marketplace, + marketplaceReference: item.marketplaceReference, + marketplaceType: item.marketplaceType, + readmeUri: item.readmeUri, + })); + } +} + +export class UninstallPluginAction extends Action { + constructor(plugin: IAgentPlugin) { + super('agentPlugin.uninstall', localize('uninstall', "Uninstall"), 'extension-action label uninstall', true, + () => { plugin.remove(); return Promise.resolve(); }); + } +} + +export class OpenPluginFolderAction extends Action { + constructor( + plugin: IAgentPlugin, + @ICommandService commandService: ICommandService, + @IOpenerService openerService: IOpenerService, + ) { + super('agentPlugin.openFolder', localize('openPluginFolder', "Open Plugin Folder"), undefined, true, + async () => { + try { + await commandService.executeCommand('revealFileInOS', plugin.uri); + } catch { + await openerService.open(dirname(plugin.uri)); + } + }); + } +} + +export class OpenPluginReadmeAction extends Action { + constructor( + readmeUri: import('../../../../base/common/uri.js').URI, + @IOpenerService openerService: IOpenerService, + ) { + super('agentPlugin.openReadme', localize('openReadme', "Open README"), undefined, true, + () => openerService.open(readmeUri)); + } +} + +//#endregion + +//#region Context menu + +/** + * Builds the standard context menu action groups for an installed plugin. + */ +export function getInstalledPluginContextMenuActions(plugin: IAgentPlugin, instantiationService: IInstantiationService): IAction[][] { + return instantiationService.invokeFunction(accessor => { + const agentPluginService = accessor.get(IAgentPluginService); + const workspaceService = accessor.get(IWorkspaceContextService); + const groups: IAction[][] = []; + groups.push(buildEnablementContextMenuGroup( + plugin.enablement.get(), + plugin.uri.toString(), + agentPluginService.enablementModel, + workspaceService, + 'agentPlugin', + )); + groups.push([ + instantiationService.createInstance(OpenPluginFolderAction, plugin), + instantiationService.createInstance(OpenPluginReadmeAction, joinPath(plugin.uri, 'README.md')), + ]); + if (plugin.fromMarketplace) { + groups.push([new UninstallPluginAction(plugin)]); + } + return groups; + }); +} + +//#endregion + +//#region Dropdown enablement actions for editor-style action bars + +/** + * Sub-action base class that auto-hides when disabled, for use inside + * {@link EnablementDropDownAction}. + */ +class EnablementSubAction extends Action { + private _hidden: boolean; + get hidden(): boolean { return this._hidden; } + set hidden(v: boolean) { this._hidden = v; } + + constructor(id: string, label: string, cssClass: string, enabled: boolean, actionCallback: () => Promise) { + super(id, label, cssClass, enabled, actionCallback); + this._hidden = !enabled; + } + + protected override _setEnabled(value: boolean): void { + super._setEnabled(value); + this.hidden = !value; + } +} + +interface IEnablementActionChangeEvent extends IActionChangeEvent { + readonly menuActions?: IAction[]; +} + +/** + * Dropdown action that aggregates enablement sub-actions and shows the + * first visible one as the primary button, with others in the dropdown. + * Hides itself entirely when all sub-actions are hidden. + */ +export class EnablementDropDownAction extends Action { + readonly menuActionClassNames = ['extension-action', 'label', 'action-dropdown']; + private _menuActions: IAction[] = []; + get menuActions(): IAction[] { return [...this._menuActions]; } + + private _isHidden = false; + get isHidden(): boolean { return this._isHidden; } + + protected override readonly _onDidChange = new Emitter(); + override get onDidChange() { return this._onDidChange.event; } + + private readonly subActions: EnablementSubAction[]; + + constructor(id: string, subActions: EnablementSubAction[]) { + super(id, undefined, 'extension-action label action-dropdown'); + this.subActions = subActions; + for (const a of subActions) { + a.onDidChange(() => this._updateDropdown()); + } + this._updateDropdown(); + } + + private _updateDropdown(): void { + const visible = this.subActions.filter(a => !a.hidden); + const primary = visible[0]; + this._menuActions = visible.length > 1 ? [...visible] : []; + + if (primary) { + this._isHidden = false; + this.enabled = true; + this.label = primary.label; + this.tooltip = primary.tooltip; + } else { + this._isHidden = true; + this.enabled = false; + } + this._onDidChange.fire({ menuActions: this._menuActions }); + } + + override async run(): Promise { + const primary = this.subActions.find(a => !a.hidden); + await primary?.run(); + } + + override dispose(): void { + for (const a of this.subActions) { + a.dispose(); + } + super.dispose(); + } +} + +/** + * View item for {@link EnablementDropDownAction} that properly hides + * the dropdown chevron when there are no secondary actions. + */ +export class EnablementDropdownActionViewItem extends ActionWithDropdownActionViewItem { + constructor( + action: EnablementDropDownAction, + options: IActionViewItemOptions & IActionWithDropdownActionViewItemOptions, + contextMenuProvider: IContextMenuProvider, + ) { + super(null, action, options, contextMenuProvider); + this._register(action.onDidChange(e => { + if (hasKey(e, { menuActions: true })) { + this.updateClass(); + } + })); + } + + override render(container: HTMLElement): void { + super.render(container); + this.updateClass(); + } + + protected override updateClass(): void { + super.updateClass(); + if (this.element && this.dropdownMenuActionViewItem?.element) { + const action = this._action as EnablementDropDownAction; + this.element.classList.toggle('hide', action.isHidden); + const isMenuEmpty = action.menuActions.length === 0; + this.element.classList.toggle('empty', isMenuEmpty); + this.dropdownMenuActionViewItem.element.classList.toggle('hide', isMenuEmpty); + } + } +} + +/** + * Creates the enable dropdown action for a plugin, containing Enable + * and Enable (Workspace) sub-actions. + */ +export function createEnablePluginDropDown( + plugin: IAgentPlugin, + enablementModel: IEnablementModel, + workspaceContextService: IWorkspaceContextService, +): EnablementDropDownAction { + const key = plugin.uri.toString(); + const hasWorkspace = workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY; + + const enable = new EnablementSubAction('agentPlugin.enable', localize('enable', "Enable"), 'extension-action label prominent', + isContributionDisabled(plugin.enablement.get()), + () => { enablementModel.setEnabled(key, ContributionEnablementState.EnabledProfile); return Promise.resolve(); }); + + const enableWorkspace = new EnablementSubAction('agentPlugin.enableForWorkspace', localize('enableForWorkspace', "Enable (Workspace)"), 'extension-action label', + isContributionDisabled(plugin.enablement.get()) && hasWorkspace, + () => { enablementModel.setEnabled(key, ContributionEnablementState.EnabledWorkspace); return Promise.resolve(); }); + + return new EnablementDropDownAction('agentPlugin.enableDropdown', [enable, enableWorkspace]); +} + +/** + * Creates the disable dropdown action for a plugin, containing Disable + * and Disable (Workspace) sub-actions. + */ +export function createDisablePluginDropDown( + plugin: IAgentPlugin, + enablementModel: IEnablementModel, + workspaceContextService: IWorkspaceContextService, +): EnablementDropDownAction { + const key = plugin.uri.toString(); + const hasWorkspace = workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY; + + const disable = new EnablementSubAction('agentPlugin.disable', localize('disable', "Disable"), 'extension-action label disable', + isContributionEnabled(plugin.enablement.get()), + () => { enablementModel.setEnabled(key, ContributionEnablementState.DisabledProfile); return Promise.resolve(); }); + + const disableWorkspace = new EnablementSubAction('agentPlugin.disableForWorkspace', localize('disableForWorkspace', "Disable (Workspace)"), 'extension-action label disable', + isContributionEnabled(plugin.enablement.get()) && hasWorkspace, + () => { enablementModel.setEnabled(key, ContributionEnablementState.DisabledWorkspace); return Promise.resolve(); }); + + return new EnablementDropDownAction('agentPlugin.disableDropdown', [disable, disableWorkspace]); +} + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts index df8124325ee..216fe391589 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts @@ -5,14 +5,15 @@ import { $, Dimension, EventType, addDisposableListener, append, reset, setParentFlowTo } from '../../../../../base/browser/dom.js'; import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; -import { Action } from '../../../../../base/common/actions.js'; +import { IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Action, IAction } from '../../../../../base/common/actions.js'; import * as arrays from '../../../../../base/common/arrays.js'; import { Cache, CacheResult } from '../../../../../base/common/cache.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas, matchesScheme } from '../../../../../base/common/network.js'; -import { autorun } from '../../../../../base/common/observable.js'; -import { basename, dirname, joinPath } from '../../../../../base/common/resources.js'; +import { autorun, derived } from '../../../../../base/common/observable.js'; +import { dirname, joinPath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; @@ -21,6 +22,7 @@ import { generateTokensCSSForColorMap } from '../../../../../editor/common/langu import { localize } from '../../../../../nls.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { IRequestService, asText } from '../../../../../platform/request/common/request.js'; @@ -35,8 +37,12 @@ import { DEFAULT_MARKDOWN_STYLES, renderMarkdownDocument } from '../../../markdo import { IWebview, IWebviewService } from '../../../webview/browser/webview.js'; import { IAgentPlugin, IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { IPluginInstallService } from '../../common/plugins/pluginInstallService.js'; +import { hasSourceChanged, IMarketplacePlugin, IPluginMarketplaceService } from '../../common/plugins/pluginMarketplaceService.js'; import { AgentPluginEditorInput } from './agentPluginEditorInput.js'; -import { AgentPluginItemKind, IAgentPluginItem, IInstalledPluginItem, IMarketplacePluginItem } from './agentPluginItems.js'; +import { AgentPluginItemKind, IAgentPluginItem, IInstalledPluginItem } from './agentPluginItems.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { EnablementStatusWidget, pluginEnablementLabels } from '../enablementStatusWidget.js'; +import { InstallPluginAction, UninstallPluginAction, createEnablePluginDropDown, createDisablePluginDropDown, EnablementDropDownAction, EnablementDropdownActionViewItem } from '../agentPluginActions.js'; import './media/agentPluginEditor.css'; interface IAgentPluginEditorTemplate { @@ -44,6 +50,7 @@ interface IAgentPluginEditorTemplate { description: HTMLElement; marketplace: HTMLElement; actionBar: ActionBar; + statusContainer: HTMLElement; content: HTMLElement; header: HTMLElement; } @@ -91,7 +98,9 @@ export class AgentPluginEditor extends EditorPane { @IRequestService private readonly requestService: IRequestService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IPluginInstallService private readonly pluginInstallService: IPluginInstallService, + @IPluginMarketplaceService private readonly pluginMarketplaceService: IPluginMarketplaceService, @ILabelService private readonly labelService: ILabelService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { super(AgentPluginEditor.ID, group, telemetryService, themeService, storageService); } @@ -119,10 +128,28 @@ export class AgentPluginEditor extends EditorPane { const actionsAndStatusContainer = append(details, $('.actions-status-container')); const actionBar = this._register(new ActionBar(actionsAndStatusContainer, { + actionViewItemProvider: (action: IAction, options: IActionViewItemOptions) => { + if (action instanceof EnablementDropDownAction) { + return new EnablementDropdownActionViewItem( + action, + { + ...options, + icon: true, + label: true, + menuActionsOrProvider: { getActions: () => action.menuActions }, + menuActionClassNames: action.menuActionClassNames, + }, + this.contextMenuService, + ); + } + return undefined; + }, focusOnlyEnabledItems: true })); actionBar.setFocusable(true); + const statusContainer = append(actionsAndStatusContainer, $('.status')); + const body = append(root, $('.body')); const content = append(body, $('.content')); content.id = generateUuid(); @@ -134,6 +161,7 @@ export class AgentPluginEditor extends EditorPane { name, marketplace, actionBar, + statusContainer, }; } @@ -184,14 +212,9 @@ export class AgentPluginEditor extends EditorPane { reset(template.marketplace, marketplaceLabel); } - // Set up actions reactively - const actionDisposables = this.transientDisposables.add(new DisposableStore()); - this.transientDisposables.add(autorun(reader => { - actionDisposables.clear(); - template.actionBar.clear(); - + const currentItem = derived(reader => { // Read observables to subscribe to changes - const allPlugins = this.agentPluginService.allPlugins.read(reader); + const allPlugins = this.agentPluginService.plugins.read(reader); let currentItem = item; @@ -202,6 +225,7 @@ export class AgentPluginEditor extends EditorPane { description: item.description, version: '', source: item.source, + sourceDescriptor: item.sourceDescriptor, marketplace: item.marketplace, marketplaceReference: item.marketplaceReference, marketplaceType: item.marketplaceType, @@ -222,6 +246,7 @@ export class AgentPluginEditor extends EditorPane { name: item.name, description: mp.description, source: mp.source, + sourceDescriptor: mp.sourceDescriptor, marketplace: mp.marketplace, marketplaceReference: mp.marketplaceReference, marketplaceType: mp.marketplaceType, @@ -232,42 +257,88 @@ export class AgentPluginEditor extends EditorPane { return; } } else { - // Read enabled state for reactivity - stillInstalled.enabled.read(reader); + // Read enablement state for reactivity + stillInstalled.enablement.read(reader); currentItem = this.installedPluginToItem(stillInstalled); } } - const actions = this.getItemActions(currentItem); + return currentItem; + }); + + const storedPlugin = currentItem.map((item, r) => { + if (!item || item.kind === AgentPluginItemKind.Marketplace) { + return undefined; + } + + return this.pluginMarketplaceService.installedPlugins.read(r) + .find(e => e.pluginUri.toString() === item.plugin.uri.toString())?.plugin + ?? item.plugin.fromMarketplace; + }); + + // Set up actions reactively + const actionDisposables = this.transientDisposables.add(new DisposableStore()); + this.transientDisposables.add(autorun(reader => { + actionDisposables.clear(); + template.actionBar.clear(); + + const current = currentItem.read(reader); + if (!current) { + return; + } + + this.pluginMarketplaceService.lastFetchedPlugins.read(reader); + + const actions = this.getItemActions(current, storedPlugin.read(reader)); if (actions.length > 0) { template.actionBar.push(actions, { icon: true, label: true }); } for (const action of actions) { actionDisposables.add(action); } + + // Update enablement status widget + if (current.kind === AgentPluginItemKind.Installed) { + actionDisposables.add(this.instantiationService.createInstance( + EnablementStatusWidget, + template.statusContainer, + current.plugin.enablement, + pluginEnablementLabels, + )); + } })); // Open readme this.activeElement = await this.openDetails(item, template, token); } - private getItemActions(item: IAgentPluginItem): Action[] { + private getItemActions(item: IAgentPluginItem, storedPlugin: IMarketplacePlugin | undefined): Action[] { if (item.kind === AgentPluginItemKind.Marketplace) { - return [this.instantiationService.createInstance(InstallPluginEditorAction, item)]; + return [this.instantiationService.createInstance(InstallPluginAction, item)]; } + const workspaceService = this.instantiationService.invokeFunction(a => a.get(IWorkspaceContextService)); const actions: Action[] = []; - if (item.plugin.enabled.get()) { - actions.push(this.instantiationService.createInstance(DisablePluginEditorAction, item.plugin)); - } else { - actions.push(this.instantiationService.createInstance(EnablePluginEditorAction, item.plugin)); + + if (storedPlugin) { + const cachedMarketplace = this.pluginMarketplaceService.lastFetchedPlugins.get(); + const key = `${storedPlugin.marketplaceReference.canonicalId}::${storedPlugin.name}`; + const livePlugin = cachedMarketplace.find(mp => + `${mp.marketplaceReference.canonicalId}::${mp.name}` === key + ); + if (livePlugin && hasSourceChanged(storedPlugin.sourceDescriptor, livePlugin.sourceDescriptor)) { + actions.push(this.instantiationService.createInstance(UpdatePluginEditorAction, item.plugin, livePlugin)); + } } - actions.push(this.instantiationService.createInstance(UninstallPluginEditorAction, item.plugin)); + + actions.push(createEnablePluginDropDown(item.plugin, this.agentPluginService.enablementModel, workspaceService)); + actions.push(createDisablePluginDropDown(item.plugin, this.agentPluginService.enablementModel, workspaceService)); + actions.push(new UninstallPluginAction(item.plugin)); return actions; } private installedPluginToItem(plugin: IAgentPlugin): IInstalledPluginItem { - const name = basename(plugin.uri); + const name = plugin.label; const description = plugin.fromMarketplace?.description ?? this.labelService.getUriLabel(dirname(plugin.uri), { relative: true }); const marketplace = plugin.fromMarketplace?.marketplace; return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin }; @@ -499,71 +570,22 @@ export class AgentPluginEditor extends EditorPane { } } -//#region Actions - -class InstallPluginEditorAction extends Action { - static readonly ID = 'agentPlugin.editor.install'; +class UpdatePluginEditorAction extends Action { + static readonly ID = 'agentPlugin.editor.update'; constructor( - private readonly item: IMarketplacePluginItem, + private readonly plugin: IAgentPlugin, + private readonly liveMarketplacePlugin: IMarketplacePlugin, @IPluginInstallService private readonly pluginInstallService: IPluginInstallService, + @IPluginMarketplaceService private readonly pluginMarketplaceService: IPluginMarketplaceService, ) { - super(InstallPluginEditorAction.ID, localize('install', "Install"), 'extension-action label prominent install'); + super(UpdatePluginEditorAction.ID, localize('update', "Update"), 'extension-action label prominent install'); } override async run(): Promise { - await this.pluginInstallService.installPlugin({ - name: this.item.name, - description: this.item.description, - version: '', - source: this.item.source, - marketplace: this.item.marketplace, - marketplaceReference: this.item.marketplaceReference, - marketplaceType: this.item.marketplaceType, - readmeUri: this.item.readmeUri, - }); - } -} - -class EnablePluginEditorAction extends Action { - static readonly ID = 'agentPlugin.editor.enable'; - - constructor( - private readonly plugin: IAgentPlugin, - @IAgentPluginService private readonly agentPluginService: IAgentPluginService, - ) { - super(EnablePluginEditorAction.ID, localize('enable', "Enable"), 'extension-action label prominent'); - } - - override async run(): Promise { - this.agentPluginService.setPluginEnabled(this.plugin.uri, true); - } -} - -class DisablePluginEditorAction extends Action { - static readonly ID = 'agentPlugin.editor.disable'; - - constructor( - private readonly plugin: IAgentPlugin, - @IAgentPluginService private readonly agentPluginService: IAgentPluginService, - ) { - super(DisablePluginEditorAction.ID, localize('disable', "Disable"), 'extension-action label disable'); - } - - override async run(): Promise { - this.agentPluginService.setPluginEnabled(this.plugin.uri, false); - } -} - -class UninstallPluginEditorAction extends Action { - static readonly ID = 'agentPlugin.editor.uninstall'; - - constructor(private readonly plugin: IAgentPlugin) { - super(UninstallPluginEditorAction.ID, localize('uninstall', "Uninstall"), 'extension-action label uninstall'); - } - - override async run(): Promise { - this.plugin.remove(); + if (await this.pluginInstallService.updatePlugin(this.liveMarketplacePlugin)) { + this.pluginMarketplaceService.addInstalledPlugin(this.plugin.uri, this.liveMarketplacePlugin); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts index 20ec5ed4009..612cea9bb62 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { URI } from '../../../../../base/common/uri.js'; +import { IObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import type { IAgentPlugin } from '../../common/plugins/agentPluginService.js'; -import type { IMarketplaceReference, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js'; +import type { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js'; export const enum AgentPluginItemKind { Installed = 'installed', @@ -18,6 +18,8 @@ export interface IInstalledPluginItem { readonly description: string; readonly marketplace?: string; readonly plugin: IAgentPlugin; + /** When set, indicates the plugin has a newer version in the marketplace. */ + readonly outdated?: IObservable; } export interface IMarketplacePluginItem { @@ -25,6 +27,7 @@ export interface IMarketplacePluginItem { readonly name: string; readonly description: string; readonly source: string; + readonly sourceDescriptor: IPluginSourceDescriptor; readonly marketplace: string; readonly marketplaceReference: IMarketplaceReference; readonly marketplaceType: MarketplaceType; diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index 7b2244345e8..329c531cad5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -6,19 +6,22 @@ import { Action } from '../../../../base/common/actions.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { revive } from '../../../../base/common/marshalling.js'; -import { dirname, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; +import { dirname, isEqual, isEqualOrParent, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; -import { IMarketplacePlugin, IMarketplaceReference, MarketplaceReferenceKind, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; +import { IPluginSource } from '../common/plugins/pluginSource.js'; +import { GitHubPluginSource, GitUrlPluginSource, NpmPluginSource, PipPluginSource, RelativePathPluginSource } from './pluginSources.js'; const MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1'; @@ -34,17 +37,37 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi private readonly _cacheRoot: URI; private readonly _marketplaceIndex = new Lazy>(() => this._loadMarketplaceIndex()); + private readonly _pluginSources: ReadonlyMap; constructor( @ICommandService private readonly _commandService: ICommandService, @IEnvironmentService environmentService: IEnvironmentService, @IFileService private readonly _fileService: IFileService, + @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, @INotificationService private readonly _notificationService: INotificationService, @IProgressService private readonly _progressService: IProgressService, @IStorageService private readonly _storageService: IStorageService, ) { this._cacheRoot = joinPath(environmentService.cacheHome, 'agentPlugins'); + + // Build per-kind source repository map via instantiation service so + // each repository can inject its own dependencies. + this._pluginSources = new Map([ + [PluginSourceKind.RelativePath, new RelativePathPluginSource()], + [PluginSourceKind.GitHub, instantiationService.createInstance(GitHubPluginSource)], + [PluginSourceKind.GitUrl, instantiationService.createInstance(GitUrlPluginSource)], + [PluginSourceKind.Npm, instantiationService.createInstance(NpmPluginSource)], + [PluginSourceKind.Pip, instantiationService.createInstance(PipPluginSource)], + ]); + } + + getPluginSource(kind: PluginSourceKind): IPluginSource { + const repo = this._pluginSources.get(kind); + if (!repo) { + throw new Error(`No source repository registered for kind '${kind}'`); + } + return repo; } getRepositoryUri(marketplace: IMarketplaceReference, marketplaceType?: MarketplaceType): URI { @@ -84,38 +107,47 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi return repoDir; } - async pullRepository(marketplace: IMarketplaceReference, options?: IPullRepositoryOptions): Promise { + async pullRepository(marketplace: IMarketplaceReference, options?: IPullRepositoryOptions): Promise { const repoDir = this.getRepositoryUri(marketplace, options?.marketplaceType); const repoExists = await this._fileService.exists(repoDir); if (!repoExists) { this._logService.warn(`[AgentPluginRepositoryService] Cannot update plugin '${options?.pluginName ?? marketplace.displayLabel}': repository not cloned`); - return; + return false; } const updateLabel = options?.pluginName ?? marketplace.displayLabel; try { - await this._progressService.withProgress( + const doPull = async () => { + return !!(await this._commandService.executeCommand('_git.pull', repoDir.fsPath)); + }; + + if (options?.silent) { + return await doPull(); + } + + return await this._progressService.withProgress( { location: ProgressLocation.Notification, title: localize('updatingPlugin', "Updating plugin '{0}'...", updateLabel), cancellable: false, }, - async () => { - await this._commandService.executeCommand('_git.pull', repoDir.fsPath); - } + doPull, ); } catch (err) { this._logService.error(`[AgentPluginRepositoryService] Failed to update ${marketplace.displayLabel}:`, err); - this._notificationService.notify({ - severity: Severity.Error, - message: localize('pullFailed', "Failed to update plugin '{0}': {1}", options?.failureLabel ?? updateLabel, err?.message ?? String(err)), - actions: { - primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { - this._commandService.executeCommand('git.showOutput'); - })], - }, - }); + if (!options?.silent) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('pullFailed', "Failed to update plugin '{0}': {1}", options?.failureLabel ?? updateLabel, err?.message ?? String(err)), + actions: { + primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { + this._commandService.executeCommand('git.showOutput'); + })], + }, + }); + } + throw err; } } @@ -176,7 +208,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi this._storageService.store(MARKETPLACE_INDEX_STORAGE_KEY, JSON.stringify(serialized), StorageScope.APPLICATION, StorageTarget.MACHINE); } - private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string): Promise { + private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise { try { await this._progressService.withProgress( { @@ -186,7 +218,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi }, async () => { await this._fileService.createFolder(dirname(repoDir)); - await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, dirname(repoDir).fsPath); + await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, repoDir.fsPath, ref); } ); } catch (err) { @@ -212,4 +244,93 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } return pluginDir; } + + getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI { + return this.getPluginSource(sourceDescriptor.kind).getInstallUri(this._cacheRoot, sourceDescriptor); + } + + async ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise { + const repo = this.getPluginSource(plugin.sourceDescriptor.kind); + if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) { + return this.ensureRepository(plugin.marketplaceReference, options); + } + return repo.ensure(this._cacheRoot, plugin, options); + } + + async updatePluginSource(plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise { + const repo = this.getPluginSource(plugin.sourceDescriptor.kind); + if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) { + return this.pullRepository(plugin.marketplaceReference, options); + } + return repo.update(this._cacheRoot, plugin, options); + } + + async fetchRepository(marketplace: IMarketplaceReference): Promise { + const repoDir = this.getRepositoryUri(marketplace); + const repoExists = await this._fileService.exists(repoDir); + if (!repoExists) { + return false; + } + + try { + await this._commandService.executeCommand('_git.fetchRepository', repoDir.fsPath); + const behindCount = await this._commandService.executeCommand('_git.revListCount', repoDir.fsPath, 'HEAD', '@{u}') ?? 0; + return behindCount > 0; + } catch (err) { + this._logService.debug(`[AgentPluginRepositoryService] Silent fetch failed for ${marketplace.displayLabel}:`, err); + return false; + } + } + + async cleanupPluginSource(plugin: IMarketplacePlugin): Promise { + const repo = this.getPluginSource(plugin.sourceDescriptor.kind); + const cleanupDir = repo.getCleanupTarget(this._cacheRoot, plugin.sourceDescriptor); + if (!cleanupDir) { + return; + } + + try { + const exists = await this._fileService.exists(cleanupDir); + if (exists) { + await this._fileService.del(cleanupDir, { recursive: true }); + this._logService.info(`[${plugin.sourceDescriptor.kind}] Removed plugin cache: ${cleanupDir.toString()}`); + } + } catch (err) { + this._logService.warn(`[${plugin.sourceDescriptor.kind}] Failed to remove plugin cache '${cleanupDir.toString()}':`, err); + } + + try { + // Prune empty parent directories up to (but not including) the cache root + // so we don't leave dangling owner/authority folders behind. + await this._pruneEmptyParents(cleanupDir); + } catch (err) { + this._logService.warn(`[${plugin.sourceDescriptor.kind}] Failed to cleanup plugin source:`, err); + } + } + + /** + * Walk from {@link child}'s parent toward {@link _cacheRoot}, removing + * each directory that is empty. Stops as soon as a non-empty directory + * is found or the cache root is reached. Only operates on descendants + * of the cache root — returns immediately for paths outside it. + */ + private async _pruneEmptyParents(child: URI): Promise { + if (!isEqualOrParent(child, this._cacheRoot)) { + return; + } + let current = dirname(child); + while (isEqualOrParent(current, this._cacheRoot) && !isEqual(current, this._cacheRoot)) { + try { + const stat = await this._fileService.resolve(current); + if (stat.children && stat.children.length > 0) { + break; + } + await this._fileService.del(current); + } catch { + break; + } + current = dirname(current); + } + } + } diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 21c59e68f76..48e0c7a57d6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -5,21 +5,21 @@ import * as dom from '../../../../base/browser/dom.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { ActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IListContextMenuEvent } from '../../../../base/browser/ui/list/list.js'; import { IPagedRenderer } from '../../../../base/browser/ui/list/listPaging.js'; import { Action, IAction, Separator } from '../../../../base/common/actions.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; -import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { Disposable, DisposableStore, disposeIfDisposable, IDisposable, isDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { autorun, derived, IObservable, IReaderWithStore } from '../../../../base/common/observable.js'; import { IPagedModel, PagedModel } from '../../../../base/common/paging.js'; -import { basename, dirname, joinPath } from '../../../../base/common/resources.js'; -import { URI } from '../../../../base/common/uri.js'; +import { dirname } from '../../../../base/common/resources.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -38,25 +38,28 @@ import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IViewDescriptorService, IViewsRegistry, Extensions as ViewExtensions } from '../../../common/views.js'; import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js'; import { VIEW_CONTAINER } from '../../extensions/browser/extensions.contribution.js'; +import { manageExtensionIcon } from '../../extensions/browser/extensionsIcons.js'; import { AbstractExtensionsListView } from '../../extensions/browser/extensionsViews.js'; import { DefaultViewsContext, extensionsFilterSubMenu, IExtensionsWorkbenchService, SearchAgentPluginsContext } from '../../extensions/common/extensions.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { IAgentPlugin, IAgentPluginService } from '../common/plugins/agentPluginService.js'; +import { isContributionEnabled } from '../common/enablement.js'; import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; -import { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; +import { hasSourceChanged, IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; import { AgentPluginEditorInput } from './agentPluginEditor/agentPluginEditorInput.js'; import { AgentPluginItemKind, IAgentPluginItem, IInstalledPluginItem, IMarketplacePluginItem } from './agentPluginEditor/agentPluginItems.js'; +import { getInstalledPluginContextMenuActions, InstallPluginAction, OpenPluginReadmeAction } from './agentPluginActions.js'; export const HasInstalledAgentPluginsContext = new RawContextKey('hasInstalledAgentPlugins', false); export const InstalledAgentPluginsViewId = 'workbench.views.agentPlugins.installed'; //#region Item model -function installedPluginToItem(plugin: IAgentPlugin, labelService: ILabelService): IInstalledPluginItem { - const name = basename(plugin.uri); +function installedPluginToItem(plugin: IAgentPlugin, labelService: ILabelService, outdated?: IObservable): IInstalledPluginItem { + const name = plugin.label; const description = plugin.fromMarketplace?.description ?? labelService.getUriLabel(dirname(plugin.uri), { relative: true }); const marketplace = plugin.fromMarketplace?.marketplace; - return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin }; + return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin, outdated }; } function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePluginItem { @@ -65,6 +68,7 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin name: plugin.name, description: plugin.description, source: plugin.source, + sourceDescriptor: plugin.sourceDescriptor, marketplace: plugin.marketplace, marketplaceReference: plugin.marketplaceReference, marketplaceType: plugin.marketplaceType, @@ -76,107 +80,74 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin //#region Actions -class InstallPluginAction extends Action { - static readonly ID = 'agentPlugin.install'; +//#region Actions + +class UpdatePluginAction extends Action { + static readonly ID = 'agentPlugin.update'; constructor( - private readonly item: IMarketplacePluginItem, + private readonly plugin: IAgentPlugin, + private readonly liveMarketplacePlugin: IMarketplacePlugin, @IPluginInstallService private readonly pluginInstallService: IPluginInstallService, + @IPluginMarketplaceService private readonly pluginMarketplaceService: IPluginMarketplaceService, ) { - super(InstallPluginAction.ID, localize('install', "Install"), 'extension-action label prominent install'); + super(UpdatePluginAction.ID, localize('update', "Update"), 'extension-action label prominent install'); } override async run(): Promise { - await this.pluginInstallService.installPlugin({ - name: this.item.name, - description: this.item.description, - version: '', - source: this.item.source, - marketplace: this.item.marketplace, - marketplaceReference: this.item.marketplaceReference, - marketplaceType: this.item.marketplaceType, - readmeUri: this.item.readmeUri, - }); - } -} - -class EnablePluginAction extends Action { - static readonly ID = 'agentPlugin.enable'; - - constructor( - private readonly plugin: IAgentPlugin, - @IAgentPluginService private readonly agentPluginService: IAgentPluginService, - ) { - super(EnablePluginAction.ID, localize('enable', "Enable")); - } - - override async run(): Promise { - this.agentPluginService.setPluginEnabled(this.plugin.uri, true); - } -} - -class DisablePluginAction extends Action { - static readonly ID = 'agentPlugin.disable'; - - constructor( - private readonly plugin: IAgentPlugin, - @IAgentPluginService private readonly agentPluginService: IAgentPluginService, - ) { - super(DisablePluginAction.ID, localize('disable', "Disable")); - } - - override async run(): Promise { - this.agentPluginService.setPluginEnabled(this.plugin.uri, false); - } -} - -class UninstallPluginAction extends Action { - static readonly ID = 'agentPlugin.uninstall'; - - constructor( - private readonly plugin: IAgentPlugin, - ) { - super(UninstallPluginAction.ID, localize('uninstall', "Uninstall")); - } - - override async run(): Promise { - this.plugin.remove(); - } -} - -class OpenPluginFolderAction extends Action { - static readonly ID = 'agentPlugin.openFolder'; - - constructor( - private readonly plugin: IAgentPlugin, - @ICommandService private readonly commandService: ICommandService, - @IOpenerService private readonly openerService: IOpenerService, - ) { - super(OpenPluginFolderAction.ID, localize('openPluginFolder', "Open Plugin Folder")); - } - - override async run(): Promise { - try { - await this.commandService.executeCommand('revealFileInOS', this.plugin.uri); - } catch { - // Fallback for web where 'revealFileInOS' is not available - await this.openerService.open(dirname(this.plugin.uri)); + if (await this.pluginInstallService.updatePlugin(this.liveMarketplacePlugin)) { + this.pluginMarketplaceService.addInstalledPlugin(this.plugin.uri, this.liveMarketplacePlugin); } } } -class OpenPluginReadmeAction extends Action { - static readonly ID = 'agentPlugin.openReadme'; +class ManagePluginAction extends Action { + static readonly ID = 'agentPlugin.manage'; + static readonly CLASS = `extension-action icon manage ${ThemeIcon.asClassName(manageExtensionIcon)}`; + + private _actionViewItem: DropDownActionViewItem | null = null; constructor( - private readonly readmeUri: URI, - @IOpenerService private readonly openerService: IOpenerService, + private readonly getActionGroups: () => IAction[][], + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { - super(OpenPluginReadmeAction.ID, localize('openReadme', "Open README")); + super(ManagePluginAction.ID, '', ManagePluginAction.CLASS, true); + this.tooltip = localize('manage', "Manage"); + } + + createActionViewItem(options: IActionViewItemOptions): DropDownActionViewItem { + this._actionViewItem = this.instantiationService.createInstance(DropDownActionViewItem, this, options); + return this._actionViewItem; } override async run(): Promise { - await this.openerService.open(this.readmeUri); + this._actionViewItem?.showMenu(this.getActionGroups()); + } +} + +class DropDownActionViewItem extends ActionViewItem { + constructor( + action: IAction, + options: IActionViewItemOptions, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + ) { + super(null, action, { ...options, icon: true, label: false }); + } + + showMenu(actionGroups: IAction[][]): void { + if (!this.element) { + return; + } + const actions = actionGroups.flatMap(group => [...group, new Separator()]); + if (actions.length > 0) { + actions.pop(); + } + const { left, top, height } = dom.getDomNodePagePosition(this.element); + this.contextMenuService.showContextMenu({ + getAnchor: () => ({ x: left, y: top + height + 10 }), + getActions: () => actions, + onHide: () => disposeIfDisposable(actions), + }); } } @@ -213,7 +184,15 @@ class AgentPluginRenderer implements IPagedRenderer { + if (action instanceof ManagePluginAction) { + return action.createActionViewItem(options); + } + return undefined; + } + }); actionbar.setFocusable(false); return { root, name, description, detail, actionbar, disposables: [actionbar], elementDisposables: [] }; } @@ -233,18 +212,34 @@ class AgentPluginRenderer implements IPagedRenderer { - data.root.classList.toggle('disabled', element.kind === AgentPluginItemKind.Installed && !element.plugin.enabled.read(reader)); + data.root.classList.toggle('disabled', element.kind === AgentPluginItemKind.Installed && !isContributionEnabled(element.plugin.enablement.read(reader))); })); - data.actionbar.clear(); - if (element.kind === AgentPluginItemKind.Marketplace) { - data.detail.textContent = element.marketplace; - const installAction = this.instantiationService.createInstance(InstallPluginAction, element); - data.elementDisposables.push(installAction); - data.actionbar.push([installAction], { icon: true, label: true }); - } else { - data.detail.textContent = element.marketplace ?? ''; - } + const updateActions = (reader: IReaderWithStore) => { + data.actionbar.clear(); + if (element.kind === AgentPluginItemKind.Marketplace) { + data.detail.textContent = element.marketplace; + const installAction = this.instantiationService.createInstance(InstallPluginAction, element); + reader.store.add(installAction); + data.actionbar.push([installAction], { icon: true, label: true }); + } else { + data.detail.textContent = element.marketplace ?? ''; + const actions: Action[] = []; + const livePlugin = element.outdated?.read(reader); + if (livePlugin) { + const updateAction = this.instantiationService.createInstance(UpdatePluginAction, element.plugin, livePlugin); + reader.store.add(updateAction); + actions.push(updateAction); + } + const manageAction = this.instantiationService.createInstance(ManagePluginAction, + () => getInstalledPluginContextMenuActions(element.plugin, this.instantiationService)); + reader.store.add(manageAction); + actions.push(manageAction); + data.actionbar.push(actions, { icon: true, label: true }); + } + }; + + data.elementDisposables.push(autorun(updateActions)); } disposeElement(_element: IAgentPluginItem | undefined, _index: number, data: IAgentPluginTemplateData): void { @@ -309,7 +304,10 @@ export class AgentPluginsListView extends AbstractExtensionsListView { - this.agentPluginService.plugins.read(reader); + const plugins = this.agentPluginService.plugins.read(reader); + for (const plugin of plugins) { + plugin.enablement.read(reader); + } if (this.list && this.isBodyVisible()) { this.refreshOnPluginsChangedScheduler.schedule(); } @@ -383,20 +381,15 @@ export class AgentPluginsListView extends AbstractExtensionsListView [...group, new Separator()]); + if (actions.length > 0) { + actions.pop(); } - - actions.push(new Separator()); - actions.push(this.instantiationService.createInstance(OpenPluginFolderAction, item.plugin)); - actions.push(this.instantiationService.createInstance(OpenPluginReadmeAction, joinPath(item.plugin.uri, 'README.md'))); - actions.push(new Separator()); - actions.push(this.instantiationService.createInstance(UninstallPluginAction, item.plugin)); } else { + actions = []; if (item.readmeUri) { actions.push(this.instantiationService.createInstance(OpenPluginReadmeAction, item.readmeUri)); } @@ -433,7 +426,11 @@ export class AgentPluginsListView extends AbstractExtensionsListView p.name.toLowerCase().includes(lowerText) || p.description.toLowerCase().includes(lowerText)) + .map(marketplacePluginToItem); // Filter out marketplace items that are already installed const installedPaths = new Set(installed.map(i => i.plugin.uri.toString())); @@ -443,6 +440,7 @@ export class AgentPluginsListView extends AbstractExtensionsListView installedPluginToItem(p, this.labelService)); + const marketplaceObs = derived(reader => { + const cachedMarketplace = this.pluginMarketplaceService.lastFetchedPlugins.read(reader); + const marketplaceByKey = new Map(); + for (const mp of cachedMarketplace) { + marketplaceByKey.set(`${mp.marketplaceReference.canonicalId}::${mp.name}`, mp); + } + + + // Read fresh installed plugin metadata from the store (not from + // IAgentPlugin.fromMarketplace which may be stale after an update). + const installedByUri = new Map(); + for (const entry of this.pluginMarketplaceService.installedPlugins.read(reader)) { + installedByUri.set(entry.pluginUri.toString(), entry.plugin); + } + + return { marketplaceByKey, installedByUri }; + }); + + + const plugins = this.agentPluginService.plugins.get(); + return plugins.map(p => { + const isOutdated = derived(reader => { + const { marketplaceByKey, installedByUri } = marketplaceObs.read(reader); + const storedPlugin = installedByUri.get(p.uri.toString()) ?? p.fromMarketplace; + if (storedPlugin) { + const key = `${storedPlugin.marketplaceReference.canonicalId}::${storedPlugin.name}`; + const live = marketplaceByKey.get(key); + if (live && hasSourceChanged(storedPlugin.sourceDescriptor, live.sourceDescriptor)) { + return live; + } + } + + return undefined; + }); + return installedPluginToItem(p, this.labelService, isOutdated); + }); } - private async queryMarketplace(text: string): Promise { + private async queryMarketplacePlugins(): Promise { this.queryCts.value?.cancel(); const cts = new CancellationTokenSource(); this.queryCts.value = cts; try { - const plugins = await this.pluginMarketplaceService.fetchMarketplacePlugins(cts.token); - const lowerText = text.toLowerCase(); - return plugins - .filter(p => p.name.toLowerCase().includes(lowerText) || p.description.toLowerCase().includes(lowerText)) - .map(marketplacePluginToItem); + return await this.pluginMarketplaceService.fetchMarketplacePlugins(cts.token); } catch { return []; } @@ -523,6 +557,38 @@ class AgentPluginsBrowseCommand extends Action2 { } } +class CheckForPluginUpdatesCommand extends Action2 { + constructor() { + super({ + id: 'workbench.agentPlugins.checkForUpdates', + title: localize2('agentPlugins.checkForUpdates', "Update Plugins"), + category: localize2('chat.category', "Chat"), + precondition: ChatContextKeys.enabled, + f1: true, + }); + } + + async run(accessor: ServicesAccessor) { + await accessor.get(IPluginInstallService).updateAllPlugins({}, CancellationToken.None); + } +} + +class ForceUpdatePluginsCommand extends Action2 { + constructor() { + super({ + id: 'workbench.agentPlugins.forceUpdate', + title: localize2('agentPlugins.forceUpdate', "Update Plugins (Force)"), + category: localize2('chat.category', "Chat"), + precondition: ChatContextKeys.enabled, + f1: true, + }); + } + + async run(accessor: ServicesAccessor) { + await accessor.get(IPluginInstallService).updateAllPlugins({ force: true }, CancellationToken.None); + } +} + //#endregion //#region Views contribution @@ -538,10 +604,12 @@ export class AgentPluginsViewsContribution extends Disposable implements IWorkbe const hasInstalledKey = HasInstalledAgentPluginsContext.bindTo(contextKeyService); this._register(autorun(reader => { - hasInstalledKey.set(agentPluginService.allPlugins.read(reader).length > 0); + hasInstalledKey.set(agentPluginService.plugins.read(reader).length > 0); })); registerAction2(AgentPluginsBrowseCommand); + registerAction2(CheckForPluginUpdatesCommand); + registerAction2(ForceUpdatePluginsCommand); Registry.as(ViewExtensions.ViewsRegistry).registerViews([ { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts new file mode 100644 index 00000000000..5b2aa56855a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; +import { Disposable, DisposableResourceMap, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, autorunIterableDelta, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { migrateLegacyTerminalToolSpecificData } from '../../common/chat.js'; +import { IChatModel } from '../../common/model/chatModel.js'; +import { IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; + +export interface IAgentSessionApprovalInfo { + readonly label: string; + readonly languageId: string | undefined; + confirm(): void; +} + +/** + * Tracks approval state for all live chat sessions. For each session, + * exposes an observable that emits {@link IAgentSessionApprovalInfo} + * when a tool invocation is waiting for user confirmation, or `undefined` + * when no approval is needed. + */ +export class AgentSessionApprovalModel extends Disposable { + + private readonly _approvals = new Map>(); + private readonly _modelTrackers = this._register(new DisposableResourceMap()); + + constructor( + @IChatService private readonly _chatService: IChatService, + @ILanguageService private readonly _languageService: ILanguageService, + ) { + super(); + + this._register(autorunIterableDelta( + reader => this._chatService.chatModels.read(reader), + ({ addedValues, removedValues }) => { + for (const model of addedValues) { + this._modelTrackers.set(model.sessionResource, this._trackModel(model)); + } + for (const model of removedValues) { + this._modelTrackers.deleteAndDispose(model.sessionResource); + this._approvals.get(model.sessionResource.toString())?.set(undefined, undefined); + } + } + )); + } + + getApproval(sessionResource: URI): IObservable { + return this._getOrCreateApproval(sessionResource.toString()); + } + + private _getOrCreateApproval(key: string): ISettableObservable { + let obs = this._approvals.get(key); + if (!obs) { + obs = observableValue(`sessionApproval.${key}`, undefined); + this._approvals.set(key, obs); + } + return obs; + } + + private _trackModel(model: IChatModel): IDisposable { + const settable = this._getOrCreateApproval(model.sessionResource.toString()); + + const setIfChanged = (value: IAgentSessionApprovalInfo | undefined) => { + const current = settable.get(); + if (current === value) { + return; + } + if (current !== undefined && value !== undefined && current.label === value.label && current.languageId === value.languageId) { + return; + } + settable.set(value, undefined); + }; + + return autorun(reader => { + const needsInput = model.requestNeedsInput.read(reader); + if (!needsInput) { + setIfChanged(undefined); + return; + } + + const lastResponse = model.lastRequest?.response; + if (!lastResponse?.response?.value) { + setIfChanged(undefined); + return; + } + + for (const part of lastResponse.response.value) { + if (part.kind !== 'toolInvocation') { + continue; + } + const state = part.state.read(reader); + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { + let label: string; + let languageId: string | undefined; + if (part.toolSpecificData?.kind === 'terminal') { + const terminalData = migrateLegacyTerminalToolSpecificData(part.toolSpecificData); + label = terminalData.presentationOverrides?.commandLine ?? terminalData.commandLine.forDisplay ?? terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original; + languageId = this._languageService.getLanguageIdByLanguageName(terminalData.presentationOverrides?.language ?? terminalData.language) ?? undefined; + } else if (needsInput.detail) { + label = needsInput.detail; + } else { + const msg = part.invocationMessage; + label = typeof msg === 'string' ? msg : renderAsPlaintext(msg); + } + + const confirmState = state; + setIfChanged({ + label, + languageId, + confirm: () => confirmState.confirm({ type: ToolConfirmKind.UserAction }), + }); + return; + } + } + + setIfChanged(undefined); + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index a8be72f98ff..f7662aa6914 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -7,7 +7,7 @@ import { localize } from '../../../../../nls.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { observableValue } from '../../../../../base/common/observable.js'; + import { IChatSessionTiming } from '../../common/chatService/chatService.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -42,18 +42,12 @@ export function getAgentSessionProvider(sessionResource: URI | string): AgentSes } } -/** - * Observable holding the display name for the background agent session provider. - * Updated via experiment treatment to allow A/B testing of the display name. - */ -export const backgroundAgentDisplayName = observableValue('backgroundAgentDisplayName', localize('chat.session.providerLabel.background', "Background")); - export function getAgentSessionProviderName(provider: AgentSessionProviders): string { switch (provider) { case AgentSessionProviders.Local: return localize('chat.session.providerLabel.local', "Local"); case AgentSessionProviders.Background: - return backgroundAgentDisplayName.get(); + return localize('chat.session.providerLabel.background', "Copilot CLI"); case AgentSessionProviders.Cloud: return localize('chat.session.providerLabel.cloud', "Cloud"); case AgentSessionProviders.Claude: diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 3150acc6c23..950fea0381e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -28,7 +28,7 @@ import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { AgentSessionsPicker } from './agentSessionsPicker.js'; -import { ActiveEditorContext } from '../../../../common/contextkeys.js'; +import { ActiveEditorContext, IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; @@ -708,10 +708,11 @@ export class OpenAgentSessionInEditorGroupAction extends BaseOpenAgentSessionAct primary: KeyMod.WinCtrl | KeyCode.Enter }, weight: KeybindingWeight.WorkbenchContrib + 1, - when: ChatContextKeys.agentSessionsViewerFocused, + when: ContextKeyExpr.and(ChatContextKeys.agentSessionsViewerFocused, IsSessionsWindowContext.negate()), }, menu: { id: MenuId.AgentSessionsContext, + when: IsSessionsWindowContext.negate(), order: 1, group: 'navigation' } @@ -741,10 +742,11 @@ export class OpenAgentSessionInNewEditorGroupAction extends BaseOpenAgentSession primary: KeyMod.WinCtrl | KeyMod.Alt | KeyCode.Enter }, weight: KeybindingWeight.WorkbenchContrib + 1, - when: ChatContextKeys.agentSessionsViewerFocused, + when: ContextKeyExpr.and(ChatContextKeys.agentSessionsViewerFocused, IsSessionsWindowContext.negate()), }, menu: { id: MenuId.AgentSessionsContext, + when: IsSessionsWindowContext.negate(), order: 2, group: 'navigation' } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 384baff52d4..9fba2ec928c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -8,9 +8,13 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IOpenEvent, WorkbenchCompressibleAsyncDataTree } from '../../../../../platform/list/browser/listService.js'; -import { $, append, EventHelper } from '../../../../../base/browser/dom.js'; +import { $, append, EventHelper, addDisposableListener, EventType, hide, setVisibility } from '../../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { localize } from '../../../../../nls.js'; import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, IMarshalledAgentSessionContext, isAgentSession, isAgentSessionSection } from './agentSessionsModel.js'; import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionsSorter, IAgentSessionsFilter, IAgentSessionsSorterOptions } from './agentSessionsViewer.js'; +import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; @@ -19,6 +23,7 @@ import { ACTION_ID_NEW_CHAT } from '../actions/chatActions.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { Throttler } from '../../../../../base/common/async.js'; +import { observableValue } from '../../../../../base/common/observable.js'; import { ITreeContextMenuEvent } from '../../../../../base/browser/ui/tree/tree.js'; import { MarshalledId } from '../../../../../base/common/marshallingIds.js'; import { Separator } from '../../../../../base/common/actions.js'; @@ -35,12 +40,15 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { IChatWidget } from '../chat.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; export interface IAgentSessionsControlOptions extends IAgentSessionsSorterOptions { readonly overrideStyles: IStyleOverride; readonly filter: IAgentSessionsFilter; readonly source: string; readonly disableHover?: boolean; + readonly showIsolationIcon?: boolean; + readonly enableApprovalRow?: boolean; getHoverPosition(): HoverPosition; trackActiveEditorSession(): boolean; @@ -68,8 +76,11 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private sessionsContainer: HTMLElement | undefined; get element(): HTMLElement | undefined { return this.sessionsContainer; } + private emptyFilterMessage: HTMLElement | undefined; + private sessionsList: WorkbenchCompressibleAsyncDataTree | undefined; private sessionsListFindIsOpen = false; + private _isProgrammaticCollapseChange = false; private readonly updateSessionsListThrottler = this._register(new Throttler()); @@ -95,6 +106,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IEditorService private readonly editorService: IEditorService, + @IStorageService private readonly storageService: IStorageService, ) { super(); @@ -103,7 +115,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.focusedAgentSessionTypeContextKey = ChatContextKeys.agentSessionType.bindTo(this.contextKeyService); this.hasMultipleAgentSessionsSelectedContextKey = ChatContextKeys.hasMultipleAgentSessionsSelected.bindTo(this.contextKeyService); - this.createList(this.container); + this.create(this.container); this.registerListeners(); } @@ -137,11 +149,77 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } } - private createList(container: HTMLElement): void { + private create(container: HTMLElement): void { this.sessionsContainer = append(container, $('.agent-sessions-viewer')); + this.createEmptyFilterMessage(this.sessionsContainer); + this.createList(this.sessionsContainer); + } + + private createEmptyFilterMessage(container: HTMLElement): void { + this.emptyFilterMessage = append(container, $('.agent-sessions-empty-filter-message')); + hide(this.emptyFilterMessage); + + const span = append(this.emptyFilterMessage, $('span')); + span.textContent = `${localize('agentSessions.noFilterResults', "No matching sessions")} - `; + + const link = append(this.emptyFilterMessage, $('span.reset-filter-link')); + link.textContent = localize('agentSessions.resetFilter', "Reset Filter"); + link.tabIndex = 0; + link.setAttribute('role', 'button'); + this._register(addDisposableListener(link, EventType.CLICK, () => this.options.filter.reset())); + this._register(addDisposableListener(link, EventType.KEY_DOWN, (e) => { + const event = new StandardKeyboardEvent(e); + if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) { + EventHelper.stop(e, true); + this.options.filter.reset(); + } + })); + } + + private static readonly SECTION_COLLAPSE_STATE_KEY = 'agentSessions.sectionCollapseState'; + + private getSavedCollapseState(section: AgentSessionSection): boolean | undefined { + const raw = this.storageService.get(AgentSessionsControl.SECTION_COLLAPSE_STATE_KEY, StorageScope.PROFILE); + if (raw) { + try { + const state: Record = JSON.parse(raw); + if (typeof state[section] === 'boolean') { + return state[section]; + } + } catch { + // ignore corrupt data + } + } + return undefined; + } + + private saveSectionCollapseState(section: AgentSessionSection, collapsed: boolean): void { + let state: Record = {}; + const raw = this.storageService.get(AgentSessionsControl.SECTION_COLLAPSE_STATE_KEY, StorageScope.PROFILE); + if (raw) { + try { + const parsed = JSON.parse(raw); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + state = parsed; + } + } catch { + // ignore corrupt data + } + } + state[section] = collapsed; + this.storageService.store(AgentSessionsControl.SECTION_COLLAPSE_STATE_KEY, JSON.stringify(state), StorageScope.PROFILE, StorageTarget.USER); + } + + private createList(container: HTMLElement): void { const collapseByDefault = (element: unknown) => { if (isAgentSessionSection(element)) { + // Check for persisted user preference first + const saved = this.getSavedCollapseState(element.section); + if (saved !== undefined) { + return saved; + } + if (element.section === AgentSessionSection.More && !this.options.filter.getExcludes().read) { return true; // More section is always collapsed unless only showing unread } @@ -163,16 +241,20 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo }; const sorter = new AgentSessionsSorter(this.options); + const approvalModel = this.options.enableApprovalRow ? this._register(this.instantiationService.createInstance(AgentSessionApprovalModel)) : undefined; + const activeSessionResource = observableValue(this, undefined); + const sessionRenderer = this._register(this.instantiationService.createInstance(AgentSessionRenderer, this.options, approvalModel, activeSessionResource)); + const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter)); const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, 'AgentSessionsView', - this.sessionsContainer, - new AgentSessionsListDelegate(), + container, + new AgentSessionsListDelegate(approvalModel), new AgentSessionsCompressionDelegate(), [ - this._register(this.instantiationService.createInstance(AgentSessionRenderer, this.options)), + sessionRenderer, this.instantiationService.createInstance(AgentSessionSectionRenderer), ], - new AgentSessionsDataSource(this.options.filter, sorter), + sessionFilter, { accessibilityProvider: new AgentSessionsAccessibilityProvider(), dnd: this.instantiationService.createInstance(AgentSessionsDragAndDrop), @@ -191,6 +273,16 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo ChatContextKeys.agentSessionsViewerFocused.bindTo(list.contextKeyService); + this._register(sessionRenderer.onDidChangeItemHeight(session => { + if (list.hasNode(session)) { + list.updateElementHeight(session, undefined); + } + })); + + this._register(sessionFilter.onDidGetChildren(count => { + this.updateEmpty(count === 0); + })); + const model = this.agentSessionsService.model; this._register(this.options.filter.onDidChange(async () => { @@ -223,10 +315,12 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.focusedAgentSessionArchivedContextKey.set(focused.isArchived()); this.focusedAgentSessionReadContextKey.set(focused.isRead()); this.focusedAgentSessionTypeContextKey.set(focused.providerType); + activeSessionResource.set(focused.resource, undefined); } else { this.focusedAgentSessionArchivedContextKey.reset(); this.focusedAgentSessionReadContextKey.reset(); this.focusedAgentSessionTypeContextKey.reset(); + activeSessionResource.set(undefined, undefined); } const selection = list.getSelection().filter(isAgentSession); @@ -238,6 +332,30 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.updateSectionCollapseStates(); })); + + this._register(list.onDidChangeCollapseState(e => { + if (this._isProgrammaticCollapseChange) { + return; + } + const element = e.node.element?.element; + if (element && isAgentSessionSection(element)) { + this.saveSectionCollapseState(element.section, e.node.collapsed); + } + })); + } + + private updateEmpty(isEmpty: boolean): void { + if (!this.emptyFilterMessage || !this.sessionsList) { + return; + } + + const model = this.agentSessionsService.model; + const hasSessionsInModel = model.sessions.length > 0; + const isFilterActive = !this.options.filter.isDefault(); + + const showEmpty = hasSessionsInModel && isEmpty && isFilterActive; + setVisibility(showEmpty, this.emptyFilterMessage); + setVisibility(!showEmpty, this.sessionsList.getHTMLElement()); } private hasTodaySessions(): boolean { @@ -335,6 +453,19 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo return; } + this._isProgrammaticCollapseChange = true; + try { + this._updateSectionCollapseStatesCore(); + } finally { + this._isProgrammaticCollapseChange = false; + } + } + + private _updateSectionCollapseStatesCore(): void { + if (!this.sessionsList) { + return; + } + const model = this.agentSessionsService.model; for (const child of this.sessionsList.getNode(model).children) { if (!isAgentSessionSection(child.element)) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 65bfc186494..5cb399a6c87 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -17,7 +17,8 @@ import { IAgentSessionsFilter, IAgentSessionsFilterExcludes } from './agentSessi export enum AgentSessionsGrouping { Capped = 'capped', - Date = 'date' + Date = 'date', + Repository = 'repository' } export interface IAgentSessionsFilterOptions extends Partial { @@ -287,7 +288,7 @@ export class AgentSessionsFilter extends Disposable implements Required; getSession(resource: URI): IAgentSession | undefined; } @@ -20,11 +22,14 @@ export interface IAgentSessionsService { export class AgentSessionsService extends Disposable implements IAgentSessionsService { declare readonly _serviceBrand: undefined; + private readonly _onDidChangeSessionArchivedState = this._register(new Emitter()); + readonly onDidChangeSessionArchivedState = this._onDidChangeSessionArchivedState.event; private _model: IAgentSessionsModel | undefined; get model(): IAgentSessionsModel { if (!this._model) { this._model = this._register(this.instantiationService.createInstance(AgentSessionsModel)); + this._register(this._model.onDidChangeSessionArchivedState(session => this._onDidChangeSessionArchivedState.fire(session))); this._model.resolve(undefined /* all providers */); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index eb1748803f0..d6dc9c07382 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -37,12 +37,19 @@ import { MenuId } from '../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; import { MarkdownString, IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { AgentSessionHoverWidget } from './agentSessionHoverWidget.js'; import { AgentSessionProviders, getAgentSessionTime } from './agentSessions.js'; import { AgentSessionsGrouping } from './agentSessionsFilter.js'; +import { autorun, IObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; +import { BugIndicatingError } from '../../../../../base/common/errors.js'; + export type AgentSessionListItem = IAgentSession | IAgentSessionSection; @@ -70,6 +77,11 @@ interface IAgentSessionItemTemplate { readonly separator: HTMLElement; readonly description: HTMLElement; + // Approval row + readonly approvalRow: HTMLElement; + readonly approvalLabel: HTMLElement; + readonly approvalButtonContainer: HTMLElement; + readonly contextKeyService: IContextKeyService; readonly elementDisposable: DisposableStore; readonly disposables: IDisposable; @@ -77,6 +89,7 @@ interface IAgentSessionItemTemplate { export interface IAgentSessionRendererOptions { readonly disableHover?: boolean; + readonly showIsolationIcon?: boolean; getHoverPosition(): HoverPosition; } @@ -84,12 +97,26 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre static readonly TEMPLATE_ID = 'agent-session'; + static readonly APPROVAL_ROW_MAX_LINES = 3; + private static readonly _APPROVAL_ROW_LINE_HEIGHT = 18; + private static readonly _APPROVAL_ROW_OVERHEAD = 14; // 4px margin-top + 4px padding-top + 4px padding-bottom + 2px border + + static getApprovalRowHeight(label: string): number { + const lineCount = Math.min(label.split(/\r?\n/).length, AgentSessionRenderer.APPROVAL_ROW_MAX_LINES); + return lineCount * AgentSessionRenderer._APPROVAL_ROW_LINE_HEIGHT + AgentSessionRenderer._APPROVAL_ROW_OVERHEAD; + } + readonly templateId = AgentSessionRenderer.TEMPLATE_ID; private readonly sessionHover = this._register(new MutableDisposable()); + private readonly _onDidChangeItemHeight = this._register(new Emitter()); + readonly onDidChangeItemHeight: Event = this._onDidChangeItemHeight.event; + constructor( private readonly options: IAgentSessionRendererOptions, + private readonly _approvalModel: AgentSessionApprovalModel | undefined, + private readonly _activeSessionResource: IObservable, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IProductService private readonly productService: IProductService, @IHoverService private readonly hoverService: IHoverService, @@ -129,6 +156,10 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre h('span.agent-session-status-time@statusTime') ]), ]), + ]), + h('div.agent-session-approval-row@approvalRow', [ + h('span.agent-session-approval-label@approvalLabel'), + h('div.agent-session-approval-button@approvalButtonContainer'), ]) ]) ] @@ -156,6 +187,9 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre statusContainer: elements.statusContainer, statusProviderIcon: elements.statusProviderIcon, statusTime: elements.statusTime, + approvalRow: elements.approvalRow, + approvalLabel: elements.approvalLabel, + approvalButtonContainer: elements.approvalButtonContainer, contextKeyService, elementDisposable, disposables @@ -229,6 +263,11 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre // Hover this.renderHover(session, template); + + // Approval row + if (this._approvalModel) { + this.renderApprovalRow(session, template); + } } private renderBadge(session: ITreeNode, template: IAgentSessionItemTemplate): boolean { @@ -353,8 +392,17 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre }; // Provider icon (only shown for non-local sessions) + // When showIsolationIcon is enabled for background sessions, show worktree/folder icon instead const isLocal = session.element.providerType === AgentSessionProviders.Local; - template.statusProviderIcon.className = isLocal ? '' : `agent-session-status-provider-icon ${ThemeIcon.asClassName(session.element.icon)}`; + if (isLocal) { + template.statusProviderIcon.className = ''; + } else if (this.options.showIsolationIcon && session.element.providerType === AgentSessionProviders.Background) { + const hasWorktree = typeof session.element.metadata?.worktreePath === 'string'; + const isolationIcon = hasWorktree ? Codicon.worktree : Codicon.folder; + template.statusProviderIcon.className = `agent-session-status-provider-icon ${ThemeIcon.asClassName(isolationIcon)}`; + } else { + template.statusProviderIcon.className = `agent-session-status-provider-icon ${ThemeIcon.asClassName(session.element.icon)}`; + } // Time label template.statusTime.textContent = getTimeLabel(session.element); @@ -396,6 +444,68 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre }; } + private renderApprovalRow(session: ITreeNode, template: IAgentSessionItemTemplate): void { + if (this._approvalModel === undefined) { + throw new BugIndicatingError('Approval model is required to render approval row'); + } + + const approvalModel = this._approvalModel; + // Initialize from current model state to avoid unnecessary height changes on first render + const initialInfo = approvalModel.getApproval(session.element.resource).get(); + let wasVisible = !!initialInfo; + template.approvalRow.classList.toggle('visible', wasVisible); + + const buttonStore = template.elementDisposable.add(new DisposableStore()); + + template.elementDisposable.add(autorun(reader => { + buttonStore.clear(); + + const info = approvalModel.getApproval(session.element.resource).read(reader); + const visible = !!info; + + template.approvalRow.classList.toggle('visible', visible); + + if (info) { + // Render up to 3 lines, each as a separate code block so CSS can truncate per-line + const lines = info.label.split('\n'); + const maxLines = AgentSessionRenderer.APPROVAL_ROW_MAX_LINES; + const visibleLines = lines.slice(0, maxLines); + if (lines.length > maxLines) { + visibleLines[maxLines - 1] = `${visibleLines[maxLines - 1]} \u2026`; + } + const langId = info.languageId ?? 'json'; + const labelContent = new MarkdownString(); + for (const line of visibleLines) { + labelContent.appendCodeblock(langId, line); + } + this.renderMarkdownOrText(labelContent, template.approvalLabel, buttonStore); + + // Hover with full content as a code block + const fullContent = new MarkdownString().appendCodeblock(info.languageId ?? 'json', info.label); + buttonStore.add(this.hoverService.setupDelayedHover(template.approvalLabel, { + content: fullContent, + style: HoverStyle.Pointer, + position: { hoverPosition: HoverPosition.BELOW }, + })); + + template.approvalButtonContainer.textContent = ''; + const isActive = this._activeSessionResource.read(reader)?.toString() === session.element.resource.toString(); + const button = buttonStore.add(new Button(template.approvalButtonContainer, { + title: localize('allowActionOnce', "Allow once"), + secondary: isActive, + ...defaultButtonStyles + })); + button.label = localize('allowAction', "Allow"); + buttonStore.add(button.onDidClick(() => info.confirm())); + } + + if (wasVisible !== visible) { + wasVisible = visible; + this._onDidChangeItemHeight.fire(session.element); + } + })); + } + renderCompressedElements(node: ITreeNode, FuzzyScore>, index: number, templateData: IAgentSessionItemTemplate, details?: ITreeElementRenderDetails): void { throw new Error('Should never happen since session is incompressible'); } @@ -509,12 +619,23 @@ export class AgentSessionsListDelegate implements IListVirtualDelegate { +export class AgentSessionsDataSource extends Disposable implements IAsyncDataSource { private static readonly CAPPED_SESSIONS_LIMIT = 3; + private readonly _onDidGetChildren = this._register(new Emitter()); + readonly onDidGetChildren: Event = this._onDidGetChildren.event; + constructor( private readonly filter: IAgentSessionsFilter | undefined, private readonly sorter: ITreeSorter, - ) { } + ) { + super(); + } hasChildren(element: IAgentSessionsModel | AgentSessionListItem): boolean { @@ -641,6 +777,7 @@ export class AgentSessionsDataSource implements IAsyncDataSource(); + const archivedSessions: IAgentSession[] = []; + const noRepoId = 'other'; + const noRepoLabel = localize('agentSessions.noRepository', "Other"); + + for (const session of sortedSessions) { + if (session.isArchived()) { + archivedSessions.push(session); + continue; + } + + const repo = this.getRepositoryInfo(session); + const repoId = repo?.id ?? noRepoId; + const repoLabel = repo?.label ?? noRepoLabel; + + let group = repoMap.get(repoId); + if (!group) { + group = { label: repoLabel, sessions: [] }; + repoMap.set(repoId, group); + } + group.sessions.push(session); + } + + const result: AgentSessionListItem[] = []; + for (const [, { label, sessions }] of repoMap) { + result.push({ + section: AgentSessionSection.Repository, + label, + sessions, + }); + } + + if (archivedSessions.length > 0) { + result.push({ + section: AgentSessionSection.Archived, + label: AgentSessionSectionLabels[AgentSessionSection.Archived], + sessions: archivedSessions, + }); + } + + return result; + } + + private getRepositoryInfo(session: IAgentSession): { id: string; label: string } | undefined { + const metadata = session.metadata; + if (metadata) { + // Cloud sessions: metadata.owner + metadata.name + const owner = metadata.owner as string | undefined; + const name = metadata.name as string | undefined; + if (owner && name) { + return { id: `${owner}/${name}`, label: name }; + } + + // repositoryNwo: "owner/repo" + const nwo = metadata.repositoryNwo as string | undefined; + if (nwo && nwo.includes('/')) { + return { id: nwo, label: nwo.split('/').pop()! }; + } + + // repository: could be "owner/repo" or a URL + const repository = metadata.repository as string | undefined; + if (repository) { + if (repository.includes('/') && !repository.includes(':')) { + return { id: repository, label: repository.split('/').pop()! }; + } + try { + const url = new URL(repository); + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 2) { + const id = `${parts[0]}/${parts[1]}`; + return { id, label: parts[1] }; + } + } catch { + // not a URL + } + } + + // repositoryUrl: "https://github.com/owner/repo" + const repositoryUrl = metadata.repositoryUrl as string | undefined; + if (repositoryUrl) { + try { + const url = new URL(repositoryUrl); + const parts = url.pathname.split('/').filter(Boolean); + if (parts.length >= 2) { + const id = `${parts[0]}/${parts[1]}`; + return { id, label: parts[1] }; + } + } catch { + // not a URL + } + } + } + + // Fallback: extract repo name from badge if it uses the $(repo) icon + const badge = session.badge; + if (badge) { + const raw = typeof badge === 'string' ? badge : badge.value; + const repoMatch = raw.match(/\$\(repo\)\s*(.+)/); + if (repoMatch) { + const label = repoMatch[1].trim(); + return { id: label, label }; + } + } + + return undefined; + } } export const AgentSessionSectionLabels = { @@ -793,7 +1040,7 @@ export class AgentSessionsIdentityProvider implements IIdentityProvider { const session = e.sessionResource.filter(resource => getChatSessionType(resource) === this.chatSessionType); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index 3a0329d0f3c..913a82d0e39 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -251,6 +251,53 @@ } } } + .agent-session-approval-row { + display: none; + align-items: flex-end; + gap: 8px; + margin-top: 4px; + padding: 4px 8px; + box-sizing: border-box; + border: 1px solid var(--vscode-contrastBorder, var(--vscode-widget-border, transparent)); + border-radius: 4px; + background-color: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + + &.visible { + display: flex; + } + + .agent-session-approval-label { + flex: 1; + overflow: hidden; + min-width: 0; + + & > .rendered-markdown, + & > .rendered-markdown > .code, + & > .rendered-markdown > .code > span { + display: block; + overflow: hidden; + } + + .monaco-tokenized-source { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: 12px; + } + } + + .agent-session-approval-button { + flex-shrink: 0; + + .monaco-button { + padding: 2px 10px; + font-size: 12px; + white-space: nowrap; + } + } + } } .agent-session-section { @@ -297,4 +344,26 @@ .monaco-list:not(:focus) .monaco-list-row.selected .agent-session-section-label { color: var(--vscode-list-inactiveSelectionForeground); } + + .agent-sessions-empty-filter-message { + padding: 8px 12px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + + .reset-filter-link { + color: var(--vscode-textLink-foreground); + cursor: pointer; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &:focus-visible { + text-decoration: underline; + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; + } + } + } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts index 4d2fd7a57eb..2d2d087fac3 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts @@ -66,3 +66,13 @@ export const extensionIcon = registerIcon('ai-customization-extension', Codicon. * Icon for plugin storage. */ export const pluginIcon = registerIcon('ai-customization-plugin', Codicon.plug, localize('aiCustomizationPluginIcon', "Icon for plugin-contributed items.")); + +/** + * Icon for built-in storage. + */ +export const builtinIcon = registerIcon('ai-customization-builtin', Codicon.starFull, localize('aiCustomizationBuiltinIcon', "Icon for built-in items.")); + +/** + * Icon for MCP servers. + */ +export const mcpServerIcon = registerIcon('ai-customization-mcp-server', Codicon.server, localize('aiCustomizationMcpServerIcon', "Icon for MCP servers.")); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 9f3f4492db7..96873795ec8 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -19,8 +19,8 @@ import { WorkbenchList } from '../../../../../platform/list/browser/listService. import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../../base/browser/ui/list/list.js'; import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon } from './aiCustomizationIcons.js'; -import { AICustomizationManagementItemMenuId, AICustomizationManagementSection } from './aiCustomizationManagement.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon, builtinIcon } from './aiCustomizationIcons.js'; +import { AICustomizationManagementItemMenuId, AICustomizationManagementSection, BUILTIN_STORAGE } from './aiCustomizationManagement.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { Delayer } from '../../../../../base/common/async.js'; @@ -37,13 +37,13 @@ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IAICustomizationWorkspaceService, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; import { Action, Separator } from '../../../../../base/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; -import { ISCMService } from '../../../scm/common/scm.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js'; import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; -import { HOOK_TYPES, formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; +import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; +import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Schemas } from '../../../../../base/common/network.js'; import { OS } from '../../../../../base/common/platform.js'; @@ -51,7 +51,7 @@ import { OS } from '../../../../../base/common/platform.js'; const $ = DOM.$; const ITEM_HEIGHT = 44; -const GROUP_HEADER_HEIGHT = 32; +const GROUP_HEADER_HEIGHT = 36; const GROUP_HEADER_HEIGHT_WITH_SEPARATOR = 40; /** @@ -65,7 +65,8 @@ export interface IAICustomizationListItem { readonly description?: string; readonly storage: PromptsStorage; readonly promptType: PromptsType; - gitStatus?: 'uncommitted' | 'committed'; + /** When set, overrides `storage` for display grouping purposes. */ + readonly groupKey?: string; nameMatches?: IMatch[]; descriptionMatches?: IMatch[]; } @@ -76,7 +77,7 @@ export interface IAICustomizationListItem { interface IGroupHeaderEntry { readonly type: 'group-header'; readonly id: string; - readonly storage: PromptsStorage; + readonly groupKey: string; readonly label: string; readonly icon: ThemeIcon; readonly count: number; @@ -114,10 +115,9 @@ class AICustomizationListDelegate implements IListVirtualDelegate { interface IAICustomizationItemTemplateData { readonly container: HTMLElement; readonly actionsContainer: HTMLElement; + readonly typeIcon: HTMLElement; readonly nameLabel: HighlightedLabel; readonly description: HighlightedLabel; - readonly storageBadge: HTMLElement; - readonly gitStatusBadge: HTMLElement; readonly disposables: DisposableStore; readonly elementDisposables: DisposableStore; } @@ -195,6 +195,47 @@ class GroupHeaderRenderer implements IListRenderer c.toUpperCase()); +} + +/** + * Truncates a description string to the first sentence, with a maximum character fallback. + */ +export function truncateToFirstSentence(text: string, maxChars = 120): string { + const match = text.match(/^[^.!?]*[.!?]/); + if (match && match[0].length <= maxChars) { + return match[0]; + } + if (text.length > maxChars) { + return text.substring(0, maxChars).trimEnd() + '\u2026'; + } + return text; +} + /** * Renderer for AI customization list items. */ @@ -213,25 +254,20 @@ class AICustomizationItemRenderer implements IListRenderer { const uriLabel = this.labelService.getUriLabel(element.uri, { relative: false }); @@ -253,11 +293,12 @@ class AICustomizationItemRenderer implements IListRenderer(); + private readonly collapsedGroups = new Set(); private readonly dropdownActionDisposables = this._register(new DisposableStore()); private readonly delayedFilter = new Delayer(200); @@ -389,7 +391,6 @@ export class AICustomizationListWidget extends Disposable { @ILabelService private readonly labelService: ILabelService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IClipboardService private readonly clipboardService: IClipboardService, - @ISCMService private readonly scmService: ISCMService, @IHoverService private readonly hoverService: IHoverService, @IFileService private readonly fileService: IFileService, @IPathService private readonly pathService: IPathService, @@ -405,18 +406,6 @@ export class AICustomizationListWidget extends Disposable { this.refresh(); })); - // Re-filter when SCM repositories change (updates git status badges after commits) - const trackRepoChanges = (repo: { provider: { onDidChangeResources: Event } }) => { - this._register(repo.provider.onDidChangeResources(() => { - this.updateGitStatus(this.allItems); - this.filterItems(); - })); - }; - for (const repo of [...this.scmService.repositories]) { - trackRepoChanges(repo); - } - this._register(this.scmService.onDidAddRepository(repo => trackRepoChanges(repo))); - } private create(): void { @@ -785,7 +774,8 @@ export class AICustomizationListWidget extends Disposable { * Loads items for the current section. */ private async loadItems(): Promise { - const promptType = sectionToPromptType(this.currentSection); + const section = this.currentSection; + const promptType = sectionToPromptType(section); const items: IAICustomizationListItem[] = []; @@ -856,7 +846,7 @@ export class AICustomizationListWidget extends Disposable { if (hooks.size > 0) { parsedHooks = true; for (const [hookType, entry] of hooks) { - const hookMeta = HOOK_TYPES.find(h => h.id === hookType); + const hookMeta = HOOK_METADATA[hookType]; for (let i = 0; i < entry.hooks.length; i++) { const hook = entry.hooks[i]; const cmdLabel = formatHookCommandLabel(hook, OS); @@ -889,6 +879,37 @@ export class AICustomizationListWidget extends Disposable { }); } } + + // Also include hooks defined in agent frontmatter (not in sessions window) + // TODO: add this back when Copilot CLI supports this + const agents = !this.workspaceService.isSessionsWindow ? await this.promptsService.getCustomAgents(CancellationToken.None) : []; + for (const agent of agents) { + if (!agent.hooks) { + continue; + } + for (const hookType of Object.values(HookType)) { + const hookCommands = agent.hooks[hookType]; + if (!hookCommands || hookCommands.length === 0) { + continue; + } + const hookMeta = HOOK_METADATA[hookType]; + for (let i = 0; i < hookCommands.length; i++) { + const hook = hookCommands[i]; + const cmdLabel = formatHookCommandLabel(hook, OS); + const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; + items.push({ + id: `${agent.uri.toString()}#hook:${hookType}[${i}]`, + uri: agent.uri, + name: hookMeta?.label ?? hookType, + filename: basename(agent.uri), + description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`, + storage: agent.source.storage, + groupKey: 'agents', + promptType, + }); + } + } + } } else { // For instructions, fetch prompt files and group by storage const promptFiles = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); @@ -917,6 +938,7 @@ export class AICustomizationListWidget extends Disposable { const userItems = allItems.filter(item => item.storage === PromptsStorage.user); const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); const pluginItems = allItems.filter(item => item.storage === PromptsStorage.plugin); + const builtinItems = allItems.filter(item => item.storage === BUILTIN_STORAGE); const mapToListItem = (item: IPromptPath): IAICustomizationListItem => { const filename = basename(item.uri); @@ -937,6 +959,7 @@ export class AICustomizationListWidget extends Disposable { items.push(...userItems.map(mapToListItem)); items.push(...extensionItems.map(mapToListItem)); items.push(...pluginItems.map(mapToListItem)); + items.push(...builtinItems.map(mapToListItem)); } // Apply storage source filter (removes items not in visible sources or excluded user roots) @@ -948,36 +971,15 @@ export class AICustomizationListWidget extends Disposable { // Sort items by name items.sort((a, b) => a.name.localeCompare(b.name)); - // Set git status for workspace (local) items - this.updateGitStatus(items); + if (this.currentSection !== section) { + return; // section changed while loading + } this.allItems = items; this.filterItems(); this._onDidChangeItemCount.fire(items.length); } - /** - * Updates git status on local workspace items by checking SCM resource groups. - * Files found in resource groups have uncommitted changes; others are committed. - */ - private updateGitStatus(items: IAICustomizationListItem[]): void { - // Build a set of URIs that have uncommitted changes in SCM - const uncommittedUris = new Set(); - for (const repo of [...this.scmService.repositories]) { - for (const group of repo.provider.groups) { - for (const resource of group.resources) { - uncommittedUris.add(resource.sourceUri.toString()); - } - } - } - - for (const item of items) { - if (item.storage === PromptsStorage.local) { - item.gitStatus = uncommittedUris.has(item.uri.toString()) ? 'uncommitted' : 'committed'; - } - } - } - /** * Derives a friendly name from a filename by removing extension suffixes. */ @@ -1010,7 +1012,10 @@ export class AICustomizationListWidget extends Disposable { matchedItems = []; for (const item of this.allItems) { - const nameMatches = matchesContiguousSubString(query, item.name); + // Compute matches against the formatted display name so highlight positions + // are correct even after .md stripping and title-casing. + const displayName = formatDisplayName(item.name); + const nameMatches = matchesContiguousSubString(query, displayName); const descriptionMatches = item.description ? matchesContiguousSubString(query, item.description) : null; const filenameMatches = matchesContiguousSubString(query, item.filename); @@ -1027,15 +1032,18 @@ export class AICustomizationListWidget extends Disposable { // Group items by storage const promptType = sectionToPromptType(this.currentSection); const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources); - const groups: { storage: PromptsStorage; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [ - { storage: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, - { storage: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, - { storage: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, - { storage: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, - ].filter(g => visibleSources.has(g.storage)); + const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [ + { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, + { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, + { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, + { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, + { groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, + { groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] }, + ].filter(g => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents'); for (const item of matchedItems) { - const group = groups.find(g => g.storage === item.storage); + const key = item.groupKey ?? item.storage; + const group = groups.find(g => g.groupKey === key); if (group) { group.items.push(item); } @@ -1054,12 +1062,12 @@ export class AICustomizationListWidget extends Disposable { continue; } - const collapsed = this.collapsedGroups.has(group.storage); + const collapsed = this.collapsedGroups.has(group.groupKey); this.displayEntries.push({ type: 'group-header', - id: `group-${group.storage}`, - storage: group.storage, + id: `group-${group.groupKey}`, + groupKey: group.groupKey, label: group.label, icon: group.icon, count: group.items.length, @@ -1084,10 +1092,10 @@ export class AICustomizationListWidget extends Disposable { * Toggles the collapsed state of a group. */ private toggleGroup(entry: IGroupHeaderEntry): void { - if (this.collapsedGroups.has(entry.storage)) { - this.collapsedGroups.delete(entry.storage); + if (this.collapsedGroups.has(entry.groupKey)) { + this.collapsedGroups.delete(entry.groupKey); } else { - this.collapsedGroups.add(entry.storage); + this.collapsedGroups.add(entry.groupKey); } this.filterItems(); } @@ -1201,14 +1209,28 @@ export class AICustomizationListWidget extends Disposable { * Layouts the widget. */ layout(height: number, width: number): void { - const sectionFooterHeight = this.sectionHeader.offsetHeight || 100; - const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 40; - const margins = 12; // search margin (6+6), not included in offsetHeight - const listHeight = height - sectionFooterHeight - searchBarHeight - margins; + const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; + const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; + const listHeight = height - sectionFooterHeight - searchBarHeight; this.searchInput.layout(); this.listContainer.style.height = `${Math.max(0, listHeight)}px`; this.list.layout(Math.max(0, listHeight), width); + + // Re-layout once after footer renders if we used a zero fallback + if (sectionFooterHeight === 0) { + DOM.getWindow(this.listContainer).requestAnimationFrame(() => { + if (this._store.isDisposed) { + return; + } + const actualFooterHeight = this.sectionHeader.offsetHeight; + if (actualFooterHeight > 0) { + const correctedHeight = height - actualFooterHeight - searchBarHeight; + this.listContainer.style.height = `${Math.max(0, correctedHeight)}px`; + this.list.layout(Math.max(0, correctedHeight), width); + } + }); + } } /** diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index 7cdad4e6440..7011d2094f6 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -6,7 +6,6 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; @@ -31,6 +30,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ChatConfiguration } from '../../common/constants.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -234,6 +234,15 @@ registerAction2(class extends Action2 { // since each skill is a folder containing SKILL.md. const deleteTarget = isSkill ? dirname(uri) : uri; await fileService.del(deleteTarget, { useTrash: true, recursive: isSkill }); + + // Commit the deletion to git (sessions: main repo + worktree) + if (storage === PromptsStorage.local) { + const workspaceService = accessor.get(IAICustomizationWorkspaceService); + const projectRoot = workspaceService.getActiveProjectRoot(); + if (projectRoot) { + await workspaceService.deleteFiles(projectRoot, [deleteTarget]); + } + } } } }); @@ -308,26 +317,6 @@ class AICustomizationManagementActionsContribution extends Disposable implements } })); - // Toggle Debug Panel in AI Customizations Editor - this._register(registerAction2(class extends Action2 { - constructor() { - super({ - id: AICustomizationManagementCommands.ToggleDebug, - title: localize2('toggleDebugPanel', "Customizations Debug"), - category: Categories.Developer, - f1: true, - }); - } - - async run(accessor: ServicesAccessor): Promise { - const editorService = accessor.get(IEditorService); - const pane = editorService.activeEditorPane; - if (pane instanceof AICustomizationManagementEditor) { - const report = await (pane as AICustomizationManagementEditor).generateDebugReport(); - await editorService.openEditor({ resource: undefined, contents: report, options: { pinned: false } }); - } - } - })); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index 55da7d7eb79..e9ed6863b8f 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -5,12 +5,24 @@ import { RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; // Re-export for convenience — consumers import from this file export { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; +/** + * Extended storage type for AI Customization that includes built-in prompts + * shipped with the application, alongside the core `PromptsStorage` values. + */ +export type AICustomizationPromptsStorage = PromptsStorage | 'builtin'; + +/** + * Storage type discriminator for built-in prompts shipped with the application. + */ +export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; + /** * Editor pane ID for the AI Customizations Management Editor. */ @@ -26,7 +38,6 @@ export const AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID = 'workbench.input.aiCu */ export const AICustomizationManagementCommands = { OpenEditor: 'aiCustomization.openManagementEditor', - ToggleDebug: 'aiCustomization.toggleDebugPanel', CreateNewAgent: 'aiCustomization.createNewAgent', CreateNewSkill: 'aiCustomization.createNewSkill', CreateNewInstructions: 'aiCustomization.createNewInstructions', diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 7b3d437b0ef..8235c4644bb 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -26,18 +26,20 @@ import { IListVirtualDelegate, IListRenderer } from '../../../../../base/browser import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; -import { basename, isEqual, joinPath } from '../../../../../base/common/resources.js'; +import { basename, isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { registerColor } from '../../../../../platform/theme/common/colorRegistry.js'; import { PANEL_BORDER } from '../../../../common/theme.js'; import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; import { AICustomizationListWidget } from './aiCustomizationListWidget.js'; import { McpListWidget } from './mcpListWidget.js'; +import { PluginListWidget } from './pluginListWidget.js'; import { AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY, AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, AICustomizationManagementSection, + BUILTIN_STORAGE, CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR, CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION, SIDEBAR_DEFAULT_WIDTH, @@ -45,9 +47,9 @@ import { SIDEBAR_MAX_WIDTH, CONTENT_MIN_WIDTH, } from './aiCustomizationManagement.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon } from './aiCustomizationIcons.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, pluginIcon } from './aiCustomizationIcons.js'; import { ChatModelsWidget } from '../chatManagement/chatModelsWidget.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { INewPromptOptions, NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../promptSyntax/newPromptFileActions.js'; import { showConfigureHooksQuickPick } from '../promptSyntax/hookActions.js'; @@ -60,16 +62,15 @@ import { IConfigurationService } from '../../../../../platform/configuration/com import { getSimpleEditorOptions } from '../../../codeEditor/browser/simpleEditorOptions.js'; import { IWorkingCopyService } from '../../../../services/workingCopy/common/workingCopyService.js'; import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; -import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { HOOKS_SOURCE_FOLDER } from '../../common/promptSyntax/config/promptFileLocations.js'; -import { COPILOT_CLI_HOOK_TYPE_MAP } from '../../common/promptSyntax/hookSchema.js'; import { McpServerEditorInput } from '../../../mcp/browser/mcpServerEditorInput.js'; import { McpServerEditor } from '../../../mcp/browser/mcpServerEditor.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IWorkbenchMcpServer } from '../../../mcp/common/mcpTypes.js'; +import { AgentPluginEditor } from '../agentPluginEditor/agentPluginEditor.js'; +import { AgentPluginEditorInput } from '../agentPluginEditor/agentPluginEditorInput.js'; +import { IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; const $ = DOM.$; @@ -141,9 +142,11 @@ export class AICustomizationManagementEditor extends EditorPane { private contentContainer!: HTMLElement; private listWidget!: AICustomizationListWidget; private mcpListWidget: McpListWidget | undefined; + private pluginListWidget: PluginListWidget | undefined; private modelsWidget: ChatModelsWidget | undefined; private promptsContentContainer!: HTMLElement; private mcpContentContainer: HTMLElement | undefined; + private pluginContentContainer: HTMLElement | undefined; private modelsContentContainer: HTMLElement | undefined; private modelsFooterElement: HTMLElement | undefined; @@ -157,13 +160,18 @@ export class AICustomizationManagementEditor extends EditorPane { private currentEditingUri: URI | undefined; private currentEditingProjectRoot: URI | undefined; private currentModelRef: IReference | undefined; - private viewMode: 'list' | 'editor' | 'mcpDetail' = 'list'; + private viewMode: 'list' | 'editor' | 'mcpDetail' | 'pluginDetail' = 'list'; // Embedded MCP server detail view private mcpDetailContainer: HTMLElement | undefined; private embeddedMcpEditor: McpServerEditor | undefined; private readonly mcpDetailDisposables = this._register(new DisposableStore()); + // Embedded plugin detail view + private pluginDetailContainer: HTMLElement | undefined; + private embeddedPluginEditor: AgentPluginEditor | undefined; + private readonly pluginDetailDisposables = this._register(new DisposableStore()); + private dimension: DOM.Dimension | undefined; private readonly sections: ISectionItem[] = []; private selectedSection: AICustomizationManagementSection = AICustomizationManagementSection.Agents; @@ -194,7 +202,6 @@ export class AICustomizationManagementEditor extends EditorPane { @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkingCopyService private readonly workingCopyService: IWorkingCopyService, @ITextFileService private readonly textFileService: ITextFileService, - @IFileService private readonly fileService: IFileService, @IFileDialogService private readonly fileDialogService: IFileDialogService, @IHoverService private readonly hoverService: IHoverService, ) { @@ -223,6 +230,7 @@ export class AICustomizationManagementEditor extends EditorPane { [AICustomizationManagementSection.Prompts]: { label: localize('prompts', "Prompts"), icon: promptIcon }, [AICustomizationManagementSection.Hooks]: { label: localize('hooks', "Hooks"), icon: hookIcon }, [AICustomizationManagementSection.McpServers]: { label: localize('mcpServers', "MCP Servers"), icon: Codicon.server }, + [AICustomizationManagementSection.Plugins]: { label: localize('plugins', "Plugins"), icon: pluginIcon }, [AICustomizationManagementSection.Models]: { label: localize('models', "Models"), icon: Codicon.vm }, }; for (const id of this.workspaceService.managementSections) { @@ -292,6 +300,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (height !== undefined) { this.listWidget.layout(height - 16, width - 24); this.mcpListWidget?.layout(height - 16, width - 24); + this.pluginListWidget?.layout(height - 16, width - 24); const modelsFooterHeight = this.modelsFooterElement?.offsetHeight || 80; this.modelsWidget?.layout(height - 16 - modelsFooterHeight, width); if (this.viewMode === 'editor' && this.embeddedEditor) { @@ -303,6 +312,10 @@ export class AICustomizationManagementEditor extends EditorPane { const backHeaderHeight = 40; this.embeddedMcpEditor.layout(new DOM.Dimension(width, Math.max(0, height - backHeaderHeight))); } + if (this.viewMode === 'pluginDetail' && this.embeddedPluginEditor) { + const backHeaderHeight = 40; + this.embeddedPluginEditor.layout(new DOM.Dimension(width, Math.max(0, height - backHeaderHeight))); + } } }, }, Sizing.Distribute, undefined, true); @@ -349,17 +362,14 @@ export class AICustomizationManagementEditor extends EditorPane { )); this.sectionsList.splice(0, this.sectionsList.length, this.sections); - - // Select the saved section - const selectedIndex = this.sections.findIndex(s => s.id === this.selectedSection); - if (selectedIndex >= 0) { - this.sectionsList.setSelection([selectedIndex]); - } + this.ensureSectionsListReflectsActiveSection(); this.editorDisposables.add(this.sectionsList.onDidChangeSelection(e => { - if (e.elements.length > 0) { - this.selectSection(e.elements[0].id); + if (e.elements.length === 0) { + this.ensureSectionsListReflectsActiveSection(); + return; } + this.selectSection(e.elements[0].id); })); // Folder picker (sessions window only) @@ -440,7 +450,7 @@ export class AICustomizationManagementEditor extends EditorPane { // Handle item selection this.editorDisposables.add(this.listWidget.onDidSelectItem(item => { const isWorkspaceFile = item.storage === PromptsStorage.local; - const isReadOnly = item.storage === PromptsStorage.extension || item.storage === PromptsStorage.plugin; + const isReadOnly = item.storage === PromptsStorage.extension || item.storage === PromptsStorage.plugin || item.storage === BUILTIN_STORAGE; this.showEmbeddedEditor(item.uri, item.name, isWorkspaceFile, isReadOnly); })); @@ -488,6 +498,21 @@ export class AICustomizationManagementEditor extends EditorPane { })); } + // Container for Plugins content + if (hasSections.has(AICustomizationManagementSection.Plugins)) { + this.pluginContentContainer = DOM.append(contentInner, $('.plugin-content-container')); + this.pluginListWidget = this.editorDisposables.add(this.instantiationService.createInstance(PluginListWidget)); + this.pluginContentContainer.appendChild(this.pluginListWidget.element); + + // Embedded plugin detail view + this.pluginDetailContainer = DOM.append(contentInner, $('.plugin-detail-container')); + this.createEmbeddedPluginDetail(); + + this.editorDisposables.add(this.pluginListWidget.onDidSelectPlugin(item => { + this.showEmbeddedPluginDetail(item); + })); + } + // Embedded editor container this.editorContentContainer = DOM.append(contentInner, $('.editor-content-container')); this.createEmbeddedEditor(); @@ -511,6 +536,7 @@ export class AICustomizationManagementEditor extends EditorPane { private selectSection(section: AICustomizationManagementSection): void { if (this.selectedSection === section) { + this.ensureSectionsListReflectsActiveSection(section); return; } @@ -520,6 +546,9 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.viewMode === 'mcpDetail') { this.goBackFromMcpDetail(); } + if (this.viewMode === 'pluginDetail') { + this.goBackFromPluginDetail(); + } this.selectedSection = section; this.sectionContextKey.set(section); @@ -534,25 +563,57 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.isPromptsSection(section)) { void this.listWidget.setSection(section); } + + this.ensureSectionsListReflectsActiveSection(section); + } + + private ensureSectionsListReflectsActiveSection(section: AICustomizationManagementSection = this.selectedSection): void { + if (!this.sectionsList) { + return; + } + + const index = this.sections.findIndex(s => s.id === section); + if (index < 0) { + return; + } + + const selection = this.sectionsList.getSelection(); + if (selection.length !== 1 || selection[0] !== index) { + this.sectionsList.setSelection([index]); + } + + const focus = this.sectionsList.getFocus(); + if (focus.length !== 1 || focus[0] !== index) { + this.sectionsList.setFocus([index]); + } } private updateContentVisibility(): void { const isEditorMode = this.viewMode === 'editor'; const isMcpDetailMode = this.viewMode === 'mcpDetail'; + const isPluginDetailMode = this.viewMode === 'pluginDetail'; + const isDetailMode = isMcpDetailMode || isPluginDetailMode; const isPromptsSection = this.isPromptsSection(this.selectedSection); const isModelsSection = this.selectedSection === AICustomizationManagementSection.Models; const isMcpSection = this.selectedSection === AICustomizationManagementSection.McpServers; + const isPluginsSection = this.selectedSection === AICustomizationManagementSection.Plugins; - this.promptsContentContainer.style.display = !isEditorMode && !isMcpDetailMode && isPromptsSection ? '' : 'none'; + this.promptsContentContainer.style.display = !isEditorMode && !isDetailMode && isPromptsSection ? '' : 'none'; if (this.modelsContentContainer) { - this.modelsContentContainer.style.display = !isEditorMode && !isMcpDetailMode && isModelsSection ? '' : 'none'; + this.modelsContentContainer.style.display = !isEditorMode && !isDetailMode && isModelsSection ? '' : 'none'; } if (this.mcpContentContainer) { - this.mcpContentContainer.style.display = !isEditorMode && !isMcpDetailMode && isMcpSection ? '' : 'none'; + this.mcpContentContainer.style.display = !isEditorMode && !isDetailMode && isMcpSection ? '' : 'none'; } if (this.mcpDetailContainer) { this.mcpDetailContainer.style.display = isMcpDetailMode ? '' : 'none'; } + if (this.pluginContentContainer) { + this.pluginContentContainer.style.display = !isEditorMode && !isDetailMode && isPluginsSection ? '' : 'none'; + } + if (this.pluginDetailContainer) { + this.pluginDetailContainer.style.display = isPluginDetailMode ? '' : 'none'; + } if (this.editorContentContainer) { this.editorContentContainer.style.display = isEditorMode ? '' : 'none'; } @@ -583,15 +644,21 @@ export class AICustomizationManagementEditor extends EditorPane { if (type === PromptsType.hook) { if (this.workspaceService.isSessionsWindow) { - // Sessions: directly create a Copilot CLI format hooks file - await this.createCopilotCliHookFile(); - } else { - // Core: show the configure hooks quick pick + // Sessions: show hooks filtered to Copilot CLI (GitHub Copilot) hook types await this.instantiationService.invokeFunction(showConfigureHooksQuickPick, { openEditor: async (resource) => { await this.showEmbeddedEditor(resource, basename(resource), true); return; }, + target: Target.GitHubCopilot, + }); + } else { + // Core: use the default core behaviour + await this.instantiationService.invokeFunction(showConfigureHooksQuickPick, { + openEditor: async (resource) => { + await this.showEmbeddedEditor(resource, basename(resource), true); + return; + } }); } return; @@ -624,36 +691,6 @@ export class AICustomizationManagementEditor extends EditorPane { void this.listWidget.refresh(); } - /** - * Ensures a Copilot CLI format hooks file exists (.github/hooks/hooks.json), - * then opens the configure hooks quick pick. - */ - private async createCopilotCliHookFile(): Promise { - const projectRoot = this.workspaceService.getActiveProjectRoot(); - if (!projectRoot) { - return; - } - - const hookFileUri = joinPath(projectRoot, HOOKS_SOURCE_FOLDER, 'hooks.json'); - - // Create the file with all hook events if it doesn't exist - try { - await this.fileService.stat(hookFileUri); - } catch { - // Derive hook event names from the schema so new events are automatically included - const hooks: Record = {}; - for (const eventName of Object.keys(COPILOT_CLI_HOOK_TYPE_MAP)) { - hooks[eventName] = [{ type: 'command', bash: '' }]; - } - const hooksContent = { version: 1, hooks }; - const jsonContent = JSON.stringify(hooksContent, null, '\t'); - await this.fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); - } - - await this.showEmbeddedEditor(hookFileUri, basename(hookFileUri), true); - void this.listWidget.refresh(); - } - override updateStyles(): void { const borderColor = this.theme.getColor(aiCustomizationManagementSashBorder); if (borderColor) { @@ -683,6 +720,9 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.viewMode === 'mcpDetail') { this.goBackFromMcpDetail(); } + if (this.viewMode === 'pluginDetail') { + this.goBackFromPluginDetail(); + } // Clear transient folder override on close this.workspaceService.clearOverrideProjectRoot(); super.clearInput(); @@ -705,6 +745,8 @@ export class AICustomizationManagementEditor extends EditorPane { } if (this.selectedSection === AICustomizationManagementSection.McpServers) { this.mcpListWidget?.focusSearch(); + } else if (this.selectedSection === AICustomizationManagementSection.Plugins) { + this.pluginListWidget?.focusSearch(); } else if (this.selectedSection === AICustomizationManagementSection.Models) { this.modelsWidget?.focusSearch(); } else { @@ -727,6 +769,9 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.viewMode === 'mcpDetail') { this.goBackFromMcpDetail(); } + if (this.viewMode === 'pluginDetail') { + this.goBackFromPluginDetail(); + } this.selectedSection = sectionId; this.sectionContextKey.set(sectionId); this.storageService.store(AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, sectionId, StorageScope.PROFILE, StorageTarget.USER); @@ -734,8 +779,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.isPromptsSection(sectionId)) { void this.listWidget.setSection(sectionId); } - this.sectionsList.setFocus([index]); - this.sectionsList.setSelection([index]); + this.ensureSectionsListReflectsActiveSection(sectionId); } } @@ -812,6 +856,12 @@ export class AICustomizationManagementEditor extends EditorPane { try { const ref = await this.textModelService.createModelReference(uri); + + if (!isEqual(this.currentEditingUri, uri)) { + ref.dispose(); + return; // another item was selected while loading + } + this.currentModelRef = ref; this.embeddedEditor!.setModel(ref.object.textEditorModel); this.embeddedEditor!.updateOptions({ readOnly: isReadOnly }); @@ -849,7 +899,9 @@ export class AICustomizationManagementEditor extends EditorPane { })); } catch (error) { console.error('Failed to load model for embedded editor:', error); - this.goBackToList(); + if (isEqual(this.currentEditingUri, uri)) { + this.goBackToList(); + } } } @@ -945,4 +997,67 @@ export class AICustomizationManagementEditor extends EditorPane { } //#endregion + + //#region Embedded Plugin Detail + + private createEmbeddedPluginDetail(): void { + if (!this.pluginDetailContainer) { + return; + } + + // Back button header + const detailHeader = DOM.append(this.pluginDetailContainer, $('.editor-header')); + const backButton = DOM.append(detailHeader, $('button.editor-back-button')); + backButton.setAttribute('aria-label', localize('backToPluginList', "Back to plugins")); + const backIconEl = DOM.append(backButton, $(`.codicon.codicon-${Codicon.arrowLeft.id}`)); + backIconEl.setAttribute('aria-hidden', 'true'); + this.editorDisposables.add(DOM.addDisposableListener(backButton, 'click', () => { + this.goBackFromPluginDetail(); + })); + + // Container for the plugin editor + const editorContainer = DOM.append(this.pluginDetailContainer, $('.plugin-detail-editor-container')); + + // Create the embedded plugin editor pane + this.embeddedPluginEditor = this.editorDisposables.add(this.instantiationService.createInstance(AgentPluginEditor, this.group)); + this.embeddedPluginEditor.create(editorContainer); + } + + private async showEmbeddedPluginDetail(item: IAgentPluginItem): Promise { + if (!this.embeddedPluginEditor) { + return; + } + + this.viewMode = 'pluginDetail'; + this.updateContentVisibility(); + + const input = new AgentPluginEditorInput(item); + this.pluginDetailDisposables.clear(); + this.pluginDetailDisposables.add(input); + + try { + await this.embeddedPluginEditor.setInput(input, undefined, {}, CancellationToken.None); + } catch { + this.goBackFromPluginDetail(); + return; + } + + if (this.dimension) { + this.layout(this.dimension); + } + } + + private goBackFromPluginDetail(): void { + this.pluginDetailDisposables.clear(); + this.embeddedPluginEditor?.clearInput(); + this.viewMode = 'list'; + this.updateContentVisibility(); + + if (this.dimension) { + this.layout(this.dimension); + } + this.pluginListWidget?.focusSearch(); + } + + //#endregion } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts index 6d867728d37..dea000c2086 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts @@ -5,10 +5,11 @@ import { constObservable, derived, IObservable, observableFromEventOpts } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection, IStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; -import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { IChatPromptSlashCommand, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { @@ -27,6 +28,7 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic constructor( @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ICommandService private readonly commandService: ICommandService, + @IPromptsService private readonly promptsService: IPromptsService, ) { const workspaceFolders = observableFromEventOpts( { owner: this }, @@ -51,6 +53,7 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic AICustomizationManagementSection.Prompts, AICustomizationManagementSection.Hooks, AICustomizationManagementSection.McpServers, + AICustomizationManagementSection.Plugins, ]; private static readonly _defaultFilter: IStorageSourceFilter = { @@ -71,6 +74,10 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic // No-op in core VS Code. } + async deleteFiles(_projectRoot: URI, _fileUris: URI[]): Promise { + // No-op in core VS Code. + } + async generateCustomization(type: PromptsType): Promise { const commandIds: Partial> = { [PromptsType.agent]: GENERATE_AGENT_COMMAND_ID, @@ -84,6 +91,10 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic await this.commandService.executeCommand(commandId); } } + + async getFilteredPromptSlashCommands(token: CancellationToken): Promise { + return this.promptsService.getPromptSlashCommands(token); + } } registerSingleton(IAICustomizationWorkspaceService, AICustomizationWorkspaceService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationGroupHeaderRenderer.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationGroupHeaderRenderer.ts new file mode 100644 index 00000000000..2ad6d993447 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationGroupHeaderRenderer.ts @@ -0,0 +1,99 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { IListRenderer } from '../../../../../base/browser/ui/list/list.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; + +const $ = DOM.$; + +export const CUSTOMIZATION_GROUP_HEADER_HEIGHT = 36; +export const CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR = 40; + +/** + * Common shape for a collapsible group header entry used in the + * MCP-server and plugin list widgets. + */ +export interface ICustomizationGroupHeaderEntry { + readonly type: 'group-header'; + readonly id: string; + readonly label: string; + readonly icon: ThemeIcon; + readonly count: number; + readonly isFirst: boolean; + readonly description: string; + collapsed: boolean; +} + +interface ICustomizationGroupHeaderTemplateData { + readonly container: HTMLElement; + readonly chevron: HTMLElement; + readonly icon: HTMLElement; + readonly label: HTMLElement; + readonly count: HTMLElement; + readonly infoIcon: HTMLElement; + readonly disposables: DisposableStore; + readonly elementDisposables: DisposableStore; +} + +/** + * Shared renderer for collapsible group headers in the AI Customization + * list widgets (MCP servers, plugins, etc.). + */ +export class CustomizationGroupHeaderRenderer implements IListRenderer { + + constructor( + readonly templateId: string, + private readonly hoverService: IHoverService, + ) { } + + renderTemplate(container: HTMLElement): ICustomizationGroupHeaderTemplateData { + const disposables = new DisposableStore(); + const elementDisposables = new DisposableStore(); + container.classList.add('ai-customization-group-header'); + + const chevron = DOM.append(container, $('.group-chevron')); + const icon = DOM.append(container, $('.group-icon')); + const labelGroup = DOM.append(container, $('.group-label-group')); + const label = DOM.append(labelGroup, $('.group-label')); + const infoIcon = DOM.append(labelGroup, $('.group-info')); + infoIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.info)); + const count = DOM.append(container, $('.group-count')); + + return { container, chevron, icon, label, count, infoIcon, disposables, elementDisposables }; + } + + renderElement(element: T, _index: number, templateData: ICustomizationGroupHeaderTemplateData): void { + templateData.elementDisposables.clear(); + + templateData.chevron.className = 'group-chevron'; + templateData.chevron.classList.add(...ThemeIcon.asClassNameArray(element.collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + templateData.icon.className = 'group-icon'; + templateData.icon.classList.add(...ThemeIcon.asClassNameArray(element.icon)); + + templateData.label.textContent = element.label; + templateData.count.textContent = `${element.count}`; + + templateData.elementDisposables.add(this.hoverService.setupDelayedHover(templateData.infoIcon, () => ({ + content: element.description, + appearance: { + compact: true, + skipFadeInAnimation: true, + } + }))); + + templateData.container.classList.toggle('collapsed', element.collapsed); + templateData.container.classList.toggle('has-previous-group', !element.isFirst); + } + + disposeTemplate(templateData: ICustomizationGroupHeaderTemplateData): void { + templateData.elementDisposables.dispose(); + templateData.disposables.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 6e6ed17aabe..ffaecde8443 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -17,6 +17,7 @@ import { Button } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, McpServerInstallState, IMcpService } from '../../../../contrib/mcp/common/mcpTypes.js'; +import { isContributionDisabled } from '../../common/enablement.js'; import { McpCommandIds } from '../../../../contrib/mcp/common/mcpCommandIds.js'; import { autorun } from '../../../../../base/common/observable.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; @@ -28,28 +29,21 @@ import { Delayer } from '../../../../../base/common/async.js'; import { IAction, Separator } from '../../../../../base/common/actions.js'; import { getContextMenuActions } from '../../../../contrib/mcp/browser/mcpServerActions.js'; import { LocalMcpServerScope } from '../../../../services/mcp/common/mcpWorkbenchManagementService.js'; -import { workspaceIcon, userIcon } from './aiCustomizationIcons.js'; +import { workspaceIcon, userIcon, mcpServerIcon, builtinIcon } from './aiCustomizationIcons.js'; +import { formatDisplayName, truncateToFirstSentence } from './aiCustomizationListWidget.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; const $ = DOM.$; -const MCP_ITEM_HEIGHT = 60; -const MCP_GROUP_HEADER_HEIGHT = 32; -const MCP_GROUP_HEADER_HEIGHT_WITH_SEPARATOR = 40; +const MCP_ITEM_HEIGHT = 36; /** * Represents a collapsible group header in the MCP server list. */ -interface IMcpGroupHeaderEntry { - readonly type: 'group-header'; - readonly id: string; - readonly scope: LocalMcpServerScope; - readonly label: string; - readonly icon: ThemeIcon; - readonly count: number; - readonly isFirst: boolean; - readonly description: string; - collapsed: boolean; +interface IMcpGroupHeaderEntry extends ICustomizationGroupHeaderEntry { + readonly scope: LocalMcpServerScope | 'builtin'; } /** @@ -60,7 +54,17 @@ interface IMcpServerItemEntry { readonly server: IWorkbenchMcpServer; } -type IMcpListEntry = IMcpGroupHeaderEntry | IMcpServerItemEntry; +/** + * Represents a built-in MCP server provided by an extension. + */ +interface IMcpBuiltinItemEntry { + readonly type: 'builtin-item'; + readonly id: string; + readonly label: string; + readonly description: string; +} + +type IMcpListEntry = IMcpGroupHeaderEntry | IMcpServerItemEntry | IMcpBuiltinItemEntry; /** * Delegate for the MCP server list. @@ -68,7 +72,10 @@ type IMcpListEntry = IMcpGroupHeaderEntry | IMcpServerItemEntry; class McpServerItemDelegate implements IListVirtualDelegate { getHeight(element: IMcpListEntry): number { if (element.type === 'group-header') { - return element.isFirst ? MCP_GROUP_HEADER_HEIGHT : MCP_GROUP_HEADER_HEIGHT_WITH_SEPARATOR; + return element.isFirst ? CUSTOMIZATION_GROUP_HEADER_HEIGHT : CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR; + } + if (element.type === 'server-item' && element.server.gallery && !element.server.local) { + return 62; } return MCP_ITEM_HEIGHT; } @@ -77,81 +84,17 @@ class McpServerItemDelegate implements IListVirtualDelegate { if (element.type === 'group-header') { return 'mcpGroupHeader'; } + if (element.type === 'builtin-item') { + return 'mcpServerItem'; + } const server = element.server; return server.gallery && !server.local ? 'mcpGalleryItem' : 'mcpServerItem'; } } -interface IMcpGroupHeaderTemplateData { - readonly container: HTMLElement; - readonly chevron: HTMLElement; - readonly icon: HTMLElement; - readonly label: HTMLElement; - readonly count: HTMLElement; - readonly infoIcon: HTMLElement; - readonly disposables: DisposableStore; - readonly elementDisposables: DisposableStore; -} - -/** - * Renderer for collapsible group headers (Workspace, User). - */ -class McpGroupHeaderRenderer implements IListRenderer { - readonly templateId = 'mcpGroupHeader'; - - constructor( - private readonly hoverService: IHoverService, - ) { } - - renderTemplate(container: HTMLElement): IMcpGroupHeaderTemplateData { - const disposables = new DisposableStore(); - const elementDisposables = new DisposableStore(); - container.classList.add('ai-customization-group-header'); - - const chevron = DOM.append(container, $('.group-chevron')); - const icon = DOM.append(container, $('.group-icon')); - const labelGroup = DOM.append(container, $('.group-label-group')); - const label = DOM.append(labelGroup, $('.group-label')); - const infoIcon = DOM.append(labelGroup, $('.group-info')); - infoIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.info)); - const count = DOM.append(container, $('.group-count')); - - return { container, chevron, icon, label, count, infoIcon, disposables, elementDisposables }; - } - - renderElement(element: IMcpGroupHeaderEntry, _index: number, templateData: IMcpGroupHeaderTemplateData): void { - templateData.elementDisposables.clear(); - - templateData.chevron.className = 'group-chevron'; - templateData.chevron.classList.add(...ThemeIcon.asClassNameArray(element.collapsed ? Codicon.chevronRight : Codicon.chevronDown)); - - templateData.icon.className = 'group-icon'; - templateData.icon.classList.add(...ThemeIcon.asClassNameArray(element.icon)); - - templateData.label.textContent = element.label; - templateData.count.textContent = `${element.count}`; - - templateData.elementDisposables.add(this.hoverService.setupDelayedHover(templateData.infoIcon, () => ({ - content: element.description, - appearance: { - compact: true, - skipFadeInAnimation: true, - } - }))); - - templateData.container.classList.toggle('collapsed', element.collapsed); - templateData.container.classList.toggle('has-previous-group', !element.isFirst); - } - - disposeTemplate(templateData: IMcpGroupHeaderTemplateData): void { - templateData.elementDisposables.dispose(); - templateData.disposables.dispose(); - } -} - interface IMcpServerItemTemplateData { readonly container: HTMLElement; - readonly icon: HTMLElement; + readonly typeIcon: HTMLElement; readonly name: HTMLElement; readonly description: HTMLElement; readonly status: HTMLElement; @@ -161,18 +104,19 @@ interface IMcpServerItemTemplateData { /** * Renderer for local MCP server list items. */ -class McpServerItemRenderer implements IListRenderer { +class McpServerItemRenderer implements IListRenderer { readonly templateId = 'mcpServerItem'; constructor( @IMcpService private readonly mcpService: IMcpService, + @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, ) { } renderTemplate(container: HTMLElement): IMcpServerItemTemplateData { container.classList.add('mcp-server-item'); - const icon = DOM.append(container, $('.mcp-server-icon')); - icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.server)); + const typeIcon = DOM.append(container, $('.mcp-server-icon')); + typeIcon.classList.add(...ThemeIcon.asClassNameArray(mcpServerIcon)); const details = DOM.append(container, $('.mcp-server-details')); const name = DOM.append(details, $('.mcp-server-name')); @@ -180,26 +124,59 @@ class McpServerItemRenderer implements IListRenderer s.definition.id === element.server.id); templateData.disposables.add(autorun(reader => { + const disabled = server ? isContributionDisabled(server.enablement.read(reader)) : false; const connectionState = server?.connectionState.read(reader); - this.updateStatus(templateData.status, connectionState?.state); + templateData.container.classList.toggle('disabled', disabled); + this.updateStatus(templateData.status, disabled ? 'disabled' : connectionState?.state); })); } - private updateStatus(statusElement: HTMLElement, state: McpConnectionState.Kind | undefined): void { + private updateStatus(statusElement: HTMLElement, state: McpConnectionState.Kind | 'disabled' | undefined): void { statusElement.className = 'mcp-server-status'; + if (this.workspaceService.isSessionsWindow) { + // In sessions window, CLI manages MCP servers — hide status + statusElement.style.display = 'none'; + return; + } + + statusElement.style.display = ''; + if (state === 'disabled') { + statusElement.textContent = localize('disabled', "Disabled"); + statusElement.classList.add('disabled'); + return; + } switch (state) { case McpConnectionState.Kind.Running: statusElement.textContent = localize('running', "Running"); @@ -228,7 +205,6 @@ class McpServerItemRenderer implements IListRenderer(); + private readonly collapsedGroups = new Set(); private galleryCts: CancellationTokenSource | undefined; private readonly delayedFilter = new Delayer(200); private readonly delayedGallerySearch = new Delayer(400); @@ -458,7 +432,7 @@ export class McpListWidget extends Disposable { // Create list const delegate = new McpServerItemDelegate(); - const groupHeaderRenderer = new McpGroupHeaderRenderer(this.hoverService); + const groupHeaderRenderer = new CustomizationGroupHeaderRenderer('mcpGroupHeader', this.hoverService); const localRenderer = this.instantiationService.createInstance(McpServerItemRenderer); const galleryRenderer = new McpGalleryItemRenderer(this.mcpWorkbenchService); @@ -477,6 +451,9 @@ export class McpListWidget extends Disposable { if (element.type === 'group-header') { return localize('mcpGroupAriaLabel', "{0}, {1} items, {2}", element.label, element.count, element.collapsed ? localize('collapsed', "collapsed") : localize('expanded', "expanded")); } + if (element.type === 'builtin-item') { + return element.label; + } return element.server.label; }, getWidgetAriaLabel() { @@ -486,7 +463,13 @@ export class McpListWidget extends Disposable { openOnSingleClick: true, identityProvider: { getId(element: IMcpListEntry) { - return element.type === 'group-header' ? element.id : element.server.id; + if (element.type === 'group-header') { + return element.id; + } + if (element.type === 'builtin-item') { + return element.id; + } + return element.server.id; } } } @@ -496,9 +479,10 @@ export class McpListWidget extends Disposable { if (e.element) { if (e.element.type === 'group-header') { this.toggleGroup(e.element); - } else { + } else if (e.element.type === 'server-item') { this._onDidSelectServer.fire(e.element.server); } + // builtin-item: no action on click (read-only) } })); @@ -619,8 +603,14 @@ export class McpListWidget extends Disposable { this.filteredServers = [...this.mcpWorkbenchService.local]; } + // Find extension-provided servers not in the local list (e.g. GitHub MCP) + const localIds = new Set(this.filteredServers.map(s => s.id)); + const builtinServers = this.mcpService.servers.get() + .filter(s => !localIds.has(s.definition.id)) + .filter(s => !query || s.definition.label.toLowerCase().includes(query)); + // Show empty state only when there are no servers at all (not when filtered to empty) - if (this.filteredServers.length === 0) { + if (this.filteredServers.length === 0 && builtinServers.length === 0) { this.emptyContainer.style.display = 'flex'; this.listContainer.style.display = 'none'; @@ -681,6 +671,32 @@ export class McpListWidget extends Disposable { isFirst = false; } + // Add built-in / extension-provided servers + if (builtinServers.length > 0) { + const collapsed = this.collapsedGroups.has('builtin'); + entries.push({ + type: 'group-header', + id: 'mcp-group-builtin', + scope: 'builtin', + label: localize('builtInGroup', "Built-in"), + icon: builtinIcon, + count: builtinServers.length, + isFirst, + description: localize('builtInGroupDescription', "MCP servers built into VS Code. These are available automatically."), + collapsed, + }); + if (!collapsed) { + for (const server of builtinServers) { + entries.push({ + type: 'builtin-item', + id: `builtin-${server.definition.id}`, + label: server.definition.label, + description: '', + }); + } + } + } + this.displayEntries = entries; this.list.splice(0, this.list.length, this.displayEntries); } @@ -701,14 +717,28 @@ export class McpListWidget extends Disposable { * Layouts the widget. */ layout(height: number, width: number): void { - const sectionFooterHeight = this.sectionHeader.offsetHeight || 100; - const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 40; + const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; + const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; const backLinkHeight = this.browseMode ? (this.backLink.offsetHeight || 28) : 0; - const margins = 12; - const listHeight = height - sectionFooterHeight - searchBarHeight - backLinkHeight - margins; + const listHeight = height - sectionFooterHeight - searchBarHeight - backLinkHeight; this.listContainer.style.height = `${Math.max(0, listHeight)}px`; this.list.layout(Math.max(0, listHeight), width); + + // Re-layout once after footer renders if we used a zero fallback + if (sectionFooterHeight === 0) { + DOM.getWindow(this.listContainer).requestAnimationFrame(() => { + if (this._store.isDisposed) { + return; + } + const actualFooterHeight = this.sectionHeader.offsetHeight; + if (actualFooterHeight > 0) { + const correctedHeight = height - actualFooterHeight - searchBarHeight - backLinkHeight; + this.listContainer.style.height = `${Math.max(0, correctedHeight)}px`; + this.list.layout(Math.max(0, correctedHeight), width); + } + }); + } } /** diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index af43b382e18..8566c32eec4 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -166,7 +166,7 @@ align-items: center; gap: 8px; flex-shrink: 0; - margin: 6px 0px; + padding: 6px 0; } .ai-customization-list-widget .list-search-container, @@ -195,7 +195,8 @@ .ai-customization-list-widget .list-container { flex: 1; - overflow: hidden; + min-height: 0; + overflow: auto; } .ai-customization-list-widget .list-empty-message { @@ -251,11 +252,9 @@ border-radius: 4px; } -/* Separator line above non-first group headers */ +/* Spacing above non-first group headers */ .ai-customization-group-header.has-previous-group { - border-top: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border)); margin-top: 4px; - padding-top: 12px; } .ai-customization-group-header:hover { @@ -307,8 +306,8 @@ flex-shrink: 0; font-size: 10px; font-weight: 500; - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); + background-color: var(--vscode-list-inactiveSelectionBackground); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); padding: 0 5px; border-radius: 8px; min-width: 14px; @@ -398,6 +397,17 @@ opacity: 0.6; } +.ai-customization-list-item .item-type-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.8; + font-size: 14px; +} + .ai-customization-list-item .item-text { display: flex; flex-direction: column; @@ -544,12 +554,11 @@ .ai-customization-overview .overview-section .section-count { flex-shrink: 0; font-size: 9px; - font-weight: 600; + font-weight: 500; padding: 1px 5px; - background-color: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); + background-color: var(--vscode-list-inactiveSelectionBackground); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); border-radius: 8px; - border: 1px solid transparent; min-width: 14px; text-align: center; } @@ -557,6 +566,7 @@ /* Content container visibility */ .ai-customization-management-editor .prompts-content-container, .ai-customization-management-editor .mcp-content-container, +.ai-customization-management-editor .plugin-content-container, .ai-customization-management-editor .models-content-container { height: 100%; display: flex; @@ -575,6 +585,18 @@ overflow: hidden; } +/* Embedded plugin detail view */ +.ai-customization-management-editor .plugin-detail-container { + height: 100%; + display: flex; + flex-direction: column; +} + +.ai-customization-management-editor .plugin-detail-editor-container { + flex: 1; + overflow: hidden; +} + /* Models section footer */ .ai-customization-management-editor .models-content-container .section-footer { flex-shrink: 0; @@ -648,7 +670,8 @@ .mcp-list-widget .mcp-list-container { flex: 1; - overflow: hidden; + min-height: 0; + overflow: auto; } /* MCP Empty State */ @@ -690,17 +713,23 @@ .mcp-server-item { display: flex; align-items: center; - padding: 8px 12px; + padding: 6px 8px; cursor: pointer; border-radius: 4px; margin: 2px 0; - gap: 12px; + min-height: 32px; + gap: 10px; } .mcp-server-item:hover { background-color: var(--vscode-list-hoverBackground); } +.mcp-server-item.builtin { + cursor: default; + opacity: 0.85; +} + .mcp-server-item .mcp-server-icon { flex-shrink: 0; width: 24px; @@ -721,10 +750,10 @@ .mcp-server-item .mcp-server-name { font-size: 13px; - font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + line-height: 18px; } .mcp-server-item .mcp-server-description { @@ -733,6 +762,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + line-height: 14px; } .mcp-server-item .mcp-server-status { @@ -763,6 +793,15 @@ color: var(--vscode-badge-foreground); } +.mcp-server-item.disabled { + opacity: 0.5; +} + +.mcp-server-item .mcp-server-status.disabled { + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + /* Button group for Add Server + Browse Marketplace */ .mcp-list-widget .list-button-group { display: flex; @@ -810,6 +849,19 @@ flex-shrink: 0; } +.mcp-gallery-item.extension-list-item { + padding: 6px 8px 6px 16px; +} + +.mcp-gallery-item.extension-list-item > .details > .footer { + display: flex; + align-items: center; +} + +.mcp-gallery-item.extension-list-item .mcp-gallery-action { + margin-left: auto; +} + .mcp-gallery-item .mcp-gallery-install-button { font-size: 11px; padding: 2px 10px; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts new file mode 100644 index 00000000000..ccbda744363 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -0,0 +1,722 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationManagement.css'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { Disposable, DisposableStore, isDisposable } from '../../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; +import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../../base/browser/ui/list/list.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; +import { IContextMenuService, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { Delayer } from '../../../../../base/common/async.js'; +import { IAction, Separator } from '../../../../../base/common/actions.js'; +import { basename, dirname } from '../../../../../base/common/resources.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IAgentPlugin, IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { isContributionEnabled } from '../../common/enablement.js'; +import { getInstalledPluginContextMenuActions } from '../agentPluginActions.js'; +import { IMarketplacePlugin, IPluginMarketplaceService } from '../../common/plugins/pluginMarketplaceService.js'; +import { IPluginInstallService } from '../../common/plugins/pluginInstallService.js'; +import { AgentPluginItemKind, IAgentPluginItem, IInstalledPluginItem, IMarketplacePluginItem } from '../agentPluginEditor/agentPluginItems.js'; +import { pluginIcon } from './aiCustomizationIcons.js'; +import { formatDisplayName, truncateToFirstSentence } from './aiCustomizationListWidget.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; + +const $ = DOM.$; + +const PLUGIN_ITEM_HEIGHT = 36; + +//#region Entry types + +/** + * Represents a collapsible group header in the plugin list. + */ +interface IPluginGroupHeaderEntry extends ICustomizationGroupHeaderEntry { + readonly group: 'enabled' | 'disabled'; +} + +/** + * Represents an installed plugin item in the list. + */ +interface IPluginInstalledItemEntry { + readonly type: 'plugin-item'; + readonly item: IInstalledPluginItem; +} + +/** + * Represents a marketplace plugin item in the list (browse mode). + */ +interface IPluginMarketplaceItemEntry { + readonly type: 'marketplace-item'; + readonly item: IMarketplacePluginItem; +} + +type IPluginListEntry = IPluginGroupHeaderEntry | IPluginInstalledItemEntry | IPluginMarketplaceItemEntry; + +//#endregion + +//#region Delegate + +class PluginItemDelegate implements IListVirtualDelegate { + getHeight(element: IPluginListEntry): number { + if (element.type === 'group-header') { + return element.isFirst ? CUSTOMIZATION_GROUP_HEADER_HEIGHT : CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR; + } + if (element.type === 'marketplace-item') { + return 62; + } + return PLUGIN_ITEM_HEIGHT; + } + + getTemplateId(element: IPluginListEntry): string { + if (element.type === 'group-header') { + return 'pluginGroupHeader'; + } + if (element.type === 'marketplace-item') { + return 'pluginMarketplaceItem'; + } + return 'pluginInstalledItem'; + } +} + +//#endregion + +//#endregion + +//#region Installed Plugin Renderer (reuses .mcp-server-item CSS) + +interface IPluginInstalledItemTemplateData { + readonly container: HTMLElement; + readonly typeIcon: HTMLElement; + readonly name: HTMLElement; + readonly description: HTMLElement; + readonly status: HTMLElement; + readonly disposables: DisposableStore; +} + +class PluginInstalledItemRenderer implements IListRenderer { + readonly templateId = 'pluginInstalledItem'; + + renderTemplate(container: HTMLElement): IPluginInstalledItemTemplateData { + container.classList.add('mcp-server-item'); + + const typeIcon = DOM.append(container, $('.mcp-server-icon')); + typeIcon.classList.add(...ThemeIcon.asClassNameArray(pluginIcon)); + + const details = DOM.append(container, $('.mcp-server-details')); + const name = DOM.append(details, $('.mcp-server-name')); + const description = DOM.append(details, $('.mcp-server-description')); + const status = DOM.append(container, $('.mcp-server-status')); + + return { container, typeIcon, name, description, status, disposables: new DisposableStore() }; + } + + renderElement(element: IPluginInstalledItemEntry, _index: number, templateData: IPluginInstalledItemTemplateData): void { + templateData.disposables.clear(); + + templateData.name.textContent = formatDisplayName(element.item.name); + + if (element.item.description) { + templateData.description.textContent = truncateToFirstSentence(element.item.description); + templateData.description.style.display = ''; + } else { + templateData.description.style.display = 'none'; + } + + // Show enabled/disabled status + templateData.disposables.add(autorun(reader => { + const enabled = isContributionEnabled(element.item.plugin.enablement.read(reader)); + templateData.container.classList.toggle('disabled', !enabled); + templateData.status.className = 'mcp-server-status'; + if (enabled) { + templateData.status.textContent = localize('enabled', "Enabled"); + templateData.status.classList.add('running'); + } else { + templateData.status.textContent = localize('disabled', "Disabled"); + templateData.status.classList.add('disabled'); + } + })); + } + + disposeTemplate(templateData: IPluginInstalledItemTemplateData): void { + templateData.disposables.dispose(); + } +} + +//#endregion + +//#region Marketplace Plugin Renderer (reuses .mcp-gallery-item CSS) + +interface IPluginMarketplaceItemTemplateData { + readonly container: HTMLElement; + readonly name: HTMLElement; + readonly publisher: HTMLElement; + readonly description: HTMLElement; + readonly installButton: Button; + readonly elementDisposables: DisposableStore; + readonly templateDisposables: DisposableStore; +} + +class PluginMarketplaceItemRenderer implements IListRenderer { + readonly templateId = 'pluginMarketplaceItem'; + + constructor( + private readonly pluginInstallService: IPluginInstallService, + ) { } + + renderTemplate(container: HTMLElement): IPluginMarketplaceItemTemplateData { + container.classList.add('mcp-server-item', 'mcp-gallery-item', 'extension-list-item'); + const details = DOM.append(container, $('.details')); + const headerContainer = DOM.append(details, $('.header-container')); + const header = DOM.append(headerContainer, $('.header')); + const name = DOM.append(header, $('span.name')); + const description = DOM.append(details, $('.description.ellipsis')); + const footer = DOM.append(details, $('.footer')); + const publisherContainer = DOM.append(footer, $('.publisher-container')); + const publisher = DOM.append(publisherContainer, $('span.publisher-name')); + const actionContainer = DOM.append(footer, $('.mcp-gallery-action')); + const installButton = new Button(actionContainer, { ...defaultButtonStyles, supportIcons: true }); + installButton.element.classList.add('mcp-gallery-install-button'); + + const templateDisposables = new DisposableStore(); + templateDisposables.add(installButton); + + return { container, name, publisher, description, installButton, elementDisposables: new DisposableStore(), templateDisposables }; + } + + renderElement(element: IPluginMarketplaceItemEntry, _index: number, templateData: IPluginMarketplaceItemTemplateData): void { + templateData.elementDisposables.clear(); + + templateData.name.textContent = element.item.name; + templateData.publisher.textContent = element.item.marketplace ? localize('byPublisher', "by {0}", element.item.marketplace) : ''; + templateData.description.textContent = element.item.description || ''; + + templateData.installButton.label = localize('install', "Install"); + templateData.installButton.enabled = true; + + templateData.elementDisposables.add(templateData.installButton.onDidClick(async () => { + templateData.installButton.label = localize('installing', "Installing..."); + templateData.installButton.enabled = false; + try { + await this.pluginInstallService.installPlugin({ + name: element.item.name, + description: element.item.description, + version: '', + sourceDescriptor: element.item.sourceDescriptor, + source: element.item.source, + marketplace: element.item.marketplace, + marketplaceReference: element.item.marketplaceReference, + marketplaceType: element.item.marketplaceType, + readmeUri: element.item.readmeUri, + }); + templateData.installButton.label = localize('installed', "Installed"); + } catch (_e) { + templateData.installButton.label = localize('install', "Install"); + templateData.installButton.enabled = true; + } + })); + } + + disposeTemplate(templateData: IPluginMarketplaceItemTemplateData): void { + templateData.elementDisposables.dispose(); + templateData.templateDisposables.dispose(); + } +} + +//#endregion + +//#region Helpers + +function installedPluginToItem(plugin: IAgentPlugin, labelService: ILabelService): IInstalledPluginItem { + const name = plugin.label ?? basename(plugin.uri); + const description = plugin.fromMarketplace?.description ?? labelService.getUriLabel(dirname(plugin.uri), { relative: true }); + const marketplace = plugin.fromMarketplace?.marketplace; + return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin }; +} + +function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePluginItem { + return { + kind: AgentPluginItemKind.Marketplace, + name: plugin.name, + description: plugin.description, + source: plugin.source, + sourceDescriptor: plugin.sourceDescriptor, + marketplace: plugin.marketplace, + marketplaceReference: plugin.marketplaceReference, + marketplaceType: plugin.marketplaceType, + readmeUri: plugin.readmeUri, + }; +} + +//#endregion + +/** + * Widget that displays a list of agent plugins with marketplace browsing. + * Follows the same patterns as {@link McpListWidget}. + */ +export class PluginListWidget extends Disposable { + + readonly element: HTMLElement; + + private readonly _onDidSelectPlugin = this._register(new Emitter()); + readonly onDidSelectPlugin = this._onDidSelectPlugin.event; + + private sectionHeader!: HTMLElement; + private sectionDescription!: HTMLElement; + private sectionLink!: HTMLAnchorElement; + private searchAndButtonContainer!: HTMLElement; + private searchInput!: InputBox; + private listContainer!: HTMLElement; + private list!: WorkbenchList; + private emptyContainer!: HTMLElement; + private emptyText!: HTMLElement; + private emptySubtext!: HTMLElement; + private browseButton!: Button; + private backLink!: HTMLElement; + + private installedItems: IInstalledPluginItem[] = []; + private displayEntries: IPluginListEntry[] = []; + private marketplaceItems: IMarketplacePluginItem[] = []; + private searchQuery: string = ''; + private browseMode: boolean = false; + private readonly collapsedGroups = new Set(); + private marketplaceCts: CancellationTokenSource | undefined; + private readonly delayedFilter = new Delayer(200); + private readonly delayedMarketplaceSearch = new Delayer(400); + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + @IPluginMarketplaceService private readonly pluginMarketplaceService: IPluginMarketplaceService, + @IPluginInstallService private readonly pluginInstallService: IPluginInstallService, + @IOpenerService private readonly openerService: IOpenerService, + @IContextViewService private readonly contextViewService: IContextViewService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @IHoverService private readonly hoverService: IHoverService, + @ILabelService private readonly labelService: ILabelService, + ) { + super(); + this.element = $('.mcp-list-widget'); // reuse MCP list widget CSS + this.create(); + this._register({ + dispose: () => { + this.marketplaceCts?.dispose(); + } + }); + } + + private create(): void { + // Search and button container + this.searchAndButtonContainer = DOM.append(this.element, $('.list-search-and-button-container')); + + // Search container + const searchContainer = DOM.append(this.searchAndButtonContainer, $('.list-search-container')); + this.searchInput = this._register(new InputBox(searchContainer, this.contextViewService, { + placeholder: localize('searchPluginsPlaceholder', "Type to search..."), + inputBoxStyles: defaultInputBoxStyles, + })); + + this._register(this.searchInput.onDidChange(() => { + this.searchQuery = this.searchInput.value; + if (this.browseMode) { + this.delayedMarketplaceSearch.trigger(() => this.queryMarketplace()); + } else { + this.delayedFilter.trigger(() => this.filterPlugins()); + } + })); + + // Button container (Browse Marketplace) + const buttonContainer = DOM.append(this.searchAndButtonContainer, $('.list-button-group')); + + const browseButtonContainer = DOM.append(buttonContainer, $('.list-add-button-container')); + this.browseButton = this._register(new Button(browseButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + this.browseButton.label = `$(${Codicon.library.id}) ${localize('browseMarketplace', "Browse Marketplace")}`; + this.browseButton.element.classList.add('list-add-button'); + this._register(this.browseButton.onDidClick(() => { + this.toggleBrowseMode(!this.browseMode); + })); + + // Back to installed link (shown only in browse mode) + this.backLink = DOM.append(this.element, $('.mcp-back-link')); + this.backLink.setAttribute('role', 'button'); + this.backLink.tabIndex = 0; + this.backLink.setAttribute('aria-label', localize('backToInstalledPluginsAriaLabel', "Back to installed plugins")); + const backIcon = DOM.append(this.backLink, $('span')); + backIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.arrowLeft)); + const backText = DOM.append(this.backLink, $('span')); + backText.textContent = localize('backToInstalledPlugins', "Back to installed plugins"); + this._register(DOM.addDisposableListener(this.backLink, 'click', () => { + this.toggleBrowseMode(false); + })); + this._register(DOM.addDisposableListener(this.backLink, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.toggleBrowseMode(false); + } + })); + this.backLink.style.display = 'none'; + + // Empty state + this.emptyContainer = DOM.append(this.element, $('.mcp-empty-state')); + const emptyIcon = DOM.append(this.emptyContainer, $('.empty-icon')); + emptyIcon.classList.add(...ThemeIcon.asClassNameArray(pluginIcon)); + this.emptyText = DOM.append(this.emptyContainer, $('.empty-text')); + this.emptySubtext = DOM.append(this.emptyContainer, $('.empty-subtext')); + + // List container + this.listContainer = DOM.append(this.element, $('.mcp-list-container')); + + // Section footer + this.sectionHeader = DOM.append(this.element, $('.section-footer')); + this.sectionDescription = DOM.append(this.sectionHeader, $('p.section-footer-description')); + this.sectionDescription.textContent = localize('pluginsDescription', "Extend your AI agent with plugins that add commands, skills, agents, hooks, and MCP servers from reusable packages."); + this.sectionLink = DOM.append(this.sectionHeader, $('a.section-footer-link')) as HTMLAnchorElement; + this.sectionLink.textContent = localize('learnMorePlugins', "Learn more about agent plugins"); + this.sectionLink.href = 'https://code.visualstudio.com/docs/copilot/chat/agent-plugins'; + this._register(DOM.addDisposableListener(this.sectionLink, 'click', (e) => { + e.preventDefault(); + const href = this.sectionLink.href; + if (href) { + this.openerService.open(URI.parse(href)); + } + })); + + // Create list + const delegate = new PluginItemDelegate(); + const groupHeaderRenderer = new CustomizationGroupHeaderRenderer('pluginGroupHeader', this.hoverService); + const installedRenderer = new PluginInstalledItemRenderer(); + const marketplaceRenderer = new PluginMarketplaceItemRenderer(this.pluginInstallService); + + this.list = this._register(this.instantiationService.createInstance( + WorkbenchList, + 'PluginManagementList', + this.listContainer, + delegate, + [groupHeaderRenderer, installedRenderer, marketplaceRenderer], + { + multipleSelectionSupport: false, + setRowLineHeight: false, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel(element: IPluginListEntry) { + if (element.type === 'group-header') { + return localize('pluginGroupAriaLabel', "{0}, {1} items, {2}", element.label, element.count, element.collapsed ? localize('collapsed', "collapsed") : localize('expanded', "expanded")); + } + if (element.type === 'marketplace-item') { + return element.item.name; + } + return element.item.name; + }, + getWidgetAriaLabel() { + return localize('pluginsListAriaLabel', "Plugins"); + } + }, + openOnSingleClick: true, + identityProvider: { + getId(element: IPluginListEntry) { + if (element.type === 'group-header') { + return element.id; + } + if (element.type === 'marketplace-item') { + return `marketplace-${element.item.marketplaceReference.canonicalId}/${element.item.source}`; + } + return element.item.plugin.uri.toString(); + } + } + } + )); + + this._register(this.list.onDidOpen(e => { + if (e.element) { + if (e.element.type === 'group-header') { + this.toggleGroup(e.element); + } else if (e.element.type === 'plugin-item') { + this._onDidSelectPlugin.fire(e.element.item); + } else if (e.element.type === 'marketplace-item') { + this._onDidSelectPlugin.fire(e.element.item); + } + } + })); + + // Handle context menu + this._register(this.list.onContextMenu(e => this.onContextMenu(e as IListContextMenuEvent))); + + // Listen to plugin service changes + this._register(autorun(reader => { + const plugins = this.agentPluginService.plugins.read(reader); + for (const plugin of plugins) { + plugin.enablement.read(reader); + } + if (!this.browseMode) { + this.refresh(); + } + })); + this._register(this.pluginMarketplaceService.onDidChangeMarketplaces(() => { + if (!this.browseMode) { + this.refresh(); + } + })); + + // Initial refresh + void this.refresh(); + } + + private async refresh(): Promise { + if (this.browseMode) { + await this.queryMarketplace(); + } else { + this.filterPlugins(); + } + } + + private toggleBrowseMode(browse: boolean): void { + this.browseMode = browse; + this.searchInput.value = ''; + this.searchQuery = ''; + + this.backLink.style.display = browse ? '' : 'none'; + this.browseButton.element.parentElement!.style.display = browse ? 'none' : ''; + + this.searchInput.setPlaceHolder(browse + ? localize('searchMarketplacePlaceholder', "Search plugin marketplace...") + : localize('searchPluginsPlaceholder', "Type to search...") + ); + + if (browse) { + void this.queryMarketplace(); + } else { + this.marketplaceCts?.dispose(true); + this.marketplaceItems = []; + this.filterPlugins(); + } + } + + private async queryMarketplace(): Promise { + this.marketplaceCts?.dispose(true); + const cts = this.marketplaceCts = new CancellationTokenSource(); + + // Show loading state + this.emptyContainer.style.display = 'flex'; + this.listContainer.style.display = 'none'; + this.emptyText.textContent = localize('loadingMarketplace', "Loading marketplace..."); + this.emptySubtext.textContent = ''; + + try { + const plugins = await this.pluginMarketplaceService.fetchMarketplacePlugins(cts.token); + + if (cts.token.isCancellationRequested) { + return; + } + + const query = this.searchQuery.toLowerCase().trim(); + const filtered = query + ? plugins.filter(p => p.name.toLowerCase().includes(query) || p.description.toLowerCase().includes(query)) + : plugins; + + // Filter out already-installed plugins + const installedUris = new Set(this.agentPluginService.plugins.get().map(p => p.uri.toString())); + this.marketplaceItems = filtered + .filter(p => { + const expectedUri = this.pluginInstallService.getPluginInstallUri(p); + return !installedUris.has(expectedUri.toString()); + }) + .map(marketplacePluginToItem); + + this.updateMarketplaceList(); + } catch { + if (!cts.token.isCancellationRequested) { + this.marketplaceItems = []; + this.emptyContainer.style.display = 'flex'; + this.listContainer.style.display = 'none'; + this.emptyText.textContent = localize('marketplaceError', "Unable to load marketplace"); + this.emptySubtext.textContent = localize('tryAgainLater', "Check your connection and try again"); + } + } + } + + private updateMarketplaceList(): void { + if (this.marketplaceItems.length === 0) { + this.emptyContainer.style.display = 'flex'; + this.listContainer.style.display = 'none'; + if (this.searchQuery.trim()) { + this.emptyText.textContent = localize('noMarketplaceResults', "No plugins match '{0}'", this.searchQuery); + this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); + } else { + this.emptyText.textContent = localize('emptyMarketplace', "No plugins available"); + this.emptySubtext.textContent = ''; + } + } else { + this.emptyContainer.style.display = 'none'; + this.listContainer.style.display = ''; + } + + const entries: IPluginListEntry[] = this.marketplaceItems.map(item => ({ type: 'marketplace-item' as const, item })); + this.list.splice(0, this.list.length, entries); + } + + private filterPlugins(): void { + const query = this.searchQuery.toLowerCase().trim(); + const allPlugins = this.agentPluginService.plugins.get(); + + this.installedItems = allPlugins + .map(p => installedPluginToItem(p, this.labelService)) + .filter(item => !query || + item.name.toLowerCase().includes(query) || + item.description.toLowerCase().includes(query) + ); + + if (this.installedItems.length === 0) { + this.emptyContainer.style.display = 'flex'; + this.listContainer.style.display = 'none'; + + if (this.searchQuery.trim()) { + this.emptyText.textContent = localize('noMatchingPlugins', "No plugins match '{0}'", this.searchQuery); + this.emptySubtext.textContent = localize('tryDifferentSearch', "Try a different search term"); + } else { + this.emptyText.textContent = localize('noPlugins', "No plugins installed"); + this.emptySubtext.textContent = localize('browseToAdd', "Browse the marketplace to discover and install plugins"); + } + } else { + this.emptyContainer.style.display = 'none'; + this.listContainer.style.display = ''; + } + + // Group plugins: enabled vs disabled + const enabledPlugins = this.installedItems.filter(item => isContributionEnabled(item.plugin.enablement.get())); + const disabledPlugins = this.installedItems.filter(item => !isContributionEnabled(item.plugin.enablement.get())); + + const entries: IPluginListEntry[] = []; + let isFirst = true; + + if (enabledPlugins.length > 0) { + const collapsed = this.collapsedGroups.has('enabled'); + entries.push({ + type: 'group-header', + id: 'plugin-group-enabled', + group: 'enabled', + label: localize('enabledGroup', "Enabled"), + icon: pluginIcon, + count: enabledPlugins.length, + isFirst, + description: localize('enabledGroupDescription', "Plugins that are currently active and providing commands, skills, agents, and other capabilities."), + collapsed, + }); + if (!collapsed) { + for (const item of enabledPlugins) { + entries.push({ type: 'plugin-item', item }); + } + } + isFirst = false; + } + + if (disabledPlugins.length > 0) { + const collapsed = this.collapsedGroups.has('disabled'); + entries.push({ + type: 'group-header', + id: 'plugin-group-disabled', + group: 'disabled', + label: localize('disabledGroup', "Disabled"), + icon: pluginIcon, + count: disabledPlugins.length, + isFirst, + description: localize('disabledGroupDescription', "Plugins that are installed but currently disabled. Enable them to use their capabilities."), + collapsed, + }); + if (!collapsed) { + for (const item of disabledPlugins) { + entries.push({ type: 'plugin-item', item }); + } + } + } + + this.displayEntries = entries; + this.list.splice(0, this.list.length, this.displayEntries); + } + + private toggleGroup(entry: IPluginGroupHeaderEntry): void { + if (this.collapsedGroups.has(entry.group)) { + this.collapsedGroups.delete(entry.group); + } else { + this.collapsedGroups.add(entry.group); + } + this.filterPlugins(); + } + + layout(height: number, width: number): void { + const sectionFooterHeight = this.sectionHeader.offsetHeight || 0; + const searchBarHeight = this.searchAndButtonContainer.offsetHeight || 52; + const backLinkHeight = this.browseMode ? (this.backLink.offsetHeight || 28) : 0; + const listHeight = height - sectionFooterHeight - searchBarHeight - backLinkHeight; + + this.listContainer.style.height = `${Math.max(0, listHeight)}px`; + this.list.layout(Math.max(0, listHeight), width); + + if (sectionFooterHeight === 0) { + DOM.getWindow(this.listContainer).requestAnimationFrame(() => { + if (this._store.isDisposed) { + return; + } + const actualFooterHeight = this.sectionHeader.offsetHeight; + if (actualFooterHeight > 0) { + const correctedHeight = height - actualFooterHeight - searchBarHeight - backLinkHeight; + this.listContainer.style.height = `${Math.max(0, correctedHeight)}px`; + this.list.layout(Math.max(0, correctedHeight), width); + } + }); + } + } + + focusSearch(): void { + this.searchInput.focus(); + } + + focus(): void { + this.list.domFocus(); + if (this.list.length > 0) { + this.list.setFocus([0]); + } + } + + private onContextMenu(e: IListContextMenuEvent): void { + if (!e.element || e.element.type !== 'plugin-item') { + return; + } + + const entry = e.element; + const disposables = new DisposableStore(); + const groups: IAction[][] = getInstalledPluginContextMenuActions(entry.item.plugin, this.instantiationService); + const actions: IAction[] = []; + for (const menuActions of groups) { + for (const menuAction of menuActions) { + actions.push(menuAction); + if (isDisposable(menuAction)) { + disposables.add(menuAction); + } + } + actions.push(new Separator()); + } + if (actions.length > 0 && actions[actions.length - 1] instanceof Separator) { + actions.pop(); + } + + this.contextMenuService.showContextMenu({ + getAnchor: () => e.anchor, + getActions: () => actions, + onHide: () => disposables.dispose() + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts index e5fb7e719a9..f57fcb71541 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts @@ -10,7 +10,6 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { IRange } from '../../../../../editor/common/core/range.js'; import { SymbolKinds } from '../../../../../editor/common/languages.js'; -import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { localize } from '../../../../../nls.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IDraggedResourceEditorInput, MarkerTransferData, DocumentSymbolTransferData, NotebookCellOutputTransferData } from '../../../../../platform/dnd/browser/dnd.js'; @@ -27,8 +26,7 @@ import { getOutputViewModelFromId } from '../../../notebook/browser/controller/c import { getNotebookEditorFromEditorPane } from '../../../notebook/browser/notebookBrowser.js'; import { SCMHistoryItemTransferData } from '../../../scm/browser/scmHistoryChatContext.js'; import { CHAT_ATTACHABLE_IMAGE_MIME_TYPES, getAttachableImageExtension } from '../../common/model/chatModel.js'; -import { IChatRequestVariableEntry, OmittedState, IDiagnosticVariableEntry, IDiagnosticVariableEntryFilterData, ISymbolVariableEntry, toPromptFileVariableEntry, PromptFileVariableKind, ISCMHistoryItemVariableEntry } from '../../common/attachments/chatVariableEntries.js'; -import { getPromptsTypeForLanguageId, PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IChatRequestVariableEntry, OmittedState, IDiagnosticVariableEntry, IDiagnosticVariableEntryFilterData, ISymbolVariableEntry, ISCMHistoryItemVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { imageToHash } from '../widget/input/editor/chatPasteProviders.js'; import { resizeImage } from '../chatImageUtils.js'; @@ -56,7 +54,6 @@ export class ChatAttachmentResolveService implements IChatAttachmentResolveServi constructor( @IFileService private fileService: IFileService, @IEditorService private editorService: IEditorService, - @ITextModelService private textModelService: ITextModelService, @IExtensionService private extensionService: IExtensionService, @IDialogService private dialogService: IDialogService ) { } @@ -115,27 +112,9 @@ export class ChatAttachmentResolveService implements IChatAttachmentResolveServi let omittedState = OmittedState.NotOmitted; if (!isDirectory) { - - let languageId: string | undefined; - try { - const createdModel = await this.textModelService.createModelReference(resource); - languageId = createdModel.object.getLanguageId(); - createdModel.dispose(); - } catch { - omittedState = OmittedState.Full; - } - if (/\.(svg)$/i.test(resource.path)) { omittedState = OmittedState.Full; } - if (languageId) { - const promptsType = getPromptsTypeForLanguageId(languageId); - if (promptsType === PromptsType.prompt) { - return toPromptFileVariableEntry(resource, PromptFileVariableKind.PromptFile); - } else if (promptsType === PromptsType.instructions) { - return toPromptFileVariableEntry(resource, PromptFileVariableKind.Instruction); - } - } } return { diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index c1763a79436..9a283997160 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -158,6 +158,8 @@ abstract class AbstractChatAttachmentWidget extends Disposable { })); this._register(dom.addStandardDisposableListener(this.element, dom.EventType.KEY_DOWN, e => { if (e.keyCode === KeyCode.Backspace || e.keyCode === KeyCode.Delete) { + e.preventDefault(); + e.stopPropagation(); this._onDidDelete.fire(e.browserEvent); } })); @@ -640,6 +642,16 @@ export class DefaultChatAttachmentWidget extends AbstractChatAttachmentWidget { })); } + // Handle click for debug events attachments + if (attachment.kind === 'debugEvents') { + this.element.style.cursor = 'pointer'; + this._register(dom.addDisposableListener(this.element, dom.EventType.CLICK, () => { + const d = new Date(attachment.snapshotTime); + const filter = `before:${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; + this.commandService.executeCommand('workbench.action.chat.openAgentDebugPanelForSession', attachment.sessionResource, filter); + })); + } + // Setup tooltip hover for string context attachments if ((isStringVariableEntry(attachment) || attachment.kind === 'generic') && attachment.tooltip) { this._setupTooltipHover(attachment.tooltip); @@ -1418,21 +1430,27 @@ export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAcces e.dataTransfer?.setDragImage(widget, 0, 0); })); - // Context menu (context key service created eagerly for keybinding preconditions, - // but resource context and provider contexts are initialized lazily on first use) - const scopedContextKeyService = store.add(parentContextKeyService.createScoped(widget)); - chatAttachmentResourceContextKey.bindTo(scopedContextKeyService).set(attachment.value.uri.toString()); - setResourceContext(accessor, scopedContextKeyService, attachment.value.uri); - + // Context menu (context key service and resource contexts are initialized lazily on first context menu open) + let scopedContextKeyService: IScopedContextKeyService | undefined; let providerContexts: ReadonlyArray<[IContextKey, LanguageFeatureRegistry]> | undefined; + const ensureContextKeyService = () => { + if (!scopedContextKeyService) { + scopedContextKeyService = store.add(parentContextKeyService.createScoped(widget)); + chatAttachmentResourceContextKey.bindTo(scopedContextKeyService).set(attachment.value.uri.toString()); + setResourceContext(accessor, scopedContextKeyService, attachment.value.uri); + } + return scopedContextKeyService; + }; + const ensureProviderContexts = () => { + const cks = ensureContextKeyService(); if (!providerContexts) { providerContexts = [ - [EditorContextKeys.hasDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.definitionProvider], - [EditorContextKeys.hasReferenceProvider.bindTo(scopedContextKeyService), languageFeaturesService.referenceProvider], - [EditorContextKeys.hasImplementationProvider.bindTo(scopedContextKeyService), languageFeaturesService.implementationProvider], - [EditorContextKeys.hasTypeDefinitionProvider.bindTo(scopedContextKeyService), languageFeaturesService.typeDefinitionProvider], + [EditorContextKeys.hasDefinitionProvider.bindTo(cks), languageFeaturesService.definitionProvider], + [EditorContextKeys.hasReferenceProvider.bindTo(cks), languageFeaturesService.referenceProvider], + [EditorContextKeys.hasImplementationProvider.bindTo(cks), languageFeaturesService.implementationProvider], + [EditorContextKeys.hasTypeDefinitionProvider.bindTo(cks), languageFeaturesService.typeDefinitionProvider], ]; } }; @@ -1454,6 +1472,8 @@ export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAcces const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent); dom.EventHelper.stop(domEvent, true); + const cks = ensureContextKeyService(); + try { await updateContextKeys(); } catch (e) { @@ -1461,10 +1481,10 @@ export function hookUpSymbolAttachmentDragAndContextMenu(accessor: ServicesAcces } contextMenuService.showContextMenu({ - contextKeyService: scopedContextKeyService, + contextKeyService: cks, getAnchor: () => event, getActions: () => { - const menu = menuService.getMenuActions(contextMenuId, scopedContextKeyService, { arg: attachment.value }); + const menu = menuService.getMenuActions(contextMenuId, cks, { arg: attachment.value }); return getFlatContextMenuActions(menu); }, }); diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts index 111f7d09b7c..5846de6f5ca 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts @@ -26,7 +26,6 @@ import { IChatService } from '../../common/chatService/chatService.js'; import { IChatRequestImplicitVariableEntry, IChatRequestVariableEntry, isStringImplicitContextValue, StringChatContextValue } from '../../common/attachments/chatVariableEntries.js'; import { ChatAgentLocation } from '../../common/constants.js'; import { ILanguageModelIgnoredFilesService } from '../../common/ignoredFiles.js'; -import { getPromptsTypeForLanguageId } from '../../common/promptSyntax/promptTypes.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; import { IChatContextService } from '../contextContrib/chatContextService.js'; import { ITextModel } from '../../../../../editor/common/model.js'; @@ -258,8 +257,6 @@ export class ChatImplicitContextContribution extends Disposable implements IWork return; } - const isPromptFile = languageId && getPromptsTypeForLanguageId(languageId) !== undefined; - const widgets = updateWidget ? [updateWidget] : [...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat), ...this.chatWidgetService.getWidgetsByLocations(ChatAgentLocation.EditorInline)]; for (const widget of widgets) { if (!widget.input.implicitContext) { @@ -267,7 +264,7 @@ export class ChatImplicitContextContribution extends Disposable implements IWork } const setting = this._implicitContextEnablement[widget.location]; const isFirstInteraction = widget.viewModel?.getItems().length === 0; - if ((setting === 'always' || setting === 'first' && isFirstInteraction) && !isPromptFile) { // disable implicit context for prompt files + if ((setting === 'always' || setting === 'first' && isFirstInteraction)) { // When there's a non-code active editor (e.g. Settings is open), preserve // existing values so the attachment bar stays visible. // But when there's no active editor at all, clear the values. diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts index 47e14912044..ffdb625d5c9 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatVariables.ts @@ -5,11 +5,72 @@ import { IChatVariablesService, IDynamicVariable } from '../../common/attachments/chatVariables.js'; import { IToolAndToolSetEnablementMap } from '../../common/tools/languageModelToolsService.js'; -import { IChatWidgetService } from '../chat.js'; +import { IChatWidget, IChatWidgetService } from '../chat.js'; import { ChatDynamicVariableModel } from './chatDynamicVariables.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { URI } from '../../../../../base/common/uri.js'; +export function getDynamicVariablesForWidget(widget: IChatWidget): ReadonlyArray { + if (!widget.viewModel || !widget.supportsFileReferences) { + return []; + } + + const model = widget.getContrib(ChatDynamicVariableModel.ID); + if (!model) { + return []; + } + + // track for editing state + if (widget.viewModel.editing && model.variables.length > 0) { + return model.variables; + } + + if (widget.input.attachmentModel.attachments.length > 0 && widget.viewModel.editing) { + const references: IDynamicVariable[] = []; + const editorModel = widget.inputEditor.getModel(); + const modelTextLength = editorModel?.getValueLength() ?? 0; + for (const attachment of widget.input.attachmentModel.attachments) { + // If the attachment has a range, it is a dynamic variable + if (attachment.range) { + if (attachment.range.start >= attachment.range.endExclusive) { + continue; + } + + if (attachment.range.start < 0 || attachment.range.endExclusive > modelTextLength) { + continue; + } + + if (!editorModel) { + continue; + } + + const startPos = editorModel.getPositionAt(attachment.range.start); + const endPos = editorModel.getPositionAt(attachment.range.endExclusive); + + const referenceObj: IDynamicVariable = { + id: attachment.id, + fullName: attachment.name, + modelDescription: attachment.modelDescription, + range: new Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column), + icon: attachment.icon, + isFile: attachment.kind === 'file', + isDirectory: attachment.kind === 'directory', + data: attachment.value + }; + references.push(referenceObj); + } + } + + return references.length > 0 ? references : model.variables; + } + + return model.variables; +} + +export function getSelectedToolAndToolSetsForWidget(widget: IChatWidget): IToolAndToolSetEnablementMap { + return widget.input.selectedToolsModel.entriesMap.get(); +} + export class ChatVariablesService implements IChatVariablesService { declare _serviceBrand: undefined; @@ -18,65 +79,11 @@ export class ChatVariablesService implements IChatVariablesService { ) { } getDynamicVariables(sessionResource: URI): ReadonlyArray { - // This is slightly wrong... the parser pulls dynamic references from the input widget, but there is no guarantee that message came from the input here. - // Need to ... - // - Parser takes list of dynamic references (annoying) - // - Or the parser is known to implicitly act on the input widget, and we need to call it before calling the chat service (maybe incompatible with the future, but easy) const widget = this.chatWidgetService.getWidgetBySessionResource(sessionResource); - if (!widget || !widget.viewModel || !widget.supportsFileReferences) { + if (!widget) { return []; } - - const model = widget.getContrib(ChatDynamicVariableModel.ID); - if (!model) { - return []; - } - - // track for editing state - if (widget.viewModel.editing && model.variables.length > 0) { - return model.variables; - } - - if (widget.input.attachmentModel.attachments.length > 0 && widget.viewModel.editing) { - const references: IDynamicVariable[] = []; - const editorModel = widget.inputEditor.getModel(); - const modelTextLength = editorModel?.getValueLength() ?? 0; - for (const attachment of widget.input.attachmentModel.attachments) { - // If the attachment has a range, it is a dynamic variable - if (attachment.range) { - if (attachment.range.start >= attachment.range.endExclusive) { - continue; - } - - if (attachment.range.start < 0 || attachment.range.endExclusive > modelTextLength) { - continue; - } - - if (!editorModel) { - continue; - } - - const startPos = editorModel.getPositionAt(attachment.range.start); - const endPos = editorModel.getPositionAt(attachment.range.endExclusive); - - const referenceObj: IDynamicVariable = { - id: attachment.id, - fullName: attachment.name, - modelDescription: attachment.modelDescription, - range: new Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column), - icon: attachment.icon, - isFile: attachment.kind === 'file', - isDirectory: attachment.kind === 'directory', - data: attachment.value - }; - references.push(referenceObj); - } - } - - return references.length > 0 ? references : model.variables; - } - - return model.variables; + return getDynamicVariablesForWidget(widget); } getSelectedToolAndToolSets(sessionResource: URI): IToolAndToolSetEnablementMap { @@ -84,7 +91,6 @@ export class ChatVariablesService implements IChatVariablesService { if (!widget) { return new Map(); } - return widget.input.selectedToolsModel.entriesMap.get(); - + return getSelectedToolAndToolSetsForWidget(widget); } } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 09612521caf..93251f797e3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -37,7 +37,7 @@ import '../common/widget/chatColors.js'; import { IChatEditingService } from '../common/editing/chatEditingService.js'; import { IChatLayoutService } from '../common/widget/chatLayoutService.js'; import { ChatModeService, IChatMode, IChatModeService } from '../common/chatModes.js'; -import { ChatResponseResourceFileSystemProvider } from '../common/widget/chatResponseResourceFileSystemProvider.js'; +import { ChatResponseResourceFileSystemProvider, ChatResponseResourceWorkbenchContribution, IChatResponseResourceFileSystemProvider } from '../common/widget/chatResponseResourceFileSystemProvider.js'; import { IChatService } from '../common/chatService/chatService.js'; import { ChatService } from '../common/chatService/chatServiceImpl.js'; import { IChatSessionsService } from '../common/chatSessionsService.js'; @@ -100,7 +100,7 @@ import { ChatDebugEditor } from './chatDebug/chatDebugEditor.js'; import { PromptsDebugContribution } from './promptsDebugContribution.js'; import { ChatDebugEditorInput, ChatDebugEditorInputSerializer } from './chatDebug/chatDebugEditorInput.js'; import './agentSessions/agentSessions.contribution.js'; -import { backgroundAgentDisplayName } from './agentSessions/agentSessions.js'; + import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { ChatViewId, IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService, isIChatResourceViewContext, isIChatViewViewContext } from './chat.js'; @@ -184,6 +184,12 @@ configurationRegistry.registerConfiguration({ title: nls.localize('interactiveSessionConfigurationTitle', "Chat"), type: 'object', properties: { + 'chat.experimentalSessionsWindowOverride': { + type: 'boolean', + description: nls.localize('chat.experimentalSessionsWindowOverride', "When true, enables sessions-window-specific behavior for extensions."), + default: false, + tags: ['experimental'], + }, 'chat.fontSize': { type: 'number', description: nls.localize('chat.fontSize', "Controls the font size in pixels in chat messages."), @@ -365,6 +371,12 @@ configurationRegistry.registerConfiguration({ scope: ConfigurationScope.APPLICATION_MACHINE, tags: ['experimental', 'advanced'], }, + [ChatConfiguration.AutopilotEnabled]: { + type: 'boolean', + markdownDescription: nls.localize('chat.autopilot.enabled', "Controls whether the Autopilot mode is available in the permissions picker. When enabled, Autopilot auto-approves all tool calls and continues until the task is done."), + default: true, + tags: ['experimental'], + }, [ChatConfiguration.GlobalAutoApprove]: { default: false, markdownDescription: globalAutoApproveDescription.value, @@ -647,12 +659,11 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['preview'], }, - [ChatConfiguration.PluginPaths]: { + [ChatConfiguration.PluginLocations]: { type: 'object', additionalProperties: { type: 'boolean' }, restricted: true, - markdownDescription: nls.localize('chat.plugins.paths', "Plugin directories to discover. Each key is a path that points directly to a plugin folder, and the value enables (`true`) or disables (`false`) it. Paths can be absolute or relative to the workspace root."), - default: {}, + markdownDescription: nls.localize('chat.pluginLocations', "Plugin directories to discover. Each key is a path that points directly to a plugin folder, and the value enables (`true`) or disables (`false`) it. Paths can be absolute, relative to the workspace root, or start with `~/` for the user's home directory."), scope: ConfigurationScope.MACHINE, tags: ['experimental'], }, @@ -717,6 +728,17 @@ configurationRegistry.registerConfiguration({ tags: ['experimental'], experiment: { mode: 'auto' + }, + policy: { + name: 'DeprecatedEditModeHidden', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.112', + localization: { + description: { + key: 'chat.editMode.hidden', + value: nls.localize('chat.editMode.hidden', "When enabled, hides the Edit mode from the chat mode picker."), + } + } } }, [ChatConfiguration.EnableMath]: { @@ -1059,6 +1081,15 @@ configurationRegistry.registerConfiguration({ disallowConfigurationDefault: true, tags: ['preview', 'prompts', 'hooks', 'agent'] }, + [PromptsConfig.USE_CUSTOM_AGENT_HOOKS]: { + type: 'boolean', + title: nls.localize('chat.useCustomAgentHooks.title', "Use Custom Agent Hooks",), + markdownDescription: nls.localize('chat.useCustomAgentHooks.description', "Controls whether hooks defined in custom agent frontmatter are parsed and executed. When disabled, hooks from agent files are ignored.",), + default: false, + restricted: true, + disallowConfigurationDefault: true, + tags: ['preview', 'prompts', 'hooks', 'agent'] + }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', scope: ConfigurationScope.RESOURCE, @@ -1317,6 +1348,13 @@ Registry.as(Extensions.ConfigurationMigration). return []; } }, + { + key: 'chat.plugins.paths', + migrateFn: (value: unknown, _accessor) => ([ + ['chat.plugins.paths', { value: undefined }], + [ChatConfiguration.PluginLocations, { value }] + ]) + }, ]); class ChatResolverContribution extends Disposable { @@ -1415,7 +1453,6 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr super(); this.newChatButtonExperimentIcon = ChatContextKeys.newChatButtonExperimentIcon.bindTo(this.contextKeyService); this.registerMaxRequestsSetting(); - this.registerBackgroundAgentDisplayName(); this.registerNewChatButtonIcon(); } @@ -1447,14 +1484,6 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr this._register(Event.runAndSubscribe(Event.debounce(this.entitlementService.onDidChangeEntitlement, () => { }, 1000), () => registerMaxRequestsSetting())); } - private registerBackgroundAgentDisplayName(): void { - this.experimentService.getTreatment('backgroundAgentDisplayName').then((value) => { - if (value) { - backgroundAgentDisplayName.set(value, undefined); - } - }); - } - private registerNewChatButtonIcon(): void { this.experimentService.getTreatment('chatNewButtonIcon').then((value) => { const supportedValues = ['copilot', 'new-session', 'comment']; @@ -1729,9 +1758,9 @@ registerWorkbenchContribution2(SimpleBrowserOverlay.ID, SimpleBrowserOverlay, Wo registerWorkbenchContribution2(ChatEditingEditorContextKeys.ID, ChatEditingEditorContextKeys, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatTransferContribution.ID, ChatTransferContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatContextContributions.ID, ChatContextContributions, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(ChatResponseResourceFileSystemProvider.ID, ChatResponseResourceFileSystemProvider, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(PromptUrlHandler.ID, PromptUrlHandler, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatEditingNotebookFileSystemProviderContrib.ID, ChatEditingNotebookFileSystemProviderContrib, WorkbenchPhase.BlockStartup); +registerWorkbenchContribution2(ChatResponseResourceWorkbenchContribution.ID, ChatResponseResourceWorkbenchContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(UserToolSetsContributions.ID, UserToolSetsContributions, WorkbenchPhase.Eventually); registerWorkbenchContribution2(PromptLanguageFeaturesProvider.ID, PromptLanguageFeaturesProvider, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatWindowNotifier.ID, ChatWindowNotifier, WorkbenchPhase.AfterRestored); @@ -1767,6 +1796,7 @@ registerEditorFeature(ChatPasteProvidersFeature); agentPluginDiscoveryRegistry.register(new SyncDescriptor(ConfiguredAgentPluginDiscovery)); agentPluginDiscoveryRegistry.register(new SyncDescriptor(MarketplaceAgentPluginDiscovery)); +registerSingleton(IChatResponseResourceFileSystemProvider, ChatResponseResourceFileSystemProvider, InstantiationType.Delayed); registerSingleton(IChatTransferService, ChatTransferService, InstantiationType.Delayed); registerSingleton(IChatService, ChatService, InstantiationType.Delayed); registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 7fa8e87b01e..9b3fc446d6c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -289,6 +289,12 @@ export interface IChatWidgetViewOptions { * redirect to a different workspace rather than executing locally. */ submitHandler?: (query: string, mode: ChatModeKind) => Promise; + + /** + * Whether we are running in the sessions window. + * When true, the secondary toolbar (permissions picker) is hidden. + */ + isSessionsWindow?: boolean; } export interface IChatViewViewContext { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugAttachment.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugAttachment.ts new file mode 100644 index 00000000000..2d847f283a9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugAttachment.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { URI } from '../../../../../base/common/uri.js'; +import * as nls from '../../../../../nls.js'; +import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; +import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; + +/** + * Descriptions of each debug event kind for the model. Adding a new event kind + * to {@link IChatDebugEvent} without adding an entry here will cause a compile error. + */ +const debugEventKindDescriptions: Record = { + generic: '- generic (category: "discovery"): File discovery for instructions, skills, agents, hooks. Resolving returns a fileList with full file paths, load status, skip reasons, and source folders. Always resolve these for questions about customization files.\n' + + '- generic (other): Miscellaneous logs. Resolving returns additional text details.', + toolCall: '- toolCall: A tool invocation. Resolving returns tool name, input, output, status, and duration.', + modelTurn: '- modelTurn: An LLM round-trip. Resolving returns model name, token usage, timing, errors, and prompt sections.', + subagentInvocation: '- subagentInvocation: A sub-agent spawn. Resolving returns agent name, status, duration, and counts.', + userMessage: '- userMessage: The full prompt sent to the model. Resolving returns the complete message and all prompt sections (system prompt, instructions, context). Essential for understanding what the model received.', + agentResponse: '- agentResponse: The model\'s response. Resolving returns the full response text and sections.', +}; + +function formatDebugEventsForContext(events: readonly IChatDebugEvent[]): string { + const lines: string[] = []; + for (const event of events) { + const ts = event.created.toISOString(); + const id = event.id ? ` [id=${event.id}]` : ''; + switch (event.kind) { + case 'generic': + lines.push(`[${ts}]${id} ${event.level >= 3 ? 'ERROR' : event.level >= 2 ? 'WARN' : 'INFO'}: ${event.name}${event.details ? ' - ' + event.details : ''}${event.category ? ' (category: ' + event.category + ')' : ''}`); + break; + case 'toolCall': + lines.push(`[${ts}]${id} TOOL_CALL: ${event.toolName}${event.result ? ' result=' + event.result : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); + break; + case 'modelTurn': + lines.push(`[${ts}]${id} MODEL_TURN: ${event.requestName ?? 'unknown'}${event.model ? ' model=' + event.model : ''}${event.inputTokens !== undefined ? ' tokens(in=' + event.inputTokens + ',out=' + (event.outputTokens ?? '?') + ')' : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); + break; + case 'subagentInvocation': + lines.push(`[${ts}]${id} SUBAGENT: ${event.agentName}${event.status ? ' status=' + event.status : ''}${event.durationInMillis !== undefined ? ' duration=' + event.durationInMillis + 'ms' : ''}`); + break; + case 'userMessage': + lines.push(`[${ts}]${id} USER_MESSAGE: ${event.message.substring(0, 200)}${event.message.length > 200 ? '...' : ''} (${event.sections.length} sections)`); + break; + case 'agentResponse': + lines.push(`[${ts}]${id} AGENT_RESPONSE: ${event.message.substring(0, 200)}${event.message.length > 200 ? '...' : ''} (${event.sections.length} sections)`); + break; + default: { + const _: never = event; + void _; + break; + } + } + } + return lines.join('\n'); +} + +/** + * Creates a debug events attachment for a chat session. + * This can be used to attach debug logs to a chat request. + */ +export async function createDebugEventsAttachment( + sessionResource: URI, + chatDebugService: IChatDebugService +): Promise { + chatDebugService.markDebugDataAttached(sessionResource); + if (!chatDebugService.hasInvokedProviders(sessionResource)) { + await chatDebugService.invokeProviders(sessionResource); + } + const events = chatDebugService.getEvents(sessionResource); + const summary = events.length > 0 + ? formatDebugEventsForContext(events) + : nls.localize('debugEventsSnapshot.noEvents', "No debug events found for this conversation."); + + return { + id: 'chatDebugEvents', + name: nls.localize('debugEventsSnapshot.contextName', "Debug Events Snapshot"), + icon: Codicon.output, + kind: 'debugEvents', + snapshotTime: Date.now(), + sessionResource, + value: summary, + modelDescription: 'These are the debug event logs from the current chat conversation. Analyze them to help answer the user\'s troubleshooting question.\n' + + '\n' + + 'CRITICAL INSTRUCTION: You MUST call the resolveDebugEventDetails tool on relevant events BEFORE answering. The log lines below are only summaries — they do NOT contain the actual data (file paths, prompt content, tool I/O, etc.). The real information is only available by resolving events. Never answer based solely on the summary lines. Always resolve first, then answer.\n' + + '\n' + + 'Call resolveDebugEventDetails in parallel on all events that could be relevant to the user\'s question. When in doubt, resolve more events rather than fewer.\n' + + '\n' + + 'IMPORTANT: Do NOT mention event IDs, tool resolution steps, or internal debug mechanics in your response. The user does not know about debug events or event IDs. Present your findings directly and naturally, as if you simply know the answer. Never say things like "I need to resolve events" or show event IDs.\n' + + '\n' + + 'Event types and what resolving them returns:\n' + + Object.values(debugEventKindDescriptions).join('\n'), + }; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts index e9b7160ea45..867053d97ac 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugEditor.ts @@ -65,9 +65,6 @@ export class ChatDebugEditor extends EditorPane { private readonly sessionModelListener = this._register(new MutableDisposable()); private readonly modelChangeListeners = this._register(new DisposableMap()); - /** Saved session resource so we can restore it after the editor is re-shown. */ - private savedSessionResource: URI | undefined; - /** * Stops the streaming pipeline and clears cached events for the * active session. Called when navigating away from a session or @@ -175,7 +172,10 @@ export class ChatDebugEditor extends EditorPane { this._register(this.chatService.onDidCreateModel(model => { if (this.viewState === ViewState.Home) { - this.homeView?.render(); + // Auto-navigate to the new session when the debug panel is + // already open on the home view. This avoids the user having to + // wait for the title to resolve and manually clicking the session. + this.navigateToSession(model.sessionResource); } // Track title changes per model, disposing the previous listener @@ -250,7 +250,9 @@ export class ChatDebugEditor extends EditorPane { } this.chatDebugService.activeSessionResource = sessionResource; - this.chatDebugService.invokeProviders(sessionResource); + if (!this.chatDebugService.hasInvokedProviders(sessionResource)) { + this.chatDebugService.invokeProviders(sessionResource); + } this.trackSessionModelChanges(sessionResource); this.overviewView?.setSession(sessionResource); @@ -305,43 +307,16 @@ export class ChatDebugEditor extends EditorPane { super.setEditorVisible(visible); if (visible) { this.telemetryService.publicLog2<{}, ChatDebugPanelOpenedClassification>('chatDebugPanelOpened'); - // Note: do NOT read this.options here. When the editor becomes - // visible via openEditor(), setEditorVisible fires before - // setOptions, so this.options still contains stale values from - // the previous openEditor() call. Navigation from new options - // is handled entirely by setOptions → _applyNavigationOptions. - // Here we only restore the previous state when the editor is - // re-shown without a new openEditor() call (e.g., tab switch). - if (this.viewState === ViewState.Home) { - const sessionResource = this.chatDebugService.activeSessionResource ?? this.savedSessionResource; - this.savedSessionResource = undefined; - if (sessionResource) { - this.navigateToSession(sessionResource, 'overview'); - } else { - this.showView(ViewState.Home); - } - } else { - // Re-activate the streaming pipeline for the current session, - // restoring the saved session resource if the editor was temporarily hidden. - const sessionResource = this.chatDebugService.activeSessionResource ?? this.savedSessionResource; - this.savedSessionResource = undefined; - if (sessionResource) { - this.chatDebugService.activeSessionResource = sessionResource; - this.chatDebugService.invokeProviders(sessionResource); - } else { - this.showView(ViewState.Home); - } - } - } else { - // Remember the active session so we can restore when re-shown - this.savedSessionResource = this.chatDebugService.activeSessionResource; - // Stop the streaming pipeline when the editor is hidden - this.endActiveSession(); + // Re-show the current view so it reloads events from scratch, + // ensuring correct ordering and no stale duplicates. + // Navigation from new openEditor() options is handled by + // setOptions → _applyNavigationOptions (fires after this). + this.showView(this.viewState); } } private _applyNavigationOptions(options: IChatDebugEditorOptions): void { - const { sessionResource, viewHint } = options; + const { sessionResource, viewHint, filter } = options; if (viewHint === 'logs' && sessionResource) { this.navigateToSession(sessionResource, 'logs'); } else if (viewHint === 'flowchart' && sessionResource) { @@ -356,6 +331,12 @@ export class ChatDebugEditor extends EditorPane { } else if (this.viewState === ViewState.Home) { this.showView(ViewState.Home); } + + // Apply filter text if provided (e.g. from debug events snapshot) + if (filter !== undefined && this.filterState) { + this.filterState.setTextFilter(filter); + this.logsView?.setFilterText(filter); + } } override layout(dimension: Dimension): void { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts index 8f25da718a1..fefa283cceb 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFilters.ts @@ -38,6 +38,10 @@ export class ChatDebugFilterState extends Disposable { // Text filter textFilter: string = ''; + // Parsed timestamp filters (epoch ms) + beforeTimestamp: number | undefined; + afterTimestamp: number | undefined; + isKindVisible(kind: string, category?: string): boolean { switch (kind) { case 'toolCall': return this.filterKindToolCall; @@ -70,10 +74,90 @@ export class ChatDebugFilterState extends Disposable { const normalized = text.toLowerCase(); if (this.textFilter !== normalized) { this.textFilter = normalized; + this._parseTimestampFilters(normalized); this._onDidChange.fire(); } } + setBeforeTimestamp(timestamp: number | undefined): void { + if (this.beforeTimestamp !== timestamp) { + this.beforeTimestamp = timestamp; + this._onDidChange.fire(); + } + } + + /** + * Parse `before:YYYY[-MM[-DD[THH[:MM[:SS]]]]]` from the filter text. + * Each component after the year is optional. + */ + private _parseTimestampFilters(text: string): void { + this.beforeTimestamp = ChatDebugFilterState.parseTimeToken(text, 'before'); + this.afterTimestamp = ChatDebugFilterState.parseTimeToken(text, 'after'); + } + + static parseTimeToken(text: string, prefix: string): number | undefined { + const regex = new RegExp(`${prefix}:(\\d{4})(?:-(\\d{2})(?:-(\\d{2})(?:t(\\d{1,2})(?::(\\d{2})(?::(\\d{2}))?)?)?)?)?(?!\\w)`); + const m = regex.exec(text); + if (!m) { + return undefined; + } + + const year = parseInt(m[1], 10); + const month = m[2] !== undefined ? parseInt(m[2], 10) - 1 : undefined; + const day = m[3] !== undefined ? parseInt(m[3], 10) : undefined; + const hour = m[4] !== undefined ? parseInt(m[4], 10) : undefined; + const minute = m[5] !== undefined ? parseInt(m[5], 10) : undefined; + const second = m[6] !== undefined ? parseInt(m[6], 10) : undefined; + + // For 'before:', round up to the end of the most specific unit given. + // For 'after:', use the start of the most specific unit. + if (prefix === 'before') { + if (second !== undefined) { + return new Date(year, month!, day!, hour!, minute!, second, 999).getTime(); + } else if (minute !== undefined) { + return new Date(year, month!, day!, hour!, minute, 59, 999).getTime(); + } else if (hour !== undefined) { + return new Date(year, month!, day!, hour, 59, 59, 999).getTime(); + } else if (day !== undefined) { + return new Date(year, month!, day, 23, 59, 59, 999).getTime(); + } else if (month !== undefined) { + // End of the given month + return new Date(year, month + 1, 0, 23, 59, 59, 999).getTime(); + } else { + // End of the given year + return new Date(year, 11, 31, 23, 59, 59, 999).getTime(); + } + } else { + return new Date( + year, + month ?? 0, + day ?? 1, + hour ?? 0, + minute ?? 0, + second ?? 0, + 0, + ).getTime(); + } + } + + /** Returns the text filter with before:/after: tokens removed. */ + get textFilterWithoutTimestamps(): string { + return this.textFilter + .replace(/\b(?:before|after):\d{4}(?:-\d{2}(?:-\d{2}(?:t\d{1,2}(?::\d{2}(?::\d{2})?)?)?)?)?\b/g, '') + .trim(); + } + + isTimestampVisible(created: Date): boolean { + const time = created.getTime(); + if (this.beforeTimestamp !== undefined && time > this.beforeTimestamp) { + return false; + } + if (this.afterTimestamp !== undefined && time < this.afterTimestamp) { + return false; + } + return true; + } + fire(): void { this._onDidChange.fire(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts index 442c56360b1..48e5ce0dced 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts @@ -179,13 +179,18 @@ export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { // For subagent invocations, enrich with description from the // filtered-out completion sibling, or fall back to the event's own field. - let sublabel = getEventSublabel(event, effectiveKind); + let label = getEventLabel(event, effectiveKind); + const sublabel = getEventSublabel(event, effectiveKind); let tooltip = getEventTooltip(event); let description: string | undefined; if (effectiveKind === 'subagentInvocation') { description = getSubagentDescription(event); + // Show "Subagent: " as the label so users can identify + // these nodes and see what task they perform. + label = description + ? localize('subagentWithDesc', "Subagent: {0}", truncateLabel(description, 30)) + : localize('subagentLabel', "Subagent"); if (description) { - sublabel = truncateLabel(description, 30) + (sublabel ? ` \u00b7 ${sublabel}` : ''); // Ensure description appears in tooltip if not already present if (tooltip && !tooltip.includes(description)) { const lines = tooltip.split('\n'); @@ -199,7 +204,7 @@ export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { id: event.id ?? `event-${events.indexOf(event)}`, kind: effectiveKind, category: event.kind === 'generic' ? event.category : undefined, - label: getEventLabel(event, effectiveKind), + label, sublabel, description, tooltip, @@ -524,29 +529,17 @@ function getEventLabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEvent[' const kind = effectiveKind ?? event.kind; switch (kind) { case 'userMessage': - return localize('userLabel', "User"); + return localize('userLabel', "User Message"); case 'modelTurn': return event.kind === 'modelTurn' ? (event.model ?? localize('modelTurnLabel', "Model Turn")) : localize('modelTurnLabel', "Model Turn"); case 'toolCall': - return event.kind === 'toolCall' ? event.toolName : event.kind === 'generic' ? event.name : ''; + return event.kind === 'toolCall' ? event.toolName : event.kind === 'generic' ? event.name : localize('toolCallLabel', "Tool Call"); case 'subagentInvocation': - return event.kind === 'subagentInvocation' ? event.agentName : ''; - case 'agentResponse': { - if (event.kind === 'agentResponse') { - return event.message || localize('responseLabel', "Response"); - } - // Remapped generic event — extract model name from parenthesized suffix - // e.g. "Agent response (claude-opus-4.5)" → "claude-opus-4.5" - if (event.kind === 'generic') { - const match = /\(([^)]+)\)\s*$/.exec(event.name); - if (match) { - return match[1]; - } - } - return localize('responseLabel', "Response"); - } + return event.kind === 'subagentInvocation' ? event.agentName : localize('subagentFallback', "Subagent"); + case 'agentResponse': + return localize('agentResponseLabel', "Agent Response"); case 'generic': - return event.kind === 'generic' ? event.name : ''; + return event.kind === 'generic' ? event.name : localize('genericLabel', "Event"); } } @@ -588,30 +581,32 @@ function getEventSublabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEven } case 'userMessage': case 'agentResponse': { - // For proper typed events, prefer the first section's content - // (which has the actual message text) over the `message` field - // (which is a short summary/name). Fall back to `message` when - // no sections are available. For remapped generic events, use - // the details property. + // Use the message summary as the sublabel. For remapped generic + // events, use the details property. let text: string | undefined; if (event.kind === 'userMessage' || event.kind === 'agentResponse') { - text = event.sections[0]?.content || event.message; + text = event.message; } else if (event.kind === 'generic') { text = event.details; } if (!text) { return undefined; } - // Find the first non-empty line (content may start with newlines) + // Find the first meaningful line, skipping trivial lines like + // lone brackets/braces that appear when the message is JSON. const lines = text.split('\n'); let firstLine = ''; for (const line of lines) { const trimmed = line.trim(); - if (trimmed) { + if (trimmed && trimmed.length > 2) { firstLine = trimmed; break; } } + if (!firstLine) { + // Fall back to the full text collapsed to a single line + firstLine = text.replace(/\s+/g, ' ').trim(); + } if (!firstLine) { return undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts index cf9dd80103e..403003e62bd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts @@ -159,7 +159,15 @@ function measureNodeWidth(label: string, sublabel?: string): number { } function subgraphHeaderLabel(node: FlowNode): string { - return node.description ? `${node.label}: ${node.description}` : node.label; + // For subagent nodes, the label already includes the description + // (e.g. "Subagent: Count markdown files"), so don't append it again. + if (node.kind === 'subagentInvocation') { + return node.label; + } + if (node.description && node.description !== node.label) { + return `${node.label}: ${node.description}`; + } + return node.label; } function measureSubgraphHeaderWidth(headerLabel: string): number { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts index f167776e078..0492768b660 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts @@ -85,7 +85,24 @@ export class ChatDebugHomeView extends Disposable { const items: HTMLButtonElement[] = []; for (const sessionResource of sessionResources) { - const sessionTitle = this.chatService.getSessionTitle(sessionResource) || LocalChatSessionUri.parseLocalSessionId(sessionResource) || sessionResource.toString(); + const rawTitle = this.chatService.getSessionTitle(sessionResource); + let sessionTitle: string; + if (rawTitle && !isUUID(rawTitle)) { + sessionTitle = rawTitle; + } else if (LocalChatSessionUri.isLocalSession(sessionResource)) { + sessionTitle = localize('chatDebug.newSession', "New Chat"); + } else { + // For imported/external sessions, use the stored title if available + const importedTitle = this.chatDebugService.getImportedSessionTitle(sessionResource); + if (importedTitle) { + sessionTitle = localize('chatDebug.importedSession', "Imported: {0}", importedTitle); + } else { + // Fall back to URI segment + const uriLabel = sessionResource.path || sessionResource.fragment || sessionResource.toString(); + const segment = uriLabel.replace(/^\/+/, '').split('/').pop() || uriLabel; + sessionTitle = localize('chatDebug.importedSession', "Imported: {0}", segment); + } + } const isActive = activeSessionResource !== undefined && sessionResource.toString() === activeSessionResource.toString(); const item = DOM.append(sessionList, $('button.chat-debug-home-session-item')); @@ -98,32 +115,20 @@ export class ChatDebugHomeView extends Disposable { DOM.append(item, $(`span${ThemeIcon.asCSSSelector(Codicon.comment)}`)); const titleSpan = DOM.append(item, $('span.chat-debug-home-session-item-title')); - // Show shimmer when the title is still a UUID — the session is - // either not yet loaded or hasn't produced a real title yet. - const isShimmering = isUUID(sessionTitle); - if (isShimmering) { - titleSpan.classList.add('chat-debug-home-session-item-shimmer'); - item.disabled = true; - item.setAttribute('aria-busy', 'true'); - item.setAttribute('aria-label', localize('chatDebug.loadingSession', "Loading session…")); - } else { - titleSpan.textContent = sessionTitle; - const ariaLabel = isActive - ? localize('chatDebug.sessionItemActive', "{0} (active)", sessionTitle) - : sessionTitle; - item.setAttribute('aria-label', ariaLabel); - } + titleSpan.textContent = sessionTitle; + const ariaLabel = isActive + ? localize('chatDebug.sessionItemActive', "{0} (active)", sessionTitle) + : sessionTitle; + item.setAttribute('aria-label', ariaLabel); if (isActive) { DOM.append(item, $('span.chat-debug-home-session-badge', undefined, localize('chatDebug.active', "Active"))); } - if (!isShimmering) { - this.renderDisposables.add(DOM.addDisposableListener(item, DOM.EventType.CLICK, () => { - this._onNavigateToSession.fire(sessionResource); - })); - items.push(item); - } + this.renderDisposables.add(DOM.addDisposableListener(item, DOM.EventType.CLICK, () => { + this._onNavigateToSession.fire(sessionResource); + })); + items.push(item); } // Arrow key navigation between session items diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index 5fa4cde6b6f..550fa005b81 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -10,8 +10,9 @@ import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IObjectTreeElement } from '../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../base/common/observable.js'; +import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; @@ -28,6 +29,8 @@ import { ChatDebugEventRenderer, ChatDebugEventDelegate, ChatDebugEventTreeRende import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem, LogsViewMode } from './chatDebugTypes.js'; import { ChatDebugFilterState, bindFilterContextKeys } from './chatDebugFilters.js'; import { ChatDebugDetailPanel } from './chatDebugDetailPanel.js'; +import { IChatWidgetService } from '../chat.js'; +import { createDebugEventsAttachment } from './chatDebugAttachment.js'; const $ = DOM.$; @@ -61,6 +64,7 @@ export class ChatDebugLogsView extends Disposable { private currentDimension: Dimension | undefined; private readonly eventListener = this._register(new MutableDisposable()); private readonly sessionStateDisposable = this._register(new MutableDisposable()); + private readonly refreshScheduler: RunOnceScheduler; private shimmerRow!: HTMLElement; constructor( @@ -70,8 +74,10 @@ export class ChatDebugLogsView extends Disposable { @IChatDebugService private readonly chatDebugService: IChatDebugService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, ) { super(); + this.refreshScheduler = this._register(new RunOnceScheduler(() => this.refreshList(), 50)); this.container = DOM.append(parent, $('.chat-debug-logs')); DOM.hide(this.container); @@ -104,7 +110,7 @@ export class ChatDebugLogsView extends Disposable { new ServiceCollection([IContextKeyService, scopedContextKeyService]) )); this.filterWidget = this._register(childInstantiationService.createInstance(FilterWidget, { - placeholder: localize('chatDebug.search', "Filter (e.g. text, !exclude)"), + placeholder: localize('chatDebug.search', "Filter (e.g. text, !exclude, before:YYYY-MM-DDTHH:MM:SS)"), ariaLabel: localize('chatDebug.filterAriaLabel', "Filter debug events"), })); @@ -119,6 +125,22 @@ export class ChatDebugLogsView extends Disposable { const filterContainer = DOM.append(this.headerContainer, $('.viewpane-filter-container')); filterContainer.appendChild(this.filterWidget.element); + // Troubleshoot button + const troubleshootButton = this._register(new Button(this.headerContainer, { ...defaultButtonStyles, secondary: true, title: localize('chatDebug.troubleshoot', "Add snapshot to Chat") })); + troubleshootButton.element.classList.add('chat-debug-troubleshoot-button', 'monaco-text-button'); + DOM.append(troubleshootButton.element, $(`span${ThemeIcon.asCSSSelector(Codicon.chatSparkle)}`)); + this._register(troubleshootButton.onDidClick(async () => { + if (!this.currentSessionResource) { + return; + } + const widget = await this.chatWidgetService.openSession(this.currentSessionResource); + if (widget) { + const attachment = await createDebugEventsAttachment(this.currentSessionResource, this.chatDebugService); + widget.attachmentModel.addContext(attachment); + widget.focusInput(); + } + })); + this._register(this.filterWidget.onDidChangeFilterText(text => { this.filterState.setTextFilter(text); })); @@ -241,6 +263,10 @@ export class ChatDebugLogsView extends Disposable { this.currentSessionResource = sessionResource; } + setFilterText(text: string): void { + this.filterWidget.setFilterText(text); + } + show(): void { DOM.show(this.container); this.loadEvents(); @@ -297,8 +323,11 @@ export class ChatDebugLogsView extends Disposable { return this.filterState.isKindVisible(e.kind, category); }); - // Filter by text search - const filterText = this.filterState.textFilter; + // Filter by timestamp (before:/after: syntax) + filtered = filtered.filter(e => this.filterState.isTimestampVisible(e.created)); + + // Filter by text search (excluding before:/after: tokens) + const filterText = this.filterState.textFilterWithoutTimestamps; if (filterText) { const terms = filterText.split(/\s*,\s*/).filter(t => t.length > 0); const includeTerms = terms.filter(t => !t.startsWith('!')).map(t => t.trim()); @@ -357,18 +386,52 @@ export class ChatDebugLogsView extends Disposable { } addEvent(event: IChatDebugEvent): void { - this.events.push(event); - this.refreshList(); + // Binary-insert to maintain chronological order without a full sort. + // Events almost always arrive in order, so the insertion point is + // typically at the end (O(log n) comparison, O(1) splice). + const time = event.created.getTime(); + let lo = 0; + let hi = this.events.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (this.events[mid].created.getTime() <= time) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo === this.events.length) { + this.events.push(event); + } else { + this.events.splice(lo, 0, event); + } + this.scheduleRefresh(); + } + + private scheduleRefresh(): void { + if (!this.refreshScheduler.isScheduled()) { + this.refreshScheduler.schedule(); + } } private loadEvents(): void { this.events = [...this.chatDebugService.getEvents(this.currentSessionResource || undefined)]; - this.eventListener.value = this.chatDebugService.onDidAddEvent(e => { + + const addEventDisposable = this.chatDebugService.onDidAddEvent(e => { if (!this.currentSessionResource || e.sessionResource.toString() === this.currentSessionResource.toString()) { - this.events.push(e); + this.addEvent(e); + } + }); + + // Reload events when provider events are cleared (before re-invoking providers) + const clearEventsDisposable = this.chatDebugService.onDidClearProviderEvents(sessionResource => { + if (!this.currentSessionResource || sessionResource.toString() === this.currentSessionResource.toString()) { + this.events = [...this.chatDebugService.getEvents(this.currentSessionResource || undefined)]; this.refreshList(); } }); + + this.eventListener.value = combinedDisposable(addEventDisposable, clearEventsDisposable); this.updateBreadcrumb(); this.trackSessionState(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts index 6555bf09308..fc5ce77add7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugOverviewView.ts @@ -16,7 +16,7 @@ import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles } from '../../../.. import { ChatDebugLogLevel, IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { ChatAgentLocation } from '../../common/constants.js'; -import { IChatSessionsService } from '../../common/chatSessionsService.js'; +import { IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType, LocalChatSessionUri } from '../../common/model/chatUri.js'; import { IChatWidgetService } from '../chat.js'; import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem } from './chatDebugTypes.js'; @@ -159,7 +159,7 @@ export class ChatDebugOverviewView extends Disposable { // Session type const sessionType = getChatSessionType(sessionUri); const contribution = this.chatSessionsService.getChatSessionContribution(sessionType); - const sessionTypeName = contribution?.displayName || (sessionType === 'local' + const sessionTypeName = contribution?.displayName || (sessionType === localChatSessionType ? localize('chatDebug.sessionType.local', "Local") : sessionType); details.push({ label: localize('chatDebug.detail.sessionType', "Session Type"), value: sessionTypeName }); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts index c3469734712..a6ac1bc9799 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugTypes.ts @@ -19,6 +19,8 @@ const $ = DOM.$; export interface IChatDebugEditorOptions extends IEditorOptions { readonly sessionResource?: URI; readonly viewHint?: 'home' | 'overview' | 'logs' | 'flowchart'; + /** When set, automatically applies this text as the log filter. */ + readonly filter?: string; } export const enum ViewState { diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css index 93068162a11..7efa2352ee7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -283,6 +283,7 @@ .chat-debug-editor-header .viewpane-filter-container { flex: 1; max-width: 500px; + margin-right: auto; } .chat-debug-editor-header .viewpane-filter-container .monaco-inputbox { border-color: var(--vscode-panelInput-border, transparent) !important; @@ -293,6 +294,12 @@ align-items: center; gap: 6px; } +.chat-debug-troubleshoot-button.monaco-button { + width: auto; + display: inline-flex; + align-items: center; + flex-shrink: 0; +} .chat-debug-view-mode-labels { display: grid; } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCheckpointTimelineImpl.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCheckpointTimelineImpl.ts index 0877eac0fd6..500774bfeb8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCheckpointTimelineImpl.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingCheckpointTimelineImpl.ts @@ -123,7 +123,19 @@ export class ChatEditingCheckpointTimelineImpl implements IChatEditingCheckpoint // Find the next edit operation that would be applied... const nextOperation = operations.find(op => op.epoch >= currentEpoch); - const nextCheckpoint = nextOperation && checkpoints.find(op => op.epoch > nextOperation.epoch); + + // When there are no more operations, advance one request at a time + // by finding the next request-start checkpoint boundary. + if (!nextOperation) { + const nextRequestStart = checkpoints.find(cp => cp.epoch >= currentEpoch && cp.undoStopId === undefined); + if (!nextRequestStart) { + return maxEncounteredEpoch + 1; + } + const requestAfter = checkpoints.find(cp => cp.epoch > nextRequestStart.epoch && cp.undoStopId === undefined); + return requestAfter ? requestAfter.epoch : (maxEncounteredEpoch + 1); + } + + const nextCheckpoint = checkpoints.find(op => op.epoch > nextOperation.epoch); // And figure out where we're going if we're navigating across request // 1. If there is no next request or if the next target checkpoint is in diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css index a29b8276312..fc44d44f42f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingEditorOverlay.css @@ -8,25 +8,25 @@ color: var(--vscode-foreground); background-color: var(--vscode-editorWidget-background); border-radius: 6px; - border: 1px solid var(--vscode-contrastBorder); + border: 1px solid var(--vscode-editorWidget-border); display: flex; align-items: center; justify-content: center; gap: 4px; z-index: 10; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-md); overflow: hidden; } @keyframes pulse { 0% { - box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-md); } 50% { - box-shadow: 0 2px 8px 4px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); } 100% { - box-shadow: 0 2px 8px 0 var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-md); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingExplanationWidget.css b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingExplanationWidget.css index 7bfca698ff8..71dbdf0a6d7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingExplanationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditingExplanationWidget.css @@ -11,7 +11,7 @@ background-color: var(--vscode-editorWidget-background); border: 1px solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder)); border-radius: 8px; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); font-size: 12px; line-height: 1.4; opacity: 0; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css index 402bd4b6b0b..5e5b64f1fcd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/media/chatEditorController.css @@ -7,7 +7,7 @@ opacity: 0; transition: opacity 0.2s ease-in-out; display: flex; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-md); border-radius: 6px; overflow: hidden; } @@ -21,7 +21,7 @@ border-radius: 6px; background-color: var(--vscode-editorWidget-background); color: var(--vscode-foreground); - border: 1px solid var(--vscode-contrastBorder); + border: 1px solid var(--vscode-editorWidget-border); overflow: hidden; } diff --git a/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts index 494c639a67a..b027ff798b9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts +++ b/src/vs/workbench/contrib/chat/browser/chatRepoInfo.ts @@ -109,7 +109,7 @@ function determineChangeType(resource: ISCMResource, groupId: string): 'added' | * files is the presence/absence of a trailing newline (content otherwise identical), * no diff will be generated because VS Code's diff algorithm treats the lines as equal. */ -async function generateUnifiedDiff( +export async function generateUnifiedDiff( fileService: IFileService, relPath: string, originalUri: URI | undefined, diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts index a21e2483b8d..241eeb90adf 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts @@ -7,6 +7,7 @@ import './media/chatSessionPickerActionItem.css'; import { IAction } from '../../../../../base/common/actions.js'; import { Event } from '../../../../../base/common/event.js'; import * as dom from '../../../../../base/browser/dom.js'; +import { getActiveWindow } from '../../../../../base/browser/dom.js'; import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction, IActionWidgetDropdownOptions } from '../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -19,6 +20,7 @@ import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { renderLabelWithIcons, renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../../nls.js'; import { URI } from '../../../../../base/common/uri.js'; +import { IChatInputPickerOptions } from '../widget/input/chatInputPickerActionItem.js'; export interface IChatSessionPickerDelegate { @@ -41,6 +43,7 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI action: IAction, initialState: { group: IChatSessionProviderOptionGroup; item: IChatSessionProviderOptionItem | undefined }, protected readonly delegate: IChatSessionPickerDelegate, + protected readonly _pickerOptions: IChatInputPickerOptions | undefined, @IActionWidgetService actionWidgetService: IActionWidgetService, @IContextKeyService contextKeyService: IContextKeyService, @IKeybindingService keybindingService: IKeybindingService, @@ -61,6 +64,7 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI }, actionBarActionProvider: undefined, reporter: { id: group.id, name: `ChatSession:${group.name}`, includeOptions: false }, + getAnchor: () => this._getAnchorElement(), }; super(actionWithLabel, sessionPickerActionWidgetOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); @@ -153,6 +157,17 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI }; } + /** + * Returns the anchor element for the dropdown. + * Falls back to the overflow anchor if this element is not in the DOM. + */ + private _getAnchorElement(): HTMLElement { + if (this.element && getActiveWindow().document.contains(this.element)) { + return this.element; + } + return this._pickerOptions?.getOverflowAnchor?.() ?? this.element!; + } + protected override renderLabel(element: HTMLElement): IDisposable | null { const domChildren = []; element.classList.add('chat-session-option-picker'); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 4f53329ca1b..766b321d55b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -8,7 +8,7 @@ import { raceCancellationError } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { AsyncEmitter, Emitter, Event } from '../../../../../base/common/event.js'; -import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import * as resources from '../../../../../base/common/resources.js'; @@ -37,20 +37,20 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatService, IChatToolInvocation } from '../../common/chatService/chatService.js'; -import { autorun, autorunIterableDelta, observableFromEvent, observableSignalFromEvent } from '../../../../../base/common/observable.js'; +import { autorun, observableFromEvent } from '../../../../../base/common/observable.js'; import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../chat.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; -import { AgentSessionProviders, backgroundAgentDisplayName, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; +import { AgentSessionProviders, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; import { BugIndicatingError, isCancellationError } from '../../../../../base/common/errors.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; -import { LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js'; import { assertNever } from '../../../../../base/common/assert.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { Target } from '../../common/promptSyntax/service/promptsService.js'; +import { Target } from '../../common/promptSyntax/promptTypes.js'; const extensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatSessions', @@ -347,7 +347,6 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ ).recomputeInitiallyAndOnChange(this._store); this._register(autorun(reader => { - backgroundAgentDisplayName.read(reader); const activatedProviders = [...builtinSessionProviders, ...contributedSessionProviders.read(reader)]; for (const provider of Object.values(AgentSessionProviders)) { if (activatedProviders.includes(provider)) { @@ -407,7 +406,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } private registerContribution(contribution: IChatSessionsExtensionPoint, ext: IRelaxedExtensionDescription): IDisposable { + this._logService.info(`[ChatSessionsService] registerContribution called for type='${contribution.type}', canDelegate=${contribution.canDelegate}, when='${contribution.when}', extension='${ext.identifier.value}'`); if (this._contributions.has(contribution.type)) { + this._logService.info(`[ChatSessionsService] registerContribution: type='${contribution.type}' already registered, skipping`); return { dispose: () => { } }; } @@ -643,6 +644,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ for (const { contribution, extension } of this._contributions.values()) { const isCurrentlyRegistered = this._contributionDisposables.has(contribution.type); const shouldBeRegistered = this._isContributionAvailable(contribution); + this._logService.trace(`[ChatSessionsService] _evaluateAvailability: type='${contribution.type}', isCurrentlyRegistered=${isCurrentlyRegistered}, shouldBeRegistered=${shouldBeRegistered}, when='${contribution.when}'`); if (isCurrentlyRegistered && !shouldBeRegistered) { // Disable the contribution by disposing its disposable store this._contributionDisposables.deleteAndDispose(contribution.type); @@ -669,6 +671,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } private _enableContribution(contribution: IChatSessionsExtensionPoint, ext: IRelaxedExtensionDescription): void { + this._logService.info(`[ChatSessionsService] _enableContribution: type='${contribution.type}', canDelegate=${contribution.canDelegate}`); const disposableStore = new DisposableStore(); this._contributionDisposables.set(contribution.type, disposableStore); if (contribution.canDelegate) { @@ -781,20 +784,20 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return !!controller; } - async canResolveChatSession(chatSessionResource: URI) { + async canResolveChatSession(sessionType: string) { await this._extensionService.whenInstalledExtensionsRegistered(); - const resolvedType = this._resolveToPrimaryType(chatSessionResource.scheme) || chatSessionResource.scheme; + const resolvedType = this._resolveToPrimaryType(sessionType) || sessionType; const contribution = this._contributions.get(resolvedType)?.contribution; if (contribution && !this._isContributionAvailable(contribution)) { return false; } - if (this._contentProviders.has(chatSessionResource.scheme)) { + if (this._contentProviders.has(sessionType)) { return true; } - await this._extensionService.activateByEvent(`onChatSession:${chatSessionResource.scheme}`); - return this._contentProviders.has(chatSessionResource.scheme); + await this._extensionService.activateByEvent(`onChatSession:${sessionType}`); + return this._contentProviders.has(sessionType); } private async tryActivateControllers(providersToResolve: readonly string[] | undefined): Promise { @@ -912,46 +915,6 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ }; } - public registerChatModelChangeListeners( - chatService: IChatService, - chatSessionType: string, - onChange: () => void - ): IDisposable { - const disposableStore = new DisposableStore(); - const chatModelsICareAbout = chatService.chatModels.map(models => - Array.from(models).filter((model: IChatModel) => model.sessionResource.scheme === chatSessionType) - ); - - const listeners = new ResourceMap(); - const autoRunDisposable = autorunIterableDelta( - reader => chatModelsICareAbout.read(reader), - ({ addedValues, removedValues }) => { - removedValues.forEach((removed) => { - const listener = listeners.get(removed.sessionResource); - if (listener) { - listeners.delete(removed.sessionResource); - listener.dispose(); - } - }); - addedValues.forEach((added) => { - const requestChangeListener = added.lastRequestObs.map(last => last?.response && observableSignalFromEvent('chatSessions.modelRequestChangeListener', last.response.onDidChange)); - const modelChangeListener = observableSignalFromEvent('chatSessions.modelChangeListener', added.onDidChange); - listeners.set(added.sessionResource, autorun(reader => { - requestChangeListener.read(reader)?.read(reader); - modelChangeListener.read(reader); - onChange(); - })); - }); - } - ); - disposableStore.add(toDisposable(() => { - for (const listener of listeners.values()) { listener.dispose(); } - })); - disposableStore.add(autoRunDisposable); - return disposableStore; - } - - public getInProgressSessionDescription(chatModel: IChatModel): string | undefined { const requests = chatModel.getRequests(); if (requests.length === 0) { @@ -1024,7 +987,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } } - if (!(await raceCancellationError(this.canResolveChatSession(sessionResource), token))) { + if (!(await raceCancellationError(this.canResolveChatSession(sessionResource.scheme), token))) { throw Error(`Can not find provider for ${sessionResource}`); } @@ -1044,7 +1007,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ let session: IChatSession; const newSessionOptions = this.getNewSessionOptionsForSessionType(resolvedType); - if (sessionResource.path.startsWith('/untitled') && newSessionOptions) { + if (isUntitledChatSession(sessionResource) && newSessionOptions) { session = { sessionResource: sessionResource, onWillDispose: Event.None, @@ -1052,14 +1015,14 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ options: newSessionOptions ?? {}, dispose: () => { } }; - - for (const [optionId, value] of Object.entries(newSessionOptions ?? {})) { - this.setSessionOption(sessionResource, optionId, value); - } } else { session = await raceCancellationError(provider.provideChatSessionContent(sessionResource, token), token); } + for (const [optionId, value] of Object.entries(session.options ?? {})) { + this.setSessionOption(sessionResource, optionId, value); + } + // Make sure another session wasn't created while we were awaiting the provider { const existingSessionData = this._sessions.get(sessionResource); @@ -1284,7 +1247,6 @@ export type NewChatSessionOpenOptions = { readonly type: string; readonly position: ChatSessionPosition; readonly displayName: string; - readonly chatResource?: UriComponents; readonly replaceEditor?: boolean; }; @@ -1360,10 +1322,6 @@ async function openChatSession(accessor: ServicesAccessor, openOptions: NewChatS } export function getResourceForNewChatSession(options: NewChatSessionOpenOptions): URI { - if (options.chatResource) { - return URI.revive(options.chatResource); - } - const isRemoteSession = options.type !== AgentSessionProviders.Local; if (isRemoteSession) { return URI.from({ @@ -1377,7 +1335,7 @@ export function getResourceForNewChatSession(options: NewChatSessionOpenOptions) return ChatEditorInput.getNewEditorUri(); } - return LocalChatSessionUri.forSession(generateUuid()); + return LocalChatSessionUri.getNewSessionUri(); } function isAgentSessionProviderType(type: string): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts index 9d22269371f..fd19654bcef 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/searchableOptionPickerActionItem.ts @@ -22,6 +22,7 @@ import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from './chatS import { ILogService } from '../../../../../platform/log/common/log.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IChatInputPickerOptions } from '../widget/input/chatInputPickerActionItem.js'; interface ISearchableOptionQuickPickItem extends IQuickPickItem { readonly optionItem: IChatSessionProviderOptionItem; @@ -43,6 +44,7 @@ export class SearchableOptionPickerActionItem extends ChatSessionPickerActionIte action: IAction, initialState: { group: IChatSessionProviderOptionGroup; item: IChatSessionProviderOptionItem | undefined }, delegate: IChatSessionPickerDelegate, + pickerOptions: IChatInputPickerOptions | undefined, @IActionWidgetService actionWidgetService: IActionWidgetService, @IContextKeyService contextKeyService: IContextKeyService, @IKeybindingService keybindingService: IKeybindingService, @@ -51,7 +53,7 @@ export class SearchableOptionPickerActionItem extends ChatSessionPickerActionIte @ICommandService commandService: ICommandService, @ITelemetryService telemetryService: ITelemetryService, ) { - super(action, initialState, delegate, actionWidgetService, contextKeyService, keybindingService, commandService, telemetryService); + super(action, initialState, delegate, pickerOptions, actionWidgetService, contextKeyService, keybindingService, commandService, telemetryService); } protected override getDropdownActions(): IActionWidgetDropdownAction[] { diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index d110a659c91..4a7157945cc 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -250,6 +250,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr if (options?.inputValue) { const chatWidget = await widgetService.revealWidget(); + chatWidget?.input.showScrollbarUntilAccept(); chatWidget?.setInput(options.inputValue); } @@ -467,9 +468,9 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr //#region Editor Context Menu - function registerGenerateCodeCommand(coreCommand: 'chat.internal.explain' | 'chat.internal.fix' | 'chat.internal.review', actualCommand: string): void { + function registerGenerateCodeCommand(coreCommand: 'chat.internal.explain' | 'chat.internal.fix' | 'chat.internal.review' | 'chat.internal.codeReview.run', actualCommand: string): void { - CommandsRegistry.registerCommand(coreCommand, async accessor => { + CommandsRegistry.registerCommand(coreCommand, async (accessor, ...args) => { const commandService = accessor.get(ICommandService); const codeEditorService = accessor.get(ICodeEditorService); const markerService = accessor.get(IMarkerService); @@ -499,6 +500,10 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr if (result) { await commandService.executeCommand(actualCommand); } + break; + } + case 'chat.internal.codeReview.run': { + return commandService.executeCommand(actualCommand, ...args); } } }); @@ -506,6 +511,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr registerGenerateCodeCommand('chat.internal.explain', 'github.copilot.chat.explain'); registerGenerateCodeCommand('chat.internal.fix', 'github.copilot.chat.fix'); registerGenerateCodeCommand('chat.internal.review', 'github.copilot.chat.review'); + registerGenerateCodeCommand('chat.internal.codeReview.run', 'github.copilot.chat.codeReview.run'); const internalGenerateCodeContext = ContextKeyExpr.and( ChatContextKeys.Setup.hidden.negate(), diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 1ffcf9f1cbb..4d183f09c9e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -29,10 +29,11 @@ import { CONFIGURE_PROMPTS_ACTION_ID } from './promptSyntax/runPromptAction.js'; import { CONFIGURE_SKILLS_ACTION_ID } from './promptSyntax/skillActions.js'; import { AutoApproveStorageKeys, - globalAutoApproveDescription + globalAutoApproveDescription, } from './tools/languageModelToolsService.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; -import { Target } from '../common/promptSyntax/service/promptsService.js'; +import { Target } from '../common/promptSyntax/promptTypes.js'; +import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; export class ChatSlashCommandsContribution extends Disposable { @@ -49,8 +50,10 @@ export class ChatSlashCommandsContribution extends Disposable { @IDialogService dialogService: IDialogService, @INotificationService notificationService: INotificationService, @IStorageService storageService: IStorageService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { super(); + this._store.add(slashCommandService.registerSlashCommand({ command: 'clear', detail: nls.localize('clear', "Start a new chat and archive the current one"), @@ -67,7 +70,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z3_hooks', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, async () => { await instantiationService.invokeFunction(showConfigureHooksQuickPick); })); @@ -77,7 +81,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z3_models', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, async () => { await commandService.executeCommand(OpenModelPickerAction.ID); })); @@ -98,27 +103,31 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z3_plugins', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, async () => { await commandService.executeCommand(ManagePluginsAction.ID); })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'debug', - detail: nls.localize('debug', "Show Chat Debug View"), - sortText: 'z3_debug', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat], - }, async () => { - await commandService.executeCommand('github.copilot.debug.showChatLogView'); - })); + if (!this.environmentService.isSessionsWindow) { + this._store.add(slashCommandService.registerSlashCommand({ + command: 'debug', + detail: nls.localize('debug', "Show Chat Debug View"), + sortText: 'z3_debug', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat], + }, async () => { + await commandService.executeCommand('github.copilot.debug.showChatLogView'); + })); + } this._store.add(slashCommandService.registerSlashCommand({ command: 'agents', detail: nls.localize('agents', "Configure custom agents"), sortText: 'z3_agents', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, async () => { await commandService.executeCommand(OpenModePickerAction.ID); })); @@ -128,7 +137,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z3_skills', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, async () => { await commandService.executeCommand(CONFIGURE_SKILLS_ACTION_ID); })); @@ -138,7 +148,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z3_instructions', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, async () => { await commandService.executeCommand(CONFIGURE_INSTRUCTIONS_ACTION_ID); })); @@ -148,7 +159,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z3_prompts', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + target: Target.VSCode }, async () => { await commandService.executeCommand(CONFIGURE_PROMPTS_ACTION_ID); })); diff --git a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts index f0bc32db08f..6a93f4d568f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipCatalog.ts @@ -123,15 +123,11 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ id: 'tip.switchToAuto', tier: ChatTipTier.Foundational, priority: 0, - buildMessage(ctx) { - const label = getCommandLabel('workbench.action.chat.openModelPicker'); - const kb = formatKeybinding(ctx, 'workbench.action.chat.openModelPicker'); + buildMessage(_ctx) { return new MarkdownString( localize( 'tip.switchToAuto', - "Using gpt-4.1? Try switching to [{0}](command:workbench.action.chat.openModelPicker){1} for better coding performance.", - label, - kb + "Using GPT-4.1? Try switching to [Auto](command:workbench.action.chat.openModelPicker \"Open Model Picker\") in the model picker for better coding performance." ) ); }, @@ -146,7 +142,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.init', - "Use [{0}](command:{1}){2} to generate or update a workspace instructions file for AI coding agents.", + "Use [{0}](command:{1} \"Run /init\"){2} to generate or update a workspace instructions file for AI coding agents.", '/init', GENERATE_AGENT_INSTRUCTIONS_COMMAND_ID, kb @@ -167,7 +163,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.createPrompt', - "Use [{0}](command:{1}){2} to generate a reusable prompt file with the agent.", + "Use [{0}](command:{1} \"Run /create-prompt\"){2} to generate a reusable prompt file with the agent.", '/create-prompt', GENERATE_PROMPT_COMMAND_ID, kb @@ -189,7 +185,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.createAgent', - "Use [{0}](command:{1}){2} to scaffold a custom agent for your workflow.", + "Use [{0}](command:{1} \"Run /create-agent\"){2} to scaffold a custom agent for your workflow.", '/create-agent', GENERATE_AGENT_COMMAND_ID, kb @@ -211,7 +207,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.createSkill', - "Use [{0}](command:{1}){2} to create a skill the agent can load when relevant.", + "Use [{0}](command:{1} \"Run /create-skill\"){2} to create a skill the agent can load when relevant.", '/create-skill', GENERATE_SKILL_COMMAND_ID, kb @@ -224,25 +220,6 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ TipTrackingCommands.CreateSkillUsed, ], }, - { - id: 'tip.agentMode', - tier: ChatTipTier.Foundational, - priority: 10, - buildMessage(ctx) { - const label = getCommandLabel('workbench.action.chat.openEditSession'); - const kb = formatKeybinding(ctx, 'workbench.action.chat.openEditSession'); - return new MarkdownString( - localize( - 'tip.agentMode', - "Try [{0}](command:workbench.action.chat.openEditSession){1} to make edits across your project and run commands.", - label, - kb - ) - ); - }, - when: ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Agent), - excludeWhenModesUsed: [ChatModeKind.Agent], - }, { id: 'tip.planMode', tier: ChatTipTier.Foundational, @@ -252,7 +229,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.planMode', - "Try the [{0}](command:workbench.action.chat.openPlan){1} to research and plan before implementing changes.", + "Try the [{0}](command:workbench.action.chat.openPlan \"Start Plan Mode\"){1} to research and plan before implementing changes.", 'Plan agent', kb ) @@ -293,7 +270,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( - localize('tip.undoChanges', "Select \"Restore Checkpoint\" to undo changes after that point in the chat conversation.") + localize('tip.undoChanges', "Hover a previous request and select \"Restore Checkpoint\" to undo changes after that point in the chat conversation.") ); }, when: ContextKeyExpr.and( @@ -324,7 +301,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.forkConversation', - "Use [{0}](command:{1}){2} to branch the conversation. Explore a different approach without losing the original context.", + "Use [{0}](command:{1} \"Run /fork\"){2} to branch the conversation. Explore a different approach without losing the original context.", '/fork', INSERT_FORK_CONVERSATION_COMMAND_ID, kb @@ -337,26 +314,6 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ TipTrackingCommands.ForkConversationUsed, ], }, - { - id: 'tip.yoloMode', - tier: ChatTipTier.Qol, - buildMessage() { - return new MarkdownString( - localize( - 'tip.yoloMode', - "Enable [{0}](command:workbench.action.openSettings?%5B%22{1}%22%5D) to give the agent full control without manual confirmation.", - 'auto approve', - ChatConfiguration.GlobalAutoApprove - ) - ); - }, - when: ContextKeyExpr.and( - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - ContextKeyExpr.notEquals('config.chat.tools.global.autoApprove', true), - ), - excludeWhenSettingsChanged: [ChatConfiguration.GlobalAutoApprove], - dismissWhenCommandsClicked: ['workbench.action.openSettings'], - }, { id: 'tip.agenticBrowser', tier: ChatTipTier.Qol, @@ -364,7 +321,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.agenticBrowser', - "Enable [{0}](command:workbench.action.openSettings?%5B%22workbench.browser.enableChatTools%22%5D) to let the agent open and interact with pages in the Integrated Browser.", + "Enable [{0}](command:workbench.action.openSettings?%5B%22workbench.browser.enableChatTools%22%5D \"Open Settings\") to let the agent open and interact with pages in the Integrated Browser.", 'agentic browser integration' ) ); @@ -381,7 +338,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( - localize('tip.mermaid', "Ask the agent to draw an architectural diagram or flow chart; it can render Mermaid diagrams directly in chat.") + localize('tip.mermaid', "Ask the agent to draw an architectural diagram or flow chart. It can render Mermaid diagrams directly in chat.") ); }, when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), @@ -392,7 +349,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ tier: ChatTipTier.Qol, buildMessage() { return new MarkdownString( - localize('tip.subagents', "Ask the agent to work in parallel to complete large tasks faster.") + localize('tip.subagents', "Have another task to work on? Start a new session to run multiple agents at once.") ); }, when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), @@ -405,7 +362,7 @@ export const TIP_CATALOG: readonly ITipDefinition[] = [ return new MarkdownString( localize( 'tip.thinkingPhrases', - "Customize the loading messages shown while the agent works with [{0}](command:workbench.action.openSettings?%5B%22{1}%22%5D).", + "Customize the loading messages shown while the agent works with [{0}](command:workbench.action.openSettings?%5B%22{1}%22%5D \"Open Settings\").", 'thinking phrases', ChatConfiguration.ThinkingPhrases ) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 40a007908dd..fc596d0a732 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -20,7 +20,7 @@ import { IChatService } from '../common/chatService/chatService.js'; import { CreateSlashCommandsUsageTracker } from './createSlashCommandsUsageTracker.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, IParsedChatRequest } from '../common/requestParser/chatParserTypes.js'; +import { ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, IParsedChatRequest } from '../common/requestParser/chatParserTypes.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { TipEligibilityTracker } from './chatTipEligibilityTracker.js'; import { ChatTipTier, extractCommandIds, ITipBuildContext, ITipDefinition, TIP_CATALOG } from './chatTipCatalog.js'; @@ -41,9 +41,7 @@ type ChatTipClassification = { }; // Re-export tracking commands for backwards compatibility -export { - TipTrackingCommands, -}; +export { TipTrackingCommands }; /** @deprecated Use TipTrackingCommands.AttachFilesReferenceUsed */ export const ATTACH_FILES_REFERENCE_TRACKING_COMMAND = TipTrackingCommands.AttachFilesReferenceUsed; /** @deprecated Use TipTrackingCommands.CreateAgentInstructionsUsed */ @@ -148,6 +146,12 @@ export interface IChatTipService { */ hasMultipleTips(): boolean; + /** + * Records usage of a slash command to update tip eligibility for flows where + * the slash command text is transformed before request submission. + */ + recordSlashCommandUsage(command: string): void; + /** * Clears all dismissed tips so they can be shown again. */ @@ -193,7 +197,6 @@ export class ChatTipService extends Disposable implements IChatTipService { private readonly _tracker: TipEligibilityTracker; private readonly _createSlashCommandsUsageTracker: CreateSlashCommandsUsageTracker; - private _yoloModeEverEnabled: boolean; private _thinkingPhrasesEverModified: boolean; private _tipsHiddenForSession = false; private readonly _tipCommandListener = this._register(new MutableDisposable()); @@ -233,26 +236,9 @@ export class ChatTipService extends Disposable implements IChatTipService { if (slashCommandTrackingId) { this._tracker.recordCommandExecuted(slashCommandTrackingId); } - })); - // Track whether yolo mode was ever enabled - this._yoloModeEverEnabled = this._storageService.getBoolean(ChatTipStorageKeys.YoloModeEverEnabled, StorageScope.APPLICATION, false); - if (!this._yoloModeEverEnabled && this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove)) { - this._yoloModeEverEnabled = true; - this._storageService.store(ChatTipStorageKeys.YoloModeEverEnabled, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - } - if (!this._yoloModeEverEnabled) { - const configListener = this._register(new MutableDisposable()); - configListener.value = this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.GlobalAutoApprove)) { - if (this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove)) { - this._yoloModeEverEnabled = true; - this._storageService.store(ChatTipStorageKeys.YoloModeEverEnabled, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - configListener.clear(); - } - } - }); - } + this._hideShownTipIfNowIneligible(); + })); this._thinkingPhrasesEverModified = this._storageService.getBoolean(ChatTipStorageKeys.ThinkingPhrasesEverModified, StorageScope.APPLICATION, false); if (!this._thinkingPhrasesEverModified && this._isSettingModified(ChatConfiguration.ThinkingPhrases)) { @@ -286,10 +272,15 @@ export class ChatTipService extends Disposable implements IChatTipService { const slashCommand = (part as ChatRequestSlashCommandPart).slashCommand.command; return this._toSlashCommandTrackingId(slashCommand); } + + if (part.kind === ChatRequestAgentSubcommandPart.Kind) { + const subCommand = (part as ChatRequestAgentSubcommandPart).command.name; + return this._toSlashCommandTrackingId(subCommand); + } } const trimmed = message.text.trimStart(); - const match = /^\/(init|create-(?:instructions|prompt|agent|skill)|fork)(?:\s|$)/.exec(trimmed); + const match = /^(?:@\S+\s+)?\/(init|create-(?:instructions|prompt|agent|skill)|fork)(?:\s|$)/.exec(trimmed); return match ? this._toSlashCommandTrackingId(match[1]) : undefined; } @@ -311,6 +302,16 @@ export class ChatTipService extends Disposable implements IChatTipService { } } + recordSlashCommandUsage(command: string): void { + const trackingId = this._toSlashCommandTrackingId(command); + if (!trackingId) { + return; + } + + this._tracker.recordCommandExecuted(trackingId); + this._hideShownTipIfNowIneligible(); + } + resetSession(): void { this._shownTip = undefined; this._tipRequestId = undefined; @@ -467,6 +468,11 @@ export class ChatTipService extends Disposable implements IChatTipService { } if (!this._isEligible(this._shownTip, contextKeyService)) { + if (this._tracker.isExcluded(this._shownTip)) { + this.hideTip(); + return undefined; + } + const nextTip = this._findNextEligibleTip(this._shownTip.id, contextKeyService); if (nextTip) { this._shownTip = nextTip; @@ -475,6 +481,9 @@ export class ChatTipService extends Disposable implements IChatTipService { this._onDidNavigateTip.fire(tip); return tip; } + + this.hideTip(); + return undefined; } return this._createTip(this._shownTip); } @@ -503,6 +512,22 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + private _hideShownTipIfNowIneligible(): void { + if (!this._shownTip || !this._contextKeyService) { + return; + } + + if (this._tipsHiddenForSession) { + return; + } + + if (this._isEligible(this._shownTip, this._contextKeyService)) { + return; + } + + this.hideTip(); + } + private _pickTip(sourceId: string, contextKeyService: IContextKeyService): IChatTip | undefined { this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); // Record the current mode for future eligibility decisions. @@ -716,17 +741,6 @@ export class ChatTipService extends Disposable implements IChatTipService { if (this._tracker.isExcluded(tip)) { return false; } - if (tip.id === 'tip.yoloMode') { - if (this._yoloModeEverEnabled) { - this._logService.debug('#ChatTips: tip excluded because yolo mode was previously enabled', tip.id); - return false; - } - const inspected = this._configurationService.inspect(ChatConfiguration.GlobalAutoApprove); - if (inspected.policyValue === false) { - this._logService.debug('#ChatTips: tip excluded because policy restricts auto-approve', tip.id); - return false; - } - } if (tip.id === 'tip.thinkingPhrases' && this._thinkingPhrasesEverModified) { this._logService.debug('#ChatTips: tip excluded because thinking phrases setting was previously modified', tip.id); return false; diff --git a/src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts b/src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts index 547cb11b836..f16af0caf0d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipStorageKeys.ts @@ -11,8 +11,6 @@ export const ChatTipStorageKeys = { DismissedTips: 'chat.tip.dismissed', /** The ID of the last tip that was shown, for round-robin selection. */ LastTipId: 'chat.tip.lastTipId', - /** Whether the user has ever enabled global auto-approve (yolo mode). */ - YoloModeEverEnabled: 'chat.tip.yoloModeEverEnabled', /** Whether the user has ever modified the thinking phrases setting. */ ThinkingPhrasesEverModified: 'chat.tip.thinkingPhrasesEverModified', }; diff --git a/src/vs/workbench/contrib/chat/browser/enablementActions.ts b/src/vs/workbench/contrib/chat/browser/enablementActions.ts new file mode 100644 index 00000000000..4b49ed7b426 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/enablementActions.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action, IAction } from '../../../../base/common/actions.js'; +import { localize } from '../../../../nls.js'; +import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { ContributionEnablementState, IEnablementModel, isContributionDisabled } from '../common/enablement.js'; + +/** + * Creates the four standard enablement actions (Enable, Enable Workspace, + * Disable, Disable Workspace) for a contribution identified by a string key. + */ +export function createEnablementActions( + key: string, + enablementModel: IEnablementModel, + idPrefix: string, +): [enable: Action, enableWorkspace: Action, disable: Action, disableWorkspace: Action] { + return [ + new Action(`${idPrefix}.enable`, localize('enable', "Enable"), undefined, true, + () => { enablementModel.setEnabled(key, ContributionEnablementState.EnabledProfile); return Promise.resolve(); }), + new Action(`${idPrefix}.enableForWorkspace`, localize('enableForWorkspace', "Enable (Workspace)"), undefined, true, + () => { enablementModel.setEnabled(key, ContributionEnablementState.EnabledWorkspace); return Promise.resolve(); }), + new Action(`${idPrefix}.disable`, localize('disable', "Disable"), undefined, true, + () => { enablementModel.setEnabled(key, ContributionEnablementState.DisabledProfile); return Promise.resolve(); }), + new Action(`${idPrefix}.disableForWorkspace`, localize('disableForWorkspace', "Disable (Workspace)"), undefined, true, + () => { enablementModel.setEnabled(key, ContributionEnablementState.DisabledWorkspace); return Promise.resolve(); }), + ]; +} + +/** + * Builds the standard enablement context-menu action group for a + * contribution. Returns either the enable or disable actions depending + * on the current state, with workspace variants included only when a + * workspace is open. + */ +export function buildEnablementContextMenuGroup( + enablementState: ContributionEnablementState, + key: string, + enablementModel: IEnablementModel, + workspaceContextService: IWorkspaceContextService, + idPrefix: string, +): IAction[] { + const hasWorkspace = workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY; + const [enable, enableWorkspace, disable, disableWorkspace] = createEnablementActions(key, enablementModel, idPrefix); + const actions: IAction[] = []; + if (isContributionDisabled(enablementState)) { + actions.push(enable); + if (hasWorkspace) { + actions.push(enableWorkspace); + } + } else { + actions.push(disable); + if (hasWorkspace) { + actions.push(disableWorkspace); + } + } + return actions; +} diff --git a/src/vs/workbench/contrib/chat/browser/enablementStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/enablementStatusWidget.ts new file mode 100644 index 00000000000..fae6918b782 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/enablementStatusWidget.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { reset } from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, autorun } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { ContributionEnablementState } from '../common/enablement.js'; + +/** + * A small reusable widget that renders an enablement status message inside + * a `.status` container, matching the style used by the extension and MCP + * server editors. The message is shown only when the contribution is + * disabled and is rendered as markdown with a theme icon prefix. + */ +export class EnablementStatusWidget extends Disposable { + + private readonly _renderDisposables = this._register(new MutableDisposable()); + + constructor( + private readonly _container: HTMLElement, + enablement: IObservable, + private readonly _labels: { + disabledProfile: string; + disabledWorkspace: string; + }, + @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, + ) { + super(); + this._register(autorun(reader => { + this._render(enablement.read(reader)); + })); + } + + private _render(state: ContributionEnablementState): void { + reset(this._container); + this._renderDisposables.value = undefined; + + let message: string | undefined; + if (state === ContributionEnablementState.DisabledProfile) { + message = this._labels.disabledProfile; + } else if (state === ContributionEnablementState.DisabledWorkspace) { + message = this._labels.disabledWorkspace; + } + + if (!message) { + return; + } + + const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + markdown.appendMarkdown(`$(${Codicon.info.id}) `); + markdown.appendText(message); + const rendered = this._markdownRendererService.render(markdown); + this._renderDisposables.value = rendered; + this._container.appendChild(rendered.element); + } +} + +/** Default labels for plugin enablement status. */ +export const pluginEnablementLabels = { + disabledProfile: localize('pluginDisabled', "This plugin is disabled."), + disabledWorkspace: localize('pluginDisabledWorkspace', "This plugin is disabled for this workspace."), +}; + +/** Default labels for MCP server enablement status. */ +export const mcpServerEnablementLabels = { + disabledProfile: localize('mcpServerDisabled', "This MCP server is disabled."), + disabledWorkspace: localize('mcpServerDisabledWorkspace', "This MCP server is disabled for this workspace."), +}; diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index fe24a8fda9b..39b0b79f015 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -3,13 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Action } from '../../../../base/common/actions.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; -import { IPluginInstallService } from '../common/plugins/pluginInstallService.js'; -import { IMarketplacePlugin, IPluginMarketplaceService } from '../common/plugins/pluginMarketplaceService.js'; +import { IPluginInstallService, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../common/plugins/pluginInstallService.js'; +import { IMarketplacePlugin, IPluginMarketplaceService, hasSourceChanged, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; export class PluginInstallService implements IPluginInstallService { declare readonly _serviceBrand: undefined; @@ -19,9 +26,238 @@ export class PluginInstallService implements IPluginInstallService { @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, @IFileService private readonly _fileService: IFileService, @INotificationService private readonly _notificationService: INotificationService, + @IDialogService private readonly _dialogService: IDialogService, + @ILogService private readonly _logService: ILogService, + @IProgressService private readonly _progressService: IProgressService, + @ICommandService private readonly _commandService: ICommandService, ) { } async installPlugin(plugin: IMarketplacePlugin): Promise { + if (!await this._ensureMarketplaceTrusted(plugin)) { + return; + } + + const kind = plugin.sourceDescriptor.kind; + + if (kind === PluginSourceKind.RelativePath) { + return this._installRelativePathPlugin(plugin); + } + + if (kind === PluginSourceKind.Npm || kind === PluginSourceKind.Pip) { + await this._installPackagePlugin(plugin); + return; + } + + // GitHub / GitUrl + return this._installGitPlugin(plugin); + } + + async updatePlugin(plugin: IMarketplacePlugin, silent?: boolean): Promise { + const kind = plugin.sourceDescriptor.kind; + + if (kind === PluginSourceKind.Npm || kind === PluginSourceKind.Pip) { + // Package-manager "update" re-runs install via terminal + return this._installPackagePlugin(plugin, silent); + } + + // For relative-path and git sources, delegate to repository service + return this._pluginRepositoryService.updatePluginSource(plugin, { + pluginName: plugin.name, + failureLabel: plugin.name, + marketplaceType: plugin.marketplaceType, + }); + } + + async updateAllPlugins(options: IUpdateAllPluginsOptions, token: CancellationToken): Promise { + const installed = this._pluginMarketplaceService.installedPlugins.get().filter(e => e.enabled); + if (installed.length === 0) { + return { updatedNames: [], failedNames: [] }; + } + + const updatedNames: string[] = []; + const failedNames: string[] = []; + + const doUpdate = async () => { + const gitTasks: Promise[] = []; + const packagePlugins: { installed: IMarketplacePlugin; marketplace: IMarketplacePlugin }[] = []; + + // 1. Pull each unique marketplace repository first (handles all + // relative-path plugins and ensures the marketplace index on + // disk is up-to-date before we re-read it). + const seenMarketplaces = new Set(); + for (const entry of installed) { + const ref = entry.plugin.marketplaceReference; + if (seenMarketplaces.has(ref.canonicalId)) { + continue; + } + seenMarketplaces.add(ref.canonicalId); + gitTasks.push((async () => { + if (token.isCancellationRequested) { + return; + } + + try { + const changed = await this._pluginRepositoryService.pullRepository(ref, { + pluginName: ref.displayLabel, + failureLabel: ref.displayLabel, + marketplaceType: entry.plugin.marketplaceType, + silent: options.silent, + }); + if (changed) { + updatedNames.push(ref.displayLabel); + } + } catch (err) { + this._logService.error(`[PluginInstallService] Failed to pull marketplace '${ref.displayLabel}':`, err); + failedNames.push(ref.displayLabel); + } + })()); + } + + await Promise.all(gitTasks); + + // 2. Re-fetch marketplace data *after* pulling so we see any + // updated plugin descriptors (new versions, refs, etc.). + const marketplacePlugins = await this._pluginMarketplaceService.fetchMarketplacePlugins(token); + const marketplaceByKey = new Map(); + for (const mp of marketplacePlugins) { + marketplaceByKey.set(`${mp.marketplaceReference.canonicalId}::${mp.name}`, mp); + } + + // 3. Update non-relative-path plugins individually. + const independentGitTasks: Promise[] = []; + for (const entry of installed) { + if (entry.plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) { + continue; + } + + const livePlugin = marketplaceByKey.get(`${entry.plugin.marketplaceReference.canonicalId}::${entry.plugin.name}`); + if (!livePlugin || !hasSourceChanged(entry.plugin.sourceDescriptor, livePlugin.sourceDescriptor)) { + continue; + } + + const desc = livePlugin.sourceDescriptor; + if (desc.kind === PluginSourceKind.Npm || desc.kind === PluginSourceKind.Pip) { + if (!options.force && !desc.version) { + continue; + } + packagePlugins.push({ installed: entry.plugin, marketplace: livePlugin }); + continue; + } + + independentGitTasks.push((async () => { + if (token.isCancellationRequested) { + return; + } + + try { + const changed = await this._pluginRepositoryService.updatePluginSource(livePlugin, { + pluginName: livePlugin.name, + failureLabel: livePlugin.name, + marketplaceType: livePlugin.marketplaceType, + silent: options.silent, + }); + if (changed) { + updatedNames.push(livePlugin.name); + this._pluginMarketplaceService.addInstalledPlugin(entry.pluginUri, livePlugin); + } + } catch (err) { + this._logService.error(`[PluginInstallService] Failed to update plugin '${livePlugin.name}':`, err); + failedNames.push(livePlugin.name); + } + })()); + } + + await Promise.all(independentGitTasks); + + for (const { installed: _installed, marketplace } of packagePlugins) { + if (token.isCancellationRequested) { + return; + } + + try { + const changed = await this.updatePlugin(marketplace, options?.silent); + if (changed) { + updatedNames.push(marketplace.name); + const pluginUri = this._pluginRepositoryService.getPluginSourceInstallUri(marketplace.sourceDescriptor); + this._pluginMarketplaceService.addInstalledPlugin(pluginUri, marketplace); + } + } catch (err) { + this._logService.error(`[PluginInstallService] Failed to update plugin '${marketplace.name}':`, err); + failedNames.push(marketplace.name); + } + } + }; + + if (options.silent) { + await doUpdate(); + } else { + await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: localize('updatingAllPlugins', "Updating plugins..."), + }, + doUpdate, + ); + } + + if (failedNames.length > 0) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('updateAllFailed', "Failed to update: {0}", failedNames.join(', ')), + actions: { + primary: [new Action('showGitOutput', localize('showOutput', "Show Output"), undefined, true, () => { + this._commandService.executeCommand('git.showOutput'); + })], + }, + }); + } else if (updatedNames.length > 0) { + this._pluginMarketplaceService.clearUpdatesAvailable(); + this._notificationService.notify({ + severity: Severity.Info, + message: localize('updateAllSuccess', "Updated plugins: {0}", updatedNames.join(', ')), + }); + } else if (!token.isCancellationRequested) { + this._pluginMarketplaceService.clearUpdatesAvailable(); + } + + return { updatedNames, failedNames }; + } + + getPluginInstallUri(plugin: IMarketplacePlugin): URI { + if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) { + return this._pluginRepositoryService.getPluginInstallUri(plugin); + } + return this._pluginRepositoryService.getPluginSourceInstallUri(plugin.sourceDescriptor); + } + + // --- Trust gate ------------------------------------------------------------- + + private async _ensureMarketplaceTrusted(plugin: IMarketplacePlugin): Promise { + if (this._pluginMarketplaceService.isMarketplaceTrusted(plugin.marketplaceReference)) { + return true; + } + + const { confirmed } = await this._dialogService.confirm({ + type: 'question', + message: localize('trustMarketplace', "Trust Plugins from '{0}'?", plugin.marketplaceReference.displayLabel), + detail: localize('trustMarketplaceDetail', "Plugins can run code on your machine. Only install plugins from sources you trust.\n\nSource: {0}", plugin.marketplaceReference.rawValue), + primaryButton: localize({ key: 'trustAndInstall', comment: ['&& denotes a mnemonic'] }, "&&Trust"), + custom: { + icon: Codicon.shield, + }, + }); + + if (!confirmed) { + return false; + } + + this._pluginMarketplaceService.trustMarketplace(plugin.marketplaceReference); + return true; + } + + // --- Relative-path source (existing git-based flow) ----------------------- + + private async _installRelativePathPlugin(plugin: IMarketplacePlugin): Promise { try { await this._pluginRepositoryService.ensureRepository(plugin.marketplaceReference, { progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name), @@ -55,15 +291,53 @@ export class PluginInstallService implements IPluginInstallService { this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); } - async updatePlugin(plugin: IMarketplacePlugin): Promise { - return this._pluginRepositoryService.pullRepository(plugin.marketplaceReference, { - pluginName: plugin.name, - failureLabel: plugin.name, - marketplaceType: plugin.marketplaceType, - }); + // --- GitHub / Git URL source (independent clone) -------------------------- + + private async _installGitPlugin(plugin: IMarketplacePlugin): Promise { + const repo = this._pluginRepositoryService.getPluginSource(plugin.sourceDescriptor.kind); + let pluginDir: URI; + try { + pluginDir = await this._pluginRepositoryService.ensurePluginSource(plugin, { + progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name), + failureLabel: plugin.name, + marketplaceType: plugin.marketplaceType, + }); + } catch { + return; + } + + const pluginExists = await this._fileService.exists(pluginDir); + if (!pluginExists) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('pluginSourceNotFound', "Plugin source '{0}' not found after cloning.", repo.getLabel(plugin.sourceDescriptor)), + }); + return; + } + + this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); } - getPluginInstallUri(plugin: IMarketplacePlugin): URI { - return this._pluginRepositoryService.getPluginInstallUri(plugin); + // --- Package-manager sources (npm / pip) ---------------------------------- + + private async _installPackagePlugin(plugin: IMarketplacePlugin, silent?: boolean): Promise { + const repo = this._pluginRepositoryService.getPluginSource(plugin.sourceDescriptor.kind); + if (!repo.runInstall) { + this._logService.error(`[PluginInstallService] Expected package repository for kind '${plugin.sourceDescriptor.kind}'`); + return false; + } + + // Ensure the parent cache directory exists (returns npm/ or pip/) + const installDir = await this._pluginRepositoryService.ensurePluginSource(plugin); + // The actual plugin content location (e.g. npm//node_modules/) + const pluginDir = this._pluginRepositoryService.getPluginSourceInstallUri(plugin.sourceDescriptor); + + const result = await repo.runInstall(installDir, pluginDir, plugin, { silent }); + if (!result) { + return false; + } + + this._pluginMarketplaceService.addInstalledPlugin(result.pluginDir, plugin); + return true; } } diff --git a/src/vs/workbench/contrib/chat/browser/pluginSources.ts b/src/vs/workbench/contrib/chat/browser/pluginSources.ts new file mode 100644 index 00000000000..8ea0f30210d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/pluginSources.ts @@ -0,0 +1,551 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action } from '../../../../base/common/actions.js'; +import { CancelablePromise, timeout } from '../../../../base/common/async.js'; +import { Event } from '../../../../base/common/event.js'; +import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { isWindows } from '../../../../base/common/platform.js'; +import { dirname, joinPath } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; +import { TerminalCapability, type ITerminalCommand } from '../../../../platform/terminal/common/capabilities/capabilities.js'; +import { ITerminalInstance, ITerminalService } from '../../terminal/browser/terminal.js'; +import { IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; +import { IGitHubPluginSource, IGitUrlPluginSource, IMarketplacePlugin, INpmPluginSource, IPipPluginSource, IPluginSourceDescriptor, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; +import { IPluginSource } from '../common/plugins/pluginSource.js'; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function sanitizeCacheSegment(name: string): string { + return name.replace(/[\\/:*?"<>|]/g, '_'); +} + +function gitRevisionCacheSuffix(ref?: string, sha?: string): string[] { + if (sha) { + return [`sha_${sanitizeCacheSegment(sha)}`]; + } + if (ref) { + return [`ref_${sanitizeCacheSegment(ref)}`]; + } + return []; +} + +function showGitOutputAction(commandService: ICommandService): Action { + return new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { + commandService.executeCommand('git.showOutput'); + }); +} + +function shellEscapeArg(value: string): string { + if (isWindows) { + return `"${value.replace(/[`$"]/g, '`$&')}"`; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function formatShellCommand(args: readonly string[]): string { + const [command, ...rest] = args; + return [command, ...rest.map(arg => shellEscapeArg(arg))].join(' '); +} + +// --------------------------------------------------------------------------- +// Base for git-based sources (GitHub shorthand & arbitrary Git URL) +// --------------------------------------------------------------------------- + +abstract class AbstractGitPluginSource implements IPluginSource { + abstract readonly kind: PluginSourceKind; + constructor( + @ICommandService protected readonly _commandService: ICommandService, + @IFileService protected readonly _fileService: IFileService, + @ILogService protected readonly _logService: ILogService, + @INotificationService protected readonly _notificationService: INotificationService, + @IProgressService protected readonly _progressService: IProgressService, + ) { } + + abstract getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI; + abstract getLabel(descriptor: IPluginSourceDescriptor): string; + protected abstract _cloneUrl(descriptor: IPluginSourceDescriptor): string; + protected abstract _displayLabel(descriptor: IPluginSourceDescriptor): string; + + getCleanupTarget(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI | undefined { + return this.getInstallUri(cacheRoot, descriptor); + } + + async ensure(cacheRoot: URI, plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise { + const descriptor = plugin.sourceDescriptor; + const repoDir = this.getInstallUri(cacheRoot, descriptor); + const repoExists = await this._fileService.exists(repoDir); + const label = this._displayLabel(descriptor); + + if (repoExists) { + await this._checkoutRevision(repoDir, descriptor, options?.failureLabel ?? label); + return repoDir; + } + + const progressTitle = options?.progressTitle ?? localize('cloningPluginSource', "Cloning plugin source '{0}'...", label); + const failureLabel = options?.failureLabel ?? label; + const ref = (descriptor as IGitHubPluginSource | IGitUrlPluginSource).ref; + + await this._cloneRepository(repoDir, this._cloneUrl(descriptor), progressTitle, failureLabel, ref); + await this._checkoutRevision(repoDir, descriptor, failureLabel); + return repoDir; + } + + async update(cacheRoot: URI, plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise { + const descriptor = plugin.sourceDescriptor; + const repoDir = this.getInstallUri(cacheRoot, descriptor); + const repoExists = await this._fileService.exists(repoDir); + if (!repoExists) { + this._logService.warn(`[${this.kind}] Cannot update plugin '${options?.pluginName ?? plugin.name}': source repository not cloned`); + return false; + } + + const updateLabel = options?.pluginName ?? plugin.name; + const failureLabel = options?.failureLabel ?? updateLabel; + + try { + const doUpdate = async () => { + await this._commandService.executeCommand('git.openRepository', repoDir.fsPath); + const git = descriptor as IGitHubPluginSource | IGitUrlPluginSource; + let changed: boolean; + if (git.sha) { + const headBefore = await this._commandService.executeCommand('_git.revParse', repoDir.fsPath, 'HEAD').catch(() => undefined); + await this._commandService.executeCommand('git.fetch', repoDir.fsPath); + await this._checkoutRevision(repoDir, descriptor, failureLabel); + const headAfter = await this._commandService.executeCommand('_git.revParse', repoDir.fsPath, 'HEAD').catch(() => undefined); + changed = headBefore !== headAfter; + } else { + changed = !!(await this._commandService.executeCommand('_git.pull', repoDir.fsPath)); + await this._checkoutRevision(repoDir, descriptor, failureLabel); + } + return changed; + }; + + if (options?.silent) { + return await doUpdate(); + } + + return await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: localize('updatingPluginSource', "Updating plugin '{0}'...", updateLabel), + cancellable: false, + }, + doUpdate, + ); + } catch (err) { + this._logService.error(`[${this.kind}] Failed to update plugin source '${updateLabel}':`, err); + if (!options?.silent) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('pullPluginSourceFailed', "Failed to update plugin '{0}': {1}", failureLabel, err?.message ?? String(err)), + actions: { primary: [showGitOutputAction(this._commandService)] }, + }); + } + throw err; + } + } + + // -- internal helpers --- + + private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise { + try { + await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: progressTitle, + cancellable: false, + }, + async () => { + await this._fileService.createFolder(dirname(repoDir)); + await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, repoDir.fsPath, ref); + } + ); + } catch (err) { + this._logService.error(`[${this.kind}] Failed to clone ${cloneUrl}:`, err); + this._notificationService.notify({ + severity: Severity.Error, + message: localize('cloneFailed', "Failed to install plugin '{0}': {1}", failureLabel, err?.message ?? String(err)), + actions: { primary: [showGitOutputAction(this._commandService)] }, + }); + throw err; + } + } + + private async _checkoutRevision(repoDir: URI, descriptor: IPluginSourceDescriptor, failureLabel: string): Promise { + const git = descriptor as IGitHubPluginSource | IGitUrlPluginSource; + if (!git.sha && !git.ref) { + return; + } + + try { + if (git.sha) { + await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, git.sha, true); + return; + } + await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, git.ref); + } catch (err) { + this._logService.error(`[${this.kind}] Failed to checkout revision for '${failureLabel}':`, err); + this._notificationService.notify({ + severity: Severity.Error, + message: localize('checkoutPluginSourceFailed', "Failed to checkout plugin '{0}' to requested revision: {1}", failureLabel, err?.message ?? String(err)), + actions: { primary: [showGitOutputAction(this._commandService)] }, + }); + throw err; + } + } +} + +// --------------------------------------------------------------------------- +// RelativePath — plugin lives inside a shared marketplace repository +// --------------------------------------------------------------------------- + +export class RelativePathPluginSource implements IPluginSource { + readonly kind = PluginSourceKind.RelativePath; + + getInstallUri(_cacheRoot: URI, _descriptor: IPluginSourceDescriptor): URI { + throw new Error('Use getPluginInstallUri() for relative-path sources'); + } + + async ensure(_cacheRoot: URI, _plugin: IMarketplacePlugin, _options?: IEnsureRepositoryOptions): Promise { + throw new Error('Use ensureRepository() for relative-path sources'); + } + + async update(_cacheRoot: URI, _plugin: IMarketplacePlugin, _options?: IPullRepositoryOptions): Promise { + throw new Error('Use pullRepository() for relative-path sources'); + } + + getCleanupTarget(_cacheRoot: URI, _descriptor: IPluginSourceDescriptor): URI | undefined { + return undefined; + } + + getLabel(descriptor: IPluginSourceDescriptor): string { + return (descriptor as { path: string }).path || '.'; + } +} + +// --------------------------------------------------------------------------- +// GitHub — `{ source: "github", repo: "owner/repo" }` +// --------------------------------------------------------------------------- + +export class GitHubPluginSource extends AbstractGitPluginSource { + readonly kind = PluginSourceKind.GitHub; + + getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { + const gh = descriptor as IGitHubPluginSource; + const [owner, repo] = gh.repo.split('/'); + return joinPath(cacheRoot, 'github.com', owner, repo, ...gitRevisionCacheSuffix(gh.ref, gh.sha)); + } + + getLabel(descriptor: IPluginSourceDescriptor): string { + return (descriptor as IGitHubPluginSource).repo; + } + + protected _cloneUrl(descriptor: IPluginSourceDescriptor): string { + return `https://github.com/${(descriptor as IGitHubPluginSource).repo}.git`; + } + + protected _displayLabel(descriptor: IPluginSourceDescriptor): string { + return (descriptor as IGitHubPluginSource).repo; + } +} + +// --------------------------------------------------------------------------- +// GitUrl — `{ source: "url", url: "https://…/repo.git" }` +// --------------------------------------------------------------------------- + +export class GitUrlPluginSource extends AbstractGitPluginSource { + readonly kind = PluginSourceKind.GitUrl; + + getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { + const git = descriptor as IGitUrlPluginSource; + const segments = this._gitUrlCacheSegments(git.url, git.ref, git.sha); + return joinPath(cacheRoot, ...segments); + } + + getLabel(descriptor: IPluginSourceDescriptor): string { + return (descriptor as IGitUrlPluginSource).url; + } + + protected _cloneUrl(descriptor: IPluginSourceDescriptor): string { + return (descriptor as IGitUrlPluginSource).url; + } + + protected _displayLabel(descriptor: IPluginSourceDescriptor): string { + return (descriptor as IGitUrlPluginSource).url; + } + + private _gitUrlCacheSegments(url: string, ref?: string, sha?: string): string[] { + try { + const parsed = URI.parse(url); + const authority = (parsed.authority || 'unknown').replace(/[\\/:*?"<>|]/g, '_').toLowerCase(); + const pathPart = parsed.path.replace(/^\/+/, '').replace(/\.git$/i, '').replace(/\/+$/g, ''); + const segments = pathPart.split('/').map(s => s.replace(/[\\/:*?"<>|]/g, '_')); + return [authority, ...segments, ...gitRevisionCacheSuffix(ref, sha)]; + } catch { + return ['git', url.replace(/[\\/:*?"<>|]/g, '_'), ...gitRevisionCacheSuffix(ref, sha)]; + } + } +} + +// --------------------------------------------------------------------------- +// Base for package-manager-based sources (npm, pip) +// --------------------------------------------------------------------------- + +export abstract class AbstractPackagePluginSource implements IPluginSource { + abstract readonly kind: PluginSourceKind; + constructor( + @IDialogService protected readonly _dialogService: IDialogService, + @IFileService protected readonly _fileService: IFileService, + @ILogService protected readonly _logService: ILogService, + @INotificationService protected readonly _notificationService: INotificationService, + @IProgressService protected readonly _progressService: IProgressService, + @ITerminalService protected readonly _terminalService: ITerminalService, + ) { } + + abstract getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI; + abstract getLabel(descriptor: IPluginSourceDescriptor): string; + + getCleanupTarget(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI | undefined { + return this._getCacheDir(cacheRoot, descriptor); + } + + /** + * Return the parent directory (prefix / target) where the package + * manager installs into. This is above the actual plugin content dir. + */ + protected abstract _getCacheDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI; + + /** Build the terminal command args for install. */ + protected abstract _buildInstallArgs(installDir: URI, plugin: IMarketplacePlugin): string[]; + + /** Human-readable package manager name for messages. */ + protected abstract get _managerName(): string; + + async ensure(cacheRoot: URI, plugin: IMarketplacePlugin, _options?: IEnsureRepositoryOptions): Promise { + const cacheDir = this._getCacheDir(cacheRoot, plugin.sourceDescriptor); + await this._fileService.createFolder(cacheDir); + return cacheDir; + } + + async update(cacheRoot: URI, plugin: IMarketplacePlugin, _options?: IPullRepositoryOptions): Promise { + // For package-manager sources, "update" re-runs install. + const installDir = this._getCacheDir(cacheRoot, plugin.sourceDescriptor); + const pluginDir = this.getInstallUri(cacheRoot, plugin.sourceDescriptor); + await this.runInstall(installDir, pluginDir, plugin, { silent: _options?.silent }); + return true; + } + + async runInstall(installDir: URI, pluginDir: URI, plugin: IMarketplacePlugin, options?: { silent?: boolean }): Promise<{ pluginDir: URI } | undefined> { + const args = this._buildInstallArgs(installDir, plugin); + const command = formatShellCommand(args); + const confirmed = await this._confirmTerminalCommand(plugin.name, command, options?.silent); + if (!confirmed) { + return undefined; + } + + const progressTitle = localize('installingPackagePlugin', "Installing {0} plugin '{1}'...", this._managerName, plugin.name); + const { success, terminal } = await this._runTerminalCommand(command, progressTitle); + if (!success) { + return undefined; + } + + const exists = await this._fileService.exists(pluginDir); + if (!exists) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('packagePluginNotFound', "{0} package '{1}' was not found after installation.", this._managerName, this.getLabel(plugin.sourceDescriptor)), + }); + return undefined; + } + + terminal?.dispose(); + return { pluginDir }; + } + + // -- terminal helpers (moved from PluginInstallService) --- + + private async _confirmTerminalCommand(pluginName: string, command: string, silent?: boolean): Promise { + if (silent) { + return new Promise(resolve => { + const n = this._notificationService.notify({ + severity: Severity.Info, + message: localize('confirmPluginInstallNotification', "Plugin '{0}' wants to run: {1}", pluginName, command), + actions: { + primary: [ + new Action('installPlugin', localize('install', "Install"), undefined, true, async () => resolve(true)), + ], + }, + }); + + Event.once(n.onDidClose)(() => resolve(false)); + }); + } + + const { confirmed } = await this._dialogService.confirm({ + type: 'question', + message: localize('confirmPluginInstall', "Install Plugin '{0}'?", pluginName), + detail: localize('confirmPluginInstallDetail', "This will run the following command in a terminal:\n\n{0}", command), + primaryButton: localize({ key: 'confirmInstall', comment: ['&& denotes a mnemonic'] }, "&&Install"), + }); + return confirmed; + } + + private async _runTerminalCommand(command: string, progressTitle: string) { + let terminal: ITerminalInstance | undefined; + try { + await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: progressTitle, + cancellable: false, + }, + async () => { + terminal = await this._terminalService.createTerminal({ + config: { + name: localize('pluginInstallTerminal', "Plugin Install"), + forceShellIntegration: true, + isTransient: true, + isFeatureTerminal: true, + }, + }); + await terminal.processReady; + this._terminalService.setActiveInstance(terminal); + + const commandResultPromise = this._waitForTerminalCommandCompletion(terminal); + await terminal.runCommand(command, true); + const exitCode = await commandResultPromise; + if (exitCode !== 0) { + throw new Error(localize('terminalCommandExitCode', "Command exited with code {0}", exitCode)); + } + } + ); + return { success: true, terminal }; + } catch (err) { + this._logService.error(`[${this.kind}] Terminal command failed:`, err); + this._notificationService.notify({ + severity: Severity.Error, + message: localize('terminalCommandFailed', "Plugin installation command failed: {0}", err?.message ?? String(err)), + }); + return { success: false, terminal }; + } + } + + private _waitForTerminalCommandCompletion(terminal: ITerminalInstance): Promise { + return new Promise(resolve => { + const disposables = new DisposableStore(); + let isResolved = false; + + const resolveAndDispose = (exitCode: number | undefined): void => { + if (isResolved) { + return; + } + isResolved = true; + disposables.dispose(); + resolve(exitCode); + }; + + const attachCommandFinishedListener = (): void => { + const commandDetection = terminal.capabilities.get(TerminalCapability.CommandDetection); + if (!commandDetection) { + return; + } + disposables.add(commandDetection.onCommandFinished((command: ITerminalCommand) => { + resolveAndDispose(command.exitCode ?? 0); + })); + }; + + attachCommandFinishedListener(); + disposables.add(terminal.capabilities.onDidAddCommandDetectionCapability(() => attachCommandFinishedListener())); + + const timeoutHandle: CancelablePromise = timeout(120_000); + disposables.add(toDisposable(() => timeoutHandle.cancel())); + void timeoutHandle.then(() => { + if (isResolved) { + return; + } + this._logService.warn(`[${this.kind}] Terminal command completion timed out`); + resolveAndDispose(undefined); + }); + }); + } +} + +// --------------------------------------------------------------------------- +// npm — `{ source: "npm", package: "@org/plugin" }` +// --------------------------------------------------------------------------- + +export class NpmPluginSource extends AbstractPackagePluginSource { + readonly kind = PluginSourceKind.Npm; + protected readonly _managerName = 'npm'; + + getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { + const npm = descriptor as INpmPluginSource; + return joinPath(cacheRoot, 'npm', sanitizeCacheSegment(npm.package), 'node_modules', npm.package); + } + + getLabel(descriptor: IPluginSourceDescriptor): string { + const npm = descriptor as INpmPluginSource; + return npm.version ? `${npm.package}@${npm.version}` : npm.package; + } + + protected _getCacheDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { + const npm = descriptor as INpmPluginSource; + return joinPath(cacheRoot, 'npm', sanitizeCacheSegment(npm.package)); + } + + protected _buildInstallArgs(installDir: URI, plugin: IMarketplacePlugin): string[] { + const npm = plugin.sourceDescriptor as INpmPluginSource; + const packageSpec = npm.version ? `${npm.package}@${npm.version}` : npm.package; + const args = ['npm', 'install', '--prefix', installDir.fsPath, packageSpec]; + if (npm.registry) { + args.push('--registry', npm.registry); + } + return args; + } +} + +// --------------------------------------------------------------------------- +// pip — `{ source: "pip", package: "my-plugin" }` +// --------------------------------------------------------------------------- + +export class PipPluginSource extends AbstractPackagePluginSource { + readonly kind = PluginSourceKind.Pip; + protected readonly _managerName = 'pip'; + + getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { + const pip = descriptor as IPipPluginSource; + return joinPath(cacheRoot, 'pip', sanitizeCacheSegment(pip.package)); + } + + getLabel(descriptor: IPluginSourceDescriptor): string { + const pip = descriptor as IPipPluginSource; + return pip.version ? `${pip.package}==${pip.version}` : pip.package; + } + + protected _getCacheDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { + const pip = descriptor as IPipPluginSource; + return joinPath(cacheRoot, 'pip', sanitizeCacheSegment(pip.package)); + } + + protected _buildInstallArgs(installDir: URI, plugin: IMarketplacePlugin): string[] { + const pip = plugin.sourceDescriptor as IPipPluginSource; + const packageSpec = pip.version ? `${pip.package}==${pip.version}` : pip.package; + const args = ['pip', 'install', '--target', installDir.fsPath, packageSpec]; + if (pip.registry) { + args.push('--index-url', pip.registry); + } + return args; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index 00255f74508..244bf9498ef 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -19,18 +19,19 @@ import { Action2, registerAction2 } from '../../../../../platform/actions/common import { Codicon } from '../../../../../base/common/codicons.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; -import { HOOK_TYPES, HookType, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; +import { HOOK_METADATA, HOOKS_BY_TARGET, HookType, IHookTypeMeta } from '../../common/promptSyntax/hookTypes.js'; +import { formatHookCommandLabel, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js'; import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../common/promptSyntax/hookCompatibility.js'; import { getClaudeHookTypeName, resolveClaudeHookType } from '../../common/promptSyntax/hookClaudeCompat.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; -import { findHookCommandSelection, parseAllHookFiles, IParsedHook } from './hookUtils.js'; +import { findHookCommandSelection, findHookCommandInYaml, parseAllHookFiles, IParsedHook } from './hookUtils.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; @@ -38,7 +39,7 @@ import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browse import { Range } from '../../../../../editor/common/core/range.js'; import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; -import { OS } from '../../../../../base/common/platform.js'; +import { OperatingSystem, OS } from '../../../../../base/common/platform.js'; /** * Action ID for the `Configure Hooks` action. @@ -46,7 +47,8 @@ import { OS } from '../../../../../base/common/platform.js'; const CONFIGURE_HOOKS_ACTION_ID = 'workbench.action.chat.configure.hooks'; interface IHookTypeQuickPickItem extends IQuickPickItem { - readonly hookType: typeof HOOK_TYPES[number]; + readonly hookType: HookType; + readonly hookTypeMeta: IHookTypeMeta; } interface IHookQuickPickItem extends IQuickPickItem { @@ -296,15 +298,17 @@ const enum Step { } /** - * Optional callbacks for customizing the hook creation and opening behaviour. + * Optional callbacks and settings for customizing the hook creation and opening behaviour. * The agentic editor passes these to open hooks in the embedded editor and * track worktree files for auto-commit. */ -export interface IHookQuickPickCallbacks { +export interface IHookQuickPickOptions { /** Override how the hook file is opened. If not provided, uses editorService.openEditor. */ readonly openEditor?: (resource: URI, options?: { selection?: ITextEditorSelection }) => Promise; /** Called after a new hook file is created on disk. */ readonly onHookFileCreated?: (uri: URI) => void; + /** Filter the displayed hook types to those supported by the given target. */ + readonly target?: Target; } /** @@ -313,7 +317,7 @@ export interface IHookQuickPickCallbacks { */ export async function showConfigureHooksQuickPick( accessor: ServicesAccessor, - callbacks?: IHookQuickPickCallbacks, + options?: IHookQuickPickOptions, ): Promise { const promptsService = accessor.get(IPromptsService); const quickInputService = accessor.get(IQuickInputService); @@ -344,7 +348,8 @@ export async function showConfigureHooksQuickPick( workspaceRootUri, userHome, targetOS, - CancellationToken.None + CancellationToken.None, + { includeAgentHooks: true } ); // Count hooks per type @@ -357,12 +362,6 @@ export async function showConfigureHooksQuickPick( const store = new DisposableStore(); const picker = store.add(quickInputService.createQuickPick({ useSeparators: true })); const backButton = quickInputService.backButton; - let suppressHideDispose = false; - store.add(picker.onDidHide(() => { - if (!suppressHideDispose) { - store.dispose(); - } - })); picker.show(); let step = Step.SelectHookType; @@ -376,123 +375,440 @@ export async function showConfigureHooksQuickPick( const stepHistory: Step[] = []; const goBack = (): Step | undefined => stepHistory.pop(); - while (true) { - switch (step) { - case Step.SelectHookType: { - // Step 1: Show all lifecycle events with hook counts - const hookTypeItems: IHookTypeQuickPickItem[] = HOOK_TYPES.map(hookType => { - const count = hookCountByType.get(hookType.id) ?? 0; - const countLabel = count > 0 ? ` (${count})` : ''; - return { - label: `${hookType.label}${countLabel}`, - description: hookType.description, - hookType + try { + while (true) { + switch (step) { + case Step.SelectHookType: { + // Step 1: Show lifecycle events with hook counts, filtered by target + const makeItem = ([hookType, meta]: [HookType, IHookTypeMeta]): IHookTypeQuickPickItem => { + const count = hookCountByType.get(hookType) ?? 0; + const countLabel = count > 0 ? ` (${count})` : ''; + return { + label: `${meta.label}${countLabel}`, + description: meta.description, + hookType, + hookTypeMeta: meta + }; }; - }); - picker.items = hookTypeItems; - picker.value = ''; - picker.placeholder = localize('commands.hooks.selectEvent.placeholder', 'Select a lifecycle event'); - picker.title = localize('commands.hooks.title', 'Hooks'); - picker.buttons = []; + let pickerItems: (IHookTypeQuickPickItem | IQuickPickSeparator)[]; - const result = await awaitPick(picker, backButton); + if (options?.target) { + // Filtered to a specific target + const targetHookTypes = new Set(Object.values(HOOKS_BY_TARGET[options.target])); + pickerItems = (Object.entries(HOOK_METADATA) as [HookType, IHookTypeMeta][]) + .filter(([hookType]) => targetHookTypes.has(hookType)) + .map(makeItem); + } else { + // No target: group into Default (shared), VS Code Only, Copilot CLI Only + const vscodeTypes = new Set(Object.values(HOOKS_BY_TARGET[Target.VSCode])); + const copilotTypes = new Set(Object.values(HOOKS_BY_TARGET[Target.GitHubCopilot])); + const allEntries = Object.entries(HOOK_METADATA) as [HookType, IHookTypeMeta][]; - if (!result || result === 'back') { - picker.hide(); - return; - } + const shared = allEntries.filter(([h]) => vscodeTypes.has(h) && copilotTypes.has(h)); + const vscodeOnly = allEntries.filter(([h]) => vscodeTypes.has(h) && !copilotTypes.has(h)); + const copilotOnly = allEntries.filter(([h]) => !vscodeTypes.has(h) && copilotTypes.has(h)); - selectedHookType = result; - stepHistory.push(Step.SelectHookType); - step = Step.SelectHook; - break; - } - - case Step.SelectHook: { - // Filter hooks by the selected type - const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType!.hookType.id); - - // Step 2: Show "Add new hook" + existing hooks of this type - const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = []; - - // Add "Add new hook" option at the top - hookItems.push({ - label: `$(plus) ${localize('commands.addNewHook.label', 'Add new hook...')}`, - isAddNewHook: true, - alwaysShow: true - }); - - // Add existing hooks - if (hooksOfType.length > 0) { - hookItems.push({ - type: 'separator', - label: localize('existingHooks', "Existing Hooks") - }); - - for (const entry of hooksOfType) { - const description = labelService.getUriLabel(entry.fileUri, { relative: true }); - hookItems.push({ - label: entry.commandLabel, - description, - hookEntry: entry - }); - } - } - - // Auto-execute if only "Add new hook" is available (no existing hooks) - if (hooksOfType.length === 0) { - selectedHook = hookItems[0] as IHookQuickPickItem; - } else { - picker.items = hookItems; - picker.value = ''; - picker.placeholder = localize('commands.hooks.selectHook.placeholder', 'Select a hook to open or add a new one'); - picker.title = selectedHookType!.hookType.label; - picker.buttons = [backButton]; - - const result = await awaitPick(picker, backButton); - - if (result === 'back') { - step = goBack() ?? Step.SelectHookType; - break; - } - if (!result) { - picker.hide(); - return; - } - selectedHook = result; - stepHistory.push(Step.SelectHook); - } - - // Handle clicking on existing hook (focus into command) - if (selectedHook.hookEntry) { - const entry = selectedHook.hookEntry; - let selection: ITextEditorSelection | undefined; - - // Determine the command field name to highlight based on target platform - const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS); - - // Try to find the command field to highlight - if (commandFieldName) { - try { - const content = await fileService.readFile(entry.fileUri); - selection = findHookCommandSelection( - content.value.toString(), - entry.originalHookTypeId, - entry.index, - commandFieldName - ); - } catch { - // Ignore errors and just open without selection + pickerItems = []; + if (shared.length > 0) { + pickerItems.push({ type: 'separator', label: localize('hookSection.default', "Local/Copilot CLI Agents") }); + pickerItems.push(...shared.map(makeItem)); + } + if (vscodeOnly.length > 0) { + pickerItems.push({ type: 'separator', label: localize('hookSection.vscodeOnly', "Local Agents") }); + pickerItems.push(...vscodeOnly.map(makeItem)); + } + if (copilotOnly.length > 0) { + pickerItems.push({ type: 'separator', label: localize('hookSection.copilotCliOnly', "Copilot CLI Agents") }); + pickerItems.push(...copilotOnly.map(makeItem)); } } + picker.items = pickerItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectEvent.placeholder', 'Select a lifecycle event'); + picker.title = localize('commands.hooks.title', 'Hooks'); + picker.buttons = []; + + const result = await awaitPick(picker, backButton); + + if (!result || result === 'back') { + return; + } + + selectedHookType = result; + stepHistory.push(Step.SelectHookType); + step = Step.SelectHook; + break; + } + + case Step.SelectHook: { + // Filter hooks by the selected type + const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType!.hookType); + + // Separate hooks by source + const fileHooks = hooksOfType.filter(h => !h.agentName); + const agentHooks = hooksOfType.filter(h => h.agentName); + + // Step 2: Show "Add new hook" + existing hooks of this type + const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "Add new hook" option at the top + hookItems.push({ + label: `$(plus) ${localize('commands.addNewHook.label', 'Add new hook...')}`, + isAddNewHook: true, + alwaysShow: true + }); + + // Add existing file-based hooks + if (fileHooks.length > 0) { + hookItems.push({ + type: 'separator', + label: localize('existingHooks', "Existing Hooks") + }); + + for (const entry of fileHooks) { + const description = labelService.getUriLabel(entry.fileUri, { relative: true }); + hookItems.push({ + label: entry.commandLabel, + description, + hookEntry: entry + }); + } + } + + // Add agent-defined hooks grouped by agent name + if (agentHooks.length > 0) { + const agentNames = [...new Set(agentHooks.map(h => h.agentName!))]; + for (const agentName of agentNames) { + hookItems.push({ + type: 'separator', + label: localize('agentHooks', "Agent: {0}", agentName) + }); + + for (const entry of agentHooks.filter(h => h.agentName === agentName)) { + const description = labelService.getUriLabel(entry.fileUri, { relative: true }); + hookItems.push({ + label: entry.commandLabel, + description, + hookEntry: entry + }); + } + } + } + + // Auto-execute if only "Add new hook" is available (no existing hooks) + if (hooksOfType.length === 0) { + selectedHook = hookItems[0] as IHookQuickPickItem; + } else { + picker.items = hookItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectHook.placeholder', 'Select a hook to open or add a new one'); + picker.title = selectedHookType!.hookTypeMeta.label; + picker.buttons = [backButton]; + + const result = await awaitPick(picker, backButton); + + if (result === 'back') { + step = goBack() ?? Step.SelectHookType; + break; + } + if (!result) { + return; + } + selectedHook = result; + stepHistory.push(Step.SelectHook); + } + + // Handle clicking on existing hook (focus into command) + if (selectedHook.hookEntry) { + const entry = selectedHook.hookEntry; + let selection: ITextEditorSelection | undefined; + + if (entry.agentName) { + // Agent hook: search the YAML frontmatter for the command + try { + const content = await fileService.readFile(entry.fileUri); + const commandText = formatHookCommandLabel(entry.command, targetOS); + if (commandText) { + selection = findHookCommandInYaml(content.value.toString(), commandText); + } + } catch { + // Ignore errors and just open without selection + } + } else { + // File hook: use JSON-based selection finder + const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS); + + if (commandFieldName) { + try { + const content = await fileService.readFile(entry.fileUri); + selection = findHookCommandSelection( + content.value.toString(), + entry.originalHookTypeId, + entry.index, + commandFieldName + ); + } catch { + // Ignore errors and just open without selection + } + } + } + + if (options?.openEditor) { + await options.openEditor(entry.fileUri, { selection }); + } else { + await editorService.openEditor({ + resource: entry.fileUri, + options: { + selection, + pinned: false + } + }); + } + return; + } + + // "Add new hook" was selected + step = Step.SelectFile; + break; + } + + case Step.SelectFile: { + // Step 3: Handle "Add new hook" - show create new file + existing hook files + // Get existing hook files (local storage only, not User Data) + const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, PromptsStorage.local, CancellationToken.None); + + const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "Create new hook config file" option at the top + fileItems.push({ + label: `$(new-file) ${localize('commands.createNewHookFile.label', 'Create new hook config file...')}`, + isCreateNewFile: true, + alwaysShow: true + }); + + // Add existing hook files + if (hookFiles.length > 0) { + fileItems.push({ + type: 'separator', + label: localize('existingHookFiles', "Existing Hook Files") + }); + + for (const hookFile of hookFiles) { + const relativePath = labelService.getUriLabel(hookFile.uri, { relative: true }); + fileItems.push({ + label: relativePath, + fileUri: hookFile.uri + }); + } + } + + // Auto-execute if no existing hook files + if (hookFiles.length === 0) { + selectedFile = fileItems[0] as IHookFileQuickPickItem; + } else { + picker.items = fileItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectFile.placeholder', 'Select a hook file or create a new one'); + picker.title = localize('commands.hooks.addHook.title', 'Add Hook'); + picker.buttons = [backButton]; + + const result = await awaitPick(picker, backButton); + + if (result === 'back') { + step = goBack() ?? Step.SelectHook; + break; + } + if (!result) { + return; + } + selectedFile = result; + stepHistory.push(Step.SelectFile); + } + + // Handle adding hook to existing file + if (selectedFile.fileUri) { + await addHookToFile( + selectedFile.fileUri, + selectedHookType!.hookType, + fileService, + editorService, + notificationService, + bulkEditService, + options?.openEditor, + ); + return; + } + + // "Create new hook config file" was selected + step = Step.SelectFolder; + break; + } + + case Step.SelectFolder: { + // Get source folders for hooks + const allFolders = await promptsService.getSourceFolders(PromptsType.hook); + const localFolders = allFolders.filter(f => f.storage === PromptsStorage.local); + + if (localFolders.length === 0) { + notificationService.error(localize('commands.hook.noLocalFolders', "Please open a workspace folder to configure hooks.")); + return; + } + + // Auto-select if only one folder, otherwise show picker + selectedFolder = localFolders[0]; + if (localFolders.length > 1) { + const folderItems = localFolders.map(folder => ({ + label: labelService.getUriLabel(folder.uri, { relative: true }), + folder + })); + + picker.items = folderItems; + picker.value = ''; + picker.placeholder = localize('commands.hook.selectFolder.placeholder', 'Select a location for the hook file'); + picker.title = localize('commands.hook.selectFolder.title', 'Hook File Location'); + picker.buttons = [backButton]; + + const result = await awaitPick(picker, backButton); + + if (result === 'back') { + step = goBack() ?? Step.SelectFile; + break; + } + if (!result) { + return; + } + selectedFolder = result.folder; + stepHistory.push(Step.SelectFolder); + } + + step = Step.EnterFilename; + break; + } + + case Step.EnterFilename: { + // Hide the picker and show an input box for the filename picker.hide(); - if (callbacks?.openEditor) { - await callbacks.openEditor(entry.fileUri, { selection }); + + const fileNameResult = await new Promise(resolve => { + let resolved = false; + const done = (value: string | 'back' | undefined) => { + if (!resolved) { + resolved = true; + inputDisposables.dispose(); + resolve(value); + } + }; + const inputDisposables = new DisposableStore(); + const inputBox = inputDisposables.add(quickInputService.createInputBox()); + inputBox.prompt = localize('commands.hook.filename.prompt', "Enter hook file name"); + inputBox.placeholder = localize('commands.hook.filename.placeholder', "e.g., hooks, diagnostics, security"); + inputBox.title = localize('commands.hook.filename.title', "Hook File Name"); + inputBox.buttons = [backButton]; + inputBox.ignoreFocusOut = true; + + inputDisposables.add(inputBox.onDidAccept(async () => { + const value = inputBox.value; + if (!value || !value.trim()) { + inputBox.validationMessage = localize('commands.hook.filename.required', "File name is required"); + return; + } + const name = value.trim(); + if (/[/\\:*?"<>|]/.test(name)) { + inputBox.validationMessage = localize('commands.hook.filename.invalidChars', "File name contains invalid characters"); + return; + } + done(name); + })); + inputDisposables.add(inputBox.onDidChangeValue(() => { + inputBox.validationMessage = undefined; + })); + inputDisposables.add(inputBox.onDidTriggerButton(button => { + if (button === backButton) { + done('back'); + } + })); + inputDisposables.add(inputBox.onDidHide(() => { + done(undefined); + })); + inputBox.show(); + }); + + if (fileNameResult === 'back') { + // Re-show the picker for the previous step + picker.show(); + step = goBack() ?? Step.SelectFolder; + break; + } + if (!fileNameResult) { + return; + } + + // Create the hooks folder if it doesn't exist + await fileService.createFolder(selectedFolder!.uri); + + // Use user-provided filename with .json extension + const hookFileName = fileNameResult.endsWith('.json') ? fileNameResult : `${fileNameResult}.json`; + const hookFileUri = URI.joinPath(selectedFolder!.uri, hookFileName); + + // Check if file already exists + if (await fileService.exists(hookFileUri)) { + // File exists - add hook to it instead of creating new + await addHookToFile( + hookFileUri, + selectedHookType!.hookType, + fileService, + editorService, + notificationService, + bulkEditService, + options?.openEditor, + ); + return; + } + + // Detect if new file is a Claude hooks file based on its path + const newFileFormat = getHookSourceFormat(hookFileUri); + const isClaudeNewFile = newFileFormat === HookSourceFormat.Claude; + const isCopilotCliOnly = !isClaudeNewFile + && !new Set(Object.values(HOOKS_BY_TARGET[Target.VSCode])).has(selectedHookType!.hookType) + && new Set(Object.values(HOOKS_BY_TARGET[Target.GitHubCopilot])).has(selectedHookType!.hookType); + const hookTypeKey = isClaudeNewFile + ? (getClaudeHookTypeName(selectedHookType!.hookType) ?? selectedHookType!.hookType) + : isCopilotCliOnly + ? (getCopilotCliHookTypeName(selectedHookType!.hookType) ?? selectedHookType!.hookType) + : selectedHookType!.hookType; + const newFileHookEntry = isCopilotCliOnly + ? { type: 'command', [targetOS === OperatingSystem.Windows ? 'powershell' : 'bash']: '' } + : buildNewHookEntry(newFileFormat); + const commandFieldKey = isCopilotCliOnly + ? (targetOS === OperatingSystem.Windows ? 'powershell' : 'bash') + : 'command'; + + // Create new hook file with the selected hook type + const hooksContent: Record = { + ...(isCopilotCliOnly ? { version: 1 } : {}), + hooks: { + [hookTypeKey]: [ + newFileHookEntry + ] + } + }; + + const jsonContent = JSON.stringify(hooksContent, null, '\t'); + await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); + + options?.onHookFileCreated?.(hookFileUri); + + // Find the selection for the new hook's command field + const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, commandFieldKey); + + // Open editor with selection + if (options?.openEditor) { + await options.openEditor(hookFileUri, { selection }); } else { await editorService.openEditor({ - resource: entry.fileUri, + resource: hookFileUri, options: { selection, pinned: false @@ -501,254 +817,10 @@ export async function showConfigureHooksQuickPick( } return; } - - // "Add new hook" was selected - step = Step.SelectFile; - break; - } - - case Step.SelectFile: { - // Step 3: Handle "Add new hook" - show create new file + existing hook files - // Get existing hook files (local storage only, not User Data) - const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, PromptsStorage.local, CancellationToken.None); - - const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = []; - - // Add "Create new hook config file" option at the top - fileItems.push({ - label: `$(new-file) ${localize('commands.createNewHookFile.label', 'Create new hook config file...')}`, - isCreateNewFile: true, - alwaysShow: true - }); - - // Add existing hook files - if (hookFiles.length > 0) { - fileItems.push({ - type: 'separator', - label: localize('existingHookFiles', "Existing Hook Files") - }); - - for (const hookFile of hookFiles) { - const relativePath = labelService.getUriLabel(hookFile.uri, { relative: true }); - fileItems.push({ - label: relativePath, - fileUri: hookFile.uri - }); - } - } - - // Auto-execute if no existing hook files - if (hookFiles.length === 0) { - selectedFile = fileItems[0] as IHookFileQuickPickItem; - } else { - picker.items = fileItems; - picker.value = ''; - picker.placeholder = localize('commands.hooks.selectFile.placeholder', 'Select a hook file or create a new one'); - picker.title = localize('commands.hooks.addHook.title', 'Add Hook'); - picker.buttons = [backButton]; - - const result = await awaitPick(picker, backButton); - - if (result === 'back') { - step = goBack() ?? Step.SelectHook; - break; - } - if (!result) { - picker.hide(); - return; - } - selectedFile = result; - stepHistory.push(Step.SelectFile); - } - - // Handle adding hook to existing file - if (selectedFile.fileUri) { - picker.hide(); - await addHookToFile( - selectedFile.fileUri, - selectedHookType!.hookType.id as HookType, - fileService, - editorService, - notificationService, - bulkEditService, - callbacks?.openEditor, - ); - return; - } - - // "Create new hook config file" was selected - step = Step.SelectFolder; - break; - } - - case Step.SelectFolder: { - // Get source folders for hooks - const allFolders = await promptsService.getSourceFolders(PromptsType.hook); - const localFolders = allFolders.filter(f => f.storage === PromptsStorage.local); - - if (localFolders.length === 0) { - picker.hide(); - notificationService.error(localize('commands.hook.noLocalFolders', "Please open a workspace folder to configure hooks.")); - return; - } - - // Auto-select if only one folder, otherwise show picker - selectedFolder = localFolders[0]; - if (localFolders.length > 1) { - const folderItems = localFolders.map(folder => ({ - label: labelService.getUriLabel(folder.uri, { relative: true }), - folder - })); - - picker.items = folderItems; - picker.value = ''; - picker.placeholder = localize('commands.hook.selectFolder.placeholder', 'Select a location for the hook file'); - picker.title = localize('commands.hook.selectFolder.title', 'Hook File Location'); - picker.buttons = [backButton]; - - const result = await awaitPick(picker, backButton); - - if (result === 'back') { - step = goBack() ?? Step.SelectFile; - break; - } - if (!result) { - picker.hide(); - return; - } - selectedFolder = result.folder; - stepHistory.push(Step.SelectFolder); - } - - step = Step.EnterFilename; - break; - } - - case Step.EnterFilename: { - // Hide the picker and show an input box for the filename - suppressHideDispose = true; - picker.hide(); - suppressHideDispose = false; - - const fileNameResult = await new Promise(resolve => { - let resolved = false; - const done = (value: string | 'back' | undefined) => { - if (!resolved) { - resolved = true; - inputDisposables.dispose(); - resolve(value); - } - }; - const inputDisposables = new DisposableStore(); - const inputBox = inputDisposables.add(quickInputService.createInputBox()); - inputBox.prompt = localize('commands.hook.filename.prompt', "Enter hook file name"); - inputBox.placeholder = localize('commands.hook.filename.placeholder', "e.g., hooks, diagnostics, security"); - inputBox.title = localize('commands.hook.filename.title', "Hook File Name"); - inputBox.buttons = [backButton]; - inputBox.ignoreFocusOut = true; - - inputDisposables.add(inputBox.onDidAccept(async () => { - const value = inputBox.value; - if (!value || !value.trim()) { - inputBox.validationMessage = localize('commands.hook.filename.required', "File name is required"); - return; - } - const name = value.trim(); - if (/[/\\:*?"<>|]/.test(name)) { - inputBox.validationMessage = localize('commands.hook.filename.invalidChars', "File name contains invalid characters"); - return; - } - done(name); - })); - inputDisposables.add(inputBox.onDidChangeValue(() => { - inputBox.validationMessage = undefined; - })); - inputDisposables.add(inputBox.onDidTriggerButton(button => { - if (button === backButton) { - done('back'); - } - })); - inputDisposables.add(inputBox.onDidHide(() => { - done(undefined); - })); - inputBox.show(); - }); - - if (fileNameResult === 'back') { - // Re-show the picker for the previous step - picker.show(); - step = goBack() ?? Step.SelectFolder; - break; - } - if (!fileNameResult) { - store.dispose(); - return; - } - - // Create the hooks folder if it doesn't exist - await fileService.createFolder(selectedFolder!.uri); - - // Use user-provided filename with .json extension - const hookFileName = fileNameResult.endsWith('.json') ? fileNameResult : `${fileNameResult}.json`; - const hookFileUri = URI.joinPath(selectedFolder!.uri, hookFileName); - - // Check if file already exists - if (await fileService.exists(hookFileUri)) { - // File exists - add hook to it instead of creating new - store.dispose(); - await addHookToFile( - hookFileUri, - selectedHookType!.hookType.id as HookType, - fileService, - editorService, - notificationService, - bulkEditService, - callbacks?.openEditor, - ); - return; - } - - // Detect if new file is a Claude hooks file based on its path - const newFileFormat = getHookSourceFormat(hookFileUri); - const isClaudeNewFile = newFileFormat === HookSourceFormat.Claude; - const hookTypeKey = isClaudeNewFile - ? (getClaudeHookTypeName(selectedHookType!.hookType.id as HookType) ?? selectedHookType!.hookType.id) - : selectedHookType!.hookType.id; - const newFileHookEntry = buildNewHookEntry(newFileFormat); - - // Create new hook file with the selected hook type - const hooksContent = { - hooks: { - [hookTypeKey]: [ - newFileHookEntry - ] - } - }; - - const jsonContent = JSON.stringify(hooksContent, null, '\t'); - await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); - - callbacks?.onHookFileCreated?.(hookFileUri); - - // Find the selection for the new hook's command field - const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, 'command'); - - // Open editor with selection - store.dispose(); - if (callbacks?.openEditor) { - await callbacks.openEditor(hookFileUri, { selection }); - } else { - await editorService.openEditor({ - resource: hookFileUri, - options: { - selection, - pinned: false - } - }); - } - return; } } + } finally { + store.dispose(); } } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts index e6dd6668f35..e019aecb93f 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts @@ -10,7 +10,8 @@ import { IPromptsService } from '../../common/promptSyntax/service/promptsServic import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { formatHookCommandLabel, HOOK_TYPES, HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; +import { formatHookCommandLabel, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; +import { HOOK_METADATA, HookType } from '../../common/promptSyntax/hookTypes.js'; import { parseHooksFromFile, parseHooksIgnoringDisableAll } from '../../common/promptSyntax/hookCompatibility.js'; import * as nls from '../../../../../nls.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -113,6 +114,54 @@ export function findHookCommandSelection(content: string, hookType: string, inde }; } +/** + * Finds the selection range for a hook command string in a YAML/Markdown file + * (e.g., an agent `.md` file with YAML frontmatter). + * + * Searches for the command text within command field lines and selects the value. + * Supports all hook command field keys: command, windows, linux, osx, bash, powershell. + * + * @param content The full file content + * @param commandText The command string to locate + * @returns The selection range, or undefined if not found + */ +export function findHookCommandInYaml(content: string, commandText: string): ITextEditorSelection | undefined { + const commandFieldKeys = ['command', 'windows', 'linux', 'osx', 'bash', 'powershell']; + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trimStart(); + + // Only match lines whose YAML key is a known command field + const matchedKey = commandFieldKeys.find(key => + trimmed.startsWith(`${key}:`) || trimmed.startsWith(`- ${key}:`) + ); + if (!matchedKey) { + continue; + } + + // Search after the colon to avoid matching within the key name itself + const colonIdx = line.indexOf(':'); + const idx = line.indexOf(commandText, colonIdx + 1); + if (idx !== -1) { + // Verify this is a full match (not a substring of a longer command) + const afterIdx = idx + commandText.length; + const charAfter = afterIdx < line.length ? line.charCodeAt(afterIdx) : -1; + // Accept if what follows is end of line, a quote, or whitespace + if (charAfter === -1 || charAfter === 34 /* " */ || charAfter === 39 /* ' */ || charAfter === 32 /* space */ || charAfter === 9 /* tab */) { + return { + startLineNumber: i + 1, + startColumn: idx + 1, + endLineNumber: i + 1, + endColumn: idx + 1 + commandText.length + }; + } + } + } + + return undefined; +} + /** * Parsed hook information. */ @@ -128,11 +177,15 @@ export interface IParsedHook { originalHookTypeId: string; /** If true, this hook is disabled via `disableAllHooks: true` in its file */ disabled?: boolean; + /** If set, this hook came from a custom agent's frontmatter */ + agentName?: string; } export interface IParseAllHookFilesOptions { /** Additional file URIs to parse (e.g., files skipped due to disableAllHooks) */ additionalDisabledFileUris?: readonly URI[]; + /** If true, also collect hooks from custom agent frontmatter */ + includeAgentHooks?: boolean; } /** @@ -161,7 +214,7 @@ export async function parseAllHookFiles( const { hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); for (const [hookType, { hooks: commands, originalId }] of hooks) { - const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType); + const hookTypeMeta = HOOK_METADATA[hookType]; if (!hookTypeMeta) { continue; } @@ -199,7 +252,7 @@ export async function parseAllHookFiles( const { hooks } = parseHooksIgnoringDisableAll(uri, json, workspaceRootUri, userHome); for (const [hookType, { hooks: commands, originalId }] of hooks) { - const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType); + const hookTypeMeta = HOOK_METADATA[hookType]; if (!hookTypeMeta) { continue; } @@ -226,5 +279,40 @@ export async function parseAllHookFiles( } } + // Collect hooks from custom agents' frontmatter + if (options?.includeAgentHooks) { + const agents = await promptsService.getCustomAgents(token); + for (const agent of agents) { + if (!agent.hooks) { + continue; + } + for (const hookTypeValue of Object.values(HookType)) { + const commands = agent.hooks[hookTypeValue]; + if (!commands || commands.length === 0) { + continue; + } + const hookTypeMeta = HOOK_METADATA[hookTypeValue]; + if (!hookTypeMeta) { + continue; + } + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + const commandLabel = formatHookCommandLabel(command, os) || nls.localize('commands.hook.emptyCommand', '(empty command)'); + parsedHooks.push({ + hookType: hookTypeValue, + hookTypeLabel: hookTypeMeta.label, + command, + commandLabel, + fileUri: agent.uri, + filePath: labelService.getUriLabel(agent.uri, { relative: true }), + index: i, + originalHookTypeId: hookTypeValue, + agentName: agent.name, + }); + } + } + } + } + return parsedHooks; } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index fc1d34ba099..a5c04f02c1e 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -16,7 +16,7 @@ import { KeybindingWeight } from '../../../../../platform/keybinding/common/keyb import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService, NeverShowAgainScope, Severity } from '../../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; -import { getLanguageIdForPromptsType, PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { getLanguageIdForPromptsType, PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { IUserDataSyncEnablementService, SyncResource } from '../../../../../platform/userDataSync/common/userDataSync.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { CONFIGURE_SYNC_COMMAND_ID } from '../../../../services/userDataSync/common/userDataSync.js'; @@ -26,8 +26,8 @@ import { askForPromptFileName } from './pickers/askForPromptName.js'; import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { getCleanPromptName, SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; -import { Target, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { getTarget } from '../../common/promptSyntax/languageProviders/promptValidator.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { getTarget } from '../../common/promptSyntax/languageProviders/promptFileAttributes.js'; /** * Options to override the default folder-picker and editor-open behaviour diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index aaf3a9b1340..349bb815934 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -54,6 +54,7 @@ export interface ISelectOptions { readonly optionRename?: boolean; readonly optionCopy?: boolean; readonly optionVisibility?: boolean; + readonly optionRun?: boolean; } export interface ISelectPromptResult { @@ -323,6 +324,11 @@ const MAKE_INVISIBLE_BUTTON: IQuickInputButton = { iconClass: ThemeIcon.asClassName(Codicon.eye), }; +const RUN_IN_CHAT_BUTTON: IQuickInputButton = { + tooltip: localize('runInChat', "Run in Chat View"), + iconClass: ThemeIcon.asClassName(Codicon.play), +}; + export class PromptFilePickers { constructor( @IQuickInputService private readonly _quickInputService: IQuickInputService, @@ -425,6 +431,9 @@ export class PromptFilePickers { private async _createPromptPickItems(options: ISelectOptions, token: CancellationToken): Promise<(IPromptPickerQuickPickItem | IQuickPickSeparator)[]> { const buttons: IQuickInputButton[] = []; + if (options.type === PromptsType.prompt && options.optionRun !== false) { + buttons.push(RUN_IN_CHAT_BUTTON); + } if (options.optionEdit !== false) { buttons.push(EDIT_BUTTON); } @@ -481,6 +490,9 @@ export class PromptFilePickers { const exts = (await this._promptsService.listPromptFilesForStorage(options.type, PromptsStorage.extension, token)).filter(isExtensionPromptPath); if (exts.length) { const extButtons: IQuickInputButton[] = []; + if (options.type === PromptsType.prompt && options.optionRun !== false) { + extButtons.push(RUN_IN_CHAT_BUTTON); + } if (options.optionEdit !== false) { extButtons.push(EDIT_BUTTON); } @@ -613,6 +625,15 @@ export class PromptFilePickers { } const value = item.promptFileUri; + if (button === RUN_IN_CHAT_BUTTON) { + const commandId = quickPick.keyMods.ctrlCmd === true + ? 'workbench.action.chat.run-in-new-chat.prompt.current' + : 'workbench.action.chat.run.prompt.current'; + await this._commandService.executeCommand(commandId, value); + quickPick.hide(); + return false; + } + // `edit` button was pressed, open the prompt file in editor if (button === EDIT_BUTTON) { await this._openerService.open(value); @@ -710,7 +731,8 @@ export class PromptFilePickers { optionDelete: true, optionRename: true, optionCopy: true, - optionVisibility: false + optionVisibility: false, + optionRun: false }; try { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts index ac323f512e4..599946c97bf 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts @@ -14,15 +14,15 @@ import { CommandsRegistry } from '../../../../../platform/commands/common/comman import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { showToolsPicker } from '../actions/chatToolPicker.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; -import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId, PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { IPromptsService, Target } from '../../common/promptSyntax/service/promptsService.js'; +import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId, PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { registerEditorFeature } from '../../../../../editor/common/editorFeatures.js'; import { PromptFileRewriter } from './promptFileRewriter.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IEditorModel } from '../../../../../editor/common/editorCommon.js'; -import { isTarget, parseCommaSeparatedList, PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; -import { getTarget, isVSCodeOrDefaultTarget } from '../../common/promptSyntax/languageProviders/promptValidator.js'; +import { parseCommaSeparatedList, PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; import { isBoolean } from '../../../../../base/common/types.js'; +import { getTarget, isTarget, isVSCodeOrDefaultTarget } from '../../common/promptSyntax/languageProviders/promptFileAttributes.js'; class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider { diff --git a/src/vs/workbench/contrib/chat/browser/promptsDebugContribution.ts b/src/vs/workbench/contrib/chat/browser/promptsDebugContribution.ts index 964f68d2ac0..48dc9effee2 100644 --- a/src/vs/workbench/contrib/chat/browser/promptsDebugContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/promptsDebugContribution.ts @@ -92,9 +92,6 @@ export class PromptsDebugContribution extends Disposable implements IWorkbenchCo sourceFolders: info.sourceFolders?.map(sf => ({ uri: sf.uri, storage: sf.storage, - exists: sf.exists, - fileCount: sf.fileCount, - errorMessage: sf.errorMessage, })), }; } diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts index 50a3495c4f8..3661f5a27c3 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts @@ -424,7 +424,15 @@ export class LanguageModelToolsConfirmationService extends Disposable implements }; } - manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session' }): void { + toolCanManageConfirmation(tool: IToolData): boolean { + return !!tool.canRequestPreApproval + || !!tool.canRequestPostApproval + || this._contributions.has(tool.id) + || !!this._preExecutionToolConfirmStore.checkAutoConfirmation(tool.id) + || !!this._postExecutionToolConfirmStore.checkAutoConfirmation(tool.id); + } + + manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session'; focusToolId?: string }): void { interface IToolTreeItem extends IQuickTreeItem { type: 'tool' | 'server' | 'tool-pre' | 'tool-post' | 'server-pre' | 'server-post' | 'manage'; toolId?: string; @@ -690,7 +698,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements description, checked, pickable, - collapsed: true, + collapsed: tools.length > 1, children: toolChildren.length > 0 ? toolChildren : undefined }); } @@ -773,12 +781,12 @@ export class LanguageModelToolsConfirmationService extends Disposable implements } })); - disposables.add(quickTree.onDidAccept(() => { - for (const item of quickTree.activeItems) { - if (item.type === 'manage') { - (item as ILanguageModelToolConfirmationContributionQuickTreeItem).onDidOpen?.(); - quickTree.hide(); - } + disposables.add(quickTree.onDidAccept(async () => { + const manageItem = quickTree.activeItems.find(i => i.type === 'manage'); + if (manageItem) { + quickTree.hide(); + await (manageItem as ILanguageModelToolConfirmationContributionQuickTreeItem).onDidOpen?.(); + this.manageConfirmationPreferences(tools, options); } })); @@ -787,6 +795,23 @@ export class LanguageModelToolsConfirmationService extends Disposable implements })); quickTree.show(); + + // If a focus tool was specified, expand its parent and set it as active. + // Must happen after show() since the tree data is applied via autorun on visibility. + if (options?.focusToolId) { + const focusToolId = options.focusToolId; + for (const serverItem of quickTree.itemTree) { + const serverItemTyped = serverItem as IToolTreeItem; + if (serverItemTyped.children) { + const toolItem = (serverItemTyped.children as IToolTreeItem[]).find(c => c.type === 'tool' && c.toolId === focusToolId); + if (toolItem) { + quickTree.expand(serverItem); + quickTree.reveal(toolItem); + break; + } + } + } + } } public resetToolAutoConfirmation(): void { diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 41ef02090b8..5deef252f00 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -40,15 +40,16 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IVariableReference } from '../../common/chatModes.js'; import { ConfirmedReason, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js'; -import { ChatConfiguration } from '../../common/constants.js'; +import { ChatConfiguration, isAutoApproveLevel } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { IChatModel, IChatRequestModel } from '../../common/model/chatModel.js'; import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; import { chatSessionResourceToId } from '../../common/model/chatUri.js'; -import { HookType } from '../../common/promptSyntax/hookSchema.js'; +import { HookType } from '../../common/promptSyntax/hookTypes.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, IExternalPreToolUseHookResult, ILanguageModelToolsService, IPreparedToolInvocation, isToolSet, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolResultInputOutputDetails, IToolSet, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, ToolInvocationPresentation, toolMatchesModel, ToolSet, ToolSetForModel, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; +import { IChatWidgetService } from '../chat.js'; const jsonSchemaRegistry = Registry.as(JSONContributionRegistry.Extensions.JSONContribution); @@ -69,7 +70,10 @@ const SkipAutoApproveConfirmationKey = 'vscode.chat.tools.global.autoApprove.tes // This tool will always require user confirmation even in auto approval mode. // Users cannot auto approve this tool via settings either, as this is a tool used before the agentic loop. -const toolIdThatCannotBeAutoApproved = 'vscode_get_confirmation_with_options'; +const toolIdsThatCannotBeAutoApproved = new Set([ + 'vscode_get_confirmation_with_options', + 'vscode_get_modified_files_confirmation', +]); export const globalAutoApproveDescription = localize2( { @@ -129,6 +133,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo @IStorageService private readonly _storageService: IStorageService, @ILanguageModelToolsConfirmationService private readonly _confirmationService: ILanguageModelToolsConfirmationService, @ICommandService private readonly _commandService: ICommandService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, ) { super(); @@ -586,7 +591,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo dto.toolSpecificData = toolInvocation?.toolSpecificData; if (preparedInvocation?.confirmationMessages?.title) { if (!IChatToolInvocation.executionConfirmedOrDenied(toolInvocation) && !autoConfirmed) { - this.playAccessibilitySignal([toolInvocation]); + this.playAccessibilitySignal([toolInvocation], dto.context?.sessionResource); } const userConfirmed = await IChatToolInvocation.awaitConfirmation(toolInvocation, token); if (userConfirmed.type === ToolConfirmKind.Denied) { @@ -641,7 +646,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo this.ensureToolDetails(dto, toolResult, tool.data); const afterExecuteState = await toolInvocation?.didExecuteTool(toolResult, undefined, () => - this.shouldAutoConfirmPostExecution(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, dto.context?.sessionResource)); + this.shouldAutoConfirmPostExecution(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, dto.context?.sessionResource, dto.chatRequestId)); if (toolInvocation && afterExecuteState?.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { const postConfirm = await IChatToolInvocation.awaitPostConfirmation(toolInvocation, token); @@ -791,7 +796,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } // No hook decision - use normal auto-confirm logic - const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, sessionResource); + const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, sessionResource, dto.chatRequestId); return { autoConfirmed, preparedInvocation }; } @@ -836,14 +841,14 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo ...prepared.confirmationMessages, title: localize('defaultToolConfirmation.title', 'Confirm tool execution'), message: localize('defaultToolConfirmation.message', 'Run the \'{0}\' tool?', fullReferenceName), - disclaimer: tool.data.id === toolIdThatCannotBeAutoApproved ? undefined : new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ title: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval] }, false)), { isTrusted: true }), + disclaimer: toolIdsThatCannotBeAutoApproved.has(tool.data.id) ? undefined : new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ text: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval], tooltip: localize('openSettings.autoApproval.tooltip', 'Open settings to configure auto-approval') }, false)), { isTrusted: true }), allowAutoConfirm: false, }; } if (!isEligibleForAutoApproval && prepared?.confirmationMessages?.title) { // Always overwrite the disclaimer if not eligible for auto-approval - prepared.confirmationMessages.disclaimer = tool.data.id === toolIdThatCannotBeAutoApproved ? undefined : new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ title: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval] }, false)), { isTrusted: true }); + prepared.confirmationMessages.disclaimer = toolIdsThatCannotBeAutoApproved.has(tool.data.id) ? undefined : new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ text: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval], tooltip: localize('openSettings.autoApproval.tooltip', 'Open settings to configure auto-approval') }, false)), { isTrusted: true }); } if (prepared?.confirmationMessages?.title) { @@ -943,12 +948,21 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } - private playAccessibilitySignal(toolInvocations: ChatToolInvocation[]): void { + private playAccessibilitySignal(toolInvocations: ChatToolInvocation[], chatSessionResource: URI | undefined): void { const autoApproved = this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove); if (autoApproved) { return; } + // Autopilot/auto-approve permission levels auto-approve all tools, skip signal + if (chatSessionResource) { + const model = this._chatService.getSession(chatSessionResource); + const request = model?.getRequests().at(-1); + if (isAutoApproveLevel(request?.modeInfo?.permissionLevel) || this._isSessionLiveAutoApproveLevel(chatSessionResource)) { + return; + } + } + // Filter out any tool invocations that have already been confirmed/denied. // This is a defensive check - normally the call site should prevent this, // but tools may be auto-approved through various mechanisms (per-session rules, @@ -996,6 +1010,26 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo }); } + /** + * Returns true if enterprise policy has explicitly disabled the global auto-approve setting. + * When this is the case, Bypass Approvals and Autopilot permission levels should not auto-approve tools. + */ + private _isAutoApprovePolicyRestricted(): boolean { + const inspected = this._configurationService.inspect(ChatConfiguration.GlobalAutoApprove); + return inspected.policyValue === false; + } + + /** + * Returns true if the session's current (live) permission picker level is auto-approve. + * This checks the widget's current state, not what was stamped on the request, + * so switching to Autopilot mid-session takes effect immediately. + */ + private _isSessionLiveAutoApproveLevel(chatSessionResource: URI): boolean { + const widget = this._chatWidgetService.getWidgetBySessionResource(chatSessionResource) + ?? this._chatWidgetService.lastFocusedWidget; + return !!widget && isAutoApproveLevel(widget.input.currentModeInfo.permissionLevel); + } + private getEligibleForAutoApprovalSpecialCase(toolData: IToolData): string | undefined { if (toolData.id === 'vscode_fetchWebPage_internal') { return 'fetch'; @@ -1009,7 +1043,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo // Special case, this fetch will call an internal tool 'vscode_fetchWebPage_internal' return true; } - if (toolData.id === toolIdThatCannotBeAutoApproved) { + if (toolIdsThatCannotBeAutoApproved.has(toolData.id)) { // Special case, this tool will always require user confirmation as there are multiple options, // These aren't LM generated instead are generated by extension before agentic loop starts. return false; @@ -1040,12 +1074,24 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return true; } - private async shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined): Promise { + private async shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined, chatRequestId: string | undefined): Promise { const tool = this._tools.get(toolId); if (!tool) { return undefined; } + // Auto-Approve All permission level bypasses all tool confirmations, + // unless enterprise policy has explicitly disabled global auto-approve. + // Check both the request-stamped level AND the live picker level so that + // switching to Autopilot mid-session takes effect immediately. + if (chatSessionResource && !this._isAutoApprovePolicyRestricted()) { + const model = this._chatService.getSession(chatSessionResource); + const request = model?.getRequests().at(-1); + if (isAutoApproveLevel(request?.modeInfo?.permissionLevel) || this._isSessionLiveAutoApproveLevel(chatSessionResource)) { + return { type: ToolConfirmKind.ConfirmationNotNeeded, reason: 'auto-approve-all' }; + } + } + if (!this.isToolEligibleForAutoApproval(tool.data)) { return undefined; } @@ -1077,7 +1123,18 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - private async shouldAutoConfirmPostExecution(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined): Promise { + private async shouldAutoConfirmPostExecution(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined, chatRequestId: string | undefined): Promise { + // Auto-Approve All permission level bypasses all post-execution confirmations, + // unless enterprise policy has explicitly disabled global auto-approve. + // Check both the request-stamped level AND the live picker level. + if (chatSessionResource && !this._isAutoApprovePolicyRestricted()) { + const model = this._chatService.getSession(chatSessionResource); + const request = model?.getRequests().at(-1); + if (isAutoApproveLevel(request?.modeInfo?.permissionLevel) || this._isSessionLiveAutoApproveLevel(chatSessionResource)) { + return { type: ToolConfirmKind.ConfirmationNotNeeded, reason: 'auto-approve-all' }; + } + } + if (this._configurationService.getValue(ChatConfiguration.GlobalAutoApprove) && await this._checkGlobalAutoApprove()) { return { type: ToolConfirmKind.Setting, id: ChatConfiguration.GlobalAutoApprove }; } @@ -1186,7 +1243,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo // Clean up any pending tool calls that belong to this request for (const [toolCallId, invocation] of this._pendingToolCalls) { if (invocation.chatRequestId === requestId) { - invocation.cancelFromStreaming(ToolConfirmKind.Skipped); this._pendingToolCalls.delete(toolCallId); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts index 7939aaa3099..67b9be3d84d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts @@ -33,9 +33,10 @@ export class ChatDisabledClaudeHooksContentPart extends Disposable implements IC icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.info)); const enableLink = createMarkdownCommandLink({ - title: localize('chat.disabledClaudeHooks.enableLink', "Enable"), + text: localize('chat.disabledClaudeHooks.enableLink', "Enable"), id: 'workbench.action.openSettings', arguments: [PromptsConfig.USE_CLAUDE_HOOKS], + tooltip: localize('chat.disabledClaudeHooks.enableLink.tooltip', "Open settings to enable Claude Code hooks"), }); const message = localize('chat.disabledClaudeHooks.message', "Claude Code hooks are available for this workspace. {0}", enableLink); const content = new MarkdownString(message, { isTrusted: true }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts index 2ed104c1de8..e7f8800ecee 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts @@ -10,14 +10,14 @@ import { IHoverService } from '../../../../../../platform/hover/browser/hover.js import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IChatHookPart } from '../../../common/chatService/chatService.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; -import { HookType, HOOK_TYPES, HookTypeValue } from '../../../common/promptSyntax/hookSchema.js'; +import { HookType, HOOK_METADATA, HookTypeValue } from '../../../common/promptSyntax/hookTypes.js'; import { ChatTreeItem } from '../../chat.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import './media/chatHookContentPart.css'; function getHookTypeLabel(hookType: HookTypeValue): string { - return HOOK_TYPES.find(hook => hook.id === hookType)?.label ?? hookType; + return HOOK_METADATA[hookType as HookType]?.label ?? hookType; } export class ChatHookContentPart extends ChatCollapsibleContentPart implements IChatContentPart { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts index d499fda339e..1e589ede366 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatInlineAnchorWidget.ts @@ -23,7 +23,7 @@ import { getFlatContextMenuActions } from '../../../../../../platform/actions/br import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; import { IResourceStat } from '../../../../../../platform/dnd/browser/dnd.js'; import { ITextResourceEditorInput } from '../../../../../../platform/editor/common/editor.js'; @@ -199,10 +199,6 @@ export class InlineAnchorWidget extends Disposable { iconEl.classList.add(...iconClasses); }; - this._register(themeService.onDidFileIconThemeChange(() => { - refreshIconClasses(); - })); - let isDirectory = false; fileService.stat(location.uri) .then(stat => { @@ -214,31 +210,42 @@ export class InlineAnchorWidget extends Disposable { }) .catch(() => { }); - // Context menu - const contextKeyService = this._register(originalContextKeyService.createScoped(element)); - chatAttachmentResourceContextKey.bindTo(contextKeyService).set(location.uri.toString()); - const isFolderContext = ExplorerFolderContext.bindTo(contextKeyService); + // Context menu (context key service created lazily on first context menu open) + let contextKeyService: IContextKeyService | undefined; + let isFolderContext: IContextKey | undefined; let contextMenuInitialized = false; + + const ensureContextKeyService = () => { + if (!contextKeyService) { + contextKeyService = this._register(originalContextKeyService.createScoped(element)); + chatAttachmentResourceContextKey.bindTo(contextKeyService).set(location.uri.toString()); + isFolderContext = ExplorerFolderContext.bindTo(contextKeyService); + } + return contextKeyService; + }; + this._register(dom.addDisposableListener(element, dom.EventType.CONTEXT_MENU, async domEvent => { const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent); dom.EventHelper.stop(domEvent, true); + const cks = ensureContextKeyService(); + if (!contextMenuInitialized) { contextMenuInitialized = true; - const resourceContextKey = new StaticResourceContextKey(contextKeyService, fileService, languageService, modelService); + const resourceContextKey = new StaticResourceContextKey(cks, fileService, languageService, modelService); resourceContextKey.set(location.uri); } - isFolderContext.set(isDirectory); + isFolderContext!.set(isDirectory); if (this._store.isDisposed) { return; } contextMenuService.showContextMenu({ - contextKeyService, + contextKeyService: cks, getAnchor: () => event, getActions: () => { - const menu = menuService.getMenuActions(MenuId.ChatInlineResourceAnchorContext, contextKeyService, { arg: location.uri }); + const menu = menuService.getMenuActions(MenuId.ChatInlineResourceAnchorContext, cks, { arg: location.uri }); return getFlatContextMenuActions(menu); }, }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts index 101b84d9810..0fec722d8a2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersInteractionContentPart.ts @@ -105,16 +105,18 @@ export class ChatMcpServersInteractionContentPart extends Disposable implements private createServerCommandLinks(servers: Array<{ id: string; label: string }>): string { return servers.map(s => createMarkdownCommandLink({ - title: '`' + escapeMarkdownSyntaxTokens(s.label) + '`', + text: '`' + escapeMarkdownSyntaxTokens(s.label) + '`', id: McpCommandIds.ServerOptions, arguments: [s.id], + tooltip: localize('mcp.server.options.tooltip', 'Show options for {0}', s.label), }, false)).join(', '); } private updateDetailedProgress(state: IAutostartResult): void { const skipText = createMarkdownCommandLink({ - title: localize('mcp.skip.link', 'Skip?'), + text: localize('mcp.skip.link', 'Skip?'), id: McpCommandIds.SkipCurrentAutostart, + tooltip: localize('mcp.skip.tooltip', 'Skip starting this MCP server'), }); let content: MarkdownString; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index 5ce3306ae08..c3d240b5a30 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -10,6 +10,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { isMacintosh } from '../../../../../../base/common/platform.js'; import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; @@ -17,8 +18,9 @@ import { IMarkdownRendererService } from '../../../../../../platform/markdown/br import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../../../platform/theme/browser/defaultStyles.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { InputBox } from '../../../../../../base/browser/ui/inputbox/inputBox.js'; +import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Checkbox } from '../../../../../../base/browser/ui/toggle/toggle.js'; -import { IChatQuestion, IChatQuestionCarousel } from '../../../common/chatService/chatService.js'; +import { IChatQuestion, IChatQuestionCarousel, IChatQuestionAnswerValue, IChatQuestionValidation, IChatSingleSelectAnswer, IChatMultiSelectAnswer } from '../../../common/chatService/chatService.js'; import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { IChatRendererContent, isResponseVM } from '../../../common/model/chatViewModel.js'; @@ -29,12 +31,13 @@ import { IHoverService } from '../../../../../../platform/hover/browser/hover.js import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; import './media/chatQuestionCarousel.css'; const PREVIOUS_QUESTION_ACTION_ID = 'workbench.action.chat.previousQuestion'; const NEXT_QUESTION_ACTION_ID = 'workbench.action.chat.nextQuestion'; export interface IChatQuestionCarouselOptions { - onSubmit: (answers: Map | undefined) => void; + onSubmit: (answers: Map | undefined) => void; shouldAutoFocus?: boolean; } @@ -45,18 +48,16 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent public readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; private _currentIndex = 0; - private readonly _answers = new Map(); + private readonly _answers = new Map(); private _questionContainer: HTMLElement | undefined; private _closeButtonContainer: HTMLElement | undefined; private _footerRow: HTMLElement | undefined; private _stepIndicator: HTMLElement | undefined; - private _navigationButtons: HTMLElement | undefined; + private _submitHint: HTMLElement | undefined; + private _submitButton: Button | undefined; private _prevButton: Button | undefined; private _nextButton: Button | undefined; - private readonly _nextButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable()); - private _submitButton: Button | undefined; - private readonly _submitButtonHover: MutableDisposable<{ dispose(): void }> = this._register(new MutableDisposable()); private _skipAllButton: Button | undefined; private _isSkipped = false; @@ -67,6 +68,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private readonly _freeformTextareas: Map = new Map(); private readonly _inputBoxes: DisposableStore = this._register(new DisposableStore()); private readonly _questionRenderStore = this._register(new MutableDisposable()); + private _inputScrollable: DomScrollableElement | undefined; /** * Disposable store for interactive UI components (header, nav buttons, etc.) @@ -74,6 +76,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent */ private readonly _interactiveUIStore: MutableDisposable = this._register(new MutableDisposable()); private readonly _inChatQuestionCarouselContextKey: IContextKey; + private _validationMessageElement: HTMLElement | undefined; + private _currentValidationError: string | undefined; constructor( public readonly carousel: IChatQuestionCarousel, @@ -144,69 +148,30 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const skipAllTitle = localize('chat.questionCarousel.skipAllTitle', 'Skip all questions'); const skipAllButton = interactiveStore.add(new Button(this._closeButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); skipAllButton.label = `$(${Codicon.close.id})`; - skipAllButton.element.classList.add('chat-question-nav-arrow', 'chat-question-close'); + skipAllButton.element.classList.add('chat-question-close'); skipAllButton.element.setAttribute('aria-label', skipAllTitle); interactiveStore.add(this._hoverService.setupDelayedHover(skipAllButton.element, { content: skipAllTitle })); this._skipAllButton = skipAllButton; } - // Footer row with step indicator and navigation buttons - this._footerRow = dom.$('.chat-question-footer-row'); - - // Step indicator (e.g., "2/4") on the left - this._stepIndicator = dom.$('.chat-question-step-indicator'); - this._footerRow.appendChild(this._stepIndicator); - - // Navigation controls (< >) - placed in footer row - this._navigationButtons = dom.$('.chat-question-carousel-nav'); - this._navigationButtons.setAttribute('role', 'navigation'); - this._navigationButtons.setAttribute('aria-label', localize('chat.questionCarousel.navigation', 'Question navigation')); - - // Group prev/next buttons together - const arrowsContainer = dom.$('.chat-question-nav-arrows'); - - const previousLabel = localize('previous', 'Previous'); - const previousLabelWithKeybinding = this.getLabelWithKeybinding(previousLabel, PREVIOUS_QUESTION_ACTION_ID); - const prevButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - prevButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-prev'); - prevButton.label = `$(${Codicon.chevronLeft.id})`; - prevButton.element.setAttribute('aria-label', previousLabelWithKeybinding); - interactiveStore.add(this._hoverService.setupDelayedHover(prevButton.element, { content: previousLabelWithKeybinding })); - this._prevButton = prevButton; - - const nextButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); - nextButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-next'); - nextButton.label = `$(${Codicon.chevronRight.id})`; - this._nextButton = nextButton; - - const submitButton = interactiveStore.add(new Button(this._navigationButtons, { ...defaultButtonStyles })); - submitButton.element.classList.add('chat-question-submit-button'); - submitButton.label = localize('submit', 'Submit'); - this._submitButton = submitButton; - - this._navigationButtons.appendChild(arrowsContainer); - this._footerRow.appendChild(this._navigationButtons); - this.domNode.append(this._footerRow); - - // Register event listeners - interactiveStore.add(prevButton.onDidClick(() => this.navigate(-1))); - interactiveStore.add(nextButton.onDidClick(() => this.navigate(1))); - interactiveStore.add(submitButton.onDidClick(() => this.submit())); if (this._skipAllButton) { interactiveStore.add(this._skipAllButton.onDidClick(() => this.ignore())); } - // Register keyboard navigation - handle Enter on text inputs and freeform textareas + // Register keyboard navigation interactiveStore.add(dom.addDisposableListener(this.domNode, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { const event = new StandardKeyboardEvent(e); if (event.keyCode === KeyCode.Escape && this.carousel.allowSkip) { e.preventDefault(); e.stopPropagation(); this.ignore(); + } else if (event.keyCode === KeyCode.Enter && (event.metaKey || event.ctrlKey)) { + // Cmd/Ctrl+Enter submits immediately from anywhere + e.preventDefault(); + e.stopPropagation(); + this.submit(); } else if (event.keyCode === KeyCode.Enter && !event.shiftKey) { - // Handle Enter key for text inputs and freeform textareas, not radio/checkbox or buttons - // Buttons have their own Enter/Space handling via Button class const target = e.target as HTMLElement; const isTextInput = target.tagName === 'INPUT' && (target as HTMLInputElement).type === 'text'; const isFreeformTextarea = target.tagName === 'TEXTAREA' && target.classList.contains('chat-question-freeform-textarea'); @@ -236,6 +201,19 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._answers.delete(currentQuestion.id); } + // Validate on change to update the Next button state + if (currentQuestion?.validation && typeof answer === 'string' && answer !== '') { + const error = this.getValidationError(answer, currentQuestion.validation); + if (error) { + this.showValidationError(error); + } else { + this.clearValidationError(); + } + } else { + this.clearValidationError(); + } + + this.updateFooterState(); this.persistDraftState(); } @@ -259,6 +237,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._currentIndex = newIndex; this.persistDraftState(); this.renderCurrentQuestion(true); + this.domNode.focus(); } } @@ -269,6 +248,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private handleNextOrSubmit(): void { this.saveCurrentAnswer(); + if (!this.validateCurrentQuestion()) { + return; + } + if (this._currentIndex < this.carousel.questions.length - 1) { // Move to next question this._currentIndex++; @@ -276,6 +259,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this.renderCurrentQuestion(true); } else { // Submit + if (!this.validateRequiredFields()) { + return; + } this._options.onSubmit(this._answers); this.hideAndShowSummary(); } @@ -286,6 +272,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent */ private submit(): void { this.saveCurrentAnswer(); + if (!this.validateCurrentQuestion()) { + return; + } + if (!this.validateRequiredFields()) { + return; + } this._options.onSubmit(this._answers); this.hideAndShowSummary(); } @@ -336,8 +328,6 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._singleSelectItems.clear(); this._multiSelectCheckboxes.clear(); this._freeformTextareas.clear(); - this._nextButtonHover.value = undefined; - this._submitButtonHover.value = undefined; // Clear references to disposed elements this._prevButton = undefined; @@ -345,10 +335,67 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._submitButton = undefined; this._skipAllButton = undefined; this._questionContainer = undefined; - this._navigationButtons = undefined; this._closeButtonContainer = undefined; this._footerRow = undefined; this._stepIndicator = undefined; + this._submitHint = undefined; + this._inputScrollable = undefined; + } + + private layoutInputScrollable(inputScrollable: DomScrollableElement): void { + if (!this._questionContainer) { + return; + } + + const scrollableNode = inputScrollable.getDomNode(); + const scrollableContent = scrollableNode.firstElementChild; + if (!dom.isHTMLElement(scrollableContent)) { + return; + } + + // Clear stale size constraints first so this step can shrink after + // navigating from a taller question. + if (scrollableNode.style.height !== '' || scrollableNode.style.maxHeight !== '') { + scrollableNode.style.height = ''; + scrollableNode.style.maxHeight = ''; + } + if (scrollableContent.style.height !== '' || scrollableContent.style.maxHeight !== '') { + scrollableContent.style.height = ''; + scrollableContent.style.maxHeight = ''; + } + + // Use the flex-resolved container height (constrained by CSS max-height) + // instead of window.innerHeight, so the scroll viewport tracks actual chat space. + const maxContainerHeight = this._questionContainer.clientHeight; + + const computedStyle = dom.getWindow(this._questionContainer).getComputedStyle(this._questionContainer); + const contentVerticalPadding = + Number.parseFloat(computedStyle.paddingTop || '0') + + Number.parseFloat(computedStyle.paddingBottom || '0'); + + const nonScrollableContentHeight = Array.from(this._questionContainer.children) + .filter(child => child !== scrollableNode) + .reduce((sum, child) => sum + (child as HTMLElement).offsetHeight, 0); + + const availableScrollableHeight = Math.floor(maxContainerHeight - contentVerticalPadding - nonScrollableContentHeight); + + const contentScrollableHeight = scrollableContent.scrollHeight; + const constrainedScrollableHeight = Math.max(0, Math.min(availableScrollableHeight, contentScrollableHeight)); + const constrainedScrollableHeightPx = `${constrainedScrollableHeight}px`; + + // Constrain wrapper + content so no stale flex sizing survives between steps. + if (scrollableNode.style.height !== constrainedScrollableHeightPx || scrollableNode.style.maxHeight !== constrainedScrollableHeightPx) { + scrollableNode.style.height = constrainedScrollableHeightPx; + scrollableNode.style.maxHeight = constrainedScrollableHeightPx; + } + + // Constrain the content element (DomScrollableElement._element) so that + // scanDomNode sees clientHeight < scrollHeight and enables scrolling. + if (scrollableContent.style.height !== constrainedScrollableHeightPx || scrollableContent.style.maxHeight !== constrainedScrollableHeightPx) { + scrollableContent.style.height = constrainedScrollableHeightPx; + scrollableContent.style.maxHeight = constrainedScrollableHeightPx; + } + inputScrollable.scanDomNode(); } /** @@ -398,8 +445,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent /** * Collects default values for all questions in the carousel. */ - private getDefaultAnswers(): Map { - const answers = new Map(); + private getDefaultAnswers(): Map { + const answers = new Map(); for (const question of this.carousel.questions) { const defaultAnswer = this.getDefaultAnswerForQuestion(question); if (defaultAnswer !== undefined) { @@ -412,10 +459,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent /** * Gets the default answer for a specific question. */ - private getDefaultAnswerForQuestion(question: IChatQuestion): unknown { + private getDefaultAnswerForQuestion(question: IChatQuestion): IChatQuestionAnswerValue | undefined { switch (question.type) { case 'text': - return question.defaultValue; + return typeof question.defaultValue === 'string' ? question.defaultValue : undefined; case 'singleSelect': { const defaultOptionId = typeof question.defaultValue === 'string' ? question.defaultValue : undefined; @@ -424,8 +471,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent : undefined; const selectedValue = defaultOption?.value; - // Always return structured format for single-select (freeform is always shown) - return selectedValue !== undefined ? { selectedValue, freeformValue: undefined } : undefined; + return selectedValue !== undefined ? { selectedValue, freeformValue: undefined } satisfies IChatSingleSelectAnswer : undefined; } case 'multiSelect': { @@ -437,12 +483,11 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent .map(opt => opt.value) .filter(v => v !== undefined) ?? []; - // Always return structured format for multi-select (freeform is always shown) - return selectedValues.length > 0 ? { selectedValues, freeformValue: undefined } : undefined; + return selectedValues.length > 0 ? { selectedValues, freeformValue: undefined } satisfies IChatMultiSelectAnswer : undefined; } default: - return question.defaultValue; + return typeof question.defaultValue === 'string' ? question.defaultValue : Array.isArray(question.defaultValue) ? { selectedValues: question.defaultValue } : undefined; } } @@ -512,12 +557,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } private renderCurrentQuestion(focusContainerForScreenReader: boolean = false): void { - if (!this._questionContainer || !this._prevButton || !this._nextButton || !this._submitButton) { + if (!this._questionContainer) { return; } const questionRenderStore = new DisposableStore(); this._questionRenderStore.value = questionRenderStore; + this._inputScrollable = undefined; // Clear previous input boxes and stale references this._inputBoxes.clear(); @@ -534,95 +580,102 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent return; } - // Render question header row with title and close button + // Render unified question title (message ?? title) const headerRow = dom.$('.chat-question-header-row'); const titleRow = dom.$('.chat-question-title-row'); - // Render question title (short header) in the header bar as plain text - if (question.title) { - const title = dom.$('.chat-question-title'); - const questionText = question.title; - const messageContent = this.getQuestionText(questionText); - - title.setAttribute('aria-label', messageContent); - - if (question.message !== undefined) { - const messageMd = isMarkdownString(questionText) ? MarkdownString.lift(questionText) : new MarkdownString(questionText); - const renderedTitle = questionRenderStore.add(this._markdownRendererService.render(messageMd)); - title.appendChild(renderedTitle.element); - } else { - // Check for subtitle in parentheses at the end - const parenMatch = messageContent.match(/^(.+?)\s*(\([^)]+\))\s*$/); - if (parenMatch) { - // Main title (bold) - const mainTitle = dom.$('span.chat-question-title-main'); - mainTitle.textContent = parenMatch[1]; - title.appendChild(mainTitle); - - // Subtitle in parentheses (normal weight) - const subtitle = dom.$('span.chat-question-title-subtitle'); - subtitle.textContent = ' ' + parenMatch[2]; - title.appendChild(subtitle); - } else { - title.textContent = messageContent; - } - } - titleRow.appendChild(title); + // Render carousel-level message if present (e.g. from MCP elicitation) + if (this.carousel.message && this._currentIndex === 0) { + const messageMd = isMarkdownString(this.carousel.message) ? MarkdownString.lift(this.carousel.message) : new MarkdownString(this.carousel.message); + const carouselMessage = dom.$('.chat-question-carousel-message'); + const renderedMessage = questionRenderStore.add(this._markdownRendererService.render(messageMd)); + carouselMessage.appendChild(renderedMessage.element); + headerRow.appendChild(carouselMessage); } - // Add close button to header row (if allowSkip is enabled) - if (this._closeButtonContainer) { - titleRow.appendChild(this._closeButtonContainer); + const questionText = question.message ?? question.title; + if (questionText) { + const title = dom.$('.chat-question-title'); + const messageContent = this.getQuestionText(questionText); + title.setAttribute('aria-label', messageContent); + + const titleText = question.required + ? new MarkdownString(`${isMarkdownString(questionText) ? questionText.value : questionText} *`) + : (isMarkdownString(questionText) ? MarkdownString.lift(questionText) : new MarkdownString(questionText)); + const renderedTitle = questionRenderStore.add(this._markdownRendererService.render(titleText)); + title.appendChild(renderedTitle.element); + titleRow.appendChild(title); } headerRow.appendChild(titleRow); - this._questionContainer.appendChild(headerRow); - - // Render full question text below the header row (supports multi-line and markdown) - if (question.message) { - const messageEl = dom.$('.chat-question-message'); - if (isMarkdownString(question.message)) { - const renderedMessage = questionRenderStore.add(this._markdownRendererService.render(MarkdownString.lift(question.message))); - messageEl.appendChild(renderedMessage.element); - } else { - messageEl.textContent = this.getQuestionText(question.message); - } - this._questionContainer.appendChild(messageEl); + // Always keep the close button in the title row so it does not overlap content. + if (this._closeButtonContainer) { + titleRow.appendChild(this._closeButtonContainer); } - const isSingleQuestion = this.carousel.questions.length === 1; - // Update step indicator in footer - if (this._stepIndicator) { - this._stepIndicator.textContent = `${this._currentIndex + 1}/${this.carousel.questions.length}`; - this._stepIndicator.style.display = isSingleQuestion ? 'none' : ''; + this._questionContainer.appendChild(headerRow); + + // Render description if present + if (question.description) { + const descriptionEl = dom.$('.chat-question-description'); + descriptionEl.textContent = question.description; + this._questionContainer.appendChild(descriptionEl); } // Render input based on question type const inputContainer = dom.$('.chat-question-input-container'); this.renderInput(inputContainer, question); - this._questionContainer.appendChild(inputContainer); - // Update navigation button states (prevButton and nextButton are guaranteed non-null from guard above) - this._prevButton!.enabled = this._currentIndex > 0; - this._prevButton!.element.style.display = isSingleQuestion ? 'none' : ''; + const inputScrollable = questionRenderStore.add(new DomScrollableElement(inputContainer, { + vertical: ScrollbarVisibility.Visible, + horizontal: ScrollbarVisibility.Hidden, + consumeMouseWheelIfScrollbarIsNeeded: true, + })); + this._inputScrollable = inputScrollable; + const inputScrollableNode = inputScrollable.getDomNode(); + inputScrollableNode.classList.add('chat-question-input-scrollable'); + this._questionContainer.appendChild(inputScrollableNode); - // Keep navigation arrows stable and disable next on the last question - const isLastQuestion = this._currentIndex === this.carousel.questions.length - 1; - const submitLabel = localize('submit', 'Submit'); - const nextLabel = localize('next', 'Next'); - const nextLabelWithKeybinding = this.getLabelWithKeybinding(nextLabel, NEXT_QUESTION_ACTION_ID); - this._nextButton!.label = `$(${Codicon.chevronRight.id})`; - this._nextButton!.enabled = !isLastQuestion; - this._nextButton!.element.setAttribute('aria-label', nextLabelWithKeybinding); - this._nextButtonHover.value = this._hoverService.setupDelayedHover(this._nextButton!.element, { content: nextLabelWithKeybinding }); + // Validation message element below the scrollable area (not inside it) + this._validationMessageElement = dom.$('.chat-question-validation-message'); + this._validationMessageElement.style.display = 'none'; + this._questionContainer.appendChild(this._validationMessageElement); - this._submitButton!.enabled = isLastQuestion; - this._submitButton!.element.style.display = isLastQuestion ? '' : 'none'; - this._submitButton!.element.setAttribute('aria-label', submitLabel); - this._submitButtonHover.value = isLastQuestion - ? this._hoverService.setupDelayedHover(this._submitButton!.element, { content: submitLabel }) - : undefined; + const isSingleQuestion = this.carousel.questions.length === 1; + + // Render footer before first layout so the scrollable area is measured against + // its final available height and does not visibly resize twice. + if (!isSingleQuestion) { + this.renderFooter(); + } else { + this.renderSingleQuestionFooter(); + } + + let relayoutScheduled = false; + const relayoutScheduler = questionRenderStore.add(new MutableDisposable()); + const scheduleLayoutInputScrollable = () => { + if (relayoutScheduled) { + return; + } + + relayoutScheduled = true; + relayoutScheduler.value = dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => { + relayoutScheduled = false; + this.layoutInputScrollable(inputScrollable); + }); + }; + + const inputResizeObserver = questionRenderStore.add(new dom.DisposableResizeObserver(() => scheduleLayoutInputScrollable())); + questionRenderStore.add(inputResizeObserver.observe(inputScrollableNode)); + questionRenderStore.add(inputResizeObserver.observe(inputContainer)); + scheduleLayoutInputScrollable(); + questionRenderStore.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(this.domNode), () => { + inputContainer.scrollTop = 0; + inputContainer.scrollLeft = 0; + inputScrollable.setScrollPosition({ scrollTop: 0, scrollLeft: 0 }); + inputScrollable.scanDomNode(); + })); // Update aria-label to reflect the current question this._updateAriaLabel(); @@ -636,6 +689,143 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._onDidChangeHeight.fire(); } + /** + * Renders or updates the persistent footer with nav arrows, step indicator, and submit button. + */ + private renderFooter(): void { + if (!this._footerRow) { + const interactiveStore = this._interactiveUIStore.value; + if (!interactiveStore) { + return; + } + + this._footerRow = dom.$('.chat-question-footer-row'); + + // Left side: nav arrows + step indicator + const leftControls = dom.$('.chat-question-footer-left.chat-question-carousel-nav'); + leftControls.setAttribute('role', 'navigation'); + leftControls.setAttribute('aria-label', localize('chat.questionCarousel.navigation', 'Question navigation')); + + const arrowsContainer = dom.$('.chat-question-nav-arrows'); + + const previousLabel = this.getLabelWithKeybinding(localize('previous', 'Previous'), PREVIOUS_QUESTION_ACTION_ID); + const prevButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + prevButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-prev'); + prevButton.label = `$(${Codicon.chevronLeft.id})`; + prevButton.element.setAttribute('aria-label', previousLabel); + interactiveStore.add(this._hoverService.setupDelayedHover(prevButton.element, { content: previousLabel })); + interactiveStore.add(prevButton.onDidClick(() => this.navigate(-1))); + this._prevButton = prevButton; + + const nextLabel = this.getLabelWithKeybinding(localize('next', 'Next'), NEXT_QUESTION_ACTION_ID); + const nextButton = interactiveStore.add(new Button(arrowsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + nextButton.element.classList.add('chat-question-nav-arrow', 'chat-question-nav-next'); + nextButton.label = `$(${Codicon.chevronRight.id})`; + nextButton.element.setAttribute('aria-label', nextLabel); + interactiveStore.add(this._hoverService.setupDelayedHover(nextButton.element, { content: nextLabel })); + interactiveStore.add(nextButton.onDidClick(() => this.navigate(1))); + this._nextButton = nextButton; + + leftControls.appendChild(arrowsContainer); + + this._stepIndicator = dom.$('.chat-question-step-indicator'); + leftControls.appendChild(this._stepIndicator); + + this._footerRow.appendChild(leftControls); + + // Right side: hint + submit + const rightControls = dom.$('.chat-question-footer-right'); + + const hint = dom.$('span.chat-question-submit-hint'); + hint.textContent = isMacintosh + ? localize('chat.questionCarousel.submitHintMac', '\u2318\u23CE to submit') + : localize('chat.questionCarousel.submitHintOther', 'Ctrl+Enter to submit'); + rightControls.appendChild(hint); + this._submitHint = hint; + + const submitButton = interactiveStore.add(new Button(rightControls, { ...defaultButtonStyles })); + submitButton.element.classList.add('chat-question-submit-button'); + submitButton.label = localize('submit', 'Submit'); + interactiveStore.add(submitButton.onDidClick(() => this.submit())); + this._submitButton = submitButton; + + this._footerRow.appendChild(rightControls); + this.domNode.append(this._footerRow); + } + + this.updateFooterState(); + } + + /** + * Updates the footer nav button enabled state and step indicator text. + */ + private updateFooterState(): void { + if (this._prevButton) { + this._prevButton.enabled = this._currentIndex > 0; + } + if (this._nextButton) { + const canAdvance = this._currentIndex < this.carousel.questions.length - 1; + const question = this.carousel.questions[this._currentIndex]; + const answer = this._answers.get(question?.id); + const hasAnswer = answer !== undefined && answer !== ''; + const hasValidationError = !!this._currentValidationError; + this._nextButton.enabled = canAdvance && (!question?.required || hasAnswer) && !hasValidationError; + } + if (this._stepIndicator) { + this._stepIndicator.textContent = localize( + 'chat.questionCarousel.stepIndicator', + '{0}/{1}', + this._currentIndex + 1, + this.carousel.questions.length + ); + } + if (this._submitButton) { + const isLastQuestion = this._currentIndex === this.carousel.questions.length - 1; + this._submitButton.element.style.display = isLastQuestion ? '' : 'none'; + if (this._submitHint) { + this._submitHint.style.display = isLastQuestion ? '' : 'none'; + } + } + } + + /** + * Renders a simplified footer with just a submit button for single-question multi-select carousels. + */ + private renderSingleQuestionFooter(): void { + if (!this._footerRow) { + const interactiveStore = this._interactiveUIStore.value; + if (!interactiveStore) { + return; + } + + this._footerRow = dom.$('.chat-question-footer-row'); + + // Spacer to push controls to the right + const leftControls = dom.$('.chat-question-footer-left.chat-question-carousel-nav'); + leftControls.setAttribute('role', 'navigation'); + leftControls.setAttribute('aria-label', localize('chat.questionCarousel.navigation', 'Question navigation')); + this._footerRow.appendChild(leftControls); + + const rightControls = dom.$('.chat-question-footer-right'); + + const hint = dom.$('span.chat-question-submit-hint'); + hint.textContent = isMacintosh + ? localize('chat.questionCarousel.submitHintMac', '\u2318\u23CE to submit') + : localize('chat.questionCarousel.submitHintOther', 'Ctrl+Enter to submit'); + rightControls.appendChild(hint); + this._submitHint = hint; + + const submitButton = interactiveStore.add(new Button(rightControls, { ...defaultButtonStyles })); + submitButton.element.classList.add('chat-question-submit-button'); + submitButton.label = localize('submit', 'Submit'); + interactiveStore.add(submitButton.onDidClick(() => this.submit())); + this._submitButton = submitButton; + + this._footerRow.appendChild(rightControls); + this.domNode.append(this._footerRow); + } + } + private getLabelWithKeybinding(label: string, actionId: string): string { const keybindingLabel = this._keybindingService.lookupKeybinding(actionId, this._contextKeyService)?.getLabel(); return keybindingLabel @@ -665,6 +855,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const autoResize = () => { textarea.style.height = 'auto'; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; + if (this._inputScrollable) { + this.layoutInputScrollable(this._inputScrollable); + } this._onDidChangeHeight.fire(); }; this._inputBoxes.add(dom.addDisposableListener(textarea, dom.EventType.INPUT, autoResize)); @@ -675,8 +868,22 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const inputBox = this._inputBoxes.add(new InputBox(container, undefined, { placeholder: localize('chat.questionCarousel.enterText', 'Enter your answer'), inputBoxStyles: defaultInputBoxStyles, + validationOptions: question.validation ? { + validation: (value: string) => { + if (!value && !question.required) { + return null; + } + const error = this.getValidationError(value, question.validation!); + if (error) { + return { type: 2 /* MessageType.WARNING */, content: error }; + } + return null; + } + } : undefined, + })); + this._inputBoxes.add(inputBox.onDidChange(() => { + this.saveCurrentAnswer(); })); - this._inputBoxes.add(inputBox.onDidChange(() => this.saveCurrentAnswer())); // Restore previous answer if exists const previousAnswer = this._answers.get(question.id); @@ -704,12 +911,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Restore previous answer if exists const previousAnswer = this._answers.get(question.id); - const previousFreeform = typeof previousAnswer === 'object' && previousAnswer !== null && hasKey(previousAnswer, { freeformValue: true }) - ? (previousAnswer as { freeformValue?: string }).freeformValue - : undefined; - const previousSelectedValue = typeof previousAnswer === 'object' && previousAnswer !== null && hasKey(previousAnswer, { selectedValue: true }) - ? (previousAnswer as { selectedValue?: unknown }).selectedValue - : previousAnswer; + const prevSingle = typeof previousAnswer === 'object' && previousAnswer !== null && hasKey(previousAnswer, { selectedValue: true }) ? previousAnswer as IChatSingleSelectAnswer : undefined; + const previousFreeform = prevSingle?.freeformValue; + const previousSelectedValue = prevSingle?.selectedValue; // Get default option id (for singleSelect, defaultValue is a single string) const defaultOptionId = typeof question.defaultValue === 'string' ? question.defaultValue : undefined; @@ -773,12 +977,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const label = dom.$('.chat-question-list-label'); const separatorIndex = option.label.indexOf(' - '); if (separatorIndex !== -1) { + listItem.classList.add('has-description'); const titleSpan = dom.$('span.chat-question-list-label-title'); titleSpan.textContent = option.label.substring(0, separatorIndex); label.appendChild(titleSpan); const descSpan = dom.$('span.chat-question-list-label-desc'); - descSpan.textContent = ': ' + option.label.substring(separatorIndex + 3); + descSpan.textContent = option.label.substring(separatorIndex + 3); label.appendChild(descSpan); } else { label.textContent = option.label; @@ -819,36 +1024,45 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent selectContainer.setAttribute('aria-activedescendant', listItems[selectedIndex].id); } - // Always show freeform input for single-select questions - const freeformContainer = dom.$('.chat-question-freeform'); + // Show freeform input only when explicitly allowed + let freeformTextarea: HTMLTextAreaElement | undefined; + if (question.allowFreeformInput !== false) { + const freeformContainer = dom.$('.chat-question-freeform'); - const freeformNumber = dom.$('.chat-question-freeform-number'); - freeformNumber.textContent = `${options.length + 1}`; - freeformContainer.appendChild(freeformNumber); + const freeformNumber = dom.$('.chat-question-freeform-number'); + freeformNumber.textContent = `${options.length + 1}`; + freeformContainer.appendChild(freeformNumber); - const freeformTextarea = dom.$('textarea.chat-question-freeform-textarea'); - freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer'); - freeformTextarea.rows = 1; + freeformTextarea = dom.$('textarea.chat-question-freeform-textarea'); + freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer'); + freeformTextarea.rows = 1; - if (previousFreeform !== undefined) { - freeformTextarea.value = previousFreeform; - } - - // Setup auto-resize behavior - const autoResize = this.setupTextareaAutoResize(freeformTextarea); - - // clear when we start typing in freeform - this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => { - if (freeformTextarea.value.length > 0) { - updateSelection(-1); - } else { - this.saveCurrentAnswer(); + if (previousFreeform !== undefined) { + freeformTextarea.value = previousFreeform; } - })); - freeformContainer.appendChild(freeformTextarea); - container.appendChild(freeformContainer); - this._freeformTextareas.set(question.id, freeformTextarea); + // Setup auto-resize behavior + const autoResize = this.setupTextareaAutoResize(freeformTextarea); + + // clear when we start typing in freeform + const capturedFreeform = freeformTextarea; + this._inputBoxes.add(dom.addDisposableListener(capturedFreeform, dom.EventType.INPUT, () => { + if (capturedFreeform.value.length > 0) { + updateSelection(-1); + } else { + this.saveCurrentAnswer(); + } + })); + + freeformContainer.appendChild(freeformTextarea); + container.appendChild(freeformContainer); + this._freeformTextareas.set(question.id, freeformTextarea); + + // Resize textarea if it has restored content + if (previousFreeform !== undefined) { + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(capturedFreeform), () => autoResize())); + } + } // Keyboard navigation for the list this._inputBoxes.add(dom.addDisposableListener(selectContainer, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { @@ -865,7 +1079,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } else if (event.keyCode === KeyCode.UpArrow) { e.preventDefault(); newIndex = Math.max(data.selectedIndex - 1, 0); - } else if (event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) { + } else if ((event.keyCode === KeyCode.Enter || event.keyCode === KeyCode.Space) && !event.metaKey && !event.ctrlKey) { // Enter confirms current selection and advances to next question e.preventDefault(); e.stopPropagation(); @@ -877,7 +1091,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (numberIndex < listItems.length) { e.preventDefault(); updateSelection(numberIndex); - } else if (numberIndex === listItems.length) { + } else if (freeformTextarea && numberIndex === listItems.length) { e.preventDefault(); updateSelection(-1); freeformTextarea.focus(); @@ -890,16 +1104,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } })); - // Resize textarea if it has restored content - if (previousFreeform !== undefined) { - this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize())); - } - // focus on the row when first rendered or textarea if it has content if (this._shouldAutoFocus()) { - if (previousFreeform) { - this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => { - freeformTextarea.focus(); + if (freeformTextarea && previousFreeform) { + const capturedFreeform = freeformTextarea; + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(capturedFreeform), () => { + capturedFreeform.focus(); })); } else if (listItems.length > 0) { const focusIndex = selectedIndex >= 0 ? selectedIndex : 0; @@ -925,12 +1135,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Restore previous answer if exists const previousAnswer = this._answers.get(question.id); - const previousFreeform = typeof previousAnswer === 'object' && previousAnswer !== null && hasKey(previousAnswer, { freeformValue: true }) - ? (previousAnswer as { freeformValue?: string }).freeformValue - : undefined; - const previousSelectedValues = typeof previousAnswer === 'object' && previousAnswer !== null && hasKey(previousAnswer, { selectedValues: true }) - ? (previousAnswer as { selectedValues?: unknown[] }).selectedValues - : (Array.isArray(previousAnswer) ? previousAnswer : []); + const prevMulti = typeof previousAnswer === 'object' && previousAnswer !== null && hasKey(previousAnswer, { selectedValues: true }) ? previousAnswer as IChatMultiSelectAnswer : undefined; + const previousFreeform = prevMulti?.freeformValue; + const previousSelectedValues = prevMulti?.selectedValues ?? []; // Get default option ids (for multiSelect, defaultValue can be string or string[]) const defaultOptionIds: string[] = Array.isArray(question.defaultValue) @@ -973,12 +1180,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const label = dom.$('.chat-question-list-label'); const separatorIndex = option.label.indexOf(' - '); if (separatorIndex !== -1) { + listItem.classList.add('has-description'); const titleSpan = dom.$('span.chat-question-list-label-title'); titleSpan.textContent = option.label.substring(0, separatorIndex); label.appendChild(titleSpan); const descSpan = dom.$('span.chat-question-list-label-desc'); - descSpan.textContent = ': ' + option.label.substring(separatorIndex + 3); + descSpan.textContent = option.label.substring(separatorIndex + 3); label.appendChild(descSpan); } else { label.textContent = option.label; @@ -1023,30 +1231,38 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._multiSelectCheckboxes.set(question.id, checkboxes); - // Always show freeform input for multi-select questions - const freeformContainer = dom.$('.chat-question-freeform'); + // Show freeform input only when explicitly allowed + let freeformTextarea: HTMLTextAreaElement | undefined; + if (question.allowFreeformInput !== false) { + const freeformContainer = dom.$('.chat-question-freeform'); - // Number indicator for freeform (comes after all options) - const freeformNumber = dom.$('.chat-question-freeform-number'); - freeformNumber.textContent = `${options.length + 1}`; - freeformContainer.appendChild(freeformNumber); + // Number indicator for freeform (comes after all options) + const freeformNumber = dom.$('.chat-question-freeform-number'); + freeformNumber.textContent = `${options.length + 1}`; + freeformContainer.appendChild(freeformNumber); - const freeformTextarea = dom.$('textarea.chat-question-freeform-textarea'); - freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer'); - freeformTextarea.rows = 1; + freeformTextarea = dom.$('textarea.chat-question-freeform-textarea'); + freeformTextarea.placeholder = localize('chat.questionCarousel.enterCustomAnswer', 'Enter custom answer'); + freeformTextarea.rows = 1; - if (previousFreeform !== undefined) { - freeformTextarea.value = previousFreeform; + if (previousFreeform !== undefined) { + freeformTextarea.value = previousFreeform; + } + + // Setup auto-resize behavior + const autoResize = this.setupTextareaAutoResize(freeformTextarea); + this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => this.saveCurrentAnswer())); + + freeformContainer.appendChild(freeformTextarea); + container.appendChild(freeformContainer); + this._freeformTextareas.set(question.id, freeformTextarea); + + // Resize textarea if it has restored content + if (previousFreeform !== undefined) { + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize())); + } } - // Setup auto-resize behavior - const autoResize = this.setupTextareaAutoResize(freeformTextarea); - this._inputBoxes.add(dom.addDisposableListener(freeformTextarea, dom.EventType.INPUT, () => this.saveCurrentAnswer())); - - freeformContainer.appendChild(freeformTextarea); - container.appendChild(freeformContainer); - this._freeformTextareas.set(question.id, freeformTextarea); - // Keyboard navigation for the list this._inputBoxes.add(dom.addDisposableListener(selectContainer, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { const event = new StandardKeyboardEvent(e); @@ -1064,7 +1280,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent e.preventDefault(); focusedIndex = Math.max(focusedIndex - 1, 0); listItems[focusedIndex].focus(); - } else if (event.keyCode === KeyCode.Enter) { + } else if (event.keyCode === KeyCode.Enter && !event.metaKey && !event.ctrlKey) { e.preventDefault(); e.stopPropagation(); this.handleNextOrSubmit(); @@ -1080,23 +1296,19 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent if (numberIndex < checkboxes.length) { e.preventDefault(); checkboxes[numberIndex].domNode.click(); - } else if (numberIndex === checkboxes.length) { + } else if (freeformTextarea && numberIndex === checkboxes.length) { e.preventDefault(); freeformTextarea.focus(); } } })); - // Resize textarea if it has restored content - if (previousFreeform !== undefined) { - this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => autoResize())); - } - // Focus on the appropriate row when rendered or textarea if it has content if (this._shouldAutoFocus()) { - if (previousFreeform) { - this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(freeformTextarea), () => { - freeformTextarea.focus(); + if (freeformTextarea && previousFreeform) { + const capturedFreeform = freeformTextarea; + this._inputBoxes.add(dom.runAtThisOrScheduleAtNextAnimationFrame(dom.getWindow(capturedFreeform), () => { + capturedFreeform.focus(); })); } else if (listItems.length > 0) { const initialFocusIndex = firstCheckedIndex >= 0 ? firstCheckedIndex : 0; @@ -1108,7 +1320,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } } - private getCurrentAnswer(): unknown { + private getCurrentAnswer(): IChatQuestionAnswerValue | undefined { const question = this.carousel.questions[this._currentIndex]; if (!question) { return undefined; @@ -1117,12 +1329,12 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent switch (question.type) { case 'text': { const inputBox = this._textInputBoxes.get(question.id); - return inputBox?.value ?? question.defaultValue; + return inputBox?.value ?? (typeof question.defaultValue === 'string' ? question.defaultValue : Array.isArray(question.defaultValue) ? { selectedValues: question.defaultValue } : undefined); } case 'singleSelect': { const data = this._singleSelectItems.get(question.id); - let selectedValue: unknown = undefined; + let selectedValue: string | undefined = undefined; if (data && data.selectedIndex >= 0) { selectedValue = question.options?.[data.selectedIndex]?.value; } @@ -1137,17 +1349,17 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const freeformValue = freeformTextarea?.value !== '' ? freeformTextarea?.value : undefined; if (freeformValue) { // Freeform takes priority - ignore selectedValue - return { selectedValue: undefined, freeformValue }; + return { selectedValue: undefined, freeformValue } satisfies IChatSingleSelectAnswer; } if (selectedValue !== undefined) { - return { selectedValue, freeformValue: undefined }; + return { selectedValue, freeformValue: undefined } satisfies IChatSingleSelectAnswer; } return undefined; } case 'multiSelect': { const checkboxes = this._multiSelectCheckboxes.get(question.id); - const selectedValues: unknown[] = []; + const selectedValues: string[] = []; if (checkboxes) { checkboxes.forEach((checkbox, index) => { if (checkbox.checked) { @@ -1166,13 +1378,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Return whatever was selected - defaults are applied at render time when // checkboxes are initially checked, so empty selection means user unchecked all if (freeformValue || selectedValues.length > 0) { - return { selectedValues, freeformValue }; + return { selectedValues, freeformValue } satisfies IChatMultiSelectAnswer; } return undefined; } default: - return question.defaultValue; + return typeof question.defaultValue === 'string' ? question.defaultValue : Array.isArray(question.defaultValue) ? { selectedValues: question.defaultValue } : undefined; } } @@ -1203,40 +1415,25 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent for (const question of this.carousel.questions) { const answer = this._answers.get(question.id); - if (answer === undefined) { - continue; - } const summaryItem = dom.$('.chat-question-summary-item'); - // Category label (use same text as shown in question UI: message ?? title) - const questionLabel = dom.$('span.chat-question-summary-label'); + const questionRow = dom.$('div.chat-question-summary-label'); const questionText = question.message ?? question.title; let labelText = typeof questionText === 'string' ? questionText : questionText.value; - // Remove trailing colons and whitespace to avoid double colons (CSS adds ': ') labelText = labelText.replace(/[:\s]+$/, ''); - questionLabel.textContent = labelText; - summaryItem.appendChild(questionLabel); + questionRow.textContent = localize('chat.questionCarousel.summaryQuestion', 'Q: {0}', labelText); + summaryItem.appendChild(questionRow); - // Format answer with title and description parts - const formattedAnswer = this.formatAnswerForSummary(question, answer); - const separatorIndex = formattedAnswer.indexOf(' - '); - - if (separatorIndex !== -1) { - // Answer title (bold) - const answerTitle = dom.$('span.chat-question-summary-answer-title'); - answerTitle.textContent = formattedAnswer.substring(0, separatorIndex); - summaryItem.appendChild(answerTitle); - - // Answer description (normal) - const answerDesc = dom.$('span.chat-question-summary-answer-desc'); - answerDesc.textContent = ' - ' + formattedAnswer.substring(separatorIndex + 3); - summaryItem.appendChild(answerDesc); + if (answer !== undefined) { + const formattedAnswer = this.formatAnswerForSummary(question, answer); + const answerRow = dom.$('div.chat-question-summary-answer-title'); + answerRow.textContent = localize('chat.questionCarousel.summaryAnswer', 'A: {0}', formattedAnswer); + summaryItem.appendChild(answerRow); } else { - // Just the answer value (bold) - const answerValue = dom.$('span.chat-question-summary-answer-title'); - answerValue.textContent = formattedAnswer; - summaryItem.appendChild(answerValue); + const unanswered = dom.$('div.chat-question-summary-unanswered'); + unanswered.textContent = localize('chat.questionCarousel.notAnsweredYet', 'Not answered yet'); + summaryItem.appendChild(unanswered); } summaryContainer.appendChild(summaryItem); @@ -1248,15 +1445,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent /** * Formats an answer for display in the summary. */ - private formatAnswerForSummary(question: IChatQuestion, answer: unknown): string { + private formatAnswerForSummary(question: IChatQuestion, answer: IChatQuestionAnswerValue): string { switch (question.type) { case 'text': return String(answer); case 'singleSelect': { - if (typeof answer === 'object' && answer !== null && hasKey(answer, { selectedValue: true })) { - const { selectedValue, freeformValue } = answer as { selectedValue?: unknown; freeformValue?: string }; - const selectedLabel = question.options?.find(opt => opt.value === selectedValue)?.label; + if (typeof answer === 'object') { + const { selectedValue, freeformValue } = answer as IChatSingleSelectAnswer; + const selectedLabel = selectedValue !== undefined ? question.options?.find(opt => opt.value === selectedValue)?.label : undefined; // For singleSelect, freeform takes priority over selection if (freeformValue) { return freeformValue; @@ -1268,9 +1465,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } case 'multiSelect': { - if (typeof answer === 'object' && answer !== null && hasKey(answer, { selectedValues: true })) { - const { selectedValues, freeformValue } = answer as { selectedValues?: unknown[]; freeformValue?: string }; - const labels = (selectedValues ?? []) + if (typeof answer === 'object' && hasKey(answer, { selectedValues: true })) { + const { selectedValues, freeformValue } = answer; + const labels = selectedValues .map(v => question.options?.find(opt => opt.value === v)?.label ?? String(v)); // For multiSelect, combine selections and freeform with comma separator if (freeformValue) { @@ -1278,11 +1475,6 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } return labels.join(localize('chat.questionCarousel.listSeparator', ', ')); } - if (Array.isArray(answer)) { - return answer - .map(v => question.options?.find(opt => opt.value === v)?.label ?? String(v)) - .join(localize('chat.questionCarousel.listSeparator', ', ')); - } return String(answer); } @@ -1296,6 +1488,131 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent return renderAsPlaintext(md); } + /** + * Validates the current question's answer against its validation rules. + * Returns true if valid, false if validation errors were shown. + */ + private validateCurrentQuestion(): boolean { + const question = this.carousel.questions[this._currentIndex]; + if (!question) { + return true; + } + + const answer = this._answers.get(question.id); + + // Check required + if (question.required && (answer === undefined || answer === '')) { + this.showValidationError(localize('chat.questionCarousel.required', 'This field is required')); + return false; + } + + // Validate text inputs + if (question.type === 'text' && question.validation && typeof answer === 'string' && answer !== '') { + const error = this.getValidationError(answer, question.validation); + if (error) { + this.showValidationError(error); + return false; + } + } + + this.clearValidationError(); + return true; + } + + /** + * Validates that all required questions have been answered. + * Returns true if all required fields are satisfied. + */ + private validateRequiredFields(): boolean { + for (let i = 0; i < this.carousel.questions.length; i++) { + const question = this.carousel.questions[i]; + if (!question.required) { + continue; + } + const answer = this._answers.get(question.id); + if (answer === undefined || answer === '') { + // Navigate to the unanswered required question + this.saveCurrentAnswer(); + this._currentIndex = i; + this.persistDraftState(); + this.renderCurrentQuestion(true); + this.showValidationError(localize('chat.questionCarousel.required', 'This field is required')); + return false; + } + } + return true; + } + + /** + * Returns a validation error message for the given value, or undefined if valid. + */ + private getValidationError(value: string, validation: IChatQuestionValidation): string | undefined { + if (validation.minLength !== undefined && value.length < validation.minLength) { + return localize('chat.questionCarousel.validation.minLength', 'Minimum length is {0}', validation.minLength); + } + if (validation.maxLength !== undefined && value.length > validation.maxLength) { + return localize('chat.questionCarousel.validation.maxLength', 'Maximum length is {0}', validation.maxLength); + } + if (validation.format) { + switch (validation.format) { + case 'email': + if (!value.includes('@')) { + return localize('chat.questionCarousel.validation.email', 'Please enter a valid email address'); + } + break; + case 'uri': + if (!URL.canParse(value)) { + return localize('chat.questionCarousel.validation.uri', 'Please enter a valid URI'); + } + break; + case 'date': { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(value) || isNaN(new Date(value).getTime())) { + return localize('chat.questionCarousel.validation.date', 'Please enter a valid date (YYYY-MM-DD)'); + } + break; + } + case 'date-time': + if (isNaN(new Date(value).getTime())) { + return localize('chat.questionCarousel.validation.dateTime', 'Please enter a valid date-time'); + } + break; + } + } + if (validation.isInteger !== undefined || validation.minimum !== undefined || validation.maximum !== undefined) { + const num = Number(value); + if (isNaN(num)) { + return localize('chat.questionCarousel.validation.number', 'Please enter a valid number'); + } + if (validation.isInteger && !Number.isInteger(num)) { + return localize('chat.questionCarousel.validation.integer', 'Please enter a valid integer'); + } + if (validation.minimum !== undefined && num < validation.minimum) { + return localize('chat.questionCarousel.validation.minimum', 'Minimum value is {0}', validation.minimum); + } + if (validation.maximum !== undefined && num > validation.maximum) { + return localize('chat.questionCarousel.validation.maximum', 'Maximum value is {0}', validation.maximum); + } + } + return undefined; + } + + private showValidationError(message: string): void { + this._currentValidationError = message; + if (this._validationMessageElement) { + this._validationMessageElement.textContent = message; + this._validationMessageElement.style.display = ''; + } + } + + private clearValidationError(): void { + this._currentValidationError = undefined; + if (this._validationMessageElement) { + this._validationMessageElement.textContent = ''; + this._validationMessageElement.style.display = 'none'; + } + } + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { // does not have same content when it is not skipped and is active and we stop the response if (!this._isSkipped && !this.carousel.isUsed && isResponseVM(element) && element.isComplete) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts index 6ff863773db..849393873dd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts @@ -56,6 +56,7 @@ export interface IChatReferenceListItem extends IChatContentReference { description?: string; state?: ModifiedFileEntryState; excluded?: boolean; + showModifiedState?: boolean; } export type IChatCollapsibleListItem = IChatReferenceListItem | IChatWarningMessage; @@ -389,8 +390,7 @@ class CollapsibleListRenderer implements IListRenderer f.value.buffer, () => undefined); + if (!value) { + entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); + } else { + entries.push({ kind: 'image', id: generateUuid(), name: basename(part.uri), value, mimeType: part.mimeType, isURL: false, references: [{ kind: 'reference', reference: part.uri }] }); + } + } + } else { + entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); + } + } + + if (this._store.isDisposed) { + return; + } + + // Render attachments immediately with placeholders + const attachments = this._register(this._instantiationService.createInstance( + ChatAttachmentsContentPart, + { + variables: entries, + limit: 5, + contentReferences: undefined, + domNode: undefined + } + )); + + attachments.contextMenuHandler = (attachment, event) => { + const index = entries.indexOf(attachment); + const part = parts[index]; + if (part) { + event.preventDefault(); + event.stopPropagation(); + + this._contextMenuService.showContextMenu({ + menuId: MenuId.ChatToolOutputResourceContext, + menuActionOptions: { shouldForwardArgs: true }, + getAnchor: () => ({ x: event.pageX, y: event.pageY }), + getActionsContext: () => ({ parts: [part] } satisfies IChatToolOutputResourceToolbarContext), + }); + } + }; + + itemsContainer.appendChild(attachments.domNode!); + + const toolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, MenuId.ChatToolOutputResourceToolbar, { + menuOptions: { + shouldForwardArgs: true, + }, + })); + toolbar.context = { parts } satisfies IChatToolOutputResourceToolbarContext; + + // Second pass: decode base64 images asynchronously and update in place + if (deferredImageParts.length > 0) { + this._register(disposableTimeout(() => { + for (const { index, part } of deferredImageParts) { + try { + const value = decodeBase64(part.base64Value!).buffer; + entries[index] = { kind: 'image', id: generateUuid(), name: basename(part.uri), value, mimeType: part.mimeType!, isURL: false, references: [{ kind: 'reference', reference: part.uri }] }; + } catch { + // Keep the file placeholder on decode failure + } + } + + // Update attachments in place + attachments.updateVariables(entries); + }, IMAGE_DECODE_DELAY_MS)); + } + } +} + + +class SaveResourcesAction extends Action2 { + public static readonly ID = 'chat.toolOutput.save'; + constructor() { + super({ + id: SaveResourcesAction.ID, + title: localize2('chat.saveResources', "Save..."), + icon: Codicon.cloudDownload, + menu: [{ + id: MenuId.ChatToolOutputResourceToolbar, + group: 'navigation', + order: 1 + }, { + id: MenuId.ChatToolOutputResourceContext, + }] + }); + } + + async run(accessor: ServicesAccessor, context: IChatToolOutputResourceToolbarContext) { + const fileDialog = accessor.get(IFileDialogService); + const fileService = accessor.get(IFileService); + const notificationService = accessor.get(INotificationService); + const progressService = accessor.get(IProgressService); + const workspaceContextService = accessor.get(IWorkspaceContextService); + const commandService = accessor.get(ICommandService); + const labelService = accessor.get(ILabelService); + const defaultFilepath = await fileDialog.defaultFilePath(); + + const savePart = async (part: IChatCollapsibleIODataPart, isFolder: boolean, uri: URI) => { + const target = isFolder ? joinPath(uri, basename(part.uri)) : uri; + try { + if (part.kind === 'data') { + await fileService.copy(part.uri, target, true); + } else { + // MCP doesn't support streaming data, so no sense trying + const contents = await fileService.readFile(part.uri); + await fileService.writeFile(target, contents.value); + } + } catch (e) { + notificationService.error(localize('chat.saveResources.error', "Failed to save {0}: {1}", basename(part.uri), e)); + } + }; + + const withProgress = async (thenReveal: URI, todo: (() => Promise)[]) => { + await progressService.withProgress({ + location: ProgressLocation.Notification, + delay: 5_000, + title: localize('chat.saveResources.progress', "Saving resources..."), + }, async report => { + for (const task of todo) { + await task(); + report.report({ increment: 1, total: todo.length }); + } + }); + + if (workspaceContextService.isInsideWorkspace(thenReveal)) { + commandService.executeCommand(REVEAL_IN_EXPLORER_COMMAND_ID, thenReveal); + } else { + notificationService.info(localize('chat.saveResources.reveal', "Saved resources to {0}", labelService.getUriLabel(thenReveal))); + } + }; + + if (context.parts.length === 1) { + const part = context.parts[0]; + const uri = await fileDialog.pickFileToSave(joinPath(defaultFilepath, basename(part.uri))); + if (!uri) { + return; + } + await withProgress(uri, [() => savePart(part, false, uri)]); + } else { + const uris = await fileDialog.showOpenDialog({ + title: localize('chat.saveResources.title', "Pick folder to save resources"), + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri: workspaceContextService.getWorkspace().folders[0]?.uri, + }); + + if (!uris?.length) { + return; + } + + await withProgress(uris[0], context.parts.map(part => () => savePart(part, true, uris[0]))); + } + } +} + +registerAction2(SaveResourcesAction); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts index c9e3d0c4a96..8952b2339b1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts @@ -9,7 +9,9 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatMode } from '../../../common/chatModes.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { IHandOff } from '../../../common/promptSyntax/promptFileParser.js'; @@ -36,7 +38,8 @@ export class ChatSuggestNextWidget extends Disposable { constructor( @IContextMenuService private readonly contextMenuService: IContextMenuService, - @IChatSessionsService private readonly chatSessionsService: IChatSessionsService + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService ) { super(); this.domNode = this.createSuggestNextWidget(); @@ -126,11 +129,15 @@ export class ChatSuggestNextWidget extends Disposable { // Get chat session contributions to show in chevron dropdown // Filter to only first-party providers that support "continue in". // TODO: Expand later to any agent with `canDelegate` === true. + const currentSessionType = this.contextKeyService.getContextKeyValue(ChatContextKeys.chatSessionType.key); const contributions = this.chatSessionsService.getAllChatSessionContributions(); const availableContributions = contributions.filter(c => { if (!c.canDelegate) { return false; } + if (c.type === currentSessionType) { + return false; + } const provider = getAgentSessionProvider(c.type); return provider !== undefined && getAgentCanContinueIn(provider); }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index bbab1de37aa..d695b515e88 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -500,6 +500,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen height: viewportHeight, scrollHeight: contentHeight }); + + // Re-evaluate hover feedback as content grows past the max height, + // reusing the already-measured contentHeight to avoid an extra layout read. + this.updateDropdownClickability(contentHeight); } private scrollToBottom(): void { @@ -651,8 +655,17 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return !(!strippedContent || strippedContent === titleToCompare); } - private updateDropdownClickability(): void { - const allowExpansion = this.shouldAllowExpansion(); + private updateDropdownClickability(knownContentHeight?: number): void { + let allowExpansion = this.shouldAllowExpansion(); + + // don't allow feedback on fixed scrolling before reaching max height. + if (allowExpansion && this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete && this.wrapper) { + const contentHeight = knownContentHeight ?? this.wrapper.scrollHeight; + if (contentHeight <= THINKING_SCROLL_MAX_HEIGHT) { + allowExpansion = false; + } + } + if (!allowExpansion && this.isExpanded()) { this.setExpanded(false); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts index 89cd89aea18..46171ef0343 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolOutputContentSubPart.ts @@ -4,39 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../../base/browser/dom.js'; -import { disposableTimeout } from '../../../../../../base/common/async.js'; -import { decodeBase64 } from '../../../../../../base/common/buffer.js'; -import { Codicon } from '../../../../../../base/common/codicons.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { basename, joinPath } from '../../../../../../base/common/resources.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { generateUuid } from '../../../../../../base/common/uuid.js'; import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; import { IModelService } from '../../../../../../editor/common/services/model.js'; -import { localize, localize2 } from '../../../../../../nls.js'; -import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; -import { Action2, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; -import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; -import { IFileDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; -import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; -import { IProgressService, ProgressLocation } from '../../../../../../platform/progress/common/progress.js'; -import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; -import { REVEAL_IN_EXPLORER_COMMAND_ID } from '../../../../files/browser/fileConstants.js'; -import { getAttachableImageExtension } from '../../../common/model/chatModel.js'; import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { IChatCodeBlockInfo } from '../../chat.js'; import { CodeBlockPart, ICodeBlockData } from './codeBlockPart.js'; -import { ChatAttachmentsContentPart } from './chatAttachmentsContentPart.js'; import { IDisposableReference } from './chatCollections.js'; import { IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatCollapsibleIOPart, IChatCollapsibleIOCodePart, IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; +import { ChatResourceGroupWidget } from './chatResourceGroupWidget.js'; /** * A reusable component for rendering tool output consisting of code blocks and/or resources. @@ -52,8 +32,6 @@ export class ChatToolOutputContentSubPart extends Disposable { private readonly parts: ChatCollapsibleIOPart[], @IInstantiationService private readonly _instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IContextMenuService private readonly _contextMenuService: IContextMenuService, - @IFileService private readonly _fileService: IFileService, @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, @@ -101,106 +79,8 @@ export class ChatToolOutputContentSubPart extends Disposable { } private addResourceGroup(parts: IChatCollapsibleIODataPart[], container: HTMLElement) { - const el = dom.h('.chat-collapsible-io-resource-group', [ - dom.h('.chat-collapsible-io-resource-items@items'), - dom.h('.chat-collapsible-io-resource-actions@actions'), - ]); - - this.fillInResourceGroup(parts, el.items, el.actions); - - container.appendChild(el.root); - return el.root; - } - - /** - * Delay in milliseconds before decoding base64 image data. - * This avoids expensive decode operations during scrolling. - */ - private static readonly IMAGE_DECODE_DELAY_MS = 100; - - private async fillInResourceGroup(parts: IChatCollapsibleIODataPart[], itemsContainer: HTMLElement, actionsContainer: HTMLElement) { - // First pass: create entries immediately, using file placeholders for base64 images - const entries: IChatRequestVariableEntry[] = []; - const deferredImageParts: { index: number; part: IChatCollapsibleIODataPart }[] = []; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (part.mimeType && getAttachableImageExtension(part.mimeType)) { - if (part.base64Value) { - // Defer base64 decode - use file placeholder for now - entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); - deferredImageParts.push({ index: i, part }); - } else if (part.value) { - entries.push({ kind: 'image', id: generateUuid(), name: basename(part.uri), value: part.value, mimeType: part.mimeType, isURL: false, references: [{ kind: 'reference', reference: part.uri }] }); - } else { - const value = await this._fileService.readFile(part.uri).then(f => f.value.buffer, () => undefined); - if (!value) { - entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); - } else { - entries.push({ kind: 'image', id: generateUuid(), name: basename(part.uri), value, mimeType: part.mimeType, isURL: false, references: [{ kind: 'reference', reference: part.uri }] }); - } - } - } else { - entries.push({ kind: 'file', id: generateUuid(), name: basename(part.uri), fullName: part.uri.path, value: part.uri }); - } - } - - if (this._store.isDisposed) { - return; - } - - // Render attachments immediately with placeholders - const attachments = this._register(this._instantiationService.createInstance( - ChatAttachmentsContentPart, - { - variables: entries, - limit: 5, - contentReferences: undefined, - domNode: undefined - } - )); - - attachments.contextMenuHandler = (attachment, event) => { - const index = entries.indexOf(attachment); - const part = parts[index]; - if (part) { - event.preventDefault(); - event.stopPropagation(); - - this._contextMenuService.showContextMenu({ - menuId: MenuId.ChatToolOutputResourceContext, - menuActionOptions: { shouldForwardArgs: true }, - getAnchor: () => ({ x: event.pageX, y: event.pageY }), - getActionsContext: () => ({ parts: [part] } satisfies IChatToolOutputResourceToolbarContext), - }); - } - }; - - itemsContainer.appendChild(attachments.domNode!); - - const toolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, MenuId.ChatToolOutputResourceToolbar, { - menuOptions: { - shouldForwardArgs: true, - }, - })); - toolbar.context = { parts } satisfies IChatToolOutputResourceToolbarContext; - - // Second pass: decode base64 images asynchronously and update in place - if (deferredImageParts.length > 0) { - this._register(disposableTimeout(() => { - for (const { index, part } of deferredImageParts) { - try { - const value = decodeBase64(part.base64Value!).buffer; - entries[index] = { kind: 'image', id: generateUuid(), name: basename(part.uri), value, mimeType: part.mimeType!, isURL: false, references: [{ kind: 'reference', reference: part.uri }] }; - } catch { - // Keep the file placeholder on decode failure - } - } - - // Update attachments in place - attachments.updateVariables(entries); - }, ChatToolOutputContentSubPart.IMAGE_DECODE_DELAY_MS)); - } + const widget = this._register(this._instantiationService.createInstance(ChatResourceGroupWidget, parts)); + container.appendChild(widget.domNode); } private addCodeBlock(parts: IChatCollapsibleIOCodePart[], container: HTMLElement): void { @@ -253,97 +133,3 @@ export class ChatToolOutputContentSubPart extends Disposable { this._editorReferences.forEach(r => r.object.layout(width)); } } - -interface IChatToolOutputResourceToolbarContext { - parts: IChatCollapsibleIODataPart[]; -} - - - -class SaveResourcesAction extends Action2 { - public static readonly ID = 'chat.toolOutput.save'; - constructor() { - super({ - id: SaveResourcesAction.ID, - title: localize2('chat.saveResources', "Save As..."), - icon: Codicon.cloudDownload, - menu: [{ - id: MenuId.ChatToolOutputResourceToolbar, - group: 'navigation', - order: 1 - }, { - id: MenuId.ChatToolOutputResourceContext, - }] - }); - } - - async run(accessor: ServicesAccessor, context: IChatToolOutputResourceToolbarContext) { - const fileDialog = accessor.get(IFileDialogService); - const fileService = accessor.get(IFileService); - const notificationService = accessor.get(INotificationService); - const progressService = accessor.get(IProgressService); - const workspaceContextService = accessor.get(IWorkspaceContextService); - const commandService = accessor.get(ICommandService); - const labelService = accessor.get(ILabelService); - const defaultFilepath = await fileDialog.defaultFilePath(); - - const savePart = async (part: IChatCollapsibleIODataPart, isFolder: boolean, uri: URI) => { - const target = isFolder ? joinPath(uri, basename(part.uri)) : uri; - try { - if (part.kind === 'data') { - await fileService.copy(part.uri, target, true); - } else { - // MCP doesn't support streaming data, so no sense trying - const contents = await fileService.readFile(part.uri); - await fileService.writeFile(target, contents.value); - } - } catch (e) { - notificationService.error(localize('chat.saveResources.error', "Failed to save {0}: {1}", basename(part.uri), e)); - } - }; - - const withProgress = async (thenReveal: URI, todo: (() => Promise)[]) => { - await progressService.withProgress({ - location: ProgressLocation.Notification, - delay: 5_000, - title: localize('chat.saveResources.progress', "Saving resources..."), - }, async report => { - for (const task of todo) { - await task(); - report.report({ increment: 1, total: todo.length }); - } - }); - - if (workspaceContextService.isInsideWorkspace(thenReveal)) { - commandService.executeCommand(REVEAL_IN_EXPLORER_COMMAND_ID, thenReveal); - } else { - notificationService.info(localize('chat.saveResources.reveal', "Saved resources to {0}", labelService.getUriLabel(thenReveal))); - } - }; - - if (context.parts.length === 1) { - const part = context.parts[0]; - const uri = await fileDialog.pickFileToSave(joinPath(defaultFilepath, basename(part.uri))); - if (!uri) { - return; - } - await withProgress(uri, [() => savePart(part, false, uri)]); - } else { - const uris = await fileDialog.showOpenDialog({ - title: localize('chat.saveResources.title', "Pick folder to save resources"), - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - defaultUri: workspaceContextService.getWorkspace().folders[0]?.uri, - }); - - if (!uris?.length) { - return; - } - - await withProgress(uris[0], context.parts.map(part => () => savePart(part, true, uris[0]))); - } - } -} - -registerAction2(SaveResourcesAction); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css index cd5343e662e..43efe1b98e0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatConfirmationWidget.css @@ -193,6 +193,23 @@ } } +.mcp-app-downloads { + margin-top: 8px; + + .chat-collapsible-io-resource-group { + animation: mcpDownloadFadeIn 300ms ease-in; + } +} + +.mcp-app-downloads:empty { + display: none; +} + +@keyframes mcpDownloadFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + .chat-confirmation-widget2 { margin-bottom: 8px; border: 1px solid var(--vscode-chat-requestBorder); @@ -249,6 +266,138 @@ } } +.chat-confirmation-widget2 .chat-modified-files-confirmation-list { + margin: 8px -9px 0; + border-top: 1px solid var(--vscode-chat-requestBorder); + border-left: none; + border-right: none; + border-bottom: none; + border-radius: 0; + padding: 3px 0 0; + + .chat-editing-session-overview { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + min-height: 22px; + cursor: pointer; + } + + .working-set-title { + flex: 1; + min-width: 0; + color: var(--vscode-descriptionForeground); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + align-content: center; + + .monaco-button { + width: fit-content; + max-width: 100%; + padding: 4px 6px 4px 0; + border: none; + border-radius: 2px; + background-color: unset; + color: var(--vscode-descriptionForeground); + } + + .monaco-button:focus { + outline: none !important; + box-shadow: none !important; + } + + .monaco-button:hover { + background-color: transparent; + } + + .monaco-button:focus-visible { + outline: none !important; + box-shadow: none !important; + } + + .monaco-button-mdlabel { + display: flex; + align-items: center; + width: auto; + flex: 0 1 auto; + text-align: left; + } + } + + .working-set-line-counts { + display: inline-flex; + gap: 4px; + margin-left: 6px; + font-size: 11px; + font-weight: 500; + flex-shrink: 0; + } + + .working-set-lines-added { + color: var(--vscode-chat-linesAddedForeground); + } + + .working-set-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); + } + + .chat-editing-session-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + padding-right: 8px; + } + + .chat-editing-session-actions .monaco-button.secondary.monaco-text-button.codicon { + cursor: pointer; + padding: 2px; + border-radius: 4px; + display: inline-flex; + } + + .chat-editing-session-list { + margin-top: 4px; + } + + .chat-editing-session-list.collapsed { + display: none; + } + + .chat-editing-session-list .monaco-scrollable-element { + border-radius: 0; + } + + .chat-editing-session-list .monaco-list-row { + border-radius: 4px; + } + + .chat-editing-session-list .monaco-list-row:hover { + background-color: var(--vscode-list-hoverBackground) !important; + } + + .chat-editing-session-list .monaco-icon-label { + padding: 0 3px; + } + + .chat-editing-session-list .working-set-line-counts { + margin: 0 6px; + } + + .chat-collapsible-list-action-bar { + display: none; + } + + .monaco-list-row:hover .chat-collapsible-list-action-bar:not(.has-no-actions), + .monaco-list-row.focused .chat-collapsible-list-action-bar:not(.has-no-actions), + .monaco-list-row.selected .chat-collapsible-list-action-bar:not(.has-no-actions) { + display: inherit; + } +} + .chat-confirmation-widget2 .chat-confirmation-message-terminal .chat-confirmation-message-terminal-editor { border-bottom: 1px solid var(--vscode-chat-requestBorder); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 28f9603b669..62d2a0a03b7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -4,48 +4,64 @@ *--------------------------------------------------------------------------------------------*/ /* question carousel - this is above edits and todos */ -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:empty { +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:empty, +.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container:empty { display: none; } /* input specific styling */ -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container { +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container, +.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container .chat-question-carousel-container { margin: 0; border: 1px solid var(--vscode-input-border, transparent); - background-color: var(--vscode-editor-background); - border-radius: 4px; + background-color: var(--vscode-panel-background); + border-radius: var(--vscode-cornerRadius-large); } /* general questions styling */ .interactive-session .chat-question-carousel-container { margin: 8px 0; border: 1px solid var(--vscode-chat-requestBorder); - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-large); display: flex; flex-direction: column; overflow: hidden; container-type: inline-size; + max-height: min(420px, 45vh); + position: relative; } /* input part wrapper */ -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container { +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container, +.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container { width: 100%; position: relative; + display: flex; + flex-direction: column; + gap: 8px; +} + +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:not(:empty), +.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container:not(:empty) { + margin-top: 8px; +} + +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-content, +.interactive-session .interactive-input-part .interactive-input-and-edit-session > .chat-question-carousel-widget-container .chat-question-carousel-content { + min-height: 0; } /* container and header */ .interactive-session .chat-question-carousel-container .chat-question-carousel-content { display: flex; flex-direction: column; - background: var(--vscode-chat-requestBackground); - padding: 8px 16px 10px 16px; + min-height: 0; overflow: hidden; .chat-question-header-row { display: flex; flex-direction: column; - background: var(--vscode-chat-requestBackground); - padding: 0 16px 10px 16px; + flex-shrink: 0; overflow: hidden; .chat-question-title-row { @@ -54,6 +70,8 @@ align-items: center; gap: 8px; min-width: 0; + padding: 8px 8px 8px 16px; + border-bottom: 1px solid var(--vscode-chat-requestBorder); } .chat-question-title { @@ -64,13 +82,6 @@ font-weight: 500; font-size: var(--vscode-chat-font-size-body-s); margin: 0; - padding-top: 4px; - padding-bottom: 4px; - margin-left: -16px; - margin-right: -16px; - padding-left: 16px; - padding-right: 16px; - border-bottom: 1px solid var(--vscode-chat-requestBorder); .rendered-markdown { a { @@ -86,15 +97,6 @@ margin: 0; } } - - .chat-question-title-main { - font-weight: 500; - } - - .chat-question-title-subtitle { - font-weight: normal; - color: var(--vscode-descriptionForeground); - } } .chat-question-close-container { @@ -105,38 +107,16 @@ width: 22px; height: 22px; padding: 0; - border: none; + border: none !important; + box-shadow: none !important; background: transparent !important; - color: var(--vscode-foreground) !important; + color: var(--vscode-icon-foreground) !important; } .monaco-button.chat-question-close:hover:not(.disabled) { background: var(--vscode-toolbar-hoverBackground) !important; } } - - .chat-question-message { - padding-top: 8px; - font-size: var(--vscode-chat-font-size-body-s); - word-wrap: break-word; - overflow-wrap: break-word; - line-height: 1.4; - - .rendered-markdown { - a { - color: var(--vscode-textLink-foreground); - } - - a:hover, - a:active { - color: var(--vscode-textLink-activeForeground); - } - - p { - margin: 0; - } - } - } } } @@ -144,41 +124,35 @@ .interactive-session .chat-question-carousel-container .chat-question-input-container { display: flex; flex-direction: column; - margin-top: 4px; + padding: 8px; min-width: 0; + &::after { + content: ''; + display: block; + height: 8px; + flex-shrink: 0; + } + /* some hackiness to get the focus looking right */ - .chat-question-list-item:focus:not(.selected), + .chat-question-list-item:focus, .chat-question-list:focus { outline: none; } - .chat-question-list:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; - } - - .chat-question-list:focus-within .chat-question-list-item.selected { - outline-width: 1px; - outline-style: solid; - outline-offset: -1px; - outline-color: var(--vscode-focusBorder); - } - .chat-question-list { display: flex; flex-direction: column; - gap: 3px; outline: none; - padding: 4px 0; + padding: 0; .chat-question-list-item { display: flex; - align-items: flex-start; - gap: 8px; - padding: 3px 8px; + align-items: center; + gap: 12px; + padding: 6px 8px; cursor: pointer; - border-radius: 3px; + border-radius: var(--vscode-cornerRadius-medium); user-select: none; .chat-question-list-indicator { @@ -189,6 +163,8 @@ justify-content: center; flex-shrink: 0; margin-left: auto; + align-self: flex-start; + margin-top: 2px; } .chat-question-list-indicator.codicon-check { @@ -201,11 +177,13 @@ flex: 1; word-wrap: break-word; overflow-wrap: break-word; - padding-top: 2px; + display: flex; + flex-direction: column; } .chat-question-list-label-title { - font-weight: 600; + font-weight: 500; + line-height: 1.4; } .chat-question-list-label-desc { @@ -214,13 +192,28 @@ } } + .chat-question-list-item.has-description { + align-items: flex-start; + + .chat-question-list-number { + line-height: 1.4; + font-size: var(--vscode-chat-font-size-body-s); + font-weight: 500; + } + + .chat-question-list-checkbox { + /* Title line-height is ~17px (1.4 * body-s), checkbox is 16px: 1px offset */ + margin-top: 1px; + } + } + .chat-question-list-item:hover { background-color: var(--vscode-list-hoverBackground); } /* Single-select: highlight entire row when selected */ .chat-question-list-item.selected { - background-color: var(--vscode-list-activeSelectionBackground); + background-color: var(--vscode-list-hoverBackground); color: var(--vscode-list-activeSelectionForeground); .chat-question-label { @@ -237,16 +230,12 @@ } .chat-question-list-number { - background-color: transparent; color: var(--vscode-list-activeSelectionForeground); - border-color: var(--vscode-list-activeSelectionForeground); - border-bottom-color: var(--vscode-list-activeSelectionForeground); - box-shadow: none; } } .chat-question-list-item.selected:hover { - background-color: var(--vscode-list-activeSelectionBackground); + background-color: var(--vscode-list-hoverBackground); } /* Checkbox for multi-select */ @@ -260,11 +249,12 @@ } .chat-question-freeform { - margin-left: 8px; + margin: 0; display: flex; flex-direction: row; align-items: center; - gap: 8px; + padding: 4px 8px; + gap: 12px; .chat-question-freeform-number { height: fit-content; @@ -307,45 +297,43 @@ /* todo: change to use keybinding service so we don't have to recreate this */ .chat-question-list-number, .chat-question-freeform-number { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 14px; - padding: 0px 4px; - border-style: solid; - border-width: 1px; - border-radius: 3px; - font-size: 11px; - font-weight: normal; - background-color: var(--vscode-keybindingLabel-background); - color: var(--vscode-keybindingLabel-foreground); - border-color: var(--vscode-keybindingLabel-border); - border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); - box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); flex-shrink: 0; + min-width: 1ch; + text-align: right; } } -/* footer with step indicator and nav buttons */ +.interactive-session .chat-question-carousel-container .chat-question-input-container > * { + flex-shrink: 0; +} + +.interactive-session .chat-question-carousel-container .chat-question-input-scrollable { + flex: 0 1 auto; + min-height: 0; + overscroll-behavior: contain; +} + +/* footer with nav arrows, step indicator, and submit */ .interactive-session .chat-question-carousel-container .chat-question-footer-row { display: flex; justify-content: space-between; align-items: center; - padding: 4px 16px; + padding: 4px 8px; border-top: 1px solid var(--vscode-chat-requestBorder); - background: var(--vscode-chat-requestBackground); + flex-shrink: 0; - .chat-question-step-indicator { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-descriptionForeground); - } - - .chat-question-carousel-nav { + .chat-question-footer-left { display: flex; align-items: center; - gap: 4px; - flex-shrink: 0; - margin-left: auto; + gap: 8px; + } + + .chat-question-footer-right { + display: flex; + align-items: center; + gap: 8px; } .chat-question-nav-arrows { @@ -354,49 +342,74 @@ gap: 4px; } - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow { + .monaco-button.chat-question-nav-arrow { min-width: 22px; width: 22px; height: 22px; padding: 0; - border: none; - } - - /* Secondary buttons (prev, next) use gray secondary background */ - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev, - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next { - background: var(--vscode-button-secondaryBackground) !important; - color: var(--vscode-button-secondaryForeground) !important; - } - - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev:hover:not(.disabled), - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next:hover:not(.disabled) { - background: var(--vscode-button-secondaryHoverBackground) !important; - } - - /* Dedicated submit button uses primary background */ - .chat-question-carousel-nav .monaco-button.chat-question-submit-button { - background: var(--vscode-button-background) !important; - color: var(--vscode-button-foreground) !important; - height: 22px; - min-width: auto; - padding: 0 8px; - } - - .chat-question-carousel-nav .monaco-button.chat-question-submit-button:hover:not(.disabled) { - background: var(--vscode-button-hoverBackground) !important; - } - - /* Close button uses transparent background */ - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close { + border: none !important; + box-shadow: none !important; background: transparent !important; color: var(--vscode-foreground) !important; } - .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close:hover:not(.disabled) { + .monaco-button.chat-question-nav-arrow:hover:not(.disabled) { background: var(--vscode-toolbar-hoverBackground) !important; } + .monaco-button.chat-question-nav-arrow.disabled { + opacity: 0.4; + } + + .chat-question-step-indicator { + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + } + + .chat-question-submit-hint { + font-size: 11px; + color: var(--vscode-descriptionForeground); + } + + .monaco-button.chat-question-submit-button { + background: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; + height: 22px; + width: auto; + flex: 0 0 auto; + min-width: auto; + padding: 0 8px; + } + + .monaco-button.chat-question-submit-button:hover:not(.disabled) { + background: var(--vscode-button-hoverBackground) !important; + } +} + +/* carousel-level message (e.g. from MCP elicitation) */ +.interactive-session .chat-question-carousel-container .chat-question-carousel-message { + padding: 8px 16px 0; + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + + .rendered-markdown p { + margin: 0; + } +} + +/* field description below question title */ +.interactive-session .chat-question-carousel-container .chat-question-description { + padding: 4px 16px; + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); +} + +/* validation error message below input area */ +.interactive-session .chat-question-carousel-container .chat-question-validation-message { + padding: 0 16px 4px; + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-editorWarning-foreground); + flex-shrink: 0; } /* summary (after finished) */ @@ -408,9 +421,7 @@ .chat-question-summary-item { display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: baseline; + flex-direction: column; gap: 0; font-size: var(--vscode-chat-font-size-body-s); } @@ -421,11 +432,6 @@ overflow-wrap: break-word; } - .chat-question-summary-label::after { - content: ': '; - white-space: pre; - } - .chat-question-summary-answer-title { color: var(--vscode-foreground); font-weight: 600; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts index 32dcfbc757a..c9327e5097f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatInputOutputMarkdownProgressPart.ts @@ -42,6 +42,7 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS message: string | IMarkdownString, subtitle: string | IMarkdownString | undefined, input: string, + inputLanguage: string | undefined, output: IToolResultInputOutputDetails['output'] | undefined, isError: boolean, @IInstantiationService instantiationService: IInstantiationService, @@ -54,10 +55,10 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS let codeBlockIndex = codeBlockStartIndex; // Simple factory to create code part data objects - const createCodePart = (data: string): IChatCollapsibleIOCodePart => ({ + const createCodePart = (data: string, languageId = 'json'): IChatCollapsibleIOCodePart => ({ kind: 'code', data, - languageId: 'json', + languageId, codeBlockIndex: codeBlockIndex++, ownerMarkdownPartId: this.codeblocksPartId, options: { @@ -82,7 +83,7 @@ export class ChatInputOutputMarkdownProgressPart extends BaseChatToolInvocationS subtitle, this.getAutoApproveMessageContent(), context, - createCodePart(input), + createCodePart(input, inputLanguage), processedOutput && processedOutput.length > 0 ? { parts: processedOutput.map((o, i): ChatCollapsibleIOPart => { const permalinkBasename = o.type === 'ref' || o.uri diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts index 7541799ba29..4c83b8ac18a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppModel.ts @@ -17,12 +17,15 @@ import { autorun, autorunSelfDisposable, IObservable, observableValue } from '.. import { basename } from '../../../../../../../base/common/resources.js'; import { isFalsyOrWhitespace } from '../../../../../../../base/common/strings.js'; import { hasKey, isDefined } from '../../../../../../../base/common/types.js'; +import { URI } from '../../../../../../../base/common/uri.js'; import { localize } from '../../../../../../../nls.js'; +import { IChatResponseResourceFileSystemProvider } from '../../../../common/widget/chatResponseResourceFileSystemProvider.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../../../../platform/product/common/productService.js'; import { IStorageService } from '../../../../../../../platform/storage/common/storage.js'; + import { IMcpAppResourceContent, McpToolCallUI } from '../../../../../mcp/browser/mcpToolCallUI.js'; import { McpResourceURI } from '../../../../../mcp/common/mcpTypes.js'; import { MCP } from '../../../../../mcp/common/modelContextProtocol.js'; @@ -32,6 +35,7 @@ import { IChatRequestVariableEntry } from '../../../../common/attachments/chatVa import { IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, IToolResult } from '../../../../common/tools/languageModelToolsService.js'; import { IChatWidgetService } from '../../../chat.js'; +import { IChatCollapsibleIODataPart } from '../chatToolInputOutputContentPart.js'; import { IMcpAppRenderData } from './chatMcpAppSubPart.js'; /** Storage key for persistent webview origins */ @@ -84,6 +88,10 @@ export class ChatMcpAppModel extends Disposable { private readonly _onDidChangeHeight = this._register(new Emitter()); public readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; + /** Accumulated download resource parts from ui/download-file calls */ + private readonly _downloadParts = observableValue(this, []); + public readonly downloadParts: IObservable = this._downloadParts; + /** Full host context for the MCP App */ public readonly hostContext: IObservable; @@ -97,6 +105,7 @@ export class ChatMcpAppModel extends Disposable { @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @IWebviewService private readonly _webviewService: IWebviewService, @IStorageService storageService: IStorageService, + @IChatResponseResourceFileSystemProvider private readonly _chatResponseResourceFsProvider: IChatResponseResourceFileSystemProvider, @ILogService private readonly _logService: ILogService, @IProductService private readonly _productService: IProductService, @IOpenerService private readonly _openerService: IOpenerService, @@ -437,6 +446,10 @@ export class ChatMcpAppModel extends Disposable { result = await this._handleOpenLink(request.params); break; + case 'ui/download-file': + result = await this._handleDownloadFile(request.params); + break; + case 'ui/request-display-mode': // VS Code only supports inline display mode result = { mode: 'inline' } satisfies McpApps.McpUiRequestDisplayModeResult; @@ -543,7 +556,8 @@ export class ChatMcpAppModel extends Disposable { resourceLink: {}, resource: {}, structuredContent: {}, - } + }, + downloadFile: {}, }, hostContext: this.hostContext.get(), } satisfies Required; @@ -682,6 +696,45 @@ export class ChatMcpAppModel extends Disposable { widget?.delegateScrollFromMouseWheelEvent(evt as IMouseWheelEvent); } + private async _handleDownloadFile(params: McpApps.McpUiDownloadFileRequest['params']): Promise { + const newParts: IChatCollapsibleIODataPart[] = []; + let hadError = false; + + for (const content of params.contents) { + try { + if (content.type === 'resource') { + // EmbeddedResource — associate inline content with the chat response FS + const resource = content.resource; + const parsed = URI.parse(resource.uri); + + const data: Uint8Array | { base64: string } = hasKey(resource, { text: true }) + ? new TextEncoder().encode(resource.text) + : { base64: resource.blob }; + + const uri = this._chatResponseResourceFsProvider.associate(this.renderData.sessionResource, data, basename(parsed)); + newParts.push({ kind: 'data', mimeType: resource.mimeType, uri }); + } else if (content.type === 'resource_link') { + // ResourceLink — create a part with an MCP resource URI, resolved lazily on save + const mcpUri = McpResourceURI.fromServer( + { id: this.renderData.serverDefinitionId, label: '' }, + content.uri, + ); + newParts.push({ kind: 'data', mimeType: content.mimeType, uri: mcpUri }); + } + } catch (error) { + hadError = true; + this._logService.warn('[MCP App] Failed to process ui/download-file content', error); + } + } + + if (newParts.length > 0) { + const existing = this._downloadParts.get(); + this._downloadParts.set([...existing, ...newParts], undefined); + } + + return hadError ? { isError: true } : {}; + } + private async _handleOpenLink(params: McpApps.McpUiOpenLinkRequest['params']): Promise { const ok = await this._openerService.open(params.url); return { isError: !ok }; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts index cf8f4d62ffa..db87356f004 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts @@ -21,6 +21,7 @@ import { IChatCodeBlockInfo } from '../../../chat.js'; import { IChatContentPartRenderContext } from '../chatContentParts.js'; import { ChatErrorWidget } from '../chatErrorContentPart.js'; import { ChatProgressSubPart } from '../chatProgressContentPart.js'; +import { ChatResourceGroupWidget } from '../chatResourceGroupWidget.js'; import { ChatMcpAppModel, McpAppLoadState } from './chatMcpAppModel.js'; import { BaseChatToolInvocationSubPart } from './chatToolInvocationSubPart.js'; @@ -63,6 +64,12 @@ export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { /** Current error node */ private _errorNode: HTMLElement | undefined; + /** Container for download resource pills */ + private readonly _downloadContainer: HTMLElement; + + /** Current resource group widget for downloads */ + private readonly _downloadWidget = this._register(new MutableDisposable()); + constructor( toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, onDidRemount: Event, @@ -81,6 +88,10 @@ export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { this._webviewContainer.style.height = '300px'; // Initial height, will be updated by model this.domNode.appendChild(this._webviewContainer); + // Download container — below webview, for ui/download-file resources + this._downloadContainer = dom.$('div.mcp-app-downloads'); + this.domNode.appendChild(this._downloadContainer); + const targetWindow = dom.getWindow(this.domNode); const getMaxHeight = () => maxWebviewHeightPct * targetWindow.innerHeight; const maxHeight = observableValue('mcpAppMaxHeight', getMaxHeight()); @@ -110,6 +121,21 @@ export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { this._updateContainerHeight(); })); + // Observe download parts and render resource group widget + this._register(autorun(reader => { + const parts = this._model.downloadParts.read(reader); + if (parts.length === 0) { + this._downloadWidget.clear(); + dom.clearNode(this._downloadContainer); + return; + } + + dom.clearNode(this._downloadContainer); + const widget = this._instantiationService.createInstance(ChatResourceGroupWidget, parts); + this._downloadWidget.value = widget; + this._downloadContainer.appendChild(widget.domNode); + })); + this._register(onDidRemount(() => { this._model.remount(); })); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts new file mode 100644 index 00000000000..8bf86a6b9d1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts @@ -0,0 +1,284 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../../base/browser/dom.js'; +import { Button, ButtonWithIcon } from '../../../../../../../base/browser/ui/button/button.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; +import { IMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { toDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { hasKey } from '../../../../../../../base/common/types.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../../nls.js'; +import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; +import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; +import { IMarkdownRendererService } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { defaultButtonStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; +import { IChatModifiedFilesConfirmationData, IChatToolInvocation, ToolConfirmKind } from '../../../../common/chatService/chatService.js'; +import { ILanguageModelToolsService } from '../../../../common/tools/languageModelToolsService.js'; +import { ModifiedFileEntryState } from '../../../../common/editing/chatEditingService.js'; +import { ChatContextKeys } from '../../../../common/actions/chatContextKeys.js'; +import { IChatCodeBlockInfo, IChatWidgetService } from '../../../chat.js'; +import { IChatContentPartRenderContext } from '../chatContentParts.js'; +import { ChatCustomConfirmationWidget, IChatConfirmationButton } from '../chatConfirmationWidget.js'; +import { CollapsibleListPool, IChatCollapsibleListItem } from '../chatReferencesContentPart.js'; +import { IEditorService } from '../../../../../../services/editor/common/editorService.js'; +import { AbstractToolConfirmationSubPart } from './abstractToolConfirmationSubPart.js'; + +export class ChatModifiedFilesConfirmationSubPart extends AbstractToolConfirmationSubPart { + public override readonly domNode: HTMLElement; + public override readonly codeblocks: IChatCodeBlockInfo[] = []; + + constructor( + toolInvocation: IChatToolInvocation, + context: IChatContentPartRenderContext, + private readonly listPool: CollapsibleListPool, + @IInstantiationService instantiationService: IInstantiationService, + @IKeybindingService keybindingService: IKeybindingService, + @IContextKeyService contextKeyService: IContextKeyService, + @IChatWidgetService chatWidgetService: IChatWidgetService, + @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, + @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @IEditorService private readonly editorService: IEditorService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(toolInvocation, context, instantiationService, keybindingService, contextKeyService, chatWidgetService, languageModelToolsService); + + const state = toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation || !state.confirmationMessages?.title) { + throw new Error('Modified files confirmation messages are missing'); + } + + const data = toolInvocation.toolSpecificData; + if (!data || data.kind !== 'modifiedFilesConfirmation') { + throw new Error('Modified files confirmation data is missing'); + } + + const tool = languageModelToolsService.getTool(toolInvocation.toolId); + const confirmWidget = this._register(this.instantiationService.createInstance( + ChatCustomConfirmationWidget<() => void>, + this.context, + { + title: this.getTitle(), + icon: tool?.icon && hasKey(tool.icon, { id: true }) ? tool.icon : Codicon.tools, + subtitle: typeof toolInvocation.originMessage === 'string' ? toolInvocation.originMessage : toolInvocation.originMessage?.value, + buttons: this.createButtons(data.options), + message: this.createWidgetContentElement(state.confirmationMessages.message, data), + } + )); + + const hasToolConfirmation = ChatContextKeys.Editing.hasToolConfirmation.bindTo(this.contextKeyService); + hasToolConfirmation.set(true); + + this._register(confirmWidget.onDidClick(button => { + button.data(); + this.chatWidgetService.getWidgetBySessionResource(this.context.element.sessionResource)?.focusInput(); + })); + + this._register(toDisposable(() => hasToolConfirmation.reset())); + this.domNode = confirmWidget.domNode; + } + + private createButtons(options: readonly string[]): IChatConfirmationButton<() => void>[] { + const [primaryOption, ...secondaryOptions] = options; + return [ + { + label: primaryOption, + data: () => this.confirmWith(this.toolInvocation, { type: ToolConfirmKind.UserAction, selectedButton: primaryOption }), + moreActions: secondaryOptions.map(option => ({ + label: option, + data: () => this.confirmWith(this.toolInvocation, { type: ToolConfirmKind.UserAction, selectedButton: option }), + })) + }, + { + label: localize('cancel', 'Cancel'), + data: () => this.confirmWith(this.toolInvocation, { type: ToolConfirmKind.Skipped }), + isSecondary: true, + } + ]; + } + + private createWidgetContentElement(message: string | IMarkdownString | undefined, data: IChatModifiedFilesConfirmationData): HTMLElement { + const container = dom.$('.chat-modified-files-confirmation'); + + if (message) { + const renderedMessage = this._register(this.markdownRendererService.render(typeof message === 'string' ? new MarkdownString(message) : message)); + container.append(renderedMessage.element); + } + + container.append(this.createModifiedFilesElement(data)); + return container; + } + + private createModifiedFilesElement(data: IChatModifiedFilesConfirmationData): HTMLElement { + const container = dom.$('.chat-modified-files-confirmation-list.chat-editing-session-container.show-file-icons'); + const overview = dom.append(container, dom.$('.chat-editing-session-overview')); + const title = dom.append(overview, dom.$('.working-set-title')); + const titleButton = this._register(new ButtonWithIcon(title, { + buttonBackground: undefined, + buttonBorder: undefined, + buttonForeground: undefined, + buttonHoverBackground: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSeparator: undefined, + supportIcons: true, + })); + const actions = dom.append(overview, dom.$('.chat-editing-session-actions')); + const countsContainer = dom.$('.working-set-line-counts'); + const addedSpan = dom.append(countsContainer, dom.$('.working-set-lines-added')); + const removedSpan = dom.append(countsContainer, dom.$('.working-set-lines-removed')); + titleButton.element.appendChild(countsContainer); + + const filesLabel = data.modifiedFiles.length === 1 + ? localize('oneFileChanged', '1 file changed') + : localize('manyFilesChanged', '{0} files changed', data.modifiedFiles.length); + titleButton.label = filesLabel; + + let added = 0; + let removed = 0; + let hasDiffStats = false; + for (const file of data.modifiedFiles) { + if (typeof file.insertions === 'number' || typeof file.deletions === 'number') { + hasDiffStats = true; + added += file.insertions ?? 0; + removed += file.deletions ?? 0; + } + } + + if (hasDiffStats) { + addedSpan.textContent = `+${added}`; + removedSpan.textContent = `-${removed}`; + titleButton.element.setAttribute('aria-label', localize('modifiedFilesSummaryWithCounts', '{0}, {1} lines added, {2} lines removed', filesLabel, added, removed)); + countsContainer.setAttribute('aria-label', localize('modifiedFilesCounts', '{0} lines added, {1} lines removed', added, removed)); + } else { + countsContainer.remove(); + titleButton.element.setAttribute('aria-label', filesLabel); + } + + const viewAllChangesButton = this._register(new Button(actions, { + ...defaultButtonStyles, + secondary: true, + small: true, + supportIcons: true, + ariaLabel: localize('viewAllChanges', 'View All Changes'), + title: localize('viewAllChanges', 'View All Changes'), + })); + viewAllChangesButton.element.classList.add('default-colors'); + viewAllChangesButton.icon = Codicon.diffMultiple; + viewAllChangesButton.label = ' '; + this._register(viewAllChangesButton.onDidClick(async () => { + await this.openAllChanges(data); + })); + + const listReference = this._register(this.listPool.get()); + const list = listReference.object; + const listItems = data.modifiedFiles.map(file => { + const resource = URI.revive(file.uri); + const originalUri = file.originalUri ? URI.revive(file.originalUri) : undefined; + return { + kind: 'reference', + reference: resource, + title: file.title, + description: file.description, + state: ModifiedFileEntryState.Accepted, + showModifiedState: true, + options: { + diffMeta: typeof file.insertions === 'number' || typeof file.deletions === 'number' ? { + added: file.insertions ?? 0, + removed: file.deletions ?? 0, + } : undefined, + originalUri, + status: undefined, + } + }; + }); + + this._register(list.onDidOpen(async e => { + if (e.element?.kind !== 'reference' || !URI.isUri(e.element.reference)) { + return; + } + + const modifiedUri = e.element.reference; + const originalUri = e.element.options?.originalUri; + if (originalUri) { + await this.editorService.openEditor({ + original: { resource: originalUri }, + modified: { resource: modifiedUri }, + options: e.editorOptions, + }); + return; + } + + await this.editorService.openEditor({ + resource: modifiedUri, + options: e.editorOptions, + }); + })); + + const maxItemsShown = 6; + const itemsShown = Math.min(listItems.length, maxItemsShown); + const height = itemsShown * 22; + const workingSetContainer = dom.append(container, dom.$('.chat-editing-session-list.collapsed')); + list.layout(height); + list.getHTMLElement().style.height = `${height}px`; + list.splice(0, list.length, listItems); + workingSetContainer.append(list.getHTMLElement()); + + let isCollapsed = true; + const setExpansionState = () => { + titleButton.icon = isCollapsed ? Codicon.chevronRight : Codicon.chevronDown; + workingSetContainer.classList.toggle('collapsed', isCollapsed); + }; + setExpansionState(); + + const toggleWorkingSet = () => { + isCollapsed = !isCollapsed; + setExpansionState(); + }; + + this._register(titleButton.onDidClick(toggleWorkingSet)); + this._register(dom.addDisposableListener(overview, 'click', e => { + if (e.defaultPrevented) { + return; + } + + const target = e.target as HTMLElement; + if (target.closest('.monaco-button')) { + return; + } + + toggleWorkingSet(); + })); + + return container; + } + + private async openAllChanges(data: IChatModifiedFilesConfirmationData): Promise { + await this.commandService.executeCommand('_workbench.openMultiDiffEditor', { + title: localize('modifiedFilesAllChangesTitle', 'All Changes'), + resources: data.modifiedFiles.map(file => ({ + originalUri: file.originalUri ? URI.revive(file.originalUri) : undefined, + modifiedUri: URI.revive(file.uri), + })) + }); + } + + protected createContentElement(): HTMLElement | string { + throw new Error('Not used'); + } + + protected getTitle(): string { + const state = this.toolInvocation.state.get(); + if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { + return ''; + } + + const title = state.confirmationMessages?.title; + return typeof title === 'string' ? title : title?.value ?? ''; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts index 8b2f2365905..eacf8b39b11 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolInvocationPart.ts @@ -23,6 +23,7 @@ import { ChatInputOutputMarkdownProgressPart } from './chatInputOutputMarkdownPr import { ChatMcpAppSubPart, IMcpAppRenderData } from './chatMcpAppSubPart.js'; import { ChatResultListSubPart } from './chatResultListSubPart.js'; import { ChatSimpleToolProgressPart } from './chatSimpleToolProgressPart.js'; +import { ChatModifiedFilesConfirmationSubPart } from './chatModifiedFilesConfirmationSubPart.js'; import { ChatTerminalToolConfirmationSubPart } from './chatTerminalToolConfirmationSubPart.js'; import { ChatTerminalToolProgressPart } from './chatTerminalToolProgressPart.js'; import { ToolConfirmationSubPart } from './chatToolConfirmationSubPart.js'; @@ -120,6 +121,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa // Add class when displaying a confirmation widget const isConfirmation = this.subPart instanceof ToolConfirmationSubPart || this.subPart instanceof ChatTerminalToolConfirmationSubPart || + this.subPart instanceof ChatModifiedFilesConfirmationSubPart || this.subPart instanceof ExtensionsInstallConfirmationWidgetSubPart || this.subPart instanceof ChatToolPostExecuteConfirmationPart; this.domNode.classList.toggle('has-confirmation', isConfirmation); @@ -173,6 +175,8 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { if (this.toolInvocation.toolSpecificData?.kind === 'terminal') { return this.instantiationService.createInstance(ChatTerminalToolConfirmationSubPart, this.toolInvocation, this.toolInvocation.toolSpecificData, this.context, this.renderer, this.editorPool, this.currentWidthDelegate, this.codeBlockModelCollection, this.codeBlockStartIndex); + } else if (this.toolInvocation.toolSpecificData?.kind === 'modifiedFilesConfirmation') { + return this.instantiationService.createInstance(ChatModifiedFilesConfirmationSubPart, this.toolInvocation, this.context, this.listPool); } else { return this.instantiationService.createInstance(ToolConfirmationSubPart, this.toolInvocation, this.context, this.renderer, this.editorPool, this.currentWidthDelegate, this.codeBlockModelCollection, this.codeBlockStartIndex); } @@ -222,6 +226,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa this.toolInvocation.pastTenseMessage ?? this.toolInvocation.invocationMessage, this.toolInvocation.originMessage, resultDetails.input, + resultDetails.inputLanguage, resultDetails.output, !!resultDetails.isError, ); @@ -237,6 +242,7 @@ export class ChatToolInvocationPart extends Disposable implements IChatContentPa this.toolInvocation.originMessage, typeof this.toolInvocation.toolSpecificData.rawInput === 'string' ? this.toolInvocation.toolSpecificData.rawInput : JSON.stringify(this.toolInvocation.toolSpecificData.rawInput, null, 2), undefined, + undefined, false, ); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts index aa7a82177c6..0aba9ec84a9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolPartUtilities.ts @@ -30,7 +30,7 @@ export function getApprovalMessageFromReason(reason: ConfirmedReason): IMarkdown let md: string; switch (reason.type) { case ToolConfirmKind.Setting: - md = localize('chat.autoapprove.setting', 'Auto approved by {0}', createMarkdownCommandLink({ title: '`' + reason.id + '`', id: 'workbench.action.openSettings', arguments: [reason.id] }, false)); + md = localize('chat.autoapprove.setting', 'Auto approved by {0}', createMarkdownCommandLink({ text: '`' + reason.id + '`', id: 'workbench.action.openSettings', arguments: [reason.id], tooltip: localize('openSettings.tooltip', 'Open settings') }, false)); break; case ToolConfirmKind.LmServicePerTool: md = reason.scope === 'session' @@ -38,7 +38,7 @@ export function getApprovalMessageFromReason(reason: ConfirmedReason): IMarkdown : reason.scope === 'workspace' ? localize('chat.autoapprove.lmServicePerTool.workspace', 'Auto approved for this workspace') : localize('chat.autoapprove.lmServicePerTool.profile', 'Auto approved for this profile'); - md += ' (' + createMarkdownCommandLink({ title: localize('edit', 'Edit'), id: 'workbench.action.chat.editToolApproval', arguments: [reason.scope] }) + ')'; + md += ' (' + createMarkdownCommandLink({ text: localize('edit', 'Edit'), id: 'workbench.action.chat.editToolApproval', arguments: [reason.scope], tooltip: localize('editToolApproval.tooltip', 'Edit tool approval settings') }) + ')'; break; case ToolConfirmKind.ConfirmationNotNeeded: if (reason.reason) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index e4fc306046d..14b3217cf6d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -56,7 +56,7 @@ import { IChatAgentMetadata } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatTextEditGroup } from '../../common/model/chatModel.js'; import { chatSubcommandLeader } from '../../common/requestParser/chatParserTypes.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionAnswerValue, IChatQuestionAnswers, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; import { ChatQuestionCarouselData } from '../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -64,7 +64,7 @@ import { IChatRequestVariableEntry } from '../../common/attachments/chatVariable import { IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM } from '../../common/model/chatViewModel.js'; import { getNWords } from '../../common/model/chatWordCounter.js'; import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../common/constants.js'; import { ClickAnimation } from '../../../../../base/browser/ui/animations/animations.js'; import { MarkHelpfulActionId, MarkUnhelpfulActionId } from '../actions/chatTitleActions.js'; import { ChatTreeItem, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidgetService } from '../chat.js'; @@ -107,7 +107,7 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; -import { HookType } from '../../common/promptSyntax/hookSchema.js'; +import { HookType } from '../../common/promptSyntax/hookTypes.js'; import { ChatQuestionCarouselAutoReply } from './chatQuestionCarouselAutoReply.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { AccessibilityWorkbenchSettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; @@ -657,6 +657,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer other.kind === content.kind); } return this.renderContentReferencesListData(content, undefined, context, templateData); @@ -2159,9 +2164,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined, part: ChatQuestionCarouselPart) => { + const handleSubmit = async (answers: Map | undefined, part: ChatQuestionCarouselPart) => { // Mark the carousel as used and store the answers - const answersRecord = answers ? Object.fromEntries(answers) : undefined; + const answersRecord: IChatQuestionAnswers | undefined = answers ? Object.fromEntries(answers) : undefined; carousel.data = answersRecord ?? {}; carousel.isUsed = true; if (carousel instanceof ChatQuestionCarouselData) { @@ -2178,8 +2183,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined) => Promise, + submit: (answers: Map | undefined) => Promise, modelName: string | undefined, requestMessageText: string | undefined, ): void { @@ -2333,7 +2339,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - if (!shouldAutoReply) { + // always autoreply in autopilot mode. + const isAutopilot = this._isAutopilotForContext(context); + if (!shouldAutoReply && !isAutopilot) { // Roll back the in-progress mark if auto-reply is not enabled. if (stableKey) { this._autoRepliedQuestionCarousels.delete(stableKey); @@ -2361,6 +2369,23 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined) => Promise, + submit: (answers: Map | undefined) => Promise, modelName: string | undefined, requestMessageText: string | undefined, token: CancellationToken, @@ -186,7 +186,7 @@ export class ChatQuestionCarouselAutoReply extends Disposable { carousel: IChatQuestionCarousel, requestMessageText: string | undefined, token: CancellationToken, - ): Promise> { + ): Promise> { const prompt = this.buildPrompt(carousel, requestMessageText, false); const response = await this.languageModelsService.sendChatRequest( modelId, @@ -217,13 +217,13 @@ export class ChatQuestionCarouselAutoReply extends Disposable { // #region Answer parsing and resolution - private parseAnswers(responseText: string, carousel: IChatQuestionCarousel): Map { + private parseAnswers(responseText: string, carousel: IChatQuestionCarousel): Map { const parsed = this.tryParseJsonObject(responseText); if (!parsed) { return new Map(); } - const answers = new Map(); + const answers = new Map(); for (const question of carousel.questions) { const rawAnswer = parsed[question.id]; const resolved = this.resolveAnswerFromRaw(question, rawAnswer); @@ -236,10 +236,10 @@ export class ChatQuestionCarouselAutoReply extends Disposable { private mergeAnswers( carousel: IChatQuestionCarousel, - resolvedAnswers: Map, - fallbackAnswers: Map, - ): Map { - const merged = new Map(); + resolvedAnswers: Map, + fallbackAnswers: Map, + ): Map { + const merged = new Map(); for (const question of carousel.questions) { const fallback = fallbackAnswers.get(question.id); if (this.hasDefaultValue(question) && fallback !== undefined) { @@ -270,7 +270,7 @@ export class ChatQuestionCarouselAutoReply extends Disposable { } } - private resolveAnswerFromRaw(question: IChatQuestion, raw: unknown): unknown | undefined { + private resolveAnswerFromRaw(question: IChatQuestion, raw: unknown): IChatQuestionAnswerValue | undefined { switch (question.type) { case 'text': { if (typeof raw === 'string') { @@ -305,7 +305,7 @@ export class ChatQuestionCarouselAutoReply extends Disposable { const match = selectedInput ? this.matchQuestionOption(question, selectedInput) : undefined; if (match) { - return { selectedValue: match.value, freeformValue: undefined }; + return { selectedValue: match.value, freeformValue: undefined } satisfies IChatSingleSelectAnswer; } return undefined; } @@ -341,7 +341,7 @@ export class ChatQuestionCarouselAutoReply extends Disposable { } } - private matchQuestionOption(question: IChatQuestion, rawInput: string): { id: string; value: unknown } | undefined { + private matchQuestionOption(question: IChatQuestion, rawInput: string): { id: string; value: string } | undefined { const options = question.options ?? []; if (!options.length) { return undefined; @@ -374,8 +374,8 @@ export class ChatQuestionCarouselAutoReply extends Disposable { // #region Fallback answers - buildFallbackCarouselAnswers(carousel: IChatQuestionCarousel, requestMessageText: string | undefined): Map { - const answers = new Map(); + buildFallbackCarouselAnswers(carousel: IChatQuestionCarousel, requestMessageText: string | undefined): Map { + const answers = new Map(); for (const question of carousel.questions) { const answer = this.getFallbackAnswerForQuestion(question, requestMessageText); if (answer !== undefined) { @@ -385,12 +385,12 @@ export class ChatQuestionCarouselAutoReply extends Disposable { return answers; } - private getFallbackAnswerForQuestion(question: IChatQuestion, requestMessageText: string | undefined): unknown { + private getFallbackAnswerForQuestion(question: IChatQuestion, requestMessageText: string | undefined): IChatQuestionAnswerValue | undefined { const fallbackFreeform = requestMessageText?.trim() || localize('chat.questionCarousel.autoReplyFallback', 'OK'); switch (question.type) { case 'text': - return question.defaultValue ?? fallbackFreeform; + return typeof question.defaultValue === 'string' ? question.defaultValue : Array.isArray(question.defaultValue) ? { selectedValues: question.defaultValue } : fallbackFreeform; case 'singleSelect': { const defaultOptionId = typeof question.defaultValue === 'string' ? question.defaultValue : undefined; const defaultOption = defaultOptionId ? question.options?.find(opt => opt.id === defaultOptionId) : undefined; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 39bc719fba5..208dd704892 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -23,7 +23,7 @@ import { Schemas } from '../../../../../base/common/network.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { filter } from '../../../../../base/common/objects.js'; import { autorun, derived, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; -import { basename, extUri, isEqual } from '../../../../../base/common/resources.js'; +import { extUri, isEqual } from '../../../../../base/common/resources.js'; import { MicrotaskDelay } from '../../../../../base/common/symbols.js'; import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -53,9 +53,10 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { applyingChatEditsFailedContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, inChatEditingSessionContextKey, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatLayoutService } from '../../common/widget/chatLayoutService.js'; import { IChatModel, IChatModelInputState, IChatResponseModel } from '../../common/model/chatModel.js'; -import { ChatMode, getModeNameForTelemetry, IChatModeService } from '../../common/chatModes.js'; +import { ChatMode, getModeNameForTelemetry, IChatMode, IChatModeService } from '../../common/chatModes.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestSlashPromptPart, ChatRequestToolPart, ChatRequestToolSetPart, chatSubcommandLeader, formatChatQuestion, IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; +import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../attachments/chatVariables.js'; import { ChatRequestQueueKind, ChatSendResult, IChatLocationData, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { IChatSlashCommandService } from '../../common/participants/chatSlashCommands.js'; @@ -63,7 +64,7 @@ import { IChatTodoListService } from '../../common/tools/chatTodoListService.js' import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isWorkspaceVariableEntry, PromptFileVariableKind, toPromptFileVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { ChatViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../../common/constants.js'; import { ILanguageModelToolsService, isToolSet } from '../../common/tools/languageModelToolsService.js'; import { ComputeAutomaticInstructions } from '../../common/promptSyntax/computeAutomaticInstructions.js'; import { IHandOff, PromptHeader } from '../../common/promptSyntax/promptFileParser.js'; @@ -285,6 +286,7 @@ export class ChatWidget extends Disposable implements IChatWidget { displayName: string; }; private readonly _lockedToCodingAgentContextKey: IContextKey; + private readonly _lockedCodingAgentIdContextKey: IContextKey; private readonly _agentSupportsAttachmentsContextKey: IContextKey; private readonly _sessionIsEmptyContextKey: IContextKey; private readonly _hasPendingRequestsContextKey: IContextKey; @@ -333,7 +335,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser) - .parseChatRequest(this.viewModel.sessionResource, this.getInput(), this.location, { + .parseChatRequestWithReferences(getDynamicVariablesForWidget(this), getSelectedToolAndToolSetsForWidget(this), this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, attachmentCapabilities: this.attachmentCapabilities, @@ -399,6 +401,7 @@ export class ChatWidget extends Disposable implements IChatWidget { super(); this._lockedToCodingAgentContextKey = ChatContextKeys.lockedToCodingAgent.bindTo(this.contextKeyService); + this._lockedCodingAgentIdContextKey = ChatContextKeys.lockedCodingAgentId.bindTo(this.contextKeyService); this._agentSupportsAttachmentsContextKey = ChatContextKeys.agentSupportsAttachments.bindTo(this.contextKeyService); this._sessionIsEmptyContextKey = ChatContextKeys.chatSessionIsEmpty.bindTo(this.contextKeyService); this._hasPendingRequestsContextKey = ChatContextKeys.hasPendingRequests.bindTo(this.contextKeyService); @@ -690,6 +693,14 @@ export class ChatWidget extends Disposable implements IChatWidget { // Forward scroll events from the parent container margins (outside the max-width area) to the chat list this._register(dom.addDisposableListener(parent, dom.EventType.MOUSE_WHEEL, (e: IMouseWheelEvent) => { + if (e.defaultPrevented) { + return; + } + + if (dom.isAncestor(e.target as Node | null, this.container)) { + return; + } + this.listWidget.delegateScrollFromMouseWheelEvent(e); })); @@ -846,7 +857,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } const previous = this.parsedChatRequest; - this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel.sessionResource, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, attachmentCapabilities: this.attachmentCapabilities }); + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequestWithReferences(getDynamicVariablesForWidget(this), getSelectedToolAndToolSetsForWidget(this), this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, attachmentCapabilities: this.attachmentCapabilities }); if (!previous || !IParsedChatRequest.equals(previous, this.parsedChatRequest)) { this._onDidChangeParsedInput.fire(); } @@ -1173,27 +1184,34 @@ export class ChatWidget extends Disposable implements IChatWidget { const lastItem = items[items.length - 1]; const lastResponseComplete = lastItem && isResponseVM(lastItem) && lastItem.isComplete; - if (!lastResponseComplete) { + if (!lastResponseComplete || lastItem.isCanceled) { + this.chatSuggestNextWidget.hide(); return; } - // Get the currently selected mode directly from the observable - // Note: We use currentModeObs instead of currentModeKind because currentModeKind returns - // the ChatModeKind enum (e.g., 'agent'), which doesn't distinguish between custom modes. - // Custom modes all have kind='agent' but different IDs. - const currentMode = this.input.currentModeObs.get(); - const handoffs = currentMode?.handOffs?.get(); - // Only show if: mode has handoffs AND chat has content AND not quick chat - const shouldShow = currentMode && handoffs && handoffs.length > 0; + // Derive handoffs from the mode that generated the last response, not the current UI selection. + // This ensures handoffs reflect what the response agent offers, regardless of mode picker state. + // Fall back to the current mode picker for old sessions where modeInfo was not persisted. + const modeInfo = lastItem.model.request?.modeInfo; + let responseMode: IChatMode | undefined; + if (modeInfo?.modeInstructions?.name) { + responseMode = this.chatModeService.findModeByName(modeInfo.modeInstructions.name); + } else if (modeInfo?.modeId) { + responseMode = this.chatModeService.findModeById(modeInfo.modeId); + } else { + responseMode = this.input.currentModeObs.get(); + } - if (shouldShow) { + const handoffs = responseMode?.handOffs?.get(); + + if (responseMode && handoffs && handoffs.length > 0) { // Log telemetry only when widget transitions from hidden to visible const wasHidden = this.chatSuggestNextWidget.domNode.style.display === 'none'; - this.chatSuggestNextWidget.render(currentMode); + this.chatSuggestNextWidget.render(responseMode); if (wasHidden) { this.telemetryService.publicLog2('chat.handoffWidgetShown', { - agent: getModeNameForTelemetry(currentMode), + agent: getModeNameForTelemetry(responseMode), handoffCount: handoffs.length }); } @@ -1533,7 +1551,11 @@ export class ChatWidget extends Disposable implements IChatWidget { ChatContextKeys.currentlyEditing.bindTo(item.contextKeyService).set(true); } - const isEditingSentRequest = currentElement.pendingKind === undefined ? ChatContextKeys.EditingRequestType.Sent : ChatContextKeys.EditingRequestType.QueueOrSteer; + const isEditingSentRequest = currentElement.pendingKind === undefined + ? ChatContextKeys.EditingRequestType.Sent + : currentElement.pendingKind === ChatRequestQueueKind.Queued + ? ChatContextKeys.EditingRequestType.Queue + : ChatContextKeys.EditingRequestType.Steer; const isInput = this.configurationService.getValue('chat.editRequests') === 'input'; this.inputPart?.setEditing(!!this.viewModel?.editing && isInput, isEditingSentRequest); @@ -1543,6 +1565,7 @@ export class ChatWidget extends Disposable implements IChatWidget { rowContainer.appendChild(this.inputContainer); this.createInput(this.inputContainer); this.input.setChatMode(this.inputPart.currentModeObs.get().id); + this.input.setPermissionLevel(this.inputPart.currentModeInfo.permissionLevel ?? ChatPermissionLevel.Default); this.input.setEditing(true, isEditingSentRequest); this._onDidChangeActiveInputEditor.fire(); } else { @@ -1643,6 +1666,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (!isInput) { this.inputPart.setChatMode(this.input.currentModeObs.get().id); + this.inputPart.setPermissionLevel(this.input.currentModeInfo.permissionLevel ?? ChatPermissionLevel.Default); const currentModel = this.input.selectedLanguageModel.get(); if (currentModel) { this.inputPart.switchModel(currentModel.metadata); @@ -1726,6 +1750,7 @@ export class ChatWidget extends Disposable implements IChatWidget { defaultMode: this.viewOptions.defaultMode, sessionTypePickerDelegate: this.viewOptions.sessionTypePickerDelegate, workspacePickerDelegate: this.viewOptions.workspacePickerDelegate, + isSessionsWindow: this.viewOptions.isSessionsWindow, }; if (this.viewModel?.editing) { @@ -1915,10 +1940,13 @@ export class ChatWidget extends Disposable implements IChatWidget { this._codeBlockModelCollection.clear(); - this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection, undefined); - + // Set the input model on the inputPart before assigning this.viewModel. Assigning this.viewModel + // fires onDidChangeViewModel, which ChatInputPart listens to and expects the input model to be initialized. // Pass input model reference to input part for state syncing this.inputPart.setInputModel(model.inputModel, model.getRequests().length === 0); + + this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection, undefined); + this.listWidget.setViewModel(this.viewModel); if (this._lockedAgent) { @@ -1987,6 +2015,7 @@ export class ChatWidget extends Disposable implements IChatWidget { if (e.kind === 'addRequest') { this.inputPart.clearTodoListWidget(this.viewModel?.sessionResource, false); this._sessionIsEmptyContextKey.set(false); + this.chatSuggestNextWidget.hide(); } // Hide widget on request removal if (e.kind === 'removeRequest') { @@ -2017,6 +2046,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.listWidget.scrollToEnd(); } + this.renderChatSuggestNextWidget(); this.updateChatInputContext(); this.input.renderChatTodoListWidget(this.viewModel.sessionResource); } @@ -2067,6 +2097,7 @@ export class ChatWidget extends Disposable implements IChatWidget { displayName }; this._lockedToCodingAgentContextKey.set(true); + this._lockedCodingAgentIdContextKey.set(agentId); this.renderWelcomeViewContentIfNeeded(); // Update capabilities for the locked agent const agent = this.chatAgentService.getAgent(agentId); @@ -2081,6 +2112,7 @@ export class ChatWidget extends Disposable implements IChatWidget { // Clear all state related to locking this._lockedAgent = undefined; this._lockedToCodingAgentContextKey.set(false); + this._lockedCodingAgentIdContextKey.set(''); this._updateAgentCapabilitiesContextKeys(undefined); // Explicitly update the DOM to reflect unlocked state @@ -2127,7 +2159,8 @@ export class ChatWidget extends Disposable implements IChatWidget { const options: IChatSendRequestOptions = { attempt: lastRequest.attempt + 1, location: this.location, - userSelectedModelId: this.input.currentLanguageModel + userSelectedModelId: this.input.currentLanguageModel, + modeInfo: this.input.currentModeInfo, }; return await this.chatService.resendRequest(lastRequest, options); } @@ -2139,6 +2172,10 @@ export class ChatWidget extends Disposable implements IChatWidget { return; } + // Prompt slash commands are transformed out of the input before sendRequest. + // Track them now so tip exclusions still update for commands like /init. + this.chatTipService.recordSlashCommandUsage(agentSlashPromptPart.name); + // need to resolve the slash command to get the prompt file const slashCommand = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.name, CancellationToken.None); if (!slashCommand) { @@ -2150,9 +2187,6 @@ export class ChatWidget extends Disposable implements IChatWidget { const toolReferences = this.toolsService.toToolReferences(refs); requestInput.attachedContext.insertFirst(toPromptFileVariableEntry(parseResult.uri, PromptFileVariableKind.PromptFile, undefined, true, toolReferences)); - // remove the slash command from the input - requestInput.input = this.parsedInput.parts.filter(part => !(part instanceof ChatRequestSlashPromptPart)).map(part => part.text).join('').trim(); - const promptPath = slashCommand.promptPath; const promptRunEvent: ChatPromptRunEvent = { storage: promptPath.storage, @@ -2165,12 +2199,6 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.telemetryService.publicLog2('chat.promptRun', promptRunEvent); - const input = requestInput.input.trim(); - requestInput.input = `Follow instructions in [${basename(parseResult.uri)}](${parseResult.uri.toString()}).`; - if (input) { - // if the input is not empty, append it to the prompt - requestInput.input += `\n${input}`; - } if (parseResult.header) { await this._applyPromptMetadata(parseResult.header, requestInput); } @@ -2210,7 +2238,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const editorValue = this.getInput(); const requestInputs: IChatRequestInputOptions = { input: !query ? editorValue : query.query, - attachedContext: options?.enableImplicitContext === false ? this.input.getAttachedContext(this.viewModel.sessionResource) : this.input.getAttachedAndImplicitContext(this.viewModel.sessionResource), + attachedContext: options?.enableImplicitContext === false ? this.input.getAttachedContext() : this.input.getAttachedAndImplicitContext(), }; const isUserQuery = !query; @@ -2222,7 +2250,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.chatService.removePendingRequest(this.viewModel.sessionResource, editingRequestId); options.queue ??= editingPendingRequest; } else { - this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource, 'acceptInput-editing'); + await this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource, 'acceptInput-editing'); options.queue = undefined; } @@ -2242,7 +2270,7 @@ export class ChatWidget extends Disposable implements IChatWidget { options.queue ??= ChatRequestQueueKind.Queued; } if (model.requestNeedsInput.get() && !model.getPendingRequests().length) { - this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource, 'acceptInput-needsInput'); + await this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource, 'acceptInput-needsInput'); options.queue ??= ChatRequestQueueKind.Queued; } if (requestInProgress) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 627dbaea7a7..86ba0bd181c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -22,7 +22,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -35,7 +35,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IEditorConstructionOptions } from '../../../../../../editor/browser/config/editorConfiguration.js'; import { EditorExtensionsRegistry } from '../../../../../../editor/browser/editorExtensions.js'; import { CodeEditorWidget } from '../../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; -import { EditorOptions, IEditorOptions } from '../../../../../../editor/common/config/editorOptions.js'; +import { EditorOptions, IEditorOptions, IEditorScrollbarOptions } from '../../../../../../editor/common/config/editorOptions.js'; import { IDimension } from '../../../../../../editor/common/core/2d/dimension.js'; import { IPosition } from '../../../../../../editor/common/core/position.js'; import { IRange, Range } from '../../../../../../editor/common/core/range.js'; @@ -82,20 +82,20 @@ import { InlineChatConfigKeys } from '../../../../inlineChat/common/inlineChat.j import { IChatViewTitleActionContext } from '../../../common/actions/chatActions.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isStringVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; -import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; +import { ChatMode, getModeNameForTelemetry, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { IChatFollowup, IChatQuestionCarousel, IChatService, IChatSessionContext } from '../../../common/chatService/chatService.js'; import { agentOptionId, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isIChatSessionFileChange2, localChatSessionType } from '../../../common/chatSessionsService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, validateChatMode } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isAutoApproveLevel, validateChatMode } from '../../../common/constants.js'; import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; -import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; +import { filterModelsForSession, findDefaultModel, hasModelsTargetingSession, isModelValidForSession, mergeModelsWithCache, resolveModelFromSyncState, shouldResetModelToDefault, shouldResetOnModelListChange, shouldRestoreLateArrivingModel, shouldRestorePersistedModel } from './chatModelSelectionLogic.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IChatResponseViewModel, isResponseVM } from '../../../common/model/chatViewModel.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; -import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; +import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenPermissionPickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; import { AgentSessionProviders, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; @@ -122,10 +122,11 @@ import { ChatSelectedTools } from './chatSelectedTools.js'; import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; +import { IPermissionPickerDelegate, PermissionPickerActionItem } from './permissionPickerActionItem.js'; import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js'; -import { Target } from '../../../common/promptSyntax/service/promptsService.js'; +import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { EnhancedModelPickerActionItem } from './modelPickerActionItem2.js'; const $ = dom.$; @@ -134,6 +135,7 @@ const INPUT_EDITOR_MAX_HEIGHT = 250; const INPUT_EDITOR_LINE_HEIGHT = 20; const INPUT_EDITOR_PADDING = { compact: { top: 2, bottom: 2 }, default: { top: 12, bottom: 12 } }; const CachedLanguageModelsKey = 'chat.cachedLanguageModels.v2'; +const CHAT_INPUT_PICKER_COLLAPSE_WIDTH = 320; export interface IChatInputStyles { overlayBackground: string; @@ -169,6 +171,11 @@ export interface IChatInputPartOptions { * for their chat request. This is useful for empty window contexts. */ workspacePickerDelegate?: IWorkspacePickerDelegate; + /** + * Whether we are running in the sessions window. + * When true, the secondary toolbar (permissions picker) is hidden. + */ + isSessionsWindow?: boolean; } export interface IWorkingSetEntry { @@ -211,10 +218,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _workingSetCollapsed = observableValue('chatInputPart.workingSetCollapsed', true); private _stableInputPartWidth = observableValue('chatInputPart.stableInputPartWidth', 0); private readonly _chatInputTodoListWidget = this._register(new MutableDisposable()); - private readonly _chatQuestionCarouselWidget = this._register(new MutableDisposable()); - private readonly _chatQuestionCarouselDisposables = this._register(new DisposableStore()); - private _currentQuestionCarouselResponseId: string | undefined; - private _currentQuestionCarouselSessionResource: URI | undefined; + private readonly _chatQuestionCarouselWidgets = this._register(new DisposableMap()); + private readonly _questionCarouselResponseIds = new Map(); + private readonly _questionCarouselSessionResources = new Map(); private _hasQuestionCarouselContextKey: IContextKey | undefined; private readonly _chatEditingTodosDisposables = this._register(new DisposableStore()); private _lastEditingSessionResource: URI | undefined; @@ -250,15 +256,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge readonly selectedToolsModel: ChatSelectedTools; - public getAttachedContext(sessionResource: URI) { + public getAttachedContext() { const contextArr = new ChatRequestVariableSet(); contextArr.add(...this.attachmentModel.attachments, ...this.chatContextService.getWorkspaceContextItems()); return contextArr; } - public getAttachedAndImplicitContext(sessionResource: URI): ChatRequestVariableSet { + public getAttachedAndImplicitContext(): ChatRequestVariableSet { - const contextArr = this.getAttachedContext(sessionResource); + const contextArr = this.getAttachedContext(); if (this.implicitContext) { const implicitChatVariables = this.implicitContext.enabledBaseEntries(this.configurationService.getValue('chat.implicitContext.suggestedContext')); @@ -286,6 +292,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private container!: HTMLElement; private inputSideToolbarContainer?: HTMLElement; + private secondaryToolbarContainer!: HTMLElement; + private secondaryToolbar!: MenuWorkbenchToolBar; private followupsContainer!: HTMLElement; private readonly followupsDisposables: DisposableStore = this._register(new DisposableStore()); @@ -322,6 +330,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _inputEditor!: CodeEditorWidget; private _inputEditorElement!: HTMLElement; + private _forceVisibleScrollbarUntilAccept = false; // Reference to the input model for syncing input state private _inputModel: IInputModel | undefined; @@ -367,11 +376,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatSessionHasTargetedModels: IContextKey; private modelWidget: EnhancedModelPickerActionItem | undefined; private modeWidget: ModePickerActionItem | undefined; + private permissionWidget: PermissionPickerActionItem | undefined; private sessionTargetWidget: SessionTypePickerActionItem | undefined; private delegationWidget: DelegationSessionPickerActionItem | undefined; private chatSessionPickerWidgets: Map = new Map(); private chatSessionPickerContainer: HTMLElement | undefined; private _lastSessionPickerAction: MenuItemAction | undefined; + private _lastSessionPickerOptions: IChatInputPickerOptions | undefined; private readonly _waitForPersistedLanguageModel: MutableDisposable = this._register(new MutableDisposable()); private readonly _chatSessionOptionEmitters: Map> = new Map(); @@ -401,6 +412,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge readonly onDidChangeCurrentChatMode: Event = this._onDidChangeCurrentChatMode.event; private readonly _currentModeObservable: ISettableObservable; + private readonly _currentPermissionLevel: ISettableObservable; + private permissionLevelKey: IContextKey; public get currentModeKind(): ChatModeKind { const mode = this._currentModeObservable.get(); @@ -413,6 +426,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._currentModeObservable; } + public get currentPermissionLevelObs(): IObservable { + return this._currentPermissionLevel; + } + public get currentModeInfo(): IChatRequestModeInfo { const mode = this._currentModeObservable.get(); const modeId: 'ask' | 'agent' | 'edit' | 'custom' | undefined = mode.isBuiltin ? this.currentModeKind : 'custom'; @@ -422,6 +439,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge kind: this.currentModeKind, isBuiltin: mode.isBuiltin, modeInstructions: modeInstructions ? { + uri: mode.uri?.get(), name: mode.name.get(), content: modeInstructions.content, toolReferences: this.toolService.toToolReferences(modeInstructions.toolReferences), @@ -429,7 +447,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge isBuiltin: mode.isBuiltin } : undefined, modeId: modeId, + modeName: getModeNameForTelemetry(mode), applyCodeBlockSuggestionId: undefined, + permissionLevel: this._currentPermissionLevel.get(), }; } @@ -530,6 +550,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._contextResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event })); this._currentModeObservable = observableValue('currentMode', this.options.defaultMode ?? ChatMode.Agent); + this._currentPermissionLevel = observableValue('permissionLevel', ChatPermissionLevel.Default); this._register(this.editorService.onDidActiveEditorChange(() => { this._indexOfLastOpenedContext = -1; this.refreshChatSessionPickers(); @@ -549,7 +570,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (sessionResource) { const ctx = this.chatService.getChatSessionFromInternalUri(sessionResource); const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); - if (ctx?.chatSessionType === chatSessionType || delegateSessionType === chatSessionType) { + if (ctx && (getChatSessionType(ctx.chatSessionResource) === chatSessionType) || delegateSessionType === chatSessionType) { this.refreshChatSessionPickers(); } } @@ -581,6 +602,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatModeKindKey = ChatContextKeys.chatModeKind.bindTo(contextKeyService); this.chatModeNameKey = ChatContextKeys.chatModeName.bindTo(contextKeyService); this.chatModelIdKey = ChatContextKeys.chatModelId.bindTo(contextKeyService); + this.permissionLevelKey = ChatContextKeys.chatPermissionLevel.bindTo(contextKeyService); this.withinEditSessionKey = ChatContextKeys.withinEditSessionDiff.bindTo(contextKeyService); this.filePartOfEditSessionKey = ChatContextKeys.filePartOfEditSession.bindTo(contextKeyService); this.chatSessionHasOptions = ChatContextKeys.chatSessionHasModels.bindTo(contextKeyService); @@ -627,8 +649,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.initSelectedModel(); this._register(this.languageModelsService.onDidChangeLanguageModels(() => { - const selectedModel = this._currentLanguageModel ? this.getModels().find(m => m.identifier === this._currentLanguageModel.get()?.identifier) : undefined; - if (!this.currentLanguageModel || !selectedModel) { + if (shouldResetOnModelListChange(this._currentLanguageModel.get()?.identifier, this.getModels())) { this.setCurrentLanguageModelToDefault(); } })); @@ -666,10 +687,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (sessionResource) { const ctx = this.chatService.getChatSessionFromInternalUri(sessionResource); if (ctx) { - this.chatSessionsService.notifySessionOptionsChange( - ctx.chatSessionResource, - [{ optionId: agentOptionId, value: mode.isBuiltin ? '' : modeName }] - ).catch(err => this.logService.error('Failed to notify extension of agent change:', err)); + let needsUpdate = true; + const agentOption = this.chatSessionsService.getSessionOption(ctx.chatSessionResource, agentOptionId); + if (typeof agentOption !== 'undefined') { + const agentId = (typeof agentOption === 'string' ? agentOption : agentOption.id) || ChatMode.Agent.id; + const isDefaultAgent = agentId === ChatMode.Agent.id; + needsUpdate = isDefaultAgent + ? mode.id !== ChatMode.Agent.id + : mode.label.read(undefined) !== agentId; // Extensions use Label (name) as identifier for custom agents. + } + if (needsUpdate) { + this.chatSessionsService.notifySessionOptionsChange( + ctx.chatSessionResource, + [{ optionId: agentOptionId, value: mode.isBuiltin ? '' : modeName }] + ).catch(err => this.logService.error('Failed to notify extension of agent change:', err)); + } } } })); @@ -710,25 +742,20 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const persistedAsDefault = this.storageService.getBoolean(this.getSelectedModelIsDefaultStorageKey(), StorageScope.APPLICATION, true); if (persistedSelection) { - const model = this.getModels().find(m => m.identifier === persistedSelection); - if (model) { - // Only restore the model if it wasn't the default at the time of storing or it is now the default - if (!persistedAsDefault || model.metadata.isDefaultForLocation[this.location]) { - this.setCurrentLanguageModel(model); - this.checkModelSupported(); - } - } else { + const result = shouldRestorePersistedModel(persistedSelection, persistedAsDefault, this.getModels(), this.location); + if (result.shouldRestore && result.model) { + this.setCurrentLanguageModel(result.model); + this.checkModelSupported(); + } else if (!result.model) { this._waitForPersistedLanguageModel.value = this.languageModelsService.onDidChangeLanguageModels(e => { const persistedModel = this.languageModelsService.lookupLanguageModel(persistedSelection); if (persistedModel) { this._waitForPersistedLanguageModel.clear(); - // Only restore the model if it wasn't the default at the time of storing or it is now the default - if (!persistedAsDefault || persistedModel.isDefaultForLocation[this.location]) { - if (persistedModel.isUserSelectable) { - this.setCurrentLanguageModel({ metadata: persistedModel, identifier: persistedSelection }); - this.checkModelSupported(); - } + const lateModel = { metadata: persistedModel, identifier: persistedSelection }; + if (shouldRestoreLateArrivingModel(persistedSelection, persistedAsDefault, lateModel, this.location)) { + this.setCurrentLanguageModel(lateModel); + this.checkModelSupported(); } } else { this.setCurrentLanguageModelToDefault(); @@ -786,6 +813,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.modeWidget?.show(); } + public openPermissionPicker(): void { + this.permissionWidget?.show(); + } + + public setPermissionLevel(level: ChatPermissionLevel): void { + this._currentPermissionLevel.set(level, undefined); + this.permissionLevelKey.set(level); + this.permissionWidget?.refresh(); + } + public openSessionTargetPicker(): void { this.sessionTargetWidget?.show(); } @@ -803,8 +840,9 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge /** * Create picker widgets for all option groups available for the current session type. */ - private createChatSessionPickerWidgets(action: MenuItemAction): (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] { + private createChatSessionPickerWidgets(action: MenuItemAction, pickerOptions?: IChatInputPickerOptions): (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] { this._lastSessionPickerAction = action; + this._lastSessionPickerOptions = pickerOptions; const result = this.computeVisibleOptionGroups(); if (!result) { @@ -855,7 +893,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } }; - const widget = this.instantiationService.createInstance(optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, action, initialState, itemDelegate); + const widget = this.instantiationService.createInstance(optionGroup.searchable ? SearchableOptionPickerActionItem : ChatSessionPickerActionItem, action, initialState, itemDelegate, pickerOptions); this.chatSessionPickerWidgets.set(optionGroup.id, widget); widgets.push(widget); } @@ -872,6 +910,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.selectedToolsModel.resetSessionEnablementState(); this._chatSessionIsEmpty = chatSessionIsEmpty; + // Reset permission level on new sessions, unless global auto-approve is on + // or the current permission level is already auto-approve/autopilot + if (chatSessionIsEmpty && !this.configurationService.getValue(ChatConfiguration.GlobalAutoApprove) && !isAutoApproveLevel(this._currentPermissionLevel.get())) { + this._currentPermissionLevel.set(ChatPermissionLevel.Default, undefined); + this.permissionLevelKey.set(ChatPermissionLevel.Default); + this.permissionWidget?.refresh(); + } + // TODO@roblourens This is for an experiment which will be obsolete in a month or two and can then be removed. if (chatSessionIsEmpty) { this._setEmptyModelState(); @@ -937,14 +983,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Sync selected model - validate it belongs to the current session's model pool if (state?.selectedModel) { - const lm = this._currentLanguageModel.get(); - if (!lm || lm.identifier !== state.selectedModel.identifier) { - if (this.isModelValidForCurrentSession(state.selectedModel)) { - this.setCurrentLanguageModel(state.selectedModel); - } else { - // Model from state doesn't belong to this session's pool - use default - this.setCurrentLanguageModelToDefault(); - } + const allModels = this.getAllMergedModels(); + const sessionType = this.getCurrentSessionType(); + const syncResult = resolveModelFromSyncState(state.selectedModel, this._currentLanguageModel.get(), allModels, sessionType, { + location: this.location, + currentModeKind: this.currentModeKind, + isInlineChatV2Enabled: !!this.configurationService.getValue(InlineChatConfigKeys.EnableV2), + sessionType, + }); + if (syncResult.action === 'apply') { + this.setCurrentLanguageModel(state.selectedModel); + } else if (syncResult.action === 'default') { + this.setCurrentLanguageModelToDefault(); } } @@ -1010,7 +1060,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private checkModelSupported(): void { const lm = this._currentLanguageModel.get(); - if (lm && (!this.modelSupportedForDefaultAgent(lm) || !this.modelSupportedForInlineChat(lm) || !this.isModelValidForCurrentSession(lm))) { + const allModels = this.getAllMergedModels(); + if (shouldResetModelToDefault(lm, this.getModels(), { + location: this.location, + currentModeKind: this.currentModeKind, + isInlineChatV2Enabled: !!this.configurationService.getValue(InlineChatConfigKeys.EnableV2), + sessionType: this.getCurrentSessionType(), + }, allModels)) { this.setCurrentLanguageModelToDefault(); } } @@ -1042,56 +1098,29 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._syncInputStateToModel(); } - private modelSupportedForDefaultAgent(model: ILanguageModelChatMetadataAndIdentifier): boolean { - // Probably this logic could live in configuration on the agent, or somewhere else, if it gets more complex - if (this.currentModeKind === ChatModeKind.Agent) { - return ILanguageModelChatMetadata.suitableForAgentMode(model.metadata); - } - - return true; - } - - private modelSupportedForInlineChat(model: ILanguageModelChatMetadataAndIdentifier): boolean { - if (this.location !== ChatAgentLocation.EditorInline || !this.configurationService.getValue(InlineChatConfigKeys.EnableV2)) { - return true; - } - return !!model.metadata.capabilities?.toolCalling; - } - - private getModels(): ILanguageModelChatMetadataAndIdentifier[] { + /** + * Get all models merged from live and cache, without session/mode filtering. + * This is the canonical source for the full model pool, including cached models + * that bridge startup races when live models haven't loaded yet. + */ + private getAllMergedModels(): ILanguageModelChatMetadataAndIdentifier[] { const cachedModels = this.storageService.getObject(CachedLanguageModelsKey, StorageScope.APPLICATION, []); const liveModels = this.languageModelsService.getLanguageModelIds() .map(modelId => ({ identifier: modelId, metadata: this.languageModelsService.lookupLanguageModel(modelId)! })); - // Merge live models with cached models per-vendor. For vendors whose - // models have resolved, use the live data. For vendors that are still - // contributed but haven't resolved yet (startup race), keep their - // cached models. Vendors that are no longer contributed at all (e.g. - // extension uninstalled) are evicted from the cache. - let models: ILanguageModelChatMetadataAndIdentifier[]; + const contributedVendors = new Set(this.languageModelsService.getVendors().map(v => v.vendor)); + const models = mergeModelsWithCache(liveModels, cachedModels, contributedVendors); if (liveModels.length > 0) { - const liveVendors = new Set(liveModels.map(m => m.metadata.vendor)); - const contributedVendors = new Set(this.languageModelsService.getVendors().map(v => v.vendor)); - models = [ - ...liveModels, - ...cachedModels.filter(m => !liveVendors.has(m.metadata.vendor) && contributedVendors.has(m.metadata.vendor)), - ]; this.storageService.store(CachedLanguageModelsKey, models, StorageScope.APPLICATION, StorageTarget.MACHINE); - } else { - models = cachedModels; } + return models; + } + + private getModels(): ILanguageModelChatMetadataAndIdentifier[] { + const models = this.getAllMergedModels(); models.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); - const sessionType = this.getCurrentSessionType(); - if (sessionType && sessionType !== AgentSessionProviders.Local) { - // Session has a specific chat session type - show only models that target - // this session type, if any such models exist. - return models.filter(entry => entry.metadata?.targetChatSessionType === sessionType && entry.metadata?.isUserSelectable); - } - - // No session type or no targeted models - show general models (those without - // a targetChatSessionType) filtered by the standard criteria. - return models.filter(entry => !entry.metadata?.targetChatSessionType && entry.metadata?.isUserSelectable && this.modelSupportedForDefaultAgent(entry) && this.modelSupportedForInlineChat(entry)); + return filterModelsForSession(models, this.getCurrentSessionType(), this.currentModeKind, this.location, !!this.configurationService.getValue(InlineChatConfigKeys.EnableV2)); } /** @@ -1105,7 +1134,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } const sessionResource = this._widget?.viewModel?.model.sessionResource; const ctx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; - return ctx?.chatSessionType; + return ctx ? getChatSessionType(ctx.chatSessionResource) : undefined; } /** @@ -1113,28 +1142,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * This is used to set the context key that controls model picker visibility. */ private hasModelsTargetingSessionType(): boolean { - const sessionType = this.getCurrentSessionType(); - if (!sessionType) { - return false; - } - return this.languageModelsService.getLanguageModelIds().some(modelId => { - const metadata = this.languageModelsService.lookupLanguageModel(modelId); - return metadata?.targetChatSessionType === sessionType; - }); + return hasModelsTargetingSession(this.getAllMergedModels(), this.getCurrentSessionType()); } - /** - * Check if a model is valid for the current session's model pool. - * If the session has targeted models, the model must target this session type. - * If no models target this session, the model must not have a targetChatSessionType. - */ private isModelValidForCurrentSession(model: ILanguageModelChatMetadataAndIdentifier): boolean { - if (this.hasModelsTargetingSessionType()) { - // Session has targeted models - model must match - return model.metadata.targetChatSessionType === this.getCurrentSessionType(); - } - // No targeted models - model must not be session-specific - return !model.metadata.targetChatSessionType; + return isModelValidForSession(model, this.getAllMergedModels(), this.getCurrentSessionType()); } /** @@ -1157,7 +1169,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private preselectModelFromSessionHistory(): void { const sessionResource = this._widget?.viewModel?.model.sessionResource; const ctx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; - const requiresCustomModels = ctx && this.chatSessionsService.requiresCustomModelsForSessionType(ctx.chatSessionType); + const requiresCustomModels = ctx && this.chatSessionsService.requiresCustomModelsForSessionType(getChatSessionType(ctx.chatSessionResource)); if (!requiresCustomModels) { return; } @@ -1209,7 +1221,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private setCurrentLanguageModelToDefault() { const allModels = this.getModels(); - const defaultModel = allModels.find(m => m.metadata.isDefaultForLocation[this.location]) || allModels[0]; + const defaultModel = findDefaultModel(allModels, this.location); if (defaultModel) { this.setCurrentLanguageModel(defaultModel); } @@ -1449,6 +1461,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.history.append(this._getFilteredEntry(userQuery)); } + this.resetScrollbarVisibilityAfterAccept(); + if (this._chatSessionIsEmpty) { this._chatSessionIsEmpty = false; this._emptyInputState.set(undefined, undefined); @@ -1591,11 +1605,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const ctx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; // Check if this session type has a customAgentTarget - const customAgentTarget = ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType); + const customAgentTarget = ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(ctx.chatSessionResource)); this.chatSessionHasCustomAgentTarget.set(customAgentTarget !== Target.Undefined); // Check if this session type requires custom models - const requiresCustomModels = ctx && this.chatSessionsService.requiresCustomModelsForSessionType(ctx.chatSessionType); + const requiresCustomModels = ctx && this.chatSessionsService.requiresCustomModelsForSessionType(getChatSessionType(ctx.chatSessionResource)); this.chatSessionHasTargetedModels.set(!!requiresCustomModels); // Handle agent option from session - set initial mode @@ -1619,7 +1633,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // - Panel/Editor: Use actual session's type (ctx available) // - Welcome view: Use delegate's type (ctx may not exist yet) const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); - const effectiveSessionType = delegateSessionType ?? ctx?.chatSessionType; + const effectiveSessionType = delegateSessionType ?? (ctx ? getChatSessionType(ctx.chatSessionResource) : undefined); if (!effectiveSessionType) { setNoOptions(); @@ -1711,7 +1725,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge !Array.from(visibleGroupIds).every(id => currentWidgetGroupIds.has(id)); if (needsRecreation && this._lastSessionPickerAction && this.chatSessionPickerContainer) { - const widgets = this.createChatSessionPickerWidgets(this._lastSessionPickerAction); + const widgets = this.createChatSessionPickerWidgets(this._lastSessionPickerAction, this._lastSessionPickerOptions); dom.clearNode(this.chatSessionPickerContainer); for (const widget of widgets) { const container = dom.$('.action-item.chat-sessionPicker-item'); @@ -1803,7 +1817,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private getEffectiveSessionType(ctx: IChatSessionContext | undefined, delegate: ISessionTypePickerDelegate | undefined): string { - return this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.() || ctx?.chatSessionType || ''; + return this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.() || (ctx && getChatSessionType(ctx.chatSessionResource)) || ''; } /** @@ -1919,7 +1933,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.refreshChatSessionPickers(); this.tryUpdateWidgetController(); this.updateContextUsageWidget(); - if (this._currentQuestionCarouselSessionResource && (!e.currentSessionResource || !isEqual(this._currentQuestionCarouselSessionResource, e.currentSessionResource))) { + let hasMatchingResource = false; + if (e.currentSessionResource) { + for (const r of this._questionCarouselSessionResources.values()) { + if (isEqual(r, e.currentSessionResource)) { + hasMatchingResource = true; + break; + } + } + } + if (this._questionCarouselSessionResources.size > 0 && (!e.currentSessionResource || !hasMatchingResource)) { this.clearQuestionCarousel(); } @@ -1927,14 +1950,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // when the session type changes (different session types may have // different model pools via targetChatSessionType). const newSessionType = this.getCurrentSessionType(); - if (newSessionType !== this._currentSessionType) { + if (e.currentSessionResource && newSessionType !== this._currentSessionType) { this._currentSessionType = newSessionType; this.initSelectedModel(); + this.checkModelInSessionPool(); } - // Validate that the current model belongs to the new session's pool - this.checkModelInSessionPool(); - // For contributed sessions with history, pre-select the model // from the last request so the user resumes with the same model. this.preselectModelFromSessionHistory(); @@ -1952,11 +1973,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [ dom.h('.chat-input-container@inputContainer', [ dom.h('.chat-editor-container@editorContainer'), - dom.h('.chat-input-toolbars@inputToolbars', [ - dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), - ]), + dom.h('.chat-input-toolbars@inputToolbars'), ]), ]), + dom.h('.chat-secondary-toolbar@secondaryToolbar', [ + dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), + ]), dom.h('.chat-attachments-container@attachmentsContainer', [ dom.h('.chat-attached-context@attachedContextContainer'), ]), @@ -1977,11 +1999,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-attached-context@attachedContextContainer'), ]), dom.h('.chat-editor-container@editorContainer'), - dom.h('.chat-input-toolbars@inputToolbars', [ - dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), - ]), + dom.h('.chat-input-toolbars@inputToolbars'), ]), ]), + dom.h('.chat-secondary-toolbar@secondaryToolbar', [ + dom.h('.chat-context-usage-container@contextUsageWidgetContainer'), + ]), ]); } this.container = elements.root; @@ -2002,6 +2025,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.attachmentsContainer = elements.attachmentsContainer; this.attachedContextContainer = elements.attachedContextContainer; const toolbarsContainer = elements.inputToolbars; + this.secondaryToolbarContainer = elements.secondaryToolbar; + if (this.options.renderStyle === 'compact') { + this.secondaryToolbarContainer.style.display = 'none'; + } this.chatEditingSessionWidgetContainer = elements.chatEditingSessionWidgetContainer; this.chatInputTodoListWidgetContainer = elements.chatInputTodoListWidgetContainer; this.chatGettingStartedTipContainer = elements.chatGettingStartedTipContainer; @@ -2010,6 +2037,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatInputWidgetsContainer = elements.chatInputWidgetsContainer; this.contextUsageWidgetContainer = elements.contextUsageWidgetContainer; + if (this.options.isSessionsWindow || this.options.renderStyle === 'compact') { + toolbarsContainer.prepend(this.contextUsageWidgetContainer); + } + // Context usage widget — will be positioned in the toolbar after toolbars are created this.contextUsageWidget = this._register(this.instantiationService.createInstance(ChatContextUsageWidget)); this.contextUsageWidgetContainer.appendChild(this.contextUsageWidget.domNode); @@ -2075,7 +2106,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge showStatusBar: false, insertMode: 'insert', }; - options.scrollbar = { ...(options.scrollbar ?? {}), vertical: 'hidden' }; + options.scrollbar = this.options.renderStyle === 'compact' + ? { ...(options.scrollbar ?? {}), vertical: 'hidden' } + : { + ...(options.scrollbar ?? {}), + vertical: 'auto', + verticalScrollbarSize: 7, + }; options.stickyScroll = { enabled: false }; this._inputEditorElement = dom.append(editorContainer, $(chatInputEditorContainerSelector)); @@ -2154,7 +2191,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const pickerOptions: IChatInputPickerOptions = { getOverflowAnchor: () => this.inputActionsToolbar.getElement(), actionContext: { widget }, - hideChevrons: derived(reader => this._stableInputPartWidth.read(reader) < 400), + hideChevrons: derived(reader => this._stableInputPartWidth.read(reader) < CHAT_INPUT_PICKER_COLLAPSE_WIDTH), hoverPosition: { forcePosition: true, hoverPosition: location === ChatWidgetLocation.SidebarRight && !isMaximized ? HoverPosition.LEFT : HoverPosition.RIGHT @@ -2188,7 +2225,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.renderAttachedContext(); }, getModels: () => this.getModels(), - canManageModels: () => { + useGroupedModelPicker: () => true, + showManageModelsAction: () => { const sessionType = this.getCurrentSessionType(); return !sessionType || sessionType === localChatSessionType; } @@ -2201,7 +2239,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge customAgentTarget: () => { const sessionResource = this._widget?.viewModel?.model.sessionResource; const ctx = sessionResource && this.chatService.getChatSessionFromInternalUri(sessionResource); - return (ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType)) ?? Target.Undefined; + return (ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(ctx.chatSessionResource))) ?? Target.Undefined; }, }; return this.modeWidget = this.instantiationService.createInstance(ModePickerActionItem, action, delegate, pickerOptions); @@ -2237,7 +2275,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } else if (action.id === ChatSessionPrimaryPickerAction.ID && action instanceof MenuItemAction) { // Create all pickers and return a container action view item - const widgets = this.createChatSessionPickerWidgets(action); + const widgets = this.createChatSessionPickerWidgets(action, pickerOptions); if (widgets.length === 0) { return new HiddenActionViewItem(action); } @@ -2255,7 +2293,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // eslint-disable-next-line no-restricted-syntax const container = toolbarElement.querySelector('.chat-sessionPicker-container'); this.chatSessionPickerContainer = container as HTMLElement | undefined; - if (this.cachedWidth && typeof this.cachedInputToolbarWidth === 'number' && this.cachedInputToolbarWidth !== this.inputActionsToolbar.getItemsWidth()) { this._toolbarRelayoutScheduler.schedule(); } @@ -2288,6 +2325,62 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge toolbarSide.context = { widget } satisfies IChatExecuteActionContext; } + // Secondary toolbar (permissions) — below the input box + this.secondaryToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, this.secondaryToolbarContainer, MenuId.ChatInputSecondary, { + telemetrySource: this.options.menus.telemetrySource, + menuOptions: { shouldForwardArgs: true }, + hiddenItemStrategy: HiddenItemStrategy.NoHide, + hoverDelegate, + actionViewItemProvider: (action, options) => { + if ((action.id === OpenSessionTargetPickerAction.ID || action.id === OpenDelegationPickerAction.ID) && action instanceof MenuItemAction) { + const getActiveSessionType = () => { + const sessionResource = this._widget?.viewModel?.sessionResource; + return sessionResource ? getAgentSessionProvider(sessionResource) : undefined; + }; + const delegate: ISessionTypePickerDelegate = this.options.sessionTypePickerDelegate ?? { + getActiveSessionProvider: () => { + return getActiveSessionType(); + }, + getPendingDelegationTarget: () => { + return this._pendingDelegationTarget; + }, + setPendingDelegationTarget: (provider: AgentSessionProviders) => { + const isActive = getActiveSessionType() === provider; + this._pendingDelegationTarget = isActive ? undefined : provider; + this.updateWidgetLockStateFromSessionType(provider); + this.updateAgentSessionTypeContextKey(); + this.refreshChatSessionPickers(); + }, + }; + const isWelcomeViewMode = !!this.options.sessionTypePickerDelegate?.setActiveSessionProvider; + const Picker = (action.id === OpenSessionTargetPickerAction.ID || isWelcomeViewMode) ? SessionTypePickerActionItem : DelegationSessionPickerActionItem; + return this.sessionTargetWidget = this.instantiationService.createInstance(Picker, action, location === ChatWidgetLocation.Editor ? 'editor' : 'sidebar', delegate, pickerOptions); + } else if (action.id === OpenWorkspacePickerAction.ID && action instanceof MenuItemAction) { + if (this.workspaceContextService.getWorkbenchState() === WorkbenchState.EMPTY && this.options.workspacePickerDelegate) { + return this.instantiationService.createInstance(WorkspacePickerActionItem, action, this.options.workspacePickerDelegate, pickerOptions); + } else { + const empty = new BaseActionViewItem(undefined, action); + if (empty.element) { + empty.element.style.display = 'none'; + } + return empty; + } + } else if (action.id === OpenPermissionPickerAction.ID && action instanceof MenuItemAction) { + const delegate: IPermissionPickerDelegate = { + currentPermissionLevel: this._currentPermissionLevel, + setPermissionLevel: (level: ChatPermissionLevel) => { + this._currentPermissionLevel.set(level, undefined); + this.permissionLevelKey.set(level); + }, + }; + return this.permissionWidget = this.instantiationService.createInstance(PermissionPickerActionItem, action, delegate, pickerOptions); + } + return undefined; + } + })); + this.secondaryToolbar.getElement().classList.add('chat-secondary-input-toolbar'); + this.secondaryToolbar.context = { widget } satisfies IChatExecuteActionContext; + let inputModel = this.modelService.getModel(this.inputUri); if (!inputModel) { inputModel = this.modelService.createModel('', null, this.inputUri, true); @@ -2579,60 +2672,74 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge renderQuestionCarousel(carousel: IChatQuestionCarousel, context: IChatContentPartRenderContext, options: IChatQuestionCarouselOptions): ChatQuestionCarouselPart { - if (this._chatQuestionCarouselWidget.value) { - const existingCarousel = this._chatQuestionCarouselWidget.value; - const existingResolveId = existingCarousel.carousel.resolveId; - if (existingResolveId && carousel.resolveId && existingResolveId === carousel.resolveId) { - return existingCarousel; - } + const carouselKey = carousel.resolveId ?? `${isResponseVM(context.element) ? context.element.requestId : ''}_${context.contentIndex}`; - // Complete the old carousel's completion promise as skipped before clearing - // This prevents the askQuestions tool from hanging when parallel subagents invoke it - const oldCarousel = existingCarousel.carousel; - if (oldCarousel instanceof ChatQuestionCarouselData && !oldCarousel.completion.isSettled) { - oldCarousel.completion.complete({ answers: undefined }); - } - - this.clearQuestionCarousel(); + // If a carousel with the same key already exists, return it + const existing = this._chatQuestionCarouselWidgets.get(carouselKey); + if (existing) { + return existing; } - // track the response id and session - this._currentQuestionCarouselResponseId = isResponseVM(context.element) ? context.element.requestId : undefined; - this._currentQuestionCarouselSessionResource = isResponseVM(context.element) ? context.element.sessionResource : undefined; + // Track the response id and session for this carousel + if (isResponseVM(context.element)) { + this._questionCarouselResponseIds.set(carouselKey, context.element.requestId); + this._questionCarouselSessionResources.set(carouselKey, context.element.sessionResource); + } - const part = this._chatQuestionCarouselDisposables.add( - this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, options) - ); - this._chatQuestionCarouselWidget.value = part; + const part = this.instantiationService.createInstance(ChatQuestionCarouselPart, carousel, context, options); + this._chatQuestionCarouselWidgets.set(carouselKey, part); this._hasQuestionCarouselContextKey?.set(true); - dom.clearNode(this.chatQuestionCarouselContainer); dom.append(this.chatQuestionCarouselContainer, part.domNode); return part; } - clearQuestionCarousel(responseId?: string): void { - if (responseId && this._currentQuestionCarouselResponseId !== responseId) { - return; + clearQuestionCarousel(responseId?: string, resolveId?: string): void { + if (resolveId !== undefined) { + // Remove a specific carousel by resolveId + const part = this._chatQuestionCarouselWidgets.get(resolveId); + if (part) { + part.domNode.remove(); + this._chatQuestionCarouselWidgets.deleteAndDispose(resolveId); + } + this._questionCarouselResponseIds.delete(resolveId); + this._questionCarouselSessionResources.delete(resolveId); + } else if (responseId !== undefined) { + // Remove all carousels associated with a given responseId + for (const [key, rid] of this._questionCarouselResponseIds) { + if (rid === responseId) { + const part = this._chatQuestionCarouselWidgets.get(key); + if (part) { + part.domNode.remove(); + this._chatQuestionCarouselWidgets.deleteAndDispose(key); + } + this._questionCarouselResponseIds.delete(key); + this._questionCarouselSessionResources.delete(key); + } + } + } else { + // Clear all carousels + this._chatQuestionCarouselWidgets.clearAndDisposeAll(); + this._questionCarouselResponseIds.clear(); + this._questionCarouselSessionResources.clear(); + dom.clearNode(this.chatQuestionCarouselContainer); } - this._chatQuestionCarouselDisposables.clear(); - this._chatQuestionCarouselWidget.clear(); - this._currentQuestionCarouselResponseId = undefined; - this._currentQuestionCarouselSessionResource = undefined; - this._hasQuestionCarouselContextKey?.set(false); - dom.clearNode(this.chatQuestionCarouselContainer); - } - get questionCarouselResponseId(): string | undefined { - return this._currentQuestionCarouselResponseId; + this._hasQuestionCarouselContextKey?.set(this._chatQuestionCarouselWidgets.size > 0); } get questionCarousel(): ChatQuestionCarouselPart | undefined { - return this._chatQuestionCarouselWidget.value; + // Return the focused carousel, or the first one + for (const part of this._chatQuestionCarouselWidgets.values()) { + if (part.hasFocus()) { + return part; + } + } + return this._chatQuestionCarouselWidgets.size > 0 ? this._chatQuestionCarouselWidgets.values().next().value : undefined; } focusQuestionCarousel(): boolean { - const carousel = this._chatQuestionCarouselWidget.value; + const carousel = this.questionCarousel; if (carousel) { carousel.focus(); return true; @@ -2641,17 +2748,21 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } isQuestionCarouselFocused(): boolean { - const carousel = this._chatQuestionCarouselWidget.value; - return carousel?.hasFocus() ?? false; + for (const part of this._chatQuestionCarouselWidgets.values()) { + if (part.hasFocus()) { + return true; + } + } + return false; } navigateToPreviousQuestion(): boolean { - const carousel = this._chatQuestionCarouselWidget.value; + const carousel = this.questionCarousel; return carousel?.navigateToPreviousQuestion() ?? false; } navigateToNextQuestion(): boolean { - const carousel = this._chatQuestionCarouselWidget.value; + const carousel = this.questionCarousel; return carousel?.navigateToNextQuestion() ?? false; } @@ -3049,8 +3160,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return { editorBorder: 2, - inputPartHorizontalPadding: this.options.renderStyle === 'compact' ? 16 : 32, - inputPartHorizontalPaddingInside: 12, + inputPartHorizontalPadding: this.options.renderStyle === 'compact' ? 16 : 24, + inputPartHorizontalPaddingInside: this.options.renderStyle === 'compact' ? 12 : 10, toolbarsWidth: this.options.renderStyle === 'compact' ? getToolbarsWidthCompact() : 0, sideToolbarWidth: inputSideToolbarWidth > 0 ? inputSideToolbarWidth + 4 /*gap*/ : 0, }; @@ -3096,6 +3207,42 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Fallback for unknown contexts return { location: ChatWidgetLocation.Editor, isMaximized: false }; } + + private getDefaultScrollbarOptions(): IEditorScrollbarOptions { + const scrollbar = this._inputEditor.getRawOptions().scrollbar ?? {}; + return this.options.renderStyle === 'compact' + ? { ...scrollbar, vertical: 'hidden' } + : { ...scrollbar, vertical: 'auto', verticalScrollbarSize: 7 }; + } + + private getVisibleScrollbarOptions(): IEditorScrollbarOptions { + const scrollbar = this._inputEditor.getRawOptions().scrollbar ?? {}; + return this.options.renderStyle === 'compact' + ? { ...scrollbar, vertical: 'hidden' } + : { ...scrollbar, vertical: 'visible', verticalScrollbarSize: 7 }; + } + + private updateInputEditorScrollbarOptions(): void { + this._inputEditor.updateOptions({ + scrollbar: this._forceVisibleScrollbarUntilAccept + ? this.getVisibleScrollbarOptions() + : this.getDefaultScrollbarOptions() + }); + } + + showScrollbarUntilAccept(): void { + this._forceVisibleScrollbarUntilAccept = true; + this.updateInputEditorScrollbarOptions(); + } + + private resetScrollbarVisibilityAfterAccept(): void { + if (!this._forceVisibleScrollbarUntilAccept) { + return; + } + + this._forceVisibleScrollbarUntilAccept = false; + this.updateInputEditorScrollbarOptions(); + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index 0257f20252d..5a4968973f9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -16,6 +16,7 @@ import { autorun, IObservable } from '../../../../../../base/common/observable.j import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; import { ActionListItemKind, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; +import { IHoverPositionOptions } from '../../../../../../base/browser/ui/hover/hover.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; @@ -76,6 +77,7 @@ type ChatModelChangeEvent = { function createModelItem( action: IActionWidgetDropdownAction & { section?: string }, model?: ILanguageModelChatMetadataAndIdentifier, + hoverPosition?: IHoverPositionOptions, ): IActionListItem { return { item: action, @@ -85,7 +87,7 @@ function createModelItem( group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) }, hideIcon: false, section: action.section, - hover: model ? { content: getModelHoverContent(model) } : undefined, + hover: model ? { content: getModelHoverContent(model), position: hoverPosition } : undefined, }; } @@ -109,6 +111,27 @@ function createModelAction( }; } +function shouldShowManageModelsAction(chatEntitlementService: IChatEntitlementService): boolean { + return chatEntitlementService.entitlement === ChatEntitlement.Free || + chatEntitlementService.entitlement === ChatEntitlement.Pro || + chatEntitlementService.entitlement === ChatEntitlement.ProPlus || + chatEntitlementService.entitlement === ChatEntitlement.Business || + chatEntitlementService.entitlement === ChatEntitlement.Enterprise || + chatEntitlementService.isInternal; +} + +function createManageModelsAction(commandService: ICommandService): IActionWidgetDropdownAction { + return { + id: 'manageModels', + enabled: true, + checked: false, + class: ThemeIcon.asClassName(Codicon.gear), + tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), + label: localize('chat.manageModels', "Manage Models..."), + run: () => { commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); } + }; +} + /** * Builds the grouped items for the model picker dropdown. * @@ -118,7 +141,7 @@ function createModelAction( * - Available models sorted alphabetically, followed by unavailable models * - Unavailable models show upgrade/update/admin status * 3. Other Models (collapsible toggle, available first, then sorted by vendor then name) - * - Last item is "Manage Models..." (always visible during filtering) + * 4. Optional "Manage Models..." action shown in Other Models after a separator */ export function buildModelPickerItems( models: ILanguageModelChatMetadataAndIdentifier[], @@ -129,9 +152,10 @@ export function buildModelPickerItems( updateStateType: StateType, onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, manageSettingsUrl: string | undefined, - canManageModels: boolean, - commandService: ICommandService, + useGroupedModelPicker: boolean, + manageModelsAction: IActionWidgetDropdownAction | undefined, chatEntitlementService: IChatEntitlementService, + hoverPosition?: IHoverPositionOptions, ): IActionListItem[] { const items: IActionListItem[] = []; if (models.length === 0) { @@ -146,11 +170,200 @@ export function buildModelPickerItems( })); } - if (!canManageModels) { + if (useGroupedModelPicker) { + const isPro = isProUser(chatEntitlementService.entitlement); + let otherModels: ILanguageModelChatMetadataAndIdentifier[] = []; + if (models.length) { + // Collect all available models into lookup maps + const allModelsMap = new Map(); + const modelsByMetadataId = new Map(); + for (const model of models) { + allModelsMap.set(model.identifier, model); + modelsByMetadataId.set(model.metadata.id, model); + } + + const placed = new Set(); + + const markPlaced = (identifierOrId: string, metadataId?: string) => { + placed.add(identifierOrId); + if (metadataId) { + placed.add(metadataId); + } + }; + + const resolveModel = (id: string) => allModelsMap.get(id) ?? modelsByMetadataId.get(id); + + const getUnavailableReason = (entry: IModelControlEntry): 'upgrade' | 'update' | 'admin' => { + if (!isPro) { + return 'upgrade'; + } + if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + return 'update'; + } + return 'admin'; + }; + + // --- 1. Auto --- + const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot'); + if (autoModel) { + markPlaced(autoModel.identifier, autoModel.metadata.id); + items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect), autoModel, hoverPosition)); + } + + // --- 2. Promoted section (selected + recently used + featured) --- + type PromotedItem = + | { kind: 'available'; model: ILanguageModelChatMetadataAndIdentifier } + | { kind: 'unavailable'; id: string; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' }; + + const promotedItems: PromotedItem[] = []; + + // Try to place a model by id. Returns true if handled. + const tryPlaceModel = (id: string): boolean => { + if (placed.has(id)) { + return false; + } + const model = resolveModel(id); + if (model && !placed.has(model.identifier)) { + markPlaced(model.identifier, model.metadata.id); + const entry = controlModels[model.metadata.id]; + if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + promotedItems.push({ kind: 'unavailable', id: model.metadata.id, entry, reason: 'update' }); + } else { + promotedItems.push({ kind: 'available', model }); + } + return true; + } + if (!model) { + const entry = controlModels[id]; + if (entry && !entry.exists) { + markPlaced(id); + promotedItems.push({ kind: 'unavailable', id, entry, reason: getUnavailableReason(entry) }); + return true; + } + } + return false; + }; + + // Selected model + if (selectedModelId && selectedModelId !== autoModel?.identifier) { + tryPlaceModel(selectedModelId); + } + + // Recently used models + for (const id of recentModelIds) { + tryPlaceModel(id); + } + + // Featured models from control manifest + for (const [entryId, entry] of Object.entries(controlModels)) { + if (!entry.featured || placed.has(entryId)) { + continue; + } + const model = resolveModel(entryId); + if (model && !placed.has(model.identifier)) { + markPlaced(model.identifier, model.metadata.id); + if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: 'update' }); + } else { + promotedItems.push({ kind: 'available', model }); + } + } else if (!model && !entry.exists) { + markPlaced(entryId); + promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: getUnavailableReason(entry) }); + } + } + + // Render promoted section: available first, then sorted alphabetically by name + if (promotedItems.length > 0) { + promotedItems.sort((a, b) => { + const aAvail = a.kind === 'available' ? 0 : 1; + const bAvail = b.kind === 'available' ? 0 : 1; + if (aAvail !== bAvail) { + return aAvail - bAvail; + } + const aName = a.kind === 'available' ? a.model.metadata.name : a.entry.label; + const bName = b.kind === 'available' ? b.model.metadata.name : b.entry.label; + return aName.localeCompare(bName); + }); + + for (const item of promotedItems) { + if (item.kind === 'available') { + items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model, hoverPosition)); + } else { + items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType, undefined, hoverPosition)); + } + } + } + + // --- 3. Other Models (collapsible) --- + otherModels = models + .filter(m => !placed.has(m.identifier) && !placed.has(m.metadata.id)) + .sort((a, b) => { + const aEntry = controlModels[a.metadata.id] ?? controlModels[a.identifier]; + const bEntry = controlModels[b.metadata.id] ?? controlModels[b.identifier]; + const aAvail = aEntry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, aEntry.minVSCodeVersion) ? 1 : 0; + const bAvail = bEntry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, bEntry.minVSCodeVersion) ? 1 : 0; + if (aAvail !== bAvail) { + return aAvail - bAvail; + } + const aCopilot = a.metadata.vendor === 'copilot' ? 0 : 1; + const bCopilot = b.metadata.vendor === 'copilot' ? 0 : 1; + if (aCopilot !== bCopilot) { + return aCopilot - bCopilot; + } + const vendorCmp = a.metadata.vendor.localeCompare(b.metadata.vendor); + return vendorCmp !== 0 ? vendorCmp : a.metadata.name.localeCompare(b.metadata.name); + }); + + if (otherModels.length > 0) { + if (items.length > 0) { + items.push({ kind: ActionListItemKind.Separator }); + } + items.push({ + item: { + id: 'otherModels', + enabled: true, + checked: false, + class: undefined, + tooltip: localize('chat.modelPicker.otherModels', "Other Models"), + label: localize('chat.modelPicker.otherModels', "Other Models"), + run: () => { /* toggle handled by isSectionToggle */ } + }, + kind: ActionListItemKind.Action, + label: localize('chat.modelPicker.otherModels', "Other Models"), + group: { title: '', icon: Codicon.chevronDown }, + hideIcon: false, + section: ModelPickerSection.Other, + isSectionToggle: true, + }); + for (const model of otherModels) { + const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; + if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { + items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, ModelPickerSection.Other, hoverPosition)); + } else { + items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other), model, hoverPosition)); + } + } + } + } + + if (manageModelsAction) { + items.push({ kind: ActionListItemKind.Separator, section: otherModels.length ? ModelPickerSection.Other : undefined }); + items.push({ + item: manageModelsAction, + kind: ActionListItemKind.Action, + label: manageModelsAction.label, + group: { title: '', icon: Codicon.blank }, + hideIcon: false, + section: otherModels.length ? ModelPickerSection.Other : undefined, + showAlways: true, + }); + } + } else { // Flat list: auto first, then all models sorted alphabetically const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot'); if (autoModel) { - items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect), autoModel)); + items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect), autoModel, hoverPosition)); } const sortedModels = models .filter(m => m !== autoModel) @@ -159,213 +372,8 @@ export function buildModelPickerItems( return vendorCmp !== 0 ? vendorCmp : a.metadata.name.localeCompare(b.metadata.name); }); for (const model of sortedModels) { - items.push(createModelItem(createModelAction(model, selectedModelId, onSelect), model)); + items.push(createModelItem(createModelAction(model, selectedModelId, onSelect), model, hoverPosition)); } - return items; - } - - const isPro = isProUser(chatEntitlementService.entitlement); - let otherModels: ILanguageModelChatMetadataAndIdentifier[] = []; - if (models.length) { - // Collect all available models into lookup maps - const allModelsMap = new Map(); - const modelsByMetadataId = new Map(); - for (const model of models) { - allModelsMap.set(model.identifier, model); - modelsByMetadataId.set(model.metadata.id, model); - } - - const placed = new Set(); - - const markPlaced = (identifierOrId: string, metadataId?: string) => { - placed.add(identifierOrId); - if (metadataId) { - placed.add(metadataId); - } - }; - - const resolveModel = (id: string) => allModelsMap.get(id) ?? modelsByMetadataId.get(id); - - const getUnavailableReason = (entry: IModelControlEntry): 'upgrade' | 'update' | 'admin' => { - if (!isPro) { - return 'upgrade'; - } - if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - return 'update'; - } - return 'admin'; - }; - - // --- 1. Auto --- - const autoModel = models.find(m => m.metadata.id === 'auto' && m.metadata.vendor === 'copilot'); - if (autoModel) { - markPlaced(autoModel.identifier, autoModel.metadata.id); - items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect), autoModel)); - } - - // --- 2. Promoted section (selected + recently used + featured) --- - type PromotedItem = - | { kind: 'available'; model: ILanguageModelChatMetadataAndIdentifier } - | { kind: 'unavailable'; id: string; entry: IModelControlEntry; reason: 'upgrade' | 'update' | 'admin' }; - - const promotedItems: PromotedItem[] = []; - - // Try to place a model by id. Returns true if handled. - const tryPlaceModel = (id: string): boolean => { - if (placed.has(id)) { - return false; - } - const model = resolveModel(id); - if (model && !placed.has(model.identifier)) { - markPlaced(model.identifier, model.metadata.id); - const entry = controlModels[model.metadata.id]; - if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - promotedItems.push({ kind: 'unavailable', id: model.metadata.id, entry, reason: 'update' }); - } else { - promotedItems.push({ kind: 'available', model }); - } - return true; - } - if (!model) { - const entry = controlModels[id]; - if (entry && !entry.exists) { - markPlaced(id); - promotedItems.push({ kind: 'unavailable', id, entry, reason: getUnavailableReason(entry) }); - return true; - } - } - return false; - }; - - // Selected model - if (selectedModelId && selectedModelId !== autoModel?.identifier) { - tryPlaceModel(selectedModelId); - } - - // Recently used models - for (const id of recentModelIds) { - tryPlaceModel(id); - } - - // Featured models from control manifest - for (const [entryId, entry] of Object.entries(controlModels)) { - if (!entry.featured || placed.has(entryId)) { - continue; - } - const model = resolveModel(entryId); - if (model && !placed.has(model.identifier)) { - markPlaced(model.identifier, model.metadata.id); - if (entry.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: 'update' }); - } else { - promotedItems.push({ kind: 'available', model }); - } - } else if (!model && !entry.exists) { - markPlaced(entryId); - promotedItems.push({ kind: 'unavailable', id: entryId, entry, reason: getUnavailableReason(entry) }); - } - } - - // Render promoted section: available first, then sorted alphabetically by name - if (promotedItems.length > 0) { - promotedItems.sort((a, b) => { - const aAvail = a.kind === 'available' ? 0 : 1; - const bAvail = b.kind === 'available' ? 0 : 1; - if (aAvail !== bAvail) { - return aAvail - bAvail; - } - const aName = a.kind === 'available' ? a.model.metadata.name : a.entry.label; - const bName = b.kind === 'available' ? b.model.metadata.name : b.entry.label; - return aName.localeCompare(bName); - }); - - for (const item of promotedItems) { - if (item.kind === 'available') { - items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model)); - } else { - items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType)); - } - } - } - - // --- 3. Other Models (collapsible) --- - otherModels = models - .filter(m => !placed.has(m.identifier) && !placed.has(m.metadata.id)) - .sort((a, b) => { - const aEntry = controlModels[a.metadata.id] ?? controlModels[a.identifier]; - const bEntry = controlModels[b.metadata.id] ?? controlModels[b.identifier]; - const aAvail = aEntry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, aEntry.minVSCodeVersion) ? 1 : 0; - const bAvail = bEntry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, bEntry.minVSCodeVersion) ? 1 : 0; - if (aAvail !== bAvail) { - return aAvail - bAvail; - } - const aCopilot = a.metadata.vendor === 'copilot' ? 0 : 1; - const bCopilot = b.metadata.vendor === 'copilot' ? 0 : 1; - if (aCopilot !== bCopilot) { - return aCopilot - bCopilot; - } - const vendorCmp = a.metadata.vendor.localeCompare(b.metadata.vendor); - return vendorCmp !== 0 ? vendorCmp : a.metadata.name.localeCompare(b.metadata.name); - }); - - if (otherModels.length > 0) { - if (items.length > 0) { - items.push({ kind: ActionListItemKind.Separator }); - } - items.push({ - item: { - id: 'otherModels', - enabled: true, - checked: false, - class: undefined, - tooltip: localize('chat.modelPicker.otherModels', "Other Models"), - label: localize('chat.modelPicker.otherModels', "Other Models"), - run: () => { /* toggle handled by isSectionToggle */ } - }, - kind: ActionListItemKind.Action, - label: localize('chat.modelPicker.otherModels', "Other Models"), - group: { title: '', icon: Codicon.chevronDown }, - hideIcon: false, - section: ModelPickerSection.Other, - isSectionToggle: true, - }); - for (const model of otherModels) { - const entry = controlModels[model.metadata.id] ?? controlModels[model.identifier]; - if (entry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, entry.minVSCodeVersion)) { - items.push(createUnavailableModelItem(model.metadata.id, entry, 'update', manageSettingsUrl, updateStateType, ModelPickerSection.Other)); - } else { - items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, ModelPickerSection.Other), model)); - } - } - } - } - - if ( - chatEntitlementService.entitlement === ChatEntitlement.Free || - chatEntitlementService.entitlement === ChatEntitlement.Pro || - chatEntitlementService.entitlement === ChatEntitlement.ProPlus || - chatEntitlementService.entitlement === ChatEntitlement.Business || - chatEntitlementService.entitlement === ChatEntitlement.Enterprise || - chatEntitlementService.isInternal - ) { - items.push({ kind: ActionListItemKind.Separator, section: otherModels.length ? ModelPickerSection.Other : undefined }); - items.push({ - item: { - id: 'manageModels', - enabled: true, - checked: false, - class: undefined, - tooltip: localize('chat.manageModels.tooltip', "Manage Language Models"), - label: localize('chat.manageModels', "Manage Models..."), - run: () => { commandService.executeCommand(MANAGE_CHAT_COMMAND_ID); } - }, - kind: ActionListItemKind.Action, - label: localize('chat.manageModels', "Manage Models..."), - group: { title: '', icon: Codicon.blank }, - hideIcon: false, - section: otherModels.length ? ModelPickerSection.Other : undefined, - showAlways: true, - }); } return items; @@ -394,6 +402,7 @@ function createUnavailableModelItem( manageSettingsUrl: string | undefined, updateStateType: StateType, section?: string, + hoverPosition?: IHoverPositionOptions, ): IActionListItem { let description: string | MarkdownString | undefined; @@ -437,7 +446,7 @@ function createUnavailableModelItem( hideIcon: false, className: 'chat-model-picker-unavailable', section, - hover: { content: hoverContent }, + hover: { content: hoverContent, position: hoverPosition }, }; } @@ -475,6 +484,7 @@ export class ModelPickerWidget extends Disposable { constructor( private readonly _delegate: IModelPickerDelegate, + private readonly _hoverPosition: IHoverPositionOptions | undefined, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @ICommandService private readonly _commandService: ICommandService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @@ -562,9 +572,12 @@ export class ModelPickerWidget extends Disposable { }; const models = this._delegate.getModels(); + const showFilter = models.length >= 10; const isPro = isProUser(this._entitlementService.entitlement); const manifest = this._languageModelsService.getModelsControlManifest(); const controlModelsForTier = isPro ? manifest.paid : manifest.free; + const canShowManageModelsAction = this._delegate.showManageModelsAction() && shouldShowManageModelsAction(this._entitlementService); + const manageModelsAction = canShowManageModelsAction ? createManageModelsAction(this._commandService) : undefined; const items = buildModelPickerItems( models, this._selectedModel?.identifier, @@ -574,17 +587,19 @@ export class ModelPickerWidget extends Disposable { this._updateService.state.type, onSelect, this._productService.defaultChatAgent?.manageSettingsUrl, - this._delegate.canManageModels(), - this._commandService, + this._delegate.useGroupedModelPicker(), + !showFilter ? manageModelsAction : undefined, this._entitlementService, + this._hoverPosition, ); const listOptions = { - showFilter: models.length >= 10, + showFilter, filterPlaceholder: localize('chat.modelPicker.search', "Search models"), + filterActions: showFilter && manageModelsAction ? [manageModelsAction] : undefined, focusFilterOnOpen: true, collapsedByDefault: new Set([ModelPickerSection.Other]), - minWidth: 300, + minWidth: 200, }; const previouslyFocusedElement = dom.getActiveElement(); @@ -655,9 +670,7 @@ export class ModelPickerWidget extends Disposable { domChildren.push(this._badgeIcon); } - if (!this._hideChevrons?.get()) { - domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); - } + domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); dom.reset(this._domNode, ...domChildren); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts new file mode 100644 index 00000000000..206a3d54765 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts @@ -0,0 +1,288 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../common/languageModels.js'; + +/** + * Describes the context needed for model selection decisions. + */ +export interface IModelSelectionContext { + readonly location: ChatAgentLocation; + readonly currentModeKind: ChatModeKind; + readonly isInlineChatV2Enabled: boolean; + readonly sessionType: string | undefined; +} + +/** + * Filter models based on session type. + * When a session has a specific type (and it's not 'local'), only models targeting that + * session type are returned. Otherwise, general-purpose models are returned. + */ +export function filterModelsForSession( + models: ILanguageModelChatMetadataAndIdentifier[], + sessionType: string | undefined, + currentModeKind: ChatModeKind, + location: ChatAgentLocation, + isInlineChatV2Enabled: boolean, +): ILanguageModelChatMetadataAndIdentifier[] { + if (sessionType && sessionType !== 'local' && hasModelsTargetingSession(models, sessionType)) { + return models.filter(entry => + entry.metadata?.targetChatSessionType === sessionType && + entry.metadata?.isUserSelectable + ); + } + + return models.filter(entry => + !entry.metadata?.targetChatSessionType && + entry.metadata?.isUserSelectable && + isModelSupportedForMode(entry, currentModeKind) && + isModelSupportedForInlineChat(entry, location, isInlineChatV2Enabled) + ); +} + +/** + * Check if a model is suitable for the current chat mode (e.g., agent mode requires tool calling). + */ +export function isModelSupportedForMode( + model: ILanguageModelChatMetadataAndIdentifier, + currentModeKind: ChatModeKind, +): boolean { + if (currentModeKind === ChatModeKind.Agent) { + return ILanguageModelChatMetadata.suitableForAgentMode(model.metadata); + } + return true; +} + +/** + * Check if a model is suitable for inline chat (editor inline) usage. + */ +export function isModelSupportedForInlineChat( + model: ILanguageModelChatMetadataAndIdentifier, + location: ChatAgentLocation, + isInlineChatV2Enabled: boolean, +): boolean { + if (location !== ChatAgentLocation.EditorInline || !isInlineChatV2Enabled) { + return true; + } + return !!model.metadata.capabilities?.toolCalling; +} + +/** + * Check if any models in the pool target a specific session type. + */ +export function hasModelsTargetingSession( + allModels: ILanguageModelChatMetadataAndIdentifier[], + sessionType: string | undefined, +): boolean { + if (!sessionType) { + return false; + } + return allModels.some(m => m.metadata.targetChatSessionType === sessionType); +} + +/** + * Check if a model is valid for the current session's model pool. + * If the session has targeted models, the model must target that session type. + * If no models target this session, the model must not be session-specific. + */ +export function isModelValidForSession( + model: ILanguageModelChatMetadataAndIdentifier, + allModels: ILanguageModelChatMetadataAndIdentifier[], + sessionType: string | undefined, +): boolean { + if (hasModelsTargetingSession(allModels, sessionType)) { + return model.metadata.targetChatSessionType === sessionType; + } + return !model.metadata.targetChatSessionType; +} + +/** + * Find the default model for a given location from a list of models. + * Prefers the model marked as default for the location, falls back to the first model. + */ +export function findDefaultModel( + models: ILanguageModelChatMetadataAndIdentifier[], + location: ChatAgentLocation, +): ILanguageModelChatMetadataAndIdentifier | undefined { + return models.find(m => m.metadata.isDefaultForLocation[location]) || models[0]; +} + +/** + * Determine whether a persisted model selection should be restored. + * + * A persisted model should be restored if: + * 1. The model still exists in the available models list + * 2. Either the model wasn't the default at the time it was persisted, + * OR it is currently the default for the location + * + * This prevents scenarios where a user's explicit model choice gets overridden + * when the default model changes, while still tracking default model changes + * for users who never explicitly chose a model. + */ +export function shouldRestorePersistedModel( + persistedModelId: string, + persistedAsDefault: boolean, + availableModels: ILanguageModelChatMetadataAndIdentifier[], + location: ChatAgentLocation, +): { shouldRestore: boolean; model: ILanguageModelChatMetadataAndIdentifier | undefined } { + const model = availableModels.find(m => m.identifier === persistedModelId); + if (!model) { + return { shouldRestore: false, model: undefined }; + } + + if (!persistedAsDefault || model.metadata.isDefaultForLocation[location]) { + return { shouldRestore: true, model }; + } + + return { shouldRestore: false, model }; +} + +/** + * Determines whether the current model should be reset because it is no longer + * compatible with the current mode, session, or availability. + * + * Returns true if the model should be reset to default. + */ +export function shouldResetModelToDefault( + currentModel: ILanguageModelChatMetadataAndIdentifier | undefined, + availableModels: ILanguageModelChatMetadataAndIdentifier[], + context: IModelSelectionContext, + allModels: ILanguageModelChatMetadataAndIdentifier[], +): boolean { + if (!currentModel) { + return true; + } + + // Model is no longer in the available list + if (!availableModels.some(m => m.identifier === currentModel.identifier)) { + return true; + } + + // Model not supported for current mode + if (!isModelSupportedForMode(currentModel, context.currentModeKind)) { + return true; + } + + // Model not supported for inline chat + if (!isModelSupportedForInlineChat(currentModel, context.location, context.isInlineChatV2Enabled)) { + return true; + } + + // Model not valid for current session + if (!isModelValidForSession(currentModel, allModels, context.sessionType)) { + return true; + } + + return false; +} + +/** + * Determines whether a model from a sync state should be applied to the current view. + * + * Returns an action: + * - `'keep'` - the view already has the same model; no change needed. + * - `'apply'` - the state model is valid; the caller should switch to it. + * - `'default'` - the state model is incompatible (wrong session pool, unsupported + * mode, or missing inline-chat capability); the caller should fall + * back to the default model for the current location. + * + * @param context Optional because some callers (e.g. unit tests, or code paths + * that only care about session-pool validation) don't have a full UI context + * available. When omitted, mode and inline-chat checks are skipped and only + * session-pool membership is validated. + */ +export function resolveModelFromSyncState( + stateModel: ILanguageModelChatMetadataAndIdentifier, + currentModel: ILanguageModelChatMetadataAndIdentifier | undefined, + allModels: ILanguageModelChatMetadataAndIdentifier[], + sessionType: string | undefined, + context?: IModelSelectionContext, +): { action: 'keep' | 'apply' | 'default' } { + // Already the same model — nothing to do + if (currentModel && currentModel.identifier === stateModel.identifier) { + return { action: 'keep' }; + } + + // Validate the state model belongs to this session's model pool + if (!isModelValidForSession(stateModel, allModels, sessionType)) { + return { action: 'default' }; + } + + // When a UI context is available, also validate mode and inline-chat compatibility + if (context) { + if (!isModelSupportedForMode(stateModel, context.currentModeKind)) { + return { action: 'default' }; + } + if (!isModelSupportedForInlineChat(stateModel, context.location, context.isInlineChatV2Enabled)) { + return { action: 'default' }; + } + } + + return { action: 'apply' }; +} + +/** + * Merges live models with cached models per-vendor. + * For vendors whose models have resolved, uses live data. + * For vendors that are contributed but haven't resolved yet (startup race), keeps cached models. + * Vendors no longer contributed are evicted from cache. + */ +export function mergeModelsWithCache( + liveModels: ILanguageModelChatMetadataAndIdentifier[], + cachedModels: ILanguageModelChatMetadataAndIdentifier[], + contributedVendors: Set, +): ILanguageModelChatMetadataAndIdentifier[] { + if (liveModels.length > 0) { + const liveVendors = new Set(liveModels.map(m => m.metadata.vendor)); + return [ + ...liveModels, + ...cachedModels.filter(m => !liveVendors.has(m.metadata.vendor) && contributedVendors.has(m.metadata.vendor)), + ]; + } + return cachedModels; +} + +/** + * Determines whether the currently selected model should be reset to default + * when the language model list changes. + * + * Returns true if the model should be reset to default (i.e., the selected model + * is no longer in the available models list). + */ +export function shouldResetOnModelListChange( + currentModelId: string | undefined, + availableModels: ILanguageModelChatMetadataAndIdentifier[], +): boolean { + if (!currentModelId) { + return true; + } + return !availableModels.some(m => m.identifier === currentModelId); +} + +/** + * Determines whether a late-arriving persisted model should be restored. + * This handles the startup race where the model wasn't available during + * `initSelectedModel` but arrives later via `onDidChangeLanguageModels`. + * + * The model must pass both the persisted-default check and the `isUserSelectable` check. + */ +export function shouldRestoreLateArrivingModel( + persistedModelId: string, + persistedAsDefault: boolean, + model: ILanguageModelChatMetadataAndIdentifier, + location: ChatAgentLocation, +): boolean { + if (!model.metadata.isUserSelectable) { + return false; + } + const result = shouldRestorePersistedModel( + persistedModelId, + persistedAsDefault, + [model], + location, + ); + return result.shouldRestore; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts index c03c6f69f39..d9beb3af8bb 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { $, addDisposableListener, append, EventType } from '../../../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, EventType, ModifierKeyEmitter } from '../../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Action, IAction } from '../../../../../../base/common/actions.js'; @@ -42,6 +42,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { private readonly _primaryActionAction: Action; private readonly _primaryAction: ActionViewItem; private readonly _dropdown: ActionWidgetDropdownActionViewItem; + private _altKeyPressed = false; constructor( action: IAction, @@ -61,7 +62,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { this._primaryActionAction = this._register(new Action( 'chat.queuePickerPrimary', isSteerDefault ? localize('chat.steerWithMessage', "Steer with Message") : localize('chat.queueMessage', "Add to Queue"), - ThemeIcon.asClassName(Codicon.arrowUp), + ThemeIcon.asClassName(isSteerDefault ? Codicon.arrowUp : Codicon.add), !!contextKeyService.getContextKeyValue(ChatContextKeys.inputHasText.key), () => this._runDefaultAction() )); @@ -91,21 +92,35 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { this._updatePrimaryAction(); } })); + + // Toggle icon when Alt key is pressed/released + this._register(ModifierKeyEmitter.getInstance().event(status => { + if (this._altKeyPressed !== status.altKey) { + this._altKeyPressed = status.altKey; + this._updatePrimaryAction(); + } + })); } private _isSteerDefault(): boolean { return this.configurationService.getValue(ChatConfiguration.RequestQueueingDefaultAction) === 'steer'; } - private _updatePrimaryAction(): void { + private _isEffectiveSteer(): boolean { const isSteerDefault = this._isSteerDefault(); - this._primaryActionAction.label = isSteerDefault + return this._altKeyPressed ? !isSteerDefault : isSteerDefault; + } + + private _updatePrimaryAction(): void { + const isSteer = this._isEffectiveSteer(); + this._primaryActionAction.label = isSteer ? localize('chat.steerWithMessage', "Steer with Message") : localize('chat.queueMessage', "Add to Queue"); + this._primaryActionAction.class = ThemeIcon.asClassName(isSteer ? Codicon.arrowUp : Codicon.add); } private _runDefaultAction(): void { - const actionId = this._isSteerDefault() + const actionId = this._isEffectiveSteer() ? ChatSteerWithMessageAction.ID : ChatQueueMessageAction.ID; this.commandService.executeCommand(actionId); @@ -183,7 +198,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { tooltip: '', enabled: true, checked: isSteerDefault, - icon: Codicon.arrowRight, + icon: Codicon.arrowUp, class: undefined, hover: { content: localize('chat.steerWithMessage.hover', "Send this message at the next opportunity, signaling the current request to yield. The current response will stop and the new message will be sent immediately."), @@ -198,7 +213,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { label: localize('chat.sendImmediately', "Stop and Send"), tooltip: '', enabled: true, - icon: Codicon.arrowUp, + icon: Codicon.arrowRight, class: undefined, hover: { content: localize('chat.sendImmediately.hover', "Cancel the current request and send this message immediately."), diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index a03b216363c..0a068f1b964 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -54,12 +54,20 @@ import { IDynamicVariable } from '../../../../common/attachments/chatVariables.j import { ChatAgentLocation, ChatModeKind, isSupportedChatFileScheme } from '../../../../common/constants.js'; import { isToolSet } from '../../../../common/tools/languageModelToolsService.js'; import { IChatSessionsService } from '../../../../common/chatSessionsService.js'; -import { IPromptsService, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; +import { + PromptsType, + Target +} from '../../../../common/promptSyntax/promptTypes.js'; import { ChatSubmitAction, IChatExecuteActionContext } from '../../../actions/chatExecuteActions.js'; import { IChatWidget, IChatWidgetService } from '../../../chat.js'; import { resizeImage } from '../../../chatImageUtils.js'; import { ChatDynamicVariableModel } from '../../../attachments/chatDynamicVariables.js'; import { IChatService } from '../../../../common/chatService/chatService.js'; +import { IChatDebugService } from '../../../../common/chatDebugService.js'; +import { createDebugEventsAttachment } from '../../../chatDebug/chatDebugAttachment.js'; +import { getPromptFileType } from '../../../../common/promptSyntax/config/promptFileLocations.js'; +import { getChatSessionType } from '../../../../common/model/chatUri.js'; /** * Regex matching a slash command word (e.g. `/foo`). Uses `\p{L}` for Unicode @@ -101,7 +109,7 @@ class SlashCommandCompletions extends Disposable { } const sessionResource = widget.viewModel.model.sessionResource; const ctx = sessionResource && chatService.getChatSessionFromInternalUri(sessionResource); - customAgentTarget = (ctx ? chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType) : undefined) ?? Target.Undefined; + customAgentTarget = (ctx ? chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource)) : undefined) ?? Target.Undefined; } const range = computeCompletionRanges(model, position, SlashCommandWord); @@ -143,7 +151,7 @@ class SlashCommandCompletions extends Disposable { .map((c, i): CompletionItem => { const withSlash = `/${c.command}`; return { - label: withSlash, + label: { label: withSlash, description: c.detail }, insertText: c.executeImmediately ? '' : `${withSlash} `, documentation: c.detail, range, @@ -187,7 +195,7 @@ class SlashCommandCompletions extends Disposable { suggestions: slashCommands.map((c, i): CompletionItem => { const withSlash = `${chatSubcommandLeader}${c.command}`; return { - label: withSlash, + label: { label: withSlash, description: c.detail }, insertText: c.executeImmediately ? '' : `${withSlash} `, documentation: c.detail, range, @@ -238,9 +246,20 @@ class SlashCommandCompletions extends Disposable { // Filter out commands that are not user-invocable (hidden from / menu) const userInvocableCommands = promptCommands .filter(c => { - // Exclude extension-provided prompt files for locked agents. - if (widget.lockedAgentId && c.promptPath.extension) { - return false; + if (widget.lockedAgentId) { + // Exclude extension-provided prompt files for locked agents. + if (c.promptPath.extension) { + return false; + } + // Exclude hooks as those don't work in locked agent scenarios. + try { + const promptType = getPromptFileType(c.promptPath.uri); + if (promptType && promptType === PromptsType.hook) { + return false; + } + } catch { + + } } return true; }) @@ -832,6 +851,7 @@ interface IVariableCompletionsDetails { class BuiltinDynamicCompletions extends Disposable { private static readonly addReferenceCommand = '_addReferenceCmd'; + private static readonly addDebugEventsSnapshotCommand = '_addDebugEventsSnapshotCmd'; private static readonly VariableNameDef = new RegExp(`${chatVariableLeader}[\\w:-]*`, 'g'); // MUST be using `g`-flag @@ -848,6 +868,7 @@ class BuiltinDynamicCompletions extends Disposable { @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IChatAgentService private readonly chatAgentService: IChatAgentService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IChatDebugService private readonly chatDebugService: IChatDebugService, ) { super(); @@ -933,10 +954,46 @@ class BuiltinDynamicCompletions extends Disposable { return result; }); + // Debug Events Snapshot completion + this.registerVariableCompletions('debugEventsSnapshot', ({ widget, range }) => { + if (widget.location !== ChatAgentLocation.Chat) { + return; + } + + const sessionResource = widget.viewModel?.sessionResource; + if (!sessionResource || this.chatDebugService.getEvents(sessionResource).length === 0) { + return; + } + + const text = `${chatVariableLeader}debugEventsSnapshot`; + const result: CompletionList = { suggestions: [] }; + result.suggestions.push({ + label: { label: text, description: localize('debugEventsSnapshot.description', 'Attach debug events snapshot') }, + filterText: text, + insertText: '', + range, + kind: CompletionItemKind.Text, + sortText: 'z', + command: { + id: BuiltinDynamicCompletions.addDebugEventsSnapshotCommand, title: '', arguments: [widget] + } + }); + return result; + }); + this._register(CommandsRegistry.registerCommand(BuiltinDynamicCompletions.addReferenceCommand, (_services, arg) => { assertType(arg instanceof ReferenceArgument); return this.cmdAddReference(arg); })); + + this._register(CommandsRegistry.registerCommand(BuiltinDynamicCompletions.addDebugEventsSnapshotCommand, async (_services, widget: IChatWidget) => { + const sessionResource = widget.viewModel?.sessionResource; + if (!sessionResource) { + return; + } + const attachment = await createDebugEventsAttachment(sessionResource, this.chatDebugService); + widget.attachmentModel.addContext(attachment); + })); } private findActiveCodeEditor(): ICodeEditor | undefined { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts index ce838a3e0a1..0bf73505e4e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts @@ -20,6 +20,7 @@ import { IChatAgentCommand, IChatAgentData, IChatAgentService } from '../../../. import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../common/widget/chatColors.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader } from '../../../../common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../../../common/requestParser/chatRequestParser.js'; +import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../../attachments/chatVariables.js'; import { IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js'; import { IChatWidget } from '../../../chat.js'; import { ChatWidget } from '../../chatWidget.js'; @@ -411,7 +412,7 @@ class ChatTokenDeleter extends Disposable { // If this was a simple delete, try to find out whether it was inside a token if (!change.text && this.widget.viewModel) { const attachmentCapabilities = previousSelectedAgent?.capabilities ?? this.widget.attachmentCapabilities; - const previousParsedValue = parser.parseChatRequest(this.widget.viewModel.sessionResource, previousInputValue, widget.location, { selectedAgent: previousSelectedAgent, mode: this.widget.input.currentModeKind, attachmentCapabilities }); + const previousParsedValue = parser.parseChatRequestWithReferences(getDynamicVariablesForWidget(this.widget), getSelectedToolAndToolSetsForWidget(this.widget), previousInputValue, this.widget.location, { selectedAgent: previousSelectedAgent, mode: this.widget.input.currentModeKind, attachmentCapabilities }); // For dynamic variables, this has to happen in ChatDynamicVariableModel with the other bookkeeping const deletableTokens = previousParsedValue.parts.filter(p => p instanceof ChatRequestAgentPart || p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashCommandPart || p instanceof ChatRequestSlashPromptPart || p instanceof ChatRequestToolPart); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts index 65089b20200..be28fc3feed 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatPasteProviders.ts @@ -5,6 +5,7 @@ import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../../base/common/codicons.js'; import { createStringDataTransferItem, IDataTransferItem, IReadonlyVSDataTransfer, VSDataTransfer } from '../../../../../../../base/common/dataTransfer.js'; +import { alert } from '../../../../../../../base/browser/ui/aria/aria.js'; import { HierarchicalKind } from '../../../../../../../base/common/hierarchicalKind.js'; import { Disposable } from '../../../../../../../base/common/lifecycle.js'; import { revive } from '../../../../../../../base/common/marshalling.js'; @@ -12,20 +13,25 @@ import { Mimes } from '../../../../../../../base/common/mime.js'; import { Schemas } from '../../../../../../../base/common/network.js'; import { basename, joinPath } from '../../../../../../../base/common/resources.js'; import { URI, UriComponents } from '../../../../../../../base/common/uri.js'; +import { Position } from '../../../../../../../editor/common/core/position.js'; import { IRange } from '../../../../../../../editor/common/core/range.js'; -import { DocumentPasteContext, DocumentPasteEdit, DocumentPasteEditProvider, DocumentPasteEditsSession } from '../../../../../../../editor/common/languages.js'; +import { DocumentPasteContext, DocumentPasteEdit, DocumentPasteEditProvider, DocumentPasteEditsSession, SymbolKinds } from '../../../../../../../editor/common/languages.js'; import { ITextModel } from '../../../../../../../editor/common/model.js'; import { ILanguageFeaturesService } from '../../../../../../../editor/common/services/languageFeatures.js'; import { IModelService } from '../../../../../../../editor/common/services/model.js'; +import { IOutlineModelService } from '../../../../../../../editor/contrib/documentSymbols/browser/outlineModel.js'; +import { getDefinitionsAtPosition } from '../../../../../../../editor/contrib/gotoSymbol/browser/goToSymbol.js'; import { localize } from '../../../../../../../nls.js'; import { IEnvironmentService } from '../../../../../../../platform/environment/common/environment.js'; import { IFileService } from '../../../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../../platform/log/common/log.js'; import { IExtensionService, isProposedApiEnabled } from '../../../../../../services/extensions/common/extensions.js'; -import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; -import { IChatVariablesService, IDynamicVariable } from '../../../../common/attachments/chatVariables.js'; +import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, isImageVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; +import { chatVariableLeader } from '../../../../common/requestParser/chatParserTypes.js'; +import { IDynamicVariable } from '../../../../common/attachments/chatVariables.js'; import { IChatWidgetService } from '../../../chat.js'; +import { getDynamicVariablesForWidget } from '../../../attachments/chatVariables.js'; import { ChatDynamicVariableModel } from '../../../attachments/chatDynamicVariables.js'; import { cleanupOldImages, createFileForMedia, resizeImage } from '../../../chatImageUtils.js'; @@ -36,6 +42,16 @@ interface SerializedCopyData { readonly range: IRange; } +interface ResolvedSymbolReference { + id: string; + fullName: string; + data: { + uri: URI; + range: IRange; + }; + icon: IDynamicVariable['icon']; +} + export class PasteImageProvider implements DocumentPasteEditProvider { private readonly imagesFolder: URI; @@ -177,6 +193,12 @@ export class CopyTextProvider implements DocumentPasteEditProvider { public readonly copyMimeTypes = [COPY_MIME_TYPES]; public readonly pasteMimeTypes = []; + constructor( + @IModelService private readonly modelService: IModelService, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IOutlineModelService private readonly outlineModelService: IOutlineModelService, + ) { } + async prepareDocumentPaste(model: ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, token: CancellationToken): Promise { if (model.uri.scheme === Schemas.vscodeChatInput) { return; @@ -185,8 +207,35 @@ export class CopyTextProvider implements DocumentPasteEditProvider { const customDataTransfer = new VSDataTransfer(); const data: SerializedCopyData = { range: ranges[0], uri: model.uri.toJSON() }; customDataTransfer.append(COPY_MIME_TYPES, createStringDataTransferItem(JSON.stringify(data))); + + const text = dataTransfer.get(Mimes.text); + if (text && ranges.length) { + void this.primeSymbolReferenceCache(model, ranges[0], text, token); + } + return customDataTransfer; } + + private async primeSymbolReferenceCache(model: ITextModel, range: IRange, textItem: IDataTransferItem, token: CancellationToken): Promise { + const copiedText = model.getValueInRange(range); + if (range.startLineNumber !== range.endLineNumber) { + return; + } + + if (token.isCancellationRequested || !identifierPattern.test(copiedText)) { + return; + } + + cacheSymbolReference(model.uri, range, copiedText, resolveSymbolReference( + this.modelService, + this.languageFeaturesService, + this.outlineModelService, + model.uri, + range, + copiedText, + token, + )); + } } class CopyAttachmentsProvider implements DocumentPasteEditProvider { @@ -201,7 +250,6 @@ class CopyAttachmentsProvider implements DocumentPasteEditProvider { constructor( @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @IChatVariablesService private readonly chatVariableService: IChatVariablesService ) { } async prepareDocumentPaste(model: ITextModel, _ranges: readonly IRange[], _dataTransfer: IReadonlyVSDataTransfer, _token: CancellationToken): Promise { @@ -212,7 +260,7 @@ class CopyAttachmentsProvider implements DocumentPasteEditProvider { } const attachments = widget.attachmentModel.attachments; - const dynamicVariables = this.chatVariableService.getDynamicVariables(widget.viewModel.sessionResource); + const dynamicVariables = getDynamicVariablesForWidget(widget); if (attachments.length === 0 && dynamicVariables.length === 0) { return undefined; @@ -386,6 +434,7 @@ function createCustomPasteEdit(model: ITextModel, context: IChatRequestVariableE const label = context.length === 1 ? context[0].name : localize('pastedAttachment.multiple', '{0} and {1} more', context[0].name, context.length - 1); + const announceImageAttachment = context.length === 1 && isImageVariableEntry(context[0]); const customEdit = { resource: model.uri, @@ -403,6 +452,9 @@ function createCustomPasteEdit(model: ITextModel, context: IChatRequestVariableE throw new Error('No widget found for redo'); } widget.attachmentModel.addContext(...context); + if (announceImageAttachment) { + alert(localize('chat.pastedImageAttached', 'Attached image')); + } }, metadata: { needsConfirmation: false, @@ -428,6 +480,205 @@ function createEditSession(edit: DocumentPasteEdit): DocumentPasteEditsSession { }; } +const identifierPattern = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; +const symbolCacheMaxSize = 3; +type SymbolReferenceCacheEntry = { + key: string; + promise?: Promise; +}; + +const symbolReferenceCache: SymbolReferenceCacheEntry[] = []; + +function getSymbolReferenceCacheKey(uri: URI, range: IRange, text: string): string { + return `${uri.toString()}|${range.startLineNumber}:${range.startColumn}-${range.endLineNumber}:${range.endColumn}|${text}`; +} + +async function getCachedSymbolReference(uri: URI, range: IRange, text: string): Promise { + const key = getSymbolReferenceCacheKey(uri, range, text); + return symbolReferenceCache.find(e => e.key === key)?.promise; +} + +function cacheSymbolReference(uri: URI, range: IRange, text: string, valuePromise: Promise): void { + const entry: SymbolReferenceCacheEntry = { + key: getSymbolReferenceCacheKey(uri, range, text), + promise: valuePromise, + }; + symbolReferenceCache.unshift(entry); + while (symbolReferenceCache.length > symbolCacheMaxSize) { + symbolReferenceCache.pop(); + } + + valuePromise.catch(() => { + const i = symbolReferenceCache.indexOf(entry); + if (i !== -1) { + symbolReferenceCache.splice(i, 1); + } + }); +} + +async function resolveSymbolReference( + modelService: IModelService, + languageFeaturesService: ILanguageFeaturesService, + outlineModelService: IOutlineModelService, + sourceUri: URI, + sourceRange: IRange, + pastedText: string, + token: CancellationToken, +): Promise { + const sourceModel = modelService.getModel(sourceUri); + if (!sourceModel) { + return; + } + + const sourcePosition = new Position(sourceRange.startLineNumber, sourceRange.startColumn); + const definitions = await getDefinitionsAtPosition(languageFeaturesService.definitionProvider, sourceModel, sourcePosition, false, token); + if (token.isCancellationRequested || !definitions.length) { + return; + } + + const def = definitions[0]; + const defRange = def.targetSelectionRange ?? def.range; + const defLocation = { uri: def.uri, range: defRange }; + + let icon = Codicon.symbolProperty; + const defModel = modelService.getModel(def.uri); + if (defModel) { + try { + const outline = await outlineModelService.getOrCreate(defModel, token); + if (!token.isCancellationRequested) { + const element = outline.getItemEnclosingPosition({ lineNumber: defRange.startLineNumber, column: defRange.startColumn }); + if (element) { + icon = SymbolKinds.toIcon(element.symbol.kind); + } + } + } catch { + // Use default icon. + } + } + + if (token.isCancellationRequested) { + return; + } + + return { + id: `vscode.symbol/${JSON.stringify(defLocation)}`, + fullName: pastedText, + data: defLocation, + icon + }; +} + +class PasteSymbolProvider implements DocumentPasteEditProvider { + + public readonly kind = new HierarchicalKind('chat.attach.symbol'); + public readonly providedPasteEditKinds = [this.kind]; + + public readonly copyMimeTypes = []; + public readonly pasteMimeTypes = [COPY_MIME_TYPES]; + + constructor( + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IModelService private readonly modelService: IModelService, + @ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService, + @IOutlineModelService private readonly outlineModelService: IOutlineModelService, + ) { } + + async provideDocumentPasteEdits(model: ITextModel, ranges: readonly IRange[], dataTransfer: IReadonlyVSDataTransfer, _context: DocumentPasteContext, token: CancellationToken): Promise { + if (model.uri.scheme !== Schemas.vscodeChatInput) { + return; + } + + const text = dataTransfer.get(Mimes.text); + const additionalEditorData = dataTransfer.get(COPY_MIME_TYPES); + if (!text || !additionalEditorData) { + return; + } + + const pastedText = await text.asString(); + if (!identifierPattern.test(pastedText)) { + return; + } + + let additionalData: SerializedCopyData; + try { + additionalData = JSON.parse(await additionalEditorData.asString()); + } catch { + return; + } + + const sourceUri = URI.revive(additionalData.uri); + const sourceRange = additionalData.range; + + const widget = this.chatWidgetService.getWidgetByInputUri(model.uri); + if (!widget) { + return; + } + + const cached = await getCachedSymbolReference(sourceUri, sourceRange, pastedText); + let resolved = cached; + if (!resolved) { + resolved = await resolveSymbolReference( + this.modelService, + this.languageFeaturesService, + this.outlineModelService, + sourceUri, + sourceRange, + pastedText, + token, + ); + } + if (!resolved) { + return; + } + + if (token.isCancellationRequested) { + return; + } + + const symText = `${chatVariableLeader}sym:${pastedText}`; + const pasteRange = ranges[0]; + const insertText = `${symText} `; + + const refRange = { + startLineNumber: pasteRange.startLineNumber, + startColumn: pasteRange.startColumn, + endLineNumber: pasteRange.startLineNumber, + endColumn: pasteRange.startColumn + symText.length + }; + + const dynamicRef = { + id: resolved.id, + fullName: resolved.fullName, + range: refRange, + data: resolved.data, + icon: resolved.icon + }; + + const edit: DocumentPasteEdit = { + insertText, + title: localize('pastedSymbolReference', 'Pasted Symbol Reference'), + kind: this.kind, + handledMimeType: COPY_MIME_TYPES, + additionalEdit: { + edits: [{ + resource: model.uri, + redo: () => { + const w = this.chatWidgetService.getWidgetByInputUri(model.uri); + w?.getContrib(ChatDynamicVariableModel.ID)?.addReference(dynamicRef); + }, + undo: () => { + // The text removal by undo is sufficient; the dynamic variable + // model auto-cleans when the decoration text changes. + } + }] + } + }; + + edit.yieldTo = [{ kind: new HierarchicalKind('chat.attach.text') }]; + return createEditSession(edit); + } +} + export class ChatPasteProvidersFeature extends Disposable { constructor( @IInstantiationService instaService: IInstantiationService, @@ -443,7 +694,7 @@ export class ChatPasteProvidersFeature extends Disposable { this._register(languageFeaturesService.documentPasteEditProvider.register({ scheme: Schemas.vscodeChatInput, pattern: '*', hasAccessToAllModels: true }, instaService.createInstance(CopyAttachmentsProvider))); this._register(languageFeaturesService.documentPasteEditProvider.register({ scheme: Schemas.vscodeChatInput, pattern: '*', hasAccessToAllModels: true }, new PasteImageProvider(chatWidgetService, extensionService, fileService, environmentService, logService))); this._register(languageFeaturesService.documentPasteEditProvider.register({ scheme: Schemas.vscodeChatInput, pattern: '*', hasAccessToAllModels: true }, new PasteTextProvider(chatWidgetService, modelService))); - this._register(languageFeaturesService.documentPasteEditProvider.register('*', new CopyTextProvider())); - this._register(languageFeaturesService.documentPasteEditProvider.register('*', new CopyTextProvider())); + this._register(languageFeaturesService.documentPasteEditProvider.register({ scheme: Schemas.vscodeChatInput, pattern: '*', hasAccessToAllModels: true }, instaService.createInstance(PasteSymbolProvider))); + this._register(languageFeaturesService.documentPasteEditProvider.register('*', instaService.createInstance(CopyTextProvider))); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 773f896f6e8..fbd4a16a579 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -28,7 +28,8 @@ import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; -import { PromptsStorage, Target } from '../../../common/promptSyntax/service/promptsService.js'; +import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { getOpenChatActionIdForMode } from '../../actions/chatActions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../actions/chatExecuteActions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index 04bfc652890..bae01a07ca3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -29,7 +29,8 @@ export interface IModelPickerDelegate { readonly currentModel: IObservable; setModel(model: ILanguageModelChatMetadataAndIdentifier): void; getModels(): ILanguageModelChatMetadataAndIdentifier[]; - canManageModels(): boolean; + useGroupedModelPicker(): boolean; + showManageModelsAction(): boolean; } type ChatModelChangeClassification = { @@ -209,9 +210,7 @@ export class ModelPickerActionItem extends ChatInputPickerActionViewItem { } domChildren.push(dom.$('span.chat-input-picker-label', undefined, name ?? localize('chat.modelPicker.auto', "Auto"))); - if (!this.pickerOptions.hideChevrons.get()) { - domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); - } + domChildren.push(...renderLabelWithIcons(`$(chevron-down)`)); dom.reset(element, ...domChildren); this.setAriaLabelAttributes(element); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts index 5a4d402e790..dfc1510a2c8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem2.ts @@ -39,7 +39,7 @@ export class EnhancedModelPickerActionItem extends BaseActionViewItem { ) { super(undefined, action); - this._pickerWidget = this._register(instantiationService.createInstance(ModelPickerWidget, delegate)); + this._pickerWidget = this._register(instantiationService.createInstance(ModelPickerWidget, delegate, pickerOptions.hoverPosition)); this._pickerWidget.setSelectedModel(delegate.currentModel.get()); this._pickerWidget.setHideChevrons(pickerOptions.hideChevrons); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts new file mode 100644 index 00000000000..b3c674193bc --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -0,0 +1,232 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { renderLabelWithIcons } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IObservable } from '../../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IActionWidgetDropdownAction, IActionWidgetDropdownActionProvider } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../common/constants.js'; +import { MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import Severity from '../../../../../../base/common/severity.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; + +// Track whether warnings have been shown this VS Code session +const shownWarnings = new Set(); + +function hasShownElevatedWarning(level: ChatPermissionLevel): boolean { + if (shownWarnings.has(level)) { + return true; + } + // Autopilot is stricter than AutoApprove, so confirming Autopilot + // implies the user already accepted the AutoApprove risks. + if (level === ChatPermissionLevel.AutoApprove && shownWarnings.has(ChatPermissionLevel.Autopilot)) { + return true; + } + return false; +} + +export interface IPermissionPickerDelegate { + readonly currentPermissionLevel: IObservable; + readonly setPermissionLevel: (level: ChatPermissionLevel) => void; +} + +export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { + constructor( + action: MenuItemAction, + private readonly delegate: IPermissionPickerDelegate, + pickerOptions: IChatInputPickerOptions, + @IActionWidgetService actionWidgetService: IActionWidgetService, + @IKeybindingService keybindingService: IKeybindingService, + @IContextKeyService contextKeyService: IContextKeyService, + @ITelemetryService telemetryService: ITelemetryService, + @IConfigurationService configurationService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, + ) { + const isAutoApprovePolicyRestricted = () => configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false; + const isAutopilotEnabled = () => configurationService.getValue(ChatConfiguration.AutopilotEnabled) !== false; + const isBackgroundProvider = contextKeyService.getContextKeyValue('lockedCodingAgentId') === 'copilotcli'; + const actionProvider: IActionWidgetDropdownActionProvider = { + getActions: () => { + const currentLevel = delegate.currentPermissionLevel.get(); + const policyRestricted = isAutoApprovePolicyRestricted(); + const actions: IActionWidgetDropdownAction[] = [ + { + ...action, + id: 'chat.permissions.default', + label: localize('permissions.default', "Default Approvals"), + description: localize('permissions.default.subtext', "Copilot uses your configured settings"), + icon: ThemeIcon.fromId(Codicon.shield.id), + checked: currentLevel === ChatPermissionLevel.Default, + tooltip: '', + hover: { + content: localize('permissions.default.description', "Use configured approval settings"), + position: pickerOptions.hoverPosition + }, + run: async () => { + delegate.setPermissionLevel(ChatPermissionLevel.Default); + if (this.element) { + this.renderLabel(this.element); + } + }, + } satisfies IActionWidgetDropdownAction, + { + ...action, + id: 'chat.permissions.autoApprove', + label: localize('permissions.autoApprove', "Bypass Approvals"), + description: localize('permissions.autoApprove.subtext', "All tool calls are auto-approved"), + icon: ThemeIcon.fromId(Codicon.warning.id), + checked: currentLevel === ChatPermissionLevel.AutoApprove, + enabled: !policyRestricted, + tooltip: policyRestricted ? localize('permissions.autoApprove.policyDisabled', "Disabled by enterprise policy") : '', + hover: { + content: policyRestricted + ? localize('permissions.autoApprove.policyDescription', "Disabled by enterprise policy") + : localize('permissions.autoApprove.description', "Auto-approve all tool calls and retry on errors"), + position: pickerOptions.hoverPosition + }, + run: async () => { + if (!hasShownElevatedWarning(ChatPermissionLevel.AutoApprove)) { + const result = await this.dialogService.prompt({ + type: Severity.Warning, + message: localize('permissions.autoApprove.warning.title', "Enable Bypass Approvals?"), + buttons: [ + { + label: localize('permissions.autoApprove.warning.confirm', "Enable"), + run: () => true + }, + { + label: localize('permissions.autoApprove.warning.cancel', "Cancel"), + run: () => false + }, + ], + custom: { + icon: Codicon.warning, + markdownDetails: [{ + markdown: new MarkdownString(localize('permissions.autoApprove.warning.detail', "Bypass Approvals will auto-approve all tool calls without asking for confirmation. This includes file edits, terminal commands, and external tool calls.")), + }], + }, + }); + if (result.result !== true) { + return; + } + shownWarnings.add(ChatPermissionLevel.AutoApprove); + } + delegate.setPermissionLevel(ChatPermissionLevel.AutoApprove); + if (this.element) { + this.renderLabel(this.element); + } + }, + } satisfies IActionWidgetDropdownAction, + ]; + if (isAutopilotEnabled() && !isBackgroundProvider) { + actions.push({ + ...action, + id: 'chat.permissions.autopilot', + label: localize('permissions.autopilot', "Autopilot (Preview)"), + description: localize('permissions.autopilot.subtext', "Autonomously iterates from start to finish"), + icon: ThemeIcon.fromId(Codicon.rocket.id), + checked: currentLevel === ChatPermissionLevel.Autopilot, + enabled: !policyRestricted, + tooltip: policyRestricted ? localize('permissions.autopilot.policyDisabled', "Disabled by enterprise policy") : '', + hover: { + content: policyRestricted + ? localize('permissions.autopilot.policyDescription', "Disabled by enterprise policy") + : localize('permissions.autopilot.description', "Auto-approve all tool calls and continue until the task is done"), + position: pickerOptions.hoverPosition + }, + run: async () => { + if (!hasShownElevatedWarning(ChatPermissionLevel.Autopilot)) { + const result = await this.dialogService.prompt({ + type: Severity.Warning, + message: localize('permissions.autopilot.warning.title', "Enable Autopilot?"), + buttons: [ + { + label: localize('permissions.autopilot.warning.confirm', "Enable"), + run: () => true + }, + { + label: localize('permissions.autopilot.warning.cancel', "Cancel"), + run: () => false + }, + ], + custom: { + icon: Codicon.rocket, + markdownDetails: [{ + markdown: new MarkdownString(localize('permissions.autopilot.warning.detail', "Autopilot will auto-approve all tool calls and continue working autonomously until the task is complete. The agent will make decisions on your behalf without asking for confirmation.\n\nYou can stop the agent at any time by clicking the stop button. This applies to the current session only.")), + }], + }, + }); + if (result.result !== true) { + return; + } + shownWarnings.add(ChatPermissionLevel.Autopilot); + } + delegate.setPermissionLevel(ChatPermissionLevel.Autopilot); + if (this.element) { + this.renderLabel(this.element); + } + }, + } satisfies IActionWidgetDropdownAction); + } + return actions; + } + }; + + super(action, { + actionProvider, + reporter: { id: 'ChatPermissionPicker', name: 'ChatPermissionPicker', includeOptions: true }, + listOptions: { descriptionBelow: true, minWidth: 255 }, + }, pickerOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); + } + + protected override renderLabel(element: HTMLElement): IDisposable | null { + this.setAriaLabelAttributes(element); + + const level = this.delegate.currentPermissionLevel.get(); + let icon: ThemeIcon; + let label: string; + switch (level) { + case ChatPermissionLevel.Autopilot: + icon = Codicon.rocket; + label = localize('permissions.autopilot.label', "Autopilot (Preview)"); + break; + case ChatPermissionLevel.AutoApprove: + icon = Codicon.warning; + label = localize('permissions.autoApprove.label', "Bypass Approvals"); + break; + default: + icon = Codicon.shield; + label = localize('permissions.default.label', "Default Approvals"); + break; + } + + const labelElements = []; + labelElements.push(...renderLabelWithIcons(`$(${icon.id})`)); + labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); + + dom.reset(element, ...labelElements); + element.classList.toggle('warning', level === ChatPermissionLevel.Autopilot); + element.classList.toggle('info', level === ChatPermissionLevel.AutoApprove); + return null; + } + + public refresh(): void { + if (this.element) { + this.renderLabel(this.element); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 897c927f25f..d70bfd59695 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -18,11 +18,11 @@ import { IKeybindingService } from '../../../../../../platform/keybinding/common import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; -import { AgentSessionProviders, backgroundAgentDisplayName, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/dropdown.js'; -import { autorun } from '../../../../../../base/common/observable.js'; + export interface ISessionTypeItem { type: AgentSessionProviders; @@ -105,15 +105,7 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { this._updateAgentSessionItems(); })); - // Re-render when the background agent display name changes via experiment - // Note: autorun runs immediately, so this also handles initial population - this._register(autorun(reader => { - backgroundAgentDisplayName.read(reader); - this._updateAgentSessionItems(); - if (this.element) { - this.renderLabel(this.element); - } - })); + this._updateAgentSessionItems(); } protected _run(sessionTypeItem: ISessionTypeItem): void { @@ -213,12 +205,9 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const icon = getAgentSessionProviderIcon(currentType ?? AgentSessionProviders.Local); const labelElements = []; - const collapsed = this.pickerOptions.hideChevrons.get(); labelElements.push(...renderLabelWithIcons(`$(${icon.id})`)); - if (!collapsed) { - labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); - labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); - } + labelElements.push(dom.$('span.chat-input-picker-label', undefined, label)); + labelElements.push(...renderLabelWithIcons(`$(chevron-down)`)); dom.reset(element, ...labelElements); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index ea94c7c0a09..cfbf53ea6d8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -820,11 +820,26 @@ have to be updated for changes to the rules above, or to support more deeply nes position: relative; } -/* Context usage widget container - positioned in the bottom toolbar */ -.interactive-session .chat-input-toolbars .chat-context-usage-container { +/* Prevent contents from covering border corners. Not applied in compact mode + because overflow:hidden creates a BFC that causes a ResizeObserver ↔ layout + feedback loop when toolbars share width with the editor. */ +.interactive-session .interactive-input-part:not(.compact) .chat-input-container { + /* Prevent contents from covering border corner */ + overflow: hidden; +} + +/* Context usage widget container - positioned in the secondary toolbar below input */ +.interactive-session .chat-input-toolbars .chat-context-usage-container, +.interactive-session .chat-secondary-toolbar .chat-context-usage-container { display: flex; align-items: center; flex-shrink: 0; + margin-left: auto; + order: 1; +} + +/* When context usage is inside the toolbars (compact mode), keep the ordering */ +.interactive-session .chat-input-toolbars .chat-context-usage-container { order: 1; } @@ -1265,6 +1280,8 @@ have to be updated for changes to the rules above, or to support more deeply nes display: flex; justify-content: space-between; padding-bottom: 0; + /* no scrollbar */ + padding-right: 6px; border-radius: var(--vscode-cornerRadius-small); } @@ -1280,7 +1297,12 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-editor-container { - padding: 0 4px; + padding: 0 0 0 4px; +} + +.interactive-session .interactive-input-part.compact .chat-editor-container { + /* No scrollbar */ + padding-right: 4px; } .chat-editor-container .monaco-editor .mtk1 { @@ -1321,6 +1343,86 @@ have to be updated for changes to the rules above, or to support more deeply nes margin-top: 4px; } +/* Secondary toolbar below the input box */ +.interactive-session .chat-secondary-toolbar { + display: flex; + align-items: center; + gap: 6px; + padding: 0 4px 0 5px; +} + +.interactive-session .chat-secondary-toolbar:empty { + display: none; +} + +.interactive-session .chat-secondary-toolbar > .chat-secondary-input-toolbar { + overflow: hidden; + min-width: 0px; + color: var(--vscode-icon-foreground); + + .monaco-action-bar .action-item .codicon { + color: var(--vscode-icon-foreground); + } + + .chat-input-picker-item { + min-width: 0px; + overflow: hidden; + + .action-label { + min-width: 0px; + overflow: hidden; + position: relative; + + .chat-input-picker-label { + overflow: hidden; + text-overflow: ellipsis; + } + + span + .chat-input-picker-label { + margin-left: 2px; + } + + .codicon { + font-size: 12px; + } + } + + .codicon { + flex-shrink: 0; + } + } +} + +.interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label { + height: 16px; + padding: 3px 0px 3px 6px; + display: flex; + align-items: center; + color: var(--vscode-icon-foreground); +} + +.interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label.warning { + color: var(--vscode-problemsWarningIcon-foreground); +} + +.interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label.warning .codicon { + color: var(--vscode-problemsWarningIcon-foreground) !important; +} + +.interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label.info { + color: var(--vscode-problemsInfoIcon-foreground); +} + +.interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label.info .codicon { + color: var(--vscode-problemsInfoIcon-foreground) !important; +} + +.monaco-workbench .interactive-session .chat-secondary-toolbar .chat-input-picker-item .action-label .codicon-chevron-down { + font-size: 10px; + margin-left: 4px; + opacity: 0.75; +} + .interactive-session .chat-input-toolbars :not(.responsive.chat-input-toolbar) .actions-container:first-child { margin-right: auto; } @@ -1412,6 +1514,12 @@ have to be updated for changes to the rules above, or to support more deeply nes color: var(--vscode-icon-foreground); } +/* Keep hover background while picker dropdown is open */ +.interactive-session .chat-input-toolbar .action-label[aria-expanded="true"], +.interactive-session .chat-secondary-toolbar .action-label[aria-expanded="true"] { + background-color: var(--vscode-toolbar-hoverBackground); +} + /* When chevrons are hidden and only showing an icon (no label), size to 22x22 with centered icon */ .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.hide-chevrons:not(:has(.chat-input-picker-label)), .interactive-session .chat-input-toolbar .chat-input-picker-item.hide-chevrons .action-label:not(:has(.chat-input-picker-label)), @@ -1430,25 +1538,27 @@ have to be updated for changes to the rules above, or to support more deeply nes } } -/* When chevrons are hidden but label is still shown (e.g. model picker), use equal padding */ -.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.hide-chevrons:has(.chat-input-picker-label), -.interactive-session .chat-input-toolbar .chat-input-picker-item.hide-chevrons .action-label:has(.chat-input-picker-label), -.interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label.hide-chevrons:has(.chat-input-picker-label) { - padding: 3px 7px; + +/* Add context button icon sizing */ +.interactive-session .chat-input-toolbar .action-item:has(.codicon-add) .action-label { + display: flex; + align-items: center; + justify-content: center; } -/* Hide the tools button when the toolbar is in collapsed state */ -.interactive-session .chat-input-toolbar:has(.hide-chevrons) .action-item:has(.codicon-tools) { - display: none; +.interactive-session .chat-input-toolbar .action-item:has(.codicon-add) .codicon-add { + font-size: 14px; } .monaco-workbench .interactive-session .chat-input-toolbar .chat-input-picker-item .action-label .codicon-chevron-down, .monaco-workbench .interactive-session .chat-input-toolbar .chat-sessionPicker-item .action-label .codicon-chevron-down { - font-size: 12px; - margin-left: 2px; + font-size: 10px; + margin-left: 4px; + opacity: 0.75; } -.interactive-session .chat-input-toolbars .monaco-action-bar .actions-container { +.interactive-session .chat-input-toolbars .monaco-action-bar .actions-container, +.interactive-session .chat-secondary-toolbar .monaco-action-bar .actions-container { display: flex; gap: 4px; } @@ -1660,7 +1770,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .interactive-input-part { margin: 0px 12px; - padding: 4px 0 12px 0px; + padding: 4px 0 4px 0px; display: flex; flex-direction: column; gap: 4px; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts index 8a1c71fb615..0a3344392bc 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts @@ -219,7 +219,7 @@ export class ChatEditor extends AbstractEditorWithViewState c.type === chatSessionType); if (contribution) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts index 43fc982f081..47fe2a59f2c 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts @@ -22,8 +22,10 @@ export interface IChatContextUsagePromptTokenDetail { export interface IChatContextUsageData { usedTokens: number; + completionTokens: number; totalContextWindow: number; percentage: number; + outputBufferPercentage?: number; promptTokenDetails?: readonly IChatContextUsagePromptTokenDetail[]; } @@ -39,6 +41,8 @@ export class ChatContextUsageDetails extends Disposable { private readonly percentageLabel: HTMLElement; private readonly tokenCountLabel: HTMLElement; private readonly progressFill: HTMLElement; + private readonly outputBufferFill: HTMLElement; + private readonly outputBufferLegend: HTMLElement; private readonly tokenDetailsContainer: HTMLElement; private readonly warningMessage: HTMLElement; private readonly actionsSection: HTMLElement; @@ -52,36 +56,40 @@ export class ChatContextUsageDetails extends Disposable { this.domNode = $('.chat-context-usage-details'); - // Using same structure as ChatUsageWidget quota items - this.quotaItem = this.domNode.appendChild($('.quota-item')); + // Quota indicator — using same structure as ChatStatusDashboard + this.quotaItem = this.domNode.appendChild($('.quota-indicator')); - // Header row with label - const quotaItemHeader = this.quotaItem.appendChild($('.quota-item-header')); - const quotaItemLabel = quotaItemHeader.appendChild($('.quota-item-label')); - quotaItemLabel.textContent = localize('contextWindow', "Context Window"); + // Header row + const header = this.domNode.insertBefore($('div.header'), this.quotaItem); + header.textContent = localize('contextWindow', "Context Window"); - // Token count and percentage row (on same line) - const tokenRow = this.quotaItem.appendChild($('.token-row')); - this.tokenCountLabel = tokenRow.appendChild($('.token-count-label')); - this.percentageLabel = tokenRow.appendChild($('.quota-item-value')); + // Quota label row with token count + percentage + const quotaLabel = this.quotaItem.appendChild($('.quota-label')); + this.tokenCountLabel = quotaLabel.appendChild($('span')); + this.percentageLabel = quotaLabel.appendChild($('span.quota-value')); - // Progress bar - using same structure as chat usage widget + // Progress bar const progressBar = this.quotaItem.appendChild($('.quota-bar')); this.progressFill = progressBar.appendChild($('.quota-bit')); + this.outputBufferFill = progressBar.appendChild($('.quota-bit.output-buffer')); + + // Output buffer legend (shown only when outputBuffer is provided) + this.outputBufferLegend = this.quotaItem.appendChild($('.output-buffer-legend')); + this.outputBufferLegend.appendChild($('.output-buffer-swatch')); + const legendLabel = this.outputBufferLegend.appendChild($('span')); + legendLabel.textContent = localize('outputReserved', "Reserved for response"); + this.outputBufferLegend.style.display = 'none'; // Token details container (for category breakdown) this.tokenDetailsContainer = this.domNode.appendChild($('.token-details-container')); // Warning message (shown when usage is high) - this.warningMessage = this.domNode.appendChild($('.warning-message')); + this.warningMessage = this.domNode.appendChild($('div.description')); this.warningMessage.textContent = localize('qualityWarning', "Quality may decline as limit nears."); this.warningMessage.style.display = 'none'; - // Actions section with header, separator, and button bar + // Actions section with button bar this.actionsSection = this.domNode.appendChild($('.actions-section')); - this.actionsSection.appendChild($('.separator')); - const actionsHeader = this.actionsSection.appendChild($('.actions-header')); - actionsHeader.textContent = localize('actions', "Actions"); const buttonBarContainer = this.actionsSection.appendChild($('.button-bar-container')); this._register(this.instantiationService.createInstance(MenuWorkbenchButtonBar, buttonBarContainer, MenuId.ChatContextUsageActions, { toolbarOptions: { @@ -102,25 +110,39 @@ export class ChatContextUsageDetails extends Disposable { } update(data: IChatContextUsageData): void { - const { percentage, usedTokens, totalContextWindow, promptTokenDetails } = data; + const { percentage, usedTokens, totalContextWindow, outputBufferPercentage, promptTokenDetails } = data; - // Update token count and percentage on same line + // Update token count and percentage — reflects actual usage only this.tokenCountLabel.textContent = localize( 'tokenCount', "{0} / {1} tokens", this.formatTokenCount(usedTokens, 1), this.formatTokenCount(totalContextWindow, 0) ); - this.percentageLabel.textContent = `• ${percentage.toFixed(0)}%`; + this.percentageLabel.textContent = localize('quotaDisplay', "{0}%", Math.min(100, percentage).toFixed(0)); - // Update progress bar - this.progressFill.style.width = `${Math.min(100, percentage)}%`; + // Progress bar: actual usage fill + remaining reserved output fill + const usageBarWidth = Math.max(0, Math.min(100, percentage)); + this.progressFill.style.width = `${usageBarWidth}%`; - // Update color classes based on usage level on the quota item + if (outputBufferPercentage !== undefined && outputBufferPercentage > 0) { + // Clamp so the reserve never overflows the bar + this.outputBufferFill.style.width = `${Math.max(0, Math.min(100 - usageBarWidth, outputBufferPercentage))}%`; + this.outputBufferFill.style.display = ''; + this.outputBufferLegend.style.display = ''; + } else { + this.outputBufferFill.style.width = '0'; + this.outputBufferFill.style.display = 'none'; + this.outputBufferLegend.style.display = 'none'; + } + + // Color classes based on total spoken-for percentage + // (actual usage + remaining reserve) + const effectivePercentage = percentage + (outputBufferPercentage ?? 0); this.quotaItem.classList.remove('warning', 'error'); - if (percentage >= 90) { + if (effectivePercentage >= 90) { this.quotaItem.classList.add('error'); - } else if (percentage >= 75) { + } else if (effectivePercentage >= 75) { this.quotaItem.classList.add('warning'); } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index b20f39c7cf6..0bd56a8fe13 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -92,6 +92,7 @@ export class ChatContextUsageWidget extends Disposable { readonly domNode: HTMLElement; private readonly progressIndicator: CircularProgressIndicator; + private readonly percentageLabel: HTMLElement; private readonly _isVisible = observableValue(this, false); get isVisible(): IObservable { return this._isVisible; } @@ -130,6 +131,9 @@ export class ChatContextUsageWidget extends Disposable { this.progressIndicator = new CircularProgressIndicator(); iconContainer.appendChild(this.progressIndicator.domNode); + // Percentage label (visible on hover/focus) + this.percentageLabel = this.domNode.appendChild($('.percentage-label')); + // Track context usage opened state this._contextUsageOpenedKey = ChatContextKeys.contextUsageHasBeenOpened.bindTo(this.contextKeyService); @@ -270,27 +274,42 @@ export class ChatContextUsageWidget extends Disposable { } const promptTokens = usage.promptTokens; + const completionTokens = usage.completionTokens; const promptTokenDetails = usage.promptTokenDetails; + const outputBuffer = usage.outputBuffer; const totalContextWindow = maxInputTokens + maxOutputTokens; - const usedTokens = promptTokens + maxOutputTokens; - const percentage = Math.min(100, (usedTokens / totalContextWindow) * 100); + const usedTokens = promptTokens + completionTokens; + const percentage = (usedTokens / totalContextWindow) * 100; - this.render(percentage, usedTokens, totalContextWindow, promptTokenDetails); + // Remaining reserve = whatever the model reserved minus what completions + // have already consumed. Once completions exceed the reserve, it drops to 0. + const outputBufferPercentage = outputBuffer !== undefined + ? (Math.max(0, outputBuffer - completionTokens) / totalContextWindow) * 100 + : undefined; + + this.render(percentage, completionTokens, usedTokens, totalContextWindow, outputBufferPercentage, promptTokenDetails); this.show(); } - private render(percentage: number, usedTokens: number, totalContextWindow: number, promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]): void { + private render(percentage: number, completionTokens: number, usedTokens: number, totalContextWindow: number, outputBufferPercentage: number | undefined, promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]): void { // Store current data for use in details popup - this.currentData = { usedTokens, totalContextWindow, percentage, promptTokenDetails }; + this.currentData = { usedTokens, completionTokens, totalContextWindow, percentage, outputBufferPercentage, promptTokenDetails }; - // Update pie chart progress - this.progressIndicator.setProgress(percentage); + // Pie chart shows actual usage + remaining reserve so the user can see + // how much of the context window is spoken for. + this.progressIndicator.setProgress(percentage + (outputBufferPercentage ?? 0)); - // Update color based on usage level + // Update percentage label and aria-label (clamp display to 100) + const roundedPercentage = Math.min(100, Math.round(percentage)); + this.percentageLabel.textContent = `${roundedPercentage}%`; + this.domNode.setAttribute('aria-label', localize('contextUsagePercentageLabel', "Context window usage: {0}%", roundedPercentage)); + + // Color based on total spoken-for percentage (usage + remaining reserve) + const effectivePercentage = percentage + (outputBufferPercentage ?? 0); this.domNode.classList.remove('warning', 'error'); - if (percentage >= 90) { + if (effectivePercentage >= 90) { this.domNode.classList.add('error'); - } else if (percentage >= 75) { + } else if (effectivePercentage >= 75) { this.domNode.classList.add('warning'); } } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 4974f0b91ce..ab722bf9ad4 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -540,6 +540,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { supportsChangingModes: true, dndContainer: parent, inputEditorMinLines: this.workbenchEnvironmentService.isSessionsWindow ? 2 : undefined, + isSessionsWindow: this.workbenchEnvironmentService.isSessionsWindow, }, { listForeground: SIDE_BAR_FOREGROUND, @@ -707,7 +708,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const model = ref?.object; if (model) { - await this.updateWidgetLockState(model.sessionResource); // Update widget lock state based on session type + await this.updateWidgetLockState(getChatSessionType(model.sessionResource)); // Update widget lock state based on session type // remember as model to restore in view state this.viewState.sessionResource = model.sessionResource; @@ -729,8 +730,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { return model; } - private async updateWidgetLockState(sessionResource: URI): Promise { - const sessionType = getChatSessionType(sessionResource); + private async updateWidgetLockState(sessionType: string): Promise { if (sessionType === localChatSessionType) { this._widget.unlockFromCodingAgent(); return; @@ -738,9 +738,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { let canResolve = false; try { - canResolve = await this.chatSessionsService.canResolveChatSession(sessionResource); + canResolve = await this.chatSessionsService.canResolveChatSession(sessionType); } catch (error) { - this.logService.warn(`Failed to resolve chat session '${sessionResource.toString()}' for locking`, error); + this.logService.warn(`Failed to resolve chat session type '${sessionType}' for locking`, error); } if (!canResolve) { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css index 41b7a7ad9d1..53344c162f3 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageDetails.css @@ -15,7 +15,8 @@ .chat-context-usage-details { display: flex; flex-direction: column; - padding: 4px 0; + margin-top: 4px; + margin-bottom: 4px; min-width: 200px; } @@ -23,97 +24,152 @@ outline: none; } -/* Using same structure as ChatUsageWidget quota items */ -.chat-context-usage-details .quota-item { +/* Section headers — matching ChatStatusDashboard */ +.chat-context-usage-details div.header { + display: flex; + align-items: center; + color: var(--vscode-descriptionForeground); margin-bottom: 4px; + font-weight: 600; } -.chat-context-usage-details .quota-item-header { +/* Quota indicator — matching ChatStatusDashboard */ +.chat-context-usage-details .quota-indicator .quota-label { display: flex; - align-items: center; justify-content: space-between; - margin-bottom: 2px; + gap: 20px; + margin-bottom: 3px; } -.chat-context-usage-details .quota-item-label { - color: var(--vscode-foreground); -} - -.chat-context-usage-details .quota-item-value { +.chat-context-usage-details .quota-indicator .quota-label .quota-value { color: var(--vscode-descriptionForeground); } -.chat-context-usage-details .token-row { - display: flex; - align-items: center; - gap: 4px; - margin-bottom: 2px; -} - -.chat-context-usage-details .token-count-label { - font-size: 12px; - color: var(--vscode-descriptionForeground); -} - -/* Progress bar - matching chat usage implementation */ -.chat-context-usage-details .quota-item .quota-bar { +.chat-context-usage-details .quota-indicator .quota-bar { width: 100%; height: 4px; background-color: var(--vscode-gauge-background); border-radius: 4px; border: 1px solid var(--vscode-gauge-border); - margin: 2px 0; + margin: 4px 0; + display: flex; } -.chat-context-usage-details .quota-item .quota-bar .quota-bit { +.chat-context-usage-details .quota-indicator .quota-bar .quota-bit { height: 100%; background-color: var(--vscode-gauge-foreground); border-radius: 4px; transition: width 0.3s ease; } -.chat-context-usage-details .quota-item.warning .quota-bar { +.chat-context-usage-details .quota-indicator .quota-bar .quota-bit.output-buffer { + background: repeating-linear-gradient( + -45deg, + var(--vscode-gauge-foreground), + var(--vscode-gauge-foreground) 2px, + transparent 2px, + transparent 4px + ); + border-radius: 0 4px 4px 0; +} + +.chat-context-usage-details .quota-indicator .quota-bar .quota-bit:not(.output-buffer):has(+ .quota-bit.output-buffer:not([style*="display: none"])) { + border-radius: 4px 0 0 4px; +} + +/* Output buffer legend */ +.chat-context-usage-details .quota-indicator .output-buffer-legend { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.chat-context-usage-details .quota-indicator .output-buffer-legend .output-buffer-swatch { + width: 12px; + height: 8px; + border-radius: 2px; + background: repeating-linear-gradient( + -45deg, + var(--vscode-gauge-foreground), + var(--vscode-gauge-foreground) 2px, + transparent 2px, + transparent 4px + ); + flex-shrink: 0; +} + +.chat-context-usage-details .quota-indicator.warning .quota-bar { background-color: var(--vscode-gauge-warningBackground); } -.chat-context-usage-details .quota-item.warning .quota-bar .quota-bit { +.chat-context-usage-details .quota-indicator.warning .quota-bar .quota-bit { background-color: var(--vscode-gauge-warningForeground); } -.chat-context-usage-details .quota-item.error .quota-bar { +.chat-context-usage-details .quota-indicator.warning .quota-bar .quota-bit.output-buffer { + background: repeating-linear-gradient( + -45deg, + var(--vscode-gauge-warningForeground), + var(--vscode-gauge-warningForeground) 2px, + transparent 2px, + transparent 4px + ); +} + +.chat-context-usage-details .quota-indicator.error .quota-bar { background-color: var(--vscode-gauge-errorBackground); } -.chat-context-usage-details .quota-item.error .quota-bar .quota-bit { +.chat-context-usage-details .quota-indicator.error .quota-bar .quota-bit { background-color: var(--vscode-gauge-errorForeground); } -.chat-context-usage-details .warning-message { - font-size: 12px; +.chat-context-usage-details .quota-indicator.error .quota-bar .quota-bit.output-buffer { + background: repeating-linear-gradient( + -45deg, + var(--vscode-gauge-errorForeground), + var(--vscode-gauge-errorForeground) 2px, + transparent 2px, + transparent 4px + ); +} + +/* Description / warning text — matching ChatStatusDashboard */ +.chat-context-usage-details div.description { + font-size: 11px; color: var(--vscode-descriptionForeground); - margin-bottom: 4px; + display: flex; + align-items: center; + gap: 3px; } /* Token details breakdown */ -.chat-context-usage-details .token-details-container { - margin-top: 4px; -} - .chat-context-usage-details .token-category { - margin-bottom: 4px; + margin-bottom: 6px; } .chat-context-usage-details .token-category-header { + display: flex; + align-items: center; + color: var(--vscode-descriptionForeground); + margin-top: 16px; + margin-bottom: 4px; font-weight: 600; - color: var(--vscode-foreground); - margin-bottom: 2px; +} + +.chat-context-usage-details .token-category:first-child .token-category-header { + margin-top: 8px; } .chat-context-usage-details .token-detail-item { display: flex; justify-content: space-between; align-items: center; - padding-left: 8px; + gap: 20px; + margin-bottom: 2px; } .chat-context-usage-details .token-detail-label { @@ -124,15 +180,9 @@ color: var(--vscode-descriptionForeground); } -.chat-context-usage-details .actions-section .separator { - border-top: 1px solid var(--vscode-editorHoverWidget-border); - margin: 4px 0; -} - -.chat-context-usage-details .actions-section .actions-header { - font-weight: 600; - color: var(--vscode-foreground); - margin-bottom: 4px; +/* Actions section */ +.chat-context-usage-details .actions-section { + margin-top: 8px; } .chat-context-usage-details .actions-section .button-bar-container { diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css index c3abb1332f0..e09c5e1aa23 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatContextUsageWidget.css @@ -6,9 +6,7 @@ .chat-context-usage-widget { display: flex; align-items: center; - justify-content: center; - height: 22px; - width: 22px; + gap: 4px; flex-shrink: 0; cursor: pointer; padding: 3px; @@ -55,7 +53,7 @@ .chat-context-usage-widget .progress-arc { fill: none; - stroke: var(--vscode-descriptionForeground); + stroke: var(--vscode-icon-foreground); stroke-width: 4; stroke-linecap: round; transform: rotate(-90deg); @@ -70,3 +68,20 @@ .chat-context-usage-widget.error .progress-arc { stroke: var(--vscode-editorError-foreground); } + +.chat-context-usage-widget .percentage-label { + font-size: 11px; + line-height: 1; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + max-width: 0; + opacity: 0; + overflow: hidden; + transition: max-width 0.1s ease-out, opacity 0.1s ease-out; +} + +.chat-context-usage-widget:hover .percentage-label, +.chat-context-usage-widget:focus .percentage-label { + max-width: 4em; + opacity: 1; +} diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index f57aca15300..83799c15a7b 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -130,6 +130,14 @@ padding: 0 6px 0 14px; } + /* Stacked: symmetric padding */ + &.sessions-control-orientation-stacked { + + .agent-sessions-empty-filter-message { + padding-left: 20px; + } + } + /* Right position: symmetric padding */ &.sessions-control-orientation-sidebyside.chat-view-position-right { diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index a85c46b7bfb..b249c635261 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -9,7 +9,7 @@ import { IsWebContext } from '../../../../../platform/contextkey/common/contextk import { RemoteNameContext } from '../../../../common/contextkeys.js'; import { ViewContainerLocation } from '../../../../common/views.js'; import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; -import { ChatAgentLocation, ChatModeKind } from '../constants.js'; +import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; export namespace ChatContextKeys { export const responseVote = new RawContextKey('chatSessionResponseVote', '', { type: 'string', description: localize('interactiveSessionResponseVote', "When the response has been voted up, is set to 'up'. When voted down, is set to 'down'. Otherwise an empty string.") }); @@ -23,7 +23,8 @@ export namespace ChatContextKeys { export const enum EditingRequestType { Sent = 's', - QueueOrSteer = 'qs', + Queue = 'q', + Steer = 'st', } export const editingRequestType = new RawContextKey('chatEditingSentRequest', undefined, { type: 'string', description: localize('chatEditingSentRequest', "The type of the current editing request.") }); @@ -46,6 +47,7 @@ export namespace ChatContextKeys { export const multipleChatTips = new RawContextKey('multipleChatTips', false, { type: 'boolean', description: localize('multipleChatTips', "True when there are multiple chat tips available.") }); export const inChatTerminalToolOutput = new RawContextKey('inChatTerminalToolOutput', false, { type: 'boolean', description: localize('inChatTerminalToolOutput', "True when focus is in the chat terminal output region.") }); export const chatModeKind = new RawContextKey('chatAgentKind', ChatModeKind.Ask, { type: 'string', description: localize('agentKind', "The 'kind' of the current agent.") }); + export const chatPermissionLevel = new RawContextKey('chatPermissionLevel', ChatPermissionLevel.Default, { type: 'string', description: localize('chatPermissionLevel', "The current permission level for tool auto-approval.") }); export const chatModeName = new RawContextKey('chatModeName', '', { type: 'string', description: localize('chatModeName', "The name of the current chat mode (e.g. 'Plan' for custom modes).") }); export const chatModelId = new RawContextKey('chatModelId', '', { type: 'string', description: localize('chatModelId', "The short id of the currently selected chat model (for example 'gpt-4.1').") }); @@ -56,6 +58,7 @@ export namespace ChatContextKeys { * True when the chat widget is locked to the coding agent session. */ export const lockedToCodingAgent = new RawContextKey('lockedToCodingAgent', false, { type: 'boolean', description: localize('lockedToCodingAgent', "True when the chat widget is locked to the coding agent session.") }); + export const lockedCodingAgentId = new RawContextKey('lockedCodingAgentId', '', { type: 'string', description: localize('lockedCodingAgentId', "The agent ID when the chat widget is locked to a coding agent session.") }); /** * True when the chat session has a customAgentTarget defined in its contribution, * which means the mode picker should be shown with filtered custom agents. @@ -89,6 +92,7 @@ export namespace ChatContextKeys { export const chatSessionIsEmpty = new RawContextKey('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") }); export const hasPendingRequests = new RawContextKey('chatHasPendingRequests', false, { type: 'boolean', description: localize('chatHasPendingRequests', "True when there are pending requests in the queue.") }); export const chatSessionHasDebugData = new RawContextKey('chatSessionHasDebugData', false, { type: 'boolean', description: localize('chatSessionHasDebugData', "True when the current chat session has debug log data.") }); + export const chatSessionHasAttachedDebugData = new RawContextKey('chatSessionHasAttachedDebugData', false, { type: 'boolean', description: localize('chatSessionHasAttachedDebugData', "True when a debug events snapshot has been attached in the current chat session.") }); export const remoteJobCreating = new RawContextKey('chatRemoteJobCreating', false, { type: 'boolean', description: localize('chatRemoteJobCreating', "True when a remote coding agent job is being created.") }); export const hasRemoteCodingAgent = new RawContextKey('hasRemoteCodingAgent', false, localize('hasRemoteCodingAgent', "Whether any remote coding agent is available")); diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 26af14f6561..2b6c01066fe 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqualOrParent } from '../../../../base/common/resources.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; -import { PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { IChatPromptSlashCommand, PromptsStorage } from './promptSyntax/service/promptsService.js'; export const IAICustomizationWorkspaceService = createDecorator('aiCustomizationWorkspaceService'); @@ -22,6 +23,7 @@ export const AICustomizationManagementSection = { Prompts: 'prompts', Hooks: 'hooks', McpServers: 'mcpServers', + Plugins: 'plugins', Models: 'models', } as const; @@ -33,9 +35,9 @@ export type AICustomizationManagementSection = typeof AICustomizationManagementS */ export interface IStorageSourceFilter { /** - * Which storage groups to display (e.g. workspace, user, extension). + * Which storage groups to display (e.g. workspace, user, extension, builtin). */ - readonly sources: readonly PromptsStorage[]; + readonly sources: readonly string[]; /** * If set, only user files under these roots are shown (allowlist). @@ -49,7 +51,7 @@ export interface IStorageSourceFilter { * Removes items whose storage is not in the filter's source list, * and for user-storage items, removes those not under an allowed root. */ -export function applyStorageSourceFilter(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { +export function applyStorageSourceFilter(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { const sourceSet = new Set(filter.sources); return items.filter(item => { if (!sourceSet.has(item.storage)) { @@ -99,6 +101,15 @@ export interface IAICustomizationWorkspaceService { */ commitFiles(projectRoot: URI, fileUris: URI[]): Promise; + /** + * Commits the deletion of resources that have already been removed from disk. + * The URIs may point to individual files or to directories (for example, when + * deleting a skill, the entire customization folder is removed). Implementations + * should ensure that directory deletions are handled recursively as needed. + * In sessions this stages and commits the removal in the relevant repositories. + */ + deleteFiles(projectRoot: URI, fileUris: URI[]): Promise; + /** * Launches the AI-guided creation flow for the given customization type. */ @@ -121,4 +132,11 @@ export interface IAICustomizationWorkspaceService { * session-derived (or workspace-derived) root. */ clearOverrideProjectRoot(): void; + + /** + * Returns prompt/skill slash commands filtered through the workspace + * service's storage source policy, ensuring the results match the + * customizations visible in the AI Customization views. + */ + getFilteredPromptSlashCommands(token: CancellationToken): Promise; } diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index 5655cc695fd..f03eea0f2a1 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -305,16 +305,26 @@ export interface IAgentFeedbackVariableEntry extends IBaseChatRequestVariableEnt readonly text: string; readonly resourceUri: URI; readonly range: IRange; + readonly codeSelection?: string; }>; } +export interface IChatRequestDebugEventsVariableEntry extends IBaseChatRequestVariableEntry { + readonly kind: 'debugEvents'; + /** Timestamp when the debug events were snapshotted. */ + readonly snapshotTime: number; + /** The session resource these debug events belong to. */ + readonly sessionResource: URI; +} + export type IChatRequestVariableEntry = IGenericChatRequestVariableEntry | IChatRequestImplicitVariableEntry | IChatRequestPasteVariableEntry | ISymbolVariableEntry | ICommandResultVariableEntry | IDiagnosticVariableEntry | IImageVariableEntry | IChatRequestToolEntry | IChatRequestToolSetEntry | IChatRequestDirectoryEntry | IChatRequestFileEntry | INotebookOutputVariableEntry | IElementVariableEntry | IPromptFileVariableEntry | IPromptTextVariableEntry | ISCMHistoryItemVariableEntry | ISCMHistoryItemChangeVariableEntry | ISCMHistoryItemChangeRangeVariableEntry | ITerminalVariableEntry - | IChatRequestStringVariableEntry | IChatRequestWorkspaceVariableEntry | IDebugVariableEntry | IAgentFeedbackVariableEntry; + | IChatRequestStringVariableEntry | IChatRequestWorkspaceVariableEntry | IDebugVariableEntry | IAgentFeedbackVariableEntry + | IChatRequestDebugEventsVariableEntry; export namespace IChatRequestVariableEntry { @@ -459,17 +469,17 @@ export function isStringImplicitContextValue(value: unknown): value is StringCha } export enum PromptFileVariableKind { - Instruction = 'vscode.prompt.instructions.root', - InstructionReference = `vscode.prompt.instructions`, - PromptFile = 'vscode.prompt.file' + Instruction = 'vscode.instructions.file.root', + InstructionReference = `vscode.instructions.file.reference`, + PromptFile = 'vscode.prompt.file', } /** * Utility to convert a {@link uri} to a chat variable entry. * The `id` of the chat variable can be one of the following: * - * - `vscode.prompt.instructions__`: for all non-root prompt instructions references - * - `vscode.prompt.instructions.root__`: for *root* prompt instructions references + * - `vscode.instructions.file.reference__`: for all non-root prompt instructions references + * - `vscode.instructions.file.root__`: for *root* prompt instructions references * - `vscode.prompt.file__`: for prompt file references * * @param uri A resource URI that points to a prompt instructions file. @@ -490,13 +500,17 @@ export function toPromptFileVariableEntry(uri: URI, kind: PromptFileVariableKind }; } +enum PromptTextVariableKind { + CustomizationsIndex = 'vscode.customizations.index', +} + export function toPromptTextVariableEntry(content: string, automaticallyAdded = false, toolReferences?: ChatRequestToolReferenceEntry[]): IPromptTextVariableEntry { return { - id: `vscode.prompt.instructions.text`, - name: `prompt:instructionsList`, + id: PromptTextVariableKind.CustomizationsIndex, + name: `prompt:customizationsIndex`, value: content, kind: 'promptText', - modelDescription: 'Prompt instructions list', + modelDescription: 'Chat customizations index', automaticallyAdded, toolReferences }; diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index a50c09b18a1..d97213d3f3e 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -125,6 +125,11 @@ export interface IChatDebugService extends IDisposable { */ readonly onDidAddEvent: Event; + /** + * Fired when provider events are cleared for a session (before re-invoking providers). + */ + readonly onDidClearProviderEvents: Event; + /** * Log a generic event to the debug service. */ @@ -167,6 +172,11 @@ export interface IChatDebugService extends IDisposable { */ registerProvider(provider: IChatDebugLogProvider): IDisposable; + /** + * Check whether providers have already been invoked for a given session. + */ + hasInvokedProviders(sessionResource: URI): boolean; + /** * Invoke all registered providers for a given session resource. * Called when the Debug View is opened to fetch events from extensions. @@ -185,6 +195,49 @@ export interface IChatDebugService extends IDisposable { * Delegates to the registered provider's resolveChatDebugLogEvent. */ resolveEvent(eventId: string): Promise; + + /** + /** + * Export the debug log for a session via the registered provider. + */ + exportLog(sessionResource: URI): Promise; + + /** + * Import a previously exported debug log via the registered provider. + * Returns the session URI for the imported data. + */ + importLog(data: Uint8Array): Promise; + + /** + * Returns true if the event was logged by VS Code core + * (not sourced from an external provider). + */ + isCoreEvent(event: IChatDebugEvent): boolean; + + /** + * Store a human-readable title for an imported session. + */ + setImportedSessionTitle(sessionResource: URI, title: string): void; + + /** + * Get the stored title for an imported session, if available. + */ + getImportedSessionTitle(sessionResource: URI): string | undefined; + + /** + * Fired when debug data is attached to a session. + */ + readonly onDidAttachDebugData: Event; + + /** + * Mark a session as having debug data attached. + */ + markDebugDataAttached(sessionResource: URI): void; + + /** + * Check whether a session has had debug data attached. + */ + hasAttachedDebugData(sessionResource: URI): boolean; } /** @@ -220,9 +273,6 @@ export interface IChatDebugFileEntry { export interface IChatDebugSourceFolderEntry { readonly uri: URI; readonly storage: string; - readonly exists: boolean; - readonly fileCount: number; - readonly errorMessage?: string; } /** @@ -292,4 +342,6 @@ export type IChatDebugResolvedEventContent = IChatDebugEventTextContent | IChatD export interface IChatDebugLogProvider { provideChatDebugLog(sessionResource: URI, token: CancellationToken): Promise; resolveChatDebugLogEvent?(eventId: string, token: CancellationToken): Promise; + provideChatDebugLogExport?(sessionResource: URI, token: CancellationToken): Promise; + resolveChatDebugLogImport?(data: Uint8Array, token: CancellationToken): Promise; } diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index cce684e6d5b..c80186d968d 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -25,12 +25,26 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic private readonly _onDidAddEvent = this._register(new Emitter()); readonly onDidAddEvent: Event = this._onDidAddEvent.event; + private readonly _onDidClearProviderEvents = this._register(new Emitter()); + readonly onDidClearProviderEvents: Event = this._onDidClearProviderEvents.event; + + private readonly _onDidAttachDebugData = this._register(new Emitter()); + readonly onDidAttachDebugData: Event = this._onDidAttachDebugData.event; + + private readonly _debugDataAttachedSessions = new ResourceMap(); + private readonly _providers = new Set(); private readonly _invocationCts = new ResourceMap(); /** Events that were returned by providers (not internally logged). */ private readonly _providerEvents = new WeakSet(); + /** Session URIs created via import, allowed through the invokeProviders guard. */ + private readonly _importedSessions = new ResourceMap(); + + /** Human-readable titles for imported sessions. */ + private readonly _importedSessionTitles = new ResourceMap(); + activeSessionResource: URI | undefined; log(sessionResource: URI, name: string, details?: string, level: ChatDebugLogLevel = ChatDebugLogLevel.Info, options?: { id?: string; category?: string; parentEventId?: string }): void { @@ -102,6 +116,7 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic this._buffer.fill(undefined); this._head = 0; this._size = 0; + this._debugDataAttachedSessions.clear(); } registerProvider(provider: IChatDebugLogProvider): IDisposable { @@ -121,11 +136,15 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic }); } + hasInvokedProviders(sessionResource: URI): boolean { + return this._invocationCts.has(sessionResource); + } + async invokeProviders(sessionResource: URI): Promise { - if (!LocalChatSessionUri.isLocalSession(sessionResource)) { + + if (!LocalChatSessionUri.isLocalSession(sessionResource) && !this._importedSessions.has(sessionResource)) { return; } - // Cancel only the previous invocation for THIS session, not others. // Each session has its own pipeline so events from multiple sessions // can be streamed concurrently. @@ -180,6 +199,7 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic cts.dispose(); this._invocationCts.delete(sessionResource); } + this._debugDataAttachedSessions.delete(sessionResource); } private _clearProviderEvents(sessionResource: URI): void { @@ -203,6 +223,18 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic this._buffer[(this._head + i) % ChatDebugServiceImpl.MAX_EVENTS] = undefined; } this._size = write; + this._onDidClearProviderEvents.fire(sessionResource); + } + + markDebugDataAttached(sessionResource: URI): void { + if (!this._debugDataAttachedSessions.has(sessionResource)) { + this._debugDataAttachedSessions.set(sessionResource, true); + this._onDidAttachDebugData.fire(sessionResource); + } + } + + hasAttachedDebugData(sessionResource: URI): boolean { + return this._debugDataAttachedSessions.has(sessionResource); } async resolveEvent(eventId: string): Promise { @@ -221,6 +253,51 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic return undefined; } + isCoreEvent(event: IChatDebugEvent): boolean { + return !this._providerEvents.has(event); + } + + setImportedSessionTitle(sessionResource: URI, title: string): void { + this._importedSessionTitles.set(sessionResource, title); + } + + getImportedSessionTitle(sessionResource: URI): string | undefined { + return this._importedSessionTitles.get(sessionResource); + } + + async exportLog(sessionResource: URI): Promise { + for (const provider of this._providers) { + if (provider.provideChatDebugLogExport) { + try { + const data = await provider.provideChatDebugLogExport(sessionResource, CancellationToken.None); + if (data !== undefined) { + return data; + } + } catch (err) { + onUnexpectedError(err); + } + } + } + return undefined; + } + + async importLog(data: Uint8Array): Promise { + for (const provider of this._providers) { + if (provider.resolveChatDebugLogImport) { + try { + const sessionUri = await provider.resolveChatDebugLogImport(data, CancellationToken.None); + if (sessionUri !== undefined) { + this._importedSessions.set(sessionUri, true); + return sessionUri; + } + } catch (err) { + onUnexpectedError(err); + } + } + } + return undefined; + } + override dispose(): void { for (const cts of this._invocationCts.values()) { cts.cancel(); diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index f6501831c4c..c13b64519ee 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -19,12 +19,14 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IChatAgentService } from './participants/chatAgents.js'; import { ChatContextKeys } from './actions/chatContextKeys.js'; import { ChatConfiguration, ChatModeKind } from './constants.js'; -import { IHandOff, isTarget } from './promptSyntax/promptFileParser.js'; -import { ExtensionAgentSourceType, IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, PromptsStorage, Target } from './promptSyntax/service/promptsService.js'; +import { IHandOff } from './promptSyntax/promptFileParser.js'; +import { ExtensionAgentSourceType, IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { Target } from './promptSyntax/promptTypes.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { hash } from '../../../../base/common/hash.js'; import { isString } from '../../../../base/common/types.js'; +import { isTarget } from './promptSyntax/languageProviders/promptFileAttributes.js'; export const IChatModeService = createDecorator('chatModeService'); export interface IChatModeService { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 868b0be875f..a083e6212a5 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -8,13 +8,13 @@ import { DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; -import { DisposableStore, IReference } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, IReference } from '../../../../../base/common/lifecycle.js'; import { autorun, autorunSelfDisposable, IObservable, IReader } from '../../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { IRange, Range } from '../../../../../editor/common/core/range.js'; -import { HookTypeValue } from '../promptSyntax/hookSchema.js'; +import { HookTypeValue } from '../promptSyntax/hookTypes.js'; import { ISelection } from '../../../../../editor/common/core/selection.js'; import { Command, Location, TextEdit } from '../../../../../editor/common/languages.js'; import { FileType } from '../../../../../platform/files/common/files.js'; @@ -151,6 +151,7 @@ export interface IChatUsagePromptTokenDetail { export interface IChatUsage { promptTokens: number; completionTokens: number; + outputBuffer?: number; promptTokenDetails?: readonly IChatUsagePromptTokenDetail[]; kind: 'usage'; } @@ -349,6 +350,18 @@ export interface IChatConfirmation { kind: 'confirmation'; } +/** + * Validation rules for a question in a question carousel. + */ +export interface IChatQuestionValidation { + minLength?: number; + maxLength?: number; + format?: 'email' | 'uri' | 'date' | 'date-time'; + minimum?: number; + maximum?: number; + isInteger?: boolean; +} + /** * Represents an individual question in a question carousel. */ @@ -357,11 +370,32 @@ export interface IChatQuestion { type: 'text' | 'singleSelect' | 'multiSelect'; title: string; message?: string | IMarkdownString; - options?: { id: string; label: string; value: unknown }[]; + description?: string; + options?: { id: string; label: string; value: string }[]; defaultValue?: string | string[]; allowFreeformInput?: boolean; + required?: boolean; + validation?: IChatQuestionValidation; } +/** Answer shape for a single-select question. */ +export interface IChatSingleSelectAnswer { + selectedValue?: string; + freeformValue?: string; +} + +/** Answer shape for a multi-select question. */ +export interface IChatMultiSelectAnswer { + selectedValues: string[]; + freeformValue?: string; +} + +/** Union of all possible answer values in a question carousel. */ +export type IChatQuestionAnswerValue = string | IChatSingleSelectAnswer | IChatMultiSelectAnswer; + +/** Record mapping question IDs to their typed answer values. */ +export type IChatQuestionAnswers = Record; + /** * A carousel for presenting multiple questions inline in the chat response. * Users can navigate between questions and submit their answers. @@ -372,9 +406,13 @@ export interface IChatQuestionCarousel { /** Unique identifier for resolving the carousel answers back to the extension */ resolveId?: string; /** Storage for collected answers when user submits */ - data?: Record; + data?: IChatQuestionAnswers; /** Whether the carousel has been submitted/skipped */ isUsed?: boolean; + /** Top-level message shown above the questions (e.g. from MCP elicitation message) */ + message?: string | IMarkdownString; + /** Source attribution (e.g. MCP server) */ + source?: ToolDataSource; kind: 'questionCarousel'; } @@ -557,7 +595,7 @@ export type ConfirmedReason = export interface IChatToolInvocation { readonly presentation: IPreparedToolInvocation['presentation']; - readonly toolSpecificData?: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatToolResourcesInvocationData; + readonly toolSpecificData?: IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatToolResourcesInvocationData | IChatModifiedFilesConfirmationData; readonly originMessage: string | IMarkdownString | undefined; readonly invocationMessage: string | IMarkdownString; readonly pastTenseMessage: string | IMarkdownString | undefined; @@ -817,7 +855,7 @@ export interface IToolResultOutputDetailsSerialized { */ export interface IChatToolInvocationSerialized { presentation: IPreparedToolInvocation['presentation']; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatToolResourcesInvocationData; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatToolResourcesInvocationData | IChatModifiedFilesConfirmationData; invocationMessage: string | IMarkdownString; originMessage: string | IMarkdownString | undefined; pastTenseMessage: string | IMarkdownString | undefined; @@ -873,8 +911,9 @@ export interface IChatExternalToolInvocationUpdate { errorMessage?: string; invocationMessage?: string | IMarkdownString; pastTenseMessage?: string | IMarkdownString; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatModifiedFilesConfirmationData; subagentInvocationId?: string; + resultDetails?: IToolResultInputOutputDetails; } export interface IChatTodoListContent { @@ -897,6 +936,19 @@ export interface IChatToolResourcesInvocationData { readonly values: Array; } +export interface IChatModifiedFilesConfirmationData { + readonly kind: 'modifiedFilesConfirmation'; + readonly options: readonly string[]; + readonly modifiedFiles: readonly { + readonly uri: UriComponents; + readonly originalUri?: UriComponents; + readonly insertions?: number; + readonly deletions?: number; + readonly title?: string; + readonly description?: string; + }[]; +} + export interface IChatMcpServersStarting { readonly kind: 'mcpServersStarting'; readonly state?: IObservable; // not hydrated when serialized @@ -1409,7 +1461,7 @@ export interface IChatService { resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions): Promise; adoptRequest(sessionResource: URI, request: IChatRequestModel): Promise; removeRequest(sessionResource: URI, requestId: string): Promise; - cancelCurrentRequestForSession(sessionResource: URI, source?: string): void; + cancelCurrentRequestForSession(sessionResource: URI, source?: string): Promise; /** * Sets yieldRequested on the active request for the given session. */ @@ -1444,8 +1496,8 @@ export interface IChatService { readonly onDidPerformUserAction: Event; notifyUserAction(event: IChatUserActionEvent): void; - readonly onDidReceiveQuestionCarouselAnswer: Event<{ requestId: string; resolveId: string; answers: Record | undefined }>; - notifyQuestionCarouselAnswer(requestId: string, resolveId: string, answers: Record | undefined): void; + readonly onDidReceiveQuestionCarouselAnswer: Event<{ requestId: string; resolveId: string; answers: IChatQuestionAnswers | undefined }>; + notifyQuestionCarouselAnswer(requestId: string, resolveId: string, answers: IChatQuestionAnswers | undefined): void; readonly onDidDisposeSession: Event<{ readonly sessionResource: URI[]; readonly reason: 'cleared' }>; @@ -1455,6 +1507,11 @@ export interface IChatService { readonly requestInProgressObs: IObservable; + /** + * @deprecated + */ + registerChatModelChangeListeners(chatSessionType: string, onChange: () => void): IDisposable; + /** * For tests only! */ @@ -1467,9 +1524,7 @@ export interface IChatService { } export interface IChatSessionContext { - readonly chatSessionType: string; readonly chatSessionResource: URI; - readonly isUntitled: boolean; readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | { id: string; name: string } }>; } @@ -1489,6 +1544,7 @@ export type ChatStopCancellationNoopEvent = { pendingRequests: number; sessionScheme?: string; lastRequestId?: string; + chatSessionId?: string; }; export type ChatStopCancellationNoopClassification = { @@ -1498,6 +1554,7 @@ export type ChatStopCancellationNoopClassification = { pendingRequests: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The number of queued pending requests at no-op time when known.'; isMeasurement: true }; sessionScheme?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The URI scheme of the session resource (e.g. vscodeLocalChatSession vs remote).' }; lastRequestId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The ID of the last request in the session, for correlating with tool invocations.' }; + chatSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat session ID.' }; owner: 'roblourens'; comment: 'Tracks possible no-op stop cancellation paths.'; }; @@ -1507,11 +1564,15 @@ export const ChatPendingRequestChangeEventName = 'chat.pendingRequestChange'; export type ChatPendingRequestChangeEvent = { action: 'add' | 'remove' | 'notCancelable'; source: string; + requestId?: string; + chatSessionId?: string; }; export type ChatPendingRequestChangeClassification = { action: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether a pending request was added or removed.' }; source: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The method that triggered the pending request change.' }; + requestId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The request ID associated with the pending request change.' }; + chatSessionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat session ID.' }; owner: 'roblourens'; comment: 'Tracks pending request lifecycle changes in the chat service.'; }; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index c2393a4b9cd..2d554ce0f8e 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -3,17 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { DeferredPromise } from '../../../../../base/common/async.js'; +import { DeferredPromise, raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { BugIndicatingError, ErrorNoTelemetry } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; -import { Disposable, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { revive } from '../../../../../base/common/marshalling.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { autorun, derived, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, autorunIterableDelta, derived, IObservable, ISettableObservable, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { isDefined } from '../../../../../base/common/types.js'; @@ -40,20 +40,21 @@ import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, ICha import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../requestParser/chatRequestParser.js'; -import { ChatMcpServersStarting, ChatPendingRequestChangeClassification, ChatPendingRequestChangeEvent, ChatPendingRequestChangeEventName, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; +import { ChatMcpServersStarting, ChatPendingRequestChangeClassification, ChatPendingRequestChangeEvent, ChatPendingRequestChangeEventName, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, ChatSendResultSent, ChatStopCancellationNoopClassification, ChatStopCancellationNoopEvent, ChatStopCancellationNoopEventName, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatQuestionAnswers, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js'; import { IChatSessionsService } from '../chatSessionsService.js'; import { ChatSessionStore, IChatSessionEntryMetadata } from '../model/chatSessionStore.js'; import { IChatSlashCommandService } from '../participants/chatSlashCommands.js'; import { IChatTransferService } from '../model/chatTransferService.js'; -import { LocalChatSessionUri } from '../model/chatUri.js'; +import { chatSessionResourceToId, LocalChatSessionUri } from '../model/chatUri.js'; import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../languageModels.js'; import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js'; import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; -import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; +import { ChatRequestHooks, mergeHooks } from '../promptSyntax/hookSchema.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; const serializedChatKey = 'interactive.sessions'; @@ -67,6 +68,7 @@ class CancellableRequest implements IDisposable { constructor( public readonly cancellationTokenSource: CancellationTokenSource, public requestId: string | undefined, + public readonly responseCompletePromise: Promise | undefined, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService ) { } @@ -115,7 +117,7 @@ export class ChatService extends Disposable implements IChatService { private readonly _onDidPerformUserAction = this._register(new Emitter()); public readonly onDidPerformUserAction: Event = this._onDidPerformUserAction.event; - private readonly _onDidReceiveQuestionCarouselAnswer = this._register(new Emitter<{ requestId: string; resolveId: string; answers: Record | undefined }>()); + private readonly _onDidReceiveQuestionCarouselAnswer = this._register(new Emitter<{ requestId: string; resolveId: string; answers: IChatQuestionAnswers | undefined }>()); public readonly onDidReceiveQuestionCarouselAnswer = this._onDidReceiveQuestionCarouselAnswer.event; private readonly _onDidDisposeSession = this._register(new Emitter<{ readonly sessionResource: URI[]; reason: 'cleared' }>()); @@ -270,7 +272,7 @@ export class ChatService extends Disposable implements IChatService { } } - notifyQuestionCarouselAnswer(requestId: string, resolveId: string, answers: Record | undefined): void { + notifyQuestionCarouselAnswer(requestId: string, resolveId: string, answers: IChatQuestionAnswers | undefined): void { this._onDidReceiveQuestionCarouselAnswer.fire({ requestId, resolveId, answers }); } @@ -579,7 +581,7 @@ export class ChatService extends Disposable implements IChatService { } private async loadRemoteSession(sessionResource: URI, location: ChatAgentLocation, token: CancellationToken): Promise { - await this.chatSessionService.canResolveChatSession(sessionResource); + await this.chatSessionService.canResolveChatSession(sessionResource.scheme); // Check if session already exists { @@ -614,8 +616,6 @@ export class ChatService extends Disposable implements IChatService { modelRef.object.setContributedChatSession({ chatSessionResource: sessionResource, - chatSessionType, - isUntitled: sessionResource.path.startsWith('/untitled-') //TODO(jospicer) }); if (providedSession.title) { @@ -679,9 +679,9 @@ export class ChatService extends Disposable implements IChatService { } if (providedSession.progressObs && lastRequest && providedSession.interruptActiveResponseCallback) { - const initialCancellationRequest = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined); + const initialCancellationRequest = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined, undefined); this._pendingRequests.set(model.sessionResource, initialCancellationRequest); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession' }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession', chatSessionId: chatSessionResourceToId(model.sessionResource) }); const cancellationListener = disposables.add(new MutableDisposable()); const createCancellationListener = (token: CancellationToken) => { @@ -689,9 +689,9 @@ export class ChatService extends Disposable implements IChatService { providedSession.interruptActiveResponseCallback?.().then(userConfirmedInterruption => { if (!userConfirmedInterruption) { // User cancelled the interruption - const newCancellationRequest = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined); + const newCancellationRequest = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined, undefined); this._pendingRequests.set(model.sessionResource, newCancellationRequest); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession' }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession', chatSessionId: chatSessionResourceToId(model.sessionResource) }); cancellationListener.value = createCancellationListener(newCancellationRequest.cancellationTokenSource.token); } }); @@ -721,7 +721,7 @@ export class ChatService extends Disposable implements IChatService { } })); } else { - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'notCancelable', source: 'remoteSession' }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'notCancelable', source: 'remoteSession', chatSessionId: chatSessionResourceToId(model.sessionResource) }); if (lastRequest && model.editingSession) { // wait for timeline to load so that a 'changes' part is added when the response completes await chatEditingSessionIsReady(model.editingSession); @@ -947,7 +947,7 @@ export class ChatService extends Disposable implements IChatService { let detectedCommand: IChatAgentCommand | undefined; // Collect hooks from hook .json files - let collectedHooks: IChatRequestHooks | undefined; + let collectedHooks: ChatRequestHooks | undefined; let hasDisabledClaudeHooks = false; try { const hooksInfo = await this.promptsService.getHooks(token, model.sessionResource); @@ -959,6 +959,20 @@ export class ChatService extends Disposable implements IChatService { this.logService.warn('[ChatService] Failed to collect hooks:', error); } + // Merge hooks from the selected custom agent's frontmatter (if any) + const agentName = options?.modeInfo?.modeInstructions?.name; + if (agentName) { + try { + const agents = await this.promptsService.getCustomAgents(token, model.sessionResource); + const customAgent = agents.find(a => a.name === agentName); + if (customAgent?.hooks) { + collectedHooks = mergeHooks(collectedHooks, customAgent.hooks); + } + } catch (error) { + this.logService.warn('[ChatService] Failed to collect agent hooks:', error); + } + } + const stopWatch = new StopWatch(false); store.add(token.onCancellationRequested(() => { this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`); @@ -1024,6 +1038,7 @@ export class ChatService extends Disposable implements IChatService { userSelectedModelId: options?.userSelectedModelId, userSelectedTools: options?.userSelectedTools?.get(), modeInstructions: options?.modeInfo?.modeInstructions, + permissionLevel: options?.modeInfo?.permissionLevel, editedFileEvents: request.editedFileEvents, hooks: collectedHooks, hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr.length > 0), @@ -1093,6 +1108,9 @@ export class ChatService extends Disposable implements IChatService { } })); pendingRequest.requestId ??= requestProps.requestId; + if (pendingRequest.requestId) { + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'sendRequestId', requestId: pendingRequest.requestId, chatSessionId: chatSessionResourceToId(sessionResource) }); + } } completeResponseCreated(); @@ -1135,7 +1153,7 @@ export class ChatService extends Disposable implements IChatService { const message = parsedRequest.text; const commandResult = await this.chatSlashCommandService.executeCommand(commandPart.slashCommand.command, message.substring(commandPart.slashCommand.command.length + 1).trimStart(), new Progress(p => { progressCallback([p]); - }), history, location, model.sessionResource, token); + }), history, location, model.sessionResource, token, options); agentOrCommandFollowups = Promise.resolve(commandResult?.followUp); rawResult = {}; @@ -1146,6 +1164,9 @@ export class ChatService extends Disposable implements IChatService { if ((token.isCancellationRequested && !rawResult)) { return; } else if (!request) { + // Silent slash command completed successfully — allow queued + // requests to proceed. + shouldProcessPending = !token.isCancellationRequested; return; } else { if (!rawResult) { @@ -1177,6 +1198,7 @@ export class ChatService extends Disposable implements IChatService { shouldProcessPending = !rawResult.errorDetails && !token.isCancellationRequested; request.response?.complete(); + if (agentOrCommandFollowups) { agentOrCommandFollowups.then(followups => { model.setFollowups(request!, followups); @@ -1208,13 +1230,13 @@ export class ChatService extends Disposable implements IChatService { let shouldProcessPending = false; const rawResponsePromise = sendRequestInternal(); // Note- requestId is not known at this point, assigned later - const cancellableRequest = this.instantiationService.createInstance(CancellableRequest, source, undefined); + const cancellableRequest = this.instantiationService.createInstance(CancellableRequest, source, undefined, rawResponsePromise); this._pendingRequests.set(model.sessionResource, cancellableRequest); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'sendRequest' }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'sendRequest', chatSessionId: chatSessionResourceToId(model.sessionResource) }); rawResponsePromise.finally(() => { if (this._pendingRequests.get(model.sessionResource) === cancellableRequest) { this._pendingRequests.deleteAndDispose(model.sessionResource); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'sendRequestComplete' }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'sendRequestComplete', requestId: cancellableRequest.requestId, chatSessionId: chatSessionResourceToId(model.sessionResource) }); } // Process the next pending request from the queue if any if (shouldProcessPending) { @@ -1241,49 +1263,90 @@ export class ChatService extends Disposable implements IChatService { /** * Process the next pending request from the model's queue, if any. * Called after a request completes to continue processing queued requests. + * Multiple consecutive steering requests are combined into a single request. */ private processNextPendingRequest(model: ChatModel): void { - const pendingRequest = model.dequeuePendingRequest(); - if (!pendingRequest) { + // Dequeue all consecutive steering requests and combine them into one + const steeringRequests = model.dequeueAllSteeringRequests(); + + // Then dequeue a single non-steering request if no steering was found + const nextQueued = steeringRequests.length === 0 ? model.dequeuePendingRequest() : undefined; + + const allRequests = steeringRequests.length > 0 ? steeringRequests : (nextQueued ? [nextQueued] : []); + if (allRequests.length === 0) { return; } - this.trace('processNextPendingRequest', `Processing queued request for session ${model.sessionResource}`); + this.trace('processNextPendingRequest', `Processing ${allRequests.length} queued request(s) for session ${model.sessionResource}`); - const deferred = this._queuedRequestDeferreds.get(pendingRequest.request.id); - this._queuedRequestDeferreds.delete(pendingRequest.request.id); + // Collect and remove all deferreds + const deferreds: DeferredPromise[] = []; + for (const req of allRequests) { + const deferred = this._queuedRequestDeferreds.get(req.request.id); + this._queuedRequestDeferreds.delete(req.request.id); + if (deferred) { + deferreds.push(deferred); + } + } + // Build send options from the first request, combining attachments from all + const firstRequest = allRequests[0]; const sendOptions: IChatSendRequestOptions = { - ...pendingRequest.sendOptions, - // Ensure attachedContext is preserved after deserialization, where sendOptions - // loses attachedContext but the request model retains it in variableData. - attachedContext: pendingRequest.request.variableData.variables.slice(), + ...firstRequest.sendOptions, + attachedContext: allRequests.flatMap(req => req.request.variableData.variables.slice()), }; + const location = sendOptions.location ?? sendOptions.locationData?.type ?? model.initialLocation; const defaultAgent = this.chatAgentService.getDefaultAgent(location, sendOptions.modeInfo?.kind); if (!defaultAgent) { this.logService.warn('processNextPendingRequest', `No default agent for location ${location}`); - deferred?.complete({ kind: 'rejected', reason: 'No default agent available' }); + for (const deferred of deferreds) { + deferred.complete({ kind: 'rejected', reason: 'No default agent available' }); + } + return; + } + + // For multiple steering requests, combine texts and re-parse; otherwise use as-is + let parsedRequest: IParsedChatRequest; + try { + if (allRequests.length > 1) { + const combinedText = allRequests.map(req => req.request.message.text).join('\n\n'); + // message.text already includes agent/slash-command prefixes from the + // original parse, so clear them to avoid double-prefixing. + parsedRequest = this.parseChatRequest(model.sessionResource, combinedText, location, { + ...sendOptions, + agentId: undefined, + slashCommand: undefined, + }); + } else { + parsedRequest = firstRequest.request.message; + } + } catch (err) { + this.logService.error('processNextPendingRequest: failed to parse combined chat request', err); + const reason = toErrorMessage(err); + for (const deferred of deferreds) { + deferred.complete({ kind: 'rejected', reason }); + } return; } - const parsedRequest = pendingRequest.request.message; const silentAgent = sendOptions.agentIdSilent ? this.chatAgentService.getAgent(sendOptions.agentIdSilent) : undefined; const agent = silentAgent ?? parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? defaultAgent; const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); - // Send the queued request - this will add it to _pendingRequests and handle it normally - const responseState = this._sendRequestAsync(model, model.sessionResource, parsedRequest, pendingRequest.request.attempt, !sendOptions.noCommandDetection, silentAgent ?? defaultAgent, location, sendOptions); + const responseState = this._sendRequestAsync(model, model.sessionResource, parsedRequest, firstRequest.request.attempt, !sendOptions.noCommandDetection, silentAgent ?? defaultAgent, location, sendOptions); - // Resolve the deferred with the sent result - deferred?.complete({ + const result: ChatSendResultSent = { kind: 'sent', data: { ...responseState, agent, slashCommand: agentSlashCommandPart?.command, }, - }); + }; + for (const deferred of deferreds) { + deferred.complete(result); + } } private generateInitialChatTitleIfNeeded(model: ChatModel, request: IChatAgentRequest, defaultAgent: IChatAgentData, token: CancellationToken): void { @@ -1374,7 +1437,7 @@ export class ChatService extends Disposable implements IChatService { if (pendingRequest?.requestId === requestId) { pendingRequest.cancel(); this._pendingRequests.deleteAndDispose(sessionResource); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'removeRequest' }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'removeRequest', requestId, chatSessionId: chatSessionResourceToId(model.sessionResource) }); } model.removeRequest(requestId); @@ -1397,8 +1460,8 @@ export class ChatService extends Disposable implements IChatService { if (cts) { cts.requestId = request.id; this._pendingRequests.set(target.sessionResource, cts); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'adoptRequest' }); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'adoptRequest' }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'adoptRequest', requestId: request.id, chatSessionId: chatSessionResourceToId(oldOwner.sessionResource) }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'adoptRequest', requestId: request.id, chatSessionId: chatSessionResourceToId(target.sessionResource) }); } } } @@ -1430,7 +1493,7 @@ export class ChatService extends Disposable implements IChatService { request.response?.complete(); } - cancelCurrentRequestForSession(sessionResource: URI, source?: string): void { + async cancelCurrentRequestForSession(sessionResource: URI, source?: string): Promise { this.trace('cancelCurrentRequestForSession', `session: ${sessionResource}`); const pendingRequest = this._pendingRequests.get(sessionResource); if (!pendingRequest) { @@ -1445,14 +1508,20 @@ export class ChatService extends Disposable implements IChatService { pendingRequests: pendingRequestsCount, sessionScheme: sessionResource.scheme, lastRequestId: lastRequest?.id, + chatSessionId: chatSessionResourceToId(sessionResource), }); this.info('cancelCurrentRequestForSession', `No pending request was found for session ${sessionResource}. requestInProgress=${requestInProgress ?? 'unknown'}, pendingRequests=${pendingRequestsCount}`); return; } + const responseCompletePromise = pendingRequest.responseCompletePromise; pendingRequest.cancel(); this._pendingRequests.deleteAndDispose(sessionResource); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: source ?? 'cancelRequest' }); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: source ?? 'cancelRequest', requestId: pendingRequest.requestId, chatSessionId: chatSessionResourceToId(sessionResource) }); + + if (responseCompletePromise) { + await raceTimeout(responseCompletePromise, 1000); + } } setYieldRequested(sessionResource: URI): void { @@ -1545,4 +1614,39 @@ export class ChatService extends Disposable implements IChatService { } return localSessionId; } + + public registerChatModelChangeListeners(chatSessionType: string, onChange: () => void): IDisposable { + const disposableStore = new DisposableStore(); + const chatModelsICareAbout = this.chatModels.map(models => + Array.from(models).filter((model: IChatModel) => model.sessionResource.scheme === chatSessionType) + ); + + const listeners = new ResourceMap(); + const autoRunDisposable = autorunIterableDelta( + reader => chatModelsICareAbout.read(reader), + ({ addedValues, removedValues }) => { + removedValues.forEach((removed) => { + const listener = listeners.get(removed.sessionResource); + if (listener) { + listeners.delete(removed.sessionResource); + listener.dispose(); + } + }); + addedValues.forEach((added) => { + const requestChangeListener = added.lastRequestObs.map(last => last?.response && observableSignalFromEvent('chatSessions.modelRequestChangeListener', last.response.onDidChange)); + const modelChangeListener = observableSignalFromEvent('chatSessions.modelChangeListener', added.onDidChange); + listeners.set(added.sessionResource, autorun(reader => { + requestChangeListener.read(reader)?.read(reader); + modelChangeListener.read(reader); + onChange(); + })); + }); + } + ); + disposableStore.add(toDisposable(() => { + for (const listener of listeners.values()) { listener.dispose(); } + })); + disposableStore.add(autoRunDisposable); + return disposableStore; + } } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts index 2a0f2cf7cc8..d524128a238 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts @@ -11,7 +11,7 @@ import { ChatRequestModel, IChatRequestVariableData } from '../model/chatModel.j import { ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart } from '../requestParser/chatParserTypes.js'; import { ChatAgentVoteDirection, ChatCopyKind, IChatSendRequestOptions, IChatUserActionEvent } from './chatService.js'; import { isImageVariableEntry } from '../attachments/chatVariableEntries.js'; -import { ChatAgentLocation } from '../constants.js'; +import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ILanguageModelsService } from '../languageModels.js'; import { chatSessionResourceToId } from '../model/chatUri.js'; @@ -149,6 +149,9 @@ export type ChatProviderInvokedEvent = { enableCommandDetection: boolean; attachmentKinds: string[]; model: string | undefined; + permissionLevel: ChatPermissionLevel | undefined; + chatMode: string | undefined; + sessionType: string | undefined; }; export type ChatProviderInvokedClassification = { @@ -167,6 +170,9 @@ export type ChatProviderInvokedClassification = { enableCommandDetection: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether participation detection was disabled for this invocation.' }; attachmentKinds: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The types of variables/attachments that the user included with their query.' }; model: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model used to generate the response.' }; + permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The tool auto-approval permission level selected in the permission picker (default, autoApprove, or autopilot). Undefined when the picker is not applicable (e.g. ask mode or API-driven requests).' }; + chatMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat mode used for the request. Built-in modes (ask, agent, edit), extension-contributed names (e.g. Plan), or a hashed identifier for user-created custom agents.' }; + sessionType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type scheme (e.g. vscodeLocalChatSession for local, or remote session scheme).' }; owner: 'roblourens'; comment: 'Provides insight into the performance of Chat agents.'; }; @@ -303,6 +309,9 @@ export class ChatRequestTelemetry { numCodeBlocks: getCodeBlocks(request.response?.response.toString() ?? '').length, attachmentKinds: this.attachmentKindsForTelemetry(request.variableData), model: this.resolveModelId(this.opts.options?.userSelectedModelId), + permissionLevel: this.opts.options?.modeInfo?.kind === ChatModeKind.Ask ? undefined : this.opts.options?.modeInfo?.permissionLevel, + chatMode: this.opts.options?.modeInfo?.modeName ?? this.opts.options?.modeInfo?.modeId, + sessionType: this.opts.sessionResource.scheme, }); } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index c609524139d..d584e8295e0 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -14,8 +14,8 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participants/chatAgents.js'; import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; -import { IChatProgress, IChatService, IChatSessionTiming } from './chatService/chatService.js'; -import { Target } from './promptSyntax/service/promptsService.js'; +import { IChatProgress, IChatSessionTiming } from './chatService/chatService.js'; +import { Target } from './promptSyntax/promptTypes.js'; export const enum ChatSessionStatus { Failed = 0, @@ -256,7 +256,7 @@ export interface IChatSessionsService { getContentProviderSchemes(): string[]; registerChatSessionContentProvider(scheme: string, provider: IChatSessionContentProvider): IDisposable; - canResolveChatSession(sessionResource: URI): Promise; + canResolveChatSession(sessionType: string): Promise; getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise; hasAnySessionOptions(sessionResource: URI): boolean; @@ -298,7 +298,6 @@ export interface IChatSessionsService { readonly onRequestNotifyExtension: Event; notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise; - registerChatModelChangeListeners(chatService: IChatService, chatSessionType: string, onChange: () => void): IDisposable; getInProgressSessionDescription(chatModel: IChatModel): string | undefined; /** diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 52fe2267506..1cc89241a27 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -11,7 +11,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', PluginsEnabled = 'chat.plugins.enabled', - PluginPaths = 'chat.plugins.paths', + PluginLocations = 'chat.pluginLocations', PluginMarketplaces = 'chat.plugins.marketplaces', AgentEnabled = 'chat.agent.enabled', PlanAgentDefaultModel = 'chat.planAgent.defaultModel', @@ -54,6 +54,7 @@ export enum ChatConfiguration { ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', GrowthNotificationEnabled = 'chat.growthNotification.enabled', ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', + AutopilotEnabled = 'chat.autopilot.enabled', } /** @@ -76,6 +77,26 @@ export function validateChatMode(mode: unknown): ChatModeKind | undefined { } } +/** + * The permission level controlling tool auto-approval behavior. + */ +export enum ChatPermissionLevel { + /** Use existing auto-approve settings */ + Default = 'default', + /** Auto-approve all tool calls, auto-retry on error */ + AutoApprove = 'autoApprove', + /** Everything AutoApprove does plus an internal stop hook that continues until the task is done */ + Autopilot = 'autopilot' +} + +/** + * Returns true if the permission level enables auto-approval of all tool calls. + * Both {@link ChatPermissionLevel.AutoApprove} and {@link ChatPermissionLevel.Autopilot} enable auto-approval. + */ +export function isAutoApproveLevel(level: ChatPermissionLevel | undefined): boolean { + return level === ChatPermissionLevel.AutoApprove || level === ChatPermissionLevel.Autopilot; +} + export function isChatMode(mode: unknown): mode is ChatModeKind { return !!validateChatMode(mode); } diff --git a/src/vs/workbench/contrib/chat/common/enablement.ts b/src/vs/workbench/contrib/chat/common/enablement.ts new file mode 100644 index 00000000000..e8cb2d26c7b --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/enablement.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IReader } from '../../../../base/common/observable.js'; +import { ObservableMemento, observableMemento } from '../../../../platform/observable/common/observableMemento.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +export const enum ContributionEnablementState { + DisabledProfile, + DisabledWorkspace, + EnabledProfile, + EnabledWorkspace, +} + +export function isContributionEnabled(state: ContributionEnablementState): boolean { + return state === ContributionEnablementState.EnabledProfile || state === ContributionEnablementState.EnabledWorkspace; +} + +export function isContributionDisabled(state: ContributionEnablementState): boolean { + return !isContributionEnabled(state); +} + +export interface IEnablementModel { + readEnabled(key: string, reader?: IReader): ContributionEnablementState; + setEnabled(key: string, state: ContributionEnablementState): void; +} + +type EnablementMap = ReadonlyMap; + +function mapToStorage(value: EnablementMap): string { + return JSON.stringify([...value]); +} + +function mapFromStorage(value: string): EnablementMap { + const parsed = JSON.parse(value); + return new Map(Array.isArray(parsed) ? parsed : []); +} + +/** + * A reusable enablement model for string-keyed contributions. Uses + * `observableMemento` to persist enable/disable state in both profile-scoped + * and workspace-scoped storage. + * + * Resolution order: if a workspace-scoped entry exists for a key, it wins. + * Otherwise, the profile-scoped entry is used. The default (absence of any + * entry) is {@link ContributionEnablementState.EnabledProfile}. + */ +export class EnablementModel extends Disposable implements IEnablementModel { + private readonly _profileState: ObservableMemento; + private readonly _workspaceState: ObservableMemento; + + constructor( + storageKey: string, + @IStorageService storageService: IStorageService, + ) { + super(); + + const mapMemento = observableMemento({ + key: storageKey, + defaultValue: new Map(), + toStorage: mapToStorage, + fromStorage: mapFromStorage, + }); + + this._profileState = this._register( + mapMemento(StorageScope.PROFILE, StorageTarget.MACHINE, storageService) + ); + + this._workspaceState = this._register( + mapMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE, storageService) + ); + } + + readEnabled(key: string, reader?: IReader): ContributionEnablementState { + const wsMap = this._workspaceState.read(reader); + if (wsMap.has(key)) { + return wsMap.get(key)! + ? ContributionEnablementState.EnabledWorkspace + : ContributionEnablementState.DisabledWorkspace; + } + + const profileMap = this._profileState.read(reader); + if (profileMap.has(key)) { + return profileMap.get(key)! + ? ContributionEnablementState.EnabledProfile + : ContributionEnablementState.DisabledProfile; + } + + return ContributionEnablementState.EnabledProfile; + } + + setEnabled(key: string, state: ContributionEnablementState): void { + switch (state) { + case ContributionEnablementState.EnabledProfile: { + // Enabled-profile is the default: remove key from profile state, + // and also remove any workspace override. + this._deleteFromMap(this._profileState, key); + this._deleteFromMap(this._workspaceState, key); + break; + } + case ContributionEnablementState.DisabledProfile: { + // Store disabled in profile, remove workspace override. + this._setInMap(this._profileState, key, false); + this._deleteFromMap(this._workspaceState, key); + break; + } + case ContributionEnablementState.EnabledWorkspace: { + // Workspace override: always store explicitly. + this._setInMap(this._workspaceState, key, true); + break; + } + case ContributionEnablementState.DisabledWorkspace: { + // Workspace override: always store explicitly. + this._setInMap(this._workspaceState, key, false); + break; + } + } + } + + private _setInMap(memento: ObservableMemento, key: string, value: boolean): void { + const current = memento.get(); + if (current.get(key) === value) { + return; + } + const next = new Map(current); + next.set(key, value); + memento.set(next, undefined); + } + + private _deleteFromMap(memento: ObservableMemento, key: string): void { + const current = memento.get(); + if (!current.has(key)) { + return; + } + const next = new Map(current); + next.delete(key); + memento.set(next, undefined); + } +} diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 6b322748eed..8d433ce3efd 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -30,7 +30,7 @@ import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCo import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; -import { ChatAgentLocation, ChatModeKind } from '../constants.js'; +import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ChatToolInvocation } from './chatProgressTypes/chatToolInvocation.js'; import { ToolDataSource, IToolData } from '../tools/languageModelToolsService.js'; import { IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../editing/chatEditingService.js'; @@ -313,10 +313,13 @@ export interface IChatRequestModeInfo { isBuiltin: boolean; modeInstructions: IChatRequestModeInstructions | undefined; modeId: 'ask' | 'agent' | 'edit' | 'custom' | 'applyCodeBlock' | undefined; + modeName?: string; applyCodeBlockSuggestionId: EditSuggestionId | undefined; + permissionLevel?: ChatPermissionLevel; } export interface IChatRequestModeInstructions { + readonly uri?: URI; readonly name: string; readonly content: string; readonly toolReferences: readonly ChatRequestToolReferenceEntry[]; @@ -847,6 +850,7 @@ export class Response extends AbstractResponse implements IDisposable { content: [], toolResultMessage: progress.pastTenseMessage, toolResultError: progress.errorMessage, + toolResultDetails: progress.resultDetails }); } if (progress.toolSpecificData !== undefined) { @@ -883,6 +887,7 @@ export class Response extends AbstractResponse implements IDisposable { content: [], toolResultMessage: progress.pastTenseMessage, toolResultError: progress.errorMessage, + toolResultDetails: progress.resultDetails }); if (progress.toolSpecificData !== undefined) { invocation.toolSpecificData = progress.toolSpecificData; @@ -1446,6 +1451,7 @@ export interface ISerializableChatRequestData extends ISerializableChatResponseD confirmation?: string; editedFileEvents?: IChatAgentEditedFileEvent[]; modelId?: string; + modeInfo?: IChatRequestModeInfo; } export interface ISerializableMarkdownInfo { @@ -1994,6 +2000,21 @@ export class ChatModel extends Disposable implements IChatModel { return request; } + /** + * @internal Used by ChatService to dequeue all consecutive steering requests at the front of the queue. + * Returns an empty array if the first pending request is not a steering request. + */ + dequeueAllSteeringRequests(): IChatPendingRequest[] { + const steeringRequests: IChatPendingRequest[] = []; + while (this._pendingRequests.at(0)?.kind === ChatRequestQueueKind.Steering) { + steeringRequests.push(this._pendingRequests.shift()!); + } + if (steeringRequests.length > 0) { + this._onDidChangePendingRequests.fire(); + } + return steeringRequests; + } + /** * @internal Used by ChatService to clear all pending requests */ @@ -2297,6 +2318,7 @@ export class ChatModel extends Disposable implements IChatModel { confirmation: raw.confirmation, editedFileEvents: raw.editedFileEvents, modelId: raw.modelId, + modeInfo: raw.modeInfo, }); request.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; // eslint-disable-next-line @typescript-eslint/no-explicit-any, local/code-no-any-casts @@ -2639,6 +2661,7 @@ export class ChatModel extends Disposable implements IChatModel { confirmation: r.confirmation, editedFileEvents: r.editedFileEvents, modelId: r.modelId, + modeInfo: r.modeInfo, ...r.response?.toJSON(), }; }), diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts index ef8ba5ae529..5feb507b755 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { DeferredPromise } from '../../../../../../base/common/async.js'; -import { IChatQuestion, IChatQuestionCarousel } from '../../chatService/chatService.js'; +import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { IChatQuestion, IChatQuestionAnswers, IChatQuestionCarousel } from '../../chatService/chatService.js'; +import { ToolDataSource } from '../../tools/languageModelToolsService.js'; /** * Runtime representation of a question carousel with a {@link DeferredPromise} @@ -13,16 +15,18 @@ import { IChatQuestion, IChatQuestionCarousel } from '../../chatService/chatServ */ export class ChatQuestionCarouselData implements IChatQuestionCarousel { public readonly kind = 'questionCarousel' as const; - public readonly completion = new DeferredPromise<{ answers: Record | undefined }>(); - public draftAnswers: Record | undefined; + public readonly completion = new DeferredPromise<{ answers: IChatQuestionAnswers | undefined }>(); + public draftAnswers: IChatQuestionAnswers | undefined; public draftCurrentIndex: number | undefined; constructor( public questions: IChatQuestion[], public allowSkip: boolean, public resolveId?: string, - public data?: Record, + public data?: IChatQuestionAnswers, public isUsed?: boolean, + public message?: string | IMarkdownString, + public source?: ToolDataSource, ) { } toJSON(): IChatQuestionCarousel { @@ -33,6 +37,8 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel { resolveId: this.resolveId, data: this.data, isUsed: this.isUsed, + message: this.message, + source: this.source, }; } } diff --git a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts index da6353f3cd5..a18f4db9d93 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts @@ -7,7 +7,7 @@ import { encodeBase64 } from '../../../../../../base/common/buffer.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { localize } from '../../../../../../nls.js'; -import { ConfirmedReason, IChatExtensionsContent, IChatSimpleToolInvocationData, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; +import { ConfirmedReason, IChatExtensionsContent, IChatModifiedFilesConfirmationData, IChatSimpleToolInvocationData, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, ToolConfirmKind, type IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; import { IPreparedToolInvocation, isToolResultOutputDetails, IToolConfirmationMessages, IToolData, IToolProgressStep, IToolResult, ToolDataSource } from '../../tools/languageModelToolsService.js'; export interface IStreamingToolCallOptions { @@ -33,7 +33,7 @@ export class ChatToolInvocation implements IChatToolInvocation { public generatedTitle?: string; public readonly chatRequestId?: string; - public toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData; + public toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatModifiedFilesConfirmationData; private readonly _progress = observableValue<{ message?: string | IMarkdownString; progress: number | undefined }>(this, { progress: 0 }); private readonly _state: ISettableObservable; diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index 767cb087be6..9c69890c9a7 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { assertNever } from '../../../../../base/common/assert.js'; +import { softAssertNever } from '../../../../../base/common/assert.js'; import { isMarkdownString } from '../../../../../base/common/htmlContent.js'; import { equals as objectsEqual } from '../../../../../base/common/objects.js'; import { isEqual as _urisEqual } from '../../../../../base/common/resources.js'; @@ -90,7 +90,9 @@ const responsePartSchema = Adapt.v m.response?.contentReferences, objectsEqual), codeCitations: Adapt.v(m => m.response?.codeCitations, objectsEqual), timeSpentWaiting: Adapt.v(m => m.response?.timestamp), // based on response timestamp + modeInfo: Adapt.v(m => m.modeInfo, objectsEqual), }, { sealed: (o) => o.modelState?.value === ResponseModelState.Cancelled || o.modelState?.value === ResponseModelState.Failed || o.modelState?.value === ResponseModelState.Complete, }); diff --git a/src/vs/workbench/contrib/chat/common/model/chatUri.ts b/src/vs/workbench/contrib/chat/common/model/chatUri.ts index 911494c6e80..0a0830b7950 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatUri.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatUri.ts @@ -91,3 +91,7 @@ export function getChatSessionType(resource: URI): string { return resource.scheme; } + +export function isUntitledChatSession(resource: URI): boolean { + return resource.path.startsWith('/untitled-'); +} diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 8a92fdb502f..e8f4fbe4a03 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -21,10 +21,10 @@ import { ExtensionIdentifier } from '../../../../../platform/extensions/common/e import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { ChatContextKeys } from '../actions/chatContextKeys.js'; import { IChatAgentEditedFileEvent, IChatProgressHistoryResponseContent, IChatRequestModeInstructions, IChatRequestVariableData, ISerializableChatAgentData } from '../model/chatModel.js'; -import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; +import { ChatRequestHooks } from '../promptSyntax/hookSchema.js'; import { IRawChatCommandContribution } from './chatParticipantContribTypes.js'; import { IChatFollowup, IChatLocationData, IChatProgress, IChatResponseErrorDetails, IChatTaskDto } from '../chatService/chatService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ILanguageModelsService } from '../languageModels.js'; //#region agent service, commands etc @@ -153,11 +153,17 @@ export interface IChatAgentRequest { * Collected hooks configuration for this request. * Contains all hooks defined in hooks .json files, organized by hook type. */ - hooks?: IChatRequestHooks; + hooks?: ChatRequestHooks; /** * Whether any hooks are enabled for this request. */ hasHooksEnabled?: boolean; + /** + * The permission level for tool auto-approval in this request. + * - `'autoApprove'`: Auto-approve all tool calls and retry on errors. + * - `'autopilot'`: Everything autoApprove does plus continues until the task is done. + */ + permissionLevel?: ChatPermissionLevel; /** * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ diff --git a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts index 50f145ccf10..35c609e3c7d 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts @@ -9,11 +9,11 @@ import { Disposable, IDisposable, toDisposable } from '../../../../../base/commo import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { IChatMessage } from '../languageModels.js'; -import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData } from '../chatService/chatService.js'; +import { IChatFollowup, IChatProgress, IChatResponseProgressFileTreeData, IChatSendRequestOptions } from '../chatService/chatService.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { URI } from '../../../../../base/common/uri.js'; -import { Target } from '../promptSyntax/service/promptsService.js'; +import { Target } from '../promptSyntax/promptTypes.js'; //#region slash service, commands etc @@ -44,7 +44,7 @@ export interface IChatSlashData { export interface IChatSlashFragment { content: string | { treeData: IChatResponseProgressFileTreeData }; } -export type IChatSlashCallback = { (prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> }; +export type IChatSlashCallback = { (prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void> }; export const IChatSlashCommandService = createDecorator('chatSlashCommandService'); @@ -55,7 +55,7 @@ export interface IChatSlashCommandService { _serviceBrand: undefined; readonly onDidChangeCommands: Event; registerSlashCommand(data: IChatSlashData, command: IChatSlashCallback): IDisposable; - executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void>; + executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void>; getCommands(location: ChatAgentLocation, mode: ChatModeKind): Array; hasCommand(id: string): boolean; } @@ -105,7 +105,7 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom return this._commands.has(id); } - async executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken): Promise<{ followUp: IChatFollowup[] } | void> { + async executeCommand(id: string, prompt: string, progress: IProgress, history: IChatMessage[], location: ChatAgentLocation, sessionResource: URI, token: CancellationToken, options?: IChatSendRequestOptions): Promise<{ followUp: IChatFollowup[] } | void> { const data = this._commands.get(id); if (!data) { throw new Error('No command with id ${id} NOT registered'); @@ -117,6 +117,6 @@ export class ChatSlashCommandService extends Disposable implements IChatSlashCom throw new Error(`No command with id ${id} NOT resolved`); } - return await data.command(prompt, progress, history, location, sessionResource, token); + return await data.command(prompt, progress, history, location, sessionResource, token, options); } } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts index 3f9a9bffda5..ea90fb6f832 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts @@ -5,7 +5,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IMarketplacePlugin, IMarketplaceReference, MarketplaceType } from './pluginMarketplaceService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceType, PluginSourceKind } from './pluginMarketplaceService.js'; +import { IPluginSource } from './pluginSource.js'; export const IAgentPluginRepositoryService = createDecorator('agentPluginRepositoryService'); @@ -31,6 +32,8 @@ export interface IPullRepositoryOptions { readonly failureLabel?: string; /** Marketplace type metadata for repository index updates. */ readonly marketplaceType?: MarketplaceType; + /** When `true`, suppresses progress notifications. */ + readonly silent?: boolean; } /** @@ -59,6 +62,55 @@ export interface IAgentPluginRepositoryService { /** * Pulls latest changes for a cloned marketplace repository. + * Returns `true` if the pull brought in new changes. */ - pullRepository(marketplace: IMarketplaceReference, options?: IPullRepositoryOptions): Promise; + pullRepository(marketplace: IMarketplaceReference, options?: IPullRepositoryOptions): Promise; + + /** + * Returns the local install URI for a plugin based on its + * {@link IPluginSourceDescriptor}. For non-relative-path sources + * (github, url, npm, pip), this computes a cache location independent + * of the marketplace repository. + */ + getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI; + + /** + * Ensures the plugin source is available locally. For github/url sources + * this clones the repository into the cache. For npm/pip sources this is + * a no-op (installation via terminal is handled by the install service). + */ + ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise; + + /** + * Updates a plugin source that is stored outside the marketplace repository. + * For github/url sources this pulls latest changes and reapplies pinned + * ref/sha checkout. For npm/pip sources this is a no-op. + * Returns `true` if the update brought in new changes. + */ + updatePluginSource(plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise; + + /** + * Returns the {@link IPluginSource} strategy for the given + * source kind, allowing callers to invoke kind-specific operations + * (install, update, label, etc.) directly. + */ + getPluginSource(kind: PluginSourceKind): IPluginSource; + + /** + * Cleans up on-disk cache for a plugin source that owns its own install + * directory. For marketplace-relative sources this is a no-op (they share + * the marketplace repository cache). For direct sources (github, url, npm, + * pip) the cache directory is deleted. + * + * This is best-effort: failures are logged but do not throw. + */ + cleanupPluginSource(plugin: IMarketplacePlugin): Promise; + + /** + * Silently fetches remote refs for a cloned marketplace repository and + * returns whether the local branch is behind the remote (i.e. updates + * are available). Returns `false` if the repo is not cloned or on + * network failure. + */ + fetchRepository(marketplace: IMarketplaceReference): Promise; } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 81ba54b263f..348cf81f5f5 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -10,7 +10,9 @@ import { URI } from '../../../../../base/common/uri.js'; import { SyncDescriptor0 } from '../../../../../platform/instantiation/common/descriptors.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IMcpServerConfiguration } from '../../../../../platform/mcp/common/mcpPlatformTypes.js'; -import { HookType, IHookCommand } from '../promptSyntax/hookSchema.js'; +import { ContributionEnablementState, IEnablementModel } from '../enablement.js'; +import { IHookCommand } from '../promptSyntax/hookSchema.js'; +import { HookType } from '../promptSyntax/hookTypes.js'; import { IMarketplacePlugin } from './pluginMarketplaceService.js'; export const IAgentPluginService = createDecorator('agentPluginService'); @@ -43,8 +45,9 @@ export interface IAgentPluginMcpServerDefinition { export interface IAgentPlugin { readonly uri: URI; - readonly enabled: IObservable; - setEnabled(enabled: boolean): void; + /** Human-readable display name for the plugin. */ + readonly label: string; + readonly enablement: IObservable; /** Removes this plugin from its discovery source (config or installed storage). */ remove(): void; readonly hooks: IObservable; @@ -59,13 +62,12 @@ export interface IAgentPlugin { export interface IAgentPluginService { readonly _serviceBrand: undefined; readonly plugins: IObservable; - readonly allPlugins: IObservable; - setPluginEnabled(pluginUri: URI, enabled: boolean): void; + readonly enablementModel: IEnablementModel; } export interface IAgentPluginDiscovery extends IDisposable { readonly plugins: IObservable; - start(): void; + start(enablementModel: IEnablementModel): void; } export function getCanonicalPluginCommandId(plugin: IAgentPlugin, commandName: string): string { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index fa2ed06dbc4..71f88c2d67f 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -5,10 +5,11 @@ import { RunOnceScheduler } from '../../../../../base/common/async.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; +import { untildify } from '../../../../../base/common/labels.js'; import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { cloneAndChange } from '../../../../../base/common/objects.js'; -import { autorun, derived, IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, observableValue } from '../../../../../base/common/observable.js'; import { posix, win32 @@ -33,7 +34,10 @@ import { parseClaudeHooks } from '../promptSyntax/hookClaudeCompat.js'; import { parseCopilotHooks } from '../promptSyntax/hookCompatibility.js'; import { IHookCommand } from '../promptSyntax/hookSchema.js'; import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginAgent, IAgentPluginCommand, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from './agentPluginService.js'; +import { EnablementModel, IEnablementModel } from '../enablement.js'; +import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js'; import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; const COMMAND_FILE_SUFFIX = '.md'; @@ -193,15 +197,18 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic declare readonly _serviceBrand: undefined; - public readonly allPlugins: IObservable; public readonly plugins: IObservable; + public readonly enablementModel: IEnablementModel; constructor( @IInstantiationService instantiationService: IInstantiationService, @IConfigurationService configurationService: IConfigurationService, + @IStorageService storageService: IStorageService, ) { super(); + this.enablementModel = this._register(new EnablementModel('agentPlugins.enablement', storageService)); + const pluginsEnabled = observableConfigValue(ChatConfiguration.PluginsEnabled, true, configurationService); const discoveries: IAgentPluginDiscovery[] = []; @@ -209,28 +216,16 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic const discovery = instantiationService.createInstance(descriptor); this._register(discovery); discoveries.push(discovery); - discovery.start(); + discovery.start(this.enablementModel); } - this.allPlugins = derived(read => { + this.plugins = derived(read => { if (!pluginsEnabled.read(read)) { return []; } return this._dedupeAndSort(discoveries.flatMap(d => d.plugins.read(read))); }); - - this.plugins = derived(reader => { - const all = this.allPlugins.read(reader); - return all.filter(p => p.enabled.read(reader)); - }); - } - - public setPluginEnabled(pluginUri: URI, enabled: boolean): void { - const plugin = this.allPlugins.get().find(p => p.uri.toString() === pluginUri.toString()); - if (plugin) { - plugin.setEnabled(enabled); - } } private _dedupeAndSort(plugins: readonly IAgentPlugin[]): readonly IAgentPlugin[] { @@ -251,7 +246,7 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic } } -type PluginEntry = IAgentPlugin & { enabled: ISettableObservable }; +type PluginEntry = IAgentPlugin; /** * Describes a single discovered plugin source, before the shared @@ -259,10 +254,7 @@ type PluginEntry = IAgentPlugin & { enabled: ISettableObservable }; */ interface IPluginSource { readonly uri: URI; - readonly enabled: boolean; readonly fromMarketplace: IMarketplacePlugin | undefined; - /** Called when setEnabled is invoked on the plugin */ - setEnabled(value: boolean): void; /** Called when remove is invoked on the plugin */ remove(): void; } @@ -283,6 +275,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements public readonly plugins: IObservable = this._plugins; protected _discoverVersion = 0; + protected _enablementModel!: IEnablementModel; constructor( protected readonly _fileService: IFileService, @@ -293,7 +286,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements super(); } - public abstract start(): void; + public abstract start(enablementModel: IEnablementModel): void; protected async _refreshPlugins(): Promise { const version = ++this._discoverVersion; @@ -318,7 +311,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements if (!seenPluginUris.has(key)) { seenPluginUris.add(key); const adapter = await this._detectPluginFormatAdapter(source.uri); - plugins.push(this._toPlugin(source.uri, source.enabled, adapter, source.fromMarketplace, value => source.setEnabled(value), () => source.remove())); + plugins.push(this._toPlugin(source.uri, adapter, source.fromMarketplace, () => source.remove())); } } @@ -346,7 +339,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements } } - private _toPlugin(uri: URI, initialEnabled: boolean, adapter: IAgentPluginFormatAdapter, fromMarketplace: IMarketplacePlugin | undefined, setEnabledCallback: (value: boolean) => void, removeCallback: () => void): IAgentPlugin { + private _toPlugin(uri: URI, adapter: IAgentPluginFormatAdapter, fromMarketplace: IMarketplacePlugin | undefined, removeCallback: () => void): IAgentPlugin { const key = uri.toString(); const existing = this._pluginEntries.get(key); if (existing) { @@ -354,7 +347,6 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements existing.store.dispose(); this._pluginEntries.delete(key); } else { - existing.plugin.enabled.set(initialEnabled, undefined); return existing.plugin; } } @@ -365,7 +357,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const agents = observableValue('agentPluginAgents', []); const hooks = observableValue('agentPluginHooks', []); const mcpServerDefinitions = observableValue('agentPluginMcpServerDefinitions', []); - const enabled = observableValue('agentPluginEnabled', initialEnabled); + const enablement = derived(r => this._enablementModel.readEnabled(key, r)); const commandsDir = joinPath(uri, 'commands'); const skillsDir = joinPath(uri, 'skills'); @@ -415,8 +407,8 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const plugin: PluginEntry = { uri, - enabled, - setEnabled: setEnabledCallback, + label: fromMarketplace?.name ?? basename(uri), + enablement, remove: removeCallback, hooks, commands, @@ -699,7 +691,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery { - private readonly _pluginPathsConfig: IObservable>; + private readonly _pluginLocationsConfig: IObservable>; constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -711,13 +703,14 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery @IInstantiationService instantiationService: IInstantiationService, ) { super(fileService, pathService, logService, instantiationService); - this._pluginPathsConfig = observableConfigValue>(ChatConfiguration.PluginPaths, {}, _configurationService); + this._pluginLocationsConfig = observableConfigValue>(ChatConfiguration.PluginLocations, {}, _configurationService); } - public override start(): void { + public override start(enablementModel: IEnablementModel): void { + this._enablementModel = enablementModel; const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); this._register(autorun(reader => { - this._pluginPathsConfig.read(reader); + this._pluginLocationsConfig.read(reader); scheduler.schedule(); })); scheduler.schedule(); @@ -725,14 +718,15 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery protected override async _discoverPluginSources(): Promise { const sources: IPluginSource[] = []; - const config = this._pluginPathsConfig.get(); + const config = this._pluginLocationsConfig.get(); + const userHome = await this._getUserHome(); for (const [path, enabled] of Object.entries(config)) { - if (!path.trim()) { + if (!path.trim() || enabled === false) { continue; } - const resources = this._resolvePluginPath(path.trim()); + const resources = this._resolvePluginPath(path.trim(), userHome); for (const resource of resources) { let stat; try { @@ -751,9 +745,7 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery const configKey = path; sources.push({ uri: stat.resource, - enabled, fromMarketplace, - setEnabled: (value: boolean) => this._updatePluginPathEnabled(configKey, value), remove: () => this._removePluginPath(configKey), }); } @@ -762,11 +754,23 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery return sources; } + private async _getUserHome(): Promise { + const userHome = await this._pathService.userHome(); + return userHome.scheme === 'file' ? userHome.fsPath : userHome.path; + } + /** - * Resolves a plugin path to one or more resource URIs. Absolute paths are - * used directly; relative paths are resolved against each workspace folder. + * Resolves a plugin path to one or more resource URIs. Supports: + * - Absolute paths (used directly) + * - Tilde paths (expanded to user home directory) + * - Relative paths (resolved against each workspace folder) */ - private _resolvePluginPath(path: string): URI[] { + private _resolvePluginPath(path: string, userHome: string): URI[] { + if (path.startsWith('~')) { + path = untildify(path, userHome); + } + + // Handle absolute paths if (win32.isAbsolute(path) || posix.isAbsolute(path)) { return [URI.file(path)]; } @@ -777,49 +781,11 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery } /** - * Updates the enabled state of a plugin path in the configuration, - * writing to the most specific config target where the key is defined. - */ - private _updatePluginPathEnabled(configKey: string, value: boolean): void { - const inspected = this._configurationService.inspect>(ChatConfiguration.PluginPaths); - - // Walk from most specific to least specific to find where this key is defined - const targets = [ - ConfigurationTarget.WORKSPACE_FOLDER, - ConfigurationTarget.WORKSPACE, - ConfigurationTarget.USER_LOCAL, - ConfigurationTarget.USER_REMOTE, - ConfigurationTarget.USER, - ConfigurationTarget.APPLICATION, - ]; - - for (const target of targets) { - const mapping = getConfigValueInTarget(inspected, target); - if (mapping && Object.prototype.hasOwnProperty.call(mapping, configKey)) { - this._configurationService.updateValue( - ChatConfiguration.PluginPaths, - { ...mapping, [configKey]: value }, - target, - ); - return; - } - } - - // Key not found in any target; write to USER_LOCAL as default - const current = getConfigValueInTarget(inspected, ConfigurationTarget.USER_LOCAL) ?? {}; - this._configurationService.updateValue( - ChatConfiguration.PluginPaths, - { ...current, [configKey]: value }, - ConfigurationTarget.USER_LOCAL, - ); - } - - /** - * Removes a plugin path from `chat.plugins.paths` in the most specific + * Removes a plugin path from `chat.pluginLocations` in the most specific * config target where the key is defined. */ private _removePluginPath(configKey: string): void { - const inspected = this._configurationService.inspect>(ChatConfiguration.PluginPaths); + const inspected = this._configurationService.inspect>(ChatConfiguration.PluginLocations); const targets = [ ConfigurationTarget.WORKSPACE_FOLDER, @@ -836,7 +802,7 @@ export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery const updated = { ...mapping }; delete updated[configKey]; this._configurationService.updateValue( - ChatConfiguration.PluginPaths, + ChatConfiguration.PluginLocations, updated, target, ); @@ -850,6 +816,7 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover constructor( @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService, + @IAgentPluginRepositoryService private readonly _pluginRepositoryService: IAgentPluginRepositoryService, @IFileService fileService: IFileService, @IPathService pathService: IPathService, @ILogService logService: ILogService, @@ -858,7 +825,8 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover super(fileService, pathService, logService, instantiationService); } - public override start(): void { + public override start(enablementModel: IEnablementModel): void { + this._enablementModel = enablementModel; const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0)); this._register(autorun(reader => { this._pluginMarketplaceService.installedPlugins.read(reader); @@ -887,10 +855,13 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover sources.push({ uri: stat.resource, - enabled: entry.enabled, fromMarketplace: entry.plugin, - setEnabled: (value: boolean) => this._pluginMarketplaceService.setInstalledPluginEnabled(entry.pluginUri, value), - remove: () => this._pluginMarketplaceService.removeInstalledPlugin(entry.pluginUri), + remove: () => { + this._pluginMarketplaceService.removeInstalledPlugin(entry.pluginUri); + this._pluginRepositoryService.cleanupPluginSource(entry.plugin).catch(error => { + this._logService.error('[MarketplaceAgentPluginDiscovery] Failed to clean up plugin source', error); + }); + }, }); } diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts index 0f66af02f91..4748070ce18 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts @@ -3,12 +3,36 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { URI } from '../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IMarketplacePlugin } from './pluginMarketplaceService.js'; export const IPluginInstallService = createDecorator('pluginInstallService'); +export interface IUpdateAllPluginsOptions { + /** + * When `true`, also re-installs npm/pip packages that have no pinned + * version. Defaults to `false` to avoid interactive terminal prompts + * during background updates. + */ + readonly force?: boolean; + + /** + * When `true`, suppresses the progress notification. An info + * notification is still shown listing any plugins that were + * updated, and error notifications are shown on failure. + */ + readonly silent?: boolean; +} + +export interface IUpdateAllPluginsResult { + /** Names of plugins/marketplaces that were updated successfully. */ + readonly updatedNames: readonly string[]; + /** Names of plugins/marketplaces that failed to update. */ + readonly failedNames: readonly string[]; +} + export interface IPluginInstallService { readonly _serviceBrand: undefined; @@ -22,7 +46,14 @@ export interface IPluginInstallService { /** * Pulls the latest changes for an already-cloned marketplace repository. */ - updatePlugin(plugin: IMarketplacePlugin): Promise; + updatePlugin(plugin: IMarketplacePlugin): Promise; + + /** + * Updates all installed plugins. First pulls each unique marketplace + * repository, then updates non-relative-path plugins individually + * (git pull, npm install, pip install, etc.). + */ + updateAllPlugins(options: IUpdateAllPluginsOptions, token: CancellationToken): Promise; /** * Returns the URI where a marketplace plugin would be installed on disk. diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 11a8db54823..d67472945cb 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -3,13 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { runWhenGlobalIdle } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Event } from '../../../../../base/common/event.js'; import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Lazy } from '../../../../../base/common/lazy.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { revive } from '../../../../../base/common/marshalling.js'; -import { IObservable } from '../../../../../base/common/observable.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; import { isEqual, isEqualOrParent, joinPath, normalizePath, relativePath } from '../../../../../base/common/resources.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -20,6 +21,7 @@ import { ObservableMemento, observableMemento } from '../../../../../platform/ob import { asJson, IRequestService } from '../../../../../platform/request/common/request.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import type { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; +import { AutoUpdateConfigurationKey, AutoUpdateConfigurationValue } from '../../../extensions/common/extensions.js'; import { ChatConfiguration } from '../constants.js'; import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js'; @@ -45,12 +47,64 @@ export interface IMarketplaceReference { readonly localRepositoryUri?: URI; } +export const enum PluginSourceKind { + RelativePath = 'relativePath', + GitHub = 'github', + GitUrl = 'url', + Npm = 'npm', + Pip = 'pip', +} + +export interface IRelativePathPluginSource { + readonly kind: PluginSourceKind.RelativePath; + /** Resolved relative path within the marketplace repository. */ + readonly path: string; +} + +export interface IGitHubPluginSource { + readonly kind: PluginSourceKind.GitHub; + readonly repo: string; + readonly ref?: string; + readonly sha?: string; +} + +export interface IGitUrlPluginSource { + readonly kind: PluginSourceKind.GitUrl; + /** Full git repository URL (must end with .git). */ + readonly url: string; + readonly ref?: string; + readonly sha?: string; +} + +export interface INpmPluginSource { + readonly kind: PluginSourceKind.Npm; + readonly package: string; + readonly version?: string; + readonly registry?: string; +} + +export interface IPipPluginSource { + readonly kind: PluginSourceKind.Pip; + readonly package: string; + readonly version?: string; + readonly registry?: string; +} + +export type IPluginSourceDescriptor = + | IRelativePathPluginSource + | IGitHubPluginSource + | IGitUrlPluginSource + | INpmPluginSource + | IPipPluginSource; + export interface IMarketplacePlugin { readonly name: string; readonly description: string; readonly version: string; - /** Subdirectory within the repository where the plugin lives. */ + /** Subdirectory within the repository where the plugin lives (for relative-path sources). */ readonly source: string; + /** Structured source descriptor indicating how the plugin should be fetched/installed. */ + readonly sourceDescriptor: IPluginSourceDescriptor; /** Marketplace label shown in UI and plugin provenance. */ readonly marketplace: string; /** Canonical reference for clone/update/install location resolution. */ @@ -60,6 +114,18 @@ export interface IMarketplacePlugin { readonly readmeUri?: URI; } +/** Raw JSON shape of a remote plugin source object in marketplace.json. */ +interface IJsonPluginSource { + readonly source: string; + readonly repo?: string; + readonly url?: string; + readonly package?: string; + readonly ref?: string; + readonly sha?: string; + readonly version?: string; + readonly registry?: string; +} + interface IMarketplaceJson { readonly metadata?: { readonly pluginRoot?: string; @@ -68,7 +134,7 @@ interface IMarketplaceJson { readonly name?: string; readonly description?: string; readonly version?: string; - readonly source?: string; + readonly source?: string | IJsonPluginSource; }[]; } @@ -85,11 +151,29 @@ export interface IPluginMarketplaceService { readonly onDidChangeMarketplaces: Event; /** Installed marketplace plugins, backed by storage. */ readonly installedPlugins: IObservable; + /** + * Observable that is `true` when at least one cloned marketplace + * repository has upstream changes available. Checked periodically + * (approximately once per day) when `extensions.autoUpdate` is enabled. + */ + readonly hasUpdatesAvailable: IObservable; + /** + * Observable snapshot of the last {@link fetchMarketplacePlugins} result. + * Empty until the first fetch completes. Views should use this for + * synchronous outdated-detection instead of calling fetchMarketplacePlugins. + */ + readonly lastFetchedPlugins: IObservable; + /** Resets {@link hasUpdatesAvailable} to `false`. */ + clearUpdatesAvailable(): void; fetchMarketplacePlugins(token: CancellationToken): Promise; getMarketplacePluginMetadata(pluginUri: URI): IMarketplacePlugin | undefined; addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void; removeInstalledPlugin(pluginUri: URI): void; setInstalledPluginEnabled(pluginUri: URI, enabled: boolean): void; + /** Returns whether the given marketplace has been explicitly trusted by the user. */ + isMarketplaceTrusted(ref: IMarketplaceReference): boolean; + /** Records that the user trusts the given marketplace, persisted permanently. */ + trustMarketplace(ref: IMarketplaceReference): void; } /** @@ -104,6 +188,13 @@ const MARKETPLACE_DEFINITIONS: { type: MarketplaceType; path: string }[] = [ const GITHUB_MARKETPLACE_CACHE_TTL_MS = 8 * 60 * 60 * 1000; const GITHUB_MARKETPLACE_CACHE_STORAGE_KEY = 'chat.plugins.marketplaces.githubCache.v1'; +/** Interval between periodic plugin update checks (24 hours). */ +const PLUGIN_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +const PLUGIN_UPDATE_LAST_CHECK_STORAGE_KEY = 'chat.plugins.lastUpdateCheck.v1'; + +/** TTL for the lastFetchedPlugins cache (5 minutes). */ +const LAST_FETCHED_PLUGINS_TTL_MS = 5 * 60 * 1000; + interface IGitHubMarketplaceCacheEntry { readonly plugins: readonly IMarketplacePlugin[]; readonly expiresAt: number; @@ -118,6 +209,23 @@ interface IStoredInstalledPlugin { readonly enabled: boolean; } +/** + * Ensures that an {@link IMarketplacePlugin} loaded from storage has a + * {@link IMarketplacePlugin.sourceDescriptor sourceDescriptor}. Plugins + * persisted before the sourceDescriptor field was introduced will only + * have the legacy `source` string — this function synthesises a + * {@link PluginSourceKind.RelativePath} descriptor from it. + */ +function ensureSourceDescriptor(plugin: IMarketplacePlugin): IMarketplacePlugin { + if (plugin.sourceDescriptor) { + return plugin; + } + return { + ...plugin, + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: plugin.source }, + }; +} + const installedPluginsMemento = observableMemento({ defaultValue: [], key: 'chat.plugins.installed.v1', @@ -128,14 +236,49 @@ const installedPluginsMemento = observableMemento({ + defaultValue: [], + key: 'chat.plugins.trustedMarketplaces.v1', + toStorage: value => JSON.stringify(value), + fromStorage: value => { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + }, +}); + +interface IStoredLastFetchedPlugins { + readonly plugins: readonly IMarketplacePlugin[]; + readonly fetchedAt: number; + readonly configFingerprint: string; +} + +const lastFetchedPluginsMemento = observableMemento({ + defaultValue: { plugins: [], fetchedAt: 0, configFingerprint: '' }, + key: 'chat.plugins.lastFetchedPlugins.v2', + toStorage: value => JSON.stringify(value), + fromStorage: value => { + const parsed = JSON.parse(value); + if (parsed && Array.isArray(parsed.plugins)) { + return parsed; + } + return { plugins: [], fetchedAt: 0, configFingerprint: '' }; + }, +}); + export class PluginMarketplaceService extends Disposable implements IPluginMarketplaceService { declare readonly _serviceBrand: undefined; private readonly _gitHubMarketplaceCache = new Lazy>(() => this._loadPersistedGitHubMarketplaceCache()); private readonly _installedPluginsStore: ObservableMemento; + private readonly _trustedMarketplacesStore: ObservableMemento; + private readonly _lastFetchedPluginsStore: ObservableMemento; + private readonly _hasUpdatesAvailable = observableValue('hasUpdatesAvailable', false); + private _updateCheckTimer: ReturnType | undefined; readonly onDidChangeMarketplaces: Event; readonly installedPlugins: IObservable; + readonly hasUpdatesAvailable: IObservable = this._hasUpdatesAvailable; + readonly lastFetchedPlugins: IObservable; constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -151,12 +294,51 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke installedPluginsMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService) ); - this.installedPlugins = this._installedPluginsStore.map(s => revive(s)); + this._trustedMarketplacesStore = this._register( + trustedMarketplacesMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService) + ); + + this._lastFetchedPluginsStore = this._register( + lastFetchedPluginsMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService) + ); + + this.lastFetchedPlugins = this._lastFetchedPluginsStore.map(s => { + const revived = revive(s) as IStoredLastFetchedPlugins; + return revived.plugins.map(ensureSourceDescriptor); + }); + + this.installedPlugins = this._installedPluginsStore.map(s => + (revive(s) as readonly IMarketplaceInstalledPlugin[]).map(e => ({ + ...e, + plugin: ensureSourceDescriptor(e.plugin), + })) + ); this.onDidChangeMarketplaces = Event.filter( _configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.PluginsEnabled) || e.affectsConfiguration(ChatConfiguration.PluginMarketplaces), ) as Event as Event; + + this._register(runWhenGlobalIdle(() => { + // Schedule periodic update checks when auto-update is enabled. + this._scheduleUpdateCheck(); + this._register(Event.filter( + _configurationService.onDidChangeConfiguration, + e => e.affectsConfiguration(AutoUpdateConfigurationKey), + )(() => this._scheduleUpdateCheck())); + })); + } + + override dispose(): void { + if (this._updateCheckTimer !== undefined) { + clearTimeout(this._updateCheckTimer); + this._updateCheckTimer = undefined; + } + super.dispose(); + } + + clearUpdatesAvailable(): void { + this._hasUpdatesAvailable.set(false, undefined); } async fetchMarketplacePlugins(token: CancellationToken): Promise { @@ -167,6 +349,16 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke const configuredRefs = this._configurationService.getValue(ChatConfiguration.PluginMarketplaces) ?? []; const refs = parseMarketplaceReferences(configuredRefs); + // Return cached results if recent and the marketplace config is unchanged. + const configFingerprint = refs.map(r => r.canonicalId).sort().join('\n'); + const stored = this._lastFetchedPluginsStore.get(); + if (stored.configFingerprint === configFingerprint && Date.now() - stored.fetchedAt < LAST_FETCHED_PLUGINS_TTL_MS) { + const cached = this.lastFetchedPlugins.get(); + if (cached.length > 0) { + return [...cached]; + } + } + for (const value of configuredRefs) { if (typeof value !== 'string' || !parseMarketplaceReference(value)) { this._logService.debug(`[PluginMarketplaceService] Ignoring invalid marketplace entry: ${String(value)}`); @@ -181,7 +373,9 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke return this._fetchFromClonedRepo(ref, token); }) ); - return results.flat(); + const plugins = results.flat(); + this._lastFetchedPluginsStore.set({ plugins, fetchedAt: Date.now(), configFingerprint }, undefined); + return plugins; } private async _fetchFromGitHubRepo(reference: IMarketplaceReference, repo: string, token: CancellationToken): Promise { @@ -213,21 +407,27 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke continue; } const plugins = json.plugins - .filter((p): p is { name: string; description?: string; version?: string; source?: string } => + .filter((p): p is { name: string; description?: string; version?: string; source?: string | IJsonPluginSource } => typeof p.name === 'string' && !!p.name ) .flatMap(p => { - const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? ''); - if (source === undefined) { - this._logService.warn(`[PluginMarketplaceService] Skipping plugin '${p.name}' in ${repo}: invalid source path '${p.source ?? ''}' with pluginRoot '${json.metadata?.pluginRoot ?? ''}'`); + const sourceDescriptor = parsePluginSource(p.source, json.metadata?.pluginRoot, { + pluginName: p.name, + logService: this._logService, + logPrefix: `[PluginMarketplaceService]`, + }); + if (!sourceDescriptor) { return []; } + const source = sourceDescriptor.kind === PluginSourceKind.RelativePath ? sourceDescriptor.path : ''; + return [{ name: p.name, description: p.description ?? '', version: p.version ?? '', source, + sourceDescriptor, marketplace: reference.displayLabel, marketplaceReference: reference, marketplaceType: def.type, @@ -293,7 +493,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke continue; } - const plugins = entry.plugins.map(plugin => ({ + const plugins = entry.plugins.map(plugin => ensureSourceDescriptor({ ...plugin, marketplace: reference.displayLabel, marketplaceReference: reference, @@ -343,10 +543,13 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void { const current = this.installedPlugins.get(); - if (current.some(e => isEqual(e.pluginUri, pluginUri))) { - return; + const existing = current.find(e => isEqual(e.pluginUri, pluginUri)); + if (existing) { + // Still update to trigger watchers to re-check, something might have happened that we want to know about + this._installedPluginsStore.set(current.map(c => c === existing ? { pluginUri, plugin, enabled: existing.enabled } : c), undefined); + } else { + this._installedPluginsStore.set([...current, { pluginUri, plugin, enabled: true }], undefined); } - this._installedPluginsStore.set([...current, { pluginUri, plugin, enabled: true }], undefined); } removeInstalledPlugin(pluginUri: URI): void { @@ -362,6 +565,95 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke ); } + isMarketplaceTrusted(ref: IMarketplaceReference): boolean { + return this._trustedMarketplacesStore.get().includes(ref.canonicalId); + } + + trustMarketplace(ref: IMarketplaceReference): void { + const current = this._trustedMarketplacesStore.get(); + if (!current.includes(ref.canonicalId)) { + this._trustedMarketplacesStore.set([...current, ref.canonicalId], undefined); + } + } + + // --- Periodic update check ------------------------------------------------ + + private _isAutoUpdateEnabled(): AutoUpdateConfigurationValue { + return this._configurationService.getValue(AutoUpdateConfigurationKey); + } + + /** + * (Re-)schedules the next periodic update check. Called on + * construction and whenever the auto-update config changes. + */ + private _scheduleUpdateCheck(): void { + if (this._updateCheckTimer !== undefined) { + clearTimeout(this._updateCheckTimer); + this._updateCheckTimer = undefined; + } + + if (!this._isAutoUpdateEnabled()) { + return; + } + + const lastCheck = this._storageService.getNumber( + PLUGIN_UPDATE_LAST_CHECK_STORAGE_KEY, + StorageScope.APPLICATION, + 0, + ); + const elapsed = Date.now() - lastCheck; + const delay = Math.max(0, PLUGIN_UPDATE_CHECK_INTERVAL_MS - elapsed); + + this._updateCheckTimer = setTimeout(() => this._runUpdateCheck(), delay); + } + + private async _runUpdateCheck(): Promise { + this._updateCheckTimer = undefined; + + try { + const installed = this.installedPlugins.get().filter(e => e.enabled); + if (installed.length === 0) { + return; + } + + const seenMarketplaces = new Set(); + let hasUpdates = false; + + for (const entry of installed) { + const ref = entry.plugin.marketplaceReference; + if (seenMarketplaces.has(ref.canonicalId)) { + continue; + } + seenMarketplaces.add(ref.canonicalId); + + try { + const behind = await this._pluginRepositoryService.fetchRepository(ref); + if (behind) { + hasUpdates = true; + break; + } + } catch (err) { + this._logService.debug(`[PluginMarketplaceService] Update check failed for ${ref.displayLabel}:`, err); + } + } + + this._hasUpdatesAvailable.set(hasUpdates, undefined); + this._storageService.store( + PLUGIN_UPDATE_LAST_CHECK_STORAGE_KEY, + Date.now(), + StorageScope.APPLICATION, + StorageTarget.MACHINE, + ); + } catch (err) { + this._logService.debug('[PluginMarketplaceService] Periodic update check failed:', err); + } finally { + // Reschedule for the next check + if (this._isAutoUpdateEnabled()) { + this._updateCheckTimer = setTimeout(() => this._runUpdateCheck(), PLUGIN_UPDATE_CHECK_INTERVAL_MS); + } + } + } + private async _fetchFromClonedRepo(reference: IMarketplaceReference, token: CancellationToken): Promise { let repoDir: URI; try { @@ -391,21 +683,27 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } return json.plugins - .filter((p): p is { name: string; description?: string; version?: string; source?: string } => + .filter((p): p is { name: string; description?: string; version?: string; source?: string | IJsonPluginSource } => typeof p.name === 'string' && !!p.name ) .flatMap(p => { - const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? ''); - if (source === undefined) { - this._logService.warn(`[PluginMarketplaceService] Skipping plugin '${p.name}' in ${reference.rawValue}: invalid source path '${p.source ?? ''}' with pluginRoot '${json.metadata?.pluginRoot ?? ''}'`); + const sourceDescriptor = parsePluginSource(p.source, json.metadata?.pluginRoot, { + pluginName: p.name, + logService: this._logService, + logPrefix: `[PluginMarketplaceService]`, + }); + if (!sourceDescriptor) { return []; } + const source = sourceDescriptor.kind === PluginSourceKind.RelativePath ? sourceDescriptor.path : ''; + return [{ name: p.name, description: p.description ?? '', version: p.version ?? '', source, + sourceDescriptor, marketplace: reference.displayLabel, marketplaceReference: reference, marketplaceType: def.type, @@ -509,13 +807,18 @@ function parseUriMarketplaceReference(rawValue: string): IMarketplaceReference | return undefined; } + const gitSuffix = '.git'; const sanitizedAuthority = sanitizePathSegment(uri.authority.toLowerCase()); - const pathSegments = normalizedPath.slice(1, -4).split('/').map(sanitizePathSegment); + const pathHasGitSuffix = normalizedPath.toLowerCase().endsWith(gitSuffix); + const pathWithoutGit = pathHasGitSuffix ? normalizedPath.slice(1, normalizedPath.length - gitSuffix.length) : normalizedPath.slice(1); + const pathSegments = pathWithoutGit.split('/').map(sanitizePathSegment); + // Always normalize the canonical path to include .git so that URLs with and without the suffix deduplicate. + const canonicalPath = pathHasGitSuffix ? normalizedPath.slice(1).toLowerCase() : `${normalizedPath.slice(1).toLowerCase()}${gitSuffix}`; return { rawValue, displayLabel: rawValue, cloneUrl: rawValue, - canonicalId: `git:${uri.authority.toLowerCase()}/${normalizedPath.slice(1).toLowerCase()}`, + canonicalId: `git:${uri.authority.toLowerCase()}/${canonicalPath}`, cacheSegments: [sanitizedAuthority, ...pathSegments], kind: MarketplaceReferenceKind.GitUri, }; @@ -544,14 +847,21 @@ function parseScpMarketplaceReference(rawValue: string): IMarketplaceReference | }; } +/** + * Normalizes a Git repository path and validates that it has at least two segments + * (i.e., at least one owner/repo pair below the root). Accepts paths with or without + * a `.git` suffix — the suffix is preserved in the returned value so callers can decide + * how to treat it. + */ function normalizeGitRepoPath(path: string): string | undefined { + const gitSuffix = '.git'; const trimmed = path.replace(/\/+/g, '/').replace(/\/+$/g, ''); - if (!trimmed.toLowerCase().endsWith('.git')) { - return undefined; - } const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; - const pathWithoutGit = withLeadingSlash.slice(1, -4); + // Strip .git suffix (if present) only for the purposes of validating path depth. + const pathWithoutGit = withLeadingSlash.toLowerCase().endsWith(gitSuffix) + ? withLeadingSlash.slice(1, withLeadingSlash.length - gitSuffix.length) + : withLeadingSlash.slice(1); if (!pathWithoutGit || !pathWithoutGit.includes('/')) { return undefined; } @@ -597,6 +907,183 @@ function resolvePluginSource(pluginRoot: string | undefined, source: string): st return relativePath(repoRoot, resolvedUri) ?? undefined; } +/** + * Parse a raw `source` field from marketplace.json into a structured + * {@link IPluginSourceDescriptor}. Accepts either a relative-path string + * or a JSON object with a `source` discriminant indicating the kind. + */ +export function parsePluginSource( + rawSource: string | IJsonPluginSource | undefined, + pluginRoot: string | undefined, + logContext: { pluginName: string; logService: ILogService; logPrefix: string }, +): IPluginSourceDescriptor | undefined { + if (rawSource === undefined || rawSource === null) { + // Treat missing source the same as empty string → pluginRoot or repo root. + const resolved = resolvePluginSource(pluginRoot, ''); + if (resolved === undefined) { + return undefined; + } + return { kind: PluginSourceKind.RelativePath, path: resolved }; + } + + // String source → legacy relative-path behaviour. + if (typeof rawSource === 'string') { + const resolved = resolvePluginSource(pluginRoot, rawSource); + if (resolved === undefined) { + return undefined; + } + return { kind: PluginSourceKind.RelativePath, path: resolved }; + } + + // Object source → discriminated by `rawSource.source`. + if (typeof rawSource !== 'object' || typeof rawSource.source !== 'string') { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': source object is missing a 'source' discriminant`); + return undefined; + } + + switch (rawSource.source) { + case 'github': { + if (typeof rawSource.repo !== 'string' || !rawSource.repo) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source is missing required 'repo' field`); + return undefined; + } + if (!isValidGitHubRepo(rawSource.repo)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source repo must be in 'owner/repo' format`); + return undefined; + } + if (!isOptionalString(rawSource.ref)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'ref' must be a string when provided`); + return undefined; + } + if (!isOptionalGitSha(rawSource.sha)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'sha' must be a full 40-character commit hash when provided`); + return undefined; + } + return { + kind: PluginSourceKind.GitHub, + repo: rawSource.repo, + ref: rawSource.ref, + sha: rawSource.sha, + }; + } + case 'url': { + if (typeof rawSource.url !== 'string' || !rawSource.url) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source is missing required 'url' field`); + return undefined; + } + if (!rawSource.url.toLowerCase().endsWith('.git')) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source must end with '.git'`); + return undefined; + } + if (!isOptionalString(rawSource.ref)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source 'ref' must be a string when provided`); + return undefined; + } + if (!isOptionalGitSha(rawSource.sha)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source 'sha' must be a full 40-character commit hash when provided`); + return undefined; + } + return { + kind: PluginSourceKind.GitUrl, + url: rawSource.url, + ref: rawSource.ref, + sha: rawSource.sha, + }; + } + case 'npm': { + if (typeof rawSource.package !== 'string' || !rawSource.package) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': npm source is missing required 'package' field`); + return undefined; + } + if (!isOptionalString(rawSource.version) || !isOptionalString(rawSource.registry)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': npm source 'version' and 'registry' must be strings when provided`); + return undefined; + } + return { + kind: PluginSourceKind.Npm, + package: rawSource.package, + version: rawSource.version, + registry: rawSource.registry, + }; + } + case 'pip': { + if (typeof rawSource.package !== 'string' || !rawSource.package) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': pip source is missing required 'package' field`); + return undefined; + } + if (!isOptionalString(rawSource.version) || !isOptionalString(rawSource.registry)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': pip source 'version' and 'registry' must be strings when provided`); + return undefined; + } + return { + kind: PluginSourceKind.Pip, + package: rawSource.package, + version: rawSource.version, + registry: rawSource.registry, + }; + } + default: + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': unknown source kind '${rawSource.source}'`); + return undefined; + } +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === 'string'; +} + +function isOptionalGitSha(value: unknown): value is string | undefined { + return value === undefined || (typeof value === 'string' && /^[0-9a-fA-F]{40}$/.test(value)); +} + +function isValidGitHubRepo(repo: string): boolean { + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo); +} + +/** + * Returns a human-readable label for a plugin source descriptor, + * suitable for error messages and UI display. + */ +export function getPluginSourceLabel(descriptor: IPluginSourceDescriptor): string { + switch (descriptor.kind) { + case PluginSourceKind.RelativePath: + return descriptor.path || '.'; + case PluginSourceKind.GitHub: + return descriptor.repo; + case PluginSourceKind.GitUrl: + return descriptor.url; + case PluginSourceKind.Npm: + return descriptor.version ? `${descriptor.package}@${descriptor.version}` : descriptor.package; + case PluginSourceKind.Pip: + return descriptor.version ? `${descriptor.package}==${descriptor.version}` : descriptor.package; + } +} + +/** + * Returns `true` when the marketplace source descriptor differs from the + * installed one — meaning an update should be performed. + */ +export function hasSourceChanged(installed: IPluginSourceDescriptor, marketplace: IPluginSourceDescriptor): boolean { + if (installed.kind !== marketplace.kind) { + return true; + } + + switch (installed.kind) { + case PluginSourceKind.GitHub: + return installed.ref !== (marketplace as typeof installed).ref + || installed.sha !== (marketplace as typeof installed).sha; + case PluginSourceKind.GitUrl: + return installed.ref !== (marketplace as typeof installed).ref + || installed.sha !== (marketplace as typeof installed).sha; + case PluginSourceKind.Npm: + return installed.version !== (marketplace as typeof installed).version; + case PluginSourceKind.Pip: + return installed.version !== (marketplace as typeof installed).version; + default: + return false; + } +} + function getMarketplaceReadmeUri(repo: string, source: string): URI { const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, ''); const readmePath = normalizedSource ? `${normalizedSource}/README.md` : 'README.md'; diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginSource.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginSource.ts new file mode 100644 index 00000000000..dd5cd838580 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginSource.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { IEnsureRepositoryOptions, IPullRepositoryOptions } from './agentPluginRepositoryService.js'; +import { IMarketplacePlugin, IPluginSourceDescriptor, PluginSourceKind } from './pluginMarketplaceService.js'; + +/** + * Per-kind strategy that centralizes install-path computation, source + * provisioning, update, label formatting, and uninstall cleanup for a + * single {@link PluginSourceKind}. + * + * Implementations are created via {@link IInstantiationService} so they + * can dependency-inject any services they need (git commands, file service, + * terminal service, etc.). + */ +export interface IPluginSource { + readonly kind: PluginSourceKind; + + /** + * Compute the local cache URI where this source's plugin files live. + * @param cacheRoot The root cache directory for all agent plugins. + */ + getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI; + + /** + * Ensure the plugin source is available locally (clone, npm install, etc.). + * Returns the install directory URI. + */ + ensure(cacheRoot: URI, plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise; + + /** + * Update an already-installed plugin source (git pull, npm update, etc.). + * Returns `true` if the update brought in new changes. + */ + update(cacheRoot: URI, plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise; + + /** + * Returns the on-disk directory to delete when this plugin is + * uninstalled, or `undefined` if no cleanup is needed. + * + * Marketplace-relative sources return `undefined` because they share + * a marketplace repository cache. Direct sources (github, url, npm, + * pip) return the directory they own. + */ + getCleanupTarget(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI | undefined; + + /** + * Returns a human-readable label for a source descriptor of this kind, + * suitable for error messages and UI display. + */ + getLabel(descriptor: IPluginSourceDescriptor): string; + + /** + * For package-manager sources (npm, pip): run the terminal install + * command and return the resulting plugin directory, or `undefined` + * if the user cancelled or the command failed. + * + * Not implemented by non-package-manager sources. + */ + runInstall?(installDir: URI, pluginDir: URI, plugin: IMarketplacePlugin, options?: { silent?: boolean }): Promise<{ pluginDir: URI } | undefined>; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 16b5459a50c..f3fbb1a8c1e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -109,9 +109,9 @@ export class ComputeAutomaticInstructions { // get copilot instructions await this._addAgentInstructions(variables, telemetryEvent, token); - const instructionsListVariable = await this._getInstructionsWithPatternsList(instructionFiles, variables, telemetryEvent, token); - if (instructionsListVariable) { - variables.add(instructionsListVariable); + const customizationsIndexVariable = await this._getCustomizationsIndex(instructionFiles, variables, telemetryEvent, token); + if (customizationsIndexVariable) { + variables.add(customizationsIndexVariable); telemetryEvent.listedInstructionsCount++; } @@ -293,7 +293,7 @@ export class ComputeAutomaticInstructions { return undefined; } - private async _getInstructionsWithPatternsList(instructionFiles: readonly IPromptPath[], _existingVariables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise { + private async _getCustomizationsIndex(instructionFiles: readonly IPromptPath[], _existingVariables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise { const readTool = this._getTool('readFile'); const runSubagentTool = this._getTool(VSCodeToolReference.runSubagent); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index 1f0b9da69ca..4c7497b9f7d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -115,6 +115,11 @@ export namespace PromptsConfig { */ export const USE_CLAUDE_HOOKS = 'chat.useClaudeHooks'; + /** + * Configuration key for enabling hooks defined in custom agent frontmatter. + */ + export const USE_CUSTOM_AGENT_HOOKS = 'chat.useCustomAgentHooks'; + /** * Configuration key for enabling stronger skill adherence prompt (experimental). */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts index eb567363a8e..9311488d61c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -4,23 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../base/common/uri.js'; -import { HookType, IHookCommand, toHookType, resolveHookCommand } from './hookSchema.js'; +import { toHookType, IHookCommand, extractHookCommandsFromItem } from './hookSchema.js'; +import { HOOKS_BY_TARGET, HookType } from './hookTypes.js'; +import { Target } from './promptTypes.js'; -/** - * Maps Claude hook type names to our abstract HookType. - * Claude uses PascalCase and slightly different names. - * @see https://docs.anthropic.com/en/docs/claude-code/hooks - */ -export const CLAUDE_HOOK_TYPE_MAP: Record = { - 'SessionStart': HookType.SessionStart, - 'UserPromptSubmit': HookType.UserPromptSubmit, - 'PreToolUse': HookType.PreToolUse, - 'PostToolUse': HookType.PostToolUse, - 'PreCompact': HookType.PreCompact, - 'SubagentStart': HookType.SubagentStart, - 'SubagentStop': HookType.SubagentStop, - 'Stop': HookType.Stop, -}; +export { extractHookCommandsFromItem }; /** * Cached inverse mapping from HookType to Claude hook type name. @@ -31,7 +19,7 @@ let _hookTypeToClaudeName: Map | undefined; function getHookTypeToClaudeNameMap(): Map { if (!_hookTypeToClaudeName) { _hookTypeToClaudeName = new Map(); - for (const [claudeName, hookType] of Object.entries(CLAUDE_HOOK_TYPE_MAP)) { + for (const [claudeName, hookType] of Object.entries(HOOKS_BY_TARGET[Target.Claude])) { _hookTypeToClaudeName.set(hookType, claudeName); } } @@ -42,7 +30,7 @@ function getHookTypeToClaudeNameMap(): Map { * Resolves a Claude hook type name to our abstract HookType. */ export function resolveClaudeHookType(name: string): HookType | undefined { - return CLAUDE_HOOK_TYPE_MAP[name]; + return HOOKS_BY_TARGET[Target.Claude][name]; } /** @@ -146,60 +134,4 @@ export function parseClaudeHooks( return { hooks: result, disabledAllHooks: false }; } -/** - * Helper to extract hook commands from an item that could be: - * 1. A direct command object: { type: 'command', command: '...' } - * 2. A nested structure with matcher (Claude style): { matcher: '...', hooks: [{ type: 'command', command: '...' }] } - * - * This allows Copilot format to handle Claude-style entries if pasted. - * Also handles Claude's leniency where 'type' field can be omitted. - */ -export function extractHookCommandsFromItem( - item: unknown, - workspaceRootUri: URI | undefined, - userHome: string -): IHookCommand[] { - if (!item || typeof item !== 'object') { - return []; - } - const itemObj = item as Record; - const commands: IHookCommand[] = []; - - // Check for nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] } - const nestedHooks = itemObj.hooks; - if (nestedHooks !== undefined && Array.isArray(nestedHooks)) { - for (const nestedHook of nestedHooks) { - if (!nestedHook || typeof nestedHook !== 'object') { - continue; - } - const normalized = normalizeForResolve(nestedHook as Record); - const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); - if (resolved) { - commands.push(resolved); - } - } - } else { - // Direct command object - const normalized = normalizeForResolve(itemObj); - const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); - if (resolved) { - commands.push(resolved); - } - } - - return commands; -} - -/** - * Normalizes a hook command object for resolving. - * Claude format allows omitting the 'type' field, treating it as 'command'. - * This ensures compatibility when Claude-style hooks are pasted into Copilot format. - */ -function normalizeForResolve(raw: Record): Record { - // If type is missing or already 'command', ensure it's set to 'command' - if (raw.type === undefined || raw.type === 'command') { - return { ...raw, type: 'command' }; - } - return raw; -} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index d00fd26cb1e..64e46956b17 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -5,9 +5,10 @@ import { URI } from '../../../../../base/common/uri.js'; import { basename, dirname } from '../../../../../base/common/path.js'; -import { HookType, IHookCommand, toHookType } from './hookSchema.js'; +import { IHookCommand, toHookType } from './hookSchema.js'; import { parseClaudeHooks, extractHookCommandsFromItem } from './hookClaudeCompat.js'; import { resolveCopilotCliHookType } from './hookCopilotCliCompat.js'; +import { HookType } from './hookTypes.js'; /** * Represents a hook source with its original and normalized properties. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCopilotCliCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCopilotCliCompat.ts index 587771544b2..9bf9c6b1076 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCopilotCliCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCopilotCliCompat.ts @@ -3,7 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { COPILOT_CLI_HOOK_TYPE_MAP, HookType } from './hookSchema.js'; +import { HOOKS_BY_TARGET, HookType } from './hookTypes.js'; +import { Target } from './promptTypes.js'; + +const COPILOT_CLI_HOOK_TYPE_MAP: Record = HOOKS_BY_TARGET[Target.GitHubCopilot]; /** * Cached inverse mapping from HookType to Copilot CLI hook type name. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index 62fee88c20e..8025b3ea675 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -10,82 +10,9 @@ import { joinPath } from '../../../../../base/common/resources.js'; import { isAbsolute } from '../../../../../base/common/path.js'; import { untildify } from '../../../../../base/common/labels.js'; import { OperatingSystem } from '../../../../../base/common/platform.js'; - -/** - * Enum of available hook types that can be configured in hooks .json - */ -export enum HookType { - SessionStart = 'SessionStart', - UserPromptSubmit = 'UserPromptSubmit', - PreToolUse = 'PreToolUse', - PostToolUse = 'PostToolUse', - PreCompact = 'PreCompact', - SubagentStart = 'SubagentStart', - SubagentStop = 'SubagentStop', - Stop = 'Stop', -} - -/** - * Maps Copilot CLI hook type names to our abstract HookType. - * Copilot CLI uses camelCase names. - */ -export const COPILOT_CLI_HOOK_TYPE_MAP = { - 'sessionStart': HookType.SessionStart, - 'userPromptSubmitted': HookType.UserPromptSubmit, - 'preToolUse': HookType.PreToolUse, - 'postToolUse': HookType.PostToolUse, -} as const satisfies Record; - -/** - * String literal type derived from HookType enum values. - */ -export type HookTypeValue = `${HookType}`; - -/** - * Metadata for hook types including localized labels and descriptions - */ -export const HOOK_TYPES = [ - { - id: HookType.SessionStart, - label: nls.localize('hookType.sessionStart.label', "Session Start"), - description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins.") - }, - { - id: HookType.UserPromptSubmit, - label: nls.localize('hookType.userPromptSubmit.label', "User Prompt Submit"), - description: nls.localize('hookType.userPromptSubmit.description', "Executed when the user submits a prompt to the agent.") - }, - { - id: HookType.PreToolUse, - label: nls.localize('hookType.preToolUse.label', "Pre-Tool Use"), - description: nls.localize('hookType.preToolUse.description', "Executed before the agent uses any tool.") - }, - { - id: HookType.PostToolUse, - label: nls.localize('hookType.postToolUse.label', "Post-Tool Use"), - description: nls.localize('hookType.postToolUse.description', "Executed after a tool completes execution successfully.") - }, - { - id: HookType.PreCompact, - label: nls.localize('hookType.preCompact.label', "Pre-Compact"), - description: nls.localize('hookType.preCompact.description', "Executed before the agent compacts the conversation context.") - }, - { - id: HookType.SubagentStart, - label: nls.localize('hookType.subagentStart.label', "Subagent Start"), - description: nls.localize('hookType.subagentStart.description', "Executed when a subagent is started.") - }, - { - id: HookType.SubagentStop, - label: nls.localize('hookType.subagentStop.label', "Subagent Stop"), - description: nls.localize('hookType.subagentStop.description', "Executed when a subagent stops.") - }, - { - id: HookType.Stop, - label: nls.localize('hookType.stop.label', "Stop"), - description: nls.localize('hookType.stop.description', "Executed when the agent stops.") - } -] as const; +import { HookType, HOOKS_BY_TARGET, HOOK_METADATA } from './hookTypes.js'; +import { Target } from './promptTypes.js'; +import { IValue, IMapValue } from './promptFileParser.js'; /** * A single hook command configuration. @@ -116,22 +43,52 @@ export interface IHookCommand { * Collected hooks for a chat request, organized by hook type. * This is passed to the extension host so it knows what hooks are available. */ -export interface IChatRequestHooks { - readonly [HookType.SessionStart]?: readonly IHookCommand[]; - readonly [HookType.UserPromptSubmit]?: readonly IHookCommand[]; - readonly [HookType.PreToolUse]?: readonly IHookCommand[]; - readonly [HookType.PostToolUse]?: readonly IHookCommand[]; - readonly [HookType.PreCompact]?: readonly IHookCommand[]; - readonly [HookType.SubagentStart]?: readonly IHookCommand[]; - readonly [HookType.SubagentStop]?: readonly IHookCommand[]; - readonly [HookType.Stop]?: readonly IHookCommand[]; +export type ChatRequestHooks = { + readonly [K in HookType]?: readonly IHookCommand[]; +}; + +/** + * Merges two sets of hooks by concatenating the command arrays for each hook type. + * Additional hooks are appended after the base hooks. + */ +export function mergeHooks(base: ChatRequestHooks | undefined, additional: ChatRequestHooks): ChatRequestHooks { + if (!base) { + return additional; + } + + const result: Partial> = { ...base }; + for (const hookType of Object.values(HookType)) { + const baseArr = base[hookType]; + const additionalArr = additional[hookType]; + if (additionalArr && additionalArr.length > 0) { + result[hookType] = baseArr ? [...baseArr, ...additionalArr] : additionalArr; + } + } + return result as ChatRequestHooks; } +/** + * Descriptions for hook command fields, used by both the JSON schema and the hover provider. + */ +export const HOOK_COMMAND_FIELD_DESCRIPTIONS: Record = { + type: nls.localize('hook.type', 'Must be "command".'), + command: nls.localize('hook.command', 'The command to execute. This is the default cross-platform command.'), + windows: nls.localize('hook.windows', 'Windows-specific command. If specified and running on Windows, this overrides the "command" field.'), + linux: nls.localize('hook.linux', 'Linux-specific command. If specified and running on Linux, this overrides the "command" field.'), + osx: nls.localize('hook.osx', 'macOS-specific command. If specified and running on macOS, this overrides the "command" field.'), + bash: nls.localize('hook.bash', 'Bash command for Linux and macOS.'), + powershell: nls.localize('hook.powershell', 'PowerShell command for Windows.'), + cwd: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).'), + env: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.'), + timeout: nls.localize('hook.timeout', 'Maximum execution time in seconds (default: 30).'), + timeoutSec: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 10).'), +}; + /** * JSON Schema for GitHub Copilot hook configuration files. * Hooks enable executing custom shell commands at strategic points in an agent's workflow. */ -const hookCommandSchema: IJSONSchema = { +const vscodeHookCommandSchema: IJSONSchema = { type: 'object', additionalProperties: true, required: ['type'], @@ -148,83 +105,63 @@ const hookCommandSchema: IJSONSchema = { type: { type: 'string', enum: ['command'], - description: nls.localize('hook.type', 'Must be "command".') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.type }, command: { type: 'string', - description: nls.localize('hook.command', 'The command to execute. This is the default cross-platform command.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.command }, windows: { type: 'string', - description: nls.localize('hook.windows', 'Windows-specific command. If specified and running on Windows, this overrides the "command" field.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.windows }, linux: { type: 'string', - description: nls.localize('hook.linux', 'Linux-specific command. If specified and running on Linux, this overrides the "command" field.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.linux }, osx: { type: 'string', - description: nls.localize('hook.osx', 'macOS-specific command. If specified and running on macOS, this overrides the "command" field.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.osx }, cwd: { type: 'string', - description: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.cwd }, env: { type: 'object', additionalProperties: { type: 'string' }, - description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.env }, timeout: { type: 'number', default: 30, - description: nls.localize('hook.timeout', 'Maximum execution time in seconds (default: 30).') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.timeout } } }; const hookArraySchema: IJSONSchema = { type: 'array', - items: hookCommandSchema + items: vscodeHookCommandSchema }; /** - * Hook properties for the VS Code / PascalCase format. + * Builds JSON Schema hook properties for a given target by looking up + * the hook keys from HOOKS_BY_TARGET and descriptions from HOOK_METADATA. */ -const vscodeHookProperties: { [key in HookType]: IJSONSchema } = { - SessionStart: { - ...hookArraySchema, - description: nls.localize('hookFile.sessionStart', 'Executed when a new agent session begins. Use to initialize environments, log session starts, validate project state, or set up temporary resources.') - }, - UserPromptSubmit: { - ...hookArraySchema, - description: nls.localize('hookFile.userPromptSubmit', 'Executed when the user submits a prompt to the agent. Use to log user requests for auditing and usage analysis.') - }, - PreToolUse: { - ...hookArraySchema, - description: nls.localize('hookFile.preToolUse', 'Executed before the agent uses any tool. This is the most powerful hook as it can approve or deny tool executions. Use to block dangerous commands, enforce security policies, require approval for sensitive operations, or log tool usage.') - }, - PostToolUse: { - ...hookArraySchema, - description: nls.localize('hookFile.postToolUse', 'Executed after a tool completes execution successfully. Use to log execution results, track usage statistics, generate audit trails, or monitor performance.') - }, - PreCompact: { - ...hookArraySchema, - description: nls.localize('hookFile.preCompact', 'Executed before the agent compacts the conversation context. Use to save conversation state, export important information, or prepare for context reduction.') - }, - SubagentStart: { - ...hookArraySchema, - description: nls.localize('hookFile.subagentStart', 'Executed when a subagent is started. Use to log subagent spawning, track nested agent usage, or initialize subagent-specific resources.') - }, - SubagentStop: { - ...hookArraySchema, - description: nls.localize('hookFile.subagentStop', 'Executed when a subagent stops. Use to log subagent completion, cleanup subagent resources, or aggregate subagent results.') - }, - Stop: { - ...hookArraySchema, - description: nls.localize('hookFile.stop', 'Executed when the agent session stops. Use to cleanup resources, generate final reports, or send completion notifications.') - } -}; +function buildHookProperties(target: Target, arraySchema: IJSONSchema): Record { + return Object.fromEntries( + Object.entries(HOOKS_BY_TARGET[target]).map(([key, hookType]) => [ + key, + { ...arraySchema, description: HOOK_METADATA[hookType]?.description } + ]) + ); +} + +/** + * Hook properties for the VS Code format. + */ +const vscodeHookProperties: Record = buildHookProperties(Target.VSCode, hookArraySchema); /** * Hook command schema for the Copilot CLI format. @@ -243,29 +180,29 @@ const copilotCliHookCommandSchema: IJSONSchema = { type: { type: 'string', enum: ['command'], - description: nls.localize('hook.type', 'Must be "command".') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.type }, bash: { type: 'string', - description: nls.localize('hook.bash', 'Bash command for Linux and macOS.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.bash }, powershell: { type: 'string', - description: nls.localize('hook.powershell', 'PowerShell command for Windows.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.powershell }, cwd: { type: 'string', - description: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.cwd }, env: { type: 'object', additionalProperties: { type: 'string' }, - description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.env }, timeoutSec: { type: 'number', default: 10, - description: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 10).') + description: HOOK_COMMAND_FIELD_DESCRIPTIONS.timeoutSec } } }; @@ -276,27 +213,9 @@ const copilotCliHookArraySchema: IJSONSchema = { }; /** - * Hook properties for the Copilot CLI / camelCase format. - * Maps from the Copilot CLI hook type names defined in COPILOT_CLI_HOOK_TYPE_MAP. + * Hook properties for the Copilot CLI format. */ -const copilotCliHookProperties: { [key in keyof typeof COPILOT_CLI_HOOK_TYPE_MAP]: IJSONSchema } = { - sessionStart: { - ...copilotCliHookArraySchema, - description: nls.localize('hookFile.cli.sessionStart', 'Executed when a new agent session begins.') - }, - userPromptSubmitted: { - ...copilotCliHookArraySchema, - description: nls.localize('hookFile.cli.userPromptSubmitted', 'Executed when the user submits a prompt to the agent.') - }, - preToolUse: { - ...copilotCliHookArraySchema, - description: nls.localize('hookFile.cli.preToolUse', 'Executed before the agent uses any tool. Can approve or deny tool executions.') - }, - postToolUse: { - ...copilotCliHookArraySchema, - description: nls.localize('hookFile.cli.postToolUse', 'Executed after a tool completes execution successfully.') - }, -}; +const copilotCliHookProperties: Record = buildHookProperties(Target.GitHubCopilot, copilotCliHookArraySchema); export const hookFileSchema: IJSONSchema = { $schema: 'http://json-schema.org/draft-07/schema#', @@ -369,11 +288,6 @@ export const hookFileSchema: IJSONSchema = { */ export const HOOK_SCHEMA_URI = 'vscode://schemas/hooks'; -/** - * Glob pattern for hook files. - */ -export const HOOK_FILE_GLOB = '.github/hooks/*.json'; - /** * Normalizes a raw hook type identifier to the canonical HookType enum value. * Only matches exact enum values. For tool-specific naming conventions (e.g., Claude, Copilot CLI), @@ -568,3 +482,155 @@ export function resolveHookCommand(raw: Record, workspaceRootUr ...(normalized.timeout !== undefined && { timeout: normalized.timeout }), }; } + +/** + * Helper to extract hook commands from an item that could be: + * 1. A direct command object: { type: 'command', command: '...' } + * 2. A nested structure with matcher (Claude style): { matcher: '...', hooks: [{ type: 'command', command: '...' }] } + * + * This allows Copilot format to handle Claude-style entries if pasted. + * Also handles Claude's leniency where 'type' field can be omitted. + */ +export function extractHookCommandsFromItem( + item: unknown, + workspaceRootUri: URI | undefined, + userHome: string +): IHookCommand[] { + if (!item || typeof item !== 'object') { + return []; + } + + const itemObj = item as Record; + const commands: IHookCommand[] = []; + + // Check for nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] } + const nestedHooks = itemObj.hooks; + if (nestedHooks !== undefined && Array.isArray(nestedHooks)) { + for (const nestedHook of nestedHooks) { + if (!nestedHook || typeof nestedHook !== 'object') { + continue; + } + const normalized = normalizeForResolve(nestedHook as Record); + const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + } else { + // Direct command object + const normalized = normalizeForResolve(itemObj); + const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + + return commands; +} + +/** + * Normalizes a hook command object for resolving. + * Claude format allows omitting the 'type' field, treating it as 'command'. + * This ensures compatibility when Claude-style hooks are pasted into Copilot format. + */ +function normalizeForResolve(raw: Record): Record { + // If type is missing or already 'command', ensure it's set to 'command' + if (raw.type === undefined || raw.type === 'command') { + return { ...raw, type: 'command' }; + } + return raw; +} + +/** + * Converts an {@link IValue} YAML AST node into a plain JavaScript value + * (string, array, or object) suitable for passing to hook parsing helpers. + */ +function yamlValueToPlain(value: IValue): unknown { + switch (value.type) { + case 'scalar': + return value.value; + case 'sequence': + return value.items.map(yamlValueToPlain); + case 'map': { + const obj: Record = {}; + for (const prop of value.properties) { + obj[prop.key.value] = yamlValueToPlain(prop.value); + } + return obj; + } + } +} + +/** + * Parses hooks from a subagent's YAML frontmatter `hooks` attribute. + * + * Supports two formats for hook entries: + * + * 1. **Direct command** (our format, without matcher): + * ```yaml + * hooks: + * PreToolUse: + * - type: command + * command: "./scripts/validate.sh" + * ``` + * + * 2. **Nested with matcher** (Claude Code format): + * ```yaml + * hooks: + * PreToolUse: + * - matcher: "Bash" + * hooks: + * - type: command + * command: "./scripts/validate.sh" + * ``` + * + * @param hooksMap The raw YAML map value from the `hooks` frontmatter attribute. + * @param workspaceRootUri Workspace root for resolving relative `cwd` paths. + * @param userHome User home directory path for tilde expansion. + * @param target The agent's target, used to resolve hook type names correctly. + * @returns Resolved hooks organized by hook type, ready for use in {@link ChatRequestHooks}. + */ +export function parseSubagentHooksFromYaml( + hooksMap: IMapValue, + workspaceRootUri: URI | undefined, + userHome: string, + target: Target = Target.Undefined, +): ChatRequestHooks { + const result: Record = {}; + const targetHookMap = HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]; + + for (const prop of hooksMap.properties) { + const hookTypeName = prop.key.value; + + // Resolve hook type name using the target's own map first, then fall back to canonical names + const hookType = targetHookMap[hookTypeName] ?? toHookType(hookTypeName); + if (!hookType) { + continue; + } + + // The value must be a sequence (array of hook entries) + if (prop.value.type !== 'sequence') { + continue; + } + + const commands: IHookCommand[] = []; + + for (const item of prop.value.items) { + // Convert the YAML AST node to a plain object so the existing + // extractHookCommandsFromItem helper can handle both direct + // commands and nested matcher structures. + const plainItem = yamlValueToPlain(item); + const extracted = extractHookCommandsFromItem(plainItem, workspaceRootUri, userHome); + commands.push(...extracted); + } + + if (commands.length > 0) { + if (!result[hookType]) { + result[hookType] = []; + } + result[hookType].push(...commands); + } + } + + return result as ChatRequestHooks; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookTypes.ts new file mode 100644 index 00000000000..66d28c22876 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookTypes.ts @@ -0,0 +1,122 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from '../../../../../nls.js'; +import { Target } from './promptTypes.js'; + +/** + * Enum of hook types across all targets. For the set of supported hooks per target, see HOOKS_BY_TARGET. + */ +export enum HookType { + SessionStart = 'SessionStart', + SessionEnd = 'SessionEnd', + UserPromptSubmit = 'UserPromptSubmit', + PreToolUse = 'PreToolUse', + PostToolUse = 'PostToolUse', + PreCompact = 'PreCompact', + SubagentStart = 'SubagentStart', + SubagentStop = 'SubagentStop', + Stop = 'Stop', + ErrorOccurred = 'ErrorOccurred', +} + +/** + * String literal type derived from HookType enum values. + */ +export type HookTypeValue = `${HookType}`; + +export const HOOKS_BY_TARGET: Record> = { + // see https://code.visualstudio.com/docs/copilot/customization/hooks#_hook-lifecycle-events + [Target.VSCode]: { + 'SessionStart': HookType.SessionStart, + 'UserPromptSubmit': HookType.UserPromptSubmit, + 'PreToolUse': HookType.PreToolUse, + 'PostToolUse': HookType.PostToolUse, + 'PreCompact': HookType.PreCompact, + 'SubagentStart': HookType.SubagentStart, + 'SubagentStop': HookType.SubagentStop, + 'Stop': HookType.Stop, + }, + // see https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks#types-of-hooks + [Target.GitHubCopilot]: { + 'sessionStart': HookType.SessionStart, + 'sessionEnd': HookType.SessionEnd, + 'userPromptSubmitted': HookType.UserPromptSubmit, + 'preToolUse': HookType.PreToolUse, + 'postToolUse': HookType.PostToolUse, + 'agentStop': HookType.Stop, + 'subagentStop': HookType.SubagentStop, + 'errorOccurred': HookType.ErrorOccurred + }, + // see https://docs.anthropic.com/en/docs/claude-code/hooks + [Target.Claude]: { + 'SessionStart': HookType.SessionStart, + 'UserPromptSubmit': HookType.UserPromptSubmit, + 'PreToolUse': HookType.PreToolUse, + 'PostToolUse': HookType.PostToolUse, + 'PreCompact': HookType.PreCompact, + 'SubagentStart': HookType.SubagentStart, + 'SubagentStop': HookType.SubagentStop, + 'Stop': HookType.Stop, + }, + // if no target, just list all known hook types. + [Target.Undefined]: Object.fromEntries( + Object.values(HookType).map(h => [h, h]) + ) as Record +}; + +/** + * Metadata for a hook type including localized label and description. + */ +export interface IHookTypeMeta { + readonly label: string; + readonly description: string; +} + +/** + * Metadata for hook types including localized labels and descriptions + */ +export const HOOK_METADATA: { [key in HookType]: IHookTypeMeta } = { + [HookType.SessionStart]: { + label: nls.localize('hookType.sessionStart.label', "Session Start"), + description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins.") + }, + [HookType.UserPromptSubmit]: { + label: nls.localize('hookType.userPromptSubmit.label', "User Prompt Submit"), + description: nls.localize('hookType.userPromptSubmit.description', "Executed when the user submits a prompt to the agent.") + }, + [HookType.PreToolUse]: { + label: nls.localize('hookType.preToolUse.label', "Pre-Tool Use"), + description: nls.localize('hookType.preToolUse.description', "Executed before the agent uses any tool.") + }, + [HookType.PostToolUse]: { + label: nls.localize('hookType.postToolUse.label', "Post-Tool Use"), + description: nls.localize('hookType.postToolUse.description', "Executed after a tool completes execution successfully.") + }, + [HookType.PreCompact]: { + label: nls.localize('hookType.preCompact.label', "Pre-Compact"), + description: nls.localize('hookType.preCompact.description', "Executed before the agent compacts the conversation context.") + }, + [HookType.SubagentStart]: { + label: nls.localize('hookType.subagentStart.label', "Subagent Start"), + description: nls.localize('hookType.subagentStart.description', "Executed when a subagent is started.") + }, + [HookType.SubagentStop]: { + label: nls.localize('hookType.subagentStop.label', "Subagent Stop"), + description: nls.localize('hookType.subagentStop.description', "Executed when a subagent stops.") + }, + [HookType.Stop]: { + label: nls.localize('hookType.stop.label', "Stop"), + description: nls.localize('hookType.stop.description', "Executed when the agent stops.") + }, + [HookType.SessionEnd]: { + label: nls.localize('hookType.sessionEnd.label', "Session End"), + description: nls.localize('hookType.sessionEnd.description', "Executed when an agent session ends.") + }, + [HookType.ErrorOccurred]: { + label: nls.localize('hookType.errorOccurred.label', "Error Occurred"), + description: nls.localize('hookType.errorOccurred.description', "Executed when an error occurs during the agent session.") + } +}; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts index 8de8c06dfba..b991d1d4db5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts @@ -9,8 +9,8 @@ import { Range } from '../../../../../../editor/common/core/range.js'; import { Definition, DefinitionProvider } from '../../../../../../editor/common/languages.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; import { IChatModeService } from '../../chatModes.js'; -import { getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptHeaderAttributes } from '../promptFileParser.js'; +import { getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; export class PromptHeaderDefinitionProvider implements DefinitionProvider { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts index 704d5cd6208..eba207145d1 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts @@ -16,9 +16,10 @@ import { Selection } from '../../../../../../editor/common/core/selection.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { getTarget, isVSCodeOrDefaultTarget, MARKERS_OWNER_ID } from './promptValidator.js'; +import { MARKERS_OWNER_ID } from './promptValidator.js'; import { IMarkerData, IMarkerService } from '../../../../../../platform/markers/common/markers.js'; import { CodeActionKind } from '../../../../../../editor/contrib/codeAction/common/types.js'; +import { getTarget, isVSCodeOrDefaultTarget } from './promptFileAttributes.js'; export class PromptCodeActionProvider implements CodeActionProvider { /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts index 3fdf4aa385e..8b5ffb7ae41 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts @@ -8,7 +8,7 @@ import { DocumentSemanticTokensProvider, ProviderResult, SemanticTokens, Semanti import { ITextModel } from '../../../../../../editor/common/model.js'; import { getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; -import { getTarget, isVSCodeOrDefaultTarget } from './promptValidator.js'; +import { getTarget, isVSCodeOrDefaultTarget } from './promptFileAttributes.js'; export class PromptDocumentSemanticTokensProvider implements DocumentSemanticTokensProvider { /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptFileAttributes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptFileAttributes.ts new file mode 100644 index 00000000000..a57216523c9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptFileAttributes.ts @@ -0,0 +1,440 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { dirname } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { SpecedToolAliases } from '../../tools/languageModelToolsService.js'; +import { CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder } from '../config/promptFileLocations.js'; +import { PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { PromptsType, Target } from '../promptTypes.js'; + +export namespace GithubPromptHeaderAttributes { + export const mcpServers = 'mcp-servers'; + export const github = 'github'; +} + +export namespace ClaudeHeaderAttributes { + export const disallowedTools = 'disallowedTools'; +} + +export function isTarget(value: unknown): value is Target { + return value === Target.VSCode || value === Target.GitHubCopilot || value === Target.Claude || value === Target.Undefined; +} + + +interface IAttributeDefinition { + readonly type: string; + readonly description: string; + readonly defaults?: readonly string[]; + readonly items?: readonly { name: string; description?: string }[]; + readonly enums?: readonly { name: string; description?: string }[]; +} + +const booleanAttributeEnumValues: readonly IValueEntry[] = [ + { name: 'true' }, + { name: 'false' } +]; + +const targetAttributeEnumValues: readonly IValueEntry[] = [ + { name: 'vscode' }, + { name: 'github-copilot' }, +]; + +// Attribute metadata for prompt files (`*.prompt.md`). +export const promptFileAttributes: Record = { + [PromptHeaderAttributes.name]: { + type: 'scalar', + description: localize('promptHeader.prompt.name', 'The name of the prompt. This is also the name of the slash command that will run this prompt.'), + }, + [PromptHeaderAttributes.description]: { + type: 'scalar', + description: localize('promptHeader.prompt.description', 'The description of the reusable prompt, what it does and when to use it.'), + }, + [PromptHeaderAttributes.argumentHint]: { + type: 'scalar', + description: localize('promptHeader.prompt.argumentHint', 'The argument-hint describes what inputs the prompt expects or supports.'), + }, + [PromptHeaderAttributes.model]: { + type: 'scalar | sequence', + description: localize('promptHeader.prompt.model', 'The model to use in this prompt. Can also be a list of models. The first available model will be used.'), + }, + [PromptHeaderAttributes.tools]: { + type: 'scalar | sequence', + description: localize('promptHeader.prompt.tools', 'The tools to use in this prompt.'), + defaults: ['[]', '[\'search\', \'edit\', \'web\']'], + }, + [PromptHeaderAttributes.agent]: { + type: 'scalar', + description: localize('promptHeader.prompt.agent.description', 'The agent to use when running this prompt.'), + }, + [PromptHeaderAttributes.mode]: { + type: 'scalar', + description: localize('promptHeader.prompt.agent.description', 'The agent to use when running this prompt.'), + }, +}; + +// Attribute metadata for instructions files (`*.instructions.md`). +export const instructionAttributes: Record = { + [PromptHeaderAttributes.name]: { + type: 'scalar', + description: localize('promptHeader.instructions.name', 'The name of the instruction file as shown in the UI. If not set, the name is derived from the file name.'), + }, + [PromptHeaderAttributes.description]: { + type: 'scalar', + description: localize('promptHeader.instructions.description', 'The description of the instruction file. It can be used to provide additional context or information about the instructions and is passed to the language model as part of the prompt.'), + }, + [PromptHeaderAttributes.applyTo]: { + type: 'scalar', + description: localize('promptHeader.instructions.applyToRange', 'One or more glob pattern (separated by comma) that describe for which files the instructions apply to. Based on these patterns, the file is automatically included in the prompt, when the context contains a file that matches one or more of these patterns. Use `**` when you want this file to always be added.\nExample: `**/*.ts`, `**/*.js`, `client/**`'), + defaults: [ + '\'**\'', + '\'**/*.ts, **/*.js\'', + '\'**/*.php\'', + '\'**/*.py\'' + ], + }, + [PromptHeaderAttributes.excludeAgent]: { + type: 'scalar | sequence', + description: localize('promptHeader.instructions.excludeAgent', 'One or more agents to exclude from using this instruction file.'), + }, +}; + +// Attribute metadata for custom agent files (`*.agent.md`). +export const customAgentAttributes: Record = { + [PromptHeaderAttributes.name]: { + type: 'scalar', + description: localize('promptHeader.agent.name', 'The name of the agent as shown in the UI.'), + }, + [PromptHeaderAttributes.description]: { + type: 'scalar', + description: localize('promptHeader.agent.description', 'The description of the custom agent, what it does and when to use it.'), + }, + [PromptHeaderAttributes.argumentHint]: { + type: 'scalar', + description: localize('promptHeader.agent.argumentHint', 'The argument-hint describes what inputs the custom agent expects or supports.'), + }, + [PromptHeaderAttributes.model]: { + type: 'scalar | sequence', + description: localize('promptHeader.agent.model', 'Specify the model that runs this custom agent. Can also be a list of models. The first available model will be used.'), + }, + [PromptHeaderAttributes.tools]: { + type: 'scalar | sequence', + description: localize('promptHeader.agent.tools', 'The set of tools that the custom agent has access to.'), + defaults: ['[]', '[search, edit, web]'], + }, + [PromptHeaderAttributes.handOffs]: { + type: 'sequence', + description: localize('promptHeader.agent.handoffs', 'Possible handoff actions when the agent has completed its task.'), + }, + [PromptHeaderAttributes.target]: { + type: 'scalar', + description: localize('promptHeader.agent.target', 'The target to which the header attributes like tools apply to. Possible values are `github-copilot` and `vscode`.'), + enums: targetAttributeEnumValues, + }, + [PromptHeaderAttributes.infer]: { + type: 'scalar', + description: localize('promptHeader.agent.infer', 'Controls visibility of the agent.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.agents]: { + type: 'sequence', + description: localize('promptHeader.agent.agents', 'One or more agents that this agent can use as subagents. Use \'*\' to specify all available agents.'), + defaults: ['["*"]'], + }, + [PromptHeaderAttributes.userInvocable]: { + type: 'scalar', + description: localize('promptHeader.agent.userInvocable', 'Whether the agent can be selected and invoked by users in the UI.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.userInvokable]: { + type: 'scalar', + description: localize('promptHeader.agent.userInvokable', 'Deprecated. Use user-invocable instead.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.disableModelInvocation]: { + type: 'scalar', + description: localize('promptHeader.agent.disableModelInvocation', 'If true, prevents the agent from being invoked as a subagent.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.advancedOptions]: { + type: 'map', + description: localize('promptHeader.agent.advancedOptions', 'Advanced options for custom agent behavior.'), + }, + [GithubPromptHeaderAttributes.github]: { + type: 'map', + description: localize('promptHeader.agent.github', 'GitHub-specific configuration for the agent, such as token permissions.'), + }, + [PromptHeaderAttributes.hooks]: { + type: 'map', + description: localize('promptHeader.agent.hooks', 'Lifecycle hooks scoped to this agent. Define hooks that run only while this agent is active.'), + }, +}; + +// Attribute metadata for skill files (`SKILL.md`). +export const skillAttributes: Record = { + [PromptHeaderAttributes.name]: { + type: 'scalar', + description: localize('promptHeader.skill.name', 'The name of the skill.'), + }, + [PromptHeaderAttributes.description]: { + type: 'scalar', + description: localize('promptHeader.skill.description', 'The description of the skill. The description is added to every request and will be used by the agent to decide when to load the skill.'), + }, + [PromptHeaderAttributes.argumentHint]: { + type: 'scalar', + description: localize('promptHeader.skill.argumentHint', 'Hint shown during autocomplete to indicate expected arguments. Example: [issue-number] or [filename] [format]'), + }, + [PromptHeaderAttributes.userInvocable]: { + type: 'scalar', + description: localize('promptHeader.skill.userInvocable', 'Set to false to hide from the / menu. Use for background knowledge users should not invoke directly. Default: true.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.userInvokable]: { + type: 'scalar', + description: localize('promptHeader.skill.userInvokable', 'Deprecated. Use user-invocable instead.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.disableModelInvocation]: { + type: 'scalar', + description: localize('promptHeader.skill.disableModelInvocation', 'Set to true to prevent the agent from automatically loading this skill. Use for workflows you want to trigger manually with /name. Default: false.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.license]: { + type: 'scalar | map', + description: localize('promptHeader.skill.license', 'License information for the skill.'), + }, + [PromptHeaderAttributes.compatibility]: { + type: 'scalar | map', + description: localize('promptHeader.skill.compatibility', 'Compatibility metadata for environments or runtimes.'), + }, + [PromptHeaderAttributes.metadata]: { + type: 'map', + description: localize('promptHeader.skill.metadata', 'Additional metadata for the skill.'), + }, +}; + +const allAttributeNames: Record = { + [PromptsType.prompt]: Object.keys(promptFileAttributes), + [PromptsType.instructions]: Object.keys(instructionAttributes), + [PromptsType.agent]: Object.keys(customAgentAttributes), + [PromptsType.skill]: Object.keys(skillAttributes), + [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter +}; +const githubCopilotAgentAttributeNames = [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.tools, PromptHeaderAttributes.target, GithubPromptHeaderAttributes.mcpServers, GithubPromptHeaderAttributes.github, PromptHeaderAttributes.infer]; +const recommendedAttributeNames: Record = { + [PromptsType.prompt]: allAttributeNames[PromptsType.prompt].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.instructions]: allAttributeNames[PromptsType.instructions].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.agent]: allAttributeNames[PromptsType.agent].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.skill]: allAttributeNames[PromptsType.skill].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter +}; + +export function getValidAttributeNames(promptType: PromptsType, includeNonRecommended: boolean, target: Target): string[] { + if (target === Target.Claude) { + if (promptType === PromptsType.instructions) { + return Object.keys(claudeRulesAttributes); + } + return Object.keys(claudeAgentAttributes); + } else if (target === Target.GitHubCopilot) { + if (promptType === PromptsType.agent) { + return githubCopilotAgentAttributeNames; + } + } + return includeNonRecommended ? allAttributeNames[promptType] : recommendedAttributeNames[promptType]; +} + +export function isNonRecommendedAttribute(attributeName: string): boolean { + return attributeName === PromptHeaderAttributes.advancedOptions || attributeName === PromptHeaderAttributes.excludeAgent || attributeName === PromptHeaderAttributes.mode || attributeName === PromptHeaderAttributes.infer || attributeName === PromptHeaderAttributes.userInvokable; +} + +export function getAttributeDefinition(attributeName: string, promptType: PromptsType, target: Target): IAttributeDefinition | undefined { + switch (promptType) { + case PromptsType.instructions: + if (target === Target.Claude) { + return claudeRulesAttributes[attributeName]; + } + return instructionAttributes[attributeName]; + case PromptsType.skill: + return skillAttributes[attributeName]; + case PromptsType.agent: + if (target === Target.Claude) { + return claudeAgentAttributes[attributeName]; + } + return customAgentAttributes[attributeName]; + case PromptsType.prompt: + return promptFileAttributes[attributeName]; + default: + return undefined; + } +} + +// The list of tools known to be used by GitHub Copilot custom agents +export const knownGithubCopilotTools = [ + { name: SpecedToolAliases.execute, description: localize('githubCopilot.execute', 'Execute commands') }, + { name: SpecedToolAliases.read, description: localize('githubCopilot.read', 'Read files') }, + { name: SpecedToolAliases.edit, description: localize('githubCopilot.edit', 'Edit files') }, + { name: SpecedToolAliases.search, description: localize('githubCopilot.search', 'Search files') }, + { name: SpecedToolAliases.agent, description: localize('githubCopilot.agent', 'Use subagents') }, +]; + +export interface IValueEntry { + readonly name: string; + readonly description?: string; +} + +export const knownClaudeTools = [ + { name: 'Bash', description: localize('claude.bash', 'Execute shell commands'), toolEquivalent: [SpecedToolAliases.execute] }, + { name: 'Edit', description: localize('claude.edit', 'Make targeted file edits'), toolEquivalent: ['edit/editNotebook', 'edit/editFiles'] }, + { name: 'Glob', description: localize('claude.glob', 'Find files by pattern'), toolEquivalent: ['search/fileSearch'] }, + { name: 'Grep', description: localize('claude.grep', 'Search file contents with regex'), toolEquivalent: ['search/textSearch'] }, + { name: 'Read', description: localize('claude.read', 'Read file contents'), toolEquivalent: ['read/readFile', 'read/getNotebookSummary'] }, + { name: 'Write', description: localize('claude.write', 'Create/overwrite files'), toolEquivalent: ['edit/createDirectory', 'edit/createFile', 'edit/createJupyterNotebook'] }, + { name: 'WebFetch', description: localize('claude.webFetch', 'Fetch URL content'), toolEquivalent: [SpecedToolAliases.web] }, + { name: 'WebSearch', description: localize('claude.webSearch', 'Perform web searches'), toolEquivalent: [SpecedToolAliases.web] }, + { name: 'Task', description: localize('claude.task', 'Run subagents for complex tasks'), toolEquivalent: [SpecedToolAliases.agent] }, + { name: 'Skill', description: localize('claude.skill', 'Execute skills'), toolEquivalent: [] }, + { name: 'LSP', description: localize('claude.lsp', 'Code intelligence (requires plugin)'), toolEquivalent: [] }, + { name: 'NotebookEdit', description: localize('claude.notebookEdit', 'Modify Jupyter notebooks'), toolEquivalent: ['edit/editNotebook'] }, + { name: 'AskUserQuestion', description: localize('claude.askUserQuestion', 'Ask multiple-choice questions'), toolEquivalent: ['vscode/askQuestions'] }, + { name: 'MCPSearch', description: localize('claude.mcpSearch', 'Searches for MCP tools when tool search is enabled'), toolEquivalent: [] } +]; + +export const knownClaudeModels = [ + { name: 'sonnet', description: localize('claude.sonnet', 'Latest Claude Sonnet'), modelEquivalent: 'Claude Sonnet 4.5 (copilot)' }, + { name: 'opus', description: localize('claude.opus', 'Latest Claude Opus'), modelEquivalent: 'Claude Opus 4.6 (copilot)' }, + { name: 'haiku', description: localize('claude.haiku', 'Latest Claude Haiku, fast for simple tasks'), modelEquivalent: 'Claude Haiku 4.5 (copilot)' }, + { name: 'inherit', description: localize('claude.inherit', 'Inherit model from parent agent or prompt'), modelEquivalent: undefined }, +]; + +export function mapClaudeModels(claudeModelNames: readonly string[]): readonly string[] { + const result = []; + for (const name of claudeModelNames) { + const claudeModel = knownClaudeModels.find(model => model.name === name); + if (claudeModel && claudeModel.modelEquivalent) { + result.push(claudeModel.modelEquivalent); + } + } + return result; +} + +/** + * Maps Claude tool names to their VS Code tool equivalents. + */ +export function mapClaudeTools(claudeToolNames: readonly string[]): string[] { + const result: string[] = []; + for (const name of claudeToolNames) { + const claudeTool = knownClaudeTools.find(tool => tool.name === name); + if (claudeTool) { + result.push(...claudeTool.toolEquivalent); + } + } + return result; +} + +export const claudeAgentAttributes: Record = { + 'name': { + type: 'scalar', + description: localize('attribute.name', "Unique identifier using lowercase letters and hyphens (required)"), + }, + 'description': { + type: 'scalar', + description: localize('attribute.description', "When to delegate to this subagent (required)"), + }, + 'tools': { + type: 'sequence', + description: localize('attribute.tools', "Array of tools the subagent can use. Inherits all tools if omitted"), + defaults: ['Read, Edit, Bash'], + items: knownClaudeTools + }, + 'disallowedTools': { + type: 'sequence', + description: localize('attribute.disallowedTools', "Tools to deny, removed from inherited or specified list"), + defaults: ['Write, Edit, Bash'], + items: knownClaudeTools + }, + 'model': { + type: 'scalar', + description: localize('attribute.model', "Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit."), + defaults: ['sonnet', 'opus', 'haiku', 'inherit'], + enums: knownClaudeModels + }, + 'permissionMode': { + type: 'scalar', + description: localize('attribute.permissionMode', "Permission mode: default, acceptEdits, dontAsk, bypassPermissions, or plan."), + defaults: ['default', 'acceptEdits', 'dontAsk', 'bypassPermissions', 'plan'], + enums: [ + { name: 'default', description: localize('claude.permissionMode.default', 'Standard behavior: prompts for permission on first use of each tool.') }, + { name: 'acceptEdits', description: localize('claude.permissionMode.acceptEdits', 'Automatically accepts file edit permissions for the session.') }, + { name: 'plan', description: localize('claude.permissionMode.plan', 'Plan Mode: Claude can analyze but not modify files or execute commands.') }, + { name: 'delegate', description: localize('claude.permissionMode.delegate', 'Coordination-only mode for agent team leads. Only available when an agent team is active.') }, + { name: 'dontAsk', description: localize('claude.permissionMode.dontAsk', 'Auto-denies tools unless pre-approved via /permissions or permissions.allow rules.') }, + { name: 'bypassPermissions', description: localize('claude.permissionMode.bypassPermissions', 'Skips all permission prompts (requires safe environment like containers).') } + ] + }, + 'skills': { + type: 'sequence', + description: localize('attribute.skills', "Skills to load into the subagent's context at startup."), + }, + 'mcpServers': { + type: 'sequence', + description: localize('attribute.mcpServers', "MCP servers available to this subagent."), + }, + 'hooks': { + type: 'object', + description: localize('attribute.hooks', "Lifecycle hooks scoped to this subagent."), + }, + 'memory': { + type: 'scalar', + description: localize('attribute.memory', "Persistent memory scope: user, project, or local. Enables cross-session learning."), + defaults: ['user', 'project', 'local'], + enums: [ + { name: 'user', description: localize('claude.memory.user', "Remember learnings across all projects.") }, + { name: 'project', description: localize('claude.memory.project', "The subagent's knowledge is project-specific and shareable via version control.") }, + { name: 'local', description: localize('claude.memory.local', "The subagent's knowledge is project-specific but should not be checked into version control.") } + ] + } +}; + +/** + * Attributes supported in Claude rules files (`.claude/rules/*.md`). + * Claude rules use `paths` instead of `applyTo` for glob patterns. + */ +export const claudeRulesAttributes: Record = { + 'description': { + type: 'scalar', + description: localize('attribute.rules.description', "A description of what this rule covers, used to provide context about when it applies."), + }, + 'paths': { + type: 'sequence', + description: localize('attribute.rules.paths', "Array of glob patterns that describe for which files the rule applies. Based on these patterns, the file is automatically included in the prompt when the context contains a file that matches.\nExample: `['src/**/*.ts', 'test/**']`"), + }, +}; + +export function isVSCodeOrDefaultTarget(target: Target): boolean { + return target === Target.VSCode || target === Target.Undefined; +} + +export function getTarget(promptType: PromptsType, header: PromptHeader | URI): Target { + const uri = header instanceof URI ? header : header.uri; + if (promptType === PromptsType.agent) { + const parentDir = dirname(uri); + if (parentDir.path.endsWith(`/${CLAUDE_AGENTS_SOURCE_FOLDER}`)) { + return Target.Claude; + } + if (!(header instanceof URI)) { + const target = header.target; + if (target === Target.GitHubCopilot || target === Target.VSCode) { + return target; + } + } + return Target.Undefined; + } else if (promptType === PromptsType.instructions) { + if (isInClaudeRulesFolder(uri)) { + return Target.Claude; + } + } + return Target.Undefined; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 5e60a57cf89..de33bb91fd0 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -12,14 +12,18 @@ import { ITextModel } from '../../../../../../editor/common/model.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService } from '../../tools/languageModelToolsService.js'; import { IChatModeService } from '../../chatModes.js'; -import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { IPromptsService, Target } from '../service/promptsService.js'; +import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; +import { IPromptsService } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; -import { ClaudeHeaderAttributes, ISequenceValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; -import { getAttributeDescription, getTarget, getValidAttributeNames, claudeAgentAttributes, claudeRulesAttributes, knownClaudeTools, knownGithubCopilotTools, IValueEntry } from './promptValidator.js'; +import { IMapValue, ISequenceValue, IValue, IHeaderAttribute, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { getAttributeDefinition, getTarget, getValidAttributeNames, knownClaudeTools, knownGithubCopilotTools, IValueEntry, ClaudeHeaderAttributes, } from './promptFileAttributes.js'; import { localize } from '../../../../../../nls.js'; import { formatArrayValue, getQuotePreference } from '../utils/promptEditHelper.js'; - +import { HOOKS_BY_TARGET, HOOK_METADATA } from '../hookTypes.js'; +import { HOOK_COMMAND_FIELD_DESCRIPTIONS } from '../hookSchema.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { PromptsConfig } from '../config/config.js'; export class PromptHeaderAutocompletion implements CompletionItemProvider { /** @@ -37,6 +41,8 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @IChatModeService private readonly chatModeService: IChatModeService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } @@ -92,6 +98,33 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { const colonPosition = colonIndex !== -1 ? new Position(position.lineNumber, colonIndex + 1) : undefined; if (!colonPosition || position.isBeforeOrEqual(colonPosition)) { + // Check if the position is inside a multi-line attribute (e.g., hooks map). + // In that case, provide value completions for that attribute instead of attribute name completions. + let containingAttribute = header.attributes.find(({ range }) => + range.startLineNumber < position.lineNumber && position.lineNumber <= range.endLineNumber); + if (!containingAttribute) { + // Handle trailing empty lines after a map-valued attribute: + // The YAML parser's range ends at the last parsed child, but logically + // an empty line before the next attribute still belongs to the map. + for (let i = header.attributes.length - 1; i >= 0; i--) { + const attr = header.attributes[i]; + if (attr.range.endLineNumber < position.lineNumber && attr.value.type === 'map') { + const nextAttr = header.attributes[i + 1]; + const nextStartLine = nextAttr ? nextAttr.range.startLineNumber : headerRange.endLineNumber; + if (position.lineNumber < nextStartLine) { + containingAttribute = attr; + } + break; + } + } + } + if (containingAttribute) { + const attrLineText = model.getLineContent(containingAttribute.range.startLineNumber); + const attrColonIndex = attrLineText.indexOf(':'); + if (attrColonIndex !== -1) { + return this.provideValueCompletions(model, position, header, new Position(containingAttribute.range.startLineNumber, attrColonIndex + 1), promptType, containingAttribute); + } + } return this.provideAttributeNameCompletions(model, position, header, colonPosition, promptType); } else if (colonPosition && colonPosition.isBefore(position)) { return this.provideValueCompletions(model, position, header, colonPosition, promptType); @@ -110,6 +143,9 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { const target = getTarget(promptType, header); const attributesToPropose = new Set(getValidAttributeNames(promptType, false, target)); + if (!this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { + attributesToPropose.delete(PromptHeaderAttributes.hooks); + } for (const attr of header.attributes) { attributesToPropose.delete(attr.key); } @@ -117,6 +153,11 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (colonPosition) { return key; } + // For map-valued attributes, insert a snippet with the nested structure + if (key === PromptHeaderAttributes.hooks && promptType === PromptsType.agent && target !== Target.Claude) { + const hookNames = Object.keys(HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]); + return `${key}:\n \${1|${hookNames.join(',')}|}:\n - type: command\n command: "$2"`; + } const valueSuggestions = this.getValueSuggestions(promptType, key, target); if (valueSuggestions.length > 0) { return `${key}: \${0:${valueSuggestions[0].name}}`; @@ -129,7 +170,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { for (const attribute of attributesToPropose) { const item: CompletionItem = { label: attribute, - documentation: getAttributeDescription(attribute, promptType, target), + documentation: getAttributeDefinition(attribute, promptType, target)?.description, kind: CompletionItemKind.Property, insertText: getInsertText(attribute), insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, @@ -147,10 +188,11 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { header: PromptHeader, colonPosition: Position, promptType: PromptsType, + preFoundAttribute?: IHeaderAttribute, ): Promise { const suggestions: CompletionItem[] = []; const posLineNumber = position.lineNumber; - const attribute = header.attributes.find(({ range }) => range.startLineNumber <= posLineNumber && posLineNumber <= range.endLineNumber); + const attribute = preFoundAttribute ?? header.attributes.find(({ range }) => range.startLineNumber <= posLineNumber && posLineNumber <= range.endLineNumber); if (!attribute) { return undefined; } @@ -181,8 +223,8 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (value.type === 'sequence') { // if the position is inside the tools metadata, we provide tool name completions const getValues = async () => { - if (target === Target.GitHubCopilot) { - // for GitHub Copilot agent files, we only suggest the known set of tools that are supported by GitHub Copilot, instead of all tools that the user has defined, because many tools won't work with GitHub Copilot and it would be frustrating for users to select a tool that doesn't work + if (target === Target.GitHubCopilot || this.environmentService.isSessionsWindow) { + // for GitHub Copilot targets and the Sessions Window, we only suggest the known set of tools that are supported by GitHub Copilot, instead of all tools that the user has defined, because many tools won't work in these contexts and it would be frustrating for users to select a tool that doesn't work return knownGithubCopilotTools; } else if (target === Target.Claude) { return knownClaudeTools; @@ -201,6 +243,18 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { }); } } + if (attribute.key === PromptHeaderAttributes.hooks) { + if (attribute.value.type === 'map') { + // Inside the hooks map — suggest hook event type names as sub-keys + return this.provideHookEventCompletions(model, position, attribute.value, target); + } + // When hooks value is not yet a map (e.g., user is mid-edit on a nested line), + // still provide hook event completions with no existing keys. + if (position.lineNumber !== attribute.range.startLineNumber) { + const emptyMap: IMapValue = { type: 'map', properties: [], range: attribute.value.range }; + return this.provideHookEventCompletions(model, position, emptyMap, target); + } + } const lineContent = model.getLineContent(attribute.range.startLineNumber); const whilespaceAfterColon = (lineContent.substring(colonPosition.column).match(/^\s*/)?.[0].length) ?? 0; const entries = this.getValueSuggestions(promptType, attribute.key, target); @@ -230,32 +284,299 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { }; suggestions.push(item); } + if (attribute.key === PromptHeaderAttributes.hooks && promptType === PromptsType.agent) { + const hookSnippet = [ + '', + ' ${1|' + Object.keys(HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]).join(',') + '|}:', + ' - type: command', + ' command: "$2"' + ].join('\n'); + const item: CompletionItem = { + label: localize('promptHeaderAutocompletion.newHook', "New Hook"), + kind: CompletionItemKind.Snippet, + insertText: whilespaceAfterColon === 0 ? ` ${hookSnippet}` : hookSnippet, + insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, + range: new Range(position.lineNumber, colonPosition.column + whilespaceAfterColon + 1, position.lineNumber, model.getLineMaxColumn(position.lineNumber)), + }; + suggestions.push(item); + } return { suggestions }; } - private getValueSuggestions(promptType: PromptsType, attribute: string, target: Target): IValueEntry[] { - if (target === Target.Claude) { - const attributeDesc = promptType === PromptsType.instructions ? claudeRulesAttributes[attribute] : claudeAgentAttributes[attribute]; - if (attributeDesc) { - if (attributeDesc.enums) { - return attributeDesc.enums; - } else if (attributeDesc.defaults) { - return attributeDesc.defaults.map(value => ({ name: value })); + /** + * Provides completions inside the `hooks:` map. + * Determines what to suggest based on nesting depth: + * - At hook event level: suggest event names (SessionStart, PreToolUse, etc.) + * - Inside a command object: suggest command fields (type, command, timeout, etc.) + */ + private provideHookEventCompletions( + model: ITextModel, + position: Position, + hooksMap: IMapValue, + target: Target, + ): CompletionList | undefined { + // Check if the cursor is on the value side of an existing hook event key (e.g., "SessionEnd:|") + // In that case, offer a command entry snippet instead of event name completions. + const hookEventOnLine = hooksMap.properties.find(p => p.key.range.startLineNumber === position.lineNumber); + if (hookEventOnLine) { + const lineText = model.getLineContent(position.lineNumber); + const colonIdx = lineText.indexOf(':'); + if (colonIdx !== -1 && position.column > colonIdx + 1) { + const whilespaceAfterColon = (lineText.substring(colonIdx + 1).match(/^\s*/)?.[0].length) ?? 0; + const commandSnippet = [ + '', + ' - type: command', + ' command: "$1"', + ].join('\n'); + return { + suggestions: [{ + label: localize('promptHeaderAutocompletion.newCommand', "New Command"), + documentation: localize('promptHeaderAutocompletion.newCommand.description', "Add a new command entry to this hook."), + kind: CompletionItemKind.Snippet, + insertText: whilespaceAfterColon === 0 ? ` ${commandSnippet}` : commandSnippet, + insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, + range: new Range(position.lineNumber, colonIdx + 1 + whilespaceAfterColon + 1, position.lineNumber, model.getLineMaxColumn(position.lineNumber)), + }] + }; + } + } + + // Try to provide command field completions if cursor is inside a command object + const commandFieldCompletions = this.provideHookCommandFieldCompletions(model, position, hooksMap, target); + if (commandFieldCompletions) { + return commandFieldCompletions; + } + + // Otherwise provide hook event name completions + const suggestions: CompletionItem[] = []; + const hooksByTarget = HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]; + + const lineText = model.getLineContent(position.lineNumber); + const firstNonWhitespace = lineText.search(/\S/); + const isEmptyLine = firstNonWhitespace === -1; + // Start the range after leading whitespace so VS Code's completion + // filtering matches the hook name prefix the user has typed. + const rangeStartColumn = isEmptyLine ? position.column : firstNonWhitespace + 1; + + // Exclude hook keys on the current line so the user sees all options while editing a key + const existingKeys = new Set( + hooksMap.properties + .filter(p => p.key.range.startLineNumber !== position.lineNumber) + .map(p => p.key.value) + ); + + // Supplement with text-based scanning: when incomplete YAML causes the + // parser to drop subsequent keys, scan the model for lines that look + // like hook event entries (e.g., " UserPromptSubmit:") at the expected + // indentation. + const expectedIndent = hooksMap.properties.length > 0 + ? hooksMap.properties[0].key.range.startColumn - 1 + : -1; + if (expectedIndent >= 0) { + const scanEnd = model.getLineCount(); + for (let lineNum = hooksMap.range.endLineNumber + 1; lineNum <= scanEnd; lineNum++) { + if (lineNum === position.lineNumber) { + continue; + } + const lt = model.getLineContent(lineNum); + const lineIndent = lt.search(/\S/); + if (lineIndent === -1) { + continue; + } + if (lineIndent < expectedIndent) { + break; // Left the hooks map scope + } + if (lineIndent === expectedIndent) { + const match = lt.match(/^\s+(\S+)\s*:/); + if (match) { + existingKeys.add(match[1]); + } } } - return []; + } + + // Check whether the current line already has a colon (editing an existing key) + const lineHasColon = lineText.indexOf(':') !== -1; + + for (const [hookName, hookType] of Object.entries(hooksByTarget)) { + if (existingKeys.has(hookName)) { + continue; + } + const meta = HOOK_METADATA[hookType]; + let insertText: string; + if (isEmptyLine) { + // On empty lines, insert a full hook snippet with command placeholder + insertText = [ + `${hookName}:`, + ` - type: command`, + ` command: "$1"`, + ].join('\n'); + } else if (lineHasColon) { + // On existing key lines, only replace the key name to preserve nested content + insertText = `${hookName}:`; + } else { + // Typing a new event name — omit the colon so the user can + // trigger the next completion (e.g., New Command snippet) by typing ':' + insertText = hookName; + } + suggestions.push({ + label: hookName, + documentation: meta?.description, + kind: CompletionItemKind.Property, + insertText, + insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, + range: new Range(position.lineNumber, rangeStartColumn, position.lineNumber, model.getLineMaxColumn(position.lineNumber)), + }); + } + + return { suggestions }; + } + + /** + * Provides completions for hook command fields (type, command, windows, etc.) + * when the cursor is inside a command object within the hooks map. + * Detects nesting by checking if the position falls within a sequence item + * of a hook event's value. + */ + private provideHookCommandFieldCompletions( + model: ITextModel, + position: Position, + hooksMap: IMapValue, + target: Target, + ): CompletionList | undefined { + // Find which hook event's command list the cursor is in + const containingCommandMap = this.findContainingCommandMap(model, position, hooksMap); + if (!containingCommandMap) { + return undefined; + } + + const isCopilotCli = target === Target.GitHubCopilot; + const validFields = isCopilotCli + ? ['type', 'bash', 'powershell', 'cwd', 'env', 'timeoutSec'] + : ['type', 'command', 'windows', 'linux', 'osx', 'bash', 'powershell', 'cwd', 'env', 'timeout']; + + const existingFields = new Set( + containingCommandMap.properties + .filter(p => p.key.range.startLineNumber !== position.lineNumber) + .map(p => p.key.value) + ); + + const lineText = model.getLineContent(position.lineNumber); + const firstNonWhitespace = lineText.search(/\S/); + const isEmptyLine = firstNonWhitespace === -1; + // Skip past the YAML sequence indicator `- ` so the range starts at the + // actual field name; otherwise VS Code's completion filter would see the + // `- ` prefix and reject valid field names. + const dashPrefixMatch = lineText.match(/^(\s*-\s+)/); + const fieldStart = dashPrefixMatch ? dashPrefixMatch[1].length : firstNonWhitespace; + const rangeStartColumn = isEmptyLine ? position.column : fieldStart + 1; + const colonIndex = lineText.indexOf(':'); + + const suggestions: CompletionItem[] = []; + for (const fieldName of validFields) { + if (existingFields.has(fieldName)) { + continue; + } + const desc = HOOK_COMMAND_FIELD_DESCRIPTIONS[fieldName]; + const insertText = colonIndex !== -1 ? fieldName : `${fieldName}: $0`; + suggestions.push({ + label: fieldName, + documentation: desc, + kind: CompletionItemKind.Property, + insertText, + insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, + range: new Range(position.lineNumber, rangeStartColumn, position.lineNumber, colonIndex !== -1 ? colonIndex + 1 : model.getLineMaxColumn(position.lineNumber)), + }); + } + + return suggestions.length > 0 ? { suggestions } : undefined; + } + + /** + * Walks the hooks map AST to find the command map object containing the position. + * Handles both direct command objects and nested matcher format. + * Also handles trailing lines after the last parsed property of a command map. + */ + private findContainingCommandMap(model: ITextModel, position: Position, hooksMap: IMapValue): IMapValue | undefined { + for (let i = 0; i < hooksMap.properties.length; i++) { + const prop = hooksMap.properties[i]; + if (prop.value.type !== 'sequence') { + continue; + } + // Check if cursor is within the sequence's range, or on a trailing line after it + const seqRange = prop.value.range; + const nextProp = hooksMap.properties[i + 1]; + const isInSeq = seqRange.containsPosition(position); + const isTrailingSeq = !isInSeq + && seqRange.endLineNumber < position.lineNumber + && (!nextProp || nextProp.key.range.startLineNumber > position.lineNumber); + + if (isInSeq || isTrailingSeq) { + // For trailing lines, verify the cursor is indented deeper than + // the hook event key — otherwise it belongs to the parent map. + if (isTrailingSeq) { + const lineText = model.getLineContent(position.lineNumber); + const firstNonWs = lineText.search(/\S/); + const effectiveIndent = firstNonWs === -1 ? position.column - 1 : firstNonWs; + const hookKeyIndent = prop.key.range.startColumn - 1; + if (effectiveIndent <= hookKeyIndent) { + continue; + } + } + const result = this.findCommandMapInSequence(position, prop.value); + if (result) { + return result; + } + } + } + return undefined; + } + + private findCommandMapInSequence(position: Position, sequence: ISequenceValue): IMapValue | undefined { + for (let i = 0; i < sequence.items.length; i++) { + const item = sequence.items[i]; + if (item.type !== 'map') { + // Handle partial typing: a scalar on the cursor line means the user + // is starting to type a command entry (e.g., "- t"). + if (item.type === 'scalar' && item.range.startLineNumber === position.lineNumber) { + return { type: 'map', properties: [], range: item.range }; + } + continue; + } + + // Check if position is within or just after this map item's parsed range. + // The parser's range may not include a trailing line being typed. + const isInRange = item.range.containsPosition(position); + const isTrailing = !isInRange + && item.range.endLineNumber < position.lineNumber + && (i + 1 >= sequence.items.length || sequence.items[i + 1].range.startLineNumber > position.lineNumber); + + if (!isInRange && !isTrailing) { + continue; + } + + // Check for nested matcher format: { hooks: [...] } + const nestedHooks = item.properties.find(p => p.key.value === 'hooks'); + if (nestedHooks?.value.type === 'sequence') { + const result = this.findCommandMapInSequence(position, nestedHooks.value); + if (result) { + return result; + } + } + return item; + } + return undefined; + } + + private getValueSuggestions(promptType: PromptsType, attribute: string, target: Target): readonly IValueEntry[] { + const attributeDesc = getAttributeDefinition(attribute, promptType, target); + if (attributeDesc?.enums) { + return attributeDesc.enums; + } + if (attributeDesc?.defaults) { + return attributeDesc.defaults.map(value => ({ name: value })); } switch (attribute) { - case PromptHeaderAttributes.applyTo: - if (promptType === PromptsType.instructions) { - return [ - { name: `'**'` }, - { name: `'**/*.ts, **/*.js'` }, - { name: `'**/*.php'` }, - { name: `'**/*.py'` } - ]; - } - break; case PromptHeaderAttributes.agent: case PromptHeaderAttributes.mode: if (promptType === PromptsType.prompt) { @@ -268,47 +589,12 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return suggestions; } break; - case PromptHeaderAttributes.target: - if (promptType === PromptsType.agent) { - return [{ name: 'vscode' }, { name: 'github-copilot' }]; - } - break; - case PromptHeaderAttributes.tools: - if (promptType === PromptsType.prompt || promptType === PromptsType.agent) { - return [ - { name: '[]' }, - { name: `['search', 'edit', 'web']` } - ]; - } - break; case PromptHeaderAttributes.model: if (promptType === PromptsType.prompt || promptType === PromptsType.agent) { return this.getModelNames(promptType === PromptsType.agent); } break; - case PromptHeaderAttributes.infer: - if (promptType === PromptsType.agent) { - return [ - { name: 'true' }, - { name: 'false' } - ]; - } - break; - case PromptHeaderAttributes.agents: - if (promptType === PromptsType.agent) { - return [{ name: '["*"]' }]; - } - break; - case PromptHeaderAttributes.userInvocable: - if (promptType === PromptsType.agent || promptType === PromptsType.skill) { - return [{ name: 'true' }, { name: 'false' }]; - } - break; - case PromptHeaderAttributes.disableModelInvocation: - if (promptType === PromptsType.agent || promptType === PromptsType.skill) { - return [{ name: 'true' }, { name: 'false' }]; - } - break; + } return []; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index 8fe87c45a10..e3d4b279cd9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -13,10 +13,14 @@ import { localize } from '../../../../../../nls.js'; import { ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, isToolSet, IToolSet } from '../../tools/languageModelToolsService.js'; import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js'; -import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { IPromptsService, Target } from '../service/promptsService.js'; -import { ClaudeHeaderAttributes, IHeaderAttribute, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; -import { getAttributeDescription, getTarget, isVSCodeOrDefaultTarget, knownClaudeModels, knownClaudeTools } from './promptValidator.js'; +import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; +import { IPromptsService } from '../service/promptsService.js'; +import { IHeaderAttribute, ISequenceValue, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { ClaudeHeaderAttributes, getAttributeDefinition, getTarget, isVSCodeOrDefaultTarget, knownClaudeModels, knownClaudeTools } from './promptFileAttributes.js'; +import { HOOKS_BY_TARGET, HOOK_METADATA } from '../hookTypes.js'; +import { HOOK_COMMAND_FIELD_DESCRIPTIONS } from '../hookSchema.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { PromptsConfig } from '../config/config.js'; export class PromptHoverProvider implements HoverProvider { /** @@ -29,6 +33,7 @@ export class PromptHoverProvider implements HoverProvider { @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IChatModeService private readonly chatModeService: IChatModeService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } @@ -73,7 +78,7 @@ export class PromptHoverProvider implements HoverProvider { private async provideHeaderHover(position: Position, promptType: PromptsType, header: PromptHeader, target: Target): Promise { for (const attribute of header.attributes) { if (attribute.range.containsPosition(position)) { - const description = getAttributeDescription(attribute.key, promptType, target); + const description = getAttributeDefinition(attribute.key, promptType, target)?.description; if (description) { switch (attribute.key) { case PromptHeaderAttributes.model: @@ -86,6 +91,11 @@ export class PromptHoverProvider implements HoverProvider { return this.getAgentHover(attribute, position, description); case PromptHeaderAttributes.handOffs: return this.getHandsOffHover(attribute, position, target); + case PromptHeaderAttributes.hooks: + if (!this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { + return undefined; + } + return this.getHooksHover(attribute, position, description, target); case PromptHeaderAttributes.infer: return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', 'Deprecated: Use `user-invocable` and `disable-model-invocation` instead.'), attribute.range); default: @@ -232,8 +242,64 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(lines.join('\n'), agentAttribute.range); } + private getHooksHover(attribute: IHeaderAttribute, position: Position, baseMessage: string, target: Target): Hover | undefined { + const value = attribute.value; + if (value.type === 'map') { + const hooksByTarget = HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined]; + for (const prop of value.properties) { + // Hover on a hook event name key (e.g., SessionStart, PreToolUse) + if (prop.key.range.containsPosition(position)) { + const hookType = hooksByTarget[prop.key.value]; + if (hookType) { + const meta = HOOK_METADATA[hookType]; + return this.createHover(`**${meta.label}**\n\n${meta.description}`, prop.key.range); + } + } + // Hover inside hook command entries + if (prop.value.type === 'sequence') { + const hover = this.getHookCommandItemHover(prop.value, position); + if (hover) { + return hover; + } + } + } + } + return this.createHover(baseMessage, attribute.range); + } + + /** + * Recursively searches hook command items for hover information. + * Handles both direct command objects and nested matcher format + * (e.g., `{ matcher: "...", hooks: [{ type: command, ... }] }`). + */ + private getHookCommandItemHover(sequence: ISequenceValue, position: Position): Hover | undefined { + for (const item of sequence.items) { + if (item.type !== 'map' || !item.range.containsPosition(position)) { + continue; + } + // Check for nested matcher format: { hooks: [...] } + const nestedHooks = item.properties.find(p => p.key.value === 'hooks'); + if (nestedHooks && nestedHooks.value.type === 'sequence') { + const hover = this.getHookCommandItemHover(nestedHooks.value, position); + if (hover) { + return hover; + } + } + // Check fields of the command object itself + for (const field of item.properties) { + if (field.key.range.containsPosition(position) || field.value.range.containsPosition(position)) { + const desc = HOOK_COMMAND_FIELD_DESCRIPTIONS[field.key.value]; + if (desc) { + return this.createHover(desc, field.key.range); + } + } + } + } + return undefined; + } + private getHandsOffHover(attribute: IHeaderAttribute, position: Position, target: Target): Hover | undefined { - const handoffsBaseMessage = getAttributeDescription(PromptHeaderAttributes.handOffs, PromptsType.agent, target)!; + const handoffsBaseMessage = getAttributeDefinition(PromptHeaderAttributes.handOffs, PromptsType.agent, target)?.description!; if (!isVSCodeOrDefaultTarget(target)) { return this.createHover(handoffsBaseMessage + '\n\n' + localize('promptHeader.agent.handoffs.githubCopilot', 'Note: This attribute is not used in GitHub Copilot or Claude targets.'), attribute.range); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index daf123f3151..d814c0b866a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -15,18 +15,23 @@ import { ChatMode, IChatMode, IChatModeService } from '../../chatModes.js'; import { ChatModeKind } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, SpecedToolAliases } from '../../tools/languageModelToolsService.js'; -import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { GithubPromptHeaderAttributes, ISequenceValue, IHeaderAttribute, IScalarValue, parseCommaSeparatedList, ParsedPromptFile, PromptHeader, PromptHeaderAttributes, IValue } from '../promptFileParser.js'; +import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; +import { ISequenceValue, IHeaderAttribute, IScalarValue, parseCommaSeparatedList, ParsedPromptFile, PromptHeader, IValue, PromptHeaderAttributes } from '../promptFileParser.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { IPromptsService, Target } from '../service/promptsService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IPromptsService } from '../service/promptsService.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { AGENTS_SOURCE_FOLDER, isInClaudeAgentsFolder, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { dirname } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { HOOKS_BY_TARGET } from '../hookTypes.js'; +import { PromptsConfig } from '../config/config.js'; +import { GithubPromptHeaderAttributes } from './promptFileAttributes.js'; export const MARKERS_OWNER_ID = 'prompts-diagnostics-provider'; @@ -37,7 +42,8 @@ export class PromptValidator { @IChatModeService private readonly chatModeService: IChatModeService, @IFileService private readonly fileService: IFileService, @ILabelService private readonly labelService: ILabelService, - @IPromptsService private readonly promptsService: IPromptsService + @IPromptsService private readonly promptsService: IPromptsService, + @IConfigurationService private readonly configurationService: IConfigurationService ) { } public async validate(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { @@ -189,6 +195,9 @@ export class PromptValidator { this.validateUserInvokable(attributes, report); this.validateDisableModelInvocation(attributes, report); this.validateTools(attributes, ChatModeKind.Agent, target, report); + if (this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { + this.validateHooks(attributes, target, report); + } if (isVSCodeOrDefaultTarget(target)) { this.validateModel(attributes, ChatModeKind.Agent, report); this.validateHandoffs(attributes, report); @@ -211,11 +220,21 @@ export class PromptValidator { } private checkForInvalidArguments(attributes: IHeaderAttribute[], promptType: PromptsType, target: Target, report: (markers: IMarkerData) => void): void { - const validAttributeNames = getValidAttributeNames(promptType, true, target); + let validAttributeNames = getValidAttributeNames(promptType, true, target); + if (!this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { + validAttributeNames = validAttributeNames.filter(name => name !== PromptHeaderAttributes.hooks); + } + const useCustomAgentHooks = this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS); const validGithubCopilotAttributeNames = new Lazy(() => new Set(getValidAttributeNames(promptType, false, Target.GitHubCopilot))); for (const attribute of attributes) { if (!validAttributeNames.includes(attribute.key)) { - const supportedNames = new Lazy(() => getValidAttributeNames(promptType, false, target).sort().join(', ')); + const supportedNames = new Lazy(() => { + let names = getValidAttributeNames(promptType, false, target); + if (!useCustomAgentHooks) { + names = names.filter(name => name !== PromptHeaderAttributes.hooks); + } + return names.sort().join(', '); + }); switch (promptType) { case PromptsType.prompt: report(toMarker(localize('promptValidator.unknownAttribute.prompt', "Attribute '{0}' is not supported in prompt files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Warning)); @@ -543,6 +562,119 @@ export class PromptValidator { } } + private validateHooks(attributes: IHeaderAttribute[], target: Target, report: (markers: IMarkerData) => void): undefined { + const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.hooks); + if (!attribute) { + return; + } + if (attribute.value.type !== 'map') { + report(toMarker(localize('promptValidator.hooksMustBeMap', "The 'hooks' attribute must be a map of hook event types to command arrays."), attribute.value.range, MarkerSeverity.Error)); + return; + } + const validHookNames = new Set(Object.keys(HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined])); + for (const prop of attribute.value.properties) { + if (!validHookNames.has(prop.key.value)) { + report(toMarker(localize('promptValidator.unknownHookType', "Unknown hook event type '{0}'. Supported: {1}.", prop.key.value, Array.from(validHookNames).join(', ')), prop.key.range, MarkerSeverity.Warning)); + } + if (prop.value.type !== 'sequence') { + report(toMarker(localize('promptValidator.hookValueMustBeArray', "Hook event '{0}' must have an array of command objects as its value.", prop.key.value), prop.value.range, MarkerSeverity.Error)); + continue; + } + for (const item of prop.value.items) { + this.validateHookCommand(item, target, report); + } + } + } + + private validateHookCommand(item: IValue, target: Target, report: (markers: IMarkerData) => void): void { + if (item.type !== 'map') { + report(toMarker(localize('promptValidator.hookCommandMustBeObject', "Each hook command must be an object."), item.range, MarkerSeverity.Error)); + return; + } + + // Detect nested matcher format: { matcher?: "...", hooks: [{ type: 'command', command: '...' }] } + const hooksProperty = item.properties.find(p => p.key.value === 'hooks'); + if (hooksProperty) { + // Validate that only known matcher properties are present + for (const prop of item.properties) { + if (prop.key.value !== 'hooks' && prop.key.value !== 'matcher') { + report(toMarker(localize('promptValidator.unknownMatcherProperty', "Unknown property '{0}' in hook matcher.", prop.key.value), prop.key.range, MarkerSeverity.Warning)); + } + } + if (hooksProperty.value.type !== 'sequence') { + report(toMarker(localize('promptValidator.nestedHooksMustBeArray', "The 'hooks' property in a matcher must be an array of command objects."), hooksProperty.value.range, MarkerSeverity.Error)); + return; + } + for (const nestedItem of hooksProperty.value.items) { + this.validateHookCommand(nestedItem, target, report); + } + return; + } + + const isCopilotCli = target === Target.GitHubCopilot; + + // Determine valid and command-providing properties based on target + const validCommandFields = isCopilotCli + ? new Set(['bash', 'powershell']) + : new Set(['command', 'windows', 'linux', 'osx', 'bash', 'powershell']); + + const validProperties = isCopilotCli + ? new Set(['type', 'bash', 'powershell', 'cwd', 'env', 'timeoutSec']) + : new Set(['type', 'command', 'windows', 'linux', 'osx', 'bash', 'powershell', 'cwd', 'env', 'timeout']); + + let hasType = false; + let hasCommandField = false; + + for (const prop of item.properties) { + const key = prop.key.value; + + if (!validProperties.has(key)) { + report(toMarker(localize('promptValidator.unknownHookProperty', "Unknown property '{0}' in hook command.", key), prop.key.range, MarkerSeverity.Warning)); + } + + if (key === 'type') { + hasType = true; + if (prop.value.type !== 'scalar' || prop.value.value !== 'command') { + report(toMarker(localize('promptValidator.hookTypeMustBeCommand', "The 'type' property in a hook command must be 'command'."), prop.value.range, MarkerSeverity.Error)); + } + } else if (validCommandFields.has(key)) { + hasCommandField = true; + if (prop.value.type !== 'scalar' || prop.value.value.trim().length === 0) { + report(toMarker(localize('promptValidator.hookCommandFieldMustBeNonEmptyString', "The '{0}' property in a hook command must be a non-empty string.", key), prop.value.range, MarkerSeverity.Error)); + } + } else if (key === 'cwd') { + if (prop.value.type !== 'scalar') { + report(toMarker(localize('promptValidator.hookCwdMustBeString', "The 'cwd' property in a hook command must be a string."), prop.value.range, MarkerSeverity.Error)); + } + } else if (key === 'env') { + if (prop.value.type !== 'map') { + report(toMarker(localize('promptValidator.hookEnvMustBeMap', "The 'env' property in a hook command must be a map of string values."), prop.value.range, MarkerSeverity.Error)); + } else { + for (const envProp of prop.value.properties) { + if (envProp.value.type !== 'scalar') { + report(toMarker(localize('promptValidator.hookEnvValueMustBeString', "Environment variable '{0}' must have a string value.", envProp.key.value), envProp.value.range, MarkerSeverity.Error)); + } + } + } + } else if (key === 'timeout' || key === 'timeoutSec') { + if (prop.value.type !== 'scalar' || isNaN(Number(prop.value.value))) { + report(toMarker(localize('promptValidator.hookTimeoutMustBeNumber', "The '{0}' property in a hook command must be a number.", key), prop.value.range, MarkerSeverity.Error)); + } + } + } + + if (!hasType) { + report(toMarker(localize('promptValidator.hookMissingType', "Hook command is missing required property 'type'."), item.range, MarkerSeverity.Error)); + } + if (!hasCommandField) { + if (isCopilotCli) { + report(toMarker(localize('promptValidator.hookMissingCopilotCommand', "Hook command must specify at least one of 'bash' or 'powershell'."), item.range, MarkerSeverity.Error)); + } else { + report(toMarker(localize('promptValidator.hookMissingCommand', "Hook command must specify at least one of 'command', 'windows', 'linux', or 'osx'."), item.range, MarkerSeverity.Error)); + } + } + } + private validateHandoffs(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.handOffs); if (!attribute) { @@ -759,7 +891,7 @@ function isTrueOrFalse(value: IValue): boolean { const allAttributeNames: Record = { [PromptsType.prompt]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.mode, PromptHeaderAttributes.agent, PromptHeaderAttributes.argumentHint], [PromptsType.instructions]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.applyTo, PromptHeaderAttributes.excludeAgent], - [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation, GithubPromptHeaderAttributes.github], + [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.hooks, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation, GithubPromptHeaderAttributes.github], [PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.license, PromptHeaderAttributes.compatibility, PromptHeaderAttributes.metadata, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter }; @@ -844,6 +976,8 @@ export function getAttributeDescription(attributeName: string, promptType: Promp return localize('promptHeader.agent.infer', 'Controls visibility of the agent.'); case PromptHeaderAttributes.agents: return localize('promptHeader.agent.agents', 'One or more agents that this agent can use as subagents. Use \'*\' to specify all available agents.'); + case PromptHeaderAttributes.hooks: + return localize('promptHeader.agent.hooks', 'Lifecycle hooks scoped to this agent. Define hooks that run only while this agent is active.'); case PromptHeaderAttributes.userInvocable: return localize('promptHeader.agent.userInvocable', 'Whether the agent can be selected and invoked by users in the UI.'); case PromptHeaderAttributes.disableModelInvocation: @@ -1022,7 +1156,8 @@ export function isVSCodeOrDefaultTarget(target: Target): boolean { export function getTarget(promptType: PromptsType, header: PromptHeader | URI): Target { const uri = header instanceof URI ? header : header.uri; if (promptType === PromptsType.agent) { - if (isInClaudeAgentsFolder(uri)) { + const parentDir = dirname(uri); + if (parentDir.path.endsWith(`/${CLAUDE_AGENTS_SOURCE_FOLDER}`)) { return Target.Claude; } if (!(header instanceof URI)) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index 0de7e30f982..240265152cf 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -10,7 +10,6 @@ import { URI } from '../../../../../base/common/uri.js'; import { parse, YamlNode, YamlParseError } from '../../../../../base/common/yaml.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { PositionOffsetTransformer } from '../../../../../editor/common/core/text/positionToOffsetImpl.js'; -import { Target } from './service/promptsService.js'; export class PromptFileParser { constructor() { @@ -85,19 +84,7 @@ export namespace PromptHeaderAttributes { export const userInvokable = 'user-invokable'; export const userInvocable = 'user-invocable'; export const disableModelInvocation = 'disable-model-invocation'; -} - -export namespace GithubPromptHeaderAttributes { - export const mcpServers = 'mcp-servers'; - export const github = 'github'; -} - -export namespace ClaudeHeaderAttributes { - export const disallowedTools = 'disallowedTools'; -} - -export function isTarget(value: unknown): value is Target { - return value === Target.VSCode || value === Target.GitHubCopilot || value === Target.Claude || value === Target.Undefined; + export const hooks = 'hooks'; } export class PromptHeader { @@ -331,6 +318,20 @@ export class PromptHeader { return this.getBooleanAttribute(PromptHeaderAttributes.disableModelInvocation); } + /** + * Gets the raw 'hooks' attribute value from the header. + * Returns the YAML map value if present, or undefined. The caller is + * responsible for converting this to `ChatRequestHooks` via + * {@link parseSubagentHooksFromYaml}. + */ + public get hooksRaw(): IMapValue | undefined { + const attr = this._parsedHeader.attributes.find(a => a.key === PromptHeaderAttributes.hooks); + if (attr?.value.type === 'map') { + return attr.value; + } + return undefined; + } + private getBooleanAttribute(key: string): boolean | undefined { const attribute = this._parsedHeader.attributes.find(attr => attr.key === key); if (attribute?.value.type === 'scalar') { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts index 8c4d0cbc58a..c51a7123aef 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts @@ -92,3 +92,9 @@ export function isValidPromptType(type: string): type is PromptsType { return Object.values(PromptsType).includes(type as PromptsType); } +export enum Target { + VSCode = 'vscode', + GitHubCopilot = 'github-copilot', + Claude = 'claude', + Undefined = 'undefined', +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 73f48099a13..87987d457fd 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -11,11 +11,11 @@ import { ITextModel } from '../../../../../../editor/common/model.js'; import { ExtensionIdentifier, IExtensionDescription } from '../../../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IChatModeInstructions, IVariableReference } from '../../chatModes.js'; -import { PromptsType } from '../promptTypes.js'; +import { PromptsType, Target } from '../promptTypes.js'; import { IHandOff, ParsedPromptFile } from '../promptFileParser.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; -import { IChatRequestHooks } from '../hookSchema.js'; import { IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; +import { ChatRequestHooks } from '../hookSchema.js'; /** * Entry emitted by the prompts service when discovery logging occurs. @@ -168,13 +168,6 @@ export function isCustomAgentVisibility(obj: unknown): obj is ICustomAgentVisibi return typeof v.userInvocable === 'boolean' && typeof v.agentInvocable === 'boolean'; } -export enum Target { - VSCode = 'vscode', - GitHubCopilot = 'github-copilot', - Claude = 'claude', - Undefined = 'undefined', -} - export interface ICustomAgent { /** * URI of a custom agent file. @@ -232,6 +225,11 @@ export interface ICustomAgent { */ readonly agents?: readonly string[]; + /** + * Lifecycle hooks scoped to this subagent. + */ + readonly hooks?: ChatRequestHooks; + /** * Where the agent was loaded from. */ @@ -306,7 +304,8 @@ export type PromptFileSkipReason = | 'parse-error' | 'disabled' | 'all-hooks-disabled' - | 'claude-hooks-disabled'; + | 'claude-hooks-disabled' + | 'workspace-untrusted'; /** * Result of discovering a single prompt file. @@ -335,12 +334,6 @@ export interface IPromptFileDiscoveryResult { export interface IPromptSourceFolderResult { readonly uri: URI; readonly storage: PromptsStorage; - /** Whether the folder exists on disk */ - readonly exists: boolean; - /** Number of matching files found in this folder */ - readonly fileCount: number; - /** Error message if resolution failed */ - readonly errorMessage?: string; } /** @@ -349,12 +342,12 @@ export interface IPromptSourceFolderResult { export interface IPromptDiscoveryInfo { readonly type: PromptsType; readonly files: readonly IPromptFileDiscoveryResult[]; - /** Source folders that were searched, with their existence and file count */ + /** Source folders that were searched */ readonly sourceFolders?: readonly IPromptSourceFolderResult[]; } export interface IConfiguredHooksInfo { - readonly hooks: IChatRequestHooks; + readonly hooks: ChatRequestHooks; readonly hasDisabledClaudeHooks: boolean; } @@ -424,6 +417,11 @@ export interface IPromptsService extends IDisposable { */ readonly onDidChangeCustomAgents: Event; + /** + * Event that is triggered when the list of instruction files changes. + */ + readonly onDidChangeInstructions: Event; + /** * Finds all available custom agents * @param sessionResource Optional session resource to scope debug logging to a specific session. @@ -491,12 +489,9 @@ export interface IPromptsService extends IDisposable { findAgentSkills(token: CancellationToken, sessionResource?: URI): Promise; /** - * Gets detailed discovery information for a prompt type. - * This includes all files found and their load/skip status with reasons. - * Used for diagnostics and config-info displays. - * @param sessionResource Optional session resource to scope debug logging to a specific session. + * Event that is triggered when the list of skills changes. */ - getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken, sessionResource?: URI): Promise; + readonly onDidChangeSkills: Event; /** * Gets all hooks collected from hooks.json files. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 6c566c8bbdc..c9c614bf77b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -30,20 +30,23 @@ import { IUserDataProfileService } from '../../../../../services/userDataProfile import { IVariableReference } from '../../chatModes.js'; import { PromptsConfig } from '../config/config.js'; import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, getCleanPromptName, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; -import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; +import { PROMPT_LANGUAGE_ID, PromptsType, Target, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target, IPromptDiscoveryLogEntry } from './promptsService.js'; +import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, IPromptDiscoveryLogEntry } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; -import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; +import { ChatRequestHooks, IHookCommand, parseSubagentHooksFromYaml } from '../hookSchema.js'; +import { HookType } from '../hookTypes.js'; import { HookSourceFormat, getHookSourceFormat, parseHooksFromFile } from '../hookCompatibility.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; -import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders/promptValidator.js'; +import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders/promptFileAttributes.js'; import { StopWatch } from '../../../../../../base/common/stopwatch.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { getCanonicalPluginCommandId, IAgentPlugin, IAgentPluginService } from '../../plugins/agentPluginService.js'; +import { isContributionEnabled } from '../../enablement.js'; import { assertNever } from '../../../../../../base/common/assert.js'; /** @@ -151,6 +154,7 @@ export class PromptsService extends Disposable implements IPromptsService { private readonly _contributedWhenKeys = new Set(); private readonly _contributedWhenClauses = new Map(); private readonly _onDidContributedWhenChange = this._register(new Emitter()); + private readonly _onDidChangeInstructions = this._register(new Emitter()); private readonly _onDidPluginPromptFilesChange = this._register(new Emitter()); private readonly _onDidPluginHooksChange = this._register(new Emitter()); private _pluginPromptFilesByType = new Map(); @@ -171,6 +175,7 @@ export class PromptsService extends Disposable implements IPromptsService { @IPathService private readonly pathService: IPathService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + @IWorkspaceTrustManagementService private readonly workspaceTrustService: IWorkspaceTrustManagementService, ) { super(); @@ -191,7 +196,12 @@ export class PromptsService extends Disposable implements IPromptsService { const modelChangeEvent = this._register(new ModelChangeTracker(this.modelService)).onDidPromptChange; this.cachedCustomAgents = this._register(new CachedPromise( (token) => this.computeCustomAgents(token), - () => Event.any(this.getFileLocatorEvent(PromptsType.agent), Event.filter(modelChangeEvent, e => e.promptType === PromptsType.agent), this._onDidContributedWhenChange.event) + () => Event.any( + this.getFileLocatorEvent(PromptsType.agent), + Event.filter(modelChangeEvent, e => e.promptType === PromptsType.agent), + this._onDidContributedWhenChange.event, + Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)), + ) )); this.cachedSlashCommands = this._register(new CachedPromise( @@ -216,6 +226,7 @@ export class PromptsService extends Disposable implements IPromptsService { this.getFileLocatorEvent(PromptsType.hook), Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CHAT_HOOKS) || e.affectsConfiguration(PromptsConfig.USE_CLAUDE_HOOKS)), this._onDidPluginHooksChange.event, + this.workspaceTrustService.onDidChangeTrust, ) )); @@ -239,7 +250,9 @@ export class PromptsService extends Disposable implements IPromptsService { this._register(autorun(reader => { const plugins = this.agentPluginService.plugins.read(reader); for (const plugin of plugins) { - plugin.hooks.read(reader); + if (isContributionEnabled(plugin.enablement.read(reader))) { + plugin.hooks.read(reader); + } } this._onDidPluginHooksChange.fire(); })); @@ -253,6 +266,9 @@ export class PromptsService extends Disposable implements IPromptsService { const plugins = this.agentPluginService.plugins.read(reader); const nextFiles: IPluginPromptPath[] = []; for (const plugin of plugins) { + if (!isContributionEnabled(plugin.enablement.read(reader))) { + continue; + } for (const item of getItems(plugin, reader)) { nextFiles.push({ uri: item.uri, @@ -323,42 +339,14 @@ export class PromptsService extends Disposable implements IPromptsService { } /** - * Collects diagnostic information about which source folders were searched - * and whether they exist, for display in the debug panel. + * Collects diagnostic information about which source folders were searched for display in the debug panel. */ - private async _collectSourceFolderDiagnostics(type: PromptsType, foundFiles: readonly { uri: URI }[]): Promise { + private async _collectSourceFolderDiagnostics(type: PromptsType): Promise { const resolvedFolders = await this.fileLocator.getSourceFoldersInDiscoveryOrder(type); - const results: IPromptSourceFolderResult[] = []; - - for (const folder of resolvedFolders) { - const fileCount = foundFiles.filter(f => f.uri.path.startsWith(folder.uri.path + '/')).length; - let exists = fileCount > 0; - let errorMessage: string | undefined; - - if (!exists) { - try { - const stat = await this.fileService.stat(folder.uri); - exists = stat.isDirectory; - } catch (e) { - if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { - exists = false; - } else { - exists = false; - errorMessage = e instanceof Error ? e.message : String(e); - } - } - } - - results.push({ - uri: folder.uri, - storage: folder.storage, - exists, - fileCount, - errorMessage, - }); - } - - return results; + return resolvedFolders.map(folder => ({ + uri: folder.uri, + storage: folder.storage, + })); } /** @@ -416,6 +404,7 @@ export class PromptsService extends Disposable implements IPromptsService { this.cachedCustomAgents.refresh(); } else if (type === PromptsType.instructions) { this.cachedFileLocations[PromptsType.instructions] = undefined; + this._onDidChangeInstructions.fire(); } else if (type === PromptsType.prompt) { this.cachedFileLocations[PromptsType.prompt] = undefined; this.cachedSlashCommands.refresh(); @@ -570,7 +559,24 @@ export class PromptsService extends Disposable implements IPromptsService { } public async getPromptSlashCommands(token: CancellationToken, sessionResource?: URI): Promise { - return await this.cachedSlashCommands.get(token); + const sw = StopWatch.create(); + const result = await this.cachedSlashCommands.get(token); + if (sessionResource) { + const elapsed = sw.elapsed(); + void this.getPromptSlashCommandDiscoveryInfo(token).catch(() => undefined).then(discoveryInfo => { + const details = result.length === 1 + ? localize("promptsService.resolvedSlashCommand", "Resolved {0} slash command in {1}ms", result.length, elapsed.toFixed(1)) + : localize("promptsService.resolvedSlashCommands", "Resolved {0} slash commands in {1}ms", result.length, elapsed.toFixed(1)); + this._onDidLogDiscovery.fire({ + sessionResource, + name: localize("promptsService.loadSlashCommands", "Load Slash Commands"), + details, + discoveryInfo, + category: 'discovery', + }); + }); + } + return result; } private async computePromptSlashCommands(token: CancellationToken): Promise { @@ -643,21 +649,30 @@ export class PromptsService extends Disposable implements IPromptsService { return this.cachedCustomAgents.onDidChange; } + public get onDidChangeInstructions(): Event { + return Event.any( + this.getFileLocatorEvent(PromptsType.instructions), + this._onDidContributedWhenChange.event, + this._onDidChangeInstructions.event, + ); + } + public async getCustomAgents(token: CancellationToken, sessionResource?: URI): Promise { const sw = StopWatch.create(); const result = await this.cachedCustomAgents.get(token); if (sessionResource) { const elapsed = sw.elapsed(); - const discoveryInfo = await this.getAgentDiscoveryInfo(token); - const details = result.length === 1 - ? localize("promptsService.resolvedAgent", "Resolved {0} agent in {1}ms", result.length, elapsed.toFixed(1)) - : localize("promptsService.resolvedAgents", "Resolved {0} agents in {1}ms", result.length, elapsed.toFixed(1)); - this._onDidLogDiscovery.fire({ - sessionResource, - name: localize("promptsService.loadAgents", "Load Agents"), - details, - discoveryInfo, - category: 'discovery', + void this.getAgentDiscoveryInfo(token).catch(() => undefined).then(discoveryInfo => { + const details = result.length === 1 + ? localize("promptsService.resolvedAgent", "Resolved {0} agent in {1}ms", result.length, elapsed.toFixed(1)) + : localize("promptsService.resolvedAgents", "Resolved {0} agents in {1}ms", result.length, elapsed.toFixed(1)); + this._onDidLogDiscovery.fire({ + sessionResource, + name: localize("promptsService.loadAgents", "Load Agents"), + details, + discoveryInfo, + category: 'discovery', + }); }); } return result; @@ -667,6 +682,12 @@ export class PromptsService extends Disposable implements IPromptsService { let agentFiles = await this.listPromptFiles(PromptsType.agent, token); const disabledAgents = this.getDisabledPromptFiles(PromptsType.agent); agentFiles = agentFiles.filter(promptPath => !disabledAgents.has(promptPath.uri)); + + // Get user home for tilde expansion in hook cwd paths + const userHomeUri = await this.pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + const defaultFolder = this.workspaceService.getWorkspace().folders[0]; + const customAgentsResults = await Promise.allSettled( agentFiles.map(async (promptPath): Promise => { const uri = promptPath.uri; @@ -722,7 +743,18 @@ export class PromptsService extends Disposable implements IPromptsService { if (target === Target.Claude && tools) { tools = mapClaudeTools(tools); } - return { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, agentInstructions, source }; + + // Parse hooks from the frontmatter if present + let hooks: ChatRequestHooks | undefined; + const useCustomAgentHooks = this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS); + const hooksRaw = ast.header.hooksRaw; + if (useCustomAgentHooks && hooksRaw) { + const hookWorkspaceFolder = this.workspaceService.getWorkspaceFolder(uri) ?? defaultFolder; + const workspaceRootUri = hookWorkspaceFolder?.uri; + hooks = parseSubagentHooksFromYaml(hooksRaw, workspaceRootUri, userHome, target); + } + + return { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, hooks, agentInstructions, source }; }) ); @@ -1047,16 +1079,17 @@ export class PromptsService extends Disposable implements IPromptsService { const result = await this.cachedSkills.get(token); if (sessionResource) { const elapsed = sw.elapsed(); - const discoveryInfo = await this.getSkillDiscoveryInfo(token); - const details = result.length === 1 - ? localize("promptsService.resolvedSkill", "Resolved {0} skill in {1}ms", result.length, elapsed.toFixed(1)) - : localize("promptsService.resolvedSkills", "Resolved {0} skills in {1}ms", result.length, elapsed.toFixed(1)); - this._onDidLogDiscovery.fire({ - sessionResource, - name: localize("promptsService.loadSkills", "Load Skills"), - details, - discoveryInfo, - category: 'discovery', + void this.getSkillDiscoveryInfo(token).catch(() => undefined).then(discoveryInfo => { + const details = result.length === 1 + ? localize("promptsService.resolvedSkill", "Resolved {0} skill in {1}ms", result.length, elapsed.toFixed(1)) + : localize("promptsService.resolvedSkills", "Resolved {0} skills in {1}ms", result.length, elapsed.toFixed(1)); + this._onDidLogDiscovery.fire({ + sessionResource, + name: localize("promptsService.loadSkills", "Load Skills"), + details, + discoveryInfo, + category: 'discovery', + }); }); } return result; @@ -1170,17 +1203,18 @@ export class PromptsService extends Disposable implements IPromptsService { const result = await this.cachedHooks.get(token); if (sessionResource) { const elapsed = sw.elapsed(); - const hookCount = result ? Object.values(result.hooks).reduce((sum, arr) => sum + arr.length, 0) : 0; - const discoveryInfo = await this.getHookDiscoveryInfo(token); - const details = hookCount === 1 - ? localize("promptsService.resolvedHook", "Resolved {0} hook in {1}ms", hookCount, elapsed.toFixed(1)) - : localize("promptsService.resolvedHooks", "Resolved {0} hooks in {1}ms", hookCount, elapsed.toFixed(1)); - this._onDidLogDiscovery.fire({ - sessionResource, - name: localize("promptsService.loadHooks", "Load Hooks"), - details, - discoveryInfo, - category: 'discovery', + void this.getHookDiscoveryInfo(token).catch(() => undefined).then(discoveryInfo => { + const hookCount = result ? Object.values(result.hooks).reduce((sum, arr) => sum + arr.length, 0) : 0; + const details = hookCount === 1 + ? localize("promptsService.resolvedHook", "Resolved {0} hook in {1}ms", hookCount, elapsed.toFixed(1)) + : localize("promptsService.resolvedHooks", "Resolved {0} hooks in {1}ms", hookCount, elapsed.toFixed(1)); + this._onDidLogDiscovery.fire({ + sessionResource, + name: localize("promptsService.loadHooks", "Load Hooks"), + details, + discoveryInfo, + category: 'discovery', + }); }); } return result; @@ -1191,16 +1225,17 @@ export class PromptsService extends Disposable implements IPromptsService { const result = await this.listPromptFiles(PromptsType.instructions, token); if (sessionResource) { const elapsed = sw.elapsed(); - const discoveryInfo = await this.getInstructionsDiscoveryInfo(token); - const details = result.length === 1 - ? localize("promptsService.resolvedInstruction", "Resolved {0} instruction in {1}ms", result.length, elapsed.toFixed(1)) - : localize("promptsService.resolvedInstructions", "Resolved {0} instructions in {1}ms", result.length, elapsed.toFixed(1)); - this._onDidLogDiscovery.fire({ - sessionResource, - name: localize("promptsService.loadInstructions", "Load Instructions"), - details, - discoveryInfo, - category: 'discovery', + void this.getInstructionsDiscoveryInfo(token).catch(() => undefined).then(discoveryInfo => { + const details = result.length === 1 + ? localize("promptsService.resolvedInstruction", "Resolved {0} instruction in {1}ms", result.length, elapsed.toFixed(1)) + : localize("promptsService.resolvedInstructions", "Resolved {0} instructions in {1}ms", result.length, elapsed.toFixed(1)); + this._onDidLogDiscovery.fire({ + sessionResource, + name: localize("promptsService.loadInstructions", "Load Instructions"), + details, + discoveryInfo, + category: 'discovery', + }); }); } return result; @@ -1212,6 +1247,10 @@ export class PromptsService extends Disposable implements IPromptsService { return undefined; } + if (!this.workspaceTrustService.isWorkspaceTrusted()) { + return undefined; + } + const useClaudeHooks = this.configurationService.getValue(PromptsConfig.USE_CLAUDE_HOOKS); const hookFiles = await this.listPromptFiles(PromptsType.hook, token); @@ -1222,16 +1261,7 @@ export class PromptsService extends Disposable implements IPromptsService { const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; let hasDisabledClaudeHooks = false; - const collectedHooks: Record = { - [HookType.SessionStart]: [], - [HookType.UserPromptSubmit]: [], - [HookType.PreToolUse]: [], - [HookType.PostToolUse]: [], - [HookType.PreCompact]: [], - [HookType.SubagentStart]: [], - [HookType.SubagentStop]: [], - [HookType.Stop]: [], - }; + const collectedHooks = new Map(); const defaultFolder = this.workspaceService.getWorkspace().folders[0]; @@ -1266,7 +1296,12 @@ export class PromptsService extends Disposable implements IPromptsService { for (const [hookType, { hooks: commands }] of hooks) { for (const command of commands) { - collectedHooks[hookType].push(command); + let bucket = collectedHooks.get(hookType); + if (!bucket) { + bucket = []; + collectedHooks.set(hookType, bucket); + } + bucket.push(command); this.logger.trace(`[PromptsService] Collected ${hookType} hook from ${hookFile.uri} (format: ${format})`); } } @@ -1278,79 +1313,32 @@ export class PromptsService extends Disposable implements IPromptsService { // Collect hooks from agent plugins const plugins = this.agentPluginService.plugins.get(); for (const plugin of plugins) { + if (!isContributionEnabled(plugin.enablement.get())) { + continue; + } for (const hook of plugin.hooks.get()) { - collectedHooks[hook.type].push(...hook.hooks); + let bucket = collectedHooks.get(hook.type); + if (!bucket) { + bucket = []; + collectedHooks.set(hook.type, bucket); + } + bucket.push(...hook.hooks); } } // Check if any hooks were collected - const hasHooks = Object.values(collectedHooks).some(arr => arr.length > 0); - if (!hasHooks) { + if (collectedHooks.size === 0) { this.logger.trace('[PromptsService] No valid hooks collected.'); return undefined; } - // Build the result, only including hook types that have entries - const result: IChatRequestHooks = Object.fromEntries( - Object.entries(collectedHooks).filter(([_, commands]) => commands.length > 0) - ) as IChatRequestHooks; + // Build the result + const result: ChatRequestHooks = Object.fromEntries(collectedHooks) as ChatRequestHooks; this.logger.trace(`[PromptsService] Collected hooks: ${JSON.stringify(Object.keys(result))}`); return { hooks: result, hasDisabledClaudeHooks }; } - public async getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken, sessionResource?: URI): Promise { - if (sessionResource) { - this._onDidLogDiscovery.fire({ - sessionResource, - name: localize("promptsService.discoveryStart", "Discovery {0} (Start)", type), - category: 'discovery', - }); - } - const files: IPromptFileDiscoveryResult[] = []; - - let result: IPromptDiscoveryInfo; - if (type === PromptsType.skill) { - result = await this.getSkillDiscoveryInfo(token); - } else if (type === PromptsType.agent) { - result = await this.getAgentDiscoveryInfo(token); - } else if (type === PromptsType.prompt) { - result = await this.getPromptSlashCommandDiscoveryInfo(token); - } else if (type === PromptsType.instructions) { - result = await this.getInstructionsDiscoveryInfo(token); - } else if (type === PromptsType.hook) { - result = await this.getHookDiscoveryInfo(token); - } else { - result = { type, files }; - } - - const loadedCount = result.files.filter(f => f.status === 'loaded').length; - const skippedCount = result.files.filter(f => f.status === 'skipped').length; - - // Add source folder diagnostics if not already present - if (!result.sourceFolders) { - const sourceFolders = await this._collectSourceFolderDiagnostics(type, result.files.filter(f => f.status === 'loaded')); - result = { ...result, sourceFolders }; - } - - if (sessionResource) { - const details = localize( - "promptsService.discoveryResult", - "{0} loaded, {1} skipped", - loadedCount, - skippedCount, - ); - this._onDidLogDiscovery.fire({ - sessionResource, - name: localize("promptsService.discoveryEnd", "Discovery {0} (End)", type), - details, - discoveryInfo: result, - category: 'discovery', - }); - } - return result; - } - private async getSkillDiscoveryInfo(token: CancellationToken): Promise { const useAgentSkills = this.configurationService.getValue(PromptsConfig.USE_AGENT_SKILLS); @@ -1364,13 +1352,12 @@ export class PromptsService extends Disposable implements IPromptsService { skipReason: 'disabled' as const, extensionId: promptPath.extension?.identifier?.value })); - const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.skill, []); + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.skill); return { type: PromptsType.skill, files, sourceFolders }; } const { files } = await this.computeSkillDiscoveryInfo(token); - const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.skill, files.filter(f => f.status === 'loaded')); - return { type: PromptsType.skill, files, sourceFolders }; + return { type: PromptsType.skill, files }; } /** @@ -1522,7 +1509,7 @@ export class PromptsService extends Disposable implements IPromptsService { } } - const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.agent, files.filter(f => f.status === 'loaded')); + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.agent); return { type: PromptsType.agent, files, sourceFolders }; } @@ -1551,7 +1538,7 @@ export class PromptsService extends Disposable implements IPromptsService { } } - const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.prompt, files.filter(f => f.status === 'loaded')); + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.prompt); return { type: PromptsType.prompt, files, sourceFolders }; } @@ -1580,7 +1567,7 @@ export class PromptsService extends Disposable implements IPromptsService { } } - const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.instructions, files.filter(f => f.status === 'loaded')); + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.instructions); return { type: PromptsType.instructions, files, sourceFolders }; } @@ -1599,6 +1586,19 @@ export class PromptsService extends Disposable implements IPromptsService { const extensionId = promptPath.extension?.identifier?.value; const name = basename(uri); + // Ignored if workspace is untrusted + if (!this.workspaceTrustService.isWorkspaceTrusted()) { + files.push({ + uri: promptPath.uri, + storage: promptPath.storage, + status: 'skipped', + skipReason: 'workspace-untrusted', + name: basename(promptPath.uri), + extensionId: promptPath.extension?.identifier?.value, + }); + continue; + } + // Skip Claude hooks when the setting is disabled if (getHookSourceFormat(uri) === HookSourceFormat.Claude && useClaudeHooks === false) { files.push({ @@ -1666,7 +1666,7 @@ export class PromptsService extends Disposable implements IPromptsService { } } - const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.hook, files.filter(f => f.status === 'loaded')); + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.hook); return { type: PromptsType.hook, files, sourceFolders }; } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 5ee6297bb31..c0675b37659 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -9,10 +9,10 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import * as nls from '../../../../../../nls.js'; import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js'; import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../config/config.js'; -import { basename, dirname, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource, isInClaudeRulesFolder } from '../config/promptFileLocations.js'; +import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; import { PromptsType } from '../promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -26,6 +26,11 @@ import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; +/** + * Maximum recursion depth when traversing subdirectories for instruction files. + */ +const MAX_INSTRUCTIONS_RECURSION_DEPTH = 5; + /** * Utility class to locate prompt files. */ @@ -492,14 +497,23 @@ export class PromptFilesLocator { /** * Uses the file service to resolve the provided location and return either the file at the location of files in the directory. - * For claude rules folders (.claude/rules), this searches recursively to support subdirectories. + * For instruction folders, this searches recursively (up to {@link MAX_INSTRUCTIONS_RECURSION_DEPTH} levels deep) provided + * the location is not a workspace folder root and does not contain wildcards, to support subdirectories while avoiding + * accidentally broad traversal. */ - private async resolveFilesAtLocation(location: URI, type: PromptsType, token: CancellationToken): Promise { + private async resolveFilesAtLocation(location: URI, type: PromptsType, token: CancellationToken, depth: number = 0): Promise { if (type === PromptsType.skill) { return this.findAgentSkillsInFolder(location, token); } - // Claude rules folders support subdirectories, so search recursively - const recursive = type === PromptsType.instructions && isInClaudeRulesFolder(joinPath(location, 'dummy.md')); + // Recurse into subdirectories for instruction folders, but only if: + // - the location is not a workspace folder root (to avoid full workspace traversal) + // - the path does not contain wildcards (already filtered upstream, but guard here too) + // - the recursion depth hasn't exceeded the limit + const isWorkspaceRoot = depth === 0 && this.getWorkspaceFolders().some(f => isEqual(f.uri, location)); + const recursive = type === PromptsType.instructions + && !isWorkspaceRoot + && !hasGlobPattern(location.path) + && depth < MAX_INSTRUCTIONS_RECURSION_DEPTH; try { const info = await this.fileService.resolve(location); if (token.isCancellationRequested) { @@ -513,8 +527,8 @@ export class PromptFilesLocator { if (child.isFile) { result.push(child.resource); } else if (recursive && child.isDirectory) { - // Recursively search subdirectories for claude rules - const subFiles = await this.resolveFilesAtLocation(child.resource, type, token); + // Recursively search subdirectories for instructions + const subFiles = await this.resolveFilesAtLocation(child.resource, type, token, depth + 1); result.push(...subFiles); } } diff --git a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts index 9ec0508a489..bf1214267a5 100644 --- a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts @@ -12,7 +12,7 @@ import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../participants/chatAgents.js'; import { IChatSlashCommandService } from '../participants/chatSlashCommands.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; -import { IToolData, IToolSet, isToolSet } from '../tools/languageModelToolsService.js'; +import { IToolAndToolSetEnablementMap, IToolData, IToolSet, isToolSet } from '../tools/languageModelToolsService.js'; import { ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestDynamicVariablePart, ChatRequestSlashCommandPart, ChatRequestSlashPromptPart, ChatRequestTextPart, ChatRequestToolPart, ChatRequestToolSetPart, IParsedChatRequest, IParsedChatRequestPart, chatAgentLeader, chatSubcommandLeader, chatVariableLeader } from './chatParserTypes.js'; const agentReg = /^@([\w_\-\.]+)(?=(\s|$|\b))/i; // An @-agent @@ -37,11 +37,16 @@ export class ChatRequestParser { ) { } parseChatRequest(sessionResource: URI, message: string, location: ChatAgentLocation = ChatAgentLocation.Chat, context?: IChatParserContext): IParsedChatRequest { - const parts: IParsedChatRequestPart[] = []; const references = this.variableService.getDynamicVariables(sessionResource); // must access this list before any async calls + const selectedToolAndToolSets = this.variableService.getSelectedToolAndToolSets(sessionResource); + return this.parseChatRequestWithReferences(references, selectedToolAndToolSets, message, location, context); + } + + parseChatRequestWithReferences(references: ReadonlyArray, selectedToolAndToolSets: IToolAndToolSetEnablementMap, message: string, location: ChatAgentLocation = ChatAgentLocation.Chat, context?: IChatParserContext): IParsedChatRequest { + const parts: IParsedChatRequestPart[] = []; const toolsByName = new Map(); const toolSetsByName = new Map(); - for (const [entry, enabled] of this.variableService.getSelectedToolAndToolSets(sessionResource)) { + for (const [entry, enabled] of selectedToolAndToolSets) { if (enabled) { if (isToolSet(entry)) { toolSetsByName.set(entry.referenceName, entry); diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts index bf7dd424e25..8dda3785b93 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/askQuestionsTool.ts @@ -8,11 +8,13 @@ import { CancellationError } from '../../../../../../base/common/errors.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { hasKey } from '../../../../../../base/common/types.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { localize } from '../../../../../../nls.js'; -import { IChatQuestion, IChatService } from '../../chatService/chatService.js'; +import { IChatQuestion, IChatQuestionAnswers, IChatQuestionAnswerValue, IChatMultiSelectAnswer, IChatService, IChatSingleSelectAnswer } from '../../chatService/chatService.js'; import { ChatQuestionCarouselData } from '../../model/chatProgressTypes/chatQuestionCarouselData.js'; import { IChatRequestModel } from '../../model/chatModel.js'; +import { ChatPermissionLevel } from '../../constants.js'; import { StopWatch } from '../../../../../../base/common/stopwatch.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; @@ -22,6 +24,12 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { raceCancellation } from '../../../../../../base/common/async.js'; import { URI } from '../../../../../../base/common/uri.js'; +/** + * Response returned to the model when the user is not available (autopilot mode). + */ +export const AUTOPILOT_ASK_USER_RESPONSE = + 'The user is not available to respond and will review your work later. Work autonomously and make good decisions.'; + // Use a distinct id to avoid clashing with extension-provided tools export const AskQuestionsToolId = 'vscode_askQuestions'; @@ -187,6 +195,17 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { return this.createSkippedResult(questions); } + // In autopilot mode, the user is not available — auto-respond instead of blocking. + // Still append a completed carousel so the user can see the auto-selected answers. + if (request.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot) { + this.logService.info('[AskQuestionsTool] Autopilot mode: auto-responding to questions'); + const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions); + carousel.data = this.buildAutopilotCarouselAnswers(questions, carousel, idToHeaderMap); + carousel.isUsed = true; + this.chatService.appendProgress(request, carousel); + return this.createAutopilotResult(questions); + } + const { carousel, idToHeaderMap } = this.toQuestionCarousel(questions); this.chatService.appendProgress(request, carousel); @@ -318,7 +337,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { }; } - protected convertCarouselAnswers(questions: IQuestion[], carouselAnswers: Record | undefined, idToHeaderMap: Map): IAnswerResult { + protected convertCarouselAnswers(questions: IQuestion[], carouselAnswers: IChatQuestionAnswers | undefined, idToHeaderMap: Map): IAnswerResult { const result: IAnswerResult = { answers: {} }; if (carouselAnswers) { @@ -344,7 +363,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { // Look up the answer using the internal ID that was used in the carousel const internalId = headerToIdMap.get(question.header); - const answer = internalId ? carouselAnswers[internalId] : undefined; + const answer: IChatQuestionAnswerValue | undefined = internalId ? carouselAnswers[internalId] : undefined; this.logService.trace(`[AskQuestionsTool] Processing question "${question.header}" (internal ID: ${internalId}), raw answer: ${JSON.stringify(answer)}, type: ${typeof answer}`); if (answer === undefined) { @@ -373,67 +392,36 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { freeText: null, skipped: false }; - } else if (typeof answer === 'object' && answer !== null) { - const answerObj = answer as Record; - const freeformValue = typeof answerObj.freeformValue === 'string' && answerObj.freeformValue ? answerObj.freeformValue : null; - const selectedValues = Array.isArray(answerObj.selectedValues) ? answerObj.selectedValues.map(v => String(v)) : undefined; - const selectedValue = answerObj.selectedValue; - const label = typeof answerObj.label === 'string' ? answerObj.label : undefined; - - if (selectedValues) { - result.answers[question.header] = { - selected: selectedValues, - freeText: freeformValue, - skipped: false - }; - } else if (typeof selectedValue === 'string') { - if (question.options?.some(opt => opt.label === selectedValue)) { - result.answers[question.header] = { - selected: [selectedValue], - freeText: freeformValue, - skipped: false - }; - } else { - result.answers[question.header] = { - selected: [], - freeText: freeformValue ?? selectedValue, - skipped: false - }; - } - } else if (Array.isArray(selectedValue)) { - result.answers[question.header] = { - selected: selectedValue.map(v => String(v)), - freeText: freeformValue, - skipped: false - }; - } else if (selectedValue === undefined || selectedValue === null) { - if (freeformValue) { - result.answers[question.header] = { - selected: [], - freeText: freeformValue, - skipped: false - }; - } else { - result.answers[question.header] = { - selected: [], - freeText: null, - skipped: true - }; - } - } else if (freeformValue) { + } else if (typeof answer === 'object' && hasKey(answer, { selectedValues: true })) { + const { selectedValues, freeformValue } = answer as IChatMultiSelectAnswer; + result.answers[question.header] = { + selected: selectedValues, + freeText: freeformValue ?? null, + skipped: false + }; + } else if (typeof answer === 'object' && (hasKey(answer, { selectedValue: true }) || hasKey(answer, { freeformValue: true }))) { + const { selectedValue, freeformValue } = answer as IChatSingleSelectAnswer; + if (freeformValue) { result.answers[question.header] = { selected: [], freeText: freeformValue, skipped: false }; - } else if (label) { - result.answers[question.header] = { - selected: [label], - freeText: null, - skipped: false - }; + } else if (selectedValue !== undefined) { + if (question.options?.some(opt => opt.label === selectedValue)) { + result.answers[question.header] = { + selected: [selectedValue], + freeText: null, + skipped: false + }; + } else { + result.answers[question.header] = { + selected: [], + freeText: selectedValue, + skipped: false + }; + } } else { - this.logService.warn(`[AskQuestionsTool] Unknown answer object format for "${question.header}": ${JSON.stringify(answer)}`); result.answers[question.header] = { selected: [], freeText: null, @@ -441,7 +429,7 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { }; } } else { - this.logService.warn(`[AskQuestionsTool] Unknown answer format for "${question.header}": ${typeof answer}`); + this.logService.warn(`[AskQuestionsTool] Unknown answer format for "${question.header}": ${JSON.stringify(answer)}`); result.answers[question.header] = { selected: [], freeText: null, @@ -477,6 +465,63 @@ export class AskQuestionsTool extends Disposable implements IToolImpl { }; } + private createAutopilotResult(questions: IQuestion[]): IToolResult { + const answers: Record = {}; + for (const question of questions) { + // Pick the recommended option if available, otherwise pick the first option + const recommended = question.options?.find(opt => opt.recommended); + const firstOption = question.options?.[0]; + const selected = recommended?.label ?? firstOption?.label; + answers[question.header] = { + selected: selected ? [selected] : [], + freeText: selected ? null : AUTOPILOT_ASK_USER_RESPONSE, + skipped: false, + }; + } + return { + content: [{ kind: 'text', value: JSON.stringify({ answers } satisfies IAnswerResult) }] + }; + } + + /** + * Build carousel answer data keyed by carousel question IDs for rendering + * the completed summary in the UI during autopilot mode. + */ + private buildAutopilotCarouselAnswers(questions: IQuestion[], carousel: ChatQuestionCarouselData, idToHeaderMap: Map): IChatQuestionAnswers { + const data: IChatQuestionAnswers = {}; + // Build reverse map: original header -> internal carousel question ID + const headerToIdMap = new Map(); + for (const [internalId, originalHeader] of idToHeaderMap) { + headerToIdMap.set(originalHeader, internalId); + } + + for (const question of questions) { + const internalId = headerToIdMap.get(question.header); + if (!internalId) { + continue; + } + + const chatQuestion = carousel.questions.find(q => q.id === internalId); + if (!chatQuestion) { + continue; + } + + const recommended = question.options?.find(opt => opt.recommended); + const firstOption = question.options?.[0]; + const selectedLabel = recommended?.label ?? firstOption?.label; + + if (chatQuestion.type === 'text' || !selectedLabel) { + data[internalId] = AUTOPILOT_ASK_USER_RESPONSE; + } else if (chatQuestion.type === 'multiSelect') { + data[internalId] = { selectedValues: [selectedLabel] }; + } else { + data[internalId] = { selectedValue: selectedLabel }; + } + } + + return data; + } + private sendTelemetry(requestId: string | undefined, questionCount: number, answeredCount: number, skippedCount: number, freeTextCount: number, recommendedAvailableCount: number, recommendedSelectedCount: number, duration: number): void { this.telemetryService.publicLog2('askQuestionsToolInvoked', { requestId, diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts index 9babb75b5bd..ee75a3bc056 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/chatExternalPathConfirmation.ts @@ -3,17 +3,32 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; import { dirname, extUriBiasedIgnorePathCase } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { ConfirmedReason, ToolConfirmKind } from '../../chatService/chatService.js'; import { ILanguageModelToolConfirmationActions, ILanguageModelToolConfirmationContribution, + ILanguageModelToolConfirmationContributionQuickTreeItem, ILanguageModelToolConfirmationRef } from '../languageModelToolsConfirmationService.js'; +const workspaceAllowlistMemento = observableMemento({ + key: 'chat.externalPath.workspaceAllowlist', + defaultValue: [], + toStorage: value => JSON.stringify(value), + fromStorage: value => { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + }, +}); + export interface IExternalPathInfo { path: string; isDirectory: boolean; @@ -24,26 +39,59 @@ export interface IExternalPathInfo { * accessing paths outside the workspace, with an option to allow all access * from a containing folder for the current chat session. */ -export class ChatExternalPathConfirmationContribution implements ILanguageModelToolConfirmationContribution { +export class ChatExternalPathConfirmationContribution implements ILanguageModelToolConfirmationContribution, IDisposable { readonly canUseDefaultApprovals = false; private readonly _sessionFolderAllowlist = new ResourceMap(); /** Cache of path URI -> resolved git root URI (or null if not in a repo) */ private readonly _gitRootCache = new ResourceMap(); + private readonly _workspaceAllowlist?: ObservableMemento; constructor( private readonly _getPathInfo: (ref: ILanguageModelToolConfirmationRef) => IExternalPathInfo | undefined, + private readonly _labelService: ILabelService, private readonly _findGitRoot?: (pathUri: URI) => Promise, - ) { } + storageService?: IStorageService, + private readonly _pickFolder?: () => Promise, + ) { + if (storageService) { + this._workspaceAllowlist = workspaceAllowlistMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE, storageService); + } + } + + dispose(): void { + this._workspaceAllowlist?.dispose(); + } + + private _getWorkspaceFolders(): ResourceSet { + if (!this._workspaceAllowlist) { + return new ResourceSet(); + } + const set = new ResourceSet(); + for (const s of this._workspaceAllowlist.get()) { + try { + set.add(URI.parse(s)); + } catch { + // ignore malformed URIs + } + } + return set; + } + + private _setWorkspaceFolders(folders: ResourceSet): void { + if (!this._workspaceAllowlist) { + return; + } + const uriStrings: string[] = []; + for (const uri of folders) { + uriStrings.push(uri.toString()); + } + this._workspaceAllowlist.set(uriStrings, undefined); + } getPreConfirmAction(ref: ILanguageModelToolConfirmationRef): ConfirmedReason | undefined { const pathInfo = this._getPathInfo(ref); - if (!pathInfo || !ref.chatSessionResource) { - return undefined; - } - - const allowedFolders = this._sessionFolderAllowlist.get(ref.chatSessionResource); - if (!allowedFolders || allowedFolders.size === 0) { + if (!pathInfo) { return undefined; } @@ -55,13 +103,26 @@ export class ChatExternalPathConfirmationContribution implements ILanguageModelT return undefined; } - // Check if path is under any allowed folder - for (const folderUri of allowedFolders) { + // Check workspace-level allowlist + const workspaceFolders = this._getWorkspaceFolders(); + for (const folderUri of workspaceFolders) { if (extUriBiasedIgnorePathCase.isEqualOrParent(pathUri, folderUri)) { return { type: ToolConfirmKind.UserAction }; } } + // Check session-level allowlist + if (ref.chatSessionResource) { + const sessionFolders = this._sessionFolderAllowlist.get(ref.chatSessionResource); + if (sessionFolders) { + for (const folderUri of sessionFolders) { + if (extUriBiasedIgnorePathCase.isEqualOrParent(pathUri, folderUri)) { + return { type: ToolConfirmKind.UserAction }; + } + } + } + } + return undefined; } @@ -149,4 +210,82 @@ export class ChatExternalPathConfirmationContribution implements ILanguageModelT return actions; } + + getManageActions(): ILanguageModelToolConfirmationContributionQuickTreeItem[] { + const items: ILanguageModelToolConfirmationContributionQuickTreeItem[] = []; + + // Workspace-level entries (persisted) + const workspaceFolders = this._getWorkspaceFolders(); + for (const folderUri of workspaceFolders) { + items.push({ + label: this._labelService.getUriLabel(folderUri), + description: localize('workspaceScope', "Workspace"), + checked: true, + onDidChangeChecked: (checked) => { + if (!checked) { + workspaceFolders.delete(folderUri); + this._setWorkspaceFolders(workspaceFolders); + } else { + workspaceFolders.add(folderUri); + this._setWorkspaceFolders(workspaceFolders); + } + }, + }); + } + + // Session-level entries (ephemeral) + const allSessionFolders = new ResourceSet(); + for (const [, folders] of this._sessionFolderAllowlist) { + for (const folder of folders) { + allSessionFolders.add(folder); + } + } + for (const folderUri of allSessionFolders) { + const wasInSessions = [...this._sessionFolderAllowlist].filter(([, folders]) => folders.has(folderUri)); + items.push({ + label: this._labelService.getUriLabel(folderUri), + description: localize('sessionScope', "Session"), + checked: true, + onDidChangeChecked: (checked) => { + if (!checked) { + for (const [, folders] of wasInSessions) { + folders.delete(folderUri); + } + } else { + for (const [, folders] of wasInSessions) { + folders.add(folderUri); + } + } + }, + }); + } + + // "Add Path..." option to add a new workspace-level folder + if (this._pickFolder) { + const pickFolder = this._pickFolder; + items.push({ + pickable: false, + label: localize('addPath', "Add Path..."), + description: localize('addPathDescription', "Allow a folder in this workspace"), + onDidOpen: async () => { + const uri = await pickFolder(); + if (uri) { + const folders = this._getWorkspaceFolders(); + folders.add(uri); + this._setWorkspaceFolders(folders); + } + } + }); + } + + return items; + } + + reset(): void { + this._sessionFolderAllowlist.clear(); + this._gitRootCache.clear(); + if (this._workspaceAllowlist) { + this._workspaceAllowlist.set([], undefined); + } + } } diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/confirmationTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/confirmationTool.ts index 7763b9f8b3d..8b938dfa71a 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/confirmationTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/confirmationTool.ts @@ -5,11 +5,13 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { IChatModifiedFilesConfirmationData, IChatTerminalToolInvocationData } from '../../chatService/chatService.js'; import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../languageModelToolsService.js'; export const ConfirmationToolId = 'vscode_get_confirmation'; export const ConfirmationToolWithOptionsId = 'vscode_get_confirmation_with_options'; +export const ModifiedFilesConfirmationToolId = 'vscode_get_modified_files_confirmation'; export const ConfirmationToolData: IToolData = { id: ConfirmationToolId, @@ -69,6 +71,69 @@ export const ConfirmationToolWithOptionsData: IToolData = { } }; +export const ModifiedFilesConfirmationToolData: IToolData = { + id: ModifiedFilesConfirmationToolId, + displayName: 'Modified Files Confirmation Tool', + modelDescription: 'A tool that shows a modified-files confirmation UI with a split primary button and a hardcoded cancel action.', + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Title for the confirmation dialog' + }, + message: { + type: 'string', + description: 'Message to show in the confirmation dialog' + }, + options: { + type: 'array', + items: { type: 'string' }, + minItems: 1, + description: 'Selectable option labels. The first option is used for the primary split button and the remaining options are placed in the dropdown menu.' + }, + modifiedFiles: { + type: 'array', + items: { + type: 'object', + properties: { + uri: { + type: 'string', + description: 'URI of the modified file.' + }, + originalUri: { + type: 'string', + description: 'Optional original URI used when opening a diff.' + }, + insertions: { + type: 'number', + description: 'Optional number of lines added.' + }, + deletions: { + type: 'number', + description: 'Optional number of lines removed.' + }, + title: { + type: 'string', + description: 'Optional title shown in the file tooltip.' + }, + description: { + type: 'string', + description: 'Optional secondary label shown for the file entry.' + } + }, + required: ['uri'], + additionalProperties: false + }, + description: 'Modified files to show in the confirmation UI.' + } + }, + required: ['title', 'message', 'options', 'modifiedFiles'], + additionalProperties: false + } +}; + export interface IConfirmationToolParams { title: string; message: string; @@ -77,6 +142,20 @@ export interface IConfirmationToolParams { buttons?: string[]; } +export interface IModifiedFilesConfirmationToolParams { + title: string; + message: string; + options: string[]; + modifiedFiles: { + uri: string; + originalUri?: string; + insertions?: number; + deletions?: number; + title?: string; + description?: string; + }[]; +} + export class ConfirmationTool implements IToolImpl { async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { const parameters = context.parameters as IConfirmationToolParams; @@ -135,3 +214,52 @@ export class ConfirmationTool implements IToolImpl { }; } } + +export class ModifiedFilesConfirmationTool implements IToolImpl { + async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { + const parameters = context.parameters as IModifiedFilesConfirmationToolParams; + if (!parameters.title || !parameters.message) { + throw new Error('Missing required parameters for ModifiedFilesConfirmationTool'); + } + + if (!parameters.options?.length) { + throw new Error('ModifiedFilesConfirmationTool requires at least one option'); + } + + const toolSpecificData: IChatModifiedFilesConfirmationData = { + kind: 'modifiedFilesConfirmation', + options: parameters.options, + modifiedFiles: parameters.modifiedFiles.map(file => ({ + uri: URI.parse(file.uri).toJSON(), + originalUri: file.originalUri ? URI.parse(file.originalUri).toJSON() : undefined, + insertions: file.insertions, + deletions: file.deletions, + title: file.title, + description: file.description, + })), + }; + + return { + confirmationMessages: { + title: parameters.title, + message: new MarkdownString(parameters.message), + allowAutoConfirm: false, + }, + toolSpecificData, + presentation: ToolInvocationPresentation.HiddenAfterComplete + }; + } + + async invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, progress: ToolProgress, token: CancellationToken): Promise { + if (!invocation.selectedCustomButton) { + throw new Error('ModifiedFilesConfirmationTool requires a selected option'); + } + + return { + content: [{ + kind: 'text', + value: invocation.selectedCustomButton + }] + }; + } +} diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts new file mode 100644 index 00000000000..fa690d45d61 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { localize } from '../../../../../../nls.js'; +import { ChatContextKeys } from '../../actions/chatContextKeys.js'; +import { IChatDebugEvent, IChatDebugResolvedEventContent, IChatDebugService } from '../../chatDebugService.js'; +import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../languageModelToolsService.js'; + +export const ResolveDebugEventDetailsToolId = 'vscode_resolveDebugEventDetails_internal'; + +export const ResolveDebugEventDetailsToolData: IToolData = { + id: ResolveDebugEventDetailsToolId, + toolReferenceName: 'resolveDebugEventDetails', + displayName: localize('resolveDebugEventDetails.displayName', "Resolve Debug Event Details"), + when: ChatContextKeys.chatSessionHasAttachedDebugData, + canBeReferencedInPrompt: false, + modelDescription: 'Resolves the full details for a specific chat debug event by its event ID. Use this tool to get detailed information about a debug event such as tool call input/output, model turn details, user message sections, or file lists. The event ID can be found in the debug event log summary provided in the conversation context.', + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + eventId: { + type: 'string', + description: 'The ID of the debug event to resolve details for.', + }, + }, + required: ['eventId'], + }, +}; + +function formatResolvedContent(content: IChatDebugResolvedEventContent): string { + switch (content.kind) { + case 'text': + return content.value; + case 'fileList': { + const lines: string[] = [`File list (${content.discoveryType}):`]; + if (content.sourceFolders) { + for (const folder of content.sourceFolders) { + lines.push(` Source folder: ${folder.uri.toString()} (${folder.storage})`); + } + } + for (const file of content.files) { + const status = file.status === 'loaded' ? 'loaded' : `skipped${file.skipReason ? `: ${file.skipReason}` : ''}`; + lines.push(` ${file.uri.toString()} [${status}]`); + } + return lines.join('\n'); + } + case 'message': { + const lines: string[] = [`${content.type === 'user' ? 'User' : 'Agent'} message: ${content.message}`]; + for (const section of content.sections) { + lines.push(`--- ${section.name} ---`); + lines.push(section.content); + } + return lines.join('\n'); + } + case 'toolCall': { + const lines: string[] = [`Tool call: ${content.toolName}`]; + if (content.result) { + lines.push(`Result: ${content.result}`); + } + if (content.durationInMillis !== undefined) { + lines.push(`Duration: ${content.durationInMillis}ms`); + } + if (content.input) { + lines.push(`Input:\n${content.input}`); + } + if (content.output) { + lines.push(`Output:\n${content.output}`); + } + return lines.join('\n'); + } + case 'modelTurn': { + const lines: string[] = [`Model turn: ${content.requestName}`]; + if (content.model) { + lines.push(`Model: ${content.model}`); + } + if (content.status) { + lines.push(`Status: ${content.status}`); + } + if (content.durationInMillis !== undefined) { + lines.push(`Duration: ${content.durationInMillis}ms`); + } + if (content.inputTokens !== undefined || content.outputTokens !== undefined) { + lines.push(`Tokens: input=${content.inputTokens ?? '?'}, output=${content.outputTokens ?? '?'}, cached=${content.cachedTokens ?? '?'}, total=${content.totalTokens ?? '?'}`); + } + if (content.errorMessage) { + lines.push(`Error: ${content.errorMessage}`); + } + if (content.sections) { + for (const section of content.sections) { + lines.push(`--- ${section.name} ---`); + lines.push(section.content); + } + } + return lines.join('\n'); + } + default: { + const _: never = content; + return JSON.stringify(_); + } + } +} + +function truncate(text: string, maxLength = 30): string { + if (text.length <= maxLength) { + return text; + } + const lastSpace = text.lastIndexOf(' ', maxLength); + const cutoff = lastSpace > maxLength / 2 ? lastSpace : maxLength; + return text.substring(0, cutoff) + '\u2026'; +} + +function getEventLabel(event: IChatDebugEvent): string { + switch (event.kind) { + case 'generic': return event.name; + case 'toolCall': return event.toolName; + case 'modelTurn': return event.requestName ?? localize('debugEvent.modelTurn', "Model Turn"); + case 'userMessage': return localize('debugEvent.userMessage', "User Message: {0}", truncate(event.message)); + case 'agentResponse': return localize('debugEvent.agentResponse', "Agent Response: {0}", truncate(event.message)); + case 'subagentInvocation': return event.agentName; + } +} + +export class ResolveDebugEventDetailsTool implements IToolImpl { + constructor( + @IChatDebugService private readonly chatDebugService: IChatDebugService, + ) { } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const eventId = context.parameters?.eventId; + let eventLabel: string | undefined; + if (typeof eventId === 'string' && context.chatSessionResource) { + const events = this.chatDebugService.getEvents(context.chatSessionResource); + const event = events.find(e => e.id === eventId); + if (event) { + eventLabel = getEventLabel(event); + } + } + + if (eventLabel) { + return { + invocationMessage: localize('resolveDebugEventDetails.invocationMessageNamed', 'Resolving details for "{0}"', eventLabel), + pastTenseMessage: localize('resolveDebugEventDetails.pastTenseMessageNamed', 'Resolved details for "{0}"', eventLabel), + }; + } + return { + invocationMessage: localize('resolveDebugEventDetails.invocationMessage', 'Resolving debug event details'), + pastTenseMessage: localize('resolveDebugEventDetails.pastTenseMessage', 'Resolved debug event details'), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const eventId = invocation.parameters['eventId']; + if (typeof eventId !== 'string' || !eventId) { + return { + content: [{ kind: 'text', value: 'Error: eventId parameter is required.' }], + }; + } + + const sessionResource = invocation.context?.sessionResource; + if (!sessionResource) { + return { + content: [{ kind: 'text', value: 'Error: no chat session context available.' }], + }; + } + + const sessionEvents = this.chatDebugService.getEvents(sessionResource); + if (!sessionEvents.some(e => e.id === eventId)) { + return { + content: [{ kind: 'text', value: `No event with ID "${eventId}" found in the current session.` }], + }; + } + + const resolved = await this.chatDebugService.resolveEvent(eventId); + if (!resolved) { + return { + content: [{ kind: 'text', value: `No details found for event ID: ${eventId}` }], + }; + } + + return { + content: [{ kind: 'text', value: formatResolvedContent(resolved) }], + }; + } +} diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 5db3195bbdb..c5931a7d732 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -23,7 +23,8 @@ import { ILanguageModelsService } from '../../languageModels.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; -import { IChatRequestHooks } from '../../promptSyntax/hookSchema.js'; +import { ChatRequestHooks, mergeHooks } from '../../promptSyntax/hookSchema.js'; +import { HookType } from '../../promptSyntax/hookTypes.js'; import { ICustomAgent, IPromptsService } from '../../promptSyntax/service/promptsService.js'; import { isBuiltinAgent } from '../../promptSyntax/utils/promptsServiceUtils.js'; import { @@ -252,7 +253,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { await computer.collect(variableSet, token); // Collect hooks from hook .json files - let collectedHooks: IChatRequestHooks | undefined; + let collectedHooks: ChatRequestHooks | undefined; try { const info = await this.promptsService.getHooks(token, invocation.context.sessionResource); collectedHooks = info?.hooks; @@ -260,6 +261,20 @@ export class RunSubagentTool extends Disposable implements IToolImpl { this.logService.warn('[ChatService] Failed to collect hooks:', error); } + // Merge subagent-level hooks (from the agent's frontmatter) with global hooks. + // Remap Stop hooks to SubagentStop since the agent is running as a subagent. + if (subagent?.hooks) { + const remapped: ChatRequestHooks = { ...subagent.hooks }; + if (remapped[HookType.Stop]) { + const stopHooks = remapped[HookType.Stop]; + (remapped as Record)[HookType.SubagentStop] = remapped[HookType.SubagentStop] + ? [...remapped[HookType.SubagentStop], ...stopHooks] + : stopHooks; + (remapped as Record)[HookType.Stop] = undefined; + } + collectedHooks = mergeHooks(collectedHooks, remapped); + } + // Build the agent request const agentRequest: IChatAgentRequest = { sessionResource: invocation.context.sessionResource, diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/taskCompleteTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/taskCompleteTool.ts new file mode 100644 index 00000000000..15094b0ce1a --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/taskCompleteTool.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress, CountTokensCallback } from '../languageModelToolsService.js'; + +export const TaskCompleteToolId = 'task_complete'; + +/** + * Message sent to the agent when the session goes idle without task completion. + */ +export const AUTOPILOT_CONTINUATION_MESSAGE = + 'You have not yet marked the task as complete using the task_complete tool. ' + + 'You MUST call task_complete when done — whether the task involved code changes, answering a question, or any other interaction.\n\n' + + 'Do NOT repeat or restate your previous response. Pick up where you left off.\n\n' + + 'If you were planning, stop planning and start implementing. ' + + 'You are not done until you have fully completed the task.\n\n' + + 'IMPORTANT: Do NOT call task_complete if:\n' + + '- You have open questions or ambiguities — make good decisions and keep working\n' + + '- You encountered an error — try to resolve it or find an alternative approach\n' + + '- There are remaining steps — complete them first\n\n' + + 'When you ARE done, first provide a brief text summary of what was accomplished, then call task_complete. ' + + 'Both the summary message and the tool call are required.\n\n' + + 'Keep working autonomously until the task is truly finished, then call task_complete.'; + +export const TaskCompleteToolData: IToolData = { + id: TaskCompleteToolId, + displayName: 'Task Complete', + modelDescription: + 'Signal that the user\'s task is fully done. You MUST call this tool when your work is complete — ' + + 'whether you made code changes, answered a question, or completed any other kind of task. ' + + 'Provide a brief summary of what was accomplished. ' + + 'Do not restate the summary in your message text — it is shown to the user directly.\n\n' + + 'IMPORTANT: Before calling this tool, you MUST output a brief text message summarizing what was done. ' + + 'The task is not complete until both your summary message AND this tool call are present.\n\n' + + 'When to call:\n' + + '- After answering the user\'s question or completing a conversational request\n' + + '- After you have completed ALL requested changes\n' + + '- After verifying results: tests pass, terminal commands succeeded, tool calls returned expected output\n\n' + + 'When NOT to call:\n' + + '- If a terminal command failed or produced unexpected output\n' + + '- If an MCP or external tool call returned an error\n' + + '- If you encountered errors you have not resolved\n' + + '- If there are remaining steps to complete\n' + + '- If you have not verified your changes work', + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + summary: { + type: 'string', + description: 'Brief summary of what was accomplished. Omit for trivial interactions.', + }, + }, + }, +}; + +export class TaskCompleteTool implements IToolImpl { + async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + return { + presentation: ToolInvocationPresentation.Hidden, + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { + const params = invocation.parameters as { summary?: string }; + const summary = params?.summary ?? 'All done!'; + return { + content: [{ + kind: 'text', + value: summary, + }], + }; + } +} diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts index 0258444f00b..41144f8be91 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts @@ -8,10 +8,12 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { IWorkbenchContribution } from '../../../../../common/contributions.js'; import { ILanguageModelToolsService } from '../languageModelToolsService.js'; import { AskQuestionsTool, AskQuestionsToolData } from './askQuestionsTool.js'; -import { ConfirmationTool, ConfirmationToolData, ConfirmationToolWithOptionsData } from './confirmationTool.js'; +import { ConfirmationTool, ConfirmationToolData, ConfirmationToolWithOptionsData, ModifiedFilesConfirmationTool, ModifiedFilesConfirmationToolData } from './confirmationTool.js'; import { EditTool, EditToolData } from './editFileTool.js'; import { createManageTodoListToolData, ManageTodoListTool } from './manageTodoListTool.js'; +import { ResolveDebugEventDetailsTool, ResolveDebugEventDetailsToolData } from './resolveDebugEventDetailsTool.js'; import { RunSubagentTool } from './runSubagentTool.js'; +import { TaskCompleteTool, TaskCompleteToolData } from './taskCompleteTool.js'; export class BuiltinToolsContribution extends Disposable implements IWorkbenchContribution { @@ -34,11 +36,22 @@ export class BuiltinToolsContribution extends Disposable implements IWorkbenchCo const manageTodoListTool = this._register(instantiationService.createInstance(ManageTodoListTool)); this._register(toolsService.registerTool(todoToolData, manageTodoListTool)); - // Register the confirmation tool const confirmationTool = instantiationService.createInstance(ConfirmationTool); this._register(toolsService.registerTool(ConfirmationToolData, confirmationTool)); this._register(toolsService.registerTool(ConfirmationToolWithOptionsData, confirmationTool)); + const modifiedFilesConfirmationTool = instantiationService.createInstance(ModifiedFilesConfirmationTool); + this._register(toolsService.registerTool(ModifiedFilesConfirmationToolData, modifiedFilesConfirmationTool)); + + + const taskCompleteTool = instantiationService.createInstance(TaskCompleteTool); + this._register(toolsService.registerTool(TaskCompleteToolData, taskCompleteTool)); + + const resolveDebugEventDetailsTool = instantiationService.createInstance(ResolveDebugEventDetailsTool); + this._register(toolsService.registerTool(ResolveDebugEventDetailsToolData, resolveDebugEventDetailsTool)); + this._register(toolsService.readToolSet.addTool(ResolveDebugEventDetailsToolData)); + + const runSubagentTool = this._register(instantiationService.createInstance(RunSubagentTool)); let runSubagentRegistration: IDisposable | undefined; diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts index d77bd07c0c0..2e3c66261b8 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts @@ -42,7 +42,7 @@ export interface ILanguageModelToolConfirmationActionProducer { export interface ILanguageModelToolConfirmationContributionQuickTreeItem extends IQuickTreeItem { onDidTriggerItemButton?(button: IQuickInputButton): void; onDidChangeChecked?(checked: boolean): void; - onDidOpen?(): void; + onDidOpen?(): void | Promise; } /** @@ -85,7 +85,7 @@ export interface ILanguageModelToolsConfirmationService extends ILanguageModelTo readonly _serviceBrand: undefined; /** Opens an IQuickTree to let the user manage their preferences. */ - manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session' }): void; + manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session'; focusToolId?: string }): void; /** * Registers a contribution that provides more specific confirmation logic @@ -93,6 +93,15 @@ export interface ILanguageModelToolsConfirmationService extends ILanguageModelTo */ registerConfirmationContribution(toolName: string, contribution: ILanguageModelToolConfirmationContribution): IDisposable; + /** + * Returns true if the tool has confirmation that can be managed, either + * because it has {@link IToolData.canRequestPreApproval} or + * {@link IToolData.canRequestPostApproval} set, because a + * {@link ILanguageModelToolConfirmationContribution} is registered for it, + * or because it has stored auto-confirmation settings. + */ + toolCanManageConfirmation(tool: IToolData): boolean; + /** Resets all tool and server confirmation preferences */ resetToolAutoConfirmation(): void; } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index fe83345a4a2..93be052e04b 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -24,7 +24,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { ChatRequestToolReferenceEntry } from '../attachments/chatVariableEntries.js'; import { IVariableReference } from '../chatModes.js'; -import { IChatExtensionsContent, IChatSimpleToolInvocationData, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; +import { IChatExtensionsContent, IChatModifiedFilesConfirmationData, IChatSimpleToolInvocationData, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; import { ILanguageModelChatMetadata, LanguageModelPartAudience } from '../languageModels.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; import { PromptElementJSON, stringifyPromptElementJSON } from './promptTsxTypes.js'; @@ -189,7 +189,7 @@ export interface IToolInvocation { * Lets us add some nicer UI to toolcalls that came from a sub-agent, but in the long run, this should probably just be rendered in a similar way to thinking text + tool call groups */ subAgentInvocationId?: string; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatModifiedFilesConfirmationData; modelId?: string; userSelectedTools?: UserSelectedTools; /** The label of the custom button selected by the user during confirmation, if custom buttons were used. */ @@ -241,6 +241,8 @@ export type ToolInputOutputReference = ToolInputOutputBase & { type: 'ref'; uri: export interface IToolResultInputOutputDetails { readonly input: string; + /** Language identifier for syntax highlighting the input. Defaults to 'json'. */ + readonly inputLanguage?: string; readonly output: (ToolInputOutputEmbedded | ToolInputOutputReference)[]; readonly isError?: boolean; /** Raw MCP tool result for MCP App UI rendering */ @@ -363,7 +365,7 @@ export interface IPreparedToolInvocation { originMessage?: string | IMarkdownString; confirmationMessages?: IToolConfirmationMessages; presentation?: ToolInvocationPresentation; - toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatModifiedFilesConfirmationData; } export interface IToolImpl { diff --git a/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts b/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts index d1d069424dd..6cab24df2d6 100644 --- a/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts +++ b/src/vs/workbench/contrib/chat/common/widget/chatResponseResourceFileSystemProvider.ts @@ -6,21 +6,38 @@ import { decodeBase64, VSBuffer } from '../../../../../base/common/buffer.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { newWriteableStream, ReadableStreamEvents } from '../../../../../base/common/stream.js'; import { URI } from '../../../../../base/common/uri.js'; -import { createFileSystemProviderError, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileService, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IStat } from '../../../../../platform/files/common/files.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { createFileSystemProviderError, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileService, IFileSystemProvider, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IStat } from '../../../../../platform/files/common/files.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ChatResponseResource } from '../model/chatModel.js'; import { IChatService, IChatToolInvocation, IChatToolInvocationSerialized } from '../chatService/chatService.js'; import { isToolResultInputOutputDetails } from '../tools/languageModelToolsService.js'; +export const IChatResponseResourceFileSystemProvider = createDecorator('chatResponseResourceFileSystemProvider'); + +export interface IChatResponseResourceFileSystemProvider extends IFileSystemProvider { + readonly _serviceBrand: undefined; + + /** + * Associates arbitrary data with a URI in the chat response resource filesystem. + * The data is scoped to the given session and automatically cleaned up when + * the session is disposed. + * Returns a URI that can later be read via the file service. + */ + associate(sessionResource: URI, data: Uint8Array | { base64: string }, name?: string): URI; +} + export class ChatResponseResourceFileSystemProvider extends Disposable implements - IWorkbenchContribution, + IChatResponseResourceFileSystemProvider, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileReadStreamCapability { - public static readonly ID = 'workbench.contrib.chatResponseResourceFileSystemProvider'; + declare readonly _serviceBrand: undefined; public readonly onDidChangeCapabilities = Event.None; public readonly onDidChangeFile = Event.None; @@ -32,12 +49,46 @@ export class ChatResponseResourceFileSystemProvider extends Disposable implement | FileSystemProviderCapabilities.FileAtomicRead | FileSystemProviderCapabilities.FileReadWrite; + /** In-memory store for data associated via {@link associate}, keyed by URI. */ + private readonly _associated = new ResourceMap(); + + /** Tracks which associated URIs belong to which session, for cleanup on dispose. */ + private readonly _sessionAssociations = new ResourceMap(); + constructor( @IChatService private readonly chatService: IChatService, @IFileService private readonly _fileService: IFileService ) { super(); - this._register(this._fileService.registerProvider(ChatResponseResource.scheme, this)); + this._register(this.chatService.onDidDisposeSession(e => { + for (const sessionResource of e.sessionResource) { + const uris = this._sessionAssociations.get(sessionResource); + if (uris) { + for (const uri of uris) { + this._associated.delete(uri); + } + this._sessionAssociations.delete(sessionResource); + } + } + })); + } + + associate(sessionResource: URI, data: Uint8Array | { base64: string }, name?: string): URI { + const id = generateUuid(); + const uri = URI.from({ + scheme: ChatResponseResource.scheme, + path: `/assoc/${id}` + (name ? `/${name}` : ''), + }); + this._associated.set(uri, data); + + let set = this._sessionAssociations.get(sessionResource); + if (!set) { + set = new ResourceSet(); + this._sessionAssociations.set(sessionResource, set); + } + set.add(uri); + + return uri; } readFile(resource: URI): Promise { @@ -108,6 +159,16 @@ export class ChatResponseResourceFileSystemProvider extends Disposable implement } private lookupURI(uri: URI): Uint8Array | Promise { + const associated = this._associated.get(uri); + if (associated) { + if (associated instanceof Uint8Array) { + return associated; + } + const decoded = decodeBase64(associated.base64).buffer; + this._associated.set(uri, decoded); + return decoded; + } + const { result, index } = this.findMatchingInvocation(uri); const details = IChatToolInvocation.resultDetails(result); if (!isToolResultInputOutputDetails(details)) { @@ -126,3 +187,16 @@ export class ChatResponseResourceFileSystemProvider extends Disposable implement return part.isText ? new TextEncoder().encode(part.value) : decodeBase64(part.value).buffer; } } + +export class ChatResponseResourceWorkbenchContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chatResponseResourceWorkbenchContribution'; + + constructor( + @IChatResponseResourceFileSystemProvider chatResponseResourceFsProvider: IChatResponseResourceFileSystemProvider, + @IFileService fileService: IFileService, + ) { + super(); + this._register(fileService.registerProvider(ChatResponseResource.scheme, chatResponseResourceFsProvider)); + } +} diff --git a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts index 3af7e138aeb..1d1d822cc30 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/tools.ts @@ -6,8 +6,11 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { dirname, extUriBiasedIgnorePathCase } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { ChatExternalPathConfirmationContribution } from '../../common/tools/builtinTools/chatExternalPathConfirmation.js'; import { ChatUrlFetchingConfirmationContribution } from '../../common/tools/builtinTools/chatUrlFetchingConfirmation.js'; @@ -25,6 +28,9 @@ export class NativeBuiltinToolsContribution extends Disposable implements IWorkb @IInstantiationService instantiationService: IInstantiationService, @ILanguageModelToolsConfirmationService confirmationService: ILanguageModelToolsConfirmationService, @IFileService fileService: IFileService, + @IStorageService storageService: IStorageService, + @IFileDialogService fileDialogService: IFileDialogService, + @ILabelService labelService: ILabelService, ) { super(); @@ -53,6 +59,7 @@ export class NativeBuiltinToolsContribution extends Disposable implements IWorkb } return undefined; }, + labelService, async (pathUri: URI) => { // Walk up from the path looking for a .git folder to find the repository root let dir = dirname(pathUri); @@ -71,8 +78,18 @@ export class NativeBuiltinToolsContribution extends Disposable implements IWorkb dir = parent; } return undefined; + }, + storageService, + async () => { + const result = await fileDialogService.showOpenDialog({ + canSelectFolders: true, + canSelectFiles: false, + canSelectMany: false, + }); + return result?.[0]; } ); + this._register(externalPathConfirmation); this._register(confirmationService.registerConfirmationContribution( 'copilot_readFile', diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionApprovalModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionApprovalModel.test.ts new file mode 100644 index 00000000000..321bac437d2 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionApprovalModel.test.ts @@ -0,0 +1,522 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../browser/agentSessions/agentSessionApprovalModel.js'; +import { MockChatModel } from '../../common/model/mockChatModel.js'; +import { MockChatService } from '../../common/chatService/mockChatService.js'; +import { IChatToolInvocation, IChatTerminalToolInvocationData, ToolConfirmKind, ConfirmedReason } from '../../../common/chatService/chatService.js'; +import { IChatModel, IChatRequestModel, IChatResponseModel, IResponse, IChatProgressResponseContent } from '../../../common/model/chatModel.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; + +function makeToolInvocationPart(options: { + state: IChatToolInvocation.State; + toolSpecificData?: IChatToolInvocation['toolSpecificData']; + invocationMessage?: string | MarkdownString; +}): IChatToolInvocation { + return { + kind: 'toolInvocation', + presentation: undefined!, + originMessage: undefined, + invocationMessage: options.invocationMessage ?? 'Running tool...', + pastTenseMessage: undefined, + source: undefined!, + toolId: 'test-tool', + toolCallId: 'call-1', + state: observableValue('toolState', options.state), + toolSpecificData: options.toolSpecificData, + toJSON: () => undefined!, + }; +} + +function makeTerminalToolData(overrides?: Partial): IChatTerminalToolInvocationData { + return { + kind: 'terminal', + commandLine: { original: 'echo hello' }, + language: 'sh', + ...overrides, + }; +} + +function makeWaitingState(confirm?: (reason: ConfirmedReason) => void): IChatToolInvocation.State { + return { + type: IChatToolInvocation.StateKind.WaitingForConfirmation, + parameters: {}, + confirm: confirm ?? (() => { }), + } as IChatToolInvocation.State; +} + +function makePostApprovalState(confirm?: (reason: ConfirmedReason) => void): IChatToolInvocation.State { + return { + type: IChatToolInvocation.StateKind.WaitingForPostApproval, + parameters: {}, + confirmed: { type: ToolConfirmKind.UserAction }, + resultDetails: undefined, + confirm: confirm ?? (() => { }), + contentForModel: [], + } as IChatToolInvocation.State; +} + +function makeExecutingState(): IChatToolInvocation.State { + return { + type: IChatToolInvocation.StateKind.Executing, + parameters: {}, + confirmed: { type: ToolConfirmKind.UserAction }, + progress: observableValue('progress', { message: undefined, progress: undefined }), + } as IChatToolInvocation.State; +} + +/** Creates a minimal mock that satisfies the response chain: lastRequest.response.response.value */ +function mockModelWithResponse(model: MockChatModel, parts: IChatProgressResponseContent[]): void { + const response: Partial = { + response: { value: parts, getMarkdown: () => '', toString: () => '' } satisfies IResponse, + }; + const request: Partial = { + response: response as IChatResponseModel, + }; + (model as { lastRequest: IChatRequestModel | undefined }).lastRequest = request as IChatRequestModel; +} + +class MockLanguageService { + getLanguageIdByLanguageName(name: string): string | undefined { + switch (name) { + case 'bash': return 'sh'; + case 'python': return 'python'; + case 'powershell': return 'pwsh'; + default: return name; + } + } +} + +suite('AgentSessionApprovalModel', () => { + + const disposables = new DisposableStore(); + let chatService: MockChatService; + let chatModelsObs: ISettableObservable>; + let langservice: MockLanguageService; + + setup(() => { + chatService = new MockChatService(); + langservice = new MockLanguageService(); + chatModelsObs = chatService.chatModels as ISettableObservable>; + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createModel(): AgentSessionApprovalModel { + const model = new AgentSessionApprovalModel(chatService, langservice as ILanguageService); + disposables.add(model); + return model; + } + + function addChatModel(uri?: URI): MockChatModel { + const chatModel = disposables.add(new MockChatModel(uri ?? URI.parse(`test://session/${Math.random()}`))); + chatModelsObs.set([...Array.from(chatModelsObs.get()), chatModel], undefined); + return chatModel; + } + + function getApproval(approvalModel: AgentSessionApprovalModel, chatModel: MockChatModel): IAgentSessionApprovalInfo | undefined { + return approvalModel.getApproval(chatModel.sessionResource).get(); + } + + test('returns undefined when no models exist', () => { + const approvalModel = createModel(); + const result = approvalModel.getApproval(URI.parse('test://nonexistent')).get(); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when model has no requestNeedsInput', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + assert.strictEqual(getApproval(approvalModel, chatModel), undefined); + }); + + test('returns undefined when requestNeedsInput is set but no response exists', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + assert.strictEqual(getApproval(approvalModel, chatModel), undefined); + }); + + test('returns undefined when response has no tool invocation parts', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + mockModelWithResponse(chatModel, []); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + assert.strictEqual(getApproval(approvalModel, chatModel), undefined); + }); + + test('returns undefined when tool invocation is in Executing state', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ state: makeExecutingState() }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + assert.strictEqual(getApproval(approvalModel, chatModel), undefined); + }); + + test('returns approval info for WaitingForConfirmation state with terminal data', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData(), + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + const result = getApproval(approvalModel, chatModel); + assert.deepStrictEqual({ + label: result?.label, + language: result?.languageId, + }, { + label: 'echo hello', + language: 'sh', + }); + }); + + test('returns approval info for WaitingForPostApproval state', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ + state: makePostApprovalState(), + toolSpecificData: makeTerminalToolData({ commandLine: { original: 'npm install' } }), + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + const result = getApproval(approvalModel, chatModel); + assert.deepStrictEqual({ + label: result?.label, + language: result?.languageId, + }, { + label: 'npm install', + language: 'sh', + }); + }); + + test('prefers presentationOverrides.commandLine and language', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData({ + commandLine: { original: 'python -c "print(1)"' }, + language: 'sh', + presentationOverrides: { commandLine: 'print(1)', language: 'python' }, + }), + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + const result = getApproval(approvalModel, chatModel); + assert.deepStrictEqual({ + label: result?.label, + language: result?.languageId, + }, { + label: 'print(1)', + language: 'python', + }); + }); + + test('uses forDisplay from commandLine when available', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData({ + commandLine: { original: 'echo raw', forDisplay: 'echo display' }, + }), + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + assert.strictEqual(getApproval(approvalModel, chatModel)?.label, 'echo display'); + }); + + test('uses userEdited from commandLine when forDisplay is not set', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData({ + commandLine: { original: 'orig', userEdited: 'user-edited' }, + }), + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + assert.strictEqual(getApproval(approvalModel, chatModel)?.label, 'user-edited'); + }); + + test('uses toolEdited from commandLine as fallback', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData({ + commandLine: { original: 'orig', toolEdited: 'tool-edited' }, + }), + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + assert.strictEqual(getApproval(approvalModel, chatModel)?.label, 'tool-edited'); + }); + + test('uses needsInput.detail when tool is not terminal', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ state: makeWaitingState() }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test', detail: 'Custom detail message' }, undefined); + + const result = getApproval(approvalModel, chatModel); + assert.deepStrictEqual({ + label: result?.label, + language: result?.languageId, + }, { + label: 'Custom detail message', + language: undefined, + }); + }); + + test('uses invocationMessage string when no terminal data and no detail', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ + state: makeWaitingState(), + invocationMessage: 'Searching files...', + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + const result = getApproval(approvalModel, chatModel); + assert.deepStrictEqual({ + label: result?.label, + language: result?.languageId, + }, { + label: 'Searching files...', + language: undefined, + }); + }); + + test('uses invocationMessage MarkdownString when no terminal data and no detail', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ + state: makeWaitingState(), + invocationMessage: new MarkdownString('**Running** tool'), + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + assert.strictEqual(getApproval(approvalModel, chatModel)?.label, 'Running tool'); + }); + + test('confirm() delegates to tool state confirm with UserAction', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + let confirmedWith: ConfirmedReason | undefined; + const part = makeToolInvocationPart({ + state: makeWaitingState(reason => { confirmedWith = reason; }), + toolSpecificData: makeTerminalToolData(), + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + getApproval(approvalModel, chatModel)?.confirm(); + assert.deepStrictEqual(confirmedWith, { type: ToolConfirmKind.UserAction }); + }); + + test('reacts to requestNeedsInput becoming undefined', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData(), + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + assert.ok(getApproval(approvalModel, chatModel)); + + chatModel.requestNeedsInput.set(undefined, undefined); + assert.strictEqual(getApproval(approvalModel, chatModel), undefined); + }); + + test('reacts to tool state changing from waiting to executing', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const stateObs = observableValue('toolState', makeWaitingState()); + const part: IChatToolInvocation = { + ...makeToolInvocationPart({ state: makeWaitingState(), toolSpecificData: makeTerminalToolData() }), + state: stateObs, + }; + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + assert.ok(getApproval(approvalModel, chatModel)); + + stateObs.set(makeExecutingState(), undefined); + assert.strictEqual(getApproval(approvalModel, chatModel), undefined); + }); + + test('tracks multiple models independently', () => { + const approvalModel = createModel(); + const chatModel1 = addChatModel(URI.parse('test://session/1')); + const chatModel2 = addChatModel(URI.parse('test://session/2')); + + const part1 = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData({ commandLine: { original: 'cmd1' } }), + }); + mockModelWithResponse(chatModel1, [part1]); + chatModel1.requestNeedsInput.set({ title: 'Session 1' }, undefined); + + assert.strictEqual(getApproval(approvalModel, chatModel1)?.label, 'cmd1'); + assert.strictEqual(getApproval(approvalModel, chatModel2), undefined); + }); + + test('clears approval when model is removed', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const part = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData(), + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + assert.ok(getApproval(approvalModel, chatModel)); + + // Remove model from chatModels + chatModelsObs.set([], undefined); + assert.strictEqual(getApproval(approvalModel, chatModel), undefined); + }); + + test('picks the first WaitingForConfirmation part when multiple parts exist', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const executingPart = makeToolInvocationPart({ state: makeExecutingState() }); + const waitingPart = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData({ commandLine: { original: 'second-cmd' } }), + }); + mockModelWithResponse(chatModel, [executingPart, waitingPart]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + assert.strictEqual(getApproval(approvalModel, chatModel)?.label, 'second-cmd'); + }); + + test('handles model added after approval model is created', () => { + const approvalModel = createModel(); + + // No models yet + const uri = URI.parse('test://session/late'); + assert.strictEqual(approvalModel.getApproval(uri).get(), undefined); + + // Add model later + const chatModel = addChatModel(uri); + const part = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData({ commandLine: { original: 'late-cmd' } }), + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + assert.strictEqual(getApproval(approvalModel, chatModel)?.label, 'late-cmd'); + }); + + test('handles legacy terminal tool data', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + // Legacy format has `command` instead of `commandLine` + const legacyData = { kind: 'terminal' as const, command: 'legacy-cmd', language: 'bash' }; + const part = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: legacyData, + }); + mockModelWithResponse(chatModel, [part]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + const result = getApproval(approvalModel, chatModel); + assert.deepStrictEqual({ + label: result?.label, + language: result?.languageId, + }, { + label: 'legacy-cmd', + language: 'sh', + }); + }); + + test('observable is reused for the same session resource', () => { + const approvalModel = createModel(); + const uri = URI.parse('test://session/same'); + + const obs1 = approvalModel.getApproval(uri); + const obs2 = approvalModel.getApproval(uri); + assert.strictEqual(obs1, obs2); + }); + + test('skips non-toolInvocation parts', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + const markdownPart = { kind: 'markdownContent' as const, content: new MarkdownString('hello') }; + const waitingPart = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData({ commandLine: { original: 'the-cmd' } }), + }); + mockModelWithResponse(chatModel, [markdownPart as unknown as IChatProgressResponseContent, waitingPart]); + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + + assert.strictEqual(getApproval(approvalModel, chatModel)?.label, 'the-cmd'); + }); + + test('updating requestNeedsInput triggers re-evaluation', () => { + const approvalModel = createModel(); + const chatModel = addChatModel(); + + // Initially no requestNeedsInput + const part = makeToolInvocationPart({ + state: makeWaitingState(), + toolSpecificData: makeTerminalToolData(), + }); + mockModelWithResponse(chatModel, [part]); + assert.strictEqual(getApproval(approvalModel, chatModel), undefined); + + // Set requestNeedsInput + chatModel.requestNeedsInput.set({ title: 'Test' }, undefined); + assert.ok(getApproval(approvalModel, chatModel)); + + // Clear again + chatModel.requestNeedsInput.set(undefined, undefined); + assert.strictEqual(getApproval(approvalModel, chatModel), undefined); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index 863ef953423..de8d1833797 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -95,7 +95,7 @@ suite('sessionDateFromNow', () => { suite('AgentSessionsDataSource', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const ONE_DAY = 24 * 60 * 60 * 1000; const WEEK_THRESHOLD = 7 * ONE_DAY; // 7 days @@ -152,7 +152,9 @@ suite('AgentSessionsDataSource', () => { onDidChange: Event.None, groupResults: () => options.groupBy, exclude: options.exclude ?? (() => false), - getExcludes: () => ({ providers: [], states: [], archived: false, read: options.excludeRead ?? false }) + getExcludes: () => ({ providers: [], states: [], archived: false, read: options.excludeRead ?? false }), + isDefault: () => true, + reset: () => { }, }; } @@ -182,7 +184,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: undefined }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -202,7 +204,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -223,7 +225,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -244,7 +246,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -263,7 +265,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -281,7 +283,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -299,7 +301,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -319,7 +321,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -353,7 +355,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -382,7 +384,7 @@ suite('AgentSessionsDataSource', () => { test('empty sessions returns empty result', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel([]); const result = Array.from(dataSource.getChildren(mockModel)); @@ -399,7 +401,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -422,7 +424,7 @@ suite('AgentSessionsDataSource', () => { const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Date }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -456,7 +458,7 @@ suite('AgentSessionsDataSource', () => { excludeRead: true // Filtering to show only unread sessions }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); @@ -481,7 +483,7 @@ suite('AgentSessionsDataSource', () => { excludeRead: false // Not filtering to unread only }); const sorter = createMockSorter(); - const dataSource = new AgentSessionsDataSource(filter, sorter); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts index c1c62a1246e..fdc31ae26eb 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts @@ -6,21 +6,21 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Emitter } from '../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { LocalAgentsSessionsController } from '../../../browser/agentSessions/localAgentSessionsController.js'; +import { IChatService, ResponseModelState } from '../../../common/chatService/chatService.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; -import { ChatRequestQueueKind, IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; -import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; -import { ChatAgentLocation } from '../../../common/constants.js'; +import { MockChatService } from '../../common/chatService/mockChatService.js'; import { MockChatSessionsService } from '../../common/mockChatSessionsService.js'; function createTestTiming(options?: { @@ -36,184 +36,6 @@ function createTestTiming(options?: { }; } -class MockChatService implements IChatService { - private readonly _chatModels: ISettableObservable> = observableValue('chatModels', []); - readonly chatModels = this._chatModels; - requestInProgressObs = observableValue('name', false); - _serviceBrand: undefined; - editingSessions = []; - transferredSessionResource = undefined; - readonly onDidSubmitRequest = Event.None; - readonly onDidCreateModel = Event.None; - - private sessions = new Map(); - private liveSessionItems: IChatDetail[] = []; - private historySessionItems: IChatDetail[] = []; - - private readonly _onDidDisposeSession = new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>(); - readonly onDidDisposeSession = this._onDidDisposeSession.event; - - fireDidDisposeSession(sessionResource: URI[]): void { - this._onDidDisposeSession.fire({ sessionResource, reason: 'cleared' }); - } - - setSaveModelsEnabled(enabled: boolean): void { - - } - - processPendingRequests(sessionResource: URI): void { - - } - - setLiveSessionItems(items: IChatDetail[]): void { - this.liveSessionItems = items; - } - - setHistorySessionItems(items: IChatDetail[]): void { - this.historySessionItems = items; - } - - addSession(sessionResource: URI, session: IChatModel): void { - this.sessions.set(sessionResource.toString(), session); - // Update the chatModels observable - this._chatModels.set([...this.sessions.values()], undefined); - } - - removeSession(sessionResource: URI): void { - this.sessions.delete(sessionResource.toString()); - // Update the chatModels observable - this._chatModels.set([...this.sessions.values()], undefined); - } - - isEnabled(_location: ChatAgentLocation): boolean { - return true; - } - - hasSessions(): boolean { - return this.sessions.size > 0; - } - - getProviderInfos() { - return []; - } - - startNewLocalSession(_location: ChatAgentLocation, _options?: IChatSessionStartOptions): any { - throw new Error('Method not implemented.'); - } - - getSession(sessionResource: URI): IChatModel | undefined { - return this.sessions.get(sessionResource.toString()); - } - - getLatestRequest(): IChatRequestModel | undefined { - return undefined; - } - - acquireOrRestoreSession(_sessionResource: URI): Promise { - throw new Error('Method not implemented.'); - } - - getSessionTitle(_sessionResource: URI): string | undefined { - return undefined; - } - - loadSessionFromData(_data: any): any { - throw new Error('Method not implemented.'); - } - - acquireOrLoadSession(_resource: URI, _position: ChatAgentLocation, _token: CancellationToken): Promise { - throw new Error('Method not implemented.'); - } - - acquireExistingSession(_sessionResource: URI): any { - return undefined; - } - - setSessionTitle(_sessionResource: URI, _title: string): void { } - - appendProgress(_request: IChatRequestModel, _progress: any): void { } - - sendRequest(_sessionResource: URI, _message: string): Promise { - throw new Error('Method not implemented.'); - } - - resendRequest(_request: IChatRequestModel, _options?: any): Promise { - throw new Error('Method not implemented.'); - } - - adoptRequest(_sessionResource: URI, _request: IChatRequestModel): Promise { - throw new Error('Method not implemented.'); - } - - removeRequest(_sessionResource: URI, _requestId: string): Promise { - throw new Error('Method not implemented.'); - } - - cancelCurrentRequestForSession(_sessionResource: URI, _source?: string): void { } - - setYieldRequested(_sessionResource: URI): void { } - - removePendingRequest(_sessionResource: URI, _requestId: string): void { } - - setPendingRequests(_sessionResource: URI, _requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void { } - - addCompleteRequest(): void { } - - async getLocalSessionHistory(): Promise { - return this.historySessionItems; - } - - async clearAllHistoryEntries(): Promise { } - - async removeHistoryEntry(_resource: URI): Promise { } - - readonly onDidPerformUserAction = Event.None; - - notifyUserAction(_event: any): void { } - - readonly onDidReceiveQuestionCarouselAnswer = Event.None; - - notifyQuestionCarouselAnswer(_requestId: string, _resolveId: string, _answers: Record | undefined): void { } - - async transferChatSession(): Promise { } - - setChatSessionTitle(): void { } - - isEditingLocation(_location: ChatAgentLocation): boolean { - return false; - } - - getChatStorageFolder(): URI { - return URI.file('/tmp'); - } - - logChatIndex(): void { } - - activateDefaultAgent(_location: ChatAgentLocation): Promise { - return Promise.resolve(); - } - - getChatSessionFromInternalUri(_sessionResource: URI): any { - return undefined; - } - - async getLiveSessionItems(): Promise { - return this.liveSessionItems; - } - - async getHistorySessionItems(): Promise { - return this.historySessionItems; - } - - waitForModelDisposals(): Promise { - return Promise.resolve(); - } - - getMetadataForSession(sessionResource: URI): Promise { - throw new Error('Method not implemented.'); - } -} - function createMockChatModel(options: { sessionResource: URI; hasRequests?: boolean; @@ -344,7 +166,7 @@ suite('LocalAgentsSessionsController', () => { timestamp: Date.now() }); - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); mockChatService.setLiveSessionItems([{ sessionResource, title: 'Test Session', @@ -395,7 +217,7 @@ suite('LocalAgentsSessionsController', () => { hasRequests: true }); - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); mockChatService.setLiveSessionItems([{ sessionResource, title: 'Live Session', @@ -432,7 +254,7 @@ suite('LocalAgentsSessionsController', () => { requestInProgress: true }); - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); mockChatService.setLiveSessionItems([{ sessionResource, title: 'In Progress Session', @@ -463,7 +285,7 @@ suite('LocalAgentsSessionsController', () => { lastResponseHasError: false }); - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); mockChatService.setLiveSessionItems([{ sessionResource, title: 'Completed Session', @@ -493,7 +315,7 @@ suite('LocalAgentsSessionsController', () => { lastResponseCanceled: true }); - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); mockChatService.setLiveSessionItems([{ sessionResource, title: 'Canceled Session', @@ -523,7 +345,7 @@ suite('LocalAgentsSessionsController', () => { lastResponseHasError: true }); - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); mockChatService.setLiveSessionItems([{ sessionResource, title: 'Error Session', @@ -568,7 +390,7 @@ suite('LocalAgentsSessionsController', () => { } }); - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); mockChatService.setLiveSessionItems([{ sessionResource, title: 'Stats Session', @@ -614,7 +436,7 @@ suite('LocalAgentsSessionsController', () => { } }); - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); mockChatService.setLiveSessionItems([{ sessionResource, title: 'No Stats Session', @@ -645,7 +467,7 @@ suite('LocalAgentsSessionsController', () => { timestamp: modelTimestamp }); - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); mockChatService.setLiveSessionItems([{ sessionResource, title: 'Timing Session', @@ -699,7 +521,7 @@ suite('LocalAgentsSessionsController', () => { lastResponseCompletedAt: completedAt }); - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); mockChatService.setLiveSessionItems([{ sessionResource, title: 'EndTime Session', @@ -728,7 +550,7 @@ suite('LocalAgentsSessionsController', () => { hasRequests: true }); - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); mockChatService.setLiveSessionItems([{ sessionResource, title: 'Icon Session', @@ -759,7 +581,7 @@ suite('LocalAgentsSessionsController', () => { }); // Add the session first - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); let changeEventCount = 0; disposables.add(controller.onDidChangeChatSessionItems(() => { @@ -767,7 +589,7 @@ suite('LocalAgentsSessionsController', () => { })); // Simulate progress change by triggering the progress listener - mockChatSessionsService.triggerProgressEvent(); + mockChatService.triggerProgressEvent(); assert.strictEqual(changeEventCount, 1); }); @@ -785,7 +607,7 @@ suite('LocalAgentsSessionsController', () => { }); // Add the session first - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); let changeEventCount = 0; disposables.add(controller.onDidChangeChatSessionItems(() => { @@ -793,7 +615,7 @@ suite('LocalAgentsSessionsController', () => { })); // Simulate progress change by triggering the progress listener - mockChatSessionsService.triggerProgressEvent(); + mockChatService.triggerProgressEvent(); assert.strictEqual(changeEventCount, 1); }); @@ -810,7 +632,7 @@ suite('LocalAgentsSessionsController', () => { }); // Add the session first - mockChatService.addSession(sessionResource, mockModel); + mockChatService.addSession(mockModel); // Now remove the session - the observable should trigger cleanup mockChatService.removeSession(sessionResource); diff --git a/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts new file mode 100644 index 00000000000..59e05f0411b --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/attachments/chatVariables.test.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { IDynamicVariable } from '../../../common/attachments/chatVariables.js'; +import { IChatWidget } from '../../../browser/chat.js'; +import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../../../browser/attachments/chatVariables.js'; +import { ChatDynamicVariableModel } from '../../../browser/attachments/chatDynamicVariables.js'; +import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; +import { IToolData, IToolSet, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; + +function createMockVariable(overrides?: Partial): IDynamicVariable { + return { + id: 'var-1', + fullName: 'test-var', + range: new Range(1, 1, 1, 10), + data: 'test-data', + ...overrides, + }; +} + +function createMockAttachment(overrides?: Partial): IChatRequestVariableEntry { + return { + id: 'attach-1', + name: 'test-attachment', + kind: 'file', + value: 'test-value', + ...overrides, + } as IChatRequestVariableEntry; +} + +function createMockWidget(options: { + hasViewModel?: boolean; + supportsFileReferences?: boolean; + contribVariables?: IDynamicVariable[]; + editing?: boolean; + attachments?: IChatRequestVariableEntry[]; + editorTextLength?: number; +}): IChatWidget { + const { + hasViewModel = true, + supportsFileReferences = true, + contribVariables = [], + editing = false, + attachments = [], + editorTextLength = 100, + } = options; + + const contribModel = { + id: ChatDynamicVariableModel.ID, + variables: contribVariables, + }; + + return { + viewModel: hasViewModel ? { editing: editing ? {} : undefined } : undefined, + supportsFileReferences, + getContrib: (id: string) => id === ChatDynamicVariableModel.ID ? contribModel : undefined, + input: { + attachmentModel: { attachments }, + }, + inputEditor: { + getModel: () => ({ + getValueLength: () => editorTextLength, + getPositionAt: (offset: number) => ({ lineNumber: 1, column: offset + 1 }), + }), + }, + } as unknown as IChatWidget; +} + +suite('getDynamicVariablesForWidget', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns empty when no viewModel', () => { + const widget = createMockWidget({ hasViewModel: false }); + assert.deepStrictEqual(getDynamicVariablesForWidget(widget), []); + }); + + test('returns empty when file references not supported', () => { + const widget = createMockWidget({ supportsFileReferences: false }); + assert.deepStrictEqual(getDynamicVariablesForWidget(widget), []); + }); + + test('returns contrib model variables when not editing', () => { + const variables = [createMockVariable()]; + const widget = createMockWidget({ contribVariables: variables }); + assert.deepStrictEqual(getDynamicVariablesForWidget(widget), variables); + }); + + test('returns contrib model variables when editing with existing variables', () => { + const variables = [createMockVariable()]; + const widget = createMockWidget({ editing: true, contribVariables: variables }); + assert.deepStrictEqual(getDynamicVariablesForWidget(widget), variables); + }); + + test('converts attachments to dynamic variables when editing with attachments and no contrib variables', () => { + const attachments = [ + createMockAttachment({ + id: 'a1', + name: 'file.ts', + kind: 'file', + value: 'file-value', + range: { start: 0, endExclusive: 8 }, + }), + ]; + const widget = createMockWidget({ editing: true, attachments, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, 'a1'); + assert.strictEqual(result[0].fullName, 'file.ts'); + assert.strictEqual(result[0].isFile, true); + assert.strictEqual(result[0].isDirectory, false); + assert.strictEqual(result[0].data, 'file-value'); + }); + + test('skips attachments without range when editing', () => { + const attachments = [createMockAttachment({ range: undefined })]; + const widget = createMockWidget({ editing: true, attachments, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + + // No ranged attachments, falls back to contrib model variables (empty) + assert.deepStrictEqual(result, []); + }); + + test('skips attachments with empty range', () => { + const attachments = [createMockAttachment({ range: { start: 5, endExclusive: 5 } })]; + const widget = createMockWidget({ editing: true, attachments, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + assert.deepStrictEqual(result, []); + }); + + test('skips attachments with out-of-bounds range', () => { + const attachments = [createMockAttachment({ range: { start: 0, endExclusive: 200 } })]; + const widget = createMockWidget({ editing: true, attachments, editorTextLength: 100, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + assert.deepStrictEqual(result, []); + }); + + test('skips attachments with negative start', () => { + const attachments = [createMockAttachment({ range: { start: -1, endExclusive: 5 } })]; + const widget = createMockWidget({ editing: true, attachments, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + assert.deepStrictEqual(result, []); + }); + + test('sets isDirectory for directory attachments', () => { + const attachments = [ + createMockAttachment({ + kind: 'directory', + range: { start: 0, endExclusive: 5 }, + }), + ]; + const widget = createMockWidget({ editing: true, attachments, contribVariables: [] }); + const result = getDynamicVariablesForWidget(widget); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].isFile, false); + assert.strictEqual(result[0].isDirectory, true); + }); +}); + +suite('getSelectedToolAndToolSetsForWidget', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns the entriesMap from the selected tools model', () => { + const toolData: IToolData = { + id: 'tool-1', + toolReferenceName: 'myTool', + displayName: 'My Tool', + modelDescription: 'A test tool', + canBeReferencedInPrompt: true, + source: ToolDataSource.Internal, + }; + const expectedMap = new Map([[toolData, true]]); + const entriesMap = observableValue('test', expectedMap); + + const widget = { + input: { + selectedToolsModel: { entriesMap }, + }, + } as unknown as IChatWidget; + + const result = getSelectedToolAndToolSetsForWidget(widget); + assert.strictEqual(result, expectedMap); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatDebugFilters.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatDebugFilters.test.ts new file mode 100644 index 00000000000..9bb37a5f829 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatDebugFilters.test.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ChatDebugFilterState } from '../../browser/chatDebug/chatDebugFilters.js'; + +suite('ChatDebugFilterState', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + suite('parseTimeToken', () => { + + suite('before: prefix', () => { + + test('year only — rounds to end of year', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026', 'before'); + assert.strictEqual(result, new Date(2026, 11, 31, 23, 59, 59, 999).getTime()); + }); + + test('year-month — rounds to end of month', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026-03', 'before'); + // new Date(2026, 3, 0) gives last day of March + assert.strictEqual(result, new Date(2026, 3, 0, 23, 59, 59, 999).getTime()); + }); + + test('year-month (February, non-leap) — rounds to end of Feb', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2025-02', 'before'); + assert.strictEqual(result, new Date(2025, 2, 0, 23, 59, 59, 999).getTime()); + }); + + test('year-month-day — rounds to end of day', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026-03-03', 'before'); + assert.strictEqual(result, new Date(2026, 2, 3, 23, 59, 59, 999).getTime()); + }); + + test('date with hour only — rounds to end of hour', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026-03-03t14', 'before'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 59, 59, 999).getTime()); + }); + + test('date with hour:minute — rounds to end of minute', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026-03-03t14:30', 'before'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 30, 59, 999).getTime()); + }); + + test('full date-time with seconds', () => { + const result = ChatDebugFilterState.parseTimeToken('before:2026-03-03t14:30:45', 'before'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 30, 45, 999).getTime()); + }); + }); + + suite('after: prefix', () => { + + test('year only — start of year', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026', 'after'); + assert.strictEqual(result, new Date(2026, 0, 1, 0, 0, 0, 0).getTime()); + }); + + test('year-month — start of month', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026-03', 'after'); + assert.strictEqual(result, new Date(2026, 2, 1, 0, 0, 0, 0).getTime()); + }); + + test('year-month-day — start of day', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026-03-03', 'after'); + assert.strictEqual(result, new Date(2026, 2, 3, 0, 0, 0, 0).getTime()); + }); + + test('date with hour only — start of hour', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026-03-03t14', 'after'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 0, 0, 0).getTime()); + }); + + test('date with hour:minute — start of minute', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026-03-03t14:30', 'after'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 30, 0, 0).getTime()); + }); + + test('full date-time with seconds', () => { + const result = ChatDebugFilterState.parseTimeToken('after:2026-03-03t14:30:45', 'after'); + assert.strictEqual(result, new Date(2026, 2, 3, 14, 30, 45, 0).getTime()); + }); + }); + + suite('no match', () => { + + test('returns undefined for empty string', () => { + assert.strictEqual(ChatDebugFilterState.parseTimeToken('', 'before'), undefined); + }); + + test('returns undefined for unrelated text', () => { + assert.strictEqual(ChatDebugFilterState.parseTimeToken('hello world', 'before'), undefined); + }); + + test('returns undefined for wrong prefix', () => { + assert.strictEqual(ChatDebugFilterState.parseTimeToken('after:2026', 'before'), undefined); + }); + + test('returns undefined for bare time without date', () => { + assert.strictEqual(ChatDebugFilterState.parseTimeToken('before:14:30', 'before'), undefined); + }); + }); + + suite('embedded in text', () => { + + test('extracts token from surrounding text', () => { + const result = ChatDebugFilterState.parseTimeToken('some text before:2026-03-03 more text', 'before'); + assert.strictEqual(result, new Date(2026, 2, 3, 23, 59, 59, 999).getTime()); + }); + + test('handles both before and after in same string', () => { + const text = 'after:2026-01 before:2026-03'; + const after = ChatDebugFilterState.parseTimeToken(text, 'after'); + const before = ChatDebugFilterState.parseTimeToken(text, 'before'); + assert.strictEqual(after, new Date(2026, 0, 1, 0, 0, 0, 0).getTime()); + assert.strictEqual(before, new Date(2026, 3, 0, 23, 59, 59, 999).getTime()); + }); + }); + }); + + suite('setTextFilter and timestamp parsing', () => { + let state: ChatDebugFilterState; + + setup(() => { + state = disposables.add(new ChatDebugFilterState()); + }); + + test('sets beforeTimestamp and afterTimestamp from text', () => { + state.setTextFilter('after:2026-01-01 before:2026-12-31'); + assert.strictEqual(state.afterTimestamp, new Date(2026, 0, 1, 0, 0, 0, 0).getTime()); + assert.strictEqual(state.beforeTimestamp, new Date(2026, 11, 31, 23, 59, 59, 999).getTime()); + }); + + test('clears timestamps when tokens removed', () => { + state.setTextFilter('before:2026'); + assert.ok(state.beforeTimestamp !== undefined); + state.setTextFilter('hello'); + assert.strictEqual(state.beforeTimestamp, undefined); + }); + }); + + suite('textFilterWithoutTimestamps', () => { + let state: ChatDebugFilterState; + + setup(() => { + state = disposables.add(new ChatDebugFilterState()); + }); + + test('strips year-only token', () => { + state.setTextFilter('before:2026 hello'); + assert.strictEqual(state.textFilterWithoutTimestamps, 'hello'); + }); + + test('strips year-month token', () => { + state.setTextFilter('after:2026-03 hello'); + assert.strictEqual(state.textFilterWithoutTimestamps, 'hello'); + }); + + test('strips full date-time token', () => { + state.setTextFilter('before:2026-03-03t14:30:45 hello'); + assert.strictEqual(state.textFilterWithoutTimestamps, 'hello'); + }); + + test('strips multiple tokens', () => { + state.setTextFilter('after:2026-01 hello before:2026-12'); + assert.strictEqual(state.textFilterWithoutTimestamps, 'hello'); + }); + + test('returns empty when only tokens', () => { + state.setTextFilter('before:2026'); + assert.strictEqual(state.textFilterWithoutTimestamps, ''); + }); + }); + + suite('isTimestampVisible', () => { + let state: ChatDebugFilterState; + + setup(() => { + state = disposables.add(new ChatDebugFilterState()); + }); + + test('visible when no timestamp filters set', () => { + assert.strictEqual(state.isTimestampVisible(new Date(2026, 5, 15)), true); + }); + + test('hidden when after beforeTimestamp', () => { + state.setTextFilter('before:2026-03'); + // April 1st is after end of March + assert.strictEqual(state.isTimestampVisible(new Date(2026, 3, 1)), false); + }); + + test('visible when before beforeTimestamp', () => { + state.setTextFilter('before:2026-03'); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 1, 15)), true); + }); + + test('hidden when before afterTimestamp', () => { + state.setTextFilter('after:2026-06'); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 4, 31)), false); + }); + + test('visible when after afterTimestamp', () => { + state.setTextFilter('after:2026-06'); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 6, 1)), true); + }); + + test('visible when within before/after range', () => { + state.setTextFilter('after:2026-03 before:2026-06'); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 3, 15)), true); + }); + + test('hidden when outside before/after range', () => { + state.setTextFilter('after:2026-03 before:2026-06'); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 0, 1)), false); + assert.strictEqual(state.isTimestampVisible(new Date(2026, 8, 1)), false); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingCheckpointTimeline.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingCheckpointTimeline.test.ts index 57478466f4f..f6f3af2a699 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingCheckpointTimeline.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingCheckpointTimeline.test.ts @@ -1248,6 +1248,54 @@ suite('ChatEditingCheckpointTimeline', function () { await timeline.navigateToCheckpoint(stop2NewCheckpointId); assert.strictEqual(fileContents.get(uri), 'replacement edit', 'Content should still be correct after full timeline traversal'); }); + + test('undo/redo with multiple no-edit requests advances one request at a time', async function () { + // req1: no edits + timeline.createCheckpoint('req1', undefined, 'Start req1'); + + // req2: no edits + timeline.createCheckpoint('req2', undefined, 'Start req2'); + + // req3: no edits + timeline.createCheckpoint('req3', undefined, 'Start req3'); + + // req4: no edits + timeline.createCheckpoint('req4', undefined, 'Start req4'); + + // Undo should step one request at a time + assert.strictEqual(timeline.canUndo.get(), true); + + await timeline.undoToLastCheckpoint(); + assert.deepStrictEqual(timeline.requestDisablement.get().map(d => d.requestId), ['req4']); + + await timeline.undoToLastCheckpoint(); + assert.deepStrictEqual(timeline.requestDisablement.get().map(d => d.requestId), ['req4', 'req3']); + + await timeline.undoToLastCheckpoint(); + assert.deepStrictEqual(timeline.requestDisablement.get().map(d => d.requestId), ['req4', 'req3', 'req2']); + + await timeline.undoToLastCheckpoint(); + assert.deepStrictEqual(timeline.requestDisablement.get().map(d => d.requestId), ['req4', 'req3', 'req2', 'req1']); + + assert.strictEqual(timeline.canUndo.get(), false); + + // Redo should also step one request at a time (not skip all at once) + assert.strictEqual(timeline.canRedo.get(), true); + + await timeline.redoToNextCheckpoint(); + assert.deepStrictEqual(timeline.requestDisablement.get().map(d => d.requestId), ['req4', 'req3', 'req2']); + + await timeline.redoToNextCheckpoint(); + assert.deepStrictEqual(timeline.requestDisablement.get().map(d => d.requestId), ['req4', 'req3']); + + await timeline.redoToNextCheckpoint(); + assert.deepStrictEqual(timeline.requestDisablement.get().map(d => d.requestId), ['req4']); + + await timeline.redoToNextCheckpoint(); + assert.deepStrictEqual(timeline.requestDisablement.get().map(d => d.requestId), []); + + assert.strictEqual(timeline.canRedo.get(), false); + }); }); // Mock notebook service for tests that don't need notebook functionality diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index f0946ccfb22..dc13e3e902e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -21,11 +21,11 @@ import { ChatTipService, CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND, CREATE_AGEN import { AgentFileType, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { URI } from '../../../../../base/common/uri.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; -import { ChatTipTier } from '../../browser/chatTipCatalog.js'; +import { ChatTipTier, TIP_CATALOG } from '../../browser/chatTipCatalog.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { TestChatEntitlementService } from '../../../../test/common/workbenchTestServices.js'; import { IChatService } from '../../common/chatService/chatService.js'; @@ -138,6 +138,22 @@ suite('ChatTipService', () => { assert.ok(tip.content.value.length > 0, 'Tip should have content'); }); + test('uses descriptive titles for tip command links', () => { + for (const tip of TIP_CATALOG) { + const markdown = tip.buildMessage({ + keybindingService: { + lookupKeybinding: () => undefined, + } as Partial as IKeybindingService, + }).value; + + const commandLinkRegex = /\[[^\]]+\]\((command:[^)]+)\)/g; + let match: RegExpExecArray | null; + while ((match = commandLinkRegex.exec(markdown)) !== null) { + assert.ok(/\s"[^"]+"$/.test(match[1]), `Expected command link in ${tip.id} to include a descriptive title: ${match[0]}`); + } + } + }); + test('records # file reference usage for attach files tip eligibility', () => { const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); instantiationService.stub(IChatService, { @@ -220,6 +236,110 @@ suite('ChatTipService', () => { assert.ok(!executedCommands.includes(FORK_CONVERSATION_TRACKING_COMMAND)); }); + + test('hides shown slash tip after submitted slash command without clicking tip link', () => { + const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); + instantiationService.stub(IChatService, { + onDidSubmitRequest: submitRequestEmitter.event, + getSession: () => undefined, + } as Partial as IChatService); + + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + + let tip = service.getWelcomeTip(contextKeyService); + assert.ok(tip); + + for (let i = 0; i < TIP_CATALOG.length && tip?.id !== 'tip.init'; i++) { + tip = service.navigateToNextTip(); + } + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.init', 'Expected to navigate to the init tip before submitting /init'); + + let didHide = false; + testDisposables.add(service.onDidHideTip(() => didHide = true)); + + submitRequestEmitter.fire({ + chatSessionResource: URI.parse('chat:session-advance-init'), + message: { + text: '/init', + parts: [], + }, + }); + + assert.ok(didHide, 'Expected slash tip to hide after submitting /init'); + assert.notStrictEqual(service.getWelcomeTip(contextKeyService)?.id, 'tip.init', 'Expected init tip to stay excluded after slash usage'); + }); + + test('removes slash tip from rotation after submitted slash command via eligibility tracking', () => { + const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); + instantiationService.stub(IChatService, { + onDidSubmitRequest: submitRequestEmitter.event, + getSession: () => undefined, + } as Partial as IChatService); + + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + + let tip = service.getWelcomeTip(contextKeyService); + assert.ok(tip); + + for (let i = 0; i < TIP_CATALOG.length && tip?.id !== 'tip.init'; i++) { + tip = service.navigateToNextTip(); + } + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.init'); + + submitRequestEmitter.fire({ + chatSessionResource: URI.parse('chat:session-rotate-init'), + message: { + text: '/init', + parts: [], + }, + }); + + for (let i = 0; i < TIP_CATALOG.length; i++) { + tip = service.navigateToNextTip(); + if (!tip) { + break; + } + assert.notStrictEqual(tip.id, 'tip.init', 'Expected init tip to be removed from tip rotation'); + } + + const executedCommands = JSON.parse(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION) ?? '[]') as string[]; + assert.ok(executedCommands.includes(CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND), 'Expected slash usage to be tracked in executed command exclusions'); + }); + + test('removes slash tip from rotation when slash usage is recorded before input transformation', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + + let tip = service.getWelcomeTip(contextKeyService); + assert.ok(tip); + + for (let i = 0; i < TIP_CATALOG.length && tip?.id !== 'tip.init'; i++) { + tip = service.navigateToNextTip(); + } + + assert.ok(tip); + assert.strictEqual(tip.id, 'tip.init'); + + service.recordSlashCommandUsage('init'); + + for (let i = 0; i < TIP_CATALOG.length; i++) { + tip = service.navigateToNextTip(); + if (!tip) { + break; + } + assert.notStrictEqual(tip.id, 'tip.init', 'Expected init tip to be removed from tip rotation'); + } + + const executedCommands = JSON.parse(storageService.get('chat.tips.executedCommands', StorageScope.APPLICATION) ?? '[]') as string[]; + assert.ok(executedCommands.includes(CREATE_AGENT_INSTRUCTIONS_TRACKING_COMMAND), 'Expected slash usage to be tracked in executed command exclusions'); + }); + test('records fork tip usage for submitted /fork command', () => { const submitRequestEmitter = testDisposables.add(new Emitter<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }>()); instantiationService.stub(IChatService, { @@ -253,6 +373,7 @@ suite('ChatTipService', () => { assert.ok(tip); assert.strictEqual(tip.id, 'tip.switchToAuto'); + assert.ok(tip.content.value.includes('GPT-4.1')); }); test('does not return Auto switch tip when current model is not gpt-4.1', () => { @@ -676,6 +797,29 @@ suite('ChatTipService', () => { assert.ok(storageService.get('chat.tip.dismissed', StorageScope.APPLICATION), 'Expected dismissed tips to migrate to application storage'); }); + test('tip.undoChanges describes where to find restore checkpoint', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatSessionType.key, localChatSessionType); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + + const tip = findTipById(service, 'tip.undoChanges'); + + assert.ok(tip); + assert.ok(tip.content.value.includes('Hover a previous request')); + assert.ok(tip.content.value.includes('Restore Checkpoint')); + }); + + test('tip.mermaid uses sentence punctuation in display text', () => { + const service = createService(); + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + + const tip = findTipById(service, 'tip.mermaid'); + + assert.ok(tip); + assert.ok(tip.content.value.includes('flow chart. It can render Mermaid diagrams directly in chat.')); + assert.ok(!tip.content.value.includes('flow chart; it can render Mermaid diagrams directly in chat.')); + }); + function createMockPromptsService( agentInstructions: IResolvedAgentFile[] = [], promptInstructions: IPromptPath[] = [], @@ -1269,81 +1413,6 @@ suite('ChatTipService', () => { } }); - test('does not show tip.yoloMode after auto-approve has ever been enabled', () => { - const service = createService(); - contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); - - // Enable auto-approve so the service records yoloModeEverEnabled - configurationService.setUserConfiguration(ChatConfiguration.GlobalAutoApprove, true); - (configurationService as TestConfigurationService).onDidChangeConfigurationEmitter.fire({ - affectsConfiguration: (key: string) => key === ChatConfiguration.GlobalAutoApprove, - affectedKeys: new Set([ChatConfiguration.GlobalAutoApprove]), - change: { keys: [], overrides: [] }, - source: ConfigurationTarget.USER, - }); - - // Turn auto-approve back off - configurationService.setUserConfiguration(ChatConfiguration.GlobalAutoApprove, false); - - // The yoloMode tip should never appear since it was ever enabled - for (let i = 0; i < 100; i++) { - const tip = service.getWelcomeTip(contextKeyService); - if (!tip) { - break; - } - assert.notStrictEqual(tip.id, 'tip.yoloMode', 'tip.yoloMode should not be shown after auto-approve was ever enabled'); - service.dismissTip(); - } - - // Verify the flag was persisted - assert.strictEqual( - storageService.getBoolean('chat.tip.yoloModeEverEnabled', StorageScope.APPLICATION, false), - true, - 'yoloModeEverEnabled should be persisted in application storage', - ); - }); - - test('does not show tip.yoloMode when yoloModeEverEnabled is already persisted in storage', () => { - // Simulate a previous session having set the flag - storageService.store('chat.tip.yoloModeEverEnabled', true, StorageScope.APPLICATION, StorageTarget.MACHINE); - - const service = createService(); - contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); - - for (let i = 0; i < 100; i++) { - const tip = service.getWelcomeTip(contextKeyService); - if (!tip) { - break; - } - assert.notStrictEqual(tip.id, 'tip.yoloMode', 'tip.yoloMode should not be shown when yoloModeEverEnabled is already in storage'); - service.dismissTip(); - } - }); - - test('does not show tip.yoloMode when policy restricts auto-approve', () => { - const policyConfigService = new TestConfigurationService(); - const originalInspect = policyConfigService.inspect.bind(policyConfigService); - policyConfigService.inspect = (key: string, overrides?: any) => { - if (key === ChatConfiguration.GlobalAutoApprove) { - return { ...originalInspect(key, overrides), policyValue: false } as unknown as T; - } - return originalInspect(key, overrides); - }; - configurationService = policyConfigService; - instantiationService.stub(IConfigurationService, configurationService); - - const service = createService(); - contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); - - for (let i = 0; i < 100; i++) { - const tip = service.getWelcomeTip(contextKeyService); - if (!tip) { - break; - } - assert.notStrictEqual(tip.id, 'tip.yoloMode', 'tip.yoloMode should not be shown when policy restricts auto-approve'); - service.dismissTip(); - } - }); function findTipById(service: ChatTipService, tipId: string, ckService: MockContextKeyServiceWithRulesMatching = contextKeyService): IChatTip | undefined { for (let i = 0; i < 100; i++) { @@ -1371,7 +1440,6 @@ suite('ChatTipService', () => { } for (const { tipId, settingKey } of [ - { tipId: 'tip.yoloMode', settingKey: ChatConfiguration.GlobalAutoApprove }, { tipId: 'tip.thinkingPhrases', settingKey: 'chat.agent.thinking.phrases' }, { tipId: 'tip.agenticBrowser', settingKey: 'workbench.browser.enableChatTools' }, ]) { @@ -1397,7 +1465,6 @@ suite('ChatTipService', () => { } for (const tipId of [ - 'tip.yoloMode', 'tip.thinkingPhrases', 'tip.agenticBrowser', ]) { diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index d5b0366678f..6fe9944953e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -15,7 +15,7 @@ import { INotificationService } from '../../../../../../platform/notification/co import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { AgentPluginRepositoryService } from '../../../browser/agentPluginRepositoryService.js'; -import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference } from '../../../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; suite('AgentPluginRepositoryService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -32,6 +32,7 @@ suite('AgentPluginRepositoryService', () => { description: '', version: '', source, + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: source }, marketplace: marketplaceReference.displayLabel, marketplaceReference, marketplaceType: MarketplaceType.Copilot, @@ -40,7 +41,7 @@ suite('AgentPluginRepositoryService', () => { function createService( onExists?: (resource: URI) => Promise, - onExecuteCommand?: (id: string) => void, + onExecuteCommand?: (id: string, ...args: unknown[]) => void, ): AgentPluginRepositoryService { const instantiationService = store.add(new TestInstantiationService()); @@ -53,8 +54,8 @@ suite('AgentPluginRepositoryService', () => { } as unknown as IProgressService; instantiationService.stub(ICommandService, { - executeCommand: async (id: string) => { - onExecuteCommand?.(id); + executeCommand: async (id: string, ...args: unknown[]) => { + onExecuteCommand?.(id, ...args); return undefined; }, } as unknown as ICommandService); @@ -170,4 +171,232 @@ suite('AgentPluginRepositoryService', () => { assert.strictEqual(uri.path, '/tmp/marketplace-repo'); assert.strictEqual(commandInvocationCount, 0); }); + + test('builds revision-aware install URI for github plugin sources', () => { + const service = createService(); + const uri = service.getPluginSourceInstallUri({ + kind: PluginSourceKind.GitHub, + repo: 'owner/repo', + ref: 'release/v1', + }); + + assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/owner/repo/ref_release_v1'); + }); + + test('updates git plugin source by pulling and checking out requested revision', async () => { + const commands: string[] = []; + const service = createService(async () => true, (id: string) => { + commands.push(id); + }); + + await service.updatePluginSource({ + name: 'my-plugin', + description: '', + version: '', + source: '', + sourceDescriptor: { + kind: PluginSourceKind.GitHub, + repo: 'owner/repo', + sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', + }, + marketplace: 'owner/repo', + marketplaceReference: parseMarketplaceReference('owner/repo')!, + marketplaceType: MarketplaceType.Copilot, + }, { + pluginName: 'my-plugin', + failureLabel: 'my-plugin', + marketplaceType: MarketplaceType.Copilot, + }); + + assert.deepStrictEqual(commands, ['git.openRepository', '_git.revParse', 'git.fetch', '_git.checkout', '_git.revParse']); + }); + + // ========================================================================= + // cleanupPluginSource — issue #297251 regression + // ========================================================================= + + suite('cleanupPluginSource', () => { + + function createServiceWithDel( + onDel: (resource: URI) => void, + options?: { resolve?: (resource: URI) => { children?: unknown[] } }, + ) { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IFileService, { + exists: async () => true, + del: async (resource: URI) => { onDel(resource); }, + createFolder: async () => undefined, + resolve: async (resource: URI) => options?.resolve?.(resource) ?? { children: [] }, + } as unknown as IFileService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); + instantiationService.stub(IProgressService, { withProgress: async (_o: unknown, cb: (...a: unknown[]) => Promise) => cb() } as unknown as IProgressService); + instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); + return instantiationService.createInstance(AgentPluginRepositoryService); + } + + test('does not delete files for relative-path (marketplace) plugin', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource({ + name: 'marketplace-plugin', + description: '', + version: '', + source: 'plugins/foo', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/foo' }, + marketplace: 'microsoft/vscode', + marketplaceReference: parseMarketplaceReference('microsoft/vscode')!, + marketplaceType: MarketplaceType.Copilot, + }); + + assert.strictEqual(deleted.length, 0); + }); + + test('deletes cache for github plugin source', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource({ + name: 'gh-plugin', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }); + + assert.ok(deleted.length >= 1); + assert.ok(deleted[0].includes('github.com/owner/repo')); + }); + + test('deletes parent cache dir for npm plugin source', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource({ + name: 'npm-plugin', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.Npm, package: '@acme/plugin' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }); + + assert.ok(deleted.length >= 1); + // First delete should be the npm/ cache dir + assert.ok(deleted[0].includes('/npm/'), `Expected npm path, got: ${deleted[0]}`); + }); + + test('deletes cache for pip plugin source', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource({ + name: 'pip-plugin', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pip-pkg' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }); + + assert.ok(deleted.length >= 1); + assert.ok(deleted[0].includes('pip/my-pip-pkg')); + }); + + test('does not throw when delete fails', async () => { + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ICommandService, { executeCommand: async () => undefined } as unknown as ICommandService); + instantiationService.stub(IEnvironmentService, { cacheHome: URI.file('/cache') } as unknown as IEnvironmentService); + instantiationService.stub(IFileService, { + exists: async () => true, + del: async () => { throw new Error('permission denied'); }, + createFolder: async () => undefined, + resolve: async () => ({ children: [] }), + } as unknown as IFileService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(INotificationService, { notify: () => undefined } as unknown as INotificationService); + instantiationService.stub(IProgressService, { withProgress: async (_o: unknown, cb: (...a: unknown[]) => Promise) => cb() } as unknown as IProgressService); + instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); + const service = instantiationService.createInstance(AgentPluginRepositoryService); + + // Should not throw — cleanup is best-effort + await service.cleanupPluginSource({ + name: 'gh-plugin', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }); + }); + + test('prunes empty parent directories up to cache root', async () => { + // After deleting github.com/owner/repo, the "owner" dir is empty + // and should also be removed. + const deleted: string[] = []; + const service = createServiceWithDel( + r => deleted.push(r.path), + { resolve: () => ({ children: [] }) }, + ); + + await service.cleanupPluginSource({ + name: 'gh-plugin', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }); + + // Should have deleted the repo dir + empty parents (owner, github.com) + assert.ok(deleted.length >= 2, `Expected at least 2 deletions (repo + parent), got ${deleted.length}: ${deleted.join(', ')}`); + assert.ok(deleted[0].includes('github.com/owner/repo'), 'First delete should be the repo dir'); + assert.ok(deleted.some(p => p.endsWith('/owner')), 'Should prune empty owner directory'); + }); + + test('stops pruning at non-empty parent', async () => { + const deleted: string[] = []; + const service = createServiceWithDel( + r => deleted.push(r.path), + { + resolve: (resource: URI) => { + // owner dir still has another repo + if (resource.path.endsWith('/owner')) { + return { children: [{ name: 'other-repo' }] }; + } + return { children: [] }; + }, + }, + ); + + await service.cleanupPluginSource({ + name: 'gh-plugin', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }); + + // Should only delete the repo dir, stop at non-empty owner dir + assert.strictEqual(deleted.length, 1); + assert.ok(deleted[0].includes('github.com/owner/repo')); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts new file mode 100644 index 00000000000..2804da7b73a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -0,0 +1,784 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; +import { ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { PluginInstallService } from '../../../browser/pluginInstallService.js'; +import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../../../common/plugins/agentPluginRepositoryService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, IPluginSourceDescriptor, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; +import { IPluginSource } from '../../../common/plugins/pluginSource.js'; + +suite('PluginInstallService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + // --- Factory helpers ------------------------------------------------------- + + function makeMarketplaceRef(marketplace: string): IMarketplaceReference { + const ref = parseMarketplaceReference(marketplace); + assert.ok(ref); + return ref!; + } + + function createPlugin(overrides: Partial & { sourceDescriptor: IPluginSourceDescriptor }): IMarketplacePlugin { + return { + name: overrides.name ?? 'test-plugin', + description: overrides.description ?? '', + version: overrides.version ?? '', + source: overrides.source ?? '', + sourceDescriptor: overrides.sourceDescriptor, + marketplace: overrides.marketplace ?? 'microsoft/vscode', + marketplaceReference: overrides.marketplaceReference ?? makeMarketplaceRef('microsoft/vscode'), + marketplaceType: overrides.marketplaceType ?? MarketplaceType.Copilot, + readmeUri: overrides.readmeUri, + }; + } + + // --- Mock tracking types --------------------------------------------------- + + interface MockState { + notifications: { severity: number; message: string }[]; + addedPlugins: { uri: string; plugin: IMarketplacePlugin }[]; + dialogConfirmResult: boolean; + fileExistsResult: boolean | ((uri: URI) => Promise); + ensureRepositoryResult: URI; + ensurePluginSourceResult: URI; + /** Plugin source install URI, per kind */ + pluginSourceInstallUris: Map; + /** The commands that were sent to the terminal */ + terminalCommands: string[]; + /** Simulated exit code from terminal */ + terminalExitCode: number; + /** Whether the terminal resolves the command completion at all */ + terminalCompletes: boolean; + pullRepositoryCalls: { marketplace: IMarketplaceReference; options?: IPullRepositoryOptions }[]; + updatePluginSourceCalls: { plugin: IMarketplacePlugin; options?: IPullRepositoryOptions }[]; + /** Whether the marketplace is already trusted */ + marketplaceTrusted: boolean; + /** Canonical IDs that were trusted via trustMarketplace() */ + trustedMarketplaces: string[]; + } + + function createDefaults(): MockState { + return { + notifications: [], + addedPlugins: [], + dialogConfirmResult: true, + fileExistsResult: true, + ensureRepositoryResult: URI.file('/cache/agentPlugins/github.com/microsoft/vscode'), + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-package'), + pluginSourceInstallUris: new Map(), + terminalCommands: [], + terminalExitCode: 0, + terminalCompletes: true, + pullRepositoryCalls: [], + updatePluginSourceCalls: [], + marketplaceTrusted: true, + trustedMarketplaces: [], + }; + } + + function createService(stateOverrides?: Partial): { service: PluginInstallService; state: MockState } { + const state: MockState = { ...createDefaults(), ...stateOverrides }; + const instantiationService = store.add(new TestInstantiationService()); + + // IFileService + instantiationService.stub(IFileService, { + exists: async (resource: URI) => { + if (typeof state.fileExistsResult === 'function') { + return state.fileExistsResult(resource); + } + return state.fileExistsResult; + }, + } as unknown as IFileService); + + // INotificationService + instantiationService.stub(INotificationService, { + notify: (notification: { severity: number; message: string }) => { + state.notifications.push({ severity: notification.severity, message: notification.message }); + return undefined; + }, + } as unknown as INotificationService); + + // IDialogService + instantiationService.stub(IDialogService, { + confirm: async () => ({ confirmed: state.dialogConfirmResult }), + } as unknown as IDialogService); + + // ITerminalService — the mock coordinates runCommand and onCommandFinished + // so the command ID matches, just like a real terminal would. + instantiationService.stub(ITerminalService, { + createTerminal: async () => { + let finishedCallback: ((cmd: { id: string; exitCode: number }) => void) | undefined; + return { + processReady: Promise.resolve(), + dispose: () => { }, + runCommand: (command: string, _addNewLine?: boolean) => { + state.terminalCommands.push(command); + // Simulate command completing after runCommand is called + if (finishedCallback) { + finishedCallback({ id: 'command', exitCode: state.terminalExitCode }); + } + }, + capabilities: { + get: () => state.terminalCompletes ? { + onCommandFinished: (callback: (cmd: { id: string; exitCode: number }) => void) => { + finishedCallback = callback; + return { dispose() { } }; + }, + } : undefined, + onDidAddCommandDetectionCapability: () => ({ dispose() { } }), + }, + }; + }, + setActiveInstance: () => { }, + } as unknown as ITerminalService); + + // IProgressService + instantiationService.stub(IProgressService, { + withProgress: async (_options: unknown, callback: (...args: unknown[]) => Promise) => callback(), + } as unknown as IProgressService); + + // ILogService + instantiationService.stub(ILogService, new NullLogService()); + + // IAgentPluginRepositoryService + // Build mock source repositories for npm/pip that simulate terminal-based install + const makeMockPackageRepo = (kind: PluginSourceKind): IPluginSource => ({ + kind, + getCleanupTarget: () => URI.file('/mock-cleanup'), + getInstallUri: () => URI.file('/mock'), + ensure: async () => state.ensurePluginSourceResult, + update: async () => true, + getLabel: (d) => kind === PluginSourceKind.Npm ? (d as { package: string }).package : (d as { package: string }).package, + runInstall: async (_installDir: URI, pluginDir: URI, plugin: IMarketplacePlugin) => { + // Simulate confirmation dialog + if (!state.dialogConfirmResult) { + return undefined; + } + + // Simulate building and running the command + const descriptor = plugin.sourceDescriptor; + let args: string[]; + if (kind === PluginSourceKind.Npm) { + const npm = descriptor as { package: string; version?: string; registry?: string }; + const packageSpec = npm.version ? `${npm.package}@${npm.version}` : npm.package; + args = ['npm', 'install', '--prefix', _installDir.fsPath, packageSpec]; + if (npm.registry) { + args.push('--registry', npm.registry); + } + } else { + const pip = descriptor as { package: string; version?: string; registry?: string }; + const packageSpec = pip.version ? `${pip.package}==${pip.version}` : pip.package; + args = ['pip', 'install', '--target', _installDir.fsPath, packageSpec]; + if (pip.registry) { + args.push('--index-url', pip.registry); + } + } + const command = args.join(' '); + state.terminalCommands.push(command); + + if (state.terminalExitCode !== 0) { + state.notifications.push({ severity: 3, message: `Plugin installation command failed: Command exited with code ${state.terminalExitCode}` }); + return undefined; + } + + // Check if plugin dir exists + const exists = typeof state.fileExistsResult === 'function' + ? await state.fileExistsResult(pluginDir) + : state.fileExistsResult; + if (!exists) { + const label = kind === PluginSourceKind.Npm ? 'npm' : 'pip'; + const pkg = (descriptor as { package: string }).package; + state.notifications.push({ severity: 3, message: `${label} package '${pkg}' was not found after installation.` }); + return undefined; + } + + return { pluginDir }; + }, + }); + + const mockSourceRepos = new Map([ + [PluginSourceKind.RelativePath, { kind: PluginSourceKind.RelativePath, getCleanupTarget: () => undefined, getInstallUri: () => { throw new Error(); }, ensure: async () => { throw new Error(); }, update: async () => { throw new Error(); }, getLabel: (d) => (d as { path: string }).path || '.' }], + [PluginSourceKind.GitHub, { kind: PluginSourceKind.GitHub, getCleanupTarget: () => URI.file('/mock'), getInstallUri: () => URI.file('/mock'), ensure: async () => URI.file('/mock'), update: async () => true, getLabel: (d) => (d as { repo: string }).repo }], + [PluginSourceKind.GitUrl, { kind: PluginSourceKind.GitUrl, getCleanupTarget: () => URI.file('/mock'), getInstallUri: () => URI.file('/mock'), ensure: async () => URI.file('/mock'), update: async () => true, getLabel: (d) => (d as { url: string }).url }], + [PluginSourceKind.Npm, makeMockPackageRepo(PluginSourceKind.Npm)], + [PluginSourceKind.Pip, makeMockPackageRepo(PluginSourceKind.Pip)], + ]); + + instantiationService.stub(IAgentPluginRepositoryService, { + getPluginInstallUri: (plugin: IMarketplacePlugin) => { + return URI.joinPath(state.ensureRepositoryResult, plugin.source); + }, + getRepositoryUri: () => state.ensureRepositoryResult, + ensureRepository: async (_marketplace: IMarketplaceReference, _options?: IEnsureRepositoryOptions) => { + return state.ensureRepositoryResult; + }, + pullRepository: async (marketplace: IMarketplaceReference, options?: IPullRepositoryOptions) => { + state.pullRepositoryCalls.push({ marketplace, options }); + }, + getPluginSourceInstallUri: (descriptor: IPluginSourceDescriptor) => { + const key = descriptor.kind; + return state.pluginSourceInstallUris.get(key) ?? URI.file(`/cache/agentPlugins/${key}/default`); + }, + ensurePluginSource: async () => state.ensurePluginSourceResult, + updatePluginSource: async (plugin: IMarketplacePlugin, options?: IPullRepositoryOptions) => { + state.updatePluginSourceCalls.push({ plugin, options }); + }, + getPluginSource: (kind: PluginSourceKind) => mockSourceRepos.get(kind)!, + cleanupPluginSource: async () => { }, + } as unknown as IAgentPluginRepositoryService); + + // IPluginMarketplaceService + instantiationService.stub(IPluginMarketplaceService, { + addInstalledPlugin: (uri: URI, plugin: IMarketplacePlugin) => { + state.addedPlugins.push({ uri: uri.toString(), plugin }); + }, + isMarketplaceTrusted: () => state.marketplaceTrusted, + trustMarketplace: (ref: IMarketplaceReference) => { + state.trustedMarketplaces.push(ref.canonicalId); + }, + } as unknown as IPluginMarketplaceService); + + const service = instantiationService.createInstance(PluginInstallService); + return { service, state }; + } + + // ========================================================================= + // getPluginInstallUri + // ========================================================================= + + suite('getPluginInstallUri', () => { + + test('delegates to getPluginInstallUri for relative-path plugins', () => { + const { service } = createService(); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/microsoft/vscode/plugins/myPlugin'); + }); + + test('delegates to getPluginSourceInstallUri for npm plugins', () => { + const npmUri = URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg'); + const { service } = createService({ + pluginSourceInstallUris: new Map([['npm', npmUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, npmUri.path); + }); + + test('delegates to getPluginSourceInstallUri for pip plugins', () => { + const pipUri = URI.file('/cache/agentPlugins/pip/my-pkg'); + const { service } = createService({ + pluginSourceInstallUris: new Map([['pip', pipUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, pipUri.path); + }); + + test('delegates to getPluginSourceInstallUri for github plugins', () => { + const ghUri = URI.file('/cache/agentPlugins/github.com/owner/repo'); + const { service } = createService({ + pluginSourceInstallUris: new Map([['github', ghUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, ghUri.path); + }); + }); + + // ========================================================================= + // installPlugin — relative path + // ========================================================================= + + suite('installPlugin — relative path', () => { + + test('installs a relative-path plugin when directory exists', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 1); + assert.ok(state.addedPlugins[0].uri.includes('plugins/myPlugin')); + assert.strictEqual(state.notifications.length, 0); + }); + + test('notifies error when plugin directory does not exist', async () => { + const { service, state } = createService({ fileExistsResult: false }); + const plugin = createPlugin({ + source: 'plugins/missing', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/missing' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + + test('does not install when ensureRepository throws', async () => { + const { state } = createService(); + // Override ensureRepository to throw + const instantiationService = store.add(new TestInstantiationService()); + const repoService = { + ensureRepository: async () => { throw new Error('clone failed'); }, + getPluginInstallUri: () => URI.file('/x'), + getPluginSourceInstallUri: () => URI.file('/x'), + }; + instantiationService.stub(IAgentPluginRepositoryService, repoService as unknown as IAgentPluginRepositoryService); + instantiationService.stub(IFileService, { exists: async () => true } as unknown as IFileService); + instantiationService.stub(INotificationService, { notify: (n: { severity: number; message: string }) => { state.notifications.push(n); } } as unknown as INotificationService); + instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }) } as unknown as IDialogService); + instantiationService.stub(ITerminalService, {} as unknown as ITerminalService); + instantiationService.stub(IProgressService, { withProgress: async (_o: unknown, cb: () => Promise) => cb() } as unknown as IProgressService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IPluginMarketplaceService, { addInstalledPlugin: () => { } } as unknown as IPluginMarketplaceService); + instantiationService.stub(IPluginMarketplaceService, 'isMarketplaceTrusted', () => true); + instantiationService.stub(IPluginMarketplaceService, 'trustMarketplace', () => { }); + const svc = instantiationService.createInstance(PluginInstallService); + + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + await svc.installPlugin(plugin); + + // Should return without installing or crashing + assert.strictEqual(state.addedPlugins.length, 0); + }); + }); + + // ========================================================================= + // installPlugin — GitHub / GitUrl + // ========================================================================= + + suite('installPlugin — git sources', () => { + + test('installs a GitHub plugin when source exists after clone', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/github.com/owner/repo'), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('installs a GitUrl plugin when source exists after clone', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/example.com/repo'), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('notifies error when cloned directory does not exist', async () => { + const { service, state } = createService({ + fileExistsResult: false, + ensurePluginSourceResult: URI.file('/cache/agentPlugins/github.com/owner/repo'), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + }); + + // ========================================================================= + // installPlugin — npm + // ========================================================================= + + suite('installPlugin — npm', () => { + + test('runs npm install and registers plugin on success', async () => { + const npmInstallUri = URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg'); + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', npmInstallUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('npm')); + assert.ok(state.terminalCommands[0].includes('install')); + assert.ok(state.terminalCommands[0].includes('my-pkg')); + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('includes version in npm install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg', version: '1.2.3' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('my-pkg@1.2.3')); + }); + + test('includes registry in npm install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg', registry: 'https://custom.registry.com' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('--registry')); + assert.ok(state.terminalCommands[0].includes('https://custom.registry.com')); + }); + + test('does not install when user declines confirmation', async () => { + const { service, state } = createService({ dialogConfirmResult: false }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + }); + + test('notifies error when npm package directory not found after install', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + // exists returns true for ensurePluginSource but false for the final check + fileExistsResult: false, + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + + test('notifies error when terminal command fails with non-zero exit code', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + terminalExitCode: 1, + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('failed')); + }); + }); + + // ========================================================================= + // installPlugin — pip + // ========================================================================= + + suite('installPlugin — pip', () => { + + test('runs pip install and registers plugin on success', async () => { + const pipInstallUri = URI.file('/cache/agentPlugins/pip/my-pkg'); + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', pipInstallUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('pip')); + assert.ok(state.terminalCommands[0].includes('install')); + assert.ok(state.terminalCommands[0].includes('my-pkg')); + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('includes version with == syntax in pip install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg', version: '2.0.0' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('my-pkg==2.0.0')); + }); + + test('includes registry with --index-url in pip install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg', registry: 'https://pypi.custom.com/simple' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('--index-url')); + assert.ok(state.terminalCommands[0].includes('https://pypi.custom.com/simple')); + }); + + test('does not install when user declines confirmation', async () => { + const { service, state } = createService({ dialogConfirmResult: false }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + }); + + test('notifies error when pip package directory not found after install', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + fileExistsResult: false, + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + }); + + // ========================================================================= + // updatePlugin + // ========================================================================= + + suite('updatePlugin', () => { + + test('calls updatePluginSource for relative-path plugins', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.updatePluginSourceCalls.length, 1); + }); + + test('calls updatePluginSource for GitHub plugins', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.updatePluginSourceCalls.length, 1); + }); + + test('calls updatePluginSource for GitUrl plugins', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.updatePluginSourceCalls.length, 1); + }); + + test('re-installs for npm plugin updates', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.updatePlugin(plugin); + + // npm update goes through the same install flow + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('npm')); + }); + + test('does not report npm plugin as updated when install is declined', async () => { + const { service, state } = createService({ + dialogConfirmResult: false, + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + const updated = await service.updatePlugin(plugin); + + assert.strictEqual(updated, false); + assert.strictEqual(state.terminalCommands.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + }); + + test('re-installs for pip plugin updates', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('pip')); + }); + + test('does not report pip plugin as updated when install is declined', async () => { + const { service, state } = createService({ + dialogConfirmResult: false, + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + const updated = await service.updatePlugin(plugin); + + assert.strictEqual(updated, false); + assert.strictEqual(state.terminalCommands.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + }); + }); + + // ========================================================================= + // installPlugin — marketplace trust + // ========================================================================= + + suite('installPlugin — marketplace trust', () => { + + test('skips trust prompt when marketplace is already trusted', async () => { + const { service, state } = createService({ marketplaceTrusted: true }); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.trustedMarketplaces.length, 0, 'should not re-trust'); + }); + + test('shows trust prompt and installs when user confirms', async () => { + const { service, state } = createService({ marketplaceTrusted: false, dialogConfirmResult: true }); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.trustedMarketplaces.length, 1); + assert.strictEqual(state.addedPlugins.length, 1); + }); + + test('does not install when user declines trust', async () => { + const { service, state } = createService({ marketplaceTrusted: false, dialogConfirmResult: false }); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.trustedMarketplaces.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + }); + + test('trust prompt applies to all source kinds', async () => { + const { service, state } = createService({ marketplaceTrusted: false, dialogConfirmResult: false }); + + const kinds: IPluginSourceDescriptor[] = [ + { kind: PluginSourceKind.RelativePath, path: 'p' }, + { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + { kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }, + { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + ]; + + for (const sourceDescriptor of kinds) { + await service.installPlugin(createPlugin({ sourceDescriptor })); + } + + assert.strictEqual(state.addedPlugins.length, 0, 'no plugins should be installed when trust is declined'); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts index d75ce8adbb8..33cbbd85c1a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js'; +import { findHookCommandInYaml, findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js'; import { ITextEditorSelection } from '../../../../../../platform/editor/common/editor.js'; import { buildNewHookEntry, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js'; @@ -722,4 +722,232 @@ suite('hookUtils', () => { }); }); }); + + suite('findHookCommandInYaml', () => { + + test('finds unquoted command value', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: echo hello', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo hello'); + assert.deepStrictEqual(result, { + startLineNumber: 4, + startColumn: 16, + endLineNumber: 4, + endColumn: 26 + }); + }); + + test('finds double-quoted command value', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: "echo hello"', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo hello'); + }); + + test('finds single-quoted command value', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ` - command: 'echo hello'`, + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo hello'); + }); + + test('finds command without list prefix', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' command: run-lint', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'run-lint'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'run-lint'); + }); + + test('does not match substring of a longer command', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: echo hello-world', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when command is not found', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: echo hello', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo goodbye'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when no command lines exist', () => { + const content = [ + '---', + 'name: my-agent', + 'description: An agent', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined for empty content', () => { + const result = findHookCommandInYaml('', 'echo hello'); + assert.strictEqual(result, undefined); + }); + + test('finds first matching command when multiple exist', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: echo hello', + ' userPromptSubmit:', + ' - command: echo hello', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(result.startLineNumber, 4); + }); + + test('ignores lines that are not command fields', () => { + const content = [ + '---', + 'description: run command echo hello', + 'hooks:', + ' sessionStart:', + ' - command: echo hello', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(result.startLineNumber, 5); + }); + + test('handles command with special characters', () => { + const content = [ + '---', + 'hooks:', + ' preToolUse:', + ' - command: echo "foo" > /tmp/out.txt', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo "foo" > /tmp/out.txt'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo "foo" > /tmp/out.txt'); + }); + + test('matches command followed by trailing whitespace', () => { + const content = [ + '---', + 'hooks:', + ' sessionStart:', + ' - command: echo hello ', + '---', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo hello'); + }); + + test('finds short command that is a substring of the key name', () => { + const content = [ + 'hooks:', + ' Stop:', + ' - timeout: 10', + ' command: "a"', + ' type: command', + ].join('\n'); + const result = findHookCommandInYaml(content, 'a'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'a'); + assert.strictEqual(result.startLineNumber, 4); + }); + + test('finds short command in bash field that is a substring of the key name', () => { + const content = [ + 'hooks:', + ' sessionStart:', + ' - bash: "a"', + ' type: command', + ].join('\n'); + const result = findHookCommandInYaml(content, 'a'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'a'); + assert.strictEqual(result.startLineNumber, 3); + }); + + test('finds command in powershell field', () => { + const content = [ + 'hooks:', + ' sessionStart:', + ' - powershell: "echo hello"', + ' type: command', + ].join('\n'); + const result = findHookCommandInYaml(content, 'echo hello'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'echo hello'); + assert.strictEqual(result.startLineNumber, 3); + }); + + test('finds command in windows field', () => { + const content = [ + 'hooks:', + ' sessionStart:', + ' - windows: "dir"', + ' type: command', + ].join('\n'); + const result = findHookCommandInYaml(content, 'dir'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'dir'); + assert.strictEqual(result.startLineNumber, 3); + }); + + test('finds command in linux and osx fields', () => { + const content = [ + 'hooks:', + ' sessionStart:', + ' - linux: "ls"', + ' osx: "ls -G"', + ' type: command', + ].join('\n'); + const linuxResult = findHookCommandInYaml(content, 'ls'); + assert.ok(linuxResult); + assert.strictEqual(getSelectedText(content, linuxResult), 'ls'); + assert.strictEqual(linuxResult.startLineNumber, 3); + + const osxResult = findHookCommandInYaml(content, 'ls -G'); + assert.ok(osxResult); + assert.strictEqual(getSelectedText(content, osxResult), 'ls -G'); + assert.strictEqual(osxResult.startLineNumber, 4); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts index 8fdd981ea71..101e3235aea 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts @@ -19,12 +19,12 @@ import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../ import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IChatModeService } from '../../../../common/chatModes.js'; import { PromptHeaderAutocompletion } from '../../../../common/promptSyntax/languageProviders/promptHeaderAutocompletion.js'; -import { ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { getLanguageIdForPromptsType, PromptsType, Target } from '../../../../common/promptSyntax/promptTypes.js'; import { createTextModel } from '../../../../../../../editor/test/common/testTextModel.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; import { ITextModel } from '../../../../../../../editor/common/model.js'; -import { getLanguageIdForPromptsType, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { getPromptFileExtension } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; @@ -37,6 +37,7 @@ suite('PromptHeaderAutocompletion', () => { setup(async () => { const testConfigService = new TestConfigurationService(); testConfigService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); + testConfigService.setUserConfiguration('chat.useCustomAgentHooks', true); instaService = workbenchInstantiationService({ contextKeyService: () => disposables.add(new ContextKeyService(testConfigService)), configurationService: () => testConfigService @@ -143,6 +144,7 @@ suite('PromptHeaderAutocompletion', () => { { label: 'disable-model-invocation', result: 'disable-model-invocation: ${0:true}' }, { label: 'github', result: 'github: $0' }, { label: 'handoffs', result: 'handoffs: $0' }, + { label: 'hooks', result: 'hooks:\n ${1|SessionStart,SessionEnd,UserPromptSubmit,PreToolUse,PostToolUse,PreCompact,SubagentStart,SubagentStop,Stop,ErrorOccurred|}:\n - type: command\n command: "$2"' }, { label: 'model', result: 'model: ${0:MAE 4 (olama)}' }, { label: 'name', result: 'name: $0' }, { label: 'target', result: 'target: ${0:vscode}' }, @@ -390,6 +392,249 @@ suite('PromptHeaderAutocompletion', () => { const labels = actual.map(a => a.label); assert.ok(!labels.includes('BG Agent Model (copilot)'), 'Models with targetChatSessionType should be excluded from agent model array completions'); }); + + test('complete hooks value with New Hook snippet', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + assert.deepStrictEqual(actual, [ + { + label: 'New Hook', + result: 'hooks: \n ${1|SessionStart,SessionEnd,UserPromptSubmit,PreToolUse,PostToolUse,PreCompact,SubagentStart,SubagentStop,Stop,ErrorOccurred|}:\n - type: command\n command: "$2"' + }, + ]); + }); + + test('complete hooks value with New Hook snippet for vscode target', async () => { + const content = [ + '---', + 'description: "Test"', + 'target: vscode', + 'hooks: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + assert.deepStrictEqual(actual, [ + { + label: 'New Hook', + result: 'hooks: \n ${1|SessionStart,UserPromptSubmit,PreToolUse,PostToolUse,PreCompact,SubagentStart,SubagentStop,Stop|}:\n - type: command\n command: "$2"' + }, + ]); + }); + + test('complete hook event names inside hooks map', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: "echo hi"', + ' |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label).sort(); + // SessionStart should be excluded since it already exists + assert.ok(!labels.includes('SessionStart'), 'SessionStart should not be suggested when already present'); + assert.ok(labels.includes('SessionEnd'), 'SessionEnd should be suggested'); + assert.ok(labels.includes('PreToolUse'), 'PreToolUse should be suggested'); + assert.ok(labels.includes('Stop'), 'Stop should be suggested'); + }); + + test('complete hook event names for vscode target excludes existing hooks', async () => { + const content = [ + '---', + 'description: "Test"', + 'target: vscode', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: "echo hi"', + ' PreToolUse:', + ' - type: command', + ' command: "lint"', + ' |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label).sort(); + assert.ok(!labels.includes('SessionStart'), 'SessionStart should not be suggested when already present'); + assert.ok(!labels.includes('PreToolUse'), 'PreToolUse should not be suggested when already present'); + assert.ok(labels.includes('UserPromptSubmit'), 'UserPromptSubmit should be suggested'); + assert.ok(labels.includes('PostToolUse'), 'PostToolUse should be suggested'); + // SessionEnd is not available for vscode target + assert.ok(!labels.includes('SessionEnd'), 'SessionEnd should not be available for vscode target'); + }); + + test('complete hook event names on empty line before existing hooks', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' |', + ' SessionStart:', + ' - type: command', + ' command: "echo hi"', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label).sort(); + assert.ok(!labels.includes('SessionStart'), 'SessionStart should not be suggested when already present'); + assert.ok(labels.includes('SessionEnd'), 'SessionEnd should be suggested'); + assert.ok(labels.includes('PreToolUse'), 'PreToolUse should be suggested'); + }); + + test('complete hook event names while editing existing key name', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' S|:', + ' - type: command', + ' command: "echo hi"', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label).sort(); + assert.ok(labels.includes('SessionStart'), 'SessionStart should be suggested'); + assert.ok(labels.includes('SubagentStart'), 'SubagentStart should be suggested'); + assert.ok(labels.includes('Stop'), 'Stop should be suggested'); + // Verify insertText only replaces the key (no full snippet) + const sessionStartItem = actual.find(a => a.label === 'SessionStart'); + assert.ok(sessionStartItem); + assert.strictEqual(sessionStartItem.result, ' SessionStart:'); + }); + + test('hooks: cursor right after colon triggers New Hook snippet', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('New Hook'), 'New Hook snippet should be suggested'); + }); + + test('hooks: typing event name on next line triggers hook events', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' S|', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('SessionStart'), 'SessionStart should be suggested'); + assert.ok(labels.includes('SessionEnd'), 'SessionEnd should be suggested'); + assert.ok(labels.includes('Stop'), 'Stop should be suggested'); + }); + + test('typing field name in first command entry triggers command fields', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionEnd:', + ' - t|', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('type'), 'type should be suggested'); + assert.ok(labels.includes('command'), 'command should be suggested'); + assert.ok(labels.includes('timeout'), 'timeout should be suggested'); + }); + + test('typing field name after existing field triggers remaining command fields', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionEnd:', + ' - type: command', + ' c|', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('command'), 'command should be suggested'); + assert.ok(labels.includes('cwd'), 'cwd should be suggested'); + assert.ok(!labels.includes('type'), 'type should not be suggested when already present'); + }); + + test('typing event name after existing hook triggers hook events', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionEnd:', + ' - type: command', + ' command: echo "Session ended."', + ' U|', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('UserPromptSubmit'), 'UserPromptSubmit should be suggested'); + assert.ok(!labels.includes('SessionEnd'), 'SessionEnd should not be suggested when already present'); + }); + + test('typing event name between existing hooks triggers hook events', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionEnd:', + ' - type: command', + ' command: echo "Session ended."', + ' S|', + ' UserPromptSubmit:', + ' - type: command', + ' command: echo "User submitted."', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('SessionStart'), 'SessionStart should be suggested'); + assert.ok(labels.includes('Stop'), 'Stop should be suggested'); + assert.ok(!labels.includes('SessionEnd'), 'SessionEnd should not be suggested when already present'); + assert.ok(!labels.includes('UserPromptSubmit'), 'UserPromptSubmit should not be suggested when already present'); + }); + + test('cursor after hook event colon triggers New Command snippet', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionEnd: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + const labels = actual.map(a => a.label); + assert.ok(labels.includes('New Command'), 'New Command snippet should be suggested'); + assert.strictEqual(actual.length, 1, 'Only one suggestion should be returned'); + }); }); suite('claude agent header completions', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts index 6f2a25d8ce3..ab2a3b4067c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts @@ -18,14 +18,14 @@ import { ChatAgentLocation, ChatConfiguration } from '../../../../common/constan import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../../common/tools/languageModelToolsService.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; import { PromptHoverProvider } from '../../../../common/promptSyntax/languageProviders/promptHovers.js'; -import { IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { getLanguageIdForPromptsType, PromptsType, Target } from '../../../../common/promptSyntax/promptTypes.js'; import { MockChatModeService } from '../../../common/mockChatModeService.js'; import { createTextModel } from '../../../../../../../editor/test/common/testTextModel.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; import { ITextModel } from '../../../../../../../editor/common/model.js'; import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; -import { getLanguageIdForPromptsType, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { getPromptFileExtension } from '../../../../common/promptSyntax/config/promptFileLocations.js'; suite('PromptHoverProvider', () => { @@ -37,6 +37,7 @@ suite('PromptHoverProvider', () => { setup(async () => { const testConfigService = new TestConfigurationService(); testConfigService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); + testConfigService.setUserConfiguration('chat.useCustomAgentHooks', true); instaService = workbenchInstantiationService({ contextKeyService: () => disposables.add(new ContextKeyService(testConfigService)), configurationService: () => testConfigService diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index e3cc440de63..be2df9c0456 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -23,9 +23,9 @@ import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../ import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; import { getPromptFileExtension } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptValidator } from '../../../../common/promptSyntax/languageProviders/promptValidator.js'; -import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; +import { PromptsType, Target } from '../../../../common/promptSyntax/promptTypes.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; -import { ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockChatModeService } from '../../../common/mockChatModeService.js'; import { MockPromptsService } from '../../../common/promptSyntax/service/mockPromptsService.js'; @@ -41,6 +41,7 @@ suite('PromptValidator', () => { const testConfigService = new TestConfigurationService(); testConfigService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); + testConfigService.setUserConfiguration('chat.useCustomAgentHooks', true); instaService = workbenchInstantiationService({ contextKeyService: () => disposables.add(new ContextKeyService(testConfigService)), configurationService: () => testConfigService @@ -551,7 +552,7 @@ suite('PromptValidator', () => { assert.deepStrictEqual( markers.map(m => ({ severity: m.severity, message: m.message })), [ - { severity: MarkerSeverity.Warning, message: `Attribute 'applyTo' is not supported in VS Code agent files. Supported: agents, argument-hint, description, disable-model-invocation, github, handoffs, model, name, target, tools, user-invocable.` }, + { severity: MarkerSeverity.Warning, message: `Attribute 'applyTo' is not supported in VS Code agent files. Supported: agents, argument-hint, description, disable-model-invocation, github, handoffs, hooks, model, name, target, tools, user-invocable.` }, ] ); }); @@ -1416,6 +1417,358 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, `The 'disable-model-invocation' attribute must be 'true' or 'false'.`); } }); + + test('hooks - valid hook commands', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' PreToolUse:', + ' - type: command', + ' command: ./validate.sh', + ' cwd: scripts', + ' timeout: 30', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual(markers, []); + }); + + test('hooks - must be a map', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks: invalid', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'hooks' attribute must be a map of hook event types to command arrays.` }, + ] + ); + }); + + test('hooks - unknown hook event type', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' UnknownEvent:', + ' - type: command', + ' command: echo hello', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Warning, message: `Unknown hook event type 'UnknownEvent'. Supported: SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PostToolUse, PreCompact, SubagentStart, SubagentStop, Stop, ErrorOccurred.` }, + ] + ); + }); + + test('hooks - hook value must be array', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart: invalid', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `Hook event 'SessionStart' must have an array of command objects as its value.` }, + ] + ); + }); + + test('hooks - command item must be object', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - just a string', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `Each hook command must be an object.` }, + ] + ); + }); + + test('hooks - missing type property', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - command: echo hello', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `Hook command is missing required property 'type'.` }, + ] + ); + }); + + test('hooks - type must be command', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: script', + ' command: echo hello', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'type' property in a hook command must be 'command'.` }, + ] + ); + }); + + test('hooks - missing command field', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `Hook command must specify at least one of 'command', 'windows', 'linux', or 'osx'.` }, + ] + ); + }); + + test('hooks - empty command string', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: ""', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'command' property in a hook command must be a non-empty string.` }, + ] + ); + }); + + test('hooks - platform-specific commands are valid', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' windows: echo hello', + ' linux: echo hello', + ' osx: echo hello', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual(markers, []); + }); + + test('hooks - env must be a map with string values', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' env: invalid', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'env' property in a hook command must be a map of string values.` }, + ] + ); + }); + + test('hooks - valid env map', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' env:', + ' NODE_ENV: production', + ' DEBUG: "true"', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual(markers, []); + }); + + test('hooks - unknown property warns', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' unknownProp: value', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Warning, message: `Unknown property 'unknownProp' in hook command.` }, + ] + ); + }); + + test('hooks - timeout must be number', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' timeout: not-a-number', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'timeout' property in a hook command must be a number.` }, + ] + ); + }); + + test('hooks - cwd must be string', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: command', + ' command: echo hello', + ' cwd:', + ' - array', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'cwd' property in a hook command must be a string.` }, + ] + ); + }); + + test('hooks - multiple errors in one command', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' SessionStart:', + ' - type: script', + ' unknownProp: value', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'type' property in a hook command must be 'command'.` }, + { severity: MarkerSeverity.Warning, message: `Unknown property 'unknownProp' in hook command.` }, + { severity: MarkerSeverity.Error, message: `Hook command must specify at least one of 'command', 'windows', 'linux', or 'osx'.` }, + ] + ); + }); + + test('hooks - nested matcher format is valid', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' UserPromptSubmit:', + ' - hooks:', + ' - type: command', + ' command: "echo foo"', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual(markers, []); + }); + + test('hooks - nested matcher validates inner commands', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' PreToolUse:', + ' - matcher: Bash', + ' hooks:', + ' - type: script', + ' command: "echo foo"', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'type' property in a hook command must be 'command'.` }, + ] + ); + }); + + test('hooks - nested hooks must be array', async () => { + const content = [ + '---', + 'description: "Test"', + 'hooks:', + ' PreToolUse:', + ' - hooks: invalid', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'hooks' property in a matcher must be an array of command objects.` }, + ] + ); + }); }); suite('instructions', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/promptsDebugContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptsDebugContribution.test.ts index f5045a72ed8..b7a4694c4e5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptsDebugContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptsDebugContribution.test.ts @@ -71,8 +71,6 @@ suite('PromptsDebugContribution', () => { sourceFolders: [{ uri: URI.file('/workspace/.github/instructions'), storage: PromptsStorage.local, - exists: true, - fileCount: 1, }], }; @@ -97,7 +95,6 @@ suite('PromptsDebugContribution', () => { assert.strictEqual(resolved.files[0].name, 'test.instructions.md'); assert.strictEqual(resolved.files[0].status, 'loaded'); assert.strictEqual(resolved.sourceFolders?.length, 1); - assert.strictEqual(resolved.sourceFolders?.[0].exists, true); } }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index f8ee29ce85f..4689f30c00a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -24,7 +24,7 @@ import { workbenchInstantiationService } from '../../../../../test/browser/workb import { LanguageModelToolsService } from '../../../browser/tools/languageModelToolsService.js'; import { ChatModel, IChatModel } from '../../../common/model/chatModel.js'; import { IChatService, IChatToolInputInvocationData, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; -import { ChatConfiguration } from '../../../common/constants.js'; +import { ChatConfiguration, ChatPermissionLevel } from '../../../common/constants.js'; import { SpecedToolAliases, isToolResultInputOutputDetails, IToolData, IToolImpl, IToolInvocation, ToolDataSource, ToolSet, IToolResultTextPart } from '../../../common/tools/languageModelToolsService.js'; import { MockChatService } from '../../common/chatService/mockChatService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -83,13 +83,13 @@ function registerToolForTest(service: LanguageModelToolsService, store: any, id: }; } -function stubGetSession(chatService: MockChatService, sessionId: string, options?: { requestId?: string; capture?: { invocation?: any } }): IChatModel { +function stubGetSession(chatService: MockChatService, sessionId: string, options?: { requestId?: string; capture?: { invocation?: any }; modeInfo?: { permissionLevel?: ChatPermissionLevel } }): IChatModel { const requestId = options?.requestId ?? 'requestId'; const capture = options?.capture; const fakeModel = { sessionId, sessionResource: LocalChatSessionUri.forSession(sessionId), - getRequests: () => [{ id: requestId, modelId: 'test-model' }], + getRequests: () => [{ id: requestId, modelId: 'test-model', modeInfo: options?.modeInfo }], } as ChatModel; chatService.addSession(fakeModel); chatService.appendProgress = (request, progress) => { @@ -592,6 +592,48 @@ suite('LanguageModelToolsService', () => { await promise; }); + test('skipping modified-files confirmation returns the shared skip message and does not invoke the tool', async () => { + let invoked = false; + const tool = registerToolForTest(service, store, 'testModifiedFilesConfirmationSkip', { + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Confirm', + message: 'Choose', + allowAutoConfirm: false, + }, + toolSpecificData: { + kind: 'modifiedFilesConfirmation', + options: ['Copy Changes', 'Move Changes'], + modifiedFiles: [{ + uri: URI.parse('file:///workspace/file1.ts') + }] + } + }), + invoke: async () => { + invoked = true; + return { content: [{ kind: 'text', value: 'should not run' }] }; + }, + }); + + const sessionId = 'sessionId-modified-files-skip'; + const capture: { invocation?: any } = {}; + stubGetSession(chatService, sessionId, { requestId: 'requestId-modified-files-skip', capture }); + + const dto = tool.makeDto({ x: 1 }, { sessionId }); + const promise = service.invokeTool(dto, async () => 0, CancellationToken.None); + const published = await waitForPublishedInvocation(capture); + assert.ok(published, 'expected ChatToolInvocation to be published'); + + IChatToolInvocation.confirmWith(published, { type: ToolConfirmKind.Skipped }); + const result = await promise; + + assert.strictEqual(invoked, false); + assert.deepStrictEqual(result.content, [{ + kind: 'text', + value: 'The user chose to skip the tool call, they want to proceed without running it' + }]); + }); + test('cancel tool call', async () => { const toolBarrier = new Barrier(); const tool = registerToolForTest(service, store, 'testTool', { @@ -1453,6 +1495,144 @@ suite('LanguageModelToolsService', () => { assert.strictEqual(testAccessibilitySignalService.signalPlayedCalls.length, 0, 'accessibility signal should not be played when auto-approve is enabled'); }); + test('autopilot permission level bypasses global auto-approve check', async () => { + // When autopilot is on, tools should auto-approve without needing global auto-approve enabled + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', false); // Global OFF + } + }); + + const tool = registerToolForTest(testService, store, 'autopilotTool', { + prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Confirm?', message: 'Should be auto-approved by autopilot' } }), + invoke: async () => ({ content: [{ kind: 'text', value: 'autopilot approved' }] }) + }); + + const sessionId = 'test-autopilot'; + stubGetSession(testChatService, sessionId, { + requestId: 'req1', + modeInfo: { permissionLevel: ChatPermissionLevel.Autopilot }, + }); + + // Tool should be auto-approved even though global auto-approve is off + const result = await testService.invokeTool( + tool.makeDto({ test: 1 }, { sessionId }), + async () => 0, + CancellationToken.None + ); + assert.strictEqual(result.content[0].value, 'autopilot approved'); + }); + + test('autopilot finds correct request by chatRequestId', async () => { + // When chatRequestId is provided, the exact request should be matched + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', false); + } + }); + + const tool = registerToolForTest(testService, store, 'autopilotIdTool', { + prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Confirm?', message: 'Test' } }), + invoke: async () => ({ content: [{ kind: 'text', value: 'found by id' }] }) + }); + + const sessionId = 'test-autopilot-id'; + const fakeModel = { + sessionId, + sessionResource: LocalChatSessionUri.forSession(sessionId), + getRequests: () => [ + { id: 'req-old', modelId: 'test-model', modeInfo: undefined }, + { id: 'req-autopilot', modelId: 'test-model', modeInfo: { permissionLevel: ChatPermissionLevel.Autopilot } }, + ], + } as ChatModel; + testChatService.addSession(fakeModel); + + const dto = tool.makeDto({ test: 1 }, { sessionId }); + dto.chatRequestId = 'req-autopilot'; + + const result = await testService.invokeTool(dto, async () => 0, CancellationToken.None); + assert.strictEqual(result.content[0].value, 'found by id'); + }); + + test('autopilot auto-approves terminal tool with confirmation messages', async () => { + // Terminal tools always return confirmationMessages when their own auto-approve is off. + // In autopilot mode, shouldAutoConfirm should still auto-approve the tool. + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', false); + } + }); + + const tool = registerToolForTest(testService, store, 'terminalTool', { + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Run shell command?', + message: 'echo hello', + }, + toolSpecificData: { + kind: 'terminal' as const, + terminalToolSessionId: 'test', + terminalCommandId: 'cmd-1', + commandLine: { original: 'echo hello' }, + language: 'sh', + }, + }), + invoke: async () => ({ content: [{ kind: 'text', value: 'terminal executed' }] }) + }); + + const sessionId = 'test-autopilot-terminal'; + stubGetSession(testChatService, sessionId, { + requestId: 'req1', + modeInfo: { permissionLevel: ChatPermissionLevel.Autopilot }, + }); + + // Terminal tool should be auto-approved by autopilot even without terminal auto-approve enabled + const result = await testService.invokeTool( + tool.makeDto({ command: 'echo hello', explanation: 'test', goal: 'test', isBackground: false }, { sessionId }), + async () => 0, + CancellationToken.None + ); + assert.strictEqual(result.content[0].value, 'terminal executed'); + }); + + test('bypass approvals auto-approves terminal tool with confirmation messages', async () => { + const { service: testService, chatService: testChatService } = createTestToolsService(store, { + configureServices: config => { + config.setUserConfiguration('chat.tools.global.autoApprove', false); + } + }); + + const tool = registerToolForTest(testService, store, 'terminalToolBypass', { + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Run shell command?', + message: 'ls -la', + }, + toolSpecificData: { + kind: 'terminal' as const, + terminalToolSessionId: 'test', + terminalCommandId: 'cmd-2', + commandLine: { original: 'ls -la' }, + language: 'sh', + }, + }), + invoke: async () => ({ content: [{ kind: 'text', value: 'bypass executed' }] }) + }); + + const sessionId = 'test-bypass-terminal'; + stubGetSession(testChatService, sessionId, { + requestId: 'req1', + modeInfo: { permissionLevel: ChatPermissionLevel.AutoApprove }, + }); + + const result = await testService.invokeTool( + tool.makeDto({ command: 'ls -la', explanation: 'test', goal: 'test', isBackground: false }, { sessionId }), + async () => 0, + CancellationToken.None + ); + assert.strictEqual(result.content[0].value, 'bypass executed'); + }); + test('shouldAutoConfirm with basic configuration', async () => { // Test basic shouldAutoConfirm behavior with simple configuration const { service: testService, chatService: testChatService } = createTestToolsService(store, { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts index 10045c7dce3..7bc6a6c0412 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatQuestionCarouselPart.test.ts @@ -9,7 +9,7 @@ import { MarkdownString } from '../../../../../../../base/common/htmlContent.js' import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../../../../browser/widget/chatContentParts/chatQuestionCarouselPart.js'; -import { IChatQuestionCarousel } from '../../../../common/chatService/chatService.js'; +import { IChatQuestionAnswerValue, IChatQuestionCarousel } from '../../../../common/chatService/chatService.js'; import { IChatContentPartRenderContext } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { ChatQuestionCarouselData } from '../../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; @@ -29,7 +29,7 @@ suite('ChatQuestionCarouselPart', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let widget: ChatQuestionCarouselPart; - let submittedAnswers: Map | undefined | null = null; + let submittedAnswers: Map | undefined | null = null; function createWidget(carousel: IChatQuestionCarousel): ChatQuestionCarouselPart { const instantiationService = workbenchInstantiationService(undefined, store); @@ -60,7 +60,6 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(widget.domNode.classList.contains('chat-question-carousel-container')); assert.ok(widget.domNode.querySelector('.chat-question-header-row')); assert.ok(widget.domNode.querySelector('.chat-question-carousel-content')); - assert.ok(widget.domNode.querySelector('.chat-question-carousel-nav')); }); test('renders question title', () => { @@ -100,12 +99,7 @@ suite('ChatQuestionCarouselPart', () => { const title = widget.domNode.querySelector('.chat-question-title'); assert.ok(title, 'title element should exist'); - const messageEl = widget.domNode.querySelector('.chat-question-message'); - assert.ok(messageEl, 'message element should exist'); - assert.ok(messageEl?.querySelector('.rendered-markdown'), 'markdown content should be rendered'); - assert.strictEqual(messageEl?.textContent?.includes('**details**'), false, 'markdown syntax should not be shown as raw text'); - const link = messageEl?.querySelector('a') as HTMLAnchorElement | null; - assert.ok(link, 'markdown link should render as anchor'); + assert.ok(title?.querySelector('.rendered-markdown'), 'markdown content should be rendered'); }); test('renders plain string question message as text', () => { @@ -119,10 +113,9 @@ suite('ChatQuestionCarouselPart', () => { ]); createWidget(carousel); - const messageEl = widget.domNode.querySelector('.chat-question-message'); - assert.ok(messageEl, 'message element should exist'); - assert.ok(messageEl?.textContent?.includes('details'), 'plain text content should be rendered'); - assert.strictEqual(messageEl?.querySelector('.rendered-markdown'), null, 'plain string message should not use markdown renderer'); + const title = widget.domNode.querySelector('.chat-question-title'); + assert.ok(title, 'title element should exist'); + assert.ok(title?.textContent?.includes('details'), 'content should be rendered'); }); test('renders progress indicator correctly', () => { @@ -139,6 +132,23 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(stepIndicator?.textContent?.includes('1')); assert.ok(stepIndicator?.textContent?.includes('3')); }); + + test('renders close button in title row for multi-question carousels', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + createWidget(carousel); + + const titleRow = widget.domNode.querySelector('.chat-question-title-row'); + assert.ok(titleRow, 'title row should exist'); + + const closeContainer = titleRow?.querySelector('.chat-question-close-container'); + assert.ok(closeContainer, 'close button container should be rendered in the title row'); + + const directChildCloseContainer = widget.domNode.querySelector(':scope > .chat-question-close-container'); + assert.strictEqual(directChildCloseContainer, null, 'close button container should not be positioned as a direct child of the carousel container'); + }); }); suite('Question Types', () => { @@ -193,7 +203,7 @@ suite('ChatQuestionCarouselPart', () => { assert.strictEqual(checkboxes.length, 3, 'Should have 3 checkboxes'); }); - test('freeform textarea is always rendered for singleSelect', () => { + test('freeform textarea is rendered for singleSelect by default', () => { const carousel = createMockCarousel([ { id: 'q1', @@ -207,10 +217,10 @@ suite('ChatQuestionCarouselPart', () => { createWidget(carousel); const freeformTextarea = widget.domNode.querySelector('.chat-question-freeform-textarea'); - assert.ok(freeformTextarea, 'Freeform textarea should always be rendered for singleSelect'); + assert.ok(freeformTextarea, 'Freeform textarea should be rendered by default for singleSelect'); }); - test('freeform textarea is always rendered for multiSelect', () => { + test('freeform textarea is rendered for multiSelect by default', () => { const carousel = createMockCarousel([ { id: 'q1', @@ -224,7 +234,45 @@ suite('ChatQuestionCarouselPart', () => { createWidget(carousel); const freeformTextarea = widget.domNode.querySelector('.chat-question-freeform-textarea'); - assert.ok(freeformTextarea, 'Freeform textarea should always be rendered for multiSelect'); + assert.ok(freeformTextarea, 'Freeform textarea should be rendered by default for multiSelect'); + }); + + test('freeform textarea is hidden when allowFreeformInput is false for singleSelect', () => { + const carousel = createMockCarousel([ + { + id: 'q1', + type: 'singleSelect', + title: 'Choose one', + allowFreeformInput: false, + options: [ + { id: 'a', label: 'Option A', value: 'a' }, + { id: 'b', label: 'Option B', value: 'b' } + ] + } + ]); + createWidget(carousel); + + const freeformTextarea = widget.domNode.querySelector('.chat-question-freeform-textarea'); + assert.strictEqual(freeformTextarea, null, 'Freeform textarea should not be rendered when allowFreeformInput is false'); + }); + + test('freeform textarea is hidden when allowFreeformInput is false for multiSelect', () => { + const carousel = createMockCarousel([ + { + id: 'q1', + type: 'multiSelect', + title: 'Choose multiple', + allowFreeformInput: false, + options: [ + { id: 'a', label: 'Option A', value: 'a' }, + { id: 'b', label: 'Option B', value: 'b' } + ] + } + ]); + createWidget(carousel); + + const freeformTextarea = widget.domNode.querySelector('.chat-question-freeform-textarea'); + assert.strictEqual(freeformTextarea, null, 'Freeform textarea should not be rendered when allowFreeformInput is false'); }); test('default options are pre-selected for singleSelect', () => { @@ -278,34 +326,40 @@ suite('ChatQuestionCarouselPart', () => { ]); createWidget(carousel); - // Use dedicated class selectors for stability - const prevButton = widget.domNode.querySelector('.chat-question-nav-prev') as HTMLButtonElement; + const navArrows = widget.domNode.querySelectorAll('.chat-question-nav-arrow') as NodeListOf; + const prevButton = navArrows[0]; assert.ok(prevButton, 'Previous button should exist'); assert.ok(prevButton.classList.contains('disabled') || prevButton.disabled, 'Previous button should be disabled on first question'); }); test('next button stays as arrow and is disabled on last question', () => { const carousel = createMockCarousel([ - { id: 'q1', type: 'text', title: 'Only Question' } + { id: 'q1', type: 'text', title: 'Only Question' }, + { id: 'q2', type: 'text', title: 'Question 2' } ]); createWidget(carousel); - // Use dedicated class selector for stability - const nextButton = widget.domNode.querySelector('.chat-question-nav-next') as HTMLButtonElement; + // Navigate to last question + widget.navigateToNextQuestion(); + + const navArrows = widget.domNode.querySelectorAll('.chat-question-nav-arrow') as NodeListOf; + const nextButton = navArrows[1]; assert.ok(nextButton, 'Next button should exist'); - assert.strictEqual(nextButton.getAttribute('aria-label'), 'Next', 'Next button should preserve Next aria-label on last question'); assert.ok(nextButton.classList.contains('disabled') || nextButton.disabled, 'Next button should be disabled on last question'); }); test('submit button is shown on last question', () => { const carousel = createMockCarousel([ - { id: 'q1', type: 'text', title: 'Only Question' } + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } ]); createWidget(carousel); + // Navigate to last question + widget.navigateToNextQuestion(); + const submitButton = widget.domNode.querySelector('.chat-question-submit-button') as HTMLButtonElement; assert.ok(submitButton, 'Submit button should exist'); - assert.strictEqual(submitButton.getAttribute('aria-label'), 'Submit'); assert.notStrictEqual(submitButton.style.display, 'none', 'Submit button should be visible on last question'); }); }); @@ -711,4 +765,163 @@ suite('ChatQuestionCarouselPart', () => { assert.ok(skippedMessage, 'Should show skipped message when no data'); }); }); + + suite('Description and Message', () => { + test('renders question description when provided', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Email', description: 'Enter your email address' } + ]); + createWidget(carousel); + + const desc = widget.domNode.querySelector('.chat-question-description'); + assert.ok(desc, 'Description element should be rendered'); + assert.strictEqual(desc?.textContent, 'Enter your email address'); + }); + + test('does not render description element when not provided', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Name' } + ]); + createWidget(carousel); + + const desc = widget.domNode.querySelector('.chat-question-description'); + assert.strictEqual(desc, null, 'Description element should not exist when not provided'); + }); + + test('renders carousel-level message on first question', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Name' }, + { id: 'q2', type: 'text', title: 'Email' } + ]); + carousel.message = 'Please fill in the following:'; + createWidget(carousel); + + const message = widget.domNode.querySelector('.chat-question-carousel-message'); + assert.ok(message, 'Carousel message should be rendered'); + assert.ok(message?.textContent?.includes('Please fill in the following:')); + }); + + test('renders carousel-level message as markdown', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Name' } + ]); + carousel.message = new MarkdownString('**Important:** Fill this form'); + createWidget(carousel); + + const message = widget.domNode.querySelector('.chat-question-carousel-message'); + assert.ok(message, 'Carousel message should be rendered'); + assert.ok(message?.querySelector('.rendered-markdown'), 'Message should be rendered as markdown'); + }); + + test('shows required indicator on required questions', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Name', required: true } + ]); + createWidget(carousel); + + const title = widget.domNode.querySelector('.chat-question-title'); + assert.ok(title?.textContent?.includes('*'), 'Required indicator (*) should be shown'); + }); + + test('does not show required indicator on optional questions', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Nickname' } + ]); + createWidget(carousel); + + const title = widget.domNode.querySelector('.chat-question-title'); + assert.ok(title?.textContent); + assert.ok(!title?.textContent?.includes('*'), 'Required indicator should not be shown'); + }); + }); + + suite('Validation', () => { + test('renders validation message element', () => { + const carousel = createMockCarousel([ + { + id: 'q1', + type: 'text', + title: 'Email', + validation: { format: 'email' } + } + ]); + createWidget(carousel); + + const validationMsg = widget.domNode.querySelector('.chat-question-validation-message') as HTMLElement | null; + assert.ok(validationMsg, 'Validation message element should exist'); + assert.strictEqual(validationMsg?.style.display, 'none', 'Validation message should be hidden initially'); + }); + + test('blocks submit on required empty text field', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Name', required: true } + ]); + createWidget(carousel); + + // Try to submit without entering a value + const submitButton = widget.domNode.querySelector('.chat-question-submit-button') as HTMLButtonElement; + assert.ok(submitButton, 'Submit button should exist'); + submitButton.click(); + + // Should show validation error and not submit + const validationMsg = widget.domNode.querySelector('.chat-question-validation-message'); + assert.ok(validationMsg?.textContent, 'Validation error should be shown'); + assert.strictEqual(submittedAnswers, null, 'Should not have submitted'); + }); + + test('next button is disabled when required text field is empty', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Name', required: true }, + { id: 'q2', type: 'text', title: 'Age' } + ]); + createWidget(carousel); + + // Next button should be disabled since required field has no answer + const nextButton = widget.domNode.querySelector('.chat-question-nav-next') as HTMLButtonElement; + assert.ok(nextButton, 'Next button should exist'); + assert.ok(nextButton.classList.contains('disabled'), 'Next button should be disabled when required field is empty'); + }); + + test('allows submit on required field with value', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Name', required: true } + ]); + createWidget(carousel); + + // Enter a value in the text input + const inputBox = widget.domNode.querySelector('.monaco-inputbox input') as HTMLInputElement; + assert.ok(inputBox, 'Input should exist'); + inputBox.value = 'John'; + inputBox.dispatchEvent(new Event('input', { bubbles: true })); + + // Submit + const submitButton = widget.domNode.querySelector('.chat-question-submit-button') as HTMLButtonElement; + submitButton.click(); + + assert.ok(submittedAnswers !== null, 'Should have submitted'); + }); + + test('validates required field across questions on submit', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Optional' }, + { id: 'q2', type: 'text', title: 'Required', required: true } + ]); + createWidget(carousel); + + // Navigate to q2 without filling q1 (optional, so allowed) + widget.navigateToNextQuestion(); + + // Go back to q1 and try to submit (q2 required but empty) + widget.navigateToPreviousQuestion(); + + // Cmd+Enter should check all required fields + const submitButton = widget.domNode.querySelector('.chat-question-submit-button') as HTMLButtonElement; + if (submitButton) { + submitButton.click(); + } + + // Should not submit because q2 is required but empty + assert.strictEqual(submittedAnswers, null, 'Should not submit when required field is empty'); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts index d74122c7f61..11be03e3f31 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -10,7 +10,6 @@ import { IStringDictionary } from '../../../../../../../base/common/collections. import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { ActionListItemKind, IActionListItem } from '../../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; -import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; import { StateType } from '../../../../../../../platform/update/common/update.js'; import { buildModelPickerItems, getModelPickerAccessibilityProvider } from '../../../../browser/widget/input/chatModelPicker.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, IModelControlEntry } from '../../../../common/languageModels.js'; @@ -48,13 +47,6 @@ function createAutoModel(): ILanguageModelChatMetadataAndIdentifier { return createModel('auto', 'Auto', 'copilot'); } -const stubCommandService: ICommandService = { - _serviceBrand: undefined, - onWillExecuteCommand: () => ({ dispose() { } }), - onDidExecuteCommand: () => ({ dispose() { } }), - executeCommand: () => Promise.resolve(undefined), -}; - function getActionItems(items: IActionListItem[]): IActionListItem[] { return items.filter(i => i.kind === ActionListItemKind.Action); } @@ -67,6 +59,16 @@ function getSeparatorCount(items: IActionListItem[] return items.filter(i => i.kind === ActionListItemKind.Separator).length; } +const stubManageModelsAction: IActionWidgetDropdownAction = { + id: 'manageModels', + enabled: true, + checked: false, + class: undefined, + tooltip: 'Manage Language Models', + label: 'Manage Models...', + run: () => { } +}; + function callBuild( models: ILanguageModelChatMetadataAndIdentifier[], opts: { @@ -95,7 +97,7 @@ function callBuild( onSelect, opts.manageSettingsUrl, true, - stubCommandService, + stubManageModelsAction, entitlementService, ); } @@ -470,7 +472,7 @@ suite('buildModelPickerItems', () => { onSelect, undefined, true, - stubCommandService, + undefined, stubChatEntitlementService, ); const gptItem = getActionItems(items).find(a => a.label === 'GPT-4o'); @@ -552,7 +554,7 @@ suite('buildModelPickerItems', () => { () => { }, 'https://aka.ms/github-copilot-settings', true, - stubCommandService, + undefined, stubChatEntitlementService, ); @@ -635,7 +637,7 @@ suite('buildModelPickerItems', () => { onSelect, undefined, true, - stubCommandService, + undefined, anonymousEntitlementService, ); const gptItem = getActionItems(items).find(a => a.label === 'GPT-4o'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts new file mode 100644 index 00000000000..581f0db3fb3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts @@ -0,0 +1,1548 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../../common/constants.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../../../common/languageModels.js'; +import { + filterModelsForSession, + findDefaultModel, + hasModelsTargetingSession, + isModelSupportedForInlineChat, + isModelSupportedForMode, + isModelValidForSession, + mergeModelsWithCache, + resolveModelFromSyncState, + shouldResetModelToDefault, + shouldResetOnModelListChange, + shouldRestoreLateArrivingModel, + shouldRestorePersistedModel, +} from '../../../../browser/widget/input/chatModelSelectionLogic.js'; + +/** + * Test helper that composes the full startup pipeline: merge live+cache → sort → filter by session/mode. + * This mirrors what `chatInputPart.getModels()` does, but without the storage side effects. + */ +function computeAvailableModels( + liveModels: ILanguageModelChatMetadataAndIdentifier[], + cachedModels: ILanguageModelChatMetadataAndIdentifier[], + contributedVendors: Set, + sessionType: string | undefined, + currentModeKind: ChatModeKind, + location: ChatAgentLocation, + isInlineChatV2Enabled: boolean, +): ILanguageModelChatMetadataAndIdentifier[] { + const merged = mergeModelsWithCache(liveModels, cachedModels, contributedVendors); + merged.sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + return filterModelsForSession(merged, sessionType, currentModeKind, location, isInlineChatV2Enabled); +} + +function createModel( + id: string, + name: string, + overrides?: Partial, +): ILanguageModelChatMetadataAndIdentifier { + return { + identifier: `copilot/${id}`, + metadata: { + extension: new ExtensionIdentifier('test.ext'), + id, + name, + vendor: 'copilot', + version: '1.0', + family: 'copilot', + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: {}, + isUserSelectable: true, + modelPickerCategory: undefined, + capabilities: { toolCalling: true, agentMode: true }, + ...overrides, + } as ILanguageModelChatMetadata, + }; +} + +function createDefaultModelForLocation( + id: string, + name: string, + location: ChatAgentLocation, + overrides?: Partial, +): ILanguageModelChatMetadataAndIdentifier { + return createModel(id, name, { + isDefaultForLocation: { [location]: true }, + ...overrides, + }); +} + +function createSessionModel( + id: string, + name: string, + sessionType: string, + overrides?: Partial, +): ILanguageModelChatMetadataAndIdentifier { + return createModel(id, name, { + targetChatSessionType: sessionType, + ...overrides, + }); +} + +suite('ChatModelSelectionLogic', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('isModelSupportedForMode', () => { + + test('any model is supported in Ask mode', () => { + const model = createModel('basic', 'Basic', { capabilities: undefined }); + assert.strictEqual(isModelSupportedForMode(model, ChatModeKind.Ask), true); + }); + + test('any model is supported in Edit mode', () => { + const model = createModel('basic', 'Basic', { capabilities: undefined }); + assert.strictEqual(isModelSupportedForMode(model, ChatModeKind.Edit), true); + }); + + test('model with tool calling and agent mode is supported in Agent mode', () => { + const model = createModel('agent-capable', 'Agent-Capable', { + capabilities: { toolCalling: true, agentMode: true }, + }); + assert.strictEqual(isModelSupportedForMode(model, ChatModeKind.Agent), true); + }); + + test('model with tool calling but agentMode=undefined is supported in Agent mode', () => { + const model = createModel('tool-only', 'Tool-Only', { + capabilities: { toolCalling: true }, + }); + assert.strictEqual(isModelSupportedForMode(model, ChatModeKind.Agent), true); + }); + + test('model without tool calling is NOT supported in Agent mode', () => { + const model = createModel('no-tools', 'No-Tools', { + capabilities: { toolCalling: false }, + }); + assert.strictEqual(isModelSupportedForMode(model, ChatModeKind.Agent), false); + }); + + test('model with agentMode=false is NOT supported in Agent mode', () => { + const model = createModel('no-agent', 'No-Agent', { + capabilities: { toolCalling: true, agentMode: false }, + }); + assert.strictEqual(isModelSupportedForMode(model, ChatModeKind.Agent), false); + }); + + test('model with no capabilities is NOT supported in Agent mode', () => { + const model = createModel('no-caps', 'No-Caps', { capabilities: undefined }); + assert.strictEqual(isModelSupportedForMode(model, ChatModeKind.Agent), false); + }); + }); + + suite('isModelSupportedForInlineChat', () => { + + test('any model is supported when not in EditorInline location', () => { + const model = createModel('basic', 'Basic', { capabilities: undefined }); + assert.strictEqual(isModelSupportedForInlineChat(model, ChatAgentLocation.Chat, true), true); + assert.strictEqual(isModelSupportedForInlineChat(model, ChatAgentLocation.Terminal, true), true); + assert.strictEqual(isModelSupportedForInlineChat(model, ChatAgentLocation.Notebook, true), true); + }); + + test('any model is supported in EditorInline when V2 is disabled', () => { + const model = createModel('basic', 'Basic', { capabilities: undefined }); + assert.strictEqual(isModelSupportedForInlineChat(model, ChatAgentLocation.EditorInline, false), true); + }); + + test('model with tool calling is supported in EditorInline with V2', () => { + const model = createModel('tools', 'Tools', { + capabilities: { toolCalling: true }, + }); + assert.strictEqual(isModelSupportedForInlineChat(model, ChatAgentLocation.EditorInline, true), true); + }); + + test('model without tool calling is NOT supported in EditorInline with V2', () => { + const model = createModel('no-tools', 'No-Tools', { + capabilities: { toolCalling: false }, + }); + assert.strictEqual(isModelSupportedForInlineChat(model, ChatAgentLocation.EditorInline, true), false); + }); + + test('model with no capabilities is NOT supported in EditorInline with V2', () => { + const model = createModel('no-caps', 'No-Caps', { capabilities: undefined }); + assert.strictEqual(isModelSupportedForInlineChat(model, ChatAgentLocation.EditorInline, true), false); + }); + }); + + suite('filterModelsForSession', () => { + + const gpt4o = createModel('gpt-4o', 'GPT-4o'); + const claude = createModel('claude', 'Claude'); + const notSelectable = createModel('hidden', 'Hidden', { isUserSelectable: false }); + const cloudModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const noToolsModel = createModel('no-tools', 'No-Tools', { + capabilities: { toolCalling: false, agentMode: false }, + }); + + test('returns user-selectable general models when no session type set', () => { + const result = filterModelsForSession( + [gpt4o, claude, notSelectable], + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['gpt-4o', 'claude']); + }); + + test('returns user-selectable general models for local session type', () => { + const result = filterModelsForSession( + [gpt4o, claude, notSelectable], + 'local', + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['gpt-4o', 'claude']); + }); + + test('excludes models targeting a specific session type when in general session', () => { + const result = filterModelsForSession( + [gpt4o, claude, cloudModel], + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['gpt-4o', 'claude']); + }); + + test('returns only session-targeted models for a specific session type', () => { + const result = filterModelsForSession( + [gpt4o, claude, cloudModel], + 'cloud', + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['cloud-gpt']); + }); + + test('filters out models incompatible with Agent mode in general session', () => { + const result = filterModelsForSession( + [gpt4o, noToolsModel], + undefined, + ChatModeKind.Agent, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['gpt-4o']); + }); + + test.skip('filters by mode for session-targeted models', () => { + const cloudNoTools = createSessionModel('cloud-basic', 'Cloud Basic', 'cloud', { + capabilities: { toolCalling: false, agentMode: false }, + }); + const result = filterModelsForSession( + [gpt4o, cloudModel, cloudNoTools], + 'cloud', + ChatModeKind.Agent, + ChatAgentLocation.Chat, + false, + ); + // Session-type filtering also checks mode and inline chat support + assert.deepStrictEqual(result.map(m => m.metadata.id), ['cloud-gpt']); + }); + + test('excludes non-selectable models from session-targeted results', () => { + const cloudHidden = createSessionModel('cloud-hidden', 'Cloud Hidden', 'cloud', { + isUserSelectable: false, + }); + const result = filterModelsForSession( + [cloudModel, cloudHidden], + 'cloud', + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['cloud-gpt']); + }); + + test('falls back to general models when no models target the session type', () => { + const result = filterModelsForSession( + [gpt4o, claude], + 'cloud', + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['gpt-4o', 'claude']); + }); + + test('filters inline chat incompatible models in EditorInline with V2', () => { + const noToolsSelectable = createModel('no-tools-selectable', 'No-Tools-Selectable', { + capabilities: { toolCalling: false }, + }); + const result = filterModelsForSession( + [gpt4o, noToolsSelectable], + undefined, + ChatModeKind.Ask, + ChatAgentLocation.EditorInline, + true, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['gpt-4o']); + }); + }); + + suite('hasModelsTargetingSession', () => { + + test('returns false when session type is undefined', () => { + const models = [createModel('gpt', 'GPT')]; + assert.strictEqual(hasModelsTargetingSession(models, undefined), false); + }); + + test('returns false when no models target the session type', () => { + const models = [createModel('gpt', 'GPT')]; + assert.strictEqual(hasModelsTargetingSession(models, 'cloud'), false); + }); + + test('returns true when a model targets the session type', () => { + const models = [ + createModel('gpt', 'GPT'), + createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'), + ]; + assert.strictEqual(hasModelsTargetingSession(models, 'cloud'), true); + }); + + test('returns false for different session type', () => { + const models = [createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud')]; + assert.strictEqual(hasModelsTargetingSession(models, 'enterprise'), false); + }); + }); + + suite('isModelValidForSession', () => { + + test('general model is valid when no models target the session', () => { + const generalModel = createModel('gpt', 'GPT'); + const allModels = [generalModel]; + assert.strictEqual(isModelValidForSession(generalModel, allModels, 'cloud'), true); + }); + + test('session-targeted model is NOT valid when no models target the session type in pool', () => { + const sessionModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const generalModel = createModel('gpt', 'GPT'); + assert.strictEqual(isModelValidForSession(sessionModel, [generalModel], undefined), false); + }); + + test('session-targeted model IS valid when pool has models targeting that session', () => { + const sessionModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const allModels = [createModel('gpt', 'GPT'), sessionModel]; + assert.strictEqual(isModelValidForSession(sessionModel, allModels, 'cloud'), true); + }); + + test('general model is NOT valid when pool has models targeting the session', () => { + const generalModel = createModel('gpt', 'GPT'); + const sessionModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const allModels = [generalModel, sessionModel]; + assert.strictEqual(isModelValidForSession(generalModel, allModels, 'cloud'), false); + }); + + test('model targeting wrong session is NOT valid', () => { + const wrongSessionModel = createSessionModel('ent-gpt', 'Enterprise GPT', 'enterprise'); + const cloudModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const allModels = [wrongSessionModel, cloudModel]; + assert.strictEqual(isModelValidForSession(wrongSessionModel, allModels, 'cloud'), false); + }); + + test('general model is valid when session type is undefined', () => { + const generalModel = createModel('gpt', 'GPT'); + assert.strictEqual(isModelValidForSession(generalModel, [generalModel], undefined), true); + }); + }); + + suite('findDefaultModel', () => { + + test('returns model marked as default for location', () => { + const regular = createModel('gpt', 'GPT'); + const defaultModel = createDefaultModelForLocation('claude', 'Claude', ChatAgentLocation.Chat); + const result = findDefaultModel([regular, defaultModel], ChatAgentLocation.Chat); + assert.strictEqual(result?.metadata.id, 'claude'); + }); + + test('falls back to first model when no default for location', () => { + const modelA = createModel('gpt', 'GPT'); + const modelB = createModel('claude', 'Claude'); + const result = findDefaultModel([modelA, modelB], ChatAgentLocation.Chat); + assert.strictEqual(result?.metadata.id, 'gpt'); + }); + + test('returns undefined for empty models array', () => { + const result = findDefaultModel([], ChatAgentLocation.Chat); + assert.strictEqual(result, undefined); + }); + + test('returns location-specific default when multiple defaults exist', () => { + const chatDefault = createDefaultModelForLocation('chat-default', 'Chat Default', ChatAgentLocation.Chat); + const terminalDefault = createDefaultModelForLocation('terminal-default', 'Terminal Default', ChatAgentLocation.Terminal); + const result = findDefaultModel([chatDefault, terminalDefault], ChatAgentLocation.Chat); + assert.strictEqual(result?.metadata.id, 'chat-default'); + }); + + test('does not pick terminal default when looking for chat default', () => { + const terminalDefault = createDefaultModelForLocation('terminal-default', 'Terminal Default', ChatAgentLocation.Terminal); + const regular = createModel('gpt', 'GPT'); + const result = findDefaultModel([terminalDefault, regular], ChatAgentLocation.Chat); + // Falls back to first model since none is default for Chat + assert.strictEqual(result?.metadata.id, 'terminal-default'); + }); + }); + + suite('shouldRestorePersistedModel', () => { + + test('restores model that was explicitly chosen (not default)', () => { + const model = createModel('gpt', 'GPT'); + const result = shouldRestorePersistedModel('copilot/gpt', false, [model], ChatAgentLocation.Chat); + assert.strictEqual(result.shouldRestore, true); + assert.strictEqual(result.model?.identifier, 'copilot/gpt'); + }); + + test('restores model that was default and is still default', () => { + const model = createDefaultModelForLocation('gpt', 'GPT', ChatAgentLocation.Chat); + const result = shouldRestorePersistedModel('copilot/gpt', true, [model], ChatAgentLocation.Chat); + assert.strictEqual(result.shouldRestore, true); + }); + + test('does NOT restore model that was default but is no longer default', () => { + const model = createModel('gpt', 'GPT'); + const result = shouldRestorePersistedModel('copilot/gpt', true, [model], ChatAgentLocation.Chat); + assert.strictEqual(result.shouldRestore, false); + assert.strictEqual(result.model?.identifier, 'copilot/gpt'); + }); + + test('does NOT restore model that no longer exists', () => { + const otherModel = createModel('claude', 'Claude'); + const result = shouldRestorePersistedModel('copilot/gpt', false, [otherModel], ChatAgentLocation.Chat); + assert.strictEqual(result.shouldRestore, false); + assert.strictEqual(result.model, undefined); + }); + + test('handles empty models list', () => { + const result = shouldRestorePersistedModel('copilot/gpt', false, [], ChatAgentLocation.Chat); + assert.strictEqual(result.shouldRestore, false); + assert.strictEqual(result.model, undefined); + }); + + test('user choice is preserved when default changes to a different model', () => { + // User explicitly chose GPT-4o, default used to be Claude, now default is something else + const gpt = createModel('gpt-4o', 'GPT-4o'); + const claude = createModel('claude', 'Claude'); + const result = shouldRestorePersistedModel('copilot/gpt-4o', false, [gpt, claude], ChatAgentLocation.Chat); + assert.strictEqual(result.shouldRestore, true); + assert.strictEqual(result.model?.metadata.id, 'gpt-4o'); + }); + + test('default tracking: follows new default when user never explicitly chose', () => { + // Old default was GPT-4o (persisted as default), now Claude is the default + const gpt = createModel('gpt-4o', 'GPT-4o'); + const claude = createDefaultModelForLocation('claude', 'Claude', ChatAgentLocation.Chat); + const result = shouldRestorePersistedModel('copilot/gpt-4o', true, [gpt, claude], ChatAgentLocation.Chat); + // Should NOT restore because GPT-4o is no longer default and was stored as default + assert.strictEqual(result.shouldRestore, false); + }); + }); + + suite('shouldResetModelToDefault', () => { + + const defaultContext = { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: false, + sessionType: undefined, + }; + + test('should reset when current model is undefined', () => { + assert.strictEqual(shouldResetModelToDefault(undefined, [], defaultContext, []), true); + }); + + test('should reset when model is no longer available', () => { + const model = createModel('gpt', 'GPT'); + assert.strictEqual(shouldResetModelToDefault(model, [], defaultContext, [model]), true); + }); + + test('should NOT reset when model is available and compatible', () => { + const model = createModel('gpt', 'GPT'); + assert.strictEqual(shouldResetModelToDefault(model, [model], defaultContext, [model]), false); + }); + + test('should reset when model is not supported for current mode', () => { + const model = createModel('no-tools', 'No-Tools', { + capabilities: { toolCalling: false, agentMode: false }, + }); + const context = { ...defaultContext, currentModeKind: ChatModeKind.Agent }; + assert.strictEqual(shouldResetModelToDefault(model, [model], context, [model]), true); + }); + + test('should reset when model is not supported for inline chat', () => { + const model = createModel('no-tools', 'No-Tools', { + capabilities: { toolCalling: false }, + }); + const context = { + ...defaultContext, + location: ChatAgentLocation.EditorInline, + isInlineChatV2Enabled: true, + }; + assert.strictEqual(shouldResetModelToDefault(model, [model], context, [model]), true); + }); + + test('should reset when model is not valid for session', () => { + const generalModel = createModel('gpt', 'GPT'); + const sessionModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const allModels = [generalModel, sessionModel]; + const context = { ...defaultContext, sessionType: 'cloud' }; + assert.strictEqual(shouldResetModelToDefault(generalModel, [generalModel], context, allModels), true); + }); + + test('should NOT reset session model in matching session', () => { + const sessionModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const context = { ...defaultContext, sessionType: 'cloud' }; + assert.strictEqual(shouldResetModelToDefault(sessionModel, [sessionModel], context, [sessionModel]), false); + }); + }); + + suite('resolveModelFromSyncState', () => { + + test('keeps current model when same as state model', () => { + const model = createModel('gpt', 'GPT'); + const result = resolveModelFromSyncState(model, model, [model], undefined); + assert.strictEqual(result.action, 'keep'); + }); + + test('applies state model when different and valid', () => { + const current = createModel('gpt', 'GPT'); + const stateModel = createModel('claude', 'Claude'); + const result = resolveModelFromSyncState(stateModel, current, [current, stateModel], undefined); + assert.strictEqual(result.action, 'apply'); + }); + + test('uses default when state model not valid for session', () => { + const current = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const stateModel = createModel('gpt', 'GPT'); // general model, not valid for cloud session + const allModels = [current, stateModel]; + const result = resolveModelFromSyncState(stateModel, current, allModels, 'cloud'); + assert.strictEqual(result.action, 'default'); + }); + + test('applies when current model is undefined', () => { + const stateModel = createModel('gpt', 'GPT'); + const result = resolveModelFromSyncState(stateModel, undefined, [stateModel], undefined); + assert.strictEqual(result.action, 'apply'); + }); + + test('applies session model when valid for matching session', () => { + const sessionModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const generalModel = createModel('gpt', 'GPT'); + const allModels = [generalModel, sessionModel]; + const result = resolveModelFromSyncState(sessionModel, generalModel, allModels, 'cloud'); + assert.strictEqual(result.action, 'apply'); + }); + + test('returns default when state model does not support current mode', () => { + const current = createModel('gpt', 'GPT'); + const stateModel = createModel('no-tools', 'No-Tools', { + capabilities: { toolCalling: false, agentMode: false }, + }); + const result = resolveModelFromSyncState(stateModel, current, [current, stateModel], undefined, { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Agent, + isInlineChatV2Enabled: false, + sessionType: undefined, + }); + assert.strictEqual(result.action, 'default'); + }); + + test('returns default when state model does not support inline chat V2', () => { + const current = createModel('gpt', 'GPT'); + const stateModel = createModel('no-tools', 'No-Tools', { + capabilities: { toolCalling: false }, + }); + const result = resolveModelFromSyncState(stateModel, current, [current, stateModel], undefined, { + location: ChatAgentLocation.EditorInline, + currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: true, + sessionType: undefined, + }); + assert.strictEqual(result.action, 'default'); + }); + + test('applies when state model supports current mode with context', () => { + const current = createModel('gpt', 'GPT'); + const stateModel = createModel('agent-model', 'Agent Model', { + capabilities: { toolCalling: true, agentMode: true }, + }); + const result = resolveModelFromSyncState(stateModel, current, [current, stateModel], undefined, { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Agent, + isInlineChatV2Enabled: false, + sessionType: undefined, + }); + assert.strictEqual(result.action, 'apply'); + }); + }); + + suite('mergeModelsWithCache', () => { + + test('uses live models when available', () => { + const liveModel = createModel('gpt', 'GPT'); + const cachedModel = createModel('cached-gpt', 'Cached GPT'); + const result = mergeModelsWithCache([liveModel], [cachedModel], new Set(['copilot'])); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].metadata.id, 'gpt'); + }); + + test('falls back to cached models when no live models', () => { + const cachedModel = createModel('cached-gpt', 'Cached GPT'); + const result = mergeModelsWithCache([], [cachedModel], new Set(['copilot'])); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].metadata.id, 'cached-gpt'); + }); + + test('merges cached models from vendors not yet resolved', () => { + const liveModel = createModel('gpt', 'GPT'); + const cachedOtherVendor = createModel('other-model', 'Other Model', { vendor: 'other-vendor' }); + const result = mergeModelsWithCache( + [liveModel], + [cachedOtherVendor], + new Set(['copilot', 'other-vendor']), + ); + assert.strictEqual(result.length, 2); + assert.deepStrictEqual(result.map(m => m.metadata.id).sort(), ['gpt', 'other-model']); + }); + + test('evicts cached models from vendors no longer contributed', () => { + const liveModel = createModel('gpt', 'GPT'); + const cachedRemovedVendor = createModel('removed-model', 'Removed Model', { vendor: 'removed-vendor' }); + const result = mergeModelsWithCache( + [liveModel], + [cachedRemovedVendor], + new Set(['copilot']), // removed-vendor is NOT contributed + ); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].metadata.id, 'gpt'); + }); + + test('does not duplicate models from same vendor', () => { + const liveModel = createModel('gpt', 'GPT'); + const cachedSameVendor = createModel('cached-gpt', 'Cached GPT'); + const result = mergeModelsWithCache( + [liveModel], + [cachedSameVendor], + new Set(['copilot']), + ); + // Both are vendor 'copilot', live vendor takes priority + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].metadata.id, 'gpt'); + }); + + test('handles empty cache and empty live models', () => { + const result = mergeModelsWithCache([], [], new Set()); + assert.deepStrictEqual(result, []); + }); + + test('handles multiple vendors with partial resolution', () => { + const liveA = createModel('a-model', 'A Model', { vendor: 'vendor-a' }); + const cachedB = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const cachedC = createModel('c-model', 'C Model', { vendor: 'vendor-c' }); + const result = mergeModelsWithCache( + [liveA], + [cachedB, cachedC], + new Set(['vendor-a', 'vendor-b']), // vendor-c not contributed + ); + assert.strictEqual(result.length, 2); + assert.deepStrictEqual(result.map(m => m.metadata.vendor).sort(), ['vendor-a', 'vendor-b']); + }); + }); + + suite('model switching scenarios', () => { + + test('switching from Ask to Agent mode should reset model without tool support', () => { + const noToolsModel = createModel('no-tools', 'No-Tools', { + capabilities: { toolCalling: false, agentMode: false }, + }); + const toolModel = createModel('tool-model', 'Tool Model'); + const allModels = [noToolsModel, toolModel]; + + // In Ask mode, model is fine + assert.strictEqual( + shouldResetModelToDefault(noToolsModel, allModels, { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: false, + sessionType: undefined, + }, allModels), + false, + ); + + // After switching to Agent mode, model should be reset + assert.strictEqual( + shouldResetModelToDefault(noToolsModel, allModels, { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Agent, + isInlineChatV2Enabled: false, + sessionType: undefined, + }, allModels), + true, + ); + }); + + test('switching sessions should reject model from wrong session pool', () => { + const cloudModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const generalModel = createModel('gpt', 'GPT'); + const allModels = [generalModel, cloudModel]; + + // Cloud model is valid in cloud session + assert.strictEqual( + isModelValidForSession(cloudModel, allModels, 'cloud'), + true, + ); + + // Cloud model is NOT valid in general session (no session type) + assert.strictEqual( + isModelValidForSession(cloudModel, allModels, undefined), + false, + ); + + // General model is NOT valid in cloud session (when cloud models exist) + assert.strictEqual( + isModelValidForSession(generalModel, allModels, 'cloud'), + false, + ); + + // General model IS valid in general session + assert.strictEqual( + isModelValidForSession(generalModel, allModels, undefined), + true, + ); + }); + + test('model removal should trigger reset', () => { + const gpt = createModel('gpt', 'GPT'); + const claude = createModel('claude', 'Claude'); + + // Initially both available, GPT is selected + assert.strictEqual( + shouldResetModelToDefault(gpt, [gpt, claude], { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: false, + sessionType: undefined, + }, [gpt, claude]), + false, + ); + + // GPT is removed from available models + assert.strictEqual( + shouldResetModelToDefault(gpt, [claude], { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: false, + sessionType: undefined, + }, [claude]), + true, + ); + }); + + test('syncing model from state respects session boundaries', () => { + const cloudModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const generalModel = createModel('gpt', 'GPT'); + const allModels = [generalModel, cloudModel]; + + // State has a cloud model, but we are in a general session + const result = resolveModelFromSyncState(cloudModel, generalModel, allModels, undefined); + assert.strictEqual(result.action, 'default'); + }); + + test('syncing model from state applies model when switching to matching session', () => { + const cloudModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const generalModel = createModel('gpt', 'GPT'); + const allModels = [generalModel, cloudModel]; + + // State has a cloud model and we are in a cloud session + const result = resolveModelFromSyncState(cloudModel, generalModel, allModels, 'cloud'); + assert.strictEqual(result.action, 'apply'); + }); + + test('persisted model selection survives when model is still default', () => { + const model = createDefaultModelForLocation('gpt-4o', 'GPT-4o', ChatAgentLocation.Chat); + const result = shouldRestorePersistedModel('copilot/gpt-4o', true, [model], ChatAgentLocation.Chat); + assert.strictEqual(result.shouldRestore, true); + }); + + test('persisted model selection does NOT restore when a new default is assigned', () => { + // GPT-4o was the old default (persisted as default=true), but it's no longer default + const gpt4o = createModel('gpt-4o', 'GPT-4o'); + const newDefault = createDefaultModelForLocation('claude', 'Claude', ChatAgentLocation.Chat); + const result = shouldRestorePersistedModel('copilot/gpt-4o', true, [gpt4o, newDefault], ChatAgentLocation.Chat); + assert.strictEqual(result.shouldRestore, false); + }); + + test('user explicit model choice persists even when default changes', () => { + // User explicitly picked Claude (persistedAsDefault=false), default was GPT-4o + // Now default switches to something else — Claude should still be restored + const claude = createModel('claude', 'Claude'); + const newDefault = createDefaultModelForLocation('new-model', 'New Model', ChatAgentLocation.Chat); + const result = shouldRestorePersistedModel('copilot/claude', false, [claude, newDefault], ChatAgentLocation.Chat); + assert.strictEqual(result.shouldRestore, true); + assert.strictEqual(result.model?.metadata.id, 'claude'); + }); + + test('combining mode switch + session switch validates correctly', () => { + const cloudToolModel = createSessionModel('cloud-tool', 'Cloud Tool', 'cloud', { + capabilities: { toolCalling: true, agentMode: true }, + }); + const cloudNoToolModel = createSessionModel('cloud-basic', 'Cloud Basic', 'cloud', { + capabilities: { toolCalling: false, agentMode: false }, + }); + const allCloudModels = [cloudToolModel, cloudNoToolModel]; + + // In cloud session, Agent mode — tool model is valid + assert.strictEqual( + shouldResetModelToDefault(cloudToolModel, allCloudModels, { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Agent, + isInlineChatV2Enabled: false, + sessionType: 'cloud', + }, allCloudModels), + false, + ); + + // The no-tool model should be reset in Agent mode + // Both filterModelsForSession and shouldResetModelToDefault enforce mode support + assert.strictEqual( + shouldResetModelToDefault(cloudNoToolModel, allCloudModels, { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Agent, + isInlineChatV2Enabled: false, + sessionType: 'cloud', + }, allCloudModels), + true, + ); + }); + }); + + suite('onDidChangeLanguageModels race conditions', () => { + + test('model temporarily removed then re-added loses user choice', () => { + const gpt = createModel('gpt', 'GPT'); + const claude = createModel('claude', 'Claude'); + + // Step 1: User has GPT selected, both models available + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', [gpt, claude]), false); + + // Step 2: Extension reloads, GPT temporarily disappears from model list + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', [claude]), true); + // → ChatInputPart resets to default (Claude) + + // Step 3: GPT comes back — but the handler just checks if current is still valid. + // By now the current is Claude (from step 2), so it stays. + assert.strictEqual(shouldResetOnModelListChange('copilot/claude', [gpt, claude]), false); + // → User's original GPT choice is lost! This is the "random switch" bug pattern. + }); + + test('model stays when model list refreshes with it still present', () => { + const gpt = createModel('gpt', 'GPT'); + const claude = createModel('claude', 'Claude'); + + // Model list refreshes but GPT is still there + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', [gpt, claude]), false); + }); + + test('reset when current model identifier is undefined', () => { + const gpt = createModel('gpt', 'GPT'); + assert.strictEqual(shouldResetOnModelListChange(undefined, [gpt]), true); + }); + + test('reset when models list is empty', () => { + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', []), true); + }); + + test('cache bridges the gap when live models temporarily unavailable', () => { + const cachedGpt = createModel('gpt', 'GPT'); + const cachedClaude = createModel('claude', 'Claude'); + + // Step 1: Extension unloaded, no live models. Cache fills the gap. + const merged = mergeModelsWithCache([], [cachedGpt, cachedClaude], new Set(['copilot'])); + assert.strictEqual(merged.length, 2); + + // Selected model is still found in the cached list + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', merged), false); + }); + + test('cache kept even for uncontributed vendors when no live models exist', () => { + const cachedGpt = createModel('gpt', 'GPT'); + + // When liveModels is empty, mergeModelsWithCache returns ALL cached + // because it can't distinguish "startup not ready" from "vendor removed" + const merged = mergeModelsWithCache([], [cachedGpt], new Set()); + assert.strictEqual(merged.length, 1); + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', merged), false); + }); + + test('cache evicted for uncontributed vendor once live models arrive', () => { + const cachedGpt = createModel('gpt', 'GPT'); + const liveOther = createModel('other', 'Other', { vendor: 'other-vendor' }); + + // Once live models exist, the vendor filter kicks in + const merged = mergeModelsWithCache([liveOther], [cachedGpt], new Set(['other-vendor'])); + assert.strictEqual(merged.length, 1); + assert.strictEqual(merged[0].metadata.id, 'other'); + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', merged), true); + }); + }); + + suite('late-arriving model restoration', () => { + + test('restores explicitly-chosen model that arrives late', () => { + const model = createModel('gpt', 'GPT'); + assert.strictEqual( + shouldRestoreLateArrivingModel('copilot/gpt', false, model, ChatAgentLocation.Chat), + true, + ); + }); + + test('restores model that was default and is still default for location', () => { + const model = createDefaultModelForLocation('gpt', 'GPT', ChatAgentLocation.Chat); + assert.strictEqual( + shouldRestoreLateArrivingModel('copilot/gpt', true, model, ChatAgentLocation.Chat), + true, + ); + }); + + test('does NOT restore model that was default but is no longer default', () => { + const model = createModel('gpt', 'GPT'); // not default for any location + assert.strictEqual( + shouldRestoreLateArrivingModel('copilot/gpt', true, model, ChatAgentLocation.Chat), + false, + ); + }); + + test('does NOT restore model that is not user-selectable', () => { + const model = createModel('internal', 'Internal', { isUserSelectable: false }); + assert.strictEqual( + shouldRestoreLateArrivingModel('copilot/internal', false, model, ChatAgentLocation.Chat), + false, + ); + }); + + test('does NOT restore model with isUserSelectable=undefined (treated as falsy)', () => { + const model = createModel('undef-sel', 'Undef-Sel', { isUserSelectable: undefined }); + assert.strictEqual( + shouldRestoreLateArrivingModel('copilot/undef-sel', false, model, ChatAgentLocation.Chat), + false, + ); + }); + + test('restores model arriving late at a different location where it is default', () => { + const model = createDefaultModelForLocation('gpt', 'GPT', ChatAgentLocation.Terminal); + // User is in Terminal — model is default there + assert.strictEqual( + shouldRestoreLateArrivingModel('copilot/gpt', true, model, ChatAgentLocation.Terminal), + true, + ); + // But not in Chat + assert.strictEqual( + shouldRestoreLateArrivingModel('copilot/gpt', true, model, ChatAgentLocation.Chat), + false, + ); + }); + }); + + suite('full startup pipeline (computeAvailableModels)', () => { + + test('startup with only cached models returns filtered cache', () => { + const cached = createModel('gpt', 'GPT'); + const result = computeAvailableModels( + [], // no live models yet + [cached], + new Set(['copilot']), + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['gpt']); + }); + + test('startup with cached models from removed vendor still returns them (no live to compare)', () => { + const cached = createModel('gpt', 'GPT'); + // When liveModels is empty, mergeModelsWithCache returns ALL cached + // because it cannot tell startup-delay from vendor removal + const result = computeAvailableModels( + [], // no live models + [cached], + new Set(), // vendor no longer contributed + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['gpt']); + }); + + test('live models supersede cached models from same vendor', () => { + const live = createModel('gpt-new', 'GPT New'); + const cached = createModel('gpt-old', 'GPT Old'); + const result = computeAvailableModels( + [live], + [cached], + new Set(['copilot']), + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['gpt-new']); + }); + + test('partial vendor resolution keeps unresolved vendors from cache', () => { + const liveA = createModel('a-model', 'A Model', { vendor: 'vendor-a' }); + const cachedB = createModel('b-model', 'B Model', { vendor: 'vendor-b' }); + const result = computeAvailableModels( + [liveA], + [cachedB], + new Set(['vendor-a', 'vendor-b']), + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id).sort(), ['a-model', 'b-model']); + }); + + test('results are sorted alphabetically by name', () => { + const modelC = createModel('c', 'Charlie'); + const modelA = createModel('a', 'Alpha'); + const modelB = createModel('b', 'Bravo'); + const result = computeAvailableModels( + [modelC, modelA, modelB], + [], + new Set(['copilot']), + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.name), ['Alpha', 'Bravo', 'Charlie']); + }); + + test('session-targeted models excluded from general session startup', () => { + const general = createModel('gpt', 'GPT'); + const cloudOnly = createSessionModel('cloud', 'Cloud', 'cloud'); + const result = computeAvailableModels( + [general, cloudOnly], + [], + new Set(['copilot']), + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['gpt']); + }); + + test('only session-targeted models returned for cloud session startup', () => { + const general = createModel('gpt', 'GPT'); + const cloudOnly = createSessionModel('cloud', 'Cloud', 'cloud'); + const result = computeAvailableModels( + [general, cloudOnly], + [], + new Set(['copilot']), + 'cloud', + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['cloud']); + }); + + test('agent mode filters non-tool models during startup', () => { + const toolModel = createModel('tool', 'Tool Model'); + const noToolModel = createModel('no-tool', 'No Tool', { + capabilities: { toolCalling: false, agentMode: false }, + }); + const result = computeAvailableModels( + [toolModel, noToolModel], + [], + new Set(['copilot']), + undefined, + ChatModeKind.Agent, + ChatAgentLocation.Chat, + false, + ); + assert.deepStrictEqual(result.map(m => m.metadata.id), ['tool']); + }); + }); + + suite('_syncFromModel edge cases', () => { + + test('sync state with undefined selectedModel keeps current', () => { + const current = createModel('gpt', 'GPT'); + // When state has no selectedModel, _syncFromModel skips the model sync + // (the code checks `if (state?.selectedModel)`) + // This means the current model stays — test that resolveModelFromSyncState + // correctly identifies "keep" for same model + const result = resolveModelFromSyncState(current, current, [current], undefined); + assert.strictEqual(result.action, 'keep'); + }); + + test('sync state model from different session does not apply', () => { + // Scenario: User is in session A with cloud model, switches to session B (general) + // Session B's state still has the cloud model reference + const cloudModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const generalModel = createModel('gpt', 'GPT'); + const allModels = [generalModel, cloudModel]; + + const result = resolveModelFromSyncState(cloudModel, generalModel, allModels, undefined); + assert.strictEqual(result.action, 'default'); + }); + + test('sync state with model matching different session type falls back to default', () => { + const enterpriseModel = createSessionModel('ent-gpt', 'Enterprise GPT', 'enterprise'); + const cloudModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const allModels = [cloudModel, enterpriseModel]; + + // State has enterprise model, but we're in cloud session + const result = resolveModelFromSyncState(enterpriseModel, cloudModel, allModels, 'cloud'); + assert.strictEqual(result.action, 'default'); + }); + + test('sync identical model reference returns keep', () => { + const model = createModel('gpt', 'GPT'); + // Same object reference + const result = resolveModelFromSyncState(model, model, [model], undefined); + assert.strictEqual(result.action, 'keep'); + }); + + test('sync same identifier but different object returns keep', () => { + const model1 = createModel('gpt', 'GPT'); + const model2 = createModel('gpt', 'GPT'); + // Different objects, same identifier + const result = resolveModelFromSyncState(model1, model2, [model1, model2], undefined); + assert.strictEqual(result.action, 'keep'); + }); + }); + + suite('checkModelSupported interaction patterns', () => { + + const askContext = { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: false, + sessionType: undefined, + }; + + const agentContext = { + ...askContext, + currentModeKind: ChatModeKind.Agent, + }; + + test('initSelectedModel → checkModelSupported: restored model passes Agent check', () => { + const agentModel = createModel('agent-model', 'Agent Model', { + capabilities: { toolCalling: true, agentMode: true }, + }); + + // 1. shouldRestorePersistedModel says "restore" + const restoreResult = shouldRestorePersistedModel('copilot/agent-model', false, [agentModel], ChatAgentLocation.Chat); + assert.strictEqual(restoreResult.shouldRestore, true); + + // 2. Immediately after, checkModelSupported runs with Agent mode + assert.strictEqual(shouldResetModelToDefault(agentModel, [agentModel], agentContext, [agentModel]), false); + }); + + test('initSelectedModel → checkModelSupported: restored model FAILS Agent check', () => { + const askOnlyModel = createModel('ask-only', 'Ask Only', { + capabilities: { toolCalling: false, agentMode: false }, + }); + const agentModel = createModel('agent-model', 'Agent Model'); + + // 1. shouldRestorePersistedModel says "restore" + const restoreResult = shouldRestorePersistedModel('copilot/ask-only', false, [askOnlyModel, agentModel], ChatAgentLocation.Chat); + assert.strictEqual(restoreResult.shouldRestore, true); + + // 2. checkModelSupported runs with Agent mode → should reset + assert.strictEqual(shouldResetModelToDefault(askOnlyModel, [askOnlyModel, agentModel], agentContext, [askOnlyModel, agentModel]), true); + + // 3. findDefaultModel picks replacement from models filtered for Agent mode + const agentCompatibleModels = filterModelsForSession( + [askOnlyModel, agentModel], undefined, ChatModeKind.Agent, ChatAgentLocation.Chat, false + ); + const defaultModel = findDefaultModel(agentCompatibleModels, ChatAgentLocation.Chat); + assert.strictEqual(defaultModel?.metadata.id, 'agent-model'); + }); + + test('mode switch triggers checkModelSupported which resets incompatible model', () => { + const noToolModel = createModel('no-tool', 'No Tool', { + capabilities: { toolCalling: false }, + }); + const toolModel = createModel('tool', 'Tool'); + + // In Ask mode: fine + assert.strictEqual(shouldResetModelToDefault(noToolModel, [noToolModel, toolModel], askContext, [noToolModel, toolModel]), false); + + // Switch to Agent mode: not fine + assert.strictEqual(shouldResetModelToDefault(noToolModel, [noToolModel, toolModel], agentContext, [noToolModel, toolModel]), true); + }); + + test('double reset is idempotent', () => { + const defaultModel = createDefaultModelForLocation('default', 'Default', ChatAgentLocation.Chat); + const otherModel = createModel('other', 'Other'); + const allModels = [defaultModel, otherModel]; + + // First reset: picks default + const result1 = findDefaultModel(allModels, ChatAgentLocation.Chat); + assert.strictEqual(result1?.metadata.id, 'default'); + + // "Second reset" — same call, same result + const result2 = findDefaultModel(allModels, ChatAgentLocation.Chat); + assert.strictEqual(result2?.metadata.id, 'default'); + + // Default model continues to pass validation + assert.strictEqual(shouldResetModelToDefault(result1!, allModels, askContext, allModels), false); + }); + }); + + suite('multiple session types and cross-contamination', () => { + + test('model from session A rejected in session B', () => { + const cloudModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const enterpriseModel = createSessionModel('ent-gpt', 'Enterprise GPT', 'enterprise'); + const generalModel = createModel('gpt', 'GPT'); + const allModels = [generalModel, cloudModel, enterpriseModel]; + + // Cloud model not valid in enterprise session + assert.strictEqual(isModelValidForSession(cloudModel, allModels, 'enterprise'), false); + // Enterprise model not valid in cloud session + assert.strictEqual(isModelValidForSession(enterpriseModel, allModels, 'cloud'), false); + // General model not valid when session-targeted models exist + assert.strictEqual(isModelValidForSession(generalModel, allModels, 'cloud'), false); + }); + + test('general model is valid when session type has no targeted models', () => { + const cloudModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const generalModel = createModel('gpt', 'GPT'); + const allModels = [generalModel, cloudModel]; + + // 'enterprise' session has no targeted models + assert.strictEqual(isModelValidForSession(generalModel, allModels, 'enterprise'), true); + }); + + test('filterModelsForSession isolates session types correctly', () => { + const general = createModel('gpt', 'GPT'); + const cloud = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const enterprise = createSessionModel('ent-gpt', 'Enterprise GPT', 'enterprise'); + const allModels = [general, cloud, enterprise]; + + const cloudFiltered = filterModelsForSession(allModels, 'cloud', ChatModeKind.Ask, ChatAgentLocation.Chat, false); + assert.deepStrictEqual(cloudFiltered.map(m => m.metadata.id), ['cloud-gpt']); + + const entFiltered = filterModelsForSession(allModels, 'enterprise', ChatModeKind.Ask, ChatAgentLocation.Chat, false); + assert.deepStrictEqual(entFiltered.map(m => m.metadata.id), ['ent-gpt']); + + const generalFiltered = filterModelsForSession(allModels, undefined, ChatModeKind.Ask, ChatAgentLocation.Chat, false); + assert.deepStrictEqual(generalFiltered.map(m => m.metadata.id), ['gpt']); + }); + + test('switching from cloud to general session resets cloud model', () => { + const cloudModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud'); + const generalModel = createModel('gpt', 'GPT'); + const allModels = [generalModel, cloudModel]; + + // In cloud session, cloud model is valid + assert.strictEqual(shouldResetModelToDefault(cloudModel, [cloudModel], { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: false, + sessionType: 'cloud', + }, allModels), false); + + // Switch to general session — cloud model should be reset + assert.strictEqual(shouldResetModelToDefault(cloudModel, [generalModel], { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: false, + sessionType: undefined, + }, allModels), true); + }); + }); + + suite('mode with forced model (mode.model property)', () => { + + test('mode forces model — simulating switchModelByQualifiedName success', () => { + const gpt = createModel('gpt-4o', 'GPT-4o'); + const claude = createModel('claude', 'Claude'); + const allModels = [gpt, claude]; + + // The autorun calls switchModelByQualifiedName which checks ILanguageModelChatMetadata.matchesQualifiedName + // Simulate: mode wants "GPT-4o (copilot)" + const qualifiedName = 'GPT-4o (copilot)'; + const match = allModels.find(m => ILanguageModelChatMetadata.matchesQualifiedName(qualifiedName, m.metadata)); + assert.strictEqual(match?.metadata.id, 'gpt-4o'); + }); + + test('mode forces model — copilot vendor shorthand works', () => { + const gpt = createModel('gpt-4o', 'GPT-4o'); + // For copilot vendor, just the name works + const match = [gpt].find(m => ILanguageModelChatMetadata.matchesQualifiedName('GPT-4o', m.metadata)); + assert.strictEqual(match?.metadata.id, 'gpt-4o'); + }); + + test('mode forces model — nonexistent model gracefully misses', () => { + const gpt = createModel('gpt-4o', 'GPT-4o'); + const match = [gpt].find(m => ILanguageModelChatMetadata.matchesQualifiedName('NonExistent (copilot)', m.metadata)); + assert.strictEqual(match, undefined); + }); + + test('mode forces model that is then checked for support', () => { + // Mode forces a model, then checkModelSupported runs + const forcedModel = createModel('forced', 'Forced', { + capabilities: { toolCalling: false, agentMode: false }, + }); + + // Mode forced this model but we're in Agent mode — should be reset + assert.strictEqual(shouldResetModelToDefault(forcedModel, [forcedModel], { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Agent, + isInlineChatV2Enabled: false, + sessionType: undefined, + }, [forcedModel]), true); + }); + }); + + suite('EditorInline + mode combined scenarios', () => { + + test('EditorInline + Agent + V2 requires both agentMode and toolCalling', () => { + const partialModel = createModel('partial', 'Partial', { + capabilities: { toolCalling: true, agentMode: false }, + }); + // Fails Agent mode check + assert.strictEqual(isModelSupportedForMode(partialModel, ChatModeKind.Agent), false); + // Passes inline chat check (has toolCalling) + assert.strictEqual(isModelSupportedForInlineChat(partialModel, ChatAgentLocation.EditorInline, true), true); + + // Combined: should reset because Agent mode fails + assert.strictEqual(shouldResetModelToDefault(partialModel, [partialModel], { + location: ChatAgentLocation.EditorInline, + currentModeKind: ChatModeKind.Agent, + isInlineChatV2Enabled: true, + sessionType: undefined, + }, [partialModel]), true); + }); + + test('EditorInline + Ask + V2 only requires toolCalling', () => { + const toolModel = createModel('tool', 'Tool'); + assert.strictEqual(shouldResetModelToDefault(toolModel, [toolModel], { + location: ChatAgentLocation.EditorInline, + currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: true, + sessionType: undefined, + }, [toolModel]), false); + }); + + test('EditorInline + Ask + V2 rejects model without toolCalling', () => { + const noToolModel = createModel('no-tool', 'No Tool', { + capabilities: {}, + }); + assert.strictEqual(shouldResetModelToDefault(noToolModel, [noToolModel], { + location: ChatAgentLocation.EditorInline, + currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: true, + sessionType: undefined, + }, [noToolModel]), true); + }); + }); + + suite('findDefaultModel edge cases', () => { + + test('when all models are session-targeted and none is default, first model wins', () => { + const m1 = createSessionModel('s1', 'Session 1', 'cloud'); + const m2 = createSessionModel('s2', 'Session 2', 'cloud'); + const result = findDefaultModel([m1, m2], ChatAgentLocation.Chat); + assert.strictEqual(result?.metadata.id, 's1'); + }); + + test('default for one location does not leak to another', () => { + const chatDefault = createDefaultModelForLocation('chat-def', 'Chat Default', ChatAgentLocation.Chat); + const noDefault = createModel('no-def', 'No Default'); + + // For Chat: chatDefault wins + assert.strictEqual(findDefaultModel([noDefault, chatDefault], ChatAgentLocation.Chat)?.metadata.id, 'chat-def'); + // For Terminal: no model is default, so first model wins + assert.strictEqual(findDefaultModel([noDefault, chatDefault], ChatAgentLocation.Terminal)?.metadata.id, 'no-def'); + }); + }); + + suite('realistic multi-step race simulations', () => { + + test('startup: cached model → live models arrive → user choice preserved', () => { + const cachedGpt = createModel('gpt', 'GPT'); + const cachedClaude = createModel('claude', 'Claude'); + + // Step 1: Startup with only cache. User had GPT selected. + const cachedModels = computeAvailableModels( + [], + [cachedGpt, cachedClaude], + new Set(['copilot']), + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + // GPT is in the cached list + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', cachedModels), false); + + // Step 2: Live models arrive (same models) + const liveModels = computeAvailableModels( + [cachedGpt, cachedClaude], + [cachedGpt, cachedClaude], + new Set(['copilot']), + undefined, + ChatModeKind.Ask, + ChatAgentLocation.Chat, + false, + ); + // GPT still in the list — no reset needed + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', liveModels), false); + }); + + test('startup: no cache → models arrive late → persisted choice restored', () => { + // Step 1: No models available at all + const emptyModels = computeAvailableModels([], [], new Set(['copilot']), undefined, ChatModeKind.Ask, ChatAgentLocation.Chat, false); + assert.strictEqual(emptyModels.length, 0); + + // initSelectedModel: model not found, enters _waitForPersistedLanguageModel path + const restoreResult = shouldRestorePersistedModel('copilot/gpt', false, emptyModels, ChatAgentLocation.Chat); + assert.strictEqual(restoreResult.shouldRestore, false); + assert.strictEqual(restoreResult.model, undefined); + + // Step 2: Models arrive via onDidChangeLanguageModels + const arrivedModel = createModel('gpt', 'GPT'); + assert.strictEqual( + shouldRestoreLateArrivingModel('copilot/gpt', false, arrivedModel, ChatAgentLocation.Chat), + true, + ); + }); + + test('extension reload: selected model flickers out then back', () => { + const gpt = createModel('gpt', 'GPT'); + const claude = createModel('claude', 'Claude'); + + // Step 1: GPT is selected + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', [gpt, claude]), false); + + // Step 2: Extension reloads, copilot vendor has no live models + // But cache bridges the gap + const duringReload = mergeModelsWithCache([], [gpt, claude], new Set(['copilot'])); + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', duringReload), false); + + // Step 3: Extension finishes loading, live models back + const afterReload = mergeModelsWithCache([gpt, claude], [gpt, claude], new Set(['copilot'])); + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', afterReload), false); + }); + + test('extension reload without cache: model lost', () => { + const gpt = createModel('gpt', 'GPT'); + + // Step 1: GPT selected, no cache + // Step 2: Extension reloads with no models and no cache + const duringReload = mergeModelsWithCache([], [], new Set(['copilot'])); + assert.strictEqual(duringReload.length, 0); + assert.strictEqual(shouldResetOnModelListChange('copilot/gpt', duringReload), true); + // → Model is lost, reset to default + + // Step 3: Models come back but user's choice is already gone + const afterReload = mergeModelsWithCache([gpt], [], new Set(['copilot'])); + assert.strictEqual(afterReload.length, 1); + // User's selection was already reset to something else + // This is expected behavior — cache is the mitigation + }); + + test('session switch race: mode + session change together', () => { + const generalDefault = createDefaultModelForLocation('gpt', 'GPT', ChatAgentLocation.Chat); + const cloudModel = createSessionModel('cloud-gpt', 'Cloud GPT', 'cloud', { + capabilities: { toolCalling: true, agentMode: true }, + }); + const allModels = [generalDefault, cloudModel]; + + // User is in general session with GPT in Agent mode + assert.strictEqual(shouldResetModelToDefault(generalDefault, [generalDefault], { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Agent, + isInlineChatV2Enabled: false, + sessionType: undefined, + }, allModels), false); + + // Switch to cloud session — general model should be reset + assert.strictEqual(shouldResetModelToDefault(generalDefault, [cloudModel], { + location: ChatAgentLocation.Chat, + currentModeKind: ChatModeKind.Agent, + isInlineChatV2Enabled: false, + sessionType: 'cloud', + }, allModels), true); + + // The default for cloud session should be the cloud model + const cloudDefault = findDefaultModel([cloudModel], ChatAgentLocation.Chat); + assert.strictEqual(cloudDefault?.metadata.id, 'cloud-gpt'); + }); + + test('rapid mode changes: ask → agent → ask preserves compatible model', () => { + const model = createModel('gpt', 'GPT'); // Compatible with all modes + const allModels = [model]; + + // Ask mode: fine + assert.strictEqual(shouldResetModelToDefault(model, allModels, { + location: ChatAgentLocation.Chat, currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: false, sessionType: undefined, + }, allModels), false); + + // → Agent mode: model has toolCalling, still fine + assert.strictEqual(shouldResetModelToDefault(model, allModels, { + location: ChatAgentLocation.Chat, currentModeKind: ChatModeKind.Agent, + isInlineChatV2Enabled: false, sessionType: undefined, + }, allModels), false); + + // → Back to Ask: still fine + assert.strictEqual(shouldResetModelToDefault(model, allModels, { + location: ChatAgentLocation.Chat, currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: false, sessionType: undefined, + }, allModels), false); + }); + + test('rapid mode changes: ask → agent resets incompatible, then agent → ask does not restore', () => { + const noToolModel = createModel('no-tool', 'No Tool', { + capabilities: { toolCalling: false }, + }); + const toolModel = createDefaultModelForLocation('tool', 'Tool', ChatAgentLocation.Chat); + const allModels = [noToolModel, toolModel]; + + // Ask mode with noToolModel: fine + assert.strictEqual(shouldResetModelToDefault(noToolModel, allModels, { + location: ChatAgentLocation.Chat, currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: false, sessionType: undefined, + }, allModels), false); + + // → Agent mode: noToolModel fails, reset picks default (toolModel) + assert.strictEqual(shouldResetModelToDefault(noToolModel, allModels, { + location: ChatAgentLocation.Chat, currentModeKind: ChatModeKind.Agent, + isInlineChatV2Enabled: false, sessionType: undefined, + }, allModels), true); + const defaultAfterReset = findDefaultModel(allModels, ChatAgentLocation.Chat); + assert.strictEqual(defaultAfterReset?.metadata.id, 'tool'); + + // → Back to Ask: toolModel is fine in Ask mode, stays as toolModel + // The original noToolModel is NOT restored — this is expected and matches ChatInputPart behavior + assert.strictEqual(shouldResetModelToDefault(toolModel, allModels, { + location: ChatAgentLocation.Chat, currentModeKind: ChatModeKind.Ask, + isInlineChatV2Enabled: false, sessionType: undefined, + }, allModels), false); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts index 5a247eaea99..9b9b0aac42a 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts @@ -214,6 +214,44 @@ suite('ChatDebugServiceImpl', () => { }); }); + suite('markDebugDataAttached', () => { + test('should track attached debug data per session', () => { + assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), false); + + const fired: URI[] = []; + disposables.add(service.onDidAttachDebugData(uri => fired.push(uri))); + + service.markDebugDataAttached(sessionGeneric); + assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), true); + assert.strictEqual(fired.length, 1); + assert.strictEqual(fired[0].toString(), sessionGeneric.toString()); + + // Idempotent — second call should not fire again + service.markDebugDataAttached(sessionGeneric); + assert.strictEqual(fired.length, 1); + + // Other sessions remain unaffected + assert.strictEqual(service.hasAttachedDebugData(sessionA), false); + }); + + test('should clear attached debug data on endSession', () => { + service.markDebugDataAttached(sessionGeneric); + assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), true); + + service.endSession(sessionGeneric); + assert.strictEqual(service.hasAttachedDebugData(sessionGeneric), false); + }); + + test('should clear attached debug data on clear', () => { + service.markDebugDataAttached(sessionA); + service.markDebugDataAttached(sessionB); + + service.clear(); + assert.strictEqual(service.hasAttachedDebugData(sessionA), false); + assert.strictEqual(service.hasAttachedDebugData(sessionB), false); + }); + }); + suite('registerProvider', () => { test('should register and unregister a provider', async () => { const extSession = URI.parse('vscode-chat-session://local/ext-session'); @@ -312,6 +350,32 @@ suite('ChatDebugServiceImpl', () => { assert.strictEqual(firstToken.isCancellationRequested, true); }); + test('should fire onDidClearProviderEvents when clearing provider events', async () => { + const clearedSessions: URI[] = []; + disposables.add(service.onDidClearProviderEvents(sessionResource => clearedSessions.push(sessionResource))); + + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async (sessionResource) => [{ + kind: 'generic', + sessionResource, + created: new Date(), + name: 'provider-event', + level: ChatDebugLogLevel.Info, + }], + }; + + disposables.add(service.registerProvider(provider)); + + // First invocation clears empty set and fires clear event + await service.invokeProviders(sessionGeneric); + assert.strictEqual(clearedSessions.length, 1, 'Clear event should fire on first invocation'); + + // Second invocation clears provider events from first invocation + await service.invokeProviders(sessionGeneric); + assert.strictEqual(clearedSessions.length, 2, 'Clear event should fire on second invocation'); + assert.strictEqual(clearedSessions[1].toString(), sessionGeneric.toString()); + }); + test('should not cancel invocations for different sessions', async () => { const tokens: Map = new Map(); diff --git a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts index f7745a0b6fa..c8e5aa27404 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -19,8 +19,9 @@ import { TestStorageService } from '../../../../test/common/workbenchTestService import { IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatMode, ChatModeService } from '../../common/chatModes.js'; import { ChatModeKind } from '../../common/constants.js'; -import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../common/promptSyntax/service/promptsService.js'; +import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; +import { Target } from '../../common/promptSyntax/promptTypes.js'; class TestChatAgentService implements Partial { _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap index 643e3727914..374c04da997 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize.0.snap @@ -75,6 +75,7 @@ confirmation: undefined, editedFileEvents: undefined, modelId: undefined, + modeInfo: undefined, responseId: undefined, result: { metadata: { metadataKey: "value" } }, responseMarkdownInfo: undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap index f0e423320dd..3119761173f 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_deserialize_with_response.0.snap @@ -75,6 +75,7 @@ confirmation: undefined, editedFileEvents: undefined, modelId: undefined, + modeInfo: undefined, responseId: undefined, result: { errorDetails: { message: "No activated agent with id \"ChatProviderWithUsedContext\"" } }, responseMarkdownInfo: undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap index 3e5c32aa740..a3d39df663c 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_can_serialize.1.snap @@ -77,6 +77,7 @@ confirmation: undefined, editedFileEvents: undefined, modelId: undefined, + modeInfo: undefined, responseId: undefined, result: { metadata: { metadataKey: "value" } }, responseMarkdownInfo: undefined, @@ -162,6 +163,7 @@ confirmation: undefined, editedFileEvents: undefined, modelId: undefined, + modeInfo: undefined, responseId: undefined, result: { }, responseMarkdownInfo: undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap index 9a7073212a2..5fc5d409eb3 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap +++ b/src/vs/workbench/contrib/chat/test/common/chatService/__snapshots__/ChatService_sendRequest_fails.0.snap @@ -77,6 +77,7 @@ confirmation: undefined, editedFileEvents: undefined, modelId: undefined, + modeInfo: undefined, responseId: undefined, result: { errorDetails: { message: "No activated agent with id \"ChatProviderWithUsedContext\"" } }, responseMarkdownInfo: undefined, diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 9adc29af139..3ca952c05d5 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -12,6 +12,7 @@ import { constObservable, observableValue } from '../../../../../../base/common/ import { URI } from '../../../../../../base/common/uri.js'; import { assertSnapshot } from '../../../../../../base/test/common/snapshot.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -40,7 +41,7 @@ import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { IChatEditingService, IChatEditingSession } from '../../../common/editing/chatEditingService.js'; import { ChatModel, IChatModel, ISerializableChatData } from '../../../common/model/chatModel.js'; -import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReference, IChatService, ResponseModelState } from '../../../common/chatService/chatService.js'; import { ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../../../common/participants/chatSlashCommands.js'; import { IChatVariablesService } from '../../../common/attachments/chatVariables.js'; @@ -479,6 +480,135 @@ suite('ChatService', () => { completeRequest.complete(); await response.data.responseCompletePromise; }); + + test('multiple steering messages are combined into a single request', async () => { + const requestStarted = new DeferredPromise(); + const completeRequest = new DeferredPromise(); + const invokedRequests: string[] = []; + + const slowAgent: IChatAgentImplementation = { + async invoke(request, progress, history, token) { + invokedRequests.push(request.message); + if (invokedRequests.length === 1) { + requestStarted.complete(); + await completeRequest.p; + } + return {}; + }, + }; + + testDisposables.add(chatAgentService.registerAgent('slowAgent', { ...getAgentData('slowAgent'), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation('slowAgent', slowAgent)); + + const testService = createChatService(); + const modelRef = testDisposables.add(startSessionModel(testService)); + const model = modelRef.object; + + // Start a request that will wait + const response = await testService.sendRequest(model.sessionResource, 'first request', { agentId: 'slowAgent' }); + ChatSendResult.assertSent(response); + + // Wait for the agent to start processing + await requestStarted.p; + + // Queue 3 steering messages while the first request is in progress + const steering1 = await testService.sendRequest(model.sessionResource, 'steering1', { agentId: 'slowAgent', queue: ChatRequestQueueKind.Steering }); + const steering2 = await testService.sendRequest(model.sessionResource, 'steering2', { agentId: 'slowAgent', queue: ChatRequestQueueKind.Steering }); + const steering3 = await testService.sendRequest(model.sessionResource, 'steering3', { agentId: 'slowAgent', queue: ChatRequestQueueKind.Steering }); + assert.ok(ChatSendResult.isQueued(steering1)); + assert.ok(ChatSendResult.isQueued(steering2)); + assert.ok(ChatSendResult.isQueued(steering3)); + + // Complete the first request - should trigger processing of combined steering requests + completeRequest.complete(); + await response.data.responseCompletePromise; + + // Wait for all deferred promises to resolve + await steering1.deferred; + await steering2.deferred; + await steering3.deferred; + + // Should have only invoked 2 requests: the initial and the combined steering + assert.strictEqual(invokedRequests.length, 2, 'Should have only 2 invocations (initial + combined steering)'); + // The combined message includes all steering texts joined with \n\n + assert.ok(invokedRequests[1].includes('steering1'), 'Combined message should include steering1'); + assert.ok(invokedRequests[1].includes('steering2'), 'Combined message should include steering2'); + assert.ok(invokedRequests[1].includes('steering3'), 'Combined message should include steering3'); + assert.ok(invokedRequests[1].includes('\n\n'), 'Combined message should use \\n\\n as separator'); + }); + test('cancelCurrentRequestForSession waits for response completion', async () => { + const requestStarted = new DeferredPromise(); + const completeRequest = new DeferredPromise(); + + const slowAgent: IChatAgentImplementation = { + async invoke(request, progress, history, token) { + requestStarted.complete(); + const listener = token.onCancellationRequested(() => { + listener.dispose(); + // Simulate some cleanup delay before completing + setTimeout(() => completeRequest.complete(), 10); + }); + await completeRequest.p; + return {}; + }, + }; + + testDisposables.add(chatAgentService.registerAgent('slowAgent', { ...getAgentData('slowAgent'), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation('slowAgent', slowAgent)); + + const testService = createChatService(); + const modelRef = testDisposables.add(startSessionModel(testService)); + const model = modelRef.object; + + const response = await testService.sendRequest(model.sessionResource, 'test request', { agentId: 'slowAgent' }); + ChatSendResult.assertSent(response); + + await requestStarted.p; + + // Cancel and await - should wait for the response to complete + await testService.cancelCurrentRequestForSession(model.sessionResource, 'test'); + + // After cancel resolves, the response model should have a result + const lastRequest = model.getRequests()[0]; + assert.ok(lastRequest.response, 'Response should exist after cancellation completes'); + assert.strictEqual(lastRequest.response.state, ResponseModelState.Cancelled, 'Response should be in Cancelled state'); + }); + + test('cancelCurrentRequestForSession returns after timeout if response does not complete', async () => { + const requestStarted = new DeferredPromise(); + const completeRequest = new DeferredPromise(); + + const hangingAgent: IChatAgentImplementation = { + async invoke(request, progress, history, token) { + requestStarted.complete(); + // Wait for external signal, ignoring cancellation to simulate a hung agent + await completeRequest.p; + return {}; + }, + }; + + testDisposables.add(chatAgentService.registerAgent('hangingAgent', { ...getAgentData('hangingAgent'), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation('hangingAgent', hangingAgent)); + + const testService = createChatService(); + const modelRef = testDisposables.add(startSessionModel(testService)); + const model = modelRef.object; + + const response = await testService.sendRequest(model.sessionResource, 'test request', { agentId: 'hangingAgent' }); + ChatSendResult.assertSent(response); + + await requestStarted.p; + + // Cancel should return after timeout even though the agent has not completed. + // Use faked timers so raceTimeout's 1s setTimeout fires instantly. + await runWithFakedTimers({ useFakeTimers: true }, async () => { + await testService.cancelCurrentRequestForSession(model.sessionResource, 'test'); + }); + + // Let the agent finish so the test cleans up properly + completeRequest.complete(); + await response.data.responseCompletePromise; + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index f10ffa492b6..e268a3a137e 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -4,164 +4,208 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Event } from '../../../../../../base/common/event.js'; -import { ResourceMap } from '../../../../../../base/common/map.js'; -import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { IChatModel, IChatRequestModel, IChatRequestVariableData, ISerializableChatData } from '../../../common/model/chatModel.js'; -import { IParsedChatRequest } from '../../../common/requestParser/chatParserTypes.js'; -import { ChatRequestQueueKind, ChatSendResult, IChatCompleteResponse, IChatDetail, IChatModelReference, IChatProgress, IChatProviderInfo, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ChatSendResult, IChatDetail, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../common/constants.js'; +import { IChatModel, IChatRequestModel, IExportableChatData, ISerializableChatData } from '../../../common/model/chatModel.js'; export class MockChatService implements IChatService { - chatModels: IObservable> = observableValue('chatModels', []); + private readonly _chatModels: ISettableObservable> = observableValue('chatModels', []); + readonly chatModels = this._chatModels; requestInProgressObs = observableValue('name', false); _serviceBrand: undefined; editingSessions = []; - transferredSessionResource: URI | undefined; - readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI; readonly message?: IParsedChatRequest }> = Event.None; - readonly onDidCreateModel: Event = Event.None; + transferredSessionResource = undefined; + readonly onDidSubmitRequest = Event.None; + readonly onDidCreateModel = Event.None; - private sessions = new ResourceMap(); + private sessions = new Map(); + private liveSessionItems: IChatDetail[] = []; + private historySessionItems: IChatDetail[] = []; + + private readonly _onDidDisposeSession = new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>(); + readonly onDidDisposeSession = this._onDidDisposeSession.event; + + fireDidDisposeSession(sessionResource: URI[]): void { + this._onDidDisposeSession.fire({ sessionResource, reason: 'cleared' }); + } setSaveModelsEnabled(enabled: boolean): void { } - isEnabled(location: ChatAgentLocation): boolean { - throw new Error('Method not implemented.'); - } - hasSessions(): boolean { - throw new Error('Method not implemented.'); - } - getProviderInfos(): IChatProviderInfo[] { - throw new Error('Method not implemented.'); - } - startNewLocalSession(location: ChatAgentLocation, options?: IChatSessionStartOptions): IChatModelReference { - throw new Error('Method not implemented.'); - } - addSession(session: IChatModel): void { - this.sessions.set(session.sessionResource, session); - } - getSession(sessionResource: URI): IChatModel | undefined { - // eslint-disable-next-line local/code-no-dangerous-type-assertions - return this.sessions.get(sessionResource) ?? {} as IChatModel; - } - getLatestRequest(): IChatRequestModel | undefined { - return undefined; - } - async acquireOrRestoreSession(sessionResource: URI): Promise { - throw new Error('Method not implemented.'); - } - getSessionTitle(sessionResource: URI): string | undefined { - throw new Error('Method not implemented.'); - } - loadSessionFromData(data: ISerializableChatData): IChatModelReference { - throw new Error('Method not implemented.'); - } - acquireOrLoadSession(resource: URI, position: ChatAgentLocation, token: CancellationToken): Promise { - throw new Error('Method not implemented.'); - } - acquireExistingSession(sessionResource: URI): IChatModelReference | undefined { - return undefined; - } - setSessionTitle(sessionResource: URI, title: string): void { - throw new Error('Method not implemented.'); - } - appendProgress(request: IChatRequestModel, progress: IChatProgress): void { - } processPendingRequests(sessionResource: URI): void { } - /** - * Returns whether the request was accepted. - */ - sendRequest(sessionResource: URI, message: string): Promise { + + setLiveSessionItems(items: IChatDetail[]): void { + this.liveSessionItems = items; + } + + setHistorySessionItems(items: IChatDetail[]): void { + this.historySessionItems = items; + } + + addSession(session: IChatModel): void { + this.sessions.set(session.sessionResource.toString(), session); + // Update the chatModels observable + this._chatModels.set([...this.sessions.values()], undefined); + } + + removeSession(sessionResource: URI): void { + this.sessions.delete(sessionResource.toString()); + // Update the chatModels observable + this._chatModels.set([...this.sessions.values()], undefined); + } + + isEnabled(_location: ChatAgentLocation): boolean { + return true; + } + + hasSessions(): boolean { + return this.sessions.size > 0; + } + + getProviderInfos() { + return []; + } + + startNewLocalSession(_location: ChatAgentLocation, _options?: IChatSessionStartOptions): IChatModelReference { throw new Error('Method not implemented.'); } - resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions | undefined): Promise { + + getSession(sessionResource: URI): IChatModel | undefined { + return this.sessions.get(sessionResource.toString()); + } + + getLatestRequest(): IChatRequestModel | undefined { + return undefined; + } + + acquireOrRestoreSession(_sessionResource: URI): Promise { throw new Error('Method not implemented.'); } - adoptRequest(sessionResource: URI, request: IChatRequestModel): Promise { + + getSessionTitle(_sessionResource: URI): string | undefined { + return undefined; + } + + loadSessionFromData(data: IExportableChatData | ISerializableChatData): IChatModelReference { throw new Error('Method not implemented.'); } - removeRequest(sessionResource: URI, requestId: string): Promise { + + acquireOrLoadSession(_resource: URI, _position: ChatAgentLocation, _token: CancellationToken): Promise { throw new Error('Method not implemented.'); } - cancelCurrentRequestForSession(sessionResource: URI, source?: string): void { + + acquireExistingSession(_sessionResource: URI): IChatModelReference | undefined { + return undefined; + } + + setSessionTitle(_sessionResource: URI, _title: string): void { } + + appendProgress(_request: IChatRequestModel, _progress: IChatProgress): void { } + + sendRequest(_sessionResource: URI, _message: string): Promise { throw new Error('Method not implemented.'); } - setYieldRequested(sessionResource: URI): void { + + resendRequest(_request: IChatRequestModel, _options?: IChatSendRequestOptions): Promise { throw new Error('Method not implemented.'); } - removePendingRequest(sessionResource: URI, requestId: string): void { + + adoptRequest(_sessionResource: URI, _request: IChatRequestModel): Promise { throw new Error('Method not implemented.'); } - setPendingRequests(sessionResource: URI, requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void { - throw new Error('Method not implemented.'); - } - addCompleteRequest(sessionResource: URI, message: IParsedChatRequest | string, variableData: IChatRequestVariableData | undefined, attempt: number | undefined, response: IChatCompleteResponse): void { + + removeRequest(_sessionResource: URI, _requestId: string): Promise { throw new Error('Method not implemented.'); } + + async cancelCurrentRequestForSession(_sessionResource: URI, _source?: string): Promise { } + + setYieldRequested(_sessionResource: URI): void { } + + removePendingRequest(_sessionResource: URI, _requestId: string): void { } + + setPendingRequests(_sessionResource: URI, _requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void { } + + addCompleteRequest(): void { } + async getLocalSessionHistory(): Promise { - throw new Error('Method not implemented.'); - } - async clearAllHistoryEntries() { - throw new Error('Method not implemented.'); - } - async removeHistoryEntry(resource: URI) { - throw new Error('Method not implemented.'); + return this.historySessionItems; } - readonly onDidPerformUserAction: Event = undefined!; - notifyUserAction(event: IChatUserActionEvent): void { - throw new Error('Method not implemented.'); - } - readonly onDidReceiveQuestionCarouselAnswer: Event<{ requestId: string; resolveId: string; answers: Record | undefined }> = undefined!; - notifyQuestionCarouselAnswer(requestId: string, resolveId: string, answers: Record | undefined): void { - throw new Error('Method not implemented.'); - } - readonly onDidDisposeSession: Event<{ sessionResource: URI[]; reason: 'cleared' }> = undefined!; + async clearAllHistoryEntries(): Promise { } - async transferChatSession(transferredSessionResource: URI, toWorkspace: URI): Promise { - throw new Error('Method not implemented.'); - } + async removeHistoryEntry(_resource: URI): Promise { } - setChatSessionTitle(sessionResource: URI, title: string): void { - throw new Error('Method not implemented.'); - } + readonly onDidPerformUserAction = Event.None; - isEditingLocation(location: ChatAgentLocation): boolean { - throw new Error('Method not implemented.'); + notifyUserAction(_event: IChatUserActionEvent): void { } + + readonly onDidReceiveQuestionCarouselAnswer = Event.None; + + notifyQuestionCarouselAnswer(_requestId: string, _resolveId: string, _answers: Record | undefined): void { } + + async transferChatSession(): Promise { } + + setChatSessionTitle(): void { } + + isEditingLocation(_location: ChatAgentLocation): boolean { + return false; } getChatStorageFolder(): URI { - throw new Error('Method not implemented.'); + return URI.file('/tmp'); } - logChatIndex(): void { - throw new Error('Method not implemented.'); + logChatIndex(): void { } + + activateDefaultAgent(_location: ChatAgentLocation): Promise { + return Promise.resolve(); } - activateDefaultAgent(location: ChatAgentLocation): Promise { - throw new Error('Method not implemented.'); - } - - getChatSessionFromInternalUri(sessionResource: URI): IChatSessionContext | undefined { - throw new Error('Method not implemented.'); + getChatSessionFromInternalUri(_sessionResource: URI): IChatSessionContext | undefined { + return undefined; } async getLiveSessionItems(): Promise { - throw new Error('Method not implemented.'); + return this.liveSessionItems; } - getHistorySessionItems(): Promise { - throw new Error('Method not implemented.'); + + async getHistorySessionItems(): Promise { + return this.historySessionItems; } waitForModelDisposals(): Promise { - throw new Error('Method not implemented.'); + return Promise.resolve(); } + getMetadataForSession(sessionResource: URI): Promise { throw new Error('Method not implemented.'); } + + + private onChange?: () => void; + + registerChatModelChangeListeners(chatSessionType: string, onChange: () => void): IDisposable { + // Store the emitter so tests can trigger it + this.onChange = onChange; + return { + dispose: () => { + this.onChange = undefined; + } + }; + } + + // Helper method for tests to trigger progress events + triggerProgressEvent(): void { + if (this.onChange) { + this.onChange(); + } + } } diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 0136bfb9393..68f264c5322 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -11,9 +11,8 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from '../../common/participants/chatAgents.js'; import { IChatModel } from '../../common/model/chatModel.js'; -import { IChatService } from '../../common/chatService/chatService.js'; import { IChatSession, IChatSessionContentProvider, IChatSessionItemController, IChatSessionItem, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; -import { Target } from '../../common/promptSyntax/service/promptsService.js'; +import { Target } from '../../common/promptSyntax/promptTypes.js'; export class MockChatSessionsService implements IChatSessionsService { _serviceBrand: undefined; @@ -47,7 +46,6 @@ export class MockChatSessionsService implements IChatSessionsService { private optionGroups = new Map(); private sessionOptions = new ResourceMap>(); private inProgress = new Map(); - private onChange = () => { }; // For testing: allow triggering events fireDidChangeItemsProviders(event: { chatSessionType: string }): void { @@ -161,8 +159,8 @@ export class MockChatSessionsService implements IChatSessionsService { return provider.provideChatSessionContent(sessionResource, token); } - async canResolveChatSession(chatSessionResource: URI): Promise { - return this.contentProviders.has(chatSessionResource.scheme); + async canResolveChatSession(sessionType: string): Promise { + return this.contentProviders.has(sessionType); } getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined { @@ -232,20 +230,4 @@ export class MockChatSessionsService implements IChatSessionsService { registerSessionResourceAlias(_untitledResource: URI, _realResource: URI): void { // noop } - - registerChatModelChangeListeners(chatService: IChatService, chatSessionType: string, onChange: () => void): IDisposable { - // Store the emitter so tests can trigger it - this.onChange = onChange; - return { - dispose: () => { - } - }; - } - - // Helper method for tests to trigger progress events - triggerProgressEvent(): void { - if (this.onChange) { - this.onChange(); - } - } } diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index c0800d4b46d..e89b504b224 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -25,10 +25,10 @@ import { TestExtensionService, TestStorageService } from '../../../../../test/co import { CellUri } from '../../../../notebook/common/notebookCommon.js'; import { IChatRequestImplicitVariableEntry, IChatRequestStringVariableEntry, IChatRequestFileEntry, StringChatContextValue } from '../../../common/attachments/chatVariableEntries.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ChatModel, ChatRequestModel, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response } from '../../../common/model/chatModel.js'; +import { ChatModel, ChatRequestModel, IChatRequestModeInfo, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response } from '../../../common/model/chatModel.js'; import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; import { ChatRequestQueueKind, IChatService, IChatToolInvocation } from '../../../common/chatService/chatService.js'; -import { ChatAgentLocation } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { MockChatService } from '../chatService/mockChatService.js'; suite('ChatModel', () => { @@ -274,6 +274,52 @@ suite('ChatModel', () => { // Should keep file attachments and implicit attachments with URI values assert.deepStrictEqual(serialized.attachments, [fileAttachment, implicitWithUri]); }); + + test('modeInfo roundtrips through serialization', async () => { + const modeInfo: IChatRequestModeInfo = { + kind: ChatModeKind.Agent, + isBuiltin: false, + modeId: 'custom', + modeInstructions: { + name: 'plan', + content: 'You are a planning agent', + toolReferences: [], + }, + applyCodeBlockSuggestionId: undefined, + }; + + const serializableData: ISerializableChatData3 = { + version: 3, + sessionId: 'test-modeinfo-session', + creationDate: Date.now(), + customTitle: undefined, + initialLocation: ChatAgentLocation.Chat, + responderUsername: 'bot', + requests: [{ + requestId: 'req1', + message: { text: 'plan something', parts: [] }, + variableData: { variables: [] }, + response: [{ value: 'Here is my plan', isTrusted: false }], + modelState: { value: 1 /* ResponseModelState.Complete */, completedAt: Date.now() }, + modeInfo, + }], + }; + + const model = testDisposables.add(instantiationService.createInstance( + ChatModel, + { value: serializableData, serializer: undefined! }, + { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + )); + + const requests = model.getRequests(); + assert.strictEqual(requests.length, 1); + assert.deepStrictEqual(requests[0].modeInfo, modeInfo); + + // Verify roundtrip through toExport + const exported = model.toExport(); + assert.strictEqual(exported.requests.length, 1); + assert.deepStrictEqual(exported.requests[0].modeInfo, modeInfo); + }); }); suite('Response', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index 46bb4fbcb3f..879e3a33850 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -15,7 +15,7 @@ import { IStorageService, InMemoryStorageService } from '../../../../../../platf import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPluginRepositoryService.js'; import { ChatConfiguration } from '../../../common/constants.js'; -import { MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, parseMarketplaceReference, parseMarketplaceReferences } from '../../../common/plugins/pluginMarketplaceService.js'; +import { MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource } from '../../../common/plugins/pluginMarketplaceService.js'; suite('PluginMarketplaceService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -76,12 +76,41 @@ suite('PluginMarketplaceService', () => { assert.deepStrictEqual(parsed.cacheSegments, []); }); - test('rejects non-shorthand marketplace entries without .git', () => { - assert.strictEqual(parseMarketplaceReference('https://example.com/org/repo'), undefined); - assert.strictEqual(parseMarketplaceReference('ssh://git@example.com/org/repo'), undefined); + test('accepts HTTPS and SSH marketplace entries without .git suffix', () => { + const https = parseMarketplaceReference('https://example.com/org/repo'); + assert.ok(https); + assert.strictEqual(https?.kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(https?.canonicalId, 'git:example.com/org/repo.git'); + assert.deepStrictEqual(https?.cacheSegments, ['example.com', 'org', 'repo']); + + const ssh = parseMarketplaceReference('ssh://git@example.com/org/repo'); + assert.ok(ssh); + assert.strictEqual(ssh?.kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(ssh?.canonicalId, 'git:git@example.com/org/repo.git'); + + // SCP-style (git@host:path) still requires .git because the colon-path syntax is + // unambiguous only for traditional git SSH URLs where .git is conventional. assert.strictEqual(parseMarketplaceReference('git@example.com:org/repo'), undefined); }); + test('parses Azure DevOps HTTPS clone URLs without .git suffix', () => { + const parsed = parseMarketplaceReference('https://dev.azure.com/org/project/_git/repo'); + assert.ok(parsed); + assert.strictEqual(parsed?.kind, MarketplaceReferenceKind.GitUri); + assert.strictEqual(parsed?.cloneUrl, 'https://dev.azure.com/org/project/_git/repo'); + assert.strictEqual(parsed?.canonicalId, 'git:dev.azure.com/org/project/_git/repo.git'); + assert.deepStrictEqual(parsed?.cacheSegments, ['dev.azure.com', 'org', 'project', '_git', 'repo']); + }); + + test('deduplicates Azure DevOps URLs with and without .git suffix', () => { + const parsed = parseMarketplaceReferences([ + 'https://dev.azure.com/org/project/_git/repo', + 'https://dev.azure.com/org/project/_git/repo.git', + ]); + assert.strictEqual(parsed.length, 1); + assert.strictEqual(parsed[0].canonicalId, 'git:dev.azure.com/org/project/_git/repo.git'); + }); + test('parses HTTPS URI with trailing slash after .git', () => { const parsed = parseMarketplaceReference('https://example.com/org/repo.git/'); assert.ok(parsed); @@ -142,6 +171,7 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { description: 'A test plugin', version: '2.0.0', source: 'plugins/my-plugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/my-plugin' } as const, marketplace: marketplaceRef.displayLabel, marketplaceReference: marketplaceRef, marketplaceType: MarketplaceType.Copilot, @@ -165,3 +195,152 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { assert.strictEqual(result, undefined); }); }); + +suite('parsePluginSource', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const logContext = { + pluginName: 'test', + logService: new NullLogService(), + logPrefix: '[test]', + }; + + test('parses string source as RelativePath', () => { + const result = parsePluginSource('./my-plugin', undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'my-plugin' }); + }); + + test('parses string source with pluginRoot', () => { + const result = parsePluginSource('sub', 'plugins', logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'plugins/sub' }); + }); + + test('parses undefined source as RelativePath using pluginRoot', () => { + const result = parsePluginSource(undefined, 'root', logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'root' }); + }); + + test('parses empty string source as RelativePath using pluginRoot', () => { + const result = parsePluginSource('', 'base', logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'base' }); + }); + + test('returns undefined for empty source without pluginRoot', () => { + assert.strictEqual(parsePluginSource('', undefined, logContext), undefined); + }); + + test('parses github object source', () => { + const result = parsePluginSource({ source: 'github', repo: 'owner/repo' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined }); + }); + + test('parses github object source with ref and sha', () => { + const result = parsePluginSource({ source: 'github', repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }); + }); + + test('returns undefined for github source missing repo', () => { + assert.strictEqual(parsePluginSource({ source: 'github' }, undefined, logContext), undefined); + }); + + test('returns undefined for github source with invalid repo format', () => { + assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner' }, undefined, logContext), undefined); + }); + + test('returns undefined for github source with invalid sha', () => { + assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner/repo', sha: 'abc123' }, undefined, logContext), undefined); + }); + + test('parses url object source', () => { + const result = parsePluginSource({ source: 'url', url: 'https://gitlab.com/team/plugin.git' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitUrl, url: 'https://gitlab.com/team/plugin.git', ref: undefined, sha: undefined }); + }); + + test('returns undefined for url source missing url field', () => { + assert.strictEqual(parsePluginSource({ source: 'url' }, undefined, logContext), undefined); + }); + + test('returns undefined for url source not ending in .git', () => { + assert.strictEqual(parsePluginSource({ source: 'url', url: 'https://gitlab.com/team/plugin' }, undefined, logContext), undefined); + }); + + test('parses npm object source', () => { + const result = parsePluginSource({ source: 'npm', package: '@acme/claude-plugin' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Npm, package: '@acme/claude-plugin', version: undefined, registry: undefined }); + }); + + test('parses npm object source with version and registry', () => { + const result = parsePluginSource({ source: 'npm', package: '@acme/claude-plugin', version: '2.1.0', registry: 'https://npm.example.com' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Npm, package: '@acme/claude-plugin', version: '2.1.0', registry: 'https://npm.example.com' }); + }); + + test('returns undefined for npm source missing package', () => { + assert.strictEqual(parsePluginSource({ source: 'npm' }, undefined, logContext), undefined); + }); + + test('returns undefined for npm source with non-string version', () => { + assert.strictEqual(parsePluginSource({ source: 'npm', package: '@acme/claude-plugin', version: 123 } as never, undefined, logContext), undefined); + }); + + test('parses pip object source', () => { + const result = parsePluginSource({ source: 'pip', package: 'my-plugin' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Pip, package: 'my-plugin', version: undefined, registry: undefined }); + }); + + test('parses pip object source with version and registry', () => { + const result = parsePluginSource({ source: 'pip', package: 'my-plugin', version: '1.0.0', registry: 'https://pypi.example.com' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Pip, package: 'my-plugin', version: '1.0.0', registry: 'https://pypi.example.com' }); + }); + + test('returns undefined for pip source missing package', () => { + assert.strictEqual(parsePluginSource({ source: 'pip' }, undefined, logContext), undefined); + }); + + test('returns undefined for pip source with non-string registry', () => { + assert.strictEqual(parsePluginSource({ source: 'pip', package: 'my-plugin', registry: 42 } as never, undefined, logContext), undefined); + }); + + test('returns undefined for unknown source kind', () => { + assert.strictEqual(parsePluginSource({ source: 'unknown' }, undefined, logContext), undefined); + }); + + test('returns undefined for object source without source discriminant', () => { + assert.strictEqual(parsePluginSource({ package: 'test' } as never, undefined, logContext), undefined); + }); +}); + +suite('getPluginSourceLabel', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('formats relative path', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.RelativePath, path: 'plugins/foo' }), 'plugins/foo'); + }); + + test('formats empty relative path', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.RelativePath, path: '' }), '.'); + }); + + test('formats github source', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitHub, repo: 'owner/repo' }), 'owner/repo'); + }); + + test('formats url source', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }), 'https://example.com/repo.git'); + }); + + test('formats npm source without version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Npm, package: '@acme/plugin' }), '@acme/plugin'); + }); + + test('formats npm source with version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Npm, package: '@acme/plugin', version: '1.0.0' }), '@acme/plugin@1.0.0'); + }); + + test('formats pip source without version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Pip, package: 'my-plugin' }), 'my-plugin'); + }); + + test('formats pip source with version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Pip, package: 'my-plugin', version: '2.0' }), 'my-plugin==2.0'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index 8a0dcd1bef3..899d04339a4 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -24,9 +24,10 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { testWorkspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; -import { TestContextService, TestUserDataProfileService } from '../../../../../test/common/workbenchTestServices.js'; +import { TestContextService, TestUserDataProfileService, TestWorkspaceTrustManagementService } from '../../../../../test/common/workbenchTestServices.js'; import { ChatRequestVariableSet, isPromptFileVariableEntry, isPromptTextVariableEntry, toFileVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions, getFilePath, InstructionsCollectionEvent } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; @@ -181,10 +182,11 @@ suite('ComputeAutomaticInstructions', () => { instaService.stub(IContextKeyService, new MockContextKeyService()); + instaService.stub(IWorkspaceTrustManagementService, disposables.add(new TestWorkspaceTrustManagementService())); + instaService.stub(IAgentPluginService, { plugins: observableValue('testPlugins', []), - allPlugins: observableValue('testAllPlugins', []), - setPluginEnabled: () => { }, + enablementModel: { readEnabled: () => 2 /* EnabledProfile */, setEnabled: () => { } }, }); service = disposables.add(instaService.createInstance(PromptsService)); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts index 76b2ac54b07..2ba30ef5848 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { HookType } from '../../../common/promptSyntax/hookSchema.js'; +import { HookType } from '../../../common/promptSyntax/hookTypes.js'; import { parseClaudeHooks, resolveClaudeHookType, getClaudeHookTypeName, extractHookCommandsFromItem } from '../../../common/promptSyntax/hookClaudeCompat.js'; import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../../common/promptSyntax/hookCompatibility.js'; import { URI } from '../../../../../../base/common/uri.js'; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts index ede5eeb5e52..7fd7eee9304 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { HookType } from '../../../common/promptSyntax/hookSchema.js'; +import { HookType } from '../../../common/promptSyntax/hookTypes.js'; import { parseCopilotHooks, parseHooksFromFile, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js'; import { URI } from '../../../../../../base/common/uri.js'; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts index 56cf17fafc8..ee30e3f60dc 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts @@ -5,9 +5,11 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { resolveHookCommand, resolveEffectiveCommand, formatHookCommandLabel, IHookCommand } from '../../../common/promptSyntax/hookSchema.js'; +import { resolveHookCommand, resolveEffectiveCommand, formatHookCommandLabel, IHookCommand, parseSubagentHooksFromYaml } from '../../../common/promptSyntax/hookSchema.js'; import { URI } from '../../../../../../base/common/uri.js'; import { OperatingSystem } from '../../../../../../base/common/platform.js'; +import { HookType } from '../../../common/promptSyntax/hookTypes.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; suite('HookSchema', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -485,4 +487,162 @@ suite('HookSchema', () => { assert.strictEqual(formatHookCommandLabel(hook, OperatingSystem.Windows), 'default-command'); }); }); + + suite('parseSubagentHooksFromYaml', () => { + + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + const dummyRange = new Range(1, 1, 1, 1); + + function makeScalar(value: string): import('../../../common/promptSyntax/promptFileParser.js').IScalarValue { + return { type: 'scalar', value, range: dummyRange, format: 'none' }; + } + + function makeMap(entries: Record): import('../../../common/promptSyntax/promptFileParser.js').IMapValue { + const properties = Object.entries(entries).map(([key, value]) => ({ + key: makeScalar(key), + value, + })); + return { type: 'map', properties, range: dummyRange }; + } + + function makeSequence(items: import('../../../common/promptSyntax/promptFileParser.js').IValue[]): import('../../../common/promptSyntax/promptFileParser.js').ISequenceValue { + return { type: 'sequence', items, range: dummyRange }; + } + + test('parses direct command format (without matcher)', () => { + // hooks: + // PreToolUse: + // - type: command + // command: "./scripts/validate.sh" + const hooksMap = makeMap({ + 'PreToolUse': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('./scripts/validate.sh'), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse]?.length, 1); + assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/validate.sh'); + }); + + test('parses Claude format (with matcher)', () => { + // hooks: + // PreToolUse: + // - matcher: "Bash" + // hooks: + // - type: command + // command: "./scripts/validate-readonly.sh" + const hooksMap = makeMap({ + 'PreToolUse': makeSequence([ + makeMap({ + 'matcher': makeScalar('Bash'), + 'hooks': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('./scripts/validate-readonly.sh'), + }), + ]), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse]?.length, 1); + assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/validate-readonly.sh'); + }); + + test('parses multiple hook types', () => { + const hooksMap = makeMap({ + 'PreToolUse': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('./scripts/pre.sh'), + }), + ]), + 'PostToolUse': makeSequence([ + makeMap({ + 'matcher': makeScalar('Edit|Write'), + 'hooks': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('./scripts/lint.sh'), + }), + ]), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse]?.length, 1); + assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/pre.sh'); + assert.strictEqual(result[HookType.PostToolUse]?.length, 1); + assert.strictEqual(result[HookType.PostToolUse]![0].command, './scripts/lint.sh'); + }); + + test('skips unknown hook types', () => { + const hooksMap = makeMap({ + 'UnknownHook': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('echo "ignored"'), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse], undefined); + assert.strictEqual(result[HookType.PostToolUse], undefined); + }); + + test('handles command without type field', () => { + const hooksMap = makeMap({ + 'PreToolUse': makeSequence([ + makeMap({ + 'command': makeScalar('./scripts/validate.sh'), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse]?.length, 1); + assert.strictEqual(result[HookType.PreToolUse]![0].command, './scripts/validate.sh'); + }); + + test('resolves cwd relative to workspace', () => { + const hooksMap = makeMap({ + 'SessionStart': makeSequence([ + makeMap({ + 'type': makeScalar('command'), + 'command': makeScalar('echo "start"'), + 'cwd': makeScalar('src'), + }), + ]), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.SessionStart]?.length, 1); + assert.deepStrictEqual(result[HookType.SessionStart]![0].cwd, URI.file('/workspace/src')); + }); + + test('skips non-sequence hook values', () => { + const hooksMap = makeMap({ + 'PreToolUse': makeScalar('not-a-sequence'), + }); + + const result = parseSubagentHooksFromYaml(hooksMap, workspaceRoot, userHome); + + assert.strictEqual(result[HookType.PreToolUse], undefined); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index e01bc0089f0..d9ef7bdde73 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -18,8 +18,8 @@ export class MockPromptsService implements IPromptsService { _serviceBrand: undefined; - private readonly _onDidChangeCustomChatModes = new Emitter(); - readonly onDidChangeCustomAgents = this._onDidChangeCustomChatModes.event; + private readonly _onDidChangeCustomAgents = new Emitter(); + readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event; private readonly _onDidLogDiscovery = new Emitter(); readonly onDidLogDiscovery: Event = this._onDidLogDiscovery.event; @@ -28,7 +28,7 @@ export class MockPromptsService implements IPromptsService { setCustomModes(modes: ICustomAgent[]): void { this._customModes = modes; - this._onDidChangeCustomChatModes.fire(); + this._onDidChangeCustomAgents.fire(); } async getCustomAgents(token: CancellationToken, sessionResource?: URI): Promise { @@ -67,9 +67,10 @@ export class MockPromptsService implements IPromptsService { registerPromptFileProvider(extension: IExtensionDescription, type: PromptsType, provider: { providePromptFiles: (context: IPromptFileContext, token: CancellationToken) => Promise }): IDisposable { throw new Error('Method not implemented.'); } findAgentSkills(token: CancellationToken, sessionResource?: URI): Promise { throw new Error('Method not implemented.'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - getPromptDiscoveryInfo(_type: any, _token: CancellationToken, _sessionResource?: URI): Promise { throw new Error('Method not implemented.'); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any getHooks(_token: CancellationToken, _sessionResource?: URI): Promise { throw new Error('Method not implemented.'); } getInstructionFiles(_token: CancellationToken, _sessionResource?: URI): Promise { throw new Error('Method not implemented.'); } dispose(): void { } + onDidChangeInstructions: Event = Event.None; + onDidChangePromptFiles: Event = Event.None; + onDidChangeSkills: Event = Event.None; } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index d77772465b4..24374b4316e 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -11,7 +11,7 @@ import { match } from '../../../../../../../base/common/glob.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../../base/common/network.js'; import { ISettableObservable, observableValue } from '../../../../../../../base/common/observable.js'; -import { relativePath } from '../../../../../../../base/common/resources.js'; +import { basename, relativePath } from '../../../../../../../base/common/resources.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; @@ -35,13 +35,13 @@ import { IWorkbenchEnvironmentService } from '../../../../../../services/environ import { IFilesConfigurationService } from '../../../../../../services/filesConfiguration/common/filesConfigurationService.js'; import { IUserDataProfileService } from '../../../../../../services/userDataProfile/common/userDataProfile.js'; import { toUserDataProfile } from '../../../../../../../platform/userDataProfile/common/userDataProfile.js'; -import { TestContextService, TestUserDataProfileService } from '../../../../../../test/common/workbenchTestServices.js'; +import { TestContextService, TestUserDataProfileService, TestWorkspaceTrustManagementService } from '../../../../../../test/common/workbenchTestServices.js'; import { ChatRequestVariableSet, isPromptFileVariableEntry, toFileVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '../../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; import { AGENTS_SOURCE_FOLDER, CLAUDE_CONFIG_FOLDER, HOOKS_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; -import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; -import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType, Target } from '../../../../common/promptSyntax/promptTypes.js'; +import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; import { mockFiles } from '../testUtils/mockFilesystem.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../../platform/storage/common/storage.js'; @@ -50,10 +50,11 @@ import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../servic import { IExtensionService } from '../../../../../../services/extensions/common/extensions.js'; import { IRemoteAgentService } from '../../../../../../services/remote/common/remoteAgentService.js'; import { ChatModeKind } from '../../../../common/constants.js'; -import { HookType } from '../../../../common/promptSyntax/hookSchema.js'; +import { HookType } from '../../../../common/promptSyntax/hookTypes.js'; import { IContextKeyService, IContextKeyChangeEvent } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IAgentPlugin, IAgentPluginAgent, IAgentPluginCommand, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from '../../../../common/plugins/agentPluginService.js'; +import { IWorkspaceTrustManagementService } from '../../../../../../../platform/workspace/common/workspaceTrust.js'; suite('PromptsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -64,7 +65,7 @@ suite('PromptsService', () => { let testConfigService: TestConfigurationService; let fileService: IFileService; let testPluginsObservable: ISettableObservable; - let testAllPluginsObservable: ISettableObservable; + let workspaceTrustService: TestWorkspaceTrustManagementService; setup(async () => { instaService = disposables.add(new TestInstantiationService()); @@ -166,13 +167,14 @@ suite('PromptsService', () => { instaService.stub(IContextKeyService, new MockContextKeyService()); + workspaceTrustService = disposables.add(new TestWorkspaceTrustManagementService()); + instaService.stub(IWorkspaceTrustManagementService, workspaceTrustService); + testPluginsObservable = observableValue('testPlugins', []); - testAllPluginsObservable = observableValue('testAllPlugins', []); instaService.stub(IAgentPluginService, { plugins: testPluginsObservable, - allPlugins: testAllPluginsObservable, - setPluginEnabled: () => { }, + enablementModel: { readEnabled: () => 2 /* EnabledProfile */, setEnabled: () => { } }, }); service = disposables.add(instaService.createInstance(PromptsService)); @@ -794,6 +796,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local } }, @@ -850,6 +853,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local }, }, @@ -925,6 +929,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local } }, @@ -943,6 +948,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1013,6 +1019,7 @@ suite('PromptsService', () => { argumentHint: undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/github-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1031,6 +1038,7 @@ suite('PromptsService', () => { tools: undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/vscode-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1049,6 +1057,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/generic-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1126,6 +1135,7 @@ suite('PromptsService', () => { argumentHint: undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/copilot-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1146,6 +1156,7 @@ suite('PromptsService', () => { argumentHint: undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent.md'), source: { storage: PromptsStorage.local } }, @@ -1165,6 +1176,7 @@ suite('PromptsService', () => { argumentHint: undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent2.md'), source: { storage: PromptsStorage.local } }, @@ -1221,6 +1233,7 @@ suite('PromptsService', () => { target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/demonstrate.md'), source: { storage: PromptsStorage.local } } @@ -1291,6 +1304,7 @@ suite('PromptsService', () => { argumentHint: undefined, target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/restricted-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1309,6 +1323,7 @@ suite('PromptsService', () => { tools: undefined, target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/no-access-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1327,6 +1342,7 @@ suite('PromptsService', () => { tools: undefined, target: Target.Undefined, visibility: { userInvocable: true, agentInvocable: true }, + hooks: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/full-access-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -3495,7 +3511,7 @@ suite('PromptsService', () => { suite('hooks', () => { const createTestPlugin = (path: string, initialHooks: readonly IAgentPluginHook[]): { plugin: IAgentPlugin; hooks: ISettableObservable } => { - const enabled = observableValue('testPluginEnabled', true); + const enablement = observableValue('testPluginEnablement', 2 /* ContributionEnablementState.EnabledProfile */); const hooks = observableValue('testPluginHooks', initialHooks); const commands = observableValue('testPluginCommands', []); const skills = observableValue('testPluginSkills', []); @@ -3505,8 +3521,8 @@ suite('PromptsService', () => { return { plugin: { uri: URI.file(path), - enabled, - setEnabled: () => { }, + label: basename(URI.file(path)), + enablement, remove: () => { }, hooks, commands, @@ -3580,7 +3596,6 @@ suite('PromptsService', () => { }]); testPluginsObservable.set([plugin], undefined); - testAllPluginsObservable.set([plugin], undefined); const result = await service.getHooks(CancellationToken.None); assert.ok(result, 'Expected hooks result'); @@ -3602,7 +3617,6 @@ suite('PromptsService', () => { }]); testPluginsObservable.set([plugin], undefined); - testAllPluginsObservable.set([plugin], undefined); const before = await service.getHooks(CancellationToken.None); assert.ok(before, 'Expected hooks result before plugin update'); @@ -3618,5 +3632,59 @@ suite('PromptsService', () => { assert.ok(after, 'Expected hooks result after plugin update'); assert.deepStrictEqual(after.hooks[HookType.PreToolUse], [{ type: 'command', command: 'echo after' }]); }); + + test('returns undefined when workspace is untrusted', async function () { + workspaceContextService.setWorkspace(testWorkspace(URI.file('/test-workspace'))); + testConfigService.setUserConfiguration(PromptsConfig.USE_CHAT_HOOKS, true); + testConfigService.setUserConfiguration(PromptsConfig.HOOKS_LOCATION_KEY, { [HOOKS_SOURCE_FOLDER]: true }); + + await mockFiles(fileService, [ + { + path: '/test-workspace/.github/hooks/my-hook.json', + contents: [ + JSON.stringify({ + hooks: { + [HookType.PreToolUse]: [ + { type: 'command', command: 'echo test' }, + ], + }, + }), + ], + }, + ]); + + // Trusted workspace should return hooks + const trustedResult = await service.getHooks(CancellationToken.None); + assert.ok(trustedResult, 'Expected hooks when workspace is trusted'); + assert.strictEqual(trustedResult.hooks[HookType.PreToolUse]?.length, 1); + + // Untrusted workspace should return undefined + await workspaceTrustService.setWorkspaceTrust(false); + const untrustedResult = await service.getHooks(CancellationToken.None); + assert.strictEqual(untrustedResult, undefined, 'Expected undefined hooks when workspace is untrusted'); + + // Re-trusting should return hooks again + await workspaceTrustService.setWorkspaceTrust(true); + const reTrustedResult = await service.getHooks(CancellationToken.None); + assert.ok(reTrustedResult, 'Expected hooks after workspace becomes trusted again'); + assert.strictEqual(reTrustedResult.hooks[HookType.PreToolUse]?.length, 1); + }); + + test('suppresses plugin hooks when workspace is untrusted', async function () { + testConfigService.setUserConfiguration(PromptsConfig.USE_CHAT_HOOKS, true); + testConfigService.setUserConfiguration(PromptsConfig.HOOKS_LOCATION_KEY, {}); + + const { plugin } = createTestPlugin('/plugins/test-plugin', [{ + type: HookType.PreToolUse, + originalId: 'plugin-pre-tool-use', + hooks: [{ type: 'command', command: 'echo from-plugin' }], + }]); + + testPluginsObservable.set([plugin], undefined); + + await workspaceTrustService.setWorkspaceTrust(false); + const result = await service.getHooks(CancellationToken.None); + assert.strictEqual(result, undefined, 'Expected undefined hooks when workspace is untrusted, even with plugin hooks'); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts index 5d9664f7907..2dde1dfb0a2 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/utils/promptFilesLocator.test.ts @@ -2366,6 +2366,69 @@ suite('PromptFilesLocator', () => { }); }); + suite('instructions', () => { + testT('finds instructions files in subdirectories of .github/instructions', async () => { + const locator = await createPromptsLocator( + { + '.github/instructions': true, + '.claude/rules': false, + '~/.copilot/instructions': false, + }, + ['/Users/legomushroom/repos/vscode'], + [ + { + name: '/Users/legomushroom/repos/vscode', + children: [ + { + name: '.github/instructions', + children: [ + { + name: 'root.instructions.md', + contents: 'root instructions', + }, + { + name: 'frontend', + children: [ + { + name: 'react.instructions.md', + contents: 'react instructions', + }, + { + name: 'css.instructions.md', + contents: 'css instructions', + }, + ], + }, + { + name: 'backend', + children: [ + { + name: 'api.instructions.md', + contents: 'api instructions', + }, + ], + }, + ], + }, + ], + }, + ], + ); + + assertOutcome( + await locator.listFiles(PromptsType.instructions, PromptsStorage.local, CancellationToken.None), + [ + '/Users/legomushroom/repos/vscode/.github/instructions/root.instructions.md', + '/Users/legomushroom/repos/vscode/.github/instructions/frontend/react.instructions.md', + '/Users/legomushroom/repos/vscode/.github/instructions/frontend/css.instructions.md', + '/Users/legomushroom/repos/vscode/.github/instructions/backend/api.instructions.md', + ], + 'Must find instructions files recursively in subdirectories of .github/instructions.', + ); + await locator.disposeAsync(); + }); + }); + suite('skills', () => { suite('findAgentSkills', () => { testT('finds skill files in configured locations', async () => { diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts index 96e754d3688..fa6cc7ac065 100644 --- a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts @@ -6,6 +6,7 @@ import { mockObject } from '../../../../../../base/test/common/mock.js'; import { assertSnapshot } from '../../../../../../base/test/common/snapshot.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { Event } from '../../../../../../base/common/event.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; @@ -70,10 +71,9 @@ suite('ChatRequestParser', () => { }); test('slash command', async () => { - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([{ command: 'fix' }]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const text = '/fix this'; @@ -82,10 +82,9 @@ suite('ChatRequestParser', () => { }); test('invalid slash command', async () => { - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([{ command: 'fix' }]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const text = '/explain this'; @@ -94,10 +93,9 @@ suite('ChatRequestParser', () => { }); test('multiple slash commands', async () => { - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([{ command: 'fix' }]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const text = '/fix /fix'; @@ -106,10 +104,9 @@ suite('ChatRequestParser', () => { }); test('slash command not first', async () => { - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([{ command: 'fix' }]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const text = 'Hello /fix'; @@ -118,10 +115,9 @@ suite('ChatRequestParser', () => { }); test('slash command after whitespace', async () => { - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([{ command: 'fix' }]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const text = ' /fix'; @@ -130,17 +126,15 @@ suite('ChatRequestParser', () => { }); test('prompt slash command', async () => { - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([{ command: 'fix' }]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); - const promptSlashCommandService = mockObject()({}); + const promptSlashCommandService = mockObject()({ _serviceBrand: undefined }); promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { return !!command.match(/^[\w_\-\.]+$/); }); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IPromptsService, promptSlashCommandService as any); + instantiationService.stub(IPromptsService, promptSlashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const text = ' /prompt'; @@ -149,17 +143,15 @@ suite('ChatRequestParser', () => { }); test('prompt slash command after text', async () => { - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([{ command: 'fix' }]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); - const promptSlashCommandService = mockObject()({}); + const promptSlashCommandService = mockObject()({ _serviceBrand: undefined }); promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { return !!command.match(/^[\w_\-\.]+$/); }); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IPromptsService, promptSlashCommandService as any); + instantiationService.stub(IPromptsService, promptSlashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const text = 'handle the / route and the request of /search-option'; @@ -168,18 +160,16 @@ suite('ChatRequestParser', () => { }); test('prompt slash command after slash', async () => { - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([{ command: 'fix' }]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); - const promptSlashCommandService = mockObject()({}); + const promptSlashCommandService = mockObject()({ _serviceBrand: undefined }); promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { return !!command.match(/^[\w_\-\.]+$/); }); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IPromptsService, promptSlashCommandService as any); + instantiationService.stub(IPromptsService, promptSlashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const text = '/ route and the request of /search-option'; @@ -188,17 +178,15 @@ suite('ChatRequestParser', () => { }); test('prompt slash command with numbers', async () => { - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([{ command: 'fix' }]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); - const promptSlashCommandService = mockObject()({}); + const promptSlashCommandService = mockObject()({ _serviceBrand: undefined }); promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { return !!command.match(/^[\w_\-\.]+$/); }); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IPromptsService, promptSlashCommandService as any); + instantiationService.stub(IPromptsService, promptSlashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const text = '/001-sample this is a test'; @@ -240,10 +228,9 @@ suite('ChatRequestParser', () => { }; test('agent with subcommand after text', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest(testSessionUri, '@agent Please do /subCommand thanks'); @@ -251,10 +238,9 @@ suite('ChatRequestParser', () => { }); test('agents, subCommand', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest(testSessionUri, '@agent /subCommand Please do thanks'); @@ -262,10 +248,9 @@ suite('ChatRequestParser', () => { }); test('agent but edit mode', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest(testSessionUri, '@agent hello', undefined, { mode: ChatModeKind.Edit }); @@ -273,10 +258,9 @@ suite('ChatRequestParser', () => { }); test('agent with question mark', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest(testSessionUri, '@agent? Are you there'); @@ -284,10 +268,9 @@ suite('ChatRequestParser', () => { }); test('agent and subcommand with leading whitespace', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest(testSessionUri, ' \r\n\t @agent \r\n\t /subCommand Thanks'); @@ -295,10 +278,9 @@ suite('ChatRequestParser', () => { }); test('agent and subcommand after newline', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest(testSessionUri, ' \n@agent\n/subCommand Thanks'); @@ -306,10 +288,9 @@ suite('ChatRequestParser', () => { }); test('agent not first', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest(testSessionUri, 'Hello Mr. @agent'); @@ -317,10 +298,9 @@ suite('ChatRequestParser', () => { }); test('agents and tools and multiline', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); variableService.setSelectedToolAndToolSets(testSessionUri, new Map([ [{ id: 'get_selection', toolReferenceName: 'selection', canBeReferencedInPrompt: true, displayName: '', modelDescription: '', source: ToolDataSource.Internal }, true], @@ -333,10 +313,9 @@ suite('ChatRequestParser', () => { }); test('agents and tools and multiline, part2', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); variableService.setSelectedToolAndToolSets(testSessionUri, new Map([ [{ id: 'get_selection', toolReferenceName: 'selection', canBeReferencedInPrompt: true, displayName: '', modelDescription: '', source: ToolDataSource.Internal }, true], @@ -349,22 +328,19 @@ suite('ChatRequestParser', () => { }); test('prompt slash command with agent and supportsPromptAttachments', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); - const promptSlashCommandService = mockObject()({}); + const promptSlashCommandService = mockObject()({ _serviceBrand: undefined }); promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { return !!command.match(/^[\w_\-\.]+$/); }); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IPromptsService, promptSlashCommandService as any); + instantiationService.stub(IPromptsService, promptSlashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest(testSessionUri, '@agent /myPrompt do something', undefined, { @@ -374,22 +350,19 @@ suite('ChatRequestParser', () => { }); test('prompt slash command with agent but no supportsPromptAttachments', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); - const promptSlashCommandService = mockObject()({}); + const promptSlashCommandService = mockObject()({ _serviceBrand: undefined }); promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { return !!command.match(/^[\w_\-\.]+$/); }); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IPromptsService, promptSlashCommandService as any); + instantiationService.stub(IPromptsService, promptSlashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest(testSessionUri, '@agent /myPrompt do something', undefined, { @@ -399,22 +372,19 @@ suite('ChatRequestParser', () => { }); test('agent subcommand still takes priority with supportsPromptAttachments', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); - const promptSlashCommandService = mockObject()({}); + const promptSlashCommandService = mockObject()({ _serviceBrand: undefined }); promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { return !!command.match(/^[\w_\-\.]+$/); }); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IPromptsService, promptSlashCommandService as any); + instantiationService.stub(IPromptsService, promptSlashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest(testSessionUri, '@agent /subCommand do something', undefined, { @@ -424,15 +394,13 @@ suite('ChatRequestParser', () => { }); test('slash command with agent and supportsPromptAttachments', async () => { - const agentsService = mockObject()({}); + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatAgentService, agentsService as any); + instantiationService.stub(IChatAgentService, agentsService); - const slashCommandService = mockObject()({}); + const slashCommandService = mockObject()({ _serviceBrand: undefined }); slashCommandService.getCommands.returns([{ command: 'fix' }]); - // eslint-disable-next-line local/code-no-any-casts - instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + instantiationService.stub(IChatSlashCommandService, slashCommandService); parser = instantiationService.createInstance(ChatRequestParser); const result = parser.parseChatRequest(testSessionUri, '@agent /fix this', undefined, { diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts index f82b6bbe55d..8085c61d223 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/askQuestionsTool.test.ts @@ -4,14 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; -import { NullLogService } from '../../../../../../../platform/log/common/log.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IChatQuestionAnswers, IChatService } from '../../../../common/chatService/chatService.js'; import { AskQuestionsTool, IAnswerResult, IQuestion, IQuestionAnswer } from '../../../../common/tools/builtinTools/askQuestionsTool.js'; -import { IChatService } from '../../../../common/chatService/chatService.js'; class TestableAskQuestionsTool extends AskQuestionsTool { - public testConvertCarouselAnswers(questions: IQuestion[], carouselAnswers: Record | undefined): IAnswerResult { + public testConvertCarouselAnswers(questions: IQuestion[], carouselAnswers: IChatQuestionAnswers | undefined): IAnswerResult { // Create an identity map where each header is also the internal ID // This simulates the simple case for testing the answer conversion logic const idToHeaderMap = new Map(); @@ -70,7 +70,7 @@ suite('AskQuestionsTool - convertCarouselAnswers', () => { { header: 'Features', question: 'Pick features', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] } ]; - const result = tool.testConvertCarouselAnswers(questions, { Features: ['A', 'B'] }); + const result = tool.testConvertCarouselAnswers(questions, { Features: { selectedValues: ['A', 'B'] } }); assert.deepStrictEqual(result.answers['Features'], { selected: ['A', 'B'], freeText: null, skipped: false }); }); @@ -131,7 +131,7 @@ suite('AskQuestionsTool - convertCarouselAnswers', () => { const result = tool.testConvertCarouselAnswers(questions, { Q1: 'text', Q2: { selectedValue: 'A' }, - Q3: ['x', 'y'] + Q3: { selectedValues: ['x', 'y'] } }); assert.strictEqual(result.answers['Q1'].freeText, 'text'); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/modifiedFilesConfirmationTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/modifiedFilesConfirmationTool.test.ts new file mode 100644 index 00000000000..19655689064 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/modifiedFilesConfirmationTool.test.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { ModifiedFilesConfirmationTool, ModifiedFilesConfirmationToolData } from '../../../../common/tools/builtinTools/confirmationTool.js'; + +suite('ModifiedFilesConfirmationTool', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('tool data exposes the expected schema', () => { + assert.strictEqual(ModifiedFilesConfirmationToolData.id, 'vscode_get_modified_files_confirmation'); + assert.ok(ModifiedFilesConfirmationToolData.inputSchema); + assert.deepStrictEqual(ModifiedFilesConfirmationToolData.inputSchema?.required, ['title', 'message', 'options', 'modifiedFiles']); + assert.ok(ModifiedFilesConfirmationToolData.inputSchema?.properties?.options); + assert.ok(ModifiedFilesConfirmationToolData.inputSchema?.properties?.modifiedFiles); + }); + + test('prepareToolInvocation parses file data and disables auto confirm', async () => { + const tool = new ModifiedFilesConfirmationTool(); + + const result = await tool.prepareToolInvocation({ + parameters: { + title: 'Review modified files', + message: 'Choose how to continue.', + options: ['Copy Changes', 'Move Changes'], + modifiedFiles: [{ + uri: 'file:///workspace/src/file1.ts', + originalUri: 'file:///workspace/src/file1.original.ts', + insertions: 10, + deletions: 3, + title: 'File 1' + }] + }, + toolCallId: 'call-1', + chatSessionResource: URI.parse('vscode-chat://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.strictEqual(result?.confirmationMessages?.allowAutoConfirm, false); + assert.strictEqual(result?.toolSpecificData?.kind, 'modifiedFilesConfirmation'); + + assert.deepStrictEqual(result.toolSpecificData.options, ['Copy Changes', 'Move Changes']); + assert.strictEqual(URI.revive(result.toolSpecificData.modifiedFiles[0].uri).toString(), 'file:///workspace/src/file1.ts'); + assert.strictEqual(result.toolSpecificData.modifiedFiles[0].originalUri ? URI.revive(result.toolSpecificData.modifiedFiles[0].originalUri).toString() : undefined, 'file:///workspace/src/file1.original.ts'); + assert.strictEqual(result.toolSpecificData.modifiedFiles[0].insertions, 10); + assert.strictEqual(result.toolSpecificData.modifiedFiles[0].deletions, 3); + }); + + test('invoke returns the selected option', async () => { + const tool = new ModifiedFilesConfirmationTool(); + + const result = await tool.invoke({ + callId: 'call-1', + toolId: 'vscode_get_modified_files_confirmation', + parameters: {}, + selectedCustomButton: 'Move Changes', + context: undefined, + }, async () => 0, { report: () => undefined }, CancellationToken.None); + + assert.deepStrictEqual(result.content, [{ kind: 'text', value: 'Move Changes' }]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 2efa4f1af16..79d99695eb5 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -16,7 +16,8 @@ import { IChatService } from '../../../../common/chatService/chatService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../../../../platform/product/common/productService.js'; -import { ICustomAgent, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { Target } from '../../../../common/promptSyntax/promptTypes.js'; import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; diff --git a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts index e8d3da161cb..d3ce0affbf8 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsConfirmationService.ts @@ -9,12 +9,15 @@ import { ILanguageModelToolConfirmationActions, ILanguageModelToolConfirmationCo import { IToolData } from '../../../common/tools/languageModelToolsService.js'; export class MockLanguageModelToolsConfirmationService implements ILanguageModelToolsConfirmationService { - manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session' }): void { + manageConfirmationPreferences(tools: readonly IToolData[], options?: { defaultScope?: 'workspace' | 'profile' | 'session'; focusToolId?: string }): void { throw new Error('Method not implemented.'); } registerConfirmationContribution(toolName: string, contribution: ILanguageModelToolConfirmationContribution): IDisposable { throw new Error('Method not implemented.'); } + toolCanManageConfirmation(): boolean { + return false; + } resetToolAutoConfirmation(): void { } diff --git a/src/vs/workbench/contrib/codeEditor/browser/accessibility/accessibility.css b/src/vs/workbench/contrib/codeEditor/browser/accessibility/accessibility.css index cda82e4a998..6767728af16 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/accessibility/accessibility.css +++ b/src/vs/workbench/contrib/codeEditor/browser/accessibility/accessibility.css @@ -7,7 +7,7 @@ position: absolute; background-color: var(--vscode-editorWidget-background); color: var(--vscode-editorWidget-foreground); - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); border: 2px solid var(--vscode-focusBorder); border-radius: 6px; margin-top: -1px; diff --git a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.css b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.css index 0a8d982123f..b002f8c148d 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.css +++ b/src/vs/workbench/contrib/codeEditor/browser/dictation/editorDictation.css @@ -9,7 +9,7 @@ border-radius: 8px; display: flex; align-items: center; - box-shadow: 0 4px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); z-index: 1000; min-height: var(--vscode-editor-dictation-widget-height); line-height: var(--vscode-editor-dictation-widget-height); diff --git a/src/vs/workbench/contrib/codeEditor/browser/editorFindAccessibilityHelp.ts b/src/vs/workbench/contrib/codeEditor/browser/editorFindAccessibilityHelp.ts index 32b0152643d..c0cef1433a2 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/editorFindAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/editorFindAccessibilityHelp.ts @@ -300,6 +300,7 @@ class EditorFindAccessibilityHelpProvider extends Disposable implements IAccessi content.push(localize('find.settingSeed', "- `editor.find.seedSearchStringFromSelection`: Controls when selection text is used to seed Find.")); content.push(localize('find.settingAutoSelection', "- `editor.find.autoFindInSelection`: Automatically enables Find in Selection based on selection type.")); content.push(localize('find.settingLoop', "- `editor.find.loop`: Wraps search at the beginning or end of the file.")); + content.push(localize('find.settingCloseOnResult', "- `editor.find.closeOnResult`: Closes the Find dialog after an explicit find navigation command lands on a match.")); content.push(localize('find.settingExtraSpace', "- `editor.find.addExtraSpaceOnTop`: Adds extra scroll space so matches are not hidden behind the Find dialog.")); content.push(localize('find.settingHistory', "- `editor.find.history`: Controls whether Find search history is stored.")); content.push(localize('find.settingOccurrences', "- `editor.occurrencesHighlight`: Highlights other occurrences of the current symbol.")); diff --git a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css index 5f1597b9de2..6fb4864cf87 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css +++ b/src/vs/workbench/contrib/codeEditor/browser/find/simpleFindWidget.css @@ -30,7 +30,7 @@ transition: top 200ms linear; background-color: var(--vscode-editorWidget-background) !important; color: var(--vscode-editorWidget-foreground); - box-shadow: 0 0 8px 2px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); border: 1px solid var(--vscode-widget-border); border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; diff --git a/src/vs/workbench/contrib/debug/browser/debugToolBar.ts b/src/vs/workbench/contrib/debug/browser/debugToolBar.ts index 2e54d39fb8e..a801205ecc7 100644 --- a/src/vs/workbench/contrib/debug/browser/debugToolBar.ts +++ b/src/vs/workbench/contrib/debug/browser/debugToolBar.ts @@ -30,7 +30,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { widgetBorder, widgetShadow } from '../../../../platform/theme/common/colorRegistry.js'; +import { widgetBorder } from '../../../../platform/theme/common/colorRegistry.js'; import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js'; import { getTitleBarStyle, TitlebarStyle } from '../../../../platform/window/common/window.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; @@ -259,9 +259,6 @@ export class DebugToolBar extends Themable implements IWorkbenchContribution { if (this.$el) { this.$el.style.backgroundColor = this.getColor(debugToolBarBackground) || ''; - const widgetShadowColor = this.getColor(widgetShadow); - this.$el.style.boxShadow = widgetShadowColor ? `0 0 8px 2px ${widgetShadowColor}` : ''; - const contrastBorderColor = this.getColor(widgetBorder); const borderColor = this.getColor(debugToolBarBorder); diff --git a/src/vs/workbench/contrib/debug/browser/media/debugHover.css b/src/vs/workbench/contrib/debug/browser/media/debugHover.css index e7dd01a9cfb..f8e51e2e4de 100644 --- a/src/vs/workbench/contrib/debug/browser/media/debugHover.css +++ b/src/vs/workbench/contrib/debug/browser/media/debugHover.css @@ -13,6 +13,7 @@ word-break: break-all; white-space: pre; border-radius: var(--vscode-cornerRadius-large); + box-shadow: var(--vscode-shadow-lg); } .monaco-editor .debug-hover-widget .complex-value { diff --git a/src/vs/workbench/contrib/debug/browser/media/debugToolBar.css b/src/vs/workbench/contrib/debug/browser/media/debugToolBar.css index f8a588049f0..ca34e6f77ba 100644 --- a/src/vs/workbench/contrib/debug/browser/media/debugToolBar.css +++ b/src/vs/workbench/contrib/debug/browser/media/debugToolBar.css @@ -13,6 +13,7 @@ left: 0; top: 0; -webkit-app-region: no-drag; + box-shadow: var(--vscode-shadow-lg); } .monaco-workbench .debug-toolbar .monaco-action-bar .action-item { diff --git a/src/vs/workbench/contrib/debug/common/debugModel.ts b/src/vs/workbench/contrib/debug/common/debugModel.ts index 9c943348a97..5de69ed78ca 100644 --- a/src/vs/workbench/contrib/debug/common/debugModel.ts +++ b/src/vs/workbench/contrib/debug/common/debugModel.ts @@ -246,7 +246,8 @@ function handleSetResponse(expression: ExpressionContainer, response: DebugProto expression.reference = response.body.variablesReference; expression.namedVariables = response.body.namedVariables; expression.indexedVariables = response.body.indexedVariables; - // todo @weinand: the set responses contain most properties, but not memory references. Should they? + expression.memoryReference = response.body.memoryReference; + expression.valueLocationReference = response.body.valueLocationReference; } } diff --git a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts index 37e6e916e16..6e2b340903c 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensions.contribution.ts @@ -62,6 +62,7 @@ import { IPreferencesService } from '../../../services/preferences/common/prefer import { CONTEXT_SYNC_ENABLEMENT } from '../../../services/userDataSync/common/userDataSync.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { WORKSPACE_TRUST_EXTENSION_SUPPORT } from '../../../services/workspaces/common/workspaceTrust.js'; +import { IPluginInstallService } from '../../chat/common/plugins/pluginInstallService.js'; import { ILanguageModelToolsService } from '../../chat/common/tools/languageModelToolsService.js'; import { CONTEXT_KEYBINDINGS_EDITOR } from '../../preferences/common/preferences.js'; import { IWebview } from '../../webview/browser/webview.js'; @@ -557,6 +558,7 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi @IDialogService private readonly dialogService: IDialogService, @ICommandService private readonly commandService: ICommandService, @IProductService private readonly productService: IProductService, + @IPluginInstallService private readonly pluginInstallService: IPluginInstallService, ) { super(); const hasLocalServerContext = CONTEXT_HAS_LOCAL_SERVER.bindTo(contextKeyService); @@ -698,11 +700,14 @@ class ExtensionsContributions extends Disposable implements IWorkbenchContributi order: 1 }], run: async () => { - await this.extensionsWorkbenchService.checkForUpdates(); + const [, pluginResult] = await Promise.all([ + this.extensionsWorkbenchService.checkForUpdates(), + this.pluginInstallService.updateAllPlugins({ silent: true }, CancellationToken.None), + ]); const outdated = this.extensionsWorkbenchService.outdated; if (outdated.length) { return this.extensionsWorkbenchService.openSearch('@outdated '); - } else { + } else if (pluginResult.updatedNames.length === 0 && pluginResult.failedNames.length === 0) { return this.dialogService.info(localize('noUpdatesAvailable', "All extensions are up to date.")); } } diff --git a/src/vs/workbench/contrib/extensions/browser/media/extensionsViewlet.css b/src/vs/workbench/contrib/extensions/browser/media/extensionsViewlet.css index fd3aa7ae144..3031aeba46d 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extensionsViewlet.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extensionsViewlet.css @@ -161,6 +161,11 @@ .extensions-viewlet > .extensions .extension-list-item { position: absolute; + box-shadow: var(--vscode-shadow-sm); +} + +.extensions-viewlet > .extensions .extension-list-item:hover { + box-shadow: var(--vscode-shadow-md); } .extensions-viewlet > .extensions .extension-list-item.loading { diff --git a/src/vs/workbench/contrib/git/browser/gitService.ts b/src/vs/workbench/contrib/git/browser/gitService.ts index 2406d972a3b..ca34f506015 100644 --- a/src/vs/workbench/contrib/git/browser/gitService.ts +++ b/src/vs/workbench/contrib/git/browser/gitService.ts @@ -7,7 +7,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; -import { IGitService, IGitExtensionDelegate, GitRef, GitRefQuery, IGitRepository, GitRepositoryState } from '../common/gitService.js'; +import { IGitService, IGitExtensionDelegate, GitRef, GitRefQuery, IGitRepository, GitRepositoryState, GitDiffChange } from '../common/gitService.js'; import { ISettableObservable, observableValueOpts } from '../../../../base/common/observable.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { AutoOpenBarrier } from '../../../../base/common/async.js'; @@ -81,4 +81,8 @@ export class GitRepository extends Disposable implements IGitRepository { async getRefs(query: GitRefQuery, token?: CancellationToken): Promise { return this.delegate.getRefs(this.rootUri, query, token); } + + async diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise { + return this.delegate.diffBetweenWithStats(this.rootUri, ref1, ref2, path); + } } diff --git a/src/vs/workbench/contrib/git/common/gitService.ts b/src/vs/workbench/contrib/git/common/gitService.ts index 353686d452a..ce605402b6f 100644 --- a/src/vs/workbench/contrib/git/common/gitService.ts +++ b/src/vs/workbench/contrib/git/common/gitService.ts @@ -29,16 +29,37 @@ export interface GitRefQuery { readonly sort?: 'alphabetically' | 'committerdate' | 'creatordate'; } +export interface GitChange { + readonly uri: URI; + readonly originalUri: URI | undefined; + readonly modifiedUri: URI | undefined; +} + +export interface GitDiffChange extends GitChange { + readonly insertions: number; + readonly deletions: number; +} + export interface GitRepositoryState { readonly HEAD?: GitBranch; + readonly mergeChanges: readonly GitChange[]; + readonly indexChanges: readonly GitChange[]; + readonly workingTreeChanges: readonly GitChange[]; + readonly untrackedChanges: readonly GitChange[]; } export interface GitBranch extends GitRef { + readonly base?: GitBaseRef; readonly upstream?: GitUpstreamRef; readonly ahead?: number; readonly behind?: number; } +export interface GitBaseRef { + readonly name: string; + readonly isProtected: boolean; +} + export interface GitUpstreamRef { readonly remote: string; readonly name: string; @@ -52,6 +73,7 @@ export interface IGitRepository { updateState(state: GitRepositoryState): void; getRefs(query: GitRefQuery, token?: CancellationToken): Promise; + diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise; } export interface IGitExtensionDelegate { @@ -59,6 +81,7 @@ export interface IGitExtensionDelegate { openRepository(uri: URI): Promise; getRefs(root: URI, query?: GitRefQuery, token?: CancellationToken): Promise; + diffBetweenWithStats(root: URI, ref1: string, ref2: string, path?: string): Promise; } export const IGitService = createDecorator('gitService'); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index 2220f7d1474..85237c14100 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -96,6 +96,7 @@ registerAction2(InlineChatActions.SubmitInlineChatInputAction); registerAction2(InlineChatActions.QueueInChatAction); registerAction2(InlineChatActions.HideInlineChatInputAction); registerAction2(InlineChatActions.FixDiagnosticsAction); +registerAction2(InlineChatActions.DismissEditorAffordanceAction); const workbenchContributionsRegistry = Registry.as(WorkbenchExtensions.Workbench); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index ec5ac5e2167..aef9aeefc52 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -11,12 +11,12 @@ import { EmbeddedDiffEditorWidget } from '../../../../editor/browser/widget/diff import { EmbeddedCodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { InlineChatController, InlineChatRunOptions } from './inlineChatController.js'; -import { ACTION_ACCEPT_CHANGES, ACTION_ASK_IN_CHAT, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED, CTX_HOVER_MODE, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_PENDING_CONFIRMATION, InlineChatConfigKeys, CTX_FIX_DIAGNOSTICS_ENABLED } from '../common/inlineChat.js'; +import { ACTION_ACCEPT_CHANGES, ACTION_ASK_IN_CHAT, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED, CTX_HOVER_MODE, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_PENDING_CONFIRMATION, InlineChatConfigKeys, CTX_FIX_DIAGNOSTICS_ENABLED, CTX_INLINE_CHAT_AFFORDANCE_VISIBLE } from '../common/inlineChat.js'; import { ctxHasEditorModification, ctxHasRequestInProgress } from '../../chat/browser/chatEditing/chatEditingEditorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; @@ -37,16 +37,6 @@ CommandsRegistry.registerCommandAlias('interactive.acceptChanges', ACTION_ACCEPT export const START_INLINE_CHAT = registerIcon('start-inline-chat', Codicon.sparkle, localize('startInlineChat', 'Icon which spawns the inline chat from the editor toolbar.')); -// some gymnastics to enable hold for speech without moving the StartSessionAction into the electron-layer - -export interface IHoldForSpeech { - (accessor: ServicesAccessor, controller: InlineChatController, source: Action2): void; -} -let _holdForSpeech: IHoldForSpeech | undefined = undefined; -export function setHoldForSpeech(holdForSpeech: IHoldForSpeech) { - _holdForSpeech = holdForSpeech; -} - const inlineChatContextKey = ContextKeyExpr.and( ContextKeyExpr.or(CTX_INLINE_CHAT_V1_ENABLED, CTX_INLINE_CHAT_V2_ENABLED), CTX_INLINE_CHAT_POSSIBLE, @@ -114,10 +104,6 @@ export class StartSessionAction extends Action2 { return; } - if (_holdForSpeech) { - accessor.get(IInstantiationService).invokeFunction(_holdForSpeech, ctrl, this); - } - let options: InlineChatRunOptions | undefined; const arg = args[0]; if (arg && InlineChatRunOptions.isInlineChatRunOptions(arg)) { @@ -253,7 +239,7 @@ export class FixDiagnosticsAction extends AbstractInlineChatAction { id: 'inlineChat.fixDiagnostics', title: localize2('fix', 'Fix'), icon: Codicon.editSparkle, - precondition: ContextKeyExpr.and(inlineChatContextKey, CTX_FIX_DIAGNOSTICS_ENABLED, EditorContextKeys.selectionHasDiagnostics, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), + precondition: ContextKeyExpr.and(CTX_FIX_DIAGNOSTICS_ENABLED, EditorContextKeys.selectionHasDiagnostics, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), menu: [{ id: MenuId.InlineChatEditorAffordance, group: '1_quickfix', @@ -269,6 +255,7 @@ export class FixDiagnosticsAction extends AbstractInlineChatAction { } override runInlineChatCommand(_accessor: ServicesAccessor, ctrl: InlineChatController, _editor: ICodeEditor, ..._args: unknown[]): void { + ctrl.inputWidget.hide(); ctrl.run({ autoSend: true, attachDiagnostics: true }); } } @@ -506,6 +493,26 @@ export class AskInChatAction extends EditorAction2 { } } +export class DismissEditorAffordanceAction extends EditorAction2 { + + constructor() { + super({ + id: 'inlineChat.dismissEditorAffordance', + title: localize2('dismissAffordance', "Dismiss Editor Affordance"), + precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_AFFORDANCE_VISIBLE, ContextKeyExpr.equals('config.inlineChat.affordance', 'editor')), + keybinding: { + when: EditorContextKeys.editorTextFocus, + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.Escape, + } + }); + } + + override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor): void { + InlineChatController.get(editor)?.inputOverlayWidget.dismiss(); + } +} + export class QueueInChatAction extends AbstractInlineChatAction { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts index 4e3cf4c6600..a5f6a2ad803 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts @@ -9,7 +9,7 @@ import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; import { ScrollType } from '../../../../editor/common/editorCommon.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { InlineChatConfigKeys } from '../common/inlineChat.js'; +import { InlineChatConfigKeys, CTX_INLINE_CHAT_AFFORDANCE_VISIBLE } from '../common/inlineChat.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; @@ -24,6 +24,7 @@ import { CodeActionController } from '../../../../editor/contrib/codeAction/brow import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { Event } from '../../../../base/common/event.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; type InlineChatAffordanceEvent = { mode: string; @@ -45,6 +46,7 @@ export class InlineChatAffordance extends Disposable { readonly #inputWidget: InlineChatInputWidget; readonly #instantiationService: IInstantiationService; readonly #menuData = observableValue<{ rect: DOMRect; above: boolean; lineNumber: number; placeholder: string } | undefined>(this, undefined); + readonly #selectionData = observableValue(this, undefined); constructor( editor: ICodeEditor, @@ -54,6 +56,7 @@ export class InlineChatAffordance extends Disposable { @IChatEntitlementService chatEntiteldService: IChatEntitlementService, @IInlineChatSessionService inlineChatSessionService: IInlineChatSessionService, @ITelemetryService telemetryService: ITelemetryService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(); this.#editor = editor; @@ -64,7 +67,10 @@ export class InlineChatAffordance extends Disposable { const affordance = observableConfigValue<'off' | 'gutter' | 'editor'>(InlineChatConfigKeys.Affordance, 'off', configurationService); const debouncedSelection = debouncedObservable(editorObs.cursorSelection, 500); - const selectionData = observableValue(this, undefined); + const selectionData = this.#selectionData; + + const ctxAffordanceVisible = CTX_INLINE_CHAT_AFFORDANCE_VISIBLE.bindTo(contextKeyService); + this._store.add({ dispose: () => ctxAffordanceVisible.reset() }); let explicitSelection = false; let affordanceId: string | undefined; @@ -114,6 +120,19 @@ export class InlineChatAffordance extends Disposable { selectionData.set(undefined, undefined); })); + // Hide when the editor loses focus (e.g., switching tabs in notebooks) + this._store.add(autorun(r => { + if (!editorObs.isFocused.read(r)) { + selectionData.set(undefined, undefined); + } + })); + + this._store.add(autorun(r => { + const sel = selectionData.read(r); + const mode = affordance.read(r); + ctxAffordanceVisible.set(sel !== undefined && (mode === 'editor' || mode === 'gutter')); + })); + const gutterAffordance = this._store.add(this.#instantiationService.createInstance( InlineChatGutterAffordance, editorObs, @@ -167,6 +186,10 @@ export class InlineChatAffordance extends Disposable { })); } + dismiss(): void { + this.#selectionData.set(undefined, undefined); + } + async showMenuAtSelection(placeholder: string): Promise { assertType(this.#editor.hasModel()); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 76aa39da942..d26f9270bdd 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -605,7 +605,7 @@ export class InlineChatController implements IEditorContribution { if (!session) { return; } - this._chatService.cancelCurrentRequestForSession(session.chatModel.sessionResource, 'inlineChatReject'); + await this._chatService.cancelCurrentRequestForSession(session.chatModel.sessionResource, 'inlineChatReject'); await session.editingSession.reject(); session.dispose(); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts index 4a008a59b0f..6879c6b4391 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts @@ -86,7 +86,7 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { const store = new DisposableStore(); store.add(toDisposable(() => { - this._chatService.cancelCurrentRequestForSession(chatModel.sessionResource, 'inlineChatSession'); + void this._chatService.cancelCurrentRequestForSession(chatModel.sessionResource, 'inlineChatSession'); chatModel.editingSession?.reject(); this._sessions.delete(uri); this._onDidChangeSessions.fire(this); diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css index 5c7b116c2bf..6556757c59c 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChat.css @@ -8,7 +8,7 @@ color: inherit; border-radius: var(--vscode-cornerRadius-large); border: 1px solid var(--vscode-inlineChat-border); - box-shadow: 0 2px 4px 0 var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); background: var(--vscode-inlineChat-background); padding-top: 3px; position: relative; diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css index 37d4268342a..de36a936fd3 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatEditorAffordance.css @@ -9,7 +9,7 @@ border-radius: 8px; display: flex; align-items: center; - box-shadow: 0 4px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); cursor: pointer; min-width: var(--vscode-inline-chat-affordance-height); min-height: var(--vscode-inline-chat-affordance-height); diff --git a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css index be58df0988b..9b8cada0767 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css +++ b/src/vs/workbench/contrib/inlineChat/browser/media/inlineChatOverlayWidget.css @@ -9,7 +9,7 @@ background: var(--vscode-panel-background); border: 1px solid var(--vscode-menu-border, var(--vscode-widget-border)); border-radius: var(--vscode-cornerRadius-large); - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); z-index: 100; } @@ -108,7 +108,7 @@ justify-content: center; gap: 4px; z-index: 10; - box-shadow: 0 2px 8px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); overflow: hidden; } diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 99e23c1d696..6331855f6a0 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -15,7 +15,6 @@ import { NOTEBOOK_IS_ACTIVE_EDITOR } from '../../notebook/common/notebookContext export const enum InlineChatConfigKeys { FinishOnType = 'inlineChat.finishOnType', - HoldToSpeech = 'inlineChat.holdToSpeech', /** @deprecated do not read on client */ EnableV2 = 'inlineChat.enableV2', notebookAgent = 'inlineChat.notebookAgent', @@ -33,11 +32,6 @@ Registry.as(Extensions.Configuration).registerConfigurat default: false, type: 'boolean' }, - [InlineChatConfigKeys.HoldToSpeech]: { - description: localize('holdToSpeech', "Whether holding the inline chat keybinding will automatically enable speech recognition."), - default: true, - type: 'boolean' - }, [InlineChatConfigKeys.EnableV2]: { description: localize('enableV2', "Whether to use the next version of inline chat."), default: false, @@ -130,6 +124,7 @@ export const CTX_INLINE_CHAT_REQUEST_IN_PROGRESS = new RawContextKey('i export const CTX_INLINE_CHAT_RESPONSE_TYPE = new RawContextKey('inlineChatResponseType', InlineChatResponseType.None, localize('inlineChatResponseTypes', "What type was the responses have been receieved, nothing yet, just messages, or messaged and local edits")); export const CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT = new RawContextKey('inlineChatFileBelongsToChat', false, localize('inlineChatFileBelongsToChat', "Whether the current file belongs to a chat editing session")); export const CTX_INLINE_CHAT_PENDING_CONFIRMATION = new RawContextKey('inlineChatPendingConfirmation', false, localize('inlineChatPendingConfirmation', "Whether an inline chat request is pending user confirmation")); +export const CTX_INLINE_CHAT_AFFORDANCE_VISIBLE = new RawContextKey('inlineChatAffordanceVisible', false, localize('inlineChatAffordanceVisible', "Whether an inline chat affordance widget is visible")); export const CTX_INLINE_CHAT_V1_ENABLED = ContextKeyExpr.or( ContextKeyExpr.and(NOTEBOOK_IS_ACTIVE_EDITOR, CTX_INLINE_CHAT_HAS_NOTEBOOK_INLINE) diff --git a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChatActions.ts deleted file mode 100644 index 0a9c91d1859..00000000000 --- a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChatActions.ts +++ /dev/null @@ -1,83 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { InlineChatController } from '../browser/inlineChatController.js'; -import { AbstractInlineChatAction, setHoldForSpeech } from '../browser/inlineChatActions.js'; -import { disposableTimeout } from '../../../../base/common/async.js'; -import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { StartVoiceChatAction, StopListeningAction, VOICE_KEY_HOLD_THRESHOLD } from '../../chat/electron-browser/actions/voiceChatActions.js'; -import { IChatExecuteActionContext } from '../../chat/browser/actions/chatExecuteActions.js'; -import { CTX_INLINE_CHAT_VISIBLE, InlineChatConfigKeys } from '../common/inlineChat.js'; -import { HasSpeechProvider, ISpeechService } from '../../speech/common/speechService.js'; -import { localize2 } from '../../../../nls.js'; -import { Action2 } from '../../../../platform/actions/common/actions.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { EditorAction2 } from '../../../../editor/browser/editorExtensions.js'; - -export class HoldToSpeak extends EditorAction2 { - - constructor() { - super({ - id: 'inlineChat.holdForSpeech', - category: AbstractInlineChatAction.category, - precondition: ContextKeyExpr.and(HasSpeechProvider, CTX_INLINE_CHAT_VISIBLE), - title: localize2('holdForSpeech', "Hold for Speech"), - keybinding: { - when: EditorContextKeys.textInputFocus, - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyCode.KeyI, - }, - }); - } - - override runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ..._args: unknown[]) { - const ctrl = InlineChatController.get(editor); - if (ctrl) { - holdForSpeech(accessor, ctrl, this); - } - } -} - -function holdForSpeech(accessor: ServicesAccessor, ctrl: InlineChatController, action: Action2): void { - - const configService = accessor.get(IConfigurationService); - const speechService = accessor.get(ISpeechService); - const keybindingService = accessor.get(IKeybindingService); - const commandService = accessor.get(ICommandService); - - // enabled or possible? - if (!configService.getValue(InlineChatConfigKeys.HoldToSpeech || !speechService.hasSpeechProvider)) { - return; - } - - const holdMode = keybindingService.enableKeybindingHoldMode(action.desc.id); - if (!holdMode) { - return; - } - let listening = false; - const handle = disposableTimeout(() => { - // start VOICE input - commandService.executeCommand(StartVoiceChatAction.ID, { voice: { disableTimeout: true } } satisfies IChatExecuteActionContext); - listening = true; - }, VOICE_KEY_HOLD_THRESHOLD); - - holdMode.finally(() => { - if (listening) { - commandService.executeCommand(StopListeningAction.ID).finally(() => { - ctrl.widget.chatWidget.acceptInput(); - }); - } - handle.dispose(); - }); -} - -// make this accessible to the chat actions from the browser layer -setHoldForSpeech(holdForSpeech); diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatAffordance.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatAffordance.test.ts new file mode 100644 index 00000000000..85719bf152c --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatAffordance.test.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { Selection } from '../../../../../editor/common/core/selection.js'; +import { CursorChangeReason } from '../../../../../editor/common/cursorEvents.js'; +import { CursorState } from '../../../../../editor/common/cursorCommon.js'; +import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; +import { instantiateTestCodeEditor, ITestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IInlineChatSessionService } from '../../browser/inlineChatSessionService.js'; +import { Event } from '../../../../../base/common/event.js'; +import { InlineChatAffordance } from '../../browser/inlineChatAffordance.js'; +import { InlineChatInputWidget } from '../../browser/inlineChatOverlayWidget.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { timeout } from '../../../../../base/common/async.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { InlineChatConfigKeys } from '../../common/inlineChat.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { mock } from '../../../../../base/test/common/mock.js'; + +function createMockInputWidget(): InlineChatInputWidget { + return new class extends mock() { + override readonly position = observableValue('test.position', null); + override show() { } + override hide() { } + override dispose() { } + }; +} + +suite('InlineChatAffordance - Telemetry', () => { + + const store = new DisposableStore(); + let editor: ITestCodeEditor; + let model: ITextModel; + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + let telemetryEvents: { eventName: string; data: Record }[]; + + setup(() => { + telemetryEvents = []; + + instantiationService = workbenchInstantiationService({ + configurationService: () => new TestConfigurationService({ + [InlineChatConfigKeys.Affordance]: 'editor', + }), + }, store); + + configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; + + instantiationService.stub(ITelemetryService, new class extends mock() { + override publicLog2(eventName: string, data?: Record) { + telemetryEvents.push({ eventName, data: data ?? {} }); + } + }); + + instantiationService.stub(IInlineChatSessionService, new class extends mock() { + override readonly onWillStartSession = Event.None; + override readonly onDidChangeSessions = Event.None; + override getSessionByTextModel() { return undefined; } + override getSessionBySessionUri() { return undefined; } + }); + + model = store.add(createTextModel('hello world\nfoo bar\nbaz qux')); + editor = store.add(instantiateTestCodeEditor(instantiationService, model)); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function setExplicitSelection(sel: Selection): void { + editor.getViewModel()!.setCursorStates( + 'test', + CursorChangeReason.Explicit, + [CursorState.fromModelSelection(sel)] + ); + } + + test('shown event includes mode "editor"', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + + setExplicitSelection(new Selection(1, 1, 1, 6)); + await timeout(600); + + const shown = telemetryEvents.filter(e => e.eventName === 'inlineChatAffordance/shown'); + assert.strictEqual(shown.length, 1); + assert.strictEqual(shown[0].data.mode, 'editor'); + assert.ok(typeof shown[0].data.id === 'string'); + assert.strictEqual(shown[0].data.commandId, ''); + })); + + test('shown event does NOT fire when mode is off', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configurationService.setUserConfiguration(InlineChatConfigKeys.Affordance, 'off'); + configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(key: string) { return key === InlineChatConfigKeys.Affordance; } + }); + + store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + + setExplicitSelection(new Selection(1, 1, 1, 6)); + await timeout(600); + + assert.strictEqual(telemetryEvents.filter(e => e.eventName === 'inlineChatAffordance/shown').length, 0); + })); + + test('shown event does NOT fire for whitespace-only selection', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + model.setValue(' \nhello'); + + store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + + setExplicitSelection(new Selection(1, 1, 1, 4)); + await timeout(600); + + assert.strictEqual(telemetryEvents.filter(e => e.eventName === 'inlineChatAffordance/shown').length, 0); + })); + + test('shown event does NOT fire for empty selection', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + + setExplicitSelection(new Selection(1, 1, 1, 1)); + await timeout(600); + + assert.strictEqual(telemetryEvents.filter(e => e.eventName === 'inlineChatAffordance/shown').length, 0); + })); + + test('each selection gets a unique affordanceId', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + + setExplicitSelection(new Selection(1, 1, 1, 6)); + await timeout(600); + + // Clear selection, then make a new one + setExplicitSelection(new Selection(2, 1, 2, 1)); + await timeout(100); + setExplicitSelection(new Selection(2, 1, 2, 4)); + await timeout(600); + + const shown = telemetryEvents.filter(e => e.eventName === 'inlineChatAffordance/shown'); + assert.strictEqual(shown.length, 2); + assert.notStrictEqual(shown[0].data.id, shown[1].data.id); + })); +}); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index 71fd9bcd6c8..a6d8c584080 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -61,10 +61,11 @@ import { TEXT_FILE_EDITOR_ID } from '../../files/common/files.js'; import { McpCommandIds } from '../common/mcpCommandIds.js'; import { McpContextKeys } from '../common/mcpContextKeys.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; -import { HasInstalledMcpServersContext, IMcpSamplingService, IMcpServer, IMcpServerStartOpts, IMcpService, InstalledMcpServersViewId, LazyCollectionState, McpCapability, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, mcpPromptPrefix, McpServerCacheState, McpStartServerInteraction } from '../common/mcpTypes.js'; +import { HasInstalledMcpServersContext, IMcpSamplingService, IMcpServer, IMcpServerStartOpts, IMcpService, IMcpWorkbenchService, InstalledMcpServersViewId, LazyCollectionState, McpCapability, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, mcpPromptPrefix, McpServerCacheState, McpStartServerInteraction } from '../common/mcpTypes.js'; import { McpAddConfigurationCommand, McpInstallFromManifestCommand } from './mcpCommandsAddConfiguration.js'; import { McpResourceQuickAccess, McpResourceQuickPick } from './mcpResourceQuickAccess.js'; import { startServerAndWaitForLiveTools } from '../common/mcpTypesUtils.js'; +import { isContributionDisabled } from '../../chat/common/enablement.js'; import './media/mcpServerAction.css'; import { openPanelChatAndGetWidget } from './openPanelChatAndGetWidget.js'; @@ -104,6 +105,7 @@ export class ListMcpServerCommand extends Action2 { const mcpService = accessor.get(IMcpService); const commandService = accessor.get(ICommandService); const quickInput = accessor.get(IQuickInputService); + const mcpWorkbenchService = accessor.get(IMcpWorkbenchService); type ItemType = { id: string } & IQuickPickItem; @@ -122,11 +124,16 @@ export class ListMcpServerCommand extends Action2 { { id: '$add', label: localize('mcp.addServer', 'Add Server'), description: localize('mcp.addServer.description', 'Add a new server configuration'), alwaysShow: true, iconClass: ThemeIcon.asClassName(Codicon.add) }, ...Object.values(servers).filter(s => s!.length).flatMap((servers): (ItemType | IQuickPickSeparator)[] => [ { type: 'separator', label: servers![0].collection.label, id: servers![0].collection.id }, - ...servers!.map(server => ({ - id: server.definition.id, - label: server.definition.label, - description: McpConnectionState.toString(server.connectionState.read(reader)), - })), + ...servers!.map(server => { + const disabled = isContributionDisabled(server.enablement.read(reader)); + return { + id: server.definition.id, + label: server.definition.label, + description: disabled + ? localize('mcp.disabled', 'Disabled') + : McpConnectionState.toString(server.connectionState.read(reader)), + }; + }), ]), ]; @@ -153,7 +160,15 @@ export class ListMcpServerCommand extends Action2 { } else if (picked.id === '$add') { commandService.executeCommand(McpCommandIds.AddConfiguration); } else { - commandService.executeCommand(McpCommandIds.ServerOptions, picked.id); + const server = mcpService.servers.get().find(s => s.definition.id === picked.id); + if (server && isContributionDisabled(server.enablement.get())) { + const workbenchServer = mcpWorkbenchService.local.find(s => s.id === picked.id); + if (workbenchServer) { + mcpWorkbenchService.open(workbenchServer); + } + } else { + commandService.executeCommand(McpCommandIds.ServerOptions, picked.id); + } } } } @@ -550,9 +565,10 @@ export class MCPServerActionRendering extends Disposable implements IWorkbenchCo protected override getHoverContents({ state, servers } = displayedStateCurrent.get()): string | undefined | IManagedHoverTooltipHTMLElement { const link = (s: IMcpServer) => createMarkdownCommandLink({ - title: s.definition.label, + text: s.definition.label, id: McpCommandIds.ServerOptions, arguments: [s.definition.id], + tooltip: localize('mcp.server.options.tooltip', 'Show server options for {0}', s.definition.label), }); const single = servers.length === 1; diff --git a/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts b/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts index 622dc36f198..0e64866bd27 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts @@ -12,13 +12,15 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { isDefined } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { ChatElicitationRequestPart } from '../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; +import { ChatQuestionCarouselData } from '../../chat/common/model/chatProgressTypes/chatQuestionCarouselData.js'; import { ChatModel } from '../../chat/common/model/chatModel.js'; -import { ElicitationState, IChatService } from '../../chat/common/chatService/chatService.js'; +import { ElicitationState, IChatQuestion, IChatQuestionAnswers, IChatQuestionValidation, IChatService } from '../../chat/common/chatService/chatService.js'; import { ElicitationKind, ElicitResult, IFormModeElicitResult, IMcpElicitationService, IMcpServer, IMcpToolCallContext, IUrlModeElicitResult, McpConnectionState, MpcResponseError } from '../common/mcpTypes.js'; import { mcpServerToSourceData } from '../common/mcpTypesUtils.js'; import { MCP } from '../common/modelContextProtocol.js'; @@ -88,41 +90,52 @@ export class McpElicitationService implements IMcpElicitationService { if (chatModel instanceof ChatModel) { const request = chatModel.getRequests().at(-1); if (request) { - const part = new ChatElicitationRequestPart( - localize('mcp.elicit.title', 'Request for Input'), - elicitation.message, - localize('msg.subtitle', "{0} (MCP Server)", server.definition.label), - localize('mcp.elicit.accept', 'Respond'), - localize('mcp.elicit.reject', 'Cancel'), - async () => { - const p = this._doElicitForm(elicitation, token); - resolve(p); - const result = await p; - part.acceptedResult = result.content; - return result.action === 'accept' ? ElicitationState.Accepted : ElicitationState.Rejected; - }, - () => { - resolve({ action: 'decline' }); - return Promise.resolve(ElicitationState.Rejected); - }, - mcpServerToSourceData(server), + const { questions, idToPropertyMap } = this._convertSchemaToQuestions(elicitation); + const carousel = new ChatQuestionCarouselData( + questions, + /* allowSkip */ true, + /* resolveId */ undefined, + /* data */ undefined, + /* isUsed */ undefined, + /* message */ new MarkdownString(elicitation.message), + /* source */ mcpServerToSourceData(server), ); - chatModel.acceptResponseProgress(request, part); + + chatModel.acceptResponseProgress(request, carousel); + + store.add(token.onCancellationRequested(() => { + carousel.completion.complete({ answers: undefined }); + })); + + carousel.completion.p.then(result => { + if (!result.answers) { + resolve({ action: 'cancel' }); + } else { + const content = this._convertCarouselAnswersToElicitResult( + result.answers, + idToPropertyMap, + elicitation.requestedSchema.properties, + ); + resolve({ action: 'accept', content }); + } + }); + return; } - } else { - const handle = this._notificationService.notify({ - message: elicitation.message, - source: localize('mcp.elicit.source', 'MCP Server ({0})', server.definition.label), - severity: Severity.Info, - actions: { - primary: [store.add(new Action('mcp.elicit.give', localize('mcp.elicit.give', 'Respond'), undefined, true, () => resolve(this._doElicitForm(elicitation, token))))], - secondary: [store.add(new Action('mcp.elicit.cancel', localize('mcp.elicit.cancel', 'Cancel'), undefined, true, () => resolve({ action: 'decline' })))], - } - }); - store.add(handle.onDidClose(() => resolve({ action: 'cancel' }))); - store.add(token.onCancellationRequested(() => resolve({ action: 'cancel' }))); } + // Fallback: no chat session → notification + quickpick + const handle = this._notificationService.notify({ + message: elicitation.message, + source: localize('mcp.elicit.source', 'MCP Server ({0})', server.definition.label), + severity: Severity.Info, + actions: { + primary: [store.add(new Action('mcp.elicit.give', localize('mcp.elicit.give', 'Respond'), undefined, true, () => resolve(this._doElicitForm(elicitation, token))))], + secondary: [store.add(new Action('mcp.elicit.cancel', localize('mcp.elicit.cancel', 'Cancel'), undefined, true, () => resolve({ action: 'decline' })))], + } + }); + store.add(handle.onDidClose(() => resolve({ action: 'cancel' }))); + store.add(token.onCancellationRequested(() => resolve({ action: 'cancel' }))); + }).finally(() => store.dispose()); return { kind: ElicitationKind.Form, value, dispose: () => { } }; @@ -518,4 +531,191 @@ export class McpElicitationService implements IMcpElicitationService { } return { isValid: true, parsedValue: parsed }; } + + /** + * Converts an MCP elicitation schema into IChatQuestion[] for the carousel UI. + * Returns the questions and a map from question ID to schema property name. + */ + private _convertSchemaToQuestions(elicitation: MCP.ElicitRequestFormParams | Pre20251125ElicitationParams): { questions: IChatQuestion[]; idToPropertyMap: Map } { + const properties = Object.entries(elicitation.requestedSchema.properties); + const requiredFields = new Set(elicitation.requestedSchema.required || []); + const questions: IChatQuestion[] = []; + const idToPropertyMap = new Map(); + + for (const [propertyName, schema] of properties) { + const id = generateUuid(); + idToPropertyMap.set(id, propertyName); + + const title = schema.title || propertyName; + const description = schema.description; + const isRequired = requiredFields.has(propertyName); + + if (schema.type === 'boolean') { + questions.push({ + id, + type: 'singleSelect', + title, + description, + required: isRequired, + allowFreeformInput: false, + options: [ + { id: 'true', label: localize('mcp.elicit.true', 'True'), value: 'true' }, + { id: 'false', label: localize('mcp.elicit.false', 'False'), value: 'false' }, + ], + defaultValue: schema.default !== undefined ? String(schema.default) : undefined, + }); + } else if (isLegacyTitledEnumSchema(schema)) { + questions.push({ + id, + type: 'singleSelect', + title, + description, + required: isRequired, + allowFreeformInput: false, + options: schema.enum.map((v, i) => ({ + id: v, + label: schema.enumNames[i] ? `${v} - ${schema.enumNames[i]}` : v, + value: v, + })), + defaultValue: schema.default, + }); + } else if (isTitledSingleEnumSchema(schema)) { + questions.push({ + id, + type: 'singleSelect', + title, + description, + required: isRequired, + allowFreeformInput: false, + options: schema.oneOf.map(({ const: value, title: optTitle }) => ({ + id: value, + label: optTitle ? `${value} - ${optTitle}` : value, + value, + })), + defaultValue: schema.default, + }); + } else if (isUntitledEnumSchema(schema)) { + questions.push({ + id, + type: 'singleSelect', + title, + description, + required: isRequired, + allowFreeformInput: false, + options: schema.enum.map(v => ({ id: v, label: v, value: v })), + defaultValue: schema.default, + }); + } else if (isTitledMultiEnumSchema(schema)) { + questions.push({ + id, + type: 'multiSelect', + title, + description, + required: isRequired, + allowFreeformInput: false, + options: schema.items.anyOf.map(({ const: value, title: optTitle }) => ({ + id: value, + label: optTitle ? `${value} - ${optTitle}` : value, + value, + })), + defaultValue: schema.default, + }); + } else if (isUntitledMultiEnumSchema(schema)) { + questions.push({ + id, + type: 'multiSelect', + title, + description, + required: isRequired, + allowFreeformInput: false, + options: schema.items.enum.map(v => ({ id: v, label: v, value: v })), + defaultValue: schema.default, + }); + } else { + // String, number, integer → text input with validation + const validation: IChatQuestionValidation = {}; + if (schema.type === 'string') { + if (schema.minLength !== undefined) { validation.minLength = schema.minLength; } + if (schema.maxLength !== undefined) { validation.maxLength = schema.maxLength; } + if (schema.format) { validation.format = schema.format; } + } else if (schema.type === 'number' || schema.type === 'integer') { + if (schema.minimum !== undefined) { validation.minimum = schema.minimum; } + if (schema.maximum !== undefined) { validation.maximum = schema.maximum; } + if (schema.type === 'integer') { validation.isInteger = true; } + } + + questions.push({ + id, + type: 'text', + title, + description, + required: isRequired, + defaultValue: schema.default !== undefined ? String(schema.default) : undefined, + validation: Object.keys(validation).length > 0 ? validation : undefined, + }); + } + } + + return { questions, idToPropertyMap }; + } + + /** + * Converts carousel answers (keyed by question ID) back into the + * MCP ElicitResult content format (keyed by schema property names), + * coercing types as needed. + */ + private _convertCarouselAnswersToElicitResult( + answers: IChatQuestionAnswers, + idToPropertyMap: Map, + schemaProperties: Record, + ): Record { + const content: Record = {}; + + for (const [questionId, answer] of Object.entries(answers)) { + const propertyName = idToPropertyMap.get(questionId); + if (!propertyName) { + continue; + } + + const schema = schemaProperties[propertyName]; + if (!schema) { + continue; + } + + // Extract the raw value from structured answers + let rawValue: unknown = answer; + if (typeof answer === 'object' && answer !== null) { + const obj = answer as Record; + if ('selectedValue' in obj) { + rawValue = obj.selectedValue; + } else if ('selectedValues' in obj) { + rawValue = obj.selectedValues; + } else if ('freeformValue' in obj && obj.freeformValue) { + rawValue = obj.freeformValue; + } + } + + if (rawValue === undefined || rawValue === null) { + continue; + } + + // Type coercion based on schema + if (schema.type === 'boolean') { + content[propertyName] = rawValue === 'true' || rawValue === true; + } else if (schema.type === 'number' || schema.type === 'integer') { + const num = Number(rawValue); + if (!isNaN(num)) { + content[propertyName] = num; + } + } else if (schema.type === 'array') { + if (Array.isArray(rawValue)) { + content[propertyName] = rawValue.map(v => String(v)); + } + } else { + content[propertyName] = String(rawValue); + } + } + + return content; + } } diff --git a/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts index 49bb014e5d9..4d2d9df2c49 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts @@ -5,6 +5,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IMcpGatewayService, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { IMcpGatewayResult, IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; @@ -23,28 +24,36 @@ export class BrowserMcpGatewayService implements IWorkbenchMcpGatewayService { constructor( @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + @ILogService private readonly _logService: ILogService, ) { } async createGateway(inRemote: boolean): Promise { + this._logService.debug(`[McpGateway][BrowserWorkbench] createGateway requested (inRemote=${inRemote})`); + // Browser can only create gateways in remote environment if (!inRemote) { + this._logService.info('[McpGateway][BrowserWorkbench] Cannot create local gateway in browser environment'); return undefined; } const connection = this._remoteAgentService.getConnection(); if (!connection) { - // Serverless web environment - no gateway available + this._logService.info('[McpGateway][BrowserWorkbench] No remote connection available (serverless web)'); return undefined; } + this._logService.info('[McpGateway][BrowserWorkbench] Creating remote gateway via remote server'); // Use the remote server's gateway service return connection.withChannel(McpGatewayChannelName, async channel => { const service = ProxyChannel.toService(channel); const info = await service.createGateway(undefined); + const address = URI.revive(info.address); + this._logService.info(`[McpGateway][BrowserWorkbench] Remote gateway created: ${address}`); return { - address: URI.revive(info.address), + address, dispose: () => { + this._logService.info(`[McpGateway][BrowserWorkbench] Disposing remote gateway: ${info.gatewayId}`); service.disposeGateway(info.gatewayId); } }; diff --git a/src/vs/workbench/contrib/mcp/browser/mcpGatewayToolBrokerContribution.ts b/src/vs/workbench/contrib/mcp/browser/mcpGatewayToolBrokerContribution.ts index 175bb839214..4c041d05f47 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpGatewayToolBrokerContribution.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpGatewayToolBrokerContribution.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { McpGatewayToolBrokerChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { IMcpService } from '../common/mcpTypes.js'; @@ -13,7 +14,8 @@ export class McpGatewayToolBrokerContribution implements IWorkbenchContribution constructor( @IRemoteAgentService remoteAgentService: IRemoteAgentService, @IMcpService mcpService: IMcpService, + @ILogService logService: ILogService, ) { - remoteAgentService.getConnection()?.registerChannel(McpGatewayToolBrokerChannelName, new McpGatewayToolBrokerChannel(mcpService)); + remoteAgentService.getConnection()?.registerChannel(McpGatewayToolBrokerChannelName, new McpGatewayToolBrokerChannel(mcpService, logService)); } } diff --git a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts index 254537d67c9..285e27f0370 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts @@ -388,9 +388,9 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib function pushAnnotation(savedId: string, offset: number, saved: IResolvedValue): InlayHint { const tooltip = new MarkdownString([ - createMarkdownCommandLink({ id: McpCommandIds.EditStoredInput, title: localize('edit', 'Edit'), arguments: [savedId, model.uri, mcpConfigurationSection, inConfig!.target] }), - createMarkdownCommandLink({ id: McpCommandIds.RemoveStoredInput, title: localize('clear', 'Clear'), arguments: [inConfig!.scope, savedId] }), - createMarkdownCommandLink({ id: McpCommandIds.RemoveStoredInput, title: localize('clearAll', 'Clear All'), arguments: [inConfig!.scope] }), + createMarkdownCommandLink({ id: McpCommandIds.EditStoredInput, text: localize('edit', 'Edit'), arguments: [savedId, model.uri, mcpConfigurationSection, inConfig!.target], tooltip: localize('edit.savedValue.tooltip', 'Edit saved value') }), + createMarkdownCommandLink({ id: McpCommandIds.RemoveStoredInput, text: localize('clear', 'Clear'), arguments: [inConfig!.scope, savedId], tooltip: localize('clear.savedValue.tooltip', 'Clear saved value') }), + createMarkdownCommandLink({ id: McpCommandIds.RemoveStoredInput, text: localize('clearAll', 'Clear All'), arguments: [inConfig!.scope], tooltip: localize('clearAll.savedValues.tooltip', 'Clear all saved values') }), ].join(' | '), { isTrusted: true }); const hint: InlayHint = { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServerActions.ts b/src/vs/workbench/contrib/mcp/browser/mcpServerActions.ts index 10f62eb9c83..25152d40926 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServerActions.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServerActions.ts @@ -37,6 +37,7 @@ import { ExtensionAction } from '../../extensions/browser/extensionsActions.js'; import { ActionWithDropdownActionViewItem, IActionWithDropdownActionViewItemOptions } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; import { IContextMenuProvider } from '../../../../base/browser/contextmenu.js'; import Severity from '../../../../base/common/severity.js'; +import { ContributionEnablementState, isContributionDisabled, isContributionEnabled } from '../../chat/common/enablement.js'; export interface IMcpServerActionChangeEvent extends IActionChangeEvent { readonly hidden?: boolean; @@ -133,10 +134,12 @@ export class ButtonWithDropDownExtensionAction extends McpServerAction { this._onDidChange.fire({ menuActions: this._menuActions }); if (this.primaryAction) { + this.hidden = false; this.enabled = this.primaryAction.enabled; this.label = this.getLabel(this.primaryAction as ExtensionAction); this.tooltip = this.primaryAction.tooltip; } else { + this.hidden = true; this.enabled = false; } } @@ -508,6 +511,174 @@ export class UninstallAction extends McpServerAction { } } +export class EnableMcpServerGloballyAction extends McpServerAction { + + static readonly ID = 'mcpServer.enableGlobally'; + + constructor( + @IMcpService private readonly mcpService: IMcpService, + ) { + super(EnableMcpServerGloballyAction.ID, localize('enableGlobally', "Enable"), McpServerAction.LABEL_ACTION_CLASS); + this.tooltip = localize('enableGloballyTooltip', "Enable this MCP server"); + this.update(); + } + + update(): void { + this.enabled = false; + if (!this.mcpServer?.local) { + return; + } + const server = this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); + if (!server) { + return; + } + const enablement = server.enablement.get(); + this.enabled = isContributionDisabled(enablement); + } + + override async run(): Promise { + if (!this.mcpServer) { + return; + } + this.mcpService.enablementModel.setEnabled(this.mcpServer.id, ContributionEnablementState.EnabledProfile); + } +} + +export class EnableMcpServerForWorkspaceAction extends McpServerAction { + + static readonly ID = 'mcpServer.enableForWorkspace'; + + constructor( + @IMcpService private readonly mcpService: IMcpService, + @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, + ) { + super(EnableMcpServerForWorkspaceAction.ID, localize('enableForWorkspace', "Enable (Workspace)"), McpServerAction.LABEL_ACTION_CLASS); + this.tooltip = localize('enableForWorkspaceTooltip', "Enable this MCP server only in this workspace"); + this.update(); + } + + update(): void { + this.enabled = false; + if (!this.mcpServer?.local) { + return; + } + if (this.workspaceService.getWorkbenchState() === WorkbenchState.EMPTY) { + return; + } + const server = this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); + if (!server) { + return; + } + const enablement = server.enablement.get(); + this.enabled = isContributionDisabled(enablement); + } + + override async run(): Promise { + if (!this.mcpServer) { + return; + } + this.mcpService.enablementModel.setEnabled(this.mcpServer.id, ContributionEnablementState.EnabledWorkspace); + } +} + +export class DisableMcpServerGloballyAction extends McpServerAction { + + static readonly ID = 'mcpServer.disableGlobally'; + + constructor( + @IMcpService private readonly mcpService: IMcpService, + ) { + super(DisableMcpServerGloballyAction.ID, localize('disableGlobally', "Disable"), McpServerAction.LABEL_ACTION_CLASS); + this.tooltip = localize('disableGloballyTooltip', "Disable this MCP server"); + this.update(); + } + + update(): void { + this.enabled = false; + if (!this.mcpServer?.local) { + return; + } + const server = this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); + if (!server) { + return; + } + const enablement = server.enablement.get(); + this.enabled = isContributionEnabled(enablement); + } + + override async run(): Promise { + if (!this.mcpServer) { + return; + } + this.mcpService.enablementModel.setEnabled(this.mcpServer.id, ContributionEnablementState.DisabledProfile); + } +} + +export class DisableMcpServerForWorkspaceAction extends McpServerAction { + + static readonly ID = 'mcpServer.disableForWorkspace'; + + constructor( + @IMcpService private readonly mcpService: IMcpService, + @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, + ) { + super(DisableMcpServerForWorkspaceAction.ID, localize('disableForWorkspace', "Disable (Workspace)"), McpServerAction.LABEL_ACTION_CLASS); + this.tooltip = localize('disableForWorkspaceTooltip', "Disable this MCP server only in this workspace"); + this.update(); + } + + update(): void { + this.enabled = false; + if (!this.mcpServer?.local) { + return; + } + if (this.workspaceService.getWorkbenchState() === WorkbenchState.EMPTY) { + return; + } + const server = this.mcpService.servers.get().find(s => s.definition.id === this.mcpServer?.id); + if (!server) { + return; + } + const enablement = server.enablement.get(); + this.enabled = isContributionEnabled(enablement); + } + + override async run(): Promise { + if (!this.mcpServer) { + return; + } + this.mcpService.enablementModel.setEnabled(this.mcpServer.id, ContributionEnablementState.DisabledWorkspace); + } +} + +export class EnableMcpDropDownAction extends ButtonWithDropDownExtensionAction { + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + ) { + super('mcpServer.enable', McpServerAction.LABEL_ACTION_CLASS, [ + [ + instantiationService.createInstance(EnableMcpServerGloballyAction), + instantiationService.createInstance(EnableMcpServerForWorkspaceAction), + ] + ]); + } +} + +export class DisableMcpDropDownAction extends ButtonWithDropDownExtensionAction { + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + ) { + super('mcpServer.disable', McpServerAction.LABEL_ACTION_CLASS, [ + [ + instantiationService.createInstance(DisableMcpServerGloballyAction), + instantiationService.createInstance(DisableMcpServerForWorkspaceAction), + ] + ]); + } +} + export function getContextMenuActions(mcpServer: IWorkbenchMcpServer, isEditorAction: boolean, instantiationService: IInstantiationService): IAction[][] { return instantiationService.invokeFunction(accessor => { const workspaceService = accessor.get(IWorkspaceContextService); @@ -524,6 +695,12 @@ export function getContextMenuActions(mcpServer: IWorkbenchMcpServer, isEditorAc instantiationService.createInstance(StopServerAction), instantiationService.createInstance(RestartServerAction), ]); + groups.push([ + instantiationService.createInstance(EnableMcpServerGloballyAction), + instantiationService.createInstance(EnableMcpServerForWorkspaceAction), + instantiationService.createInstance(DisableMcpServerGloballyAction), + instantiationService.createInstance(DisableMcpServerForWorkspaceAction), + ]); groups.push([ instantiationService.createInstance(AuthServerAction), ]); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts b/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts index 034985bdf44..1f0f633be66 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts @@ -38,7 +38,7 @@ import { IExtensionService } from '../../../services/extensions/common/extension import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IMcpServerContainer, IMcpServerEditorOptions, IMcpWorkbenchService, IWorkbenchMcpServer, McpServerContainers, McpServerInstallState } from '../common/mcpTypes.js'; import { StarredWidget, McpServerIconWidget, McpServerStatusWidget, McpServerWidget, onClick, PublisherWidget, McpServerScopeBadgeWidget, LicenseWidget } from './mcpServerWidgets.js'; -import { ButtonWithDropDownExtensionAction, ButtonWithDropdownExtensionActionViewItem, DropDownAction, InstallAction, InstallingLabelAction, InstallInRemoteAction, InstallInWorkspaceAction, ManageMcpServerAction, McpServerStatusAction, UninstallAction } from './mcpServerActions.js'; +import { ButtonWithDropDownExtensionAction, ButtonWithDropdownExtensionActionViewItem, DisableMcpDropDownAction, DropDownAction, EnableMcpDropDownAction, InstallAction, InstallingLabelAction, InstallInRemoteAction, InstallInWorkspaceAction, ManageMcpServerAction, McpServerStatusAction, UninstallAction } from './mcpServerActions.js'; import { McpServerEditorInput } from './mcpServerEditorInput.js'; import { ILocalMcpServer, IGalleryMcpServerConfiguration, IMcpServerPackage, IMcpServerKeyValueInput, RegistryType } from '../../../../platform/mcp/common/mcpManagement.js'; import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; @@ -251,6 +251,8 @@ export class McpServerEditor extends EditorPane { this.instantiationService.createInstance(InstallInRemoteAction, false) ] ]), + this.instantiationService.createInstance(EnableMcpDropDownAction), + this.instantiationService.createInstance(DisableMcpDropDownAction), this.instantiationService.createInstance(ManageMcpServerAction, true), ]; diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts index 9e82f119e88..864cc4f5f8c 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServersView.ts @@ -270,7 +270,7 @@ export class McpServersListView extends AbstractExtensionsListView { + for (const server of mcpService.servers.read(reader)) { + server.enablement.read(reader); + } + this._onChange.fire(undefined); + })); } private async onDidChangeProfile() { @@ -743,10 +752,31 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ return enablementStatus; } - if (!this.mcpService.servers.get().find(s => s.definition.id === mcpServer.id)) { + const server = this.mcpService.servers.get().find(s => s.definition.id === mcpServer.id); + if (!server) { return { state: McpServerEnablementState.Disabled }; } + const enablement = server.enablement.get(); + if (enablement === ContributionEnablementState.DisabledProfile) { + return { + state: McpServerEnablementState.DisabledProfile, + message: { + severity: Severity.Info, + text: new MarkdownString(localize('disabled globally', "This MCP server is disabled.")) + } + }; + } + if (enablement === ContributionEnablementState.DisabledWorkspace) { + return { + state: McpServerEnablementState.DisabledWorkspace, + message: { + severity: Severity.Info, + text: new MarkdownString(localize('disabled in workspace', "This MCP server is disabled for this workspace.")) + } + }; + } + return undefined; } diff --git a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts index 1dc17864797..c3d16718ad3 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts @@ -96,6 +96,7 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc env: config.env || {}, envFile: config.envFile, cwd: config.cwd, + sandbox: server.rootSandbox }; definitions[1].push({ @@ -103,7 +104,6 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc label: server.name, launch, sandboxEnabled: config.type === 'http' ? undefined : config.sandboxEnabled, - sandbox: config.type === 'http' ? undefined : config.sandbox, cacheNonce: await McpServerLaunch.hash(launch), roots: mcpConfigPath?.workspaceFolder ? [mcpConfigPath.workspaceFolder.uri] : undefined, variableReplacement: { diff --git a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts index 3287e0347e7..fe7aca88798 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/nativeMcpDiscoveryAdapters.ts @@ -49,6 +49,7 @@ export async function claudeConfigToServerDefinition(idPrefix: string, contents: env: server.env || {}, envFile: undefined, cwd: cwd?.fsPath, + sandbox: undefined }; return { diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 904987c9e34..7cb6a6efebd 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -8,7 +8,6 @@ import { Disposable, DisposableResourceMap } from '../../../../../base/common/li import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { basename } from '../../../../../base/common/resources.js'; import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; @@ -19,6 +18,7 @@ import { IAgentPluginMcpServerDefinition, IAgentPluginService } from '../../../chat/common/plugins/agentPluginService.js'; +import { isContributionEnabled } from '../../../chat/common/enablement.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; import { McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; @@ -40,6 +40,9 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { const plugins = this._agentPluginService.plugins.read(reader); const seen = new ResourceSet(); for (const plugin of plugins) { + if (!isContributionEnabled(plugin.enablement.read(reader))) { + continue; + } seen.add(plugin.uri); let collectionState = this._collections.get(plugin.uri); @@ -61,7 +64,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { const collectionId = `plugin.${plugin.uri}`; return this._mcpRegistry.registerCollection({ id: collectionId, - label: `${basename(plugin.uri)} (Agent Plugin)`, + label: `${plugin.label} (Agent Plugin)`, remoteAuthority: plugin.uri.scheme === Schemas.vscodeRemote ? plugin.uri.authority : null, configTarget: ConfigurationTarget.USER, scope: StorageScope.PROFILE, @@ -101,6 +104,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { env: config.env ? { ...config.env } : {}, envFile: config.envFile, cwd: config.cwd, + sandbox: undefined, }; } diff --git a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts index 88a1da8b4b6..7e4e2c18691 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts @@ -8,6 +8,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates } from '../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../../../platform/mcp/common/modelContextProtocol.js'; import { McpServer } from './mcpServer.js'; @@ -30,10 +31,25 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh private readonly _serverIdMap = new Map(); private _nextServerIndex = 0; + /** + * Per-server promise that races server startup against the grace period timeout. + * Once set for a server, subsequent list calls await the already-resolved promise + * and return immediately instead of waiting again. + * + * The `resolved` flag tracks whether the promise has settled. If a server's + * cacheState regresses to Unknown/Outdated after the promise resolved (e.g. + * after a cache reset), `_waitForStartup` discards the stale entry and creates + * a fresh race so the server gets another chance to start. + */ + private readonly _startupGrace = new Map; resolved: boolean }>(); + constructor( private readonly _mcpService: IMcpService, + private readonly _logService: ILogService, + private readonly _startupGracePeriodMs = 5000, ) { super(); + this._logService.debug('[McpGateway][ToolBroker] Initialized'); let toolsInitialized = false; this._register(autorun(reader => { @@ -42,6 +58,7 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh } if (toolsInitialized) { + this._logService.debug('[McpGateway][ToolBroker] Tools changed, firing onDidChangeTools'); this._onDidChangeTools.fire(); } else { toolsInitialized = true; @@ -55,6 +72,7 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh } if (resourcesInitialized) { + this._logService.debug('[McpGateway][ToolBroker] Resources changed, firing onDidChangeResources'); this._onDidChangeResources.fire(); } else { resourcesInitialized = true; @@ -81,6 +99,50 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh return undefined; } + private _waitForStartup(server: IMcpServer): Promise { + const id = server.definition.id; + const existing = this._startupGrace.get(id); + // If the previous grace promise already resolved but the server is still + // Unknown/Outdated, the entry is stale (e.g. caches were reset). Discard + // it so we create a fresh race below. + if (existing?.resolved) { + const state = server.cacheState.get(); + if (state === McpServerCacheState.Unknown || state === McpServerCacheState.Outdated) { + this._startupGrace.delete(id); + } + } + if (!this._startupGrace.has(id)) { + const entry: { promise: Promise; resolved: boolean } = { + promise: Promise.race([ + this._ensureServerReady(server), + new Promise(resolve => setTimeout(() => resolve(false), this._startupGracePeriodMs)), + ]), + resolved: false, + }; + entry.promise.then(() => { entry.resolved = true; }); + this._startupGrace.set(id, entry); + } + return this._startupGrace.get(id)!.promise; + } + + private async _shouldUseCachedData(server: IMcpServer): Promise { + const cacheState = server.cacheState.get(); + if (cacheState === McpServerCacheState.Unknown || cacheState === McpServerCacheState.Outdated) { + // On first list call: wait up to the grace period for the server to start. + // On subsequent calls: the stored promise is already resolved, returns immediately. + // Outdated servers get the same grace period as Unknown — a prior fast startup + // does not guarantee a fast restart. + await this._waitForStartup(server); + const newState = server.cacheState.get(); + return newState === McpServerCacheState.Live + || newState === McpServerCacheState.Cached + || newState === McpServerCacheState.RefreshingFromCached; + } + return cacheState === McpServerCacheState.Live + || cacheState === McpServerCacheState.Cached + || cacheState === McpServerCacheState.RefreshingFromCached; + } + listen(_ctx: unknown, event: string): Event { switch (event) { case 'onDidChangeTools': @@ -93,6 +155,8 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh } async call(_ctx: unknown, command: string, arg?: unknown, cancellationToken?: CancellationToken): Promise { + this._logService.debug(`[McpGateway][ToolBroker] IPC call: ${command}`); + switch (command) { case 'listTools': { const tools = await this._listTools(); @@ -122,80 +186,94 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh } private async _listTools(): Promise { - const mcpTools: MCP.Tool[] = []; const servers = this._mcpService.servers.get(); - await Promise.all(servers.map(server => this._ensureServerReady(server))); - - for (const server of servers) { - const cacheState = server.cacheState.get(); - if (cacheState !== McpServerCacheState.Live && cacheState !== McpServerCacheState.Cached && cacheState !== McpServerCacheState.RefreshingFromCached) { - continue; + const perServer = await Promise.all(servers.map(async server => { + if (!await this._shouldUseCachedData(server)) { + this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' not ready, skipping tool listing`); + return [] as MCP.Tool[]; } + return server.tools.get() + .filter(t => t.visibility & McpToolVisibility.Model) + .map(t => t.definition); + })); - for (const tool of server.tools.get()) { - if (!(tool.visibility & McpToolVisibility.Model)) { - continue; - } - - mcpTools.push(tool.definition); - } - } + const mcpTools = perServer.flat(); + this._logService.debug(`[McpGateway][ToolBroker] listTools result: ${mcpTools.length} tool(s): [${mcpTools.map(t => t.name).join(', ')}]`); return mcpTools; } private async _callTool(name: string, args: Record, token: CancellationToken = CancellationToken.None): Promise { + this._logService.debug(`[McpGateway][ToolBroker] callTool '${name}' with args: ${JSON.stringify(args)}`); + for (const server of this._mcpService.servers.get()) { const tool = server.tools.get().find(t => t.definition.name === name && (t.visibility & McpToolVisibility.Model) ); if (tool) { + this._logService.debug(`[McpGateway][ToolBroker] Found tool '${name}' on server '${server.definition.id}' (index=${this._getServerIndex(server)})`); const result = await tool.call(args, undefined, token); + this._logService.debug(`[McpGateway][ToolBroker] Tool '${name}' completed (isError=${result.isError ?? false}, content blocks=${result.content.length})`); return { result, serverIndex: this._getServerIndex(server) }; } } + this._logService.warn(`[McpGateway][ToolBroker] Tool '${name}' not found on any server`); throw new Error(`Unknown tool: ${name}`); } private async _listResources(): Promise { const results: IGatewayServerResources[] = []; const servers = this._mcpService.servers.get(); + this._logService.debug(`[McpGateway][ToolBroker] listResources: ${servers.length} server(s) known`); + await Promise.all(servers.map(async server => { - await this._ensureServerReady(server); + if (!await this._shouldUseCachedData(server)) { + return; + } const capabilities = server.capabilities.get(); if (!capabilities || !(capabilities & McpCapability.Resources)) { + this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' has no resource capability, skipping`); return; } try { const resources = await McpServer.callOn(server, h => h.listResources()); + this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' (index=${this._getServerIndex(server)}) listed ${resources.length} resource(s)`); results.push({ serverIndex: this._getServerIndex(server), resources }); - } catch { - // Server failed; skip + } catch (error) { + this._logService.warn(`[McpGateway][ToolBroker] Server '${server.definition.id}' failed to list resources`, error); } })); + this._logService.debug(`[McpGateway][ToolBroker] listResources result: ${results.length} server(s) with resources`); return results; } private async _readResource(serverIndex: number, uri: string, token: CancellationToken = CancellationToken.None): Promise { const server = this._getServerByIndex(serverIndex); if (!server) { + this._logService.warn(`[McpGateway][ToolBroker] readResource: unknown server index ${serverIndex}`); throw new Error(`Unknown server index: ${serverIndex}`); } - return McpServer.callOn(server, h => h.readResource({ uri }, token), token); + this._logService.debug(`[McpGateway][ToolBroker] readResource '${uri}' from server '${server.definition.id}' (index=${serverIndex})`); + const result = await McpServer.callOn(server, h => h.readResource({ uri }, token), token); + this._logService.debug(`[McpGateway][ToolBroker] readResource returned ${result.contents.length} content(s)`); + return result; } private async _listResourceTemplates(): Promise { const results: IGatewayServerResourceTemplates[] = []; const servers = this._mcpService.servers.get(); + this._logService.debug(`[McpGateway][ToolBroker] listResourceTemplates: ${servers.length} server(s) known`); await Promise.all(servers.map(async server => { - await this._ensureServerReady(server); + if (!await this._shouldUseCachedData(server)) { + return; + } const capabilities = server.capabilities.get(); if (!capabilities || !(capabilities & McpCapability.Resources)) { @@ -204,12 +282,14 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh try { const resourceTemplates = await McpServer.callOn(server, h => h.listResourceTemplates()); + this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' (index=${this._getServerIndex(server)}) listed ${resourceTemplates.length} resource template(s)`); results.push({ serverIndex: this._getServerIndex(server), resourceTemplates }); - } catch { - // Server failed; skip + } catch (error) { + this._logService.warn(`[McpGateway][ToolBroker] Server '${server.definition.id}' failed to list resource templates`, error); } })); + this._logService.debug(`[McpGateway][ToolBroker] listResourceTemplates result: ${results.length} server(s) with templates`); return results; } @@ -219,12 +299,16 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh return true; } + this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' not ready (cacheState=${cacheState}), starting...`); try { - return await startServerAndWaitForLiveTools(server, { + const ready = await startServerAndWaitForLiveTools(server, { promptType: 'all-untrusted', errorOnUserInteraction: true, }); - } catch { + this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' ready=${ready}`); + return ready; + } catch (error) { + this._logService.warn(`[McpGateway][ToolBroker] Server '${server.definition.id}' failed to start`, error); return false; } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index 9ef7cf37e5f..28400d12acf 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -23,6 +23,7 @@ import { mcpAppsEnabledConfig } from '../../../../platform/mcp/common/mcpManagem import { IProductService } from '../../../../platform/product/common/productService.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { isContributionEnabled } from '../../chat/common/enablement.js'; import { ChatResponseResource, getAttachableImageExtension } from '../../chat/common/model/chatModel.js'; import { LanguageModelPartAudience } from '../../chat/common/languageModels.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolConfirmationMessages, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, IToolResultInputOutputDetails, ToolDataSource, ToolProgress, ToolSet } from '../../chat/common/tools/languageModelToolsService.js'; @@ -59,6 +60,11 @@ export class McpLanguageModelToolContribution extends Disposable implements IWor const toDelete = new Set(previous.keys()); for (const server of servers) { + // Skip disabled servers — don't register their tools. + if (!isContributionEnabled(server.enablement.read(reader))) { + continue; + } + const previousRec = previous.get(server); if (previousRec) { toDelete.delete(server); diff --git a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts index 20500e68f87..d9ff9dad84a 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpRegistry.ts @@ -26,6 +26,7 @@ import { observableConfigValue } from '../../../../platform/observable/common/pl import { IQuickInputButton, IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; import { ConfigurationResolverExpression, IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js'; import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; @@ -87,6 +88,8 @@ export class McpRegistry extends Disposable implements IMcpRegistry { @ILabelService private readonly _labelService: ILabelService, @ILogService private readonly _logService: ILogService, @IMcpSandboxService private readonly _mcpSandboxService: IMcpSandboxService, + @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, ) { super(); this._mcpAccessValue = observableConfigValue(mcpAccessConfig, McpAccessValue.All, configurationService); @@ -215,6 +218,14 @@ export class McpRegistry extends Disposable implements IMcpRegistry { autoTrustChanges = false, errorOnUserInteraction = false, }: IMcpResolveConnectionOptions) { + if (collection.scope === StorageScope.WORKSPACE && !this._workspaceTrustManagementService.isWorkspaceTrusted()) { + if (errorOnUserInteraction) { + throw new UserInteractionRequiredError('workspaceTrust'); + } else if (!await this._workspaceTrustRequestService.requestWorkspaceTrust({ message: localize('runTrust', "This MCP server definition is defined in your workspace files.") })) { + return false; + } + } + if (collection.trustBehavior === McpServerTrust.Kind.Trusted) { this._logService.trace(`MCP server ${definition.id} is trusted, no trust prompt needed`); return true; diff --git a/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts b/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts index a49f79b70dc..363f8a12c5f 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpSamplingService.ts @@ -17,7 +17,7 @@ import { ConfigurationTarget, getConfigValueInTarget, IConfigurationService } fr import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; -import { ChatAgentLocation, ChatConfiguration } from '../../chat/common/constants.js'; +import { ChatConfiguration } from '../../chat/common/constants.js'; import { ChatImageMimeType, ChatMessageRole, IChatMessage, IChatMessagePart, ILanguageModelsService } from '../../chat/common/languageModels.js'; import { McpCommandIds } from './mcpCommandIds.js'; import { IMcpServerSamplingConfiguration, mcpServerSamplingSection } from './mcpConfiguration.js'; @@ -241,11 +241,8 @@ export class McpSamplingService extends Disposable implements IMcpSamplingServic return config.allowedOutsideChat === undefined ? ModelMatch.UnsureAllowedOutsideChat : ModelMatch.NotAllowed; } - // 2. Get the configured models, or the default model(s) - const foundModelIdsDeep = config.allowedModels?.filter(m => !!this._languageModelsService.lookupLanguageModel(m)) || this._languageModelsService.getLanguageModelIds().filter(m => this._languageModelsService.lookupLanguageModel(m)?.isDefaultForLocation[ChatAgentLocation.Chat]); - - const foundModelIds = foundModelIdsDeep.flat().sort((a, b) => b.length - a.length); // Sort by length to prefer most specific - + // 2. Get the configured models, or the default free model(s) + const foundModelIds = config.allowedModels?.filter(m => !!this._languageModelsService.lookupLanguageModel(m)) || this._getDefaultModels(); if (!foundModelIds.length) { return ModelMatch.NoMatchingModel; } @@ -261,6 +258,20 @@ export class McpSamplingService extends Disposable implements IMcpSamplingServic return foundModelIds[0]; // Return the first matching model } + private _getDefaultModels() { + const candidates = this._languageModelsService.getLanguageModelIds().map(m => { + const model = this._languageModelsService.lookupLanguageModel(m); + return model && !model.multiplierNumeric && !model.targetChatSessionType ? { model, id: m } : undefined; + }).filter(isDefined); + + const someDefault = candidates.findIndex(c => Object.values(c.model.isDefaultForLocation).some(Boolean)); + if (someDefault !== -1) { + [candidates[0], candidates[someDefault]] = [candidates[someDefault], candidates[0]]; + } + + return candidates.map(c => c.id); + } + private _configKey(server: IMcpServer) { return `${server.collection.label}: ${server.definition.label}`; } diff --git a/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts b/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts index 0f8db744c93..0776dcd3bff 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts @@ -19,9 +19,9 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { IMcpResourceScannerService, McpResourceTarget } from '../../../../platform/mcp/common/mcpResourceScannerService.js'; import { IRemoteAgentEnvironment } from '../../../../platform/remote/common/remoteAgentEnvironment.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; -import { IMcpSandboxConfiguration, IMcpStdioServerConfiguration, McpServerType } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; -import { IMcpPotentialSandboxBlock, McpServerDefinition, McpServerLaunch, McpServerTransportType } from './mcpTypes.js'; -import { Mutable } from '../../../../base/common/types.js'; +import { IMcpSandboxConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { IMcpPotentialSandboxBlock, McpServerDefinition, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from './mcpTypes.js'; + export const IMcpSandboxService = createDecorator('mcpSandboxService'); @@ -46,6 +46,7 @@ type SandboxConfigSuggestionResult = { type SandboxLaunchDetails = { execPath: string | undefined; srtPath: string | undefined; + rgPath: string | undefined; sandboxConfigPath: string | undefined; tempDir: URI | undefined; }; @@ -56,6 +57,7 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService private _sandboxSettingsId: string | undefined; private _remoteEnvDetailsPromise: Promise; private readonly _defaultAllowedDomains: readonly string[] = ['registry.npmjs.org']; // Default allowed domains that are commonly needed for MCP servers, even if the user doesn't specify them in their sandbox config + private _defaultAllowWritePaths: string[] = ['~/.npm']; private _sandboxConfigPerConfigurationTarget: Map = new Map(); constructor( @@ -85,17 +87,18 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService } if (await this.isEnabled(serverDef, remoteAuthority)) { this._logService.trace(`McpSandboxService: Launching with config target ${configTarget}`); - const launchDetails = await this._resolveSandboxLaunchDetails(configTarget, remoteAuthority, serverDef.sandbox, launch.cwd); - const sandboxArgs = this._getSandboxCommandArgs(launch.command, launch.args, launchDetails.sandboxConfigPath); - const sandboxEnv = this._getSandboxEnvVariables(launchDetails.tempDir, remoteAuthority); + const launchDetails = await this._resolveSandboxLaunchDetails(configTarget, remoteAuthority, launch.sandbox, launch.cwd); + const quotedCommand = this._quoteShellArgument(launch.command); + const quotedArgs = launch.args.map(arg => this._quoteShellArgument(arg)); + const sandboxArgs = this._getSandboxCommandArgs(quotedCommand, quotedArgs, launchDetails.sandboxConfigPath); + const sandboxEnv = await this._getSandboxEnvVariables(launch.env, launchDetails.tempDir, launchDetails.rgPath, remoteAuthority); if (launchDetails.srtPath) { - const envWithSandbox = sandboxEnv ? { ...launch.env, ...sandboxEnv } : launch.env; if (launchDetails.execPath) { return { ...launch, command: launchDetails.execPath, args: [launchDetails.srtPath, ...sandboxArgs], - env: envWithSandbox, + env: sandboxEnv, type: McpServerTransportType.Stdio, }; } else { @@ -103,7 +106,7 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService ...launch, command: launchDetails.srtPath, args: sandboxArgs, - env: envWithSandbox, + env: sandboxEnv, type: McpServerTransportType.Stdio, }; } @@ -160,7 +163,7 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService let didChange = false; await this._mcpResourceScannerService.updateSandboxConfig(data => { - const existingSandbox = data.sandbox ?? serverDef.sandbox; + const existingSandbox = data.sandbox; const suggestedAllowedDomains = suggestedSandboxConfig?.network?.allowedDomains ?? []; const suggestedAllowWrite = suggestedSandboxConfig?.filesystem?.allowWrite ?? []; @@ -178,41 +181,24 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService } } - didChange = currentAllowedDomains.size !== (existingSandbox?.network?.allowedDomains?.length ?? 0) - || currentAllowWrite.size !== (existingSandbox?.filesystem?.allowWrite?.length ?? 0); - - if (!didChange) { + if (suggestedAllowedDomains.length === 0 && suggestedAllowWrite.length === 0) { return data; } - const nextSandboxConfig: IMcpSandboxConfiguration = { - ...existingSandbox, - }; - - if (currentAllowedDomains.size > 0 || existingSandbox?.network?.deniedDomains?.length) { + didChange = true; + const nextSandboxConfig: IMcpSandboxConfiguration = {}; + if (currentAllowedDomains.size > 0) { nextSandboxConfig.network = { ...existingSandbox?.network, - allowedDomains: [...currentAllowedDomains], + allowedDomains: [...currentAllowedDomains] }; } - - if (currentAllowWrite.size > 0 || existingSandbox?.filesystem?.denyRead?.length || existingSandbox?.filesystem?.denyWrite?.length) { + if (currentAllowWrite.size > 0) { nextSandboxConfig.filesystem = { ...existingSandbox?.filesystem, allowWrite: [...currentAllowWrite], }; } - - //always remove sandbox at server level when writing back, it should only exist at the top level. This is to sanitize any old or malformed configs that may have sandbox defined at the server level. - if (data.servers) { - for (const serverName in data.servers) { - const serverConfig = data.servers[serverName]; - if (serverConfig.type === McpServerType.LOCAL) { - delete (serverConfig as Mutable).sandbox; - } - } - } - return { ...data, sandbox: nextSandboxConfig, @@ -270,16 +256,17 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService private async _resolveSandboxLaunchDetails(configTarget: ConfigurationTarget, remoteAuthority?: string, sandboxConfig?: IMcpSandboxConfiguration, launchCwd?: string): Promise { const os = await this._getOperatingSystem(remoteAuthority); if (os === OperatingSystem.Windows) { - return { execPath: undefined, srtPath: undefined, sandboxConfigPath: undefined, tempDir: undefined }; + return { execPath: undefined, srtPath: undefined, rgPath: undefined, sandboxConfigPath: undefined, tempDir: undefined }; } const appRoot = await this._getAppRoot(remoteAuthority); const execPath = await this._getExecPath(os, appRoot, remoteAuthority); const tempDir = await this._getTempDir(remoteAuthority); const srtPath = this._pathJoin(os, appRoot, 'node_modules', '@anthropic-ai', 'sandbox-runtime', 'dist', 'cli.js'); + const rgPath = this._pathJoin(os, appRoot, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg'); const sandboxConfigPath = tempDir ? await this._updateSandboxConfig(tempDir, configTarget, sandboxConfig, launchCwd) : undefined; this._logService.debug(`McpSandboxService: Updated sandbox config path: ${sandboxConfigPath}`); - return { execPath, srtPath, sandboxConfigPath, tempDir }; + return { execPath, srtPath, rgPath, sandboxConfigPath, tempDir }; } private async _getExecPath(os: OperatingSystem, appRoot: string, remoteAuthority?: string): Promise { @@ -289,10 +276,13 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService return undefined; // Use Electron executable as the default exec path for local development, which will run the sandbox runtime wrapper with Electron in node mode. For remote, we need to specify the node executable to ensure it runs with Node.js. } - private _getSandboxEnvVariables(tempDir: URI | undefined, remoteAuthority?: string): Record | undefined { - let env: Record = {}; + private async _getSandboxEnvVariables(baseEnv: McpServerTransportStdio['env'], tempDir: URI | undefined, rgPath: string | undefined, remoteAuthority?: string): Promise { + let env: McpServerTransportStdio['env'] = { ...baseEnv }; if (tempDir) { - env = { TMPDIR: tempDir.path, SRT_DEBUG: 'true' }; + env = { ...env, TMPDIR: tempDir.path, SRT_DEBUG: 'true', NODE_USE_ENV_PROXY: '1' }; + } + if (rgPath) { + env = { ...env, PATH: env['PATH'] ? `${env['PATH']}${await this._getPathDelimiter(remoteAuthority)}${dirname(rgPath)}` : dirname(rgPath) }; } if (!remoteAuthority) { // Add any remote-specific environment variables here @@ -307,6 +297,7 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService const result: string[] = []; if (sandboxConfigPath) { result.push('--settings', sandboxConfigPath); + result.push('--'); } result.push(command, ...args); return result; @@ -394,14 +385,13 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService } private _getDefaultAllowWrite(directories?: string[]): readonly string[] { - const defaultAllowWrite: string[] = ['~/.npm']; for (const launchCwd of directories ?? []) { const trimmed = launchCwd.trim(); if (trimmed) { - defaultAllowWrite.push(trimmed); + this._defaultAllowWritePaths.push(trimmed); } } - return defaultAllowWrite; + return this._defaultAllowWritePaths; } private _pathJoin = (os: OperatingSystem, ...segments: string[]) => { @@ -409,4 +399,13 @@ export class McpSandboxService extends Disposable implements IMcpSandboxService return path.join(...segments); }; + private _getPathDelimiter = async (remoteAuthority?: string) => { + const os = await this._getOperatingSystem(remoteAuthority); + return os === OperatingSystem.Windows ? win32.delimiter : posix.delimiter; + }; + + private _quoteShellArgument(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; + } + } diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 143f856c0f4..9f93c186e58 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -41,6 +41,7 @@ import { IMcpSandboxService } from './mcpSandboxService.js'; import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; import { McpTaskManager } from './mcpTaskManager.js'; import { ElicitationKind, extensionMcpCollectionPrefix, IMcpElicitationService, IMcpIcons, IMcpPotentialSandboxBlock, IMcpPrompt, IMcpPromptMessage, IMcpResource, IMcpResourceTemplate, IMcpSamplingService, IMcpServer, IMcpServerConnection, IMcpServerStartOpts, IMcpTool, IMcpToolCallContext, McpCapability, McpCollectionDefinition, McpCollectionReference, McpConnectionFailedError, McpConnectionState, McpDefinitionReference, mcpPromptReplaceSpecialChars, McpResourceURI, McpServerCacheState, McpServerDefinition, McpServerStaticToolAvailability, McpServerTransportType, McpToolName, McpToolVisibility, MpcResponseError, UserInteractionRequiredError } from './mcpTypes.js'; +import { ContributionEnablementState, IEnablementModel } from '../../chat/common/enablement.js'; import { MCP } from './modelContextProtocol.js'; import { McpApps } from './modelContextProtocolApps.js'; import { UriTemplate } from './uriTemplate.js'; @@ -424,6 +425,8 @@ export class McpServer extends Disposable implements IMcpServer { /** Count of running tool calls, used to detect if sampling is during an LM call */ public runningToolCalls = new Set(); + public readonly enablement: IObservable; + constructor( initialCollection: McpCollectionDefinition, public readonly definition: McpDefinitionReference, @@ -431,6 +434,7 @@ export class McpServer extends Disposable implements IMcpServer { private readonly _requiresExtensionActivation: boolean | undefined, private readonly _primitiveCache: McpServerMetadataCache, toolPrefix: string, + enablementModel: IEnablementModel, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, @IWorkspaceContextService workspacesService: IWorkspaceContextService, @IExtensionService private readonly _extensionService: IExtensionService, @@ -451,6 +455,7 @@ export class McpServer extends Disposable implements IMcpServer { this.collection = initialCollection; this._fullDefinitions = this._mcpRegistry.getServerDefinition(this.collection, this.definition); + this.enablement = derived(r => enablementModel.readEnabled(definition.id, r)); this._loggerId = `mcpServer.${definition.id}`; this._logger = this._register(_loggerService.createLogger(this._loggerId, { hidden: true, name: `MCP: ${definition.label}` })); @@ -904,6 +909,13 @@ export class McpServer extends Disposable implements IMcpServer { tool.name = tool.name.replace(toolInvalidCharRe, '_'); } + // Per MCP spec, properties is optional. But JSON Schema Draft 7 requires + // it for object types. Normalize the schema to include an empty properties + // object if not present. https://github.com/microsoft/vscode/issues/251723 + if (tool.inputSchema && !tool.inputSchema.properties) { + tool.inputSchema = { ...tool.inputSchema, properties: {} }; + } + type JsonDiagnostic = { message: string; range: { line: number; character: number }[] }; let diagnostics: JsonDiagnostic[] = []; diff --git a/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts index b67ba4f17a7..5ab7cdc20d8 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServerConnection.ts @@ -162,15 +162,7 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect }; } - if (/\b(?:EAI_AGAIN|ENOTFOUND)\b/i.test(message)) { - return { - kind: 'network', - message, - host: this._extractSandboxHost(message), - }; - } - - if (/(?:\b(?:EACCES|EPERM|ENOENT|fail(?:ed|ure)?)\b|not accessible)/i.test(message)) { + if (/(?:\b(?:EACCES|EPERM|ENOENT|EROFS|fail(?:ed|ure)?)\b|not accessible|read[- ]only)/i.test(message)) { return { kind: 'filesystem', message, @@ -187,7 +179,7 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect return bracketedPath[1].trim(); } - const quotedPath = line.match(/["'](\/[^"']+)["']/); + const quotedPath = line.match(/["'`](\/[^"'`]+)["'`]/); if (quotedPath?.[1]) { return quotedPath[1]; } @@ -197,16 +189,7 @@ export class McpServerConnection extends Disposable implements IMcpServerConnect } private _extractSandboxHost(value: string): string | undefined { - const deniedMatch = value.match(/No matching config rule, denying:\s+(.+)$/i); - const matchTarget = deniedMatch?.[1] ?? value; - const trimmed = matchTarget.trim().replace(/^["'`]+|["'`,.;]+$/g, ''); - if (!trimmed) { - return undefined; - } - - const withoutProtocol = trimmed.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); - const firstToken = withoutProtocol.split(/[\s/]/, 1)[0] ?? ''; - const host = firstToken.replace(/:\d+$/, ''); - return host || undefined; + const match = value.match(/No matching config rule, denying:\s+(?[^:\s]+):\d+\.?$/i); + return match?.groups?.host; } } diff --git a/src/vs/workbench/contrib/mcp/common/mcpService.ts b/src/vs/workbench/contrib/mcp/common/mcpService.ts index f994ad463e1..940704772ae 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpService.ts @@ -11,7 +11,8 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { mcpAutoStartConfig, McpAutoStartValue } from '../../../../platform/mcp/common/mcpManagement.js'; -import { StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { EnablementModel, isContributionEnabled } from '../../chat/common/enablement.js'; import { IMcpRegistry } from './mcpRegistryTypes.js'; import { McpServer, McpServerMetadataCache } from './mcpServer.js'; import { IAutostartResult, IMcpServer, IMcpService, McpCollectionDefinition, McpConnectionState, McpDefinitionReference, McpServerCacheState, McpServerDefinition, McpStartServerInteraction, McpToolName, UserInteractionRequiredError } from './mcpTypes.js'; @@ -29,6 +30,8 @@ export class McpService extends Disposable implements IMcpService { public get lazyCollectionState() { return this._mcpRegistry.lazyCollectionState; } + public readonly enablementModel: EnablementModel; + protected readonly userCache: McpServerMetadataCache; protected readonly workspaceCache: McpServerMetadataCache; @@ -36,10 +39,13 @@ export class McpService extends Disposable implements IMcpService { @IInstantiationService private readonly _instantiationService: IInstantiationService, @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, @ILogService private readonly _logService: ILogService, - @IConfigurationService private readonly configurationService: IConfigurationService + @IConfigurationService private readonly configurationService: IConfigurationService, + @IStorageService storageService: IStorageService, ) { super(); + this.enablementModel = this._register(new EnablementModel('mcp.enablement', storageService)); + this.userCache = this._register(_instantiationService.createInstance(McpServerMetadataCache, StorageScope.PROFILE)); this.workspaceCache = this._register(_instantiationService.createInstance(McpServerMetadataCache, StorageScope.WORKSPACE)); @@ -96,8 +102,11 @@ export class McpService extends Disposable implements IMcpService { return; } - // don't try re-running errored servers, let the user choose if they want that - const candidates = this.servers.get().filter(s => s.connectionState.get().state !== McpConnectionState.Kind.Error); + // don't try re-running errored servers or disabled servers + const candidates = this.servers.get().filter(s => + s.connectionState.get().state !== McpConnectionState.Kind.Error + && isContributionEnabled(s.enablement.get()) + ); let todo = new Set(); if (autoStartConfig === McpAutoStartValue.OnlyNew) { @@ -203,6 +212,7 @@ export class McpService extends Disposable implements IMcpService { !!def.collectionDefinition.lazy, def.collectionDefinition.scope === StorageScope.WORKSPACE ? this.workspaceCache : this.userCache, def.toolPrefix, + this.enablementModel, ); nextServers.push({ object, toolPrefix: def.toolPrefix }); diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 8ce685dfb1d..4877aa5d706 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -23,11 +23,12 @@ import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { ExtensionIdentifier } from '../../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { McpGalleryManifestStatus } from '../../../../platform/mcp/common/mcpGalleryManifest.js'; -import { IGalleryMcpServer, IInstallableMcpServer, IGalleryMcpServerConfiguration, IQueryOptions } from '../../../../platform/mcp/common/mcpManagement.js'; +import { IGalleryMcpServer, IGalleryMcpServerConfiguration, IInstallableMcpServer, IQueryOptions } from '../../../../platform/mcp/common/mcpManagement.js'; import { IMcpDevModeConfig, IMcpSandboxConfiguration, IMcpServerConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceFolder, IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; import { IWorkbenchLocalMcpServer, IWorkbencMcpServerInstallOptions } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { ContributionEnablementState, IEnablementModel } from '../../chat/common/enablement.js'; import { ToolProgress } from '../../chat/common/tools/languageModelToolsService.js'; import { IMcpServerSamplingConfiguration } from './mcpConfiguration.js'; import { McpServerRequestHandler } from './mcpServerRequestHandler.js'; @@ -138,8 +139,6 @@ export interface McpServerDefinition { readonly staticMetadata?: McpServerStaticMetadata; /** Indicates if the sandbox is enabled for this server. */ readonly sandboxEnabled?: boolean; - /** Sandbox configuration to apply for this server. */ - readonly sandbox?: IMcpSandboxConfiguration; readonly presentation?: { @@ -173,7 +172,6 @@ export namespace McpServerDefinition { readonly variableReplacement?: McpServerDefinitionVariableReplacement.Serialized; readonly staticMetadata?: McpServerStaticMetadata; readonly sandboxEnabled?: boolean; - readonly sandbox?: IMcpSandboxConfiguration; } export function toSerialized(def: McpServerDefinition): McpServerDefinition.Serialized { @@ -188,7 +186,6 @@ export namespace McpServerDefinition { staticMetadata: def.staticMetadata, launch: McpServerLaunch.fromSerialized(def.launch), sandboxEnabled: def.sandboxEnabled, - sandbox: def.sandboxEnabled ? def.sandbox : undefined, variableReplacement: def.variableReplacement ? McpServerDefinitionVariableReplacement.fromSerialized(def.variableReplacement) : undefined, }; } @@ -202,8 +199,8 @@ export namespace McpServerDefinition { && objectsEqual(a.presentation, b.presentation) && objectsEqual(a.variableReplacement, b.variableReplacement) && objectsEqual(a.devMode, b.devMode) - && a.sandboxEnabled === b.sandboxEnabled - && objectsEqual(a.sandbox, b.sandbox); + && a.sandboxEnabled === b.sandboxEnabled; + } } @@ -249,6 +246,9 @@ export interface IMcpService { _serviceBrand: undefined; readonly servers: IObservable; + /** The enablement model for MCP servers. */ + readonly enablementModel: IEnablementModel; + /** Resets the cached tools. */ resetCaches(): void; @@ -333,6 +333,7 @@ export namespace McpServerTrust { export interface IMcpServer extends IDisposable { readonly collection: McpCollectionReference; readonly definition: McpDefinitionReference; + readonly enablement: IObservable; readonly connection: IObservable; readonly connectionState: IObservable; readonly serverMetadata: IObservable<{ @@ -519,6 +520,7 @@ export interface McpServerTransportStdio { readonly args: readonly string[]; readonly env: Record; readonly envFile: string | undefined; + readonly sandbox: IMcpSandboxConfiguration | undefined; } export interface McpServerTransportHTTPAuthentication { @@ -551,7 +553,7 @@ export type McpServerLaunch = export namespace McpServerLaunch { export type Serialized = | { type: McpServerTransportType.HTTP; uri: UriComponents; headers: [string, string][]; authentication?: McpServerTransportHTTPAuthentication } - | { type: McpServerTransportType.Stdio; cwd: string | undefined; command: string; args: readonly string[]; env: Record; envFile: string | undefined }; + | { type: McpServerTransportType.Stdio; cwd: string | undefined; command: string; args: readonly string[]; env: Record; envFile: string | undefined; sandbox: IMcpSandboxConfiguration | undefined }; export function toSerialized(launch: McpServerLaunch): McpServerLaunch.Serialized { return launch; @@ -569,6 +571,7 @@ export namespace McpServerLaunch { args: launch.args, env: launch.env, envFile: launch.envFile, + sandbox: launch.sandbox }; } } @@ -744,6 +747,8 @@ export interface IMcpServerEditorOptions extends IEditorOptions { export const enum McpServerEnablementState { Disabled, DisabledByAccess, + DisabledProfile, + DisabledWorkspace, Enabled, } diff --git a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts index 2af2c0b4adf..71d0dcdc64b 100644 --- a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts +++ b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts @@ -6,6 +6,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { IMcpGatewayService, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { IMcpGatewayResult, IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; @@ -24,6 +25,7 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { constructor( @IMainProcessService mainProcessService: IMainProcessService, @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + @ILogService private readonly _logService: ILogService, ) { this._localPlatformService = ProxyChannel.toService( mainProcessService.getChannel(McpGatewayChannelName) @@ -31,6 +33,7 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { } async createGateway(inRemote: boolean): Promise { + this._logService.debug(`[McpGateway][Workbench] createGateway requested (inRemote=${inRemote})`); if (inRemote) { return this._createRemoteGateway(); } else { @@ -39,11 +42,15 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { } private async _createLocalGateway(): Promise { + this._logService.info('[McpGateway][Workbench] Creating local gateway via main process'); const info = await this._localPlatformService.createGateway(undefined); + const address = URI.revive(info.address); + this._logService.info(`[McpGateway][Workbench] Local gateway created: ${address}`); return { - address: URI.revive(info.address), + address, dispose: () => { + this._logService.info(`[McpGateway][Workbench] Disposing local gateway: ${info.gatewayId}`); this._localPlatformService.disposeGateway(info.gatewayId); } }; @@ -52,17 +59,21 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { private async _createRemoteGateway(): Promise { const connection = this._remoteAgentService.getConnection(); if (!connection) { - // No remote connection - cannot create remote gateway + this._logService.info('[McpGateway][Workbench] No remote connection available for remote gateway'); return undefined; } + this._logService.info('[McpGateway][Workbench] Creating remote gateway via remote server'); return connection.withChannel(McpGatewayChannelName, async channel => { const service = ProxyChannel.toService(channel); const info = await service.createGateway(undefined); + const address = URI.revive(info.address); + this._logService.info(`[McpGateway][Workbench] Remote gateway created: ${address}`); return { - address: URI.revive(info.address), + address, dispose: () => { + this._logService.info(`[McpGateway][Workbench] Disposing remote gateway: ${info.gatewayId}`); service.disposeGateway(info.gatewayId); } }; diff --git a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayToolBrokerContribution.ts b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayToolBrokerContribution.ts index af994c82fe3..778d53de5f6 100644 --- a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayToolBrokerContribution.ts +++ b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayToolBrokerContribution.ts @@ -5,6 +5,7 @@ import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { McpGatewayToolBrokerChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; import { IMcpService } from '../common/mcpTypes.js'; import { McpGatewayToolBrokerChannel } from '../common/mcpGatewayToolBrokerChannel.js'; @@ -13,7 +14,8 @@ export class McpGatewayToolBrokerContribution implements IWorkbenchContribution constructor( @IMainProcessService mainProcessService: IMainProcessService, @IMcpService mcpService: IMcpService, + @ILogService logService: ILogService, ) { - mainProcessService.registerChannel(McpGatewayToolBrokerChannelName, new McpGatewayToolBrokerChannel(mcpService)); + mainProcessService.registerChannel(McpGatewayToolBrokerChannelName, new McpGatewayToolBrokerChannel(mcpService, logService)); } } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts index 53124c8acc3..e0dcab8d7f1 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts @@ -6,7 +6,10 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { observableValue } from '../../../../../base/common/observable.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ContributionEnablementState } from '../../../chat/common/enablement.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IGatewayCallToolResult } from '../../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../common/modelContextProtocol.js'; import { McpGatewayToolBrokerChannel } from '../../common/mcpGatewayToolBrokerChannel.js'; @@ -18,7 +21,7 @@ suite('McpGatewayToolBrokerChannel', () => { test('lists model-visible tools with namespaced identities', async () => { const mcpService = new TestMcpService(); - const channel = new McpGatewayToolBrokerChannel(mcpService); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); const serverA = createServer('collectionA', 'serverA', [ createTool('mcp_serverA_echo', async () => ({ content: [{ type: 'text', text: 'A' }] })), @@ -43,7 +46,7 @@ suite('McpGatewayToolBrokerChannel', () => { test('routes tool calls by namespaced identity', async () => { const mcpService = new TestMcpService(); - const channel = new McpGatewayToolBrokerChannel(mcpService); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); const invoked: string[] = []; const serverA = createServer('collectionA', 'serverA', [ @@ -79,7 +82,7 @@ suite('McpGatewayToolBrokerChannel', () => { test('emits onDidChangeTools when tool lists change', async () => { const mcpService = new TestMcpService(); - const channel = new McpGatewayToolBrokerChannel(mcpService); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); const server = createServer('collectionA', 'serverA', [ createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] })), ]); @@ -104,7 +107,7 @@ suite('McpGatewayToolBrokerChannel', () => { test('does not start server when cache state is live', async () => { const mcpService = new TestMcpService(); - const channel = new McpGatewayToolBrokerChannel(mcpService); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); const server = createServer( 'collectionA', @@ -122,7 +125,7 @@ suite('McpGatewayToolBrokerChannel', () => { test('starts server when cache state is unknown', async () => { const mcpService = new TestMcpService(); - const channel = new McpGatewayToolBrokerChannel(mcpService); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); const server = createServer( 'collectionA', @@ -132,11 +135,121 @@ suite('McpGatewayToolBrokerChannel', () => { ); mcpService.servers.set([server], undefined); - await channel.call(undefined, 'listTools'); + const tools = await channel.call(undefined, 'listTools'); + // Server started during the grace period; tools are now available. assert.strictEqual(server.startCalls, 1); + assert.deepStrictEqual(tools.map(t => t.name), ['echo']); channel.dispose(); }); + + test('starts server and waits within grace period when cache state is outdated', async () => { + const mcpService = new TestMcpService(); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); + + const server = createServer( + 'collectionA', + 'serverA', + [createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))], + McpServerCacheState.Outdated, + ); + + mcpService.servers.set([server], undefined); + const tools = await channel.call(undefined, 'listTools'); + + // Outdated server gets the same grace period as Unknown — started and tools returned. + assert.strictEqual(server.startCalls, 1); + assert.deepStrictEqual(tools.map(t => t.name), ['echo']); + channel.dispose(); + }); + + test('returns empty tools and does not re-wait if server does not start within grace period', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const mcpService = new TestMcpService(); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService(), 100); + + const server = createNeverStartingServer( + 'collectionA', + 'serverA', + [createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))], + ); + + mcpService.servers.set([server], undefined); + + // First call: waits up to the grace period, server never starts → empty result. + const tools = await channel.call(undefined, 'listTools'); + assert.deepStrictEqual(tools, []); + + // Second call: grace-period promise already resolved; returns immediately without re-waiting. + const tools2 = await channel.call(undefined, 'listTools'); + assert.deepStrictEqual(tools2, []); + + channel.dispose(); + }); + }); + + test('invalidates stale grace entry when cacheState regresses to Unknown after timeout', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const mcpService = new TestMcpService(); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService(), 100); + + const server = createNeverStartingServer( + 'collectionA', + 'serverA', + [createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))], + ); + + mcpService.servers.set([server], undefined); + + // First call: grace period elapses, server never starts → empty. + const tools1 = await channel.call(undefined, 'listTools'); + assert.deepStrictEqual(tools1, []); + assert.strictEqual(server.startCalls, 1); + + // Simulate a cache reset: server goes back to Unknown. + server.cacheStateValue.set(McpServerCacheState.Unknown, undefined); + + // Make the server succeed this time. + server.startBehavior = 'succeed'; + + // Second call: stale grace entry should be discarded, a new grace race starts, + // and the server successfully starts → tools returned. + const tools2 = await channel.call(undefined, 'listTools'); + assert.deepStrictEqual(tools2.map(t => t.name), ['echo']); + assert.strictEqual(server.startCalls, 2); + + channel.dispose(); + }); + }); + + test('does not invalidate grace entry when cacheState is not Unknown/Outdated', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const mcpService = new TestMcpService(); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService(), 100); + + const server = createServer( + 'collectionA', + 'serverA', + [createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))], + McpServerCacheState.Unknown, + ); + + mcpService.servers.set([server], undefined); + + // First call: server starts successfully during grace period. + const tools1 = await channel.call(undefined, 'listTools'); + assert.deepStrictEqual(tools1.map(t => t.name), ['echo']); + assert.strictEqual(server.startCalls, 1); + + // Second call: cacheState is now Live (server started), grace entry should NOT + // be invalidated, so no additional start call is made. + const tools2 = await channel.call(undefined, 'listTools'); + assert.deepStrictEqual(tools2.map(t => t.name), ['echo']); + assert.strictEqual(server.startCalls, 1); + + channel.dispose(); + }); + }); }); function createServer( @@ -156,6 +269,7 @@ function createServer( definition: { id: definitionId, label: definitionId }, connection: observableValue(owner, undefined), connectionState, + enablement: observableValue(owner, ContributionEnablementState.EnabledProfile), serverMetadata: observableValue(owner, undefined), readDefinitions: () => observableValue(owner, { server: undefined, collection: undefined }), showOutput: async () => { }, @@ -177,6 +291,52 @@ function createServer( }; } +function createNeverStartingServer( + collectionId: string, + definitionId: string, + initialTools: readonly IMcpTool[], +): IMcpServer & { startCalls: number; startBehavior: 'hang' | 'succeed'; cacheStateValue: ReturnType> } { + const owner = {}; + const tools = observableValue(owner, initialTools); + const connectionState = observableValue(owner, { state: McpConnectionState.Kind.Running }); + const cacheState = observableValue(owner, McpServerCacheState.Unknown); + let startCalls = 0; + let startBehavior: 'hang' | 'succeed' = 'hang'; + + const result: IMcpServer & { startCalls: number; startBehavior: 'hang' | 'succeed'; cacheStateValue: ReturnType> } = { + collection: { id: collectionId, label: collectionId }, + definition: { id: definitionId, label: definitionId }, + connection: observableValue(owner, undefined), + connectionState, + enablement: observableValue(owner, ContributionEnablementState.EnabledProfile), + serverMetadata: observableValue(owner, undefined), + readDefinitions: () => observableValue(owner, { server: undefined, collection: undefined }), + showOutput: async () => { }, + start: async () => { + startCalls++; + if (result.startBehavior === 'succeed') { + cacheState.set(McpServerCacheState.Live, undefined); + return { state: McpConnectionState.Kind.Running }; + } + // Never resolves — simulates a server that hangs on startup. + return new Promise(() => { }); + }, + stop: async () => { }, + cacheState, + tools, + prompts: observableValue(owner, []), + capabilities: observableValue(owner, undefined), + resources: () => (async function* () { })(), + resourceTemplates: async () => [], + dispose: () => { }, + get startCalls() { return startCalls; }, + get startBehavior() { return startBehavior; }, + set startBehavior(v) { startBehavior = v; }, + cacheStateValue: cacheState, + }; + return result; +} + function createTool(name: string, call: (params: Record) => Promise, visibility: McpToolVisibility = McpToolVisibility.Model): IMcpTool { const definition: MCP.Tool = { name, diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpIcons.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpIcons.test.ts index 7cdb2b661d0..90430507be1 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpIcons.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpIcons.test.ts @@ -22,7 +22,8 @@ const createStdioLaunch = (): McpServerTransportStdio => ({ command: 'cmd', args: [], env: {}, - envFile: undefined + envFile: undefined, + sandbox: undefined }); suite('MCP Icons', () => { diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts index e052e97d131..722a4ee2fe7 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistry.test.ts @@ -239,6 +239,7 @@ suite('Workbench - MCP - Registry', () => { env: {}, envFile: undefined, cwd: '/test', + sandbox: undefined } }; }); @@ -301,6 +302,7 @@ suite('Workbench - MCP - Registry', () => { }, envFile: undefined, cwd: '/test', + sandbox: undefined }, variableReplacement: { section: 'mcp', @@ -402,6 +404,7 @@ suite('Workbench - MCP - Registry', () => { env: {}, envFile: undefined, cwd: '/test', + sandbox: undefined }, }; @@ -726,6 +729,7 @@ suite('Workbench - MCP - Registry', () => { env: {}, envFile: undefined, cwd: '/test', + sandbox: undefined } }; } diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts index 2648adedeb4..69b07fdf55f 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpRegistryTypes.ts @@ -171,7 +171,7 @@ export class TestMcpRegistry implements IMcpRegistry { serverDefinitions: observableValue(this, [{ id: 'test-server', label: 'Test Server', - launch: { type: McpServerTransportType.Stdio, command: 'echo', args: ['Hello MCP'], env: {}, envFile: undefined, cwd: undefined }, + launch: { type: McpServerTransportType.Stdio, command: 'echo', args: ['Hello MCP'], env: {}, envFile: undefined, cwd: undefined, sandbox: undefined }, cacheNonce: 'a', } satisfies McpServerDefinition]), trustBehavior: McpServerTrust.Kind.Trusted, diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpResourceFilesystem.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpResourceFilesystem.test.ts index 543ab09965b..e546b5f5153 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpResourceFilesystem.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpResourceFilesystem.test.ts @@ -35,9 +35,10 @@ suite('Workbench - MCP - ResourceFilesystem', () => { let fs: McpResourceFilesystem; setup(() => { + const storageService = ds.add(new TestStorageService()); const services = new ServiceCollection( [IFileService, { registerProvider: () => { } }], - [IStorageService, ds.add(new TestStorageService())], + [IStorageService, storageService], [ILoggerService, ds.add(new TestLoggerService())], [IWorkspaceContextService, new TestContextService()], [IWorkbenchEnvironmentService, {}], @@ -49,7 +50,7 @@ suite('Workbench - MCP - ResourceFilesystem', () => { const registry = new TestMcpRegistry(parentInsta1); const parentInsta2 = ds.add(parentInsta1.createChild(new ServiceCollection([IMcpRegistry, registry]))); - const mcpService = ds.add(new McpService(parentInsta2, registry, new NullLogService(), new TestConfigurationService())); + const mcpService = ds.add(new McpService(parentInsta2, registry, new NullLogService(), new TestConfigurationService(), storageService)); mcpService.updateCollectedServers(); const instaService = ds.add(parentInsta2.createChild(new ServiceCollection( diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts index 33114bc70d4..518481fb123 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerConnection.test.ts @@ -108,7 +108,8 @@ suite('Workbench - MCP - ServerConnection', () => { args: [], env: {}, envFile: undefined, - cwd: '/test' + cwd: '/test', + sandbox: undefined } }; }); @@ -337,6 +338,153 @@ suite('Workbench - MCP - ServerConnection', () => { await timeout(10); }); + test('should emit a sandbox filesystem block for read-only errors with backtick paths', async () => { + const sandboxedDefinition: McpServerDefinition = { + ...serverDefinition, + sandboxEnabled: true, + }; + + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + sandboxedDefinition, + delegate, + sandboxedDefinition.launch, + new NullLogger(), + false, + store.add(new McpTaskManager()), + ); + store.add(connection); + + const message = 'error: failed to open file `/test-for-sandbox/.git`: Read-only file system (os error 30)'; + const sandboxBlock = Event.toPromise(connection.onPotentialSandboxBlock); + const startPromise = connection.start({}); + + transport.simulateLog(message); + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + + assert.deepStrictEqual(await sandboxBlock, { + kind: 'filesystem', + message, + path: '/test-for-sandbox/.git', + }); + + await startPromise; + + connection.dispose(); + await timeout(10); + }); + + test('should emit a sandbox filesystem block for read-only errors with double-quoted paths', async () => { + const sandboxedDefinition: McpServerDefinition = { + ...serverDefinition, + sandboxEnabled: true, + }; + + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + sandboxedDefinition, + delegate, + sandboxedDefinition.launch, + new NullLogger(), + false, + store.add(new McpTaskManager()), + ); + store.add(connection); + + const message = 'error: failed to open file `/test-for-sandbox/.testfile`: Read-only file system (os error 30)'; + const sandboxBlock = Event.toPromise(connection.onPotentialSandboxBlock); + const startPromise = connection.start({}); + + transport.simulateLog(message); + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + + assert.deepStrictEqual(await sandboxBlock, { + kind: 'filesystem', + message, + path: '/test-for-sandbox/.testfile', + }); + + await startPromise; + + connection.dispose(); + await timeout(10); + }); + + test('should emit a sandbox filesystem block for read-only at-path errors with double-quoted paths', async () => { + const sandboxedDefinition: McpServerDefinition = { + ...serverDefinition, + sandboxEnabled: true, + }; + + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + sandboxedDefinition, + delegate, + sandboxedDefinition.launch, + new NullLogger(), + false, + store.add(new McpTaskManager()), + ); + store.add(connection); + + const message = 'error: Read-only file system (os error 30) at path "/test-for-sandbox/.testfile"'; + const sandboxBlock = Event.toPromise(connection.onPotentialSandboxBlock); + const startPromise = connection.start({}); + + transport.simulateLog(message); + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + + assert.deepStrictEqual(await sandboxBlock, { + kind: 'filesystem', + message, + path: '/test-for-sandbox/.testfile', + }); + + await startPromise; + + connection.dispose(); + await timeout(10); + }); + + test('should emit a sandbox network block with the denied host', async () => { + const sandboxedDefinition: McpServerDefinition = { + ...serverDefinition, + sandboxEnabled: true, + }; + + const connection = instantiationService.createInstance( + McpServerConnection, + collection, + sandboxedDefinition, + delegate, + sandboxedDefinition.launch, + new NullLogger(), + false, + store.add(new McpTaskManager()), + ); + store.add(connection); + + const sandboxBlock = Event.toPromise(connection.onPotentialSandboxBlock); + const startPromise = connection.start({}); + + transport.simulateLog('No matching config rule, denying: api.example.com:443.'); + transport.setConnectionState({ state: McpConnectionState.Kind.Running }); + + assert.deepStrictEqual(await sandboxBlock, { + kind: 'network', + message: 'No matching config rule, denying: api.example.com:443.', + host: 'api.example.com', + }); + + await startPromise; + + connection.dispose(); + await timeout(10); + }); + test('should correctly handle transitions to and from error state', async () => { // Create server connection const connection = instantiationService.createInstance( diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts index b73a1d48fb5..d7a6d11283b 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpTypes.test.ts @@ -40,7 +40,8 @@ suite('MCP Types', () => { command: 'test-command', args: [], env: {}, - envFile: undefined + envFile: undefined, + sandbox: undefined }, ...overrides }); @@ -89,7 +90,8 @@ suite('MCP Types', () => { command: 'command1', args: [], env: {}, - envFile: undefined + envFile: undefined, + sandbox: undefined } }); const def2 = createBasicDefinition({ @@ -99,7 +101,8 @@ suite('MCP Types', () => { command: 'command2', args: [], env: {}, - envFile: undefined + envFile: undefined, + sandbox: undefined } }); assert.strictEqual(McpServerDefinition.equals(def1, def2), false); diff --git a/src/vs/workbench/contrib/mcp/test/common/testMcpService.ts b/src/vs/workbench/contrib/mcp/test/common/testMcpService.ts index 8479338a5b3..004220e5876 100644 --- a/src/vs/workbench/contrib/mcp/test/common/testMcpService.ts +++ b/src/vs/workbench/contrib/mcp/test/common/testMcpService.ts @@ -4,11 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { observableValue } from '../../../../../base/common/observable.js'; +import { ContributionEnablementState, IEnablementModel } from '../../../chat/common/enablement.js'; import { IAutostartResult, IMcpServer, IMcpService, LazyCollectionState } from '../../common/mcpTypes.js'; +export class TestEnablementModel implements IEnablementModel { + readEnabled(_key: string): ContributionEnablementState { + return ContributionEnablementState.EnabledProfile; + } + setEnabled(_key: string, _state: ContributionEnablementState): void { } +} + export class TestMcpService implements IMcpService { declare readonly _serviceBrand: undefined; public servers = observableValue(this, []); + public readonly enablementModel: IEnablementModel = new TestEnablementModel(); resetCaches(): void { } diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.css b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.css index 65208ad34b0..4bd8e44bf11 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.css +++ b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.css @@ -16,7 +16,7 @@ visibility: hidden; background-color: var(--vscode-editorWidget-background) !important; color: var(--vscode-editorWidget-foreground); - box-shadow: 0 0 8px 2px var(--vscode-widget-shadow); + box-shadow: var(--vscode-shadow-lg); border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; } diff --git a/src/vs/workbench/contrib/notebook/browser/media/notebook.css b/src/vs/workbench/contrib/notebook/browser/media/notebook.css index 54f1f0dfc78..01010edd2af 100644 --- a/src/vs/workbench/contrib/notebook/browser/media/notebook.css +++ b/src/vs/workbench/contrib/notebook/browser/media/notebook.css @@ -299,6 +299,7 @@ display: block; position: absolute; pointer-events: none; + box-shadow: inset var(--vscode-shadow-sm); } .monaco-workbench .notebookOverlay .monaco-list .monaco-list-row .cell-insertion-indicator-top { @@ -383,9 +384,10 @@ .notebookOverlay .monaco-list-row .cell-title-toolbar { border-radius: var(--vscode-cornerRadius-medium); + box-shadow: var(--vscode-shadow-sm); + background-color: var(--vscode-editorWidget-background); } -.notebookOverlay .monaco-list-row .cell-title-toolbar, .notebookOverlay .monaco-list-row.cell-drag-image, .notebookOverlay .cell-bottom-toolbar-container .action-item, .notebookOverlay .cell-list-top-cell-toolbar-container .action-item { diff --git a/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts b/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts index 18206f1ab62..b6a6170b071 100644 --- a/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts +++ b/src/vs/workbench/contrib/preferences/browser/keybindingWidgets.ts @@ -20,7 +20,7 @@ import { IKeybindingService } from '../../../../platform/keybinding/common/keybi import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ICodeEditor, IOverlayWidget, IOverlayWidgetPosition } from '../../../../editor/browser/editorBrowser.js'; -import { asCssVariable, editorWidgetBackground, editorWidgetForeground, widgetShadow } from '../../../../platform/theme/common/colorRegistry.js'; +import { asCssVariable, editorWidgetBackground, editorWidgetForeground } from '../../../../platform/theme/common/colorRegistry.js'; import { ScrollType } from '../../../../editor/common/editorCommon.js'; import { SearchWidget, SearchOptions } from './preferencesWidgets.js'; import { Promises, timeout } from '../../../../base/common/async.js'; @@ -171,7 +171,6 @@ export class DefineKeybindingWidget extends Widget { this._domNode.domNode.style.backgroundColor = asCssVariable(editorWidgetBackground); this._domNode.domNode.style.color = asCssVariable(editorWidgetForeground); - this._domNode.domNode.style.boxShadow = `0 2px 8px ${asCssVariable(widgetShadow)}`; this._keybindingInputWidget = this._register(this.instantiationService.createInstance(KeybindingsSearchWidget, this._domNode.domNode, { ariaLabel: message, history: new Set([]), inputBoxStyles: defaultInputBoxStyles })); this._keybindingInputWidget.startRecordingKeys(); diff --git a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts index 5ac483ffef2..6fce0410f84 100644 --- a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts +++ b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts @@ -15,7 +15,6 @@ import { HighlightedLabel } from '../../../../base/browser/ui/highlightedlabel/h import { KeybindingLabel } from '../../../../base/browser/ui/keybindingLabel/keybindingLabel.js'; import { IAction, Action, Separator } from '../../../../base/common/actions.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -43,13 +42,13 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { MenuRegistry, MenuId, isIMenuItem } from '../../../../platform/actions/common/actions.js'; import { IListAccessibilityProvider } from '../../../../base/browser/ui/list/listWidget.js'; import { WORKBENCH_BACKGROUND } from '../../../common/theme.js'; -import { IKeybindingItemEntry, IKeybindingsEditorPane, IPreferencesService } from '../../../services/preferences/common/preferences.js'; +import { IKeybindingItemEntry, IKeybindingsEditorPane } from '../../../services/preferences/common/preferences.js'; import { keybindingsRecordKeysIcon, keybindingsSortIcon, keybindingsAddIcon, preferencesClearInputIcon, keybindingsEditIcon } from './preferencesIcons.js'; import { ITableRenderer, ITableVirtualDelegate } from '../../../../base/browser/ui/table/table.js'; import { KeybindingsEditorInput } from '../../../services/preferences/browser/keybindingsEditorInput.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { ToolBar } from '../../../../base/browser/ui/toolbar/toolbar.js'; -import { defaultButtonStyles, defaultKeybindingLabelStyles, defaultToggleStyles, getInputBoxStyle } from '../../../../platform/theme/browser/defaultStyles.js'; +import { defaultKeybindingLabelStyles, defaultToggleStyles, getInputBoxStyle } from '../../../../platform/theme/browser/defaultStyles.js'; import { IExtensionsWorkbenchService } from '../../extensions/common/extensions.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { isString } from '../../../../base/common/types.js'; @@ -131,8 +130,7 @@ export class KeybindingsEditor extends EditorPane imp @IEditorService private readonly editorService: IEditorService, @IStorageService storageService: IStorageService, @IConfigurationService private readonly configurationService: IConfigurationService, - @IAccessibilityService private readonly accessibilityService: IAccessibilityService, - @IPreferencesService private readonly preferencesService: IPreferencesService + @IAccessibilityService private readonly accessibilityService: IAccessibilityService ) { super(KeybindingsEditor.ID, group, telemetryService, themeService, storageService); this.delayedFiltering = this._register(new Delayer(300)); @@ -366,8 +364,7 @@ export class KeybindingsEditor extends EditorPane imp const clearInputAction = this._register(new Action(KEYBINDINGS_EDITOR_COMMAND_CLEAR_SEARCH_RESULTS, localize('clearInput', "Clear Keybindings Search Input"), ThemeIcon.asClassName(preferencesClearInputIcon), false, async () => this.clearSearchResults())); - const searchRowContainer = DOM.append(this.headerContainer, $('.search-row-container')); - const searchContainer = DOM.append(searchRowContainer, $('.search-container')); + const searchContainer = DOM.append(this.headerContainer, $('.search-container')); this.searchWidget = this._register(this.instantiationService.createInstance(KeybindingsSearchWidget, searchContainer, { ariaLabel: fullTextSearchPlaceholder, placeholder: fullTextSearchPlaceholder, @@ -429,11 +426,6 @@ export class KeybindingsEditor extends EditorPane imp })); toolBar.setActions(actions); this._register(this.keybindingsService.onDidUpdateKeybindings(() => toolBar.setActions(actions))); - - const openKeybindingsJsonContainer = DOM.append(searchRowContainer, $('.open-keybindings-json')); - const openKeybindingsJsonButton = this._register(new Button(openKeybindingsJsonContainer, { secondary: true, title: true, ...defaultButtonStyles })); - openKeybindingsJsonButton.label = localize('openKeybindingsJson', "Edit as JSON"); - this._register(openKeybindingsJsonButton.onDidClick(() => this.preferencesService.openGlobalKeybindingSettings(true, { groupId: this.group.id }))); } private updateSearchOptions(): void { diff --git a/src/vs/workbench/contrib/preferences/browser/media/keybindings.css b/src/vs/workbench/contrib/preferences/browser/media/keybindings.css index 3874b5b70f7..482f819ee58 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/keybindings.css +++ b/src/vs/workbench/contrib/preferences/browser/media/keybindings.css @@ -7,6 +7,8 @@ padding: 10px; border-radius: var(--vscode-cornerRadius-large); position: absolute; + box-shadow: var(--vscode-shadow-lg); + border: 1px solid var(--vscode-editorWidget-border); } .defineKeybindingWidget .message { diff --git a/src/vs/workbench/contrib/preferences/browser/media/keybindingsEditor.css b/src/vs/workbench/contrib/preferences/browser/media/keybindingsEditor.css index f93706d6120..96a94b07cde 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/keybindingsEditor.css +++ b/src/vs/workbench/contrib/preferences/browser/media/keybindingsEditor.css @@ -23,13 +23,11 @@ padding: 0px 10px 11px 0; } -.keybindings-editor > .keybindings-header > .search-row-container > .search-container { +.keybindings-editor > .keybindings-header > .search-container { position: relative; - flex: 1; - min-width: 0; } -.keybindings-editor > .keybindings-header > .search-row-container > .search-container > .keybindings-search-actions-container { +.keybindings-editor > .keybindings-header > .search-container > .keybindings-search-actions-container { position: absolute; top: 0; right: 10px; @@ -37,22 +35,22 @@ display: flex; } -.keybindings-editor > .keybindings-header > .search-row-container > .search-container > .keybindings-search-actions-container > .recording-badge { +.keybindings-editor > .keybindings-header > .search-container > .keybindings-search-actions-container > .recording-badge { margin-right: 8px; padding: 4px; } -.keybindings-editor > .keybindings-header.small > .search-row-container > .search-container > .keybindings-search-actions-container > .recording-badge, -.keybindings-editor > .keybindings-header > .search-row-container > .search-container > .keybindings-search-actions-container > .recording-badge.disabled { +.keybindings-editor > .keybindings-header.small > .search-container > .keybindings-search-actions-container > .recording-badge, +.keybindings-editor > .keybindings-header > .search-container > .keybindings-search-actions-container > .recording-badge.disabled { display: none; } -.keybindings-editor > .keybindings-header > .search-row-container > .search-container > .keybindings-search-actions-container .monaco-action-bar .action-item > .icon { +.keybindings-editor > .keybindings-header > .search-container > .keybindings-search-actions-container .monaco-action-bar .action-item > .icon { width: 16px; height: 18px; } -.keybindings-editor > .keybindings-header > .search-row-container > .search-container > .keybindings-search-actions-container .monaco-action-bar .action-item { +.keybindings-editor > .keybindings-header > .search-container > .keybindings-search-actions-container .monaco-action-bar .action-item { margin-right: 4px; } @@ -88,21 +86,6 @@ opacity: 1; } -.keybindings-editor > .keybindings-header > .search-row-container { - display: flex; - align-items: center; - gap: 8px; -} - -.keybindings-editor > .keybindings-header > .search-row-container > .open-keybindings-json { - flex-shrink: 0; -} - -.keybindings-editor > .keybindings-header > .search-row-container > .open-keybindings-json > .monaco-button { - padding: 2px 8px; - line-height: 18px; -} - /** Table styling **/ .keybindings-editor > .keybindings-body .keybindings-table-container { diff --git a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css index 280ae562ace..f2540882206 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css +++ b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css @@ -62,6 +62,7 @@ .settings-editor > .settings-header > .search-container > .search-container-widgets > .settings-count-widget { margin-right: 3px; padding-bottom: 3px; + color: var(--vscode-descriptionForeground); } .settings-editor > .settings-header > .search-container > .search-container-widgets > .settings-count-widget:empty { @@ -95,13 +96,6 @@ flex: auto; } -.settings-editor > .settings-header > .settings-header-controls > .settings-right-controls { - display: flex; - align-items: center; - gap: 8px; - padding-bottom: 4px; -} - .settings-editor > .settings-header > .settings-header-controls .settings-tabs-widget .action-label { opacity: 0.9; border-radius: 0; @@ -295,6 +289,7 @@ pointer-events: none; z-index: 10; position: absolute; + box-shadow: var(--vscode-shadow-sm); } .settings-editor > .settings-body .settings-toc-container .monaco-list { diff --git a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts index c1bd907895e..3f491f41b3a 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferences.contribution.ts @@ -835,6 +835,12 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon group: 'navigation', order: 1, }, + { + id: MenuId.ModalEditorEditorTitle, + when: ResourceContextKey.Resource.isEqualTo(that.userDataProfileService.currentProfile.keybindingsResource.toString()), + group: 'navigation', + order: 1, + }, { id: MenuId.GlobalActivity, group: '2_configuration', @@ -883,6 +889,11 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon id: MenuId.EditorTitle, when: ContextKeyExpr.and(CONTEXT_KEYBINDINGS_EDITOR), group: 'navigation', + }, + { + id: MenuId.ModalEditorEditorTitle, + when: ContextKeyExpr.and(CONTEXT_KEYBINDINGS_EDITOR), + group: 'navigation', } ] }); @@ -1246,13 +1257,24 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon const commandId = '_workbench.openWorkspaceSettingsEditor'; if (this.workspaceContextService.getWorkbenchState() === WorkbenchState.WORKSPACE && !CommandsRegistry.getCommand(commandId)) { CommandsRegistry.registerCommand(commandId, () => this.preferencesService.openWorkspaceSettings({ jsonEditor: false })); + const when = ContextKeyExpr.and(ResourceContextKey.Resource.isEqualTo(this.preferencesService.workspaceSettingsResource!.toString()), WorkbenchStateContext.isEqualTo('workspace'), ContextKeyExpr.not('isInDiffEditor')); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: commandId, title: OPEN_USER_SETTINGS_UI_TITLE, icon: preferencesOpenSettingsIcon }, - when: ContextKeyExpr.and(ResourceContextKey.Resource.isEqualTo(this.preferencesService.workspaceSettingsResource!.toString()), WorkbenchStateContext.isEqualTo('workspace'), ContextKeyExpr.not('isInDiffEditor')), + when, + group: 'navigation', + order: 1 + }); + MenuRegistry.appendMenuItem(MenuId.ModalEditorEditorTitle, { + command: { + id: commandId, + title: OPEN_USER_SETTINGS_UI_TITLE, + icon: preferencesOpenSettingsIcon + }, + when, group: 'navigation', order: 1 }); @@ -1272,13 +1294,24 @@ class PreferencesActionsContribution extends Disposable implements IWorkbenchCon return this.preferencesService.openFolderSettings({ folderUri: folder.uri, jsonEditor: false, groupId }); } }); + const when = ContextKeyExpr.and(ResourceContextKey.Resource.isEqualTo(this.preferencesService.getFolderSettingsResource(folder.uri)!.toString()), ContextKeyExpr.not('isInDiffEditor')); MenuRegistry.appendMenuItem(MenuId.EditorTitle, { command: { id: commandId, title: OPEN_USER_SETTINGS_UI_TITLE, icon: preferencesOpenSettingsIcon }, - when: ContextKeyExpr.and(ResourceContextKey.Resource.isEqualTo(this.preferencesService.getFolderSettingsResource(folder.uri)!.toString()), ContextKeyExpr.not('isInDiffEditor')), + when, + group: 'navigation', + order: 1 + }); + MenuRegistry.appendMenuItem(MenuId.ModalEditorEditorTitle, { + command: { + id: commandId, + title: OPEN_USER_SETTINGS_UI_TITLE, + icon: preferencesOpenSettingsIcon + }, + when, group: 'navigation', order: 1 }); @@ -1320,6 +1353,11 @@ class SettingsEditorTitleContribution extends Disposable implements IWorkbenchCo when: openUserSettingsEditorWhen, group: 'navigation', order: 1 + }, { + id: MenuId.ModalEditorEditorTitle, + when: openUserSettingsEditorWhen, + group: 'navigation', + order: 1 }] }); } @@ -1349,6 +1387,11 @@ class SettingsEditorTitleContribution extends Disposable implements IWorkbenchCo when: openSettingsJsonWhen, group: 'navigation', order: 1 + }, { + id: MenuId.ModalEditorEditorTitle, + when: openSettingsJsonWhen, + group: 'navigation', + order: 1 }] }); } diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts b/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts index 8c40f80dbe2..e33e0d1413d 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesWidgets.ts @@ -363,7 +363,7 @@ export class SettingsTargetsWidget extends Widget { private async update(): Promise { this.settingsSwitcherBar.domNode.classList.toggle('empty-workbench', this.contextService.getWorkbenchState() === WorkbenchState.EMPTY); this.userRemoteSettings.enabled = !!(this.options.enableRemoteSettings && this.environmentService.remoteAuthority); - this.workspaceSettings.enabled = this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY; + this.workspaceSettings.enabled = this.contextService.getWorkbenchState() !== WorkbenchState.EMPTY && !this.environmentService.isSessionsWindow; this.folderSettings.action.enabled = this.contextService.getWorkbenchState() === WorkbenchState.WORKSPACE && this.contextService.getWorkspace().folders.length > 0; this.workspaceSettings.tooltip = localize('workspaceSettings', "Workspace"); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 19d87307110..17e5663deb8 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -44,7 +44,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { defaultButtonStyles, defaultToggleStyles } from '../../../../platform/theme/browser/defaultStyles.js'; -import { asCssVariable, asCssVariableWithDefault, badgeBackground, badgeForeground, contrastBorder, editorForeground, inputBackground } from '../../../../platform/theme/common/colorRegistry.js'; +import { asCssVariable, editorForeground } from '../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IUserDataSyncEnablementService, IUserDataSyncService, SyncStatus } from '../../../../platform/userDataSync/common/userDataSync.js'; import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; @@ -783,15 +783,8 @@ export class SettingsEditor2 extends EditorPane { } })); - const headerRightControlsContainer = DOM.append(headerControlsContainer, $('.settings-right-controls')); - - const openSettingsJsonContainer = DOM.append(headerRightControlsContainer, $('.open-settings-json')); - const openSettingsJsonButton = this._register(new Button(openSettingsJsonContainer, { secondary: true, title: true, ...defaultButtonStyles })); - openSettingsJsonButton.label = localize('openSettingsJson', "Edit as JSON"); - this._register(openSettingsJsonButton.onDidClick(() => this.openSettingsFile())); - if (this.userDataSyncWorkbenchService.enabled && this.userDataSyncEnablementService.canToggleEnablement()) { - const syncControls = this._register(this.instantiationService.createInstance(SyncControls, this.window, headerRightControlsContainer)); + const syncControls = this._register(this.instantiationService.createInstance(SyncControls, this.window, headerControlsContainer)); this._register(syncControls.onDidChangeLastSyncedLabel(lastSyncedLabel => { this.lastSyncedLabel = lastSyncedLabel; this.updateInputAriaLabel(); @@ -801,9 +794,6 @@ export class SettingsEditor2 extends EditorPane { this.controlsElement = DOM.append(this.searchContainer, DOM.$('.search-container-widgets')); this.countElement = DOM.append(this.controlsElement, DOM.$('.settings-count-widget.monaco-count-badge.long')); - this.countElement.style.backgroundColor = asCssVariable(badgeBackground); - this.countElement.style.color = asCssVariable(badgeForeground); - this.countElement.style.border = `1px solid ${asCssVariableWithDefault(contrastBorder, asCssVariable(inputBackground))}`; this.searchInputActionBar = this._register(new ActionBar(this.controlsElement, { actionViewItemProvider: (action, options) => { @@ -2166,9 +2156,10 @@ class SyncControls extends Disposable { ) { super(); - const turnOnSyncButtonContainer = DOM.append(container, $('.turn-on-sync')); + const headerRightControlsContainer = DOM.append(container, $('.settings-right-controls')); + const turnOnSyncButtonContainer = DOM.append(headerRightControlsContainer, $('.turn-on-sync')); this.turnOnSyncButton = this._register(new Button(turnOnSyncButtonContainer, { title: true, ...defaultButtonStyles })); - this.lastSyncedLabel = DOM.append(container, $('.last-synced-label')); + this.lastSyncedLabel = DOM.append(headerRightControlsContainer, $('.last-synced-label')); DOM.hide(this.lastSyncedLabel); this.turnOnSyncButton.enabled = true; diff --git a/src/vs/workbench/contrib/scm/browser/media/scm.css b/src/vs/workbench/contrib/scm/browser/media/scm.css index 20c78c396f1..408b771f84c 100644 --- a/src/vs/workbench/contrib/scm/browser/media/scm.css +++ b/src/vs/workbench/contrib/scm/browser/media/scm.css @@ -38,6 +38,7 @@ height: 100%; align-items: center; flex-flow: nowrap; + box-shadow: var(--vscode-shadow-sm); } .scm-view.hide-provider-counts .scm-provider > .count, diff --git a/src/vs/workbench/contrib/scm/browser/scmInput.ts b/src/vs/workbench/contrib/scm/browser/scmInput.ts index bcc312c6ba2..a35d479e1a8 100644 --- a/src/vs/workbench/contrib/scm/browser/scmInput.ts +++ b/src/vs/workbench/contrib/scm/browser/scmInput.ts @@ -261,6 +261,7 @@ class SCMInputWidgetEditorOptions { e.affectsConfiguration('editor.cursorWidth') || e.affectsConfiguration('editor.emptySelectionClipboard') || e.affectsConfiguration('editor.fontFamily') || + e.affectsConfiguration('editor.roundedSelection') || e.affectsConfiguration('editor.rulers') || e.affectsConfiguration('editor.wordWrap') || e.affectsConfiguration('editor.wordSegmenterLocales') || @@ -304,8 +305,9 @@ class SCMInputWidgetEditorOptions { const cursorStyle = this.configurationService.getValue('editor.cursorStyle'); const cursorWidth = this.configurationService.getValue('editor.cursorWidth') ?? 1; const emptySelectionClipboard = this.configurationService.getValue('editor.emptySelectionClipboard') === true; + const roundedSelection = this.configurationService.getValue('editor.roundedSelection') === true; - return { ...this._getEditorLanguageConfiguration(), accessibilitySupport, cursorBlinking, cursorStyle, cursorWidth, fontFamily, fontSize, lineHeight, emptySelectionClipboard, wordSegmenterLocales }; + return { ...this._getEditorLanguageConfiguration(), accessibilitySupport, cursorBlinking, cursorStyle, cursorWidth, fontFamily, fontSize, lineHeight, emptySelectionClipboard, roundedSelection, wordSegmenterLocales }; } private _getEditorFontFamily(): string { diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index a730051c4e7..b54f86b3ad3 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -520,6 +520,12 @@ export interface ITerminalService extends ITerminalInstanceHost { * @param forceSaveState Used when the window is shutting down and we need to reveal and save hideFromUser terminals */ showBackgroundTerminal(instance: ITerminalInstance, suppressSetActive?: boolean): Promise; + /** + * Moves a visible terminal instance to the background. The terminal process + * remains alive but the instance is removed from its group/editor and tracked + * internally so it can later be shown again via {@link showBackgroundTerminal}. + */ + moveToBackground(instance: ITerminalInstance): void; revealActiveTerminal(preserveFocus?: boolean): Promise; moveToEditor(source: ITerminalInstance, group?: GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE): void; moveIntoNewEditor(source: ITerminalInstance): void; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts b/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts index 8b40d469375..b158684ae0e 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalEditor.ts @@ -13,6 +13,7 @@ import { IContextKeyService } from '../../../../platform/contextkey/common/conte import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; @@ -60,6 +61,7 @@ export class TerminalEditor extends EditorPane { @ITerminalProfileResolverService private readonly _terminalProfileResolverService: ITerminalProfileResolverService, @ITerminalService private readonly _terminalService: ITerminalService, @ITerminalConfigurationService private readonly _terminalConfigurationService: ITerminalConfigurationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @IMenuService menuService: IMenuService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -156,7 +158,7 @@ export class TerminalEditor extends EditorPane { private _updateTabActionBar(profiles: ITerminalProfile[]): void { this._disposableStore.clear(); - const actions = getTerminalActionBarArgs(TerminalLocation.Editor, profiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore); + const actions = getTerminalActionBarArgs(TerminalLocation.Editor, profiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore, this._configurationService); this._newDropdown.value?.update(actions.dropdownAction, actions.dropdownMenuActions); } @@ -180,7 +182,7 @@ export class TerminalEditor extends EditorPane { if (action instanceof MenuItemAction) { const location = { viewColumn: ACTIVE_GROUP }; this._disposableStore.clear(); - const actions = getTerminalActionBarArgs(location, this._terminalProfileService.availableProfiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore); + const actions = getTerminalActionBarArgs(location, this._terminalProfileService.availableProfiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore, this._configurationService); this._newDropdown.value = this._instantiationService.createInstance(DropdownWithPrimaryActionViewItem, action, actions.dropdownAction, actions.dropdownMenuActions, actions.className, { hoverDelegate: options.hoverDelegate }); this._newDropdown.value?.update(actions.dropdownAction, actions.dropdownMenuActions); return this._newDropdown.value; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts b/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts index b0860ecc69c..281d624fcec 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalMenus.ts @@ -8,6 +8,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Schemas } from '../../../../base/common/network.js'; import { localize, localize2 } from '../../../../nls.js'; import { IMenu, MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IExtensionTerminalProfile, ITerminalProfile, TerminalLocation, TerminalSettingId } from '../../../../platform/terminal/common/terminal.js'; import { ResourceContextKey } from '../../../common/contextkeys.js'; @@ -781,12 +782,20 @@ export function setupTerminalMenus(): void { } } -export function getTerminalActionBarArgs(location: ITerminalLocationOptions, profiles: ITerminalProfile[], defaultProfileName: string, contributedProfiles: readonly IExtensionTerminalProfile[], terminalService: ITerminalService, dropdownMenu: IMenu, disposableStore: DisposableStore): { +export function getTerminalActionBarArgs(location: ITerminalLocationOptions, profiles: ITerminalProfile[], defaultProfileName: string, contributedProfiles: readonly IExtensionTerminalProfile[], terminalService: ITerminalService, dropdownMenu: IMenu, disposableStore: DisposableStore, configurationService: IConfigurationService): { dropdownAction: IAction; dropdownMenuActions: IAction[]; className: string; dropdownIcon?: string; } { + const shouldElevateAiProfiles = configurationService.getValue(TerminalSettingId.ExperimentalAiProfileGrouping); + profiles = profiles.filter(e => !e.isAutoDetected); + const [aiProfiles, otherProfiles] = shouldElevateAiProfiles + ? splitProfiles(profiles) + : [[], profiles]; + const [aiContributedProfiles, otherContributedProfiles] = shouldElevateAiProfiles + ? splitContributedProfiles(contributedProfiles) + : [[], contributedProfiles]; const dropdownActions: IAction[] = []; const submenuActions: IAction[] = []; const splitLocation = (location === TerminalLocation.Editor || (typeof location === 'object' && hasKey(location, { viewColumn: true }) && location.viewColumn === ACTIVE_GROUP)) ? { viewColumn: SIDE_GROUP } : { splitActiveTerminal: true }; @@ -806,40 +815,22 @@ export function getTerminalActionBarArgs(location: ITerminalLocationOptions, pro location: splitLocation })))); dropdownActions.push(new Separator()); - - profiles = profiles.filter(e => !e.isAutoDetected); - for (const p of profiles) { - const isDefault = p.profileName === defaultProfileName; - const options: ICreateTerminalOptions = { config: p, location }; - const splitOptions: ICreateTerminalOptions = { config: p, location: splitLocation }; - const sanitizedProfileName = p.profileName.replace(/[\n\r\t]/g, ''); - dropdownActions.push(disposableStore.add(new Action(TerminalCommandId.NewWithProfile, isDefault ? localize('defaultTerminalProfile', "{0} (Default)", sanitizedProfileName) : sanitizedProfileName, undefined, true, async () => { - await terminalService.createAndFocusTerminal(options); - }))); - submenuActions.push(disposableStore.add(new Action(TerminalCommandId.Split, isDefault ? localize('defaultTerminalProfile', "{0} (Default)", sanitizedProfileName) : sanitizedProfileName, undefined, true, async () => { - await terminalService.createAndFocusTerminal(splitOptions); - }))); + for (const p of aiProfiles) { + addProfileActions(p, defaultProfileName, location, splitLocation, terminalService, dropdownActions, submenuActions, disposableStore); + } + for (const contributed of aiContributedProfiles) { + addContributedProfileActions(contributed, defaultProfileName, location, splitLocation, terminalService, dropdownActions, submenuActions, disposableStore); + } + if ((aiProfiles.length > 0 || aiContributedProfiles.length > 0) && (otherProfiles.length > 0 || otherContributedProfiles.length > 0)) { + dropdownActions.push(new Separator()); } - for (const contributed of contributedProfiles) { - const isDefault = contributed.title === defaultProfileName; - const title = isDefault ? localize('defaultTerminalProfile', "{0} (Default)", contributed.title.replace(/[\n\r\t]/g, '')) : contributed.title.replace(/[\n\r\t]/g, ''); - dropdownActions.push(disposableStore.add(new Action('contributed', title, undefined, true, () => terminalService.createAndFocusTerminal({ - config: { - extensionIdentifier: contributed.extensionIdentifier, - id: contributed.id, - title - }, - location - })))); - submenuActions.push(disposableStore.add(new Action('contributed-split', title, undefined, true, () => terminalService.createAndFocusTerminal({ - config: { - extensionIdentifier: contributed.extensionIdentifier, - id: contributed.id, - title - }, - location: splitLocation - })))); + for (const p of otherProfiles) { + addProfileActions(p, defaultProfileName, location, splitLocation, terminalService, dropdownActions, submenuActions, disposableStore); + } + + for (const contributed of otherContributedProfiles) { + addContributedProfileActions(contributed, defaultProfileName, location, splitLocation, terminalService, dropdownActions, submenuActions, disposableStore); } if (dropdownActions.length > 0) { @@ -852,3 +843,95 @@ export function getTerminalActionBarArgs(location: ITerminalLocationOptions, pro const dropdownAction = disposableStore.add(new Action('refresh profiles', localize('launchProfile', 'Launch Profile...'), 'codicon-chevron-down', true)); return { dropdownAction, dropdownMenuActions: dropdownActions, className: `terminal-tab-actions-${terminalService.resolveLocation(location)}` }; } + +function splitProfiles(profiles: readonly ITerminalProfile[]): [ITerminalProfile[], ITerminalProfile[]] { + const aiProfiles: ITerminalProfile[] = []; + const otherProfiles: ITerminalProfile[] = []; + for (const profile of profiles) { + if (isAiProfileName(profile.profileName)) { + aiProfiles.push(profile); + } else { + otherProfiles.push(profile); + } + } + return [aiProfiles, otherProfiles]; +} + +function splitContributedProfiles(contributedProfiles: readonly IExtensionTerminalProfile[]): [IExtensionTerminalProfile[], IExtensionTerminalProfile[]] { + const aiContributedProfiles: IExtensionTerminalProfile[] = []; + const otherContributedProfiles: IExtensionTerminalProfile[] = []; + for (const profile of contributedProfiles) { + if (isAiContributedProfile(profile)) { + aiContributedProfiles.push(profile); + } else { + otherContributedProfiles.push(profile); + } + } + return [aiContributedProfiles, otherContributedProfiles]; +} + +function isAiContributedProfile(profile: IExtensionTerminalProfile): boolean { + const extensionIdentifier = profile.extensionIdentifier.toLowerCase(); + if (extensionIdentifier === 'github.copilot-chat' || extensionIdentifier === 'anthropic.claude-code') { + return true; + } + + return isAiProfileName(profile.title); +} + +function isAiProfileName(name: string): boolean { + const lowerCaseName = name.toLowerCase(); + return lowerCaseName.includes('copilot') || lowerCaseName.includes('claude'); +} + +function addProfileActions( + profile: ITerminalProfile, + defaultProfileName: string, + location: ITerminalLocationOptions, + splitLocation: ITerminalLocationOptions, + terminalService: ITerminalService, + dropdownActions: IAction[], + submenuActions: IAction[], + disposableStore: DisposableStore +): void { + const isDefault = profile.profileName === defaultProfileName; + const options: ICreateTerminalOptions = { config: profile, location }; + const splitOptions: ICreateTerminalOptions = { config: profile, location: splitLocation }; + const sanitizedProfileName = profile.profileName.replace(/[\n\r\t]/g, ''); + dropdownActions.push(disposableStore.add(new Action(TerminalCommandId.NewWithProfile, isDefault ? localize('defaultTerminalProfile', "{0} (Default)", sanitizedProfileName) : sanitizedProfileName, undefined, true, async () => { + await terminalService.createAndFocusTerminal(options); + }))); + submenuActions.push(disposableStore.add(new Action(TerminalCommandId.Split, isDefault ? localize('defaultTerminalProfile', "{0} (Default)", sanitizedProfileName) : sanitizedProfileName, undefined, true, async () => { + await terminalService.createAndFocusTerminal(splitOptions); + }))); +} + +function addContributedProfileActions( + contributed: IExtensionTerminalProfile, + defaultProfileName: string, + location: ITerminalLocationOptions, + splitLocation: ITerminalLocationOptions, + terminalService: ITerminalService, + dropdownActions: IAction[], + submenuActions: IAction[], + disposableStore: DisposableStore +): void { + const isDefault = contributed.title === defaultProfileName; + const title = isDefault ? localize('defaultTerminalProfile', "{0} (Default)", contributed.title.replace(/[\n\r\t]/g, '')) : contributed.title.replace(/[\n\r\t]/g, ''); + dropdownActions.push(disposableStore.add(new Action('contributed', title, undefined, true, () => terminalService.createAndFocusTerminal({ + config: { + extensionIdentifier: contributed.extensionIdentifier, + id: contributed.id, + title + }, + location + })))); + submenuActions.push(disposableStore.add(new Action('contributed-split', title, undefined, true, () => terminalService.createAndFocusTerminal({ + config: { + extensionIdentifier: contributed.extensionIdentifier, + id: contributed.id, + title + }, + location: splitLocation + })))); +} diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index 2cc3d43b763..1733e24d83e 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -261,7 +261,12 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce this.backend = backend; // Create variable resolver - const variableResolver = terminalEnvironment.createVariableResolver(this._cwdWorkspaceFolder, await this._terminalProfileResolverService.getEnvironment(this.remoteAuthority), this._configurationResolverService); + // Start with the full base environment so that all standard variables (e.g. PATH) are + // available, then overlay the shell environment on top so that launch configuration + // variables and shell-profile modifications take precedence. + const envForResolver = { ...await this._terminalProfileResolverService.getEnvironment(this.remoteAuthority) }; + terminalEnvironment.mergeEnvironments(envForResolver, await backend.getShellEnvironment()); + const variableResolver = terminalEnvironment.createVariableResolver(this._cwdWorkspaceFolder, envForResolver, this._configurationResolverService); // resolvedUserHome is needed here as remote resolvers can launch local terminals before // they're connected to the remote. diff --git a/src/vs/workbench/contrib/terminal/browser/terminalService.ts b/src/vs/workbench/contrib/terminal/browser/terminalService.ts index 6107058afad..421ba3cb758 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalService.ts @@ -1220,6 +1220,10 @@ export class TerminalService extends Disposable implements ITerminalService { } private _evaluateLocalCwd(shellLaunchConfig: IShellLaunchConfig) { + if (this._environmentService.isSessionsWindow) { + return; + } + // Add welcome message and title annotation for local terminals launched within remote or // virtual workspaces if (!isString(shellLaunchConfig.cwd) && shellLaunchConfig.cwd?.scheme === Schemas.file) { @@ -1233,6 +1237,45 @@ export class TerminalService extends Disposable implements ITerminalService { } } + moveToBackground(instance: ITerminalInstance): void { + // Already backgrounded + if (this._backgroundedTerminalInstances.some(bg => bg.instance === instance)) { + return; + } + + // Remove from its current location (panel group or editor) + if (instance.target === TerminalLocation.Editor) { + this._terminalEditorService.detachInstance(instance); + } else { + const group = this._terminalGroupService.getGroupForInstance(instance); + if (!group) { + return; + } + group.removeInstance(instance); + } + + instance.detachFromElement(); + + // Track in background + this._backgroundedTerminalInstances.push({ instance, terminalLocationOptions: instance.target === TerminalLocation.Editor ? { viewColumn: ACTIVE_GROUP } : undefined }); + this._backgroundedTerminalDisposables.set(instance.instanceId, [ + instance.onDisposed(instance => { + const idx = this._backgroundedTerminalInstances.findIndex(bg => bg.instance === instance); + if (idx !== -1) { + this._backgroundedTerminalInstances.splice(idx, 1); + } + const disposables = this._backgroundedTerminalDisposables.get(instance.instanceId); + if (disposables) { + dispose(disposables); + } + this._backgroundedTerminalDisposables.delete(instance.instanceId); + this._onDidDisposeInstance.fire(instance); + }) + ]); + + this._onDidChangeInstances.fire(); + } + public async showBackgroundTerminal(instance: ITerminalInstance, suppressSetActive?: boolean): Promise { const index = this._backgroundedTerminalInstances.findIndex(bg => bg.instance === instance); if (index === -1) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTelemetry.ts b/src/vs/workbench/contrib/terminal/browser/terminalTelemetry.ts index 9351985ccb8..45914710aaa 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTelemetry.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTelemetry.ts @@ -80,6 +80,8 @@ export class TerminalTelemetryContribution extends Disposable implements IWorkbe shellIntegrationInjected: boolean; shellIntegrationInjectionFailureReason: ShellIntegrationInjectionFailureReason | undefined; + imageAddonLoaded: boolean; + terminalSessionId: string; }; type TerminalCreationTelemetryClassification = { @@ -101,6 +103,8 @@ export class TerminalTelemetryContribution extends Disposable implements IWorkbe shellIntegrationInjected: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the shell integration script was injected.' }; shellIntegrationInjectionFailureReason: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Info about shell integration injection.' }; + imageAddonLoaded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the xterm.js image addon was loaded.' }; + terminalSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session ID of the terminal instance.' }; }; this._telemetryService.publicLog2('terminal/createInstance', { @@ -122,6 +126,7 @@ export class TerminalTelemetryContribution extends Disposable implements IWorkbe shellIntegrationQuality: commandDetection?.hasRichCommandDetection ? 2 : commandDetection ? 1 : 0, shellIntegrationInjected: instance.usedShellIntegrationInjection, shellIntegrationInjectionFailureReason: instance.shellIntegrationInjectionFailureReason, + imageAddonLoaded: instance.xterm?.isImageAddonLoaded ?? false, terminalSessionId: instance.sessionId, }); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalView.ts b/src/vs/workbench/contrib/terminal/browser/terminalView.ts index 9a745727415..01632bee153 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalView.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalView.ts @@ -289,7 +289,7 @@ export class TerminalViewPane extends ViewPane { case TerminalCommandId.New: { if (action instanceof MenuItemAction) { this._disposableStore.clear(); - const actions = getTerminalActionBarArgs(TerminalLocation.Panel, this._terminalProfileService.availableProfiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore); + const actions = getTerminalActionBarArgs(TerminalLocation.Panel, this._terminalProfileService.availableProfiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore, this._configurationService); this._newDropdown.value = this._instantiationService.createInstance(DropdownWithPrimaryActionViewItem, action, actions.dropdownAction, actions.dropdownMenuActions, actions.className, { hoverDelegate: options.hoverDelegate, getKeyBinding: (action: IAction) => this._keybindingService.lookupKeybinding(action.id, this._contextKeyService) @@ -318,8 +318,15 @@ export class TerminalViewPane extends ViewPane { private _updateTabActionBar(profiles: ITerminalProfile[]): void { this._disposableStore.clear(); - const actions = getTerminalActionBarArgs(TerminalLocation.Panel, profiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore); + const actions = getTerminalActionBarArgs(TerminalLocation.Panel, profiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore, this._configurationService); this._newDropdown.value?.update(actions.dropdownAction, actions.dropdownMenuActions); + + this._disposableStore.add(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(TerminalSettingId.ExperimentalAiProfileGrouping)) { + const updatedActions = getTerminalActionBarArgs(TerminalLocation.Panel, profiles, this._getDefaultProfileName(), this._terminalProfileService.contributedProfiles, this._terminalService, this._dropdownMenu, this._disposableStore, this._configurationService); + this._newDropdown.value?.update(updatedActions.dropdownAction, updatedActions.dropdownMenuActions); + } + })); } override focus() { diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index be33e2b306b..b043728acf3 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -47,6 +47,7 @@ import type { IProgressState } from '@xterm/addon-progress'; import type { CommandDetectionCapability } from '../../../../../platform/terminal/common/capabilities/commandDetectionCapability.js'; import { URI } from '../../../../../base/common/uri.js'; import { isNumber } from '../../../../../base/common/types.js'; +import { clamp } from '../../../../../base/common/numbers.js'; const enum RenderConstants { SmoothScrollDuration = 125 @@ -141,6 +142,7 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach get isStdinDisabled(): boolean { return !!this.raw.options.disableStdin; } get isGpuAccelerated(): boolean { return !!this._webglAddon; } + get isImageAddonLoaded(): boolean { return !!this._imageAddon; } private readonly _onDidRequestRunCommand = this._register(new Emitter<{ command: ITerminalCommand; noNewLine?: boolean }>()); readonly onDidRequestRunCommand = this._onDidRequestRunCommand.event; @@ -916,6 +918,18 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach const AddonCtor = await this._xtermAddonLoader.importAddon('image'); this._imageAddon = new AddonCtor(); this.raw.loadAddon(this._imageAddon); + type TerminalImageAddonActivatedClassification = { + owner: 'anthonykim1'; + comment: 'Tracks when the xterm.js image addon is loaded, including dynamic enablement'; + }; + this._telemetryService.publicLog2<{}, TerminalImageAddonActivatedClassification>('terminal/imageAddonActivated'); + this._register(this._imageAddon.onImageAdded(() => { + type TerminalImageAddedClassification = { + owner: 'anthonykim1'; + comment: 'Tracks when an image is added to the terminal via the image addon'; + }; + this._telemetryService.publicLog2<{}, TerminalImageAddedClassification>('terminal/imageAdded'); + })); } } else { try { @@ -951,16 +965,21 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach this.raw.loadAddon(this._serializeAddon); } + const lastLine = this.raw.buffer.active.length - 1; + if (lastLine < 0) { + return ''; + } + const hasValidEndMarker = isNumber(endMarker?.line); - const start = isNumber(startMarker?.line) && startMarker?.line > -1 ? startMarker.line : 0; + const start = clamp(isNumber(startMarker?.line) && startMarker.line > -1 ? startMarker.line : 0, 0, lastLine); let end = hasValidEndMarker ? endMarker.line : this.raw.buffer.active.length - 1; if (skipLastLine && hasValidEndMarker) { end = end - 1; } - end = Math.max(end, start); + end = clamp(Math.max(end, start), start, lastLine); return this._serializeAddon.serialize({ range: { - start: startMarker?.line ?? 0, + start, end } }); diff --git a/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts b/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts index 6b6e849e344..6cdd98743ab 100644 --- a/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts +++ b/src/vs/workbench/contrib/terminal/common/remote/remoteTerminalChannel.ts @@ -8,6 +8,7 @@ import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { IChannel } from '../../../../../base/parts/ipc/common/ipc.js'; import { IWorkbenchConfigurationService } from '../../../../services/configuration/common/configuration.js'; import { IRemoteAuthorityResolverService } from '../../../../../platform/remote/common/remoteAuthorityResolver.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { serializeEnvironmentDescriptionMap, serializeEnvironmentVariableCollection } from '../../../../../platform/terminal/common/environmentVariableShared.js'; import { IConfigurationResolverService } from '../../../../services/configurationResolver/common/configurationResolver.js'; @@ -111,6 +112,7 @@ export class RemoteTerminalChannelClient implements IPtyHostController { @ITerminalLogService private readonly _logService: ITerminalLogService, @IEditorService private readonly _editorService: IEditorService, @ILabelService private readonly _labelService: ILabelService, + @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, ) { } restartPtyHost(): Promise { @@ -152,7 +154,13 @@ export class RemoteTerminalChannelClient implements IPtyHostController { } const resolverResult = await this._remoteAuthorityResolverService.resolveAuthority(this._remoteAuthority); - const resolverEnv = resolverResult.options && resolverResult.options.extensionHostEnv; + const resolverEnv = { + /** + * If the extension host was spawned via a launch configuration, + * include the environment provided by that launch configuration. + */ + ...(this._environmentService.debugExtensionHost.env ?? {}), ...resolverResult.options?.extensionHostEnv + }; const workspace = this._workspaceContextService.getWorkspace(); const workspaceFolders = workspace.folders; diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 632938e0d5d..132db6d2982 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -604,6 +604,15 @@ const terminalConfiguration: IStringDictionary = { mode: 'auto' } }, + [TerminalSettingId.ExperimentalAiProfileGrouping]: { + markdownDescription: localize('terminal.integrated.experimental.aiProfileGrouping', "Whether to elevate AI-contributed terminal profiles (for example Copilot CLI and Claude Agent) in the new terminal dropdown."), + type: 'boolean', + default: false, + tags: ['experimental'], + experiment: { + mode: 'auto' + } + }, [TerminalSettingId.ShellIntegrationEnabled]: { restricted: true, markdownDescription: localize('terminal.integrated.shellIntegration.enabled', "Determines whether or not shell integration is auto-injected to support features like enhanced command tracking and current working directory detection. \n\nShell integration works by injecting the shell with a startup script. The script gives VS Code insight into what is happening within the terminal.\n\nSupported shells:\n\n- Linux/macOS: bash, fish, pwsh, zsh\n - Windows: pwsh, git bash\n\nThis setting applies only when terminals are created, so you will need to restart your terminals for it to take effect.\n\n Note that the script injection may not work if you have custom arguments defined in the terminal profile, have enabled {1}, have a [complex bash `PROMPT_COMMAND`](https://code.visualstudio.com/docs/editor/integrated-terminal#_complex-bash-promptcommand), or other unsupported setup. To disable decorations, see {0}", '`#terminal.integrated.shellIntegration.decorationsEnabled#`', '`#editor.accessibilitySupport#`'), diff --git a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts index 3d91b41d178..9a30e722fe2 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/chatTerminalCommandMirror.test.ts @@ -202,6 +202,17 @@ suite('Workbench - ChatTerminalCommandMirror', () => { strictEqual(mirrorText.includes('before'), false); }); + test('disposed start marker does not throw in VT serialization', async () => { + const source = await createXterm(); + await write(source, 'line 1\r\nline 2'); + + const startMarker = source.raw.registerMarker(0)!; + startMarker.dispose(); + + const vt = await source.getRangeAsVT(startMarker, undefined, true); + strictEqual(typeof vt, 'string'); + }); + test('incremental mirroring appends correctly', async () => { const source = await createXterm(); const marker = source.raw.registerMarker(0)!; diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts index 70ed6a4426e..f029f371a90 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalProcessManager.test.ts @@ -69,7 +69,8 @@ class TestTerminalInstanceService implements Partial { options: any, shouldPersist: boolean ) => new TestTerminalChildProcess(shouldPersist), - getLatency: () => Promise.resolve([]) + getLatency: () => Promise.resolve([]), + getShellEnvironment: () => Promise.resolve({}) } as unknown as ITerminalBackend; } } diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts index 2be6c5d9e96..be4f138c4ea 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts @@ -412,7 +412,7 @@ export class TerminalChatWidget extends Disposable { if (!model?.sessionResource) { return; } - this._chatService.cancelCurrentRequestForSession(model?.sessionResource, 'terminalChat'); + void this._chatService.cancelCurrentRequestForSession(model?.sessionResource, 'terminalChat'); } async viewInChat(): Promise { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index eef1a76329b..43ef48eca9d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -17,8 +17,8 @@ import { IChatWidgetService } from '../../../../../chat/browser/chat.js'; import { ChatElicitationRequestPart } from '../../../../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatModel } from '../../../../../chat/common/model/chatModel.js'; import { ElicitationState, IChatService } from '../../../../../chat/common/chatService/chatService.js'; -import { ChatAgentLocation } from '../../../../../chat/common/constants.js'; -import { ChatMessageRole, getTextResponseFromStream, ILanguageModelsService } from '../../../../../chat/common/languageModels.js'; +import { ChatAgentLocation, ChatPermissionLevel } from '../../../../../chat/common/constants.js'; +import { ChatMessageRole, getTextResponseFromStream, type ILanguageModelChatSelector, ILanguageModelsService } from '../../../../../chat/common/languageModels.js'; import { IToolInvocationContext } from '../../../../../chat/common/tools/languageModelToolsService.js'; import { ITaskService } from '../../../../../tasks/common/taskService.js'; import { ILinkLocation } from '../../taskHelpers.js'; @@ -108,6 +108,9 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { private readonly _onDidFinishCommand = this._register(new Emitter()); readonly onDidFinishCommand: Event = this._onDidFinishCommand.event; + /** The chat session resource for this tool invocation, used to check permission level. */ + private readonly _sessionResource: URI | undefined; + constructor( private readonly _execution: IExecution, private readonly _pollFn: ((execution: IExecution, token: CancellationToken, taskService: ITaskService) => Promise) | undefined, @@ -124,6 +127,8 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { ) { super(); + this._sessionResource = invocationContext?.sessionResource; + // Start async to ensure listeners are set up timeout(0).then(() => { this._startMonitoring(command, invocationContext, token); @@ -237,7 +242,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { // Check for generic "press any key" prompts from scripts. if ((!isTask || !isTaskInactive) && detectsGenericPressAnyKeyPattern(output)) { - this._logService.trace('OutputMonitor: Idle -> generic "press any key" detected, requesting free-form input'); + this._logService.trace('OutputMonitor: Idle -> generic "press any key" detected'); + const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts) || this._isAutopilotMode(); + if (autoReply) { + this._logService.trace('OutputMonitor: Auto-reply enabled -> not showing free-form prompt for "press any key", stopping'); + this._cleanupIdleInputListener(); + return { shouldContinuePollling: false, output }; + } + this._logService.trace('OutputMonitor: Requesting free-form input for "press any key"'); // Register a marker to track this prompt position so we don't re-detect it const currentMarker = this._execution.instance.registerMarker(); if (currentMarker) { @@ -286,17 +298,11 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._cleanupIdleInputListener(); return { shouldContinuePollling: true }; } - const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts); - if (autoReply && !this._isSensitivePrompt(confirmationPrompt.prompt)) { - const explicitInput = confirmationPrompt.suggestedInput ?? this._extractExplicitInputFromPrompt(confirmationPrompt.prompt); - const normalizedInput = this._normalizeAutoReplyInput(explicitInput); - if (normalizedInput !== undefined) { - this._logService.trace('OutputMonitor: Auto-replying to free-form prompt'); - await this._execution.instance.sendText(normalizedInput, true); - this._outputMonitorTelemetryCounters.inputToolAutoAcceptCount++; - this._outputMonitorTelemetryCounters.inputToolAutoChars += normalizedInput.length; - return { shouldContinuePollling: true }; - } + const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts) || this._isAutopilotMode(); + if (autoReply) { + this._logService.trace('OutputMonitor: Auto-reply enabled -> not propagating free-form prompt, stopping'); + this._cleanupIdleInputListener(); + return { shouldContinuePollling: false, output }; } // Clean up the input listener now - the prompt will set up its own this._cleanupIdleInputListener(); @@ -588,35 +594,25 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return /(password|passphrase|token|api\s*key|secret)/i.test(prompt); } - private _normalizeAutoReplyInput(input: string | undefined): string | undefined { - if (!input) { - return undefined; + /** + * Returns true if the current session is in Autopilot mode (not Bypass Approvals). + * In Autopilot, terminal prompts should be auto-replied to so the agent can + * work autonomously from start to finish. + */ + private _isAutopilotMode(): boolean { + if (!this._sessionResource) { + return false; } - const trimmed = input.trim(); - if (!trimmed) { - return undefined; + // Check the live widget picker level + const widget = this._chatWidgetService.getWidgetBySessionResource(this._sessionResource) + ?? this._chatWidgetService.lastFocusedWidget; + if (widget?.input.currentModeInfo.permissionLevel === ChatPermissionLevel.Autopilot) { + return true; } - const lowered = trimmed.toLowerCase(); - if (lowered === '\\r' || lowered === '\\n' || lowered === 'enter' || lowered === 'return') { - return ''; - } - return trimmed; - } - - private _extractExplicitInputFromPrompt(prompt: string): string | undefined { - const normalizedPrompt = prompt.trim(); - if (!normalizedPrompt) { - return undefined; - } - const directCommandMatch = normalizedPrompt.match(/\b(?:type|enter|input)\s+["'`]([^"'`]+)["'`]/i); - if (directCommandMatch?.[1]) { - return directCommandMatch[1]; - } - const bareCommandMatch = normalizedPrompt.match(/\b(?:type|enter|input)\s+([\w.-]+)\b/i); - if (bareCommandMatch?.[1]) { - return bareCommandMatch[1]; - } - return undefined; + // Fall back to the request-stamped level + const model = this._chatService.getSession(this._sessionResource); + const request = model?.getRequests().at(-1); + return request?.modeInfo?.permissionLevel === ChatPermissionLevel.Autopilot; } private async _selectAndHandleOption( @@ -626,10 +622,10 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (!confirmationPrompt?.options.length) { return undefined; } - const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts); + const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts) || this._isAutopilotMode(); let model = this._chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat)[0]?.input.currentLanguageModel; if (model) { - const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: model.replaceAll('copilot/', '') }); + const models = await this._safeSelectLanguageModels({ vendor: 'copilot', family: model.replaceAll('copilot/', '') }); model = models[0]; } if (!model) { @@ -931,9 +927,18 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } private async _getLanguageModel(): Promise { - const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); + const models = await this._safeSelectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); return models.length ? models[0] : undefined; } + + private async _safeSelectLanguageModels(selector: ILanguageModelChatSelector): Promise { + try { + return await this._languageModelsService.selectLanguageModels(selector); + } catch (error) { + this._logService.trace('OutputMonitor: selectLanguageModels failed', { selector, error }); + return []; + } + } } function getMoreActions(suggestedOption: SuggestedOption, confirmationPrompt: IConfirmationPrompt): IAction[] | undefined { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 93f3b0c40d0..2947937e8e5 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -10,7 +10,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Event } from '../../../../../../base/common/event.js'; import { MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { basename, posix, win32 } from '../../../../../../base/common/path.js'; import { OperatingSystem, OS } from '../../../../../../base/common/platform.js'; @@ -63,12 +63,13 @@ import { IWorkspaceContextService } from '../../../../../../platform/workspace/c import { IHistoryService } from '../../../../../services/history/common/history.js'; import { TerminalCommandArtifactCollector } from './terminalCommandArtifactCollector.js'; import { isNumber, isString } from '../../../../../../base/common/types.js'; -import { ChatConfiguration } from '../../../../chat/common/constants.js'; +import { ChatConfiguration, isAutoApproveLevel } from '../../../../chat/common/constants.js'; import { IChatWidgetService } from '../../../../chat/browser/chat.js'; import { TerminalChatCommandId } from '../../../chat/browser/terminalChat.js'; import { clamp } from '../../../../../../base/common/numbers.js'; import { IOutputAnalyzer } from './outputAnalyzer.js'; import { SandboxOutputAnalyzer } from './sandboxOutputAnalyzer.js'; +import { IAgentSessionsService } from '../../../../chat/browser/agentSessions/agentSessionsService.js'; // #region Tool data @@ -322,8 +323,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { private readonly _commandLineAnalyzers: ICommandLineAnalyzer[]; private readonly _commandLinePresenters: ICommandLinePresenter[]; private readonly _outputAnalyzers: IOutputAnalyzer[]; + private readonly _archivedSessionListener = this._register(new MutableDisposable()); protected readonly _sessionTerminalAssociations = new ResourceMap(); + protected readonly _sessionTerminalInstances = new ResourceMap>(); + private readonly _terminalsBeingDisposedBySessionCleanup = new Set(); // Immutable window state protected readonly _osBackend: Promise; @@ -373,6 +377,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { @ITerminalService private readonly _terminalService: ITerminalService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, ) { super(); @@ -417,11 +422,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Restore terminal associations from storage this._restoreTerminalAssociations(); this._register(this._terminalService.onDidDisposeInstance(e => { - for (const [sessionResource, toolTerminal] of this._sessionTerminalAssociations.entries()) { - if (e === toolTerminal.instance) { - this._sessionTerminalAssociations.delete(sessionResource); - } - } + this._removeTerminalAssociations(e); })); // Listen for chat session disposal to clean up associated terminals @@ -430,6 +431,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._cleanupSessionTerminals(resource); } })); + } async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { @@ -644,8 +646,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } } + // Check if the session's permission level (Autopilot/Bypass Approvals) auto-approves all tools. + // When active, skip terminal confirmation entirely since the user has opted into full auto-approval. + const isSessionAutoApproved = chatSessionResource && this._isSessionAutoApproveLevel(chatSessionResource); + // If forceConfirmationReason is set, always show confirmation regardless of auto-approval - const shouldShowConfirmation = !isFinalAutoApproved || context.forceConfirmationReason !== undefined; + const shouldShowConfirmation = (!isFinalAutoApproved && !isSessionAutoApproved) || context.forceConfirmationReason !== undefined; const confirmationMessages = shouldShowConfirmation ? { title: confirmationTitle, message: new MarkdownString(localize('runInTerminal.confirmationMessage', "Explanation: {0}\n\nGoal: {1}", args.explanation, args.goal)), @@ -659,6 +665,30 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } + /** + * Returns true if the chat session's permission level (Autopilot/Bypass Approvals) + * auto-approves all tool calls, unless enterprise policy restricts it. + * Checks both the request-stamped level and the live picker level. + */ + private _isSessionAutoApproveLevel(chatSessionResource: URI): boolean { + const inspected = this._configurationService.inspect(ChatConfiguration.GlobalAutoApprove); + if (inspected.policyValue === false) { + return false; + } + // Check the live widget picker level (handles mid-session switches). + // Fall back to lastFocusedWidget if the session-specific widget isn't found + // (e.g., widget was backgrounded or URI mismatch). + const widget = this._chatWidgetService.getWidgetBySessionResource(chatSessionResource) + ?? this._chatWidgetService.lastFocusedWidget; + if (widget && isAutoApproveLevel(widget.input.currentModeInfo.permissionLevel)) { + return true; + } + // Fall back to the request-stamped level + const model = this._chatService.getSession(chatSessionResource); + const request = model?.getRequests().at(-1); + return isAutoApproveLevel(request?.modeInfo?.permissionLevel); + } + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { const toolSpecificData = invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined; if (!toolSpecificData) { @@ -1080,7 +1110,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._terminalChatService.registerTerminalInstanceWithToolSession(terminalToolSessionId, toolTerminal.instance); this._terminalChatService.registerTerminalInstanceWithChatSession(chatSessionResource, toolTerminal.instance); this._registerInputListener(toolTerminal); - this._sessionTerminalAssociations.set(chatSessionResource, toolTerminal); + this._addSessionTerminalAssociation(chatSessionResource, toolTerminal); if (token.isCancellationRequested) { toolTerminal.instance.dispose(); throw new CancellationError(); @@ -1121,7 +1151,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { shellIntegrationQuality: association.shellIntegrationQuality, isBackground: association.isBackground }; - this._sessionTerminalAssociations.set(chatSessionResource, toolTerminal); + this._addSessionTerminalAssociation(chatSessionResource, toolTerminal); this._terminalChatService.registerTerminalInstanceWithChatSession(chatSessionResource, instance); // Listen for terminal disposal to clean up storage @@ -1192,23 +1222,83 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } private _cleanupSessionTerminals(chatSessionResource: URI): void { + const sessionTerminals = this._sessionTerminalInstances.get(chatSessionResource); const toolTerminal = this._sessionTerminalAssociations.get(chatSessionResource); - if (toolTerminal) { - this._logService.debug(`RunInTerminalTool: Cleaning up terminal for disposed chat session ${chatSessionResource}`); + const terminalsToDispose = sessionTerminals ?? (toolTerminal ? new Set([toolTerminal.instance]) : undefined); + if (!terminalsToDispose || terminalsToDispose.size === 0) { + return; + } - this._sessionTerminalAssociations.delete(chatSessionResource); - toolTerminal.instance.dispose(); + this._logService.debug(`RunInTerminalTool: Cleaning up ${terminalsToDispose.size} terminal(s) for ended chat session ${chatSessionResource}`); - // Clean up any active executions associated with this session - const terminalToRemove: string[] = []; - for (const [termId, execution] of RunInTerminalTool._activeExecutions.entries()) { - if (execution.instance === toolTerminal.instance) { - execution.dispose(); - terminalToRemove.push(termId); - } + this._sessionTerminalAssociations.delete(chatSessionResource); + this._sessionTerminalInstances.delete(chatSessionResource); + + for (const terminal of terminalsToDispose) { + // Skip redundant map walks in onDidDispose since this session has already been removed. + this._terminalsBeingDisposedBySessionCleanup.add(terminal); + terminal.dispose(); + } + + // Clean up any active executions associated with this session + const terminalToRemove: string[] = []; + for (const [termId, execution] of RunInTerminalTool._activeExecutions.entries()) { + if (terminalsToDispose.has(execution.instance)) { + execution.dispose(); + terminalToRemove.push(termId); } - for (const termId of terminalToRemove) { - RunInTerminalTool._activeExecutions.delete(termId); + } + for (const termId of terminalToRemove) { + RunInTerminalTool._activeExecutions.delete(termId); + } + } + + private _addSessionTerminalAssociation(chatSessionResource: URI, toolTerminal: IToolTerminal): void { + this._ensureArchivedSessionListener(); + + let sessionTerminals = this._sessionTerminalInstances.get(chatSessionResource); + if (!sessionTerminals) { + sessionTerminals = new Set(); + this._sessionTerminalInstances.set(chatSessionResource, sessionTerminals); + } + sessionTerminals.add(toolTerminal.instance); + + if (!toolTerminal.isBackground) { + this._sessionTerminalAssociations.set(chatSessionResource, toolTerminal); + } + } + + private _ensureArchivedSessionListener(): void { + if (this._archivedSessionListener.value) { + return; + } + + // Archiving a session does not fire onDidDisposeSession, but we still need to dispose + // any terminals associated with the archived session to avoid process accumulation. + this._archivedSessionListener.value = this._agentSessionsService.onDidChangeSessionArchivedState(session => { + if (session.isArchived()) { + this._cleanupSessionTerminals(session.resource); + } + }); + } + + private _removeTerminalAssociations(terminal: ITerminalInstance): void { + if (this._terminalsBeingDisposedBySessionCleanup.delete(terminal)) { + return; + } + + for (const [sessionResource, toolTerminal] of this._sessionTerminalAssociations.entries()) { + if (terminal === toolTerminal.instance) { + this._sessionTerminalAssociations.delete(sessionResource); + } + } + + for (const [sessionResource, sessionTerminals] of this._sessionTerminalInstances.entries()) { + if (!sessionTerminals.delete(terminal)) { + continue; + } + if (sessionTerminals.size === 0) { + this._sessionTerminalInstances.delete(sessionResource); } } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index aafc4af0856..f50dfea219f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -519,6 +519,9 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary(TerminalChatAgentToolsSettingId.TerminalSandboxMacFileSystem) ?? {} : {}; const configFileUri = URI.joinPath(this._tempDir, `vscode-sandbox-settings-${this._sandboxSettingsId}.json`); + const defaultAllowWrite = [...this._defaultWritePaths]; + const linuxAllowWrite = [...new Set([...(linuxFileSystemSetting.allowWrite ?? []), ...defaultAllowWrite])]; + const macAllowWrite = [...new Set([...(macFileSystemSetting.allowWrite ?? []), ...defaultAllowWrite])]; let allowedDomains = networkSetting.allowedDomains ?? []; if (networkSetting.allowTrustedDomains) { @@ -176,7 +180,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb }, filesystem: { denyRead: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.denyRead : linuxFileSystemSetting.denyRead, - allowWrite: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.allowWrite : linuxFileSystemSetting.allowWrite, + allowWrite: this._os === OperatingSystem.Macintosh ? macAllowWrite : linuxAllowWrite, denyWrite: this._os === OperatingSystem.Macintosh ? macFileSystemSetting.denyWrite : linuxFileSystemSetting.denyWrite, } }; @@ -203,6 +207,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const environmentService = this._environmentService as IEnvironmentService & { tmpDir?: URI }; this._tempDir = environmentService.tmpDir; } + if (this._tempDir) { + this._defaultWritePaths.push(this._tempDir.path); + } if (!this._tempDir) { this._logService.warn('TerminalSandboxService: Cannot create sandbox settings file because no tmpDir is available in this environment'); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index 5e37377e3df..f4f34ecd4e9 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -31,17 +31,22 @@ suite('OutputMonitor', () => { let cts: CancellationTokenSource; let instantiationService: TestInstantiationService; let sendTextCalled: boolean; + let sentText: string | undefined; let dataEmitter: Emitter; setup(() => { sendTextCalled = false; + sentText = undefined; dataEmitter = new Emitter(); execution = { getOutput: () => 'test output', isActive: async () => false, instance: { instanceId: 1, - sendText: async () => { sendTextCalled = true; }, + sendText: async (text?: string) => { + sendTextCalled = true; + sentText = text; + }, onDidInputData: dataEmitter.event, onDisposed: Event.None, onData: dataEmitter.event, @@ -285,6 +290,60 @@ suite('OutputMonitor', () => { assert.strictEqual(optionResult?.suggestedOption, 'n', 'suggested option should be derived from fallback model response'); }); + test('auto reply stops on generic press any key prompts', async () => { + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [TerminalChatAgentToolsSettingId.AutoReplyToPrompts]: true + })); + + execution.getOutput = () => 'Press any key to continue...'; + const monitorCts = new CancellationTokenSource(); + monitorCts.cancel(); + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), monitorCts.token, 'test command')); + + const outputMonitorWithPrivateMethod = monitor as unknown as { + [key: string]: ((token: CancellationToken) => Promise<{ shouldContinuePollling: boolean }>) | undefined; + }; + const idleResult = await outputMonitorWithPrivateMethod['_handleIdleState']!(CancellationToken.None); + await Event.toPromise(monitor.onDidFinishCommand); + monitorCts.dispose(); + + assert.strictEqual(sendTextCalled, false, 'sendText should not be called when auto reply is enabled for free-form prompts'); + assert.strictEqual(sentText, undefined, 'no terminal input should be sent'); + assert.strictEqual(idleResult.shouldContinuePollling, false, 'monitor should stop polling for free-form prompts in auto reply mode'); + }); + + test('auto reply does not propagate free-form input requests without explicit input', async () => { + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [TerminalChatAgentToolsSettingId.AutoReplyToPrompts]: true + })); + + const monitorCts = new CancellationTokenSource(); + monitorCts.cancel(); + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), monitorCts.token, 'test command')); + + const outputMonitorWithPrivateMethod = monitor as unknown as { + [key: string]: unknown; + }; + let freeFormRequestShown = false; + outputMonitorWithPrivateMethod['_determineUserInputOptions'] = async () => ({ + prompt: 'Password:', + options: [], + detectedRequestForFreeFormInput: true + }); + outputMonitorWithPrivateMethod['_requestFreeFormTerminalInput'] = async () => { + freeFormRequestShown = true; + return true; + }; + + const idleResult = await (outputMonitorWithPrivateMethod['_handleIdleState'] as (token: CancellationToken) => Promise<{ shouldContinuePollling: boolean }>)(CancellationToken.None); + await Event.toPromise(monitor.onDidFinishCommand); + monitorCts.dispose(); + + assert.strictEqual(freeFormRequestShown, false, 'free-form elicitation should not be shown when auto reply is enabled'); + assert.strictEqual(sendTextCalled, false, 'sensitive free-form prompt should not be auto-replied'); + assert.strictEqual(idleResult.shouldContinuePollling, false, 'monitor should stop instead of propagating free-form prompt'); + }); + suite('detectsInputRequiredPattern', () => { test('detects yes/no confirmation prompts (pairs and variants)', () => { assert.strictEqual(detectsInputRequiredPattern('Continue? (y/N) '), true); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index e362782b44b..43c58ddd915 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -41,11 +41,14 @@ import { ShellIntegrationQuality } from '../../browser/toolTerminalCreator.js'; import { terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; import { TerminalChatService } from '../../../chat/browser/terminalChatService.js'; import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { IAgentSessionsService } from '../../../../chat/browser/agentSessions/agentSessionsService.js'; +import { IAgentSession } from '../../../../chat/browser/agentSessions/agentSessionsModel.js'; class TestRunInTerminalTool extends RunInTerminalTool { protected override _osBackend: Promise = Promise.resolve(OperatingSystem.Windows); get sessionTerminalAssociations() { return this._sessionTerminalAssociations; } + get sessionTerminalInstances() { return this._sessionTerminalInstances; } get profileFetcher() { return this._profileFetcher; } setBackendOs(os: OperatingSystem) { @@ -63,6 +66,7 @@ suite('RunInTerminalTool', () => { let workspaceContextService: TestContextService; let terminalServiceDisposeEmitter: Emitter; let chatServiceDisposeEmitter: Emitter<{ sessionResource: URI[]; reason: 'cleared' }>; + let chatSessionArchivedEmitter: Emitter; let runInTerminalTool: TestRunInTerminalTool; @@ -79,6 +83,7 @@ suite('RunInTerminalTool', () => { setConfig(TerminalChatAgentToolsSettingId.BlockDetectedFileWrites, 'outsideWorkspace'); terminalServiceDisposeEmitter = new Emitter(); chatServiceDisposeEmitter = new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>(); + chatSessionArchivedEmitter = new Emitter(); instantiationService = workbenchInstantiationService({ configurationService: () => configurationService, @@ -86,7 +91,14 @@ suite('RunInTerminalTool', () => { }, store); instantiationService.stub(IChatService, { - onDidDisposeSession: chatServiceDisposeEmitter.event + onDidDisposeSession: chatServiceDisposeEmitter.event, + getSession: () => undefined, + }); + instantiationService.stub(IAgentSessionsService, { + onDidChangeSessionArchivedState: chatSessionArchivedEmitter.event, + model: { + onDidChangeSessionArchivedState: chatSessionArchivedEmitter.event, + } as IAgentSessionsService['model'] }); instantiationService.stub(ITerminalChatService, store.add(instantiationService.createInstance(TerminalChatService))); instantiationService.stub(IWorkspaceContextService, workspaceContextService); @@ -504,8 +516,7 @@ suite('RunInTerminalTool', () => { // Verify that auto-approve information is included ok(result?.toolSpecificData, 'Expected toolSpecificData to be defined'); - // eslint-disable-next-line local/code-no-any-casts - const terminalData = result!.toolSpecificData as any; + const terminalData = result!.toolSpecificData as IChatTerminalToolInvocationData; ok(terminalData.autoApproveInfo, 'Expected autoApproveInfo to be defined for auto-approved background command'); ok(terminalData.autoApproveInfo.value, 'Expected autoApproveInfo to have a value'); ok(terminalData.autoApproveInfo.value.includes('npm'), 'Expected autoApproveInfo to mention the approved rule'); @@ -1139,13 +1150,149 @@ suite('RunInTerminalTool', () => { }); suite('chat session disposal cleanup', () => { + const createMockTerminal = (processId: number): ITerminalInstance => ({ + dispose: () => { /* Mock dispose */ }, + processId + } as unknown as ITerminalInstance); + + test('should restore all terminals into the session terminal map and dispose them when archived', () => { + const sessionId = 'test-session-restored-archive'; + const sessionResource = LocalChatSessionUri.forSession(sessionId); + + let terminal1Disposed = false; + let terminal2Disposed = false; + const terminal1DisposedEmitter = new Emitter(); + const terminal2DisposedEmitter = new Emitter(); + const mockTerminal1 = { + dispose: () => { + terminal1Disposed = true; + terminal1DisposedEmitter.fire(); + }, + onDisposed: terminal1DisposedEmitter.event, + processId: 55555, + } as unknown as ITerminalInstance; + const mockTerminal2 = { + dispose: () => { + terminal2Disposed = true; + terminal2DisposedEmitter.fire(); + }, + onDisposed: terminal2DisposedEmitter.event, + processId: 66666, + } as unknown as ITerminalInstance; + + storageService.store('chat.terminalSessions', JSON.stringify({ + [mockTerminal1.processId!]: { + sessionId, + id: 'restored-1', + shellIntegrationQuality: ShellIntegrationQuality.None, + isBackground: true, + }, + [mockTerminal2.processId!]: { + sessionId, + id: 'restored-2', + shellIntegrationQuality: ShellIntegrationQuality.None, + isBackground: false, + } + }), StorageScope.WORKSPACE, StorageTarget.USER); + + instantiationService.stub(ITerminalService, { + onDidDisposeInstance: terminalServiceDisposeEmitter.event, + instances: [mockTerminal1, mockTerminal2], + setNextCommandId: async () => { } + }); + + const restoredRunInTerminalTool = store.add(instantiationService.createInstance(TestRunInTerminalTool)); + const restoredSessionTerminals = restoredRunInTerminalTool.sessionTerminalInstances.get(sessionResource); + strictEqual(restoredSessionTerminals?.size, 2, 'Both restored terminals should be tracked for the session'); + + chatSessionArchivedEmitter.fire({ + resource: sessionResource, + isArchived: () => true, + } as unknown as IAgentSession); + + strictEqual(terminal1Disposed, true, 'Restored background terminal should have been disposed'); + strictEqual(terminal2Disposed, true, 'Restored foreground terminal should have been disposed'); + ok(!restoredRunInTerminalTool.sessionTerminalAssociations.has(sessionResource), 'Foreground terminal association should be removed after archive'); + ok(!restoredRunInTerminalTool.sessionTerminalInstances.has(sessionResource), 'All restored terminals for the session should be removed after archive'); + }); + + test('should dispose all terminals associated with a single chat session when archived', () => { + const sessionId = 'test-session-archive'; + const sessionResource = LocalChatSessionUri.forSession(sessionId); + const mockTerminal1 = { dispose: () => { /* Mock dispose */ }, processId: 33333 } as unknown as ITerminalInstance; + const mockTerminal2 = { dispose: () => { /* Mock dispose */ }, processId: 44444 } as unknown as ITerminalInstance; + + let terminal1Disposed = false; + let terminal2Disposed = false; + mockTerminal1.dispose = () => { terminal1Disposed = true; }; + mockTerminal2.dispose = () => { terminal2Disposed = true; }; + + runInTerminalTool.sessionTerminalAssociations.set(sessionResource, { + instance: mockTerminal2, + shellIntegrationQuality: ShellIntegrationQuality.None + }); + runInTerminalTool.sessionTerminalInstances.set(sessionResource, new Set([mockTerminal1, mockTerminal2])); + + // Initialize lazy archive listener before firing the archive event. + const ensureArchivedSessionListener = (runInTerminalTool as unknown as Record void>)['_ensureArchivedSessionListener']; + ensureArchivedSessionListener.call(runInTerminalTool); + + chatSessionArchivedEmitter.fire({ + resource: sessionResource, + isArchived: () => true, + } as unknown as IAgentSession); + + strictEqual(terminal1Disposed, true, 'Terminal 1 should have been disposed'); + strictEqual(terminal2Disposed, true, 'Terminal 2 should have been disposed'); + ok(!runInTerminalTool.sessionTerminalAssociations.has(sessionResource), 'Terminal association should be removed after archive'); + ok(!runInTerminalTool.sessionTerminalInstances.has(sessionResource), 'All tracked terminals for the session should be removed after archive'); + }); + + test('should not access agent sessions model when initializing archive listener', () => { + let modelAccessed = false; + instantiationService.stub(IAgentSessionsService, { + onDidChangeSessionArchivedState: chatSessionArchivedEmitter.event, + get model() { + modelAccessed = true; + throw new Error('model should not be accessed when wiring archive listener'); + }, + } as unknown as IAgentSessionsService); + + const noModelAccessRunInTerminalTool = store.add(instantiationService.createInstance(TestRunInTerminalTool)); + const ensureArchivedSessionListener = (noModelAccessRunInTerminalTool as unknown as Record void>)['_ensureArchivedSessionListener']; + ensureArchivedSessionListener.call(noModelAccessRunInTerminalTool); + + strictEqual(modelAccessed, false, 'Agent sessions model should not be accessed when initializing archive listener'); + }); + + test('should dispose all terminals associated with a single chat session', () => { + const sessionId = 'test-session-multiple-terminals'; + const mockTerminal1 = createMockTerminal(11111); + const mockTerminal2 = createMockTerminal(22222); + + let terminal1Disposed = false; + let terminal2Disposed = false; + mockTerminal1.dispose = () => { terminal1Disposed = true; }; + mockTerminal2.dispose = () => { terminal2Disposed = true; }; + + const sessionResource = LocalChatSessionUri.forSession(sessionId); + runInTerminalTool.sessionTerminalAssociations.set(sessionResource, { + instance: mockTerminal2, + shellIntegrationQuality: ShellIntegrationQuality.None + }); + runInTerminalTool.sessionTerminalInstances.set(sessionResource, new Set([mockTerminal1, mockTerminal2])); + + chatServiceDisposeEmitter.fire({ sessionResource: [sessionResource], reason: 'cleared' }); + + strictEqual(terminal1Disposed, true, 'Terminal 1 should have been disposed'); + strictEqual(terminal2Disposed, true, 'Terminal 2 should have been disposed'); + ok(!runInTerminalTool.sessionTerminalAssociations.has(sessionResource), 'Terminal association should be removed after disposal'); + ok(!runInTerminalTool.sessionTerminalInstances.has(sessionResource), 'All tracked terminals for the session should be removed after disposal'); + }); + test('should dispose associated terminals when chat session is disposed', () => { const sessionId = 'test-session-123'; - // eslint-disable-next-line local/code-no-any-casts - const mockTerminal: ITerminalInstance = { - dispose: () => { /* Mock dispose */ }, - processId: 12345 - } as any; + const mockTerminal = createMockTerminal(12345); let terminalDisposed = false; mockTerminal.dispose = () => { terminalDisposed = true; }; @@ -1166,16 +1313,8 @@ suite('RunInTerminalTool', () => { test('should not affect other sessions when one session is disposed', () => { const sessionId1 = 'test-session-1'; const sessionId2 = 'test-session-2'; - // eslint-disable-next-line local/code-no-any-casts - const mockTerminal1: ITerminalInstance = { - dispose: () => { /* Mock dispose */ }, - processId: 12345 - } as any; - // eslint-disable-next-line local/code-no-any-casts - const mockTerminal2: ITerminalInstance = { - dispose: () => { /* Mock dispose */ }, - processId: 67890 - } as any; + const mockTerminal1 = createMockTerminal(12345); + const mockTerminal2 = createMockTerminal(67890); let terminal1Disposed = false; let terminal2Disposed = false; diff --git a/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css b/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css index 4210055bfeb..a4a092d8349 100644 --- a/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css +++ b/src/vs/workbench/contrib/update/browser/media/releasenoteseditor.css @@ -2,8 +2,3 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - -.file-icons-enabled .show-file-icons .webview-vs_code_release_notes-name-file-icon.file-icon::before { - content: ' '; - background-image: url('../../../../browser/media/code-icon.svg'); -} diff --git a/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css b/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css index ee233981af0..c6bf5c79915 100644 --- a/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css +++ b/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css @@ -7,136 +7,3 @@ color: var(--vscode-button-background); font-size: 16px; } - -.update-status-tooltip { - display: flex; - flex-direction: column; - padding: 4px 0; - min-width: 310px; - max-width: 410px; -} - -/* Header with title and gear icon */ -.update-status-tooltip .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; -} - -.update-status-tooltip .header .title { - font-weight: 600; - font-size: var(--vscode-bodyFontSize); - color: var(--vscode-foreground); - margin-bottom: 0; -} - -.update-status-tooltip .header .monaco-action-bar { - margin-left: auto; -} - -/* Product info section with logo */ -.update-status-tooltip .product-info { - display: flex; - gap: 12px; - margin-bottom: 16px; -} - -.update-status-tooltip .product-logo { - width: 48px; - height: 48px; - border-radius: var(--vscode-cornerRadius-large); - padding: 5px; - flex-shrink: 0; - background-image: url('../../../../browser/media/code-icon.svg'); - background-size: contain; - background-position: center; - background-repeat: no-repeat; -} - -.update-status-tooltip .product-details { - display: flex; - flex-direction: column; - justify-content: center; -} - -.update-status-tooltip .product-name { - font-weight: 600; - color: var(--vscode-foreground); - margin-bottom: 4px; -} - -.update-status-tooltip .product-version, -.update-status-tooltip .product-release-date { - color: var(--vscode-descriptionForeground); - font-size: var(--vscode-bodyFontSize-small); -} - -.update-status-tooltip .release-notes-link { - color: var(--vscode-textLink-foreground); - text-decoration: none; - font-size: var(--vscode-bodyFontSize-small); - cursor: pointer; -} - -.update-status-tooltip .release-notes-link:hover { - color: var(--vscode-textLink-activeForeground); - text-decoration: underline; -} - -/* What's Included section */ -.update-status-tooltip .whats-included .section-title { - font-weight: 600; - color: var(--vscode-foreground); - margin-bottom: 8px; -} - -.update-status-tooltip .whats-included ul { - margin: 0; - padding-left: 16px; - color: var(--vscode-descriptionForeground); - font-size: var(--vscode-bodyFontSize-small); -} - -.update-status-tooltip .whats-included li { - margin-bottom: 2px; -} - -/* Progress bar */ -.update-status-tooltip .progress-container { - margin-bottom: 8px; -} - -.update-status-tooltip .progress-bar { - width: 100%; - height: 4px; - background-color: color-mix(in srgb, var(--vscode-progressBar-background) 30%, transparent); - border-radius: var(--vscode-cornerRadius-small); - overflow: hidden; -} - -.update-status-tooltip .progress-bar .progress-fill { - height: 100%; - background-color: var(--vscode-progressBar-background); - border-radius: var(--vscode-cornerRadius-small); - transition: width 0.3s ease; -} - -.update-status-tooltip .progress-text { - display: flex; - justify-content: space-between; - margin-top: 4px; - font-size: var(--vscode-bodyFontSize-small); - color: var(--vscode-descriptionForeground); -} - -.update-status-tooltip .progress-details { - color: var(--vscode-descriptionForeground); - margin-bottom: 4px; -} - -.update-status-tooltip .speed-info, -.update-status-tooltip .time-remaining { - color: var(--vscode-descriptionForeground); - font-size: var(--vscode-bodyFontSize-small); -} diff --git a/src/vs/workbench/contrib/update/browser/media/updateTitleBarEntry.css b/src/vs/workbench/contrib/update/browser/media/updateTitleBarEntry.css new file mode 100644 index 00000000000..eb3ac37b111 --- /dev/null +++ b/src/vs/workbench/contrib/update/browser/media/updateTitleBarEntry.css @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.monaco-action-bar .update-indicator { + display: flex; + align-items: center; + border-radius: var(--vscode-cornerRadius-medium); + white-space: nowrap; + padding: 0px 12px; + height: 24px; + background-color: transparent; + border: 1px solid transparent; +} + +.monaco-action-bar .update-indicator:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.monaco-action-bar .update-indicator .indicator-label { + font-size: var(--vscode-bodyFontSize-small); + position: relative; +} + +/* Prominent state (action required) — primary button style */ +.monaco-action-bar .update-indicator.prominent { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border-color: var(--vscode-button-background); +} + +.monaco-action-bar .update-indicator.prominent:hover { + background-color: var(--vscode-button-hoverBackground); + border-color: var(--vscode-button-hoverBackground); +} + +/* Disabled state */ +.monaco-action-bar .update-indicator.update-disabled .indicator-label { + color: var(--vscode-disabledForeground); +} + +/* Progress underline bar (shared base) */ +.monaco-action-bar .update-indicator.progress-indefinite .indicator-label::after, +.monaco-action-bar .update-indicator.progress-percent .indicator-label::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + height: 2px; + border-radius: 1px; +} + +/* Progress: indefinite — animated shimmer underline */ +.monaco-action-bar .update-indicator.progress-indefinite .indicator-label::after { + width: 100%; + background: linear-gradient( + 90deg, + transparent 0%, + var(--vscode-progressBar-background) 80%, + transparent 100% + ); + background-size: 200% 100%; + animation: update-indicator-shimmer 1.5s ease-in-out infinite; +} + +@keyframes update-indicator-shimmer { + 0% { background-position: 100% 0; } + 100% { background-position: -100% 0; } +} + +/* Progress: percentage — left-to-right fill underline */ +.monaco-action-bar .update-indicator.progress-percent .indicator-label::after { + width: 100%; + background: linear-gradient( + 90deg, + var(--vscode-progressBar-background) var(--update-progress, 0%), + color-mix(in srgb, var(--vscode-progressBar-background) 25%, transparent) var(--update-progress, 0%) + ); + transition: background 0.3s ease; +} + +/* Reduced motion */ +.monaco-workbench.monaco-reduce-motion .update-indicator.progress-indefinite .indicator-label::after { + animation: none; +} + +.monaco-workbench.monaco-reduce-motion .update-indicator.progress-percent .indicator-label::after { + transition: none; +} diff --git a/src/vs/workbench/contrib/update/browser/media/updateTooltip.css b/src/vs/workbench/contrib/update/browser/media/updateTooltip.css new file mode 100644 index 00000000000..ab714ea2e0f --- /dev/null +++ b/src/vs/workbench/contrib/update/browser/media/updateTooltip.css @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.update-tooltip { + display: flex; + flex-direction: column; + gap: 12px; + padding: 6px 6px; + min-width: 310px; + max-width: 410px; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-bodyFontSize-small); +} + +/* Header */ +.update-tooltip .header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.update-tooltip .header .title { + font-weight: 600; + font-size: var(--vscode-bodyFontSize); + color: var(--vscode-foreground); +} + +/* Product info */ +.update-tooltip .product-info { + display: flex; + gap: 12px; +} + +.update-tooltip .product-logo { + width: 48px; + height: 48px; + border-radius: var(--vscode-cornerRadius-large); + padding: 5px; + flex-shrink: 0; + background: url('../../../../browser/media/code-icon.svg') center / contain no-repeat; +} + +.update-tooltip .product-details { + display: flex; + flex-direction: column; + justify-content: center; +} + +.update-tooltip .product-name { + font-weight: 600; + color: var(--vscode-foreground); + margin-bottom: 4px; +} + +.update-tooltip .release-notes-link { + color: var(--vscode-textLink-foreground); + text-decoration: none; +} + +.update-tooltip .release-notes-link:hover { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +/* Progress bar */ +.update-tooltip .progress-bar { + height: 4px; + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 30%, transparent); + border-radius: var(--vscode-cornerRadius-small); + overflow: hidden; +} + +.update-tooltip .progress-fill { + height: 100%; + background-color: var(--vscode-progressBar-background); + border-radius: var(--vscode-cornerRadius-small); + transition: width 0.3s ease; +} + +.monaco-workbench.monaco-reduce-motion .update-tooltip .progress-fill { + transition: none; +} + +.update-tooltip .progress-text, +.update-tooltip .download-stats { + display: flex; + justify-content: space-between; +} + +.update-tooltip .progress-text { + margin-top: 4px; +} + +.update-tooltip .state-message { + display: flex; + align-items: flex-start; + font-size: var(--vscode-bodyFontSize); + gap: 4px; +} + +.update-tooltip .state-message-icon.codicon[class*='codicon-'] { + font-size: 16px; + flex-shrink: 0; + margin-top: 2px; +} + +.update-tooltip .state-message-icon.codicon.codicon-warning { + color: var(--vscode-editorWarning-foreground); +} + +.update-tooltip .state-message-icon.codicon.codicon-error { + color: var(--vscode-editorError-foreground); +} diff --git a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts index 550329a85ef..92c942f9d5e 100644 --- a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts +++ b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import './media/releasenoteseditor.css'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; import { KeybindingParser } from '../../../../base/common/keybindingParser.js'; @@ -124,7 +124,7 @@ export class ReleaseNotesManager extends Disposable { }, 'releaseNotes', title, - undefined, + Codicon.vscode, { group: ACTIVE_GROUP, preserveFocus: false }); const disposables = new DisposableStore(); diff --git a/src/vs/workbench/contrib/update/browser/update.contribution.ts b/src/vs/workbench/contrib/update/browser/update.contribution.ts index 35a6855e8f9..4f19831828b 100644 --- a/src/vs/workbench/contrib/update/browser/update.contribution.ts +++ b/src/vs/workbench/contrib/update/browser/update.contribution.ts @@ -10,7 +10,8 @@ import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } fr import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { MenuId, registerAction2, Action2 } from '../../../../platform/actions/common/actions.js'; import { ProductContribution, UpdateContribution, CONTEXT_UPDATE_STATE, SwitchProductQualityContribution, showReleaseNotesInEditor, DefaultAccountUpdateContribution } from './update.js'; -import { UpdateStatusBarEntryContribution } from './updateStatusBarEntry.js'; +import { UpdateStatusBarContribution } from './updateStatusBarEntry.js'; +import { UpdateTitleBarContribution } from './updateTitleBarEntry.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import product from '../../../../platform/product/common/product.js'; import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; @@ -30,7 +31,8 @@ workbench.registerWorkbenchContribution(ProductContribution, LifecyclePhase.Rest workbench.registerWorkbenchContribution(UpdateContribution, LifecyclePhase.Restored); workbench.registerWorkbenchContribution(SwitchProductQualityContribution, LifecyclePhase.Restored); workbench.registerWorkbenchContribution(DefaultAccountUpdateContribution, LifecyclePhase.Eventually); -workbench.registerWorkbenchContribution(UpdateStatusBarEntryContribution, LifecyclePhase.Restored); +workbench.registerWorkbenchContribution(UpdateStatusBarContribution, LifecyclePhase.Restored); +workbench.registerWorkbenchContribution(UpdateTitleBarContribution, LifecyclePhase.Restored); // Release notes diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts index 68b982cf452..aca3bb3ce27 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts @@ -32,6 +32,7 @@ import { Event } from '../../../../base/common/event.js'; import { toAction } from '../../../../base/common/actions.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { getInternalOrg } from '../../../../platform/assignment/common/assignment.js'; +import { IVersion, preprocessError, tryParseVersion } from '../common/updateUtils.js'; export const CONTEXT_UPDATE_STATE = new RawContextKey('updateState', StateType.Uninitialized); export const MAJOR_MINOR_UPDATE_AVAILABLE = new RawContextKey('majorMinorUpdateAvailable', false); @@ -146,26 +147,6 @@ export function appendUpdateMenuItems(menuId: MenuId, group: string): void { }); } -interface IVersion { - major: number; - minor: number; - patch: number; -} - -function parseVersion(version: string): IVersion | undefined { - const match = /([0-9]+)\.([0-9]+)\.([0-9]+)/.exec(version); - - if (!match) { - return undefined; - } - - return { - major: parseInt(match[1]), - minor: parseInt(match[2]), - patch: parseInt(match[3]) - }; -} - function isMajorMinorUpdate(before: IVersion, after: IVersion): boolean { return before.major < after.major || before.minor < after.minor; } @@ -193,8 +174,12 @@ export class ProductContribution implements IWorkbenchContribution { return; } - const lastVersion = parseVersion(storageService.get(ProductContribution.KEY, StorageScope.APPLICATION, '')); - const currentVersion = parseVersion(productService.version); + if (configurationService.getValue('update.titleBar') !== 'none') { + return; + } + + const lastVersion = tryParseVersion(storageService.get(ProductContribution.KEY, StorageScope.APPLICATION, '')); + const currentVersion = tryParseVersion(productService.version); const shouldShowReleaseNotes = configurationService.getValue('update.showReleaseNotes'); const releaseNotesUrl = productService.releaseNotesUrl; @@ -229,6 +214,7 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu private overwriteNotificationHandle: INotificationHandle | undefined; private updateStateContextKey: IContextKey; private majorMinorUpdateAvailableContextKey: IContextKey; + private titleBarEnabled: boolean; constructor( @IStorageService private readonly storageService: IStorageService, @@ -268,6 +254,14 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu this.storageService.remove('update/updateNotificationTime', StorageScope.APPLICATION); } + this.titleBarEnabled = this.configurationService.getValue('update.titleBar') !== 'none'; + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('update.titleBar')) { + this.titleBarEnabled = this.configurationService.getValue('update.titleBar') !== 'none'; + this.onUpdateStateChange(this.updateService.state); + } + })); + this.registerGlobalActivityActions(); } @@ -276,7 +270,7 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu switch (state.type) { case StateType.Disabled: - if (state.reason === DisablementReason.RunningAsAdmin) { + if (!this.titleBarEnabled && state.reason === DisablementReason.RunningAsAdmin) { this.notificationService.notify({ severity: Severity.Info, message: nls.localize('update service disabled', "Updates are disabled because you are running the user-scope installation of {0} as Administrator.", this.productService.nameLong), @@ -317,8 +311,8 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu case StateType.Ready: { const productVersion = state.update.productVersion; if (productVersion) { - const currentVersion = parseVersion(this.productService.version); - const nextVersion = parseVersion(productVersion); + const currentVersion = tryParseVersion(this.productService.version); + const nextVersion = tryParseVersion(productVersion); this.majorMinorUpdateAvailableContextKey.set(Boolean(currentVersion && nextVersion && isMajorMinorUpdate(currentVersion, nextVersion))); } this.onUpdateReady(state); @@ -328,14 +322,16 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu let badge: IBadge | undefined = undefined; - if (state.type === StateType.AvailableForDownload || state.type === StateType.Downloaded || state.type === StateType.Ready) { - badge = new NumberBadge(1, () => nls.localize('updateIsReady', "New {0} update available.", this.productService.nameShort)); - } else if (state.type === StateType.CheckingForUpdates) { - badge = new ProgressBadge(() => nls.localize('checkingForUpdates', "Checking for {0} updates...", this.productService.nameShort)); - } else if (state.type === StateType.Downloading || state.type === StateType.Overwriting) { - badge = new ProgressBadge(() => nls.localize('downloading', "Downloading {0} update...", this.productService.nameShort)); - } else if (state.type === StateType.Updating) { - badge = new ProgressBadge(() => nls.localize('updating', "Updating {0}...", this.productService.nameShort)); + if (!this.titleBarEnabled) { + if (state.type === StateType.AvailableForDownload || state.type === StateType.Downloaded || state.type === StateType.Ready) { + badge = new NumberBadge(1, () => nls.localize('updateIsReady', "New {0} update available.", this.productService.nameShort)); + } else if (state.type === StateType.CheckingForUpdates) { + badge = new ProgressBadge(() => nls.localize('checkingForUpdates', "Checking for {0} updates...", this.productService.nameShort)); + } else if (state.type === StateType.Downloading || state.type === StateType.Overwriting) { + badge = new ProgressBadge(() => nls.localize('downloading', "Downloading {0} update...", this.productService.nameShort)); + } else if (state.type === StateType.Updating) { + badge = new ProgressBadge(() => nls.localize('updating', "Updating {0}...", this.productService.nameShort)); + } } this.badgeDisposable.clear(); @@ -348,25 +344,34 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu } private onError(error: string): void { - if (/The request timed out|The network connection was lost/i.test(error)) { + if (this.titleBarEnabled) { return; } - error = error.replace(/See https:\/\/github\.com\/Squirrel\/Squirrel\.Mac\/issues\/182 for more information/, 'This might mean the application was put on quarantine by macOS. See [this link](https://github.com/microsoft/vscode/issues/7426#issuecomment-425093469) for more information'); - - this.notificationService.notify({ - severity: Severity.Error, - message: error, - source: nls.localize('update service', "Update Service"), - }); + const processedError = preprocessError(error); + if (processedError) { + this.notificationService.notify({ + severity: Severity.Error, + message: processedError, + source: nls.localize('update service', "Update Service"), + }); + } } private onUpdateNotAvailable(): void { + if (this.titleBarEnabled) { + return; + } + this.dialogService.info(nls.localize('noUpdatesAvailable', "There are currently no updates available.")); } // linux private onUpdateAvailable(update: IUpdate): void { + if (this.titleBarEnabled) { + return; + } + if (!this.shouldShowNotification()) { return; } @@ -397,6 +402,10 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu // windows fast updates private onUpdateDownloaded(update: IUpdate): void { + if (this.titleBarEnabled) { + return; + } + if (isMacintosh) { return; } @@ -434,6 +443,12 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu // windows and mac private onUpdateReady(state: Ready): void { + if (this.titleBarEnabled) { + this.overwriteNotificationHandle?.progress.done(); + this.overwriteNotificationHandle = undefined; + return; + } + if (state.overwrite && this.overwriteNotificationHandle) { const handle = this.overwriteNotificationHandle; this.overwriteNotificationHandle = undefined; @@ -485,6 +500,10 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu // macOS overwrite update - overwriting private onUpdateOverwriting(state: Overwriting): void { + if (this.titleBarEnabled) { + return; + } + if (!state.explicit) { return; } diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts index baf84977f90..4ed3e130eea 100644 --- a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -3,39 +3,33 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as dom from '../../../../base/browser/dom.js'; -import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; -import { toAction } from '../../../../base/common/actions.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { isWeb } from '../../../../base/common/platform.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import * as nls from '../../../../nls.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { Command } from '../../../../editor/common/languages.js'; +import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; -import { Downloading, IUpdate, IUpdateService, Overwriting, StateType, State as UpdateState, Updating } from '../../../../platform/update/common/update.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { Downloading, IUpdateService, StateType, State as UpdateState, Updating } from '../../../../platform/update/common/update.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; -import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, TooltipContent } from '../../../services/statusbar/browser/statusbar.js'; +import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment } from '../../../services/statusbar/browser/statusbar.js'; +import { computeProgressPercent, formatBytes } from '../common/updateUtils.js'; import './media/updateStatusBarEntry.css'; +import { UpdateTooltip } from './updateTooltip.js'; /** * Displays update status and actions in the status bar. */ -export class UpdateStatusBarEntryContribution extends Disposable implements IWorkbenchContribution { - private static readonly NAME = nls.localize('updateStatus', "Update Status"); - private readonly statusBarEntryAccessor = this._register(new MutableDisposable()); +export class UpdateStatusBarContribution extends Disposable implements IWorkbenchContribution { + private static readonly actionableStates = [StateType.AvailableForDownload, StateType.Downloaded, StateType.Ready]; + private readonly accessor = this._register(new MutableDisposable()); + private readonly tooltip!: UpdateTooltip; private lastStateType: StateType | undefined; constructor( - @IUpdateService private readonly updateService: IUpdateService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IInstantiationService instantiationService: IInstantiationService, @IStatusbarService private readonly statusbarService: IStatusbarService, - @IProductService private readonly productService: IProductService, - @ICommandService private readonly commandService: ICommandService, - @IHoverService private readonly hoverService: IHoverService, - @IConfigurationService private readonly configurationService: IConfigurationService + @IUpdateService updateService: IUpdateService, ) { super(); @@ -43,126 +37,112 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor return; // Electron only } - this._register(this.updateService.onStateChange(state => this.onUpdateStateChange(state))); + this.tooltip = this._register(instantiationService.createInstance(UpdateTooltip)); + + this._register(updateService.onStateChange(this.onStateChange.bind(this))); this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('update.statusBar')) { - this.onUpdateStateChange(this.updateService.state); + if (e.affectsConfiguration('update.statusBar') || e.affectsConfiguration('update.titleBar')) { + this.onStateChange(updateService.state); } })); - this.onUpdateStateChange(this.updateService.state); + + this.onStateChange(updateService.state); } - private onUpdateStateChange(state: UpdateState) { + private onStateChange(state: UpdateState) { + const titleBarMode = this.configurationService.getValue('update.titleBar'); + if (titleBarMode !== 'none') { + this.accessor.clear(); + return; + } + + const mode = this.configurationService.getValue('update.statusBar'); + if (mode === 'hidden' || mode === 'actionable' && !UpdateStatusBarContribution.actionableStates.includes(state.type)) { + this.accessor.clear(); + return; + } + if (this.lastStateType !== state.type) { - this.statusBarEntryAccessor.clear(); + this.accessor.clear(); this.lastStateType = state.type; } - const statusBarMode = this.configurationService.getValue('update.statusBar'); - - if (statusBarMode === 'hidden') { - this.statusBarEntryAccessor.clear(); - return; - } - - const actionRequiredStates = [ - StateType.AvailableForDownload, - StateType.Downloaded, - StateType.Ready - ]; - - // In 'actionable' mode, only show for states that require user action - if (statusBarMode === 'actionable' && !actionRequiredStates.includes(state.type)) { - this.statusBarEntryAccessor.clear(); - return; - } - switch (state.type) { - case StateType.Uninitialized: - case StateType.Idle: - case StateType.Disabled: - this.statusBarEntryAccessor.clear(); - break; - case StateType.CheckingForUpdates: - this.updateStatusBarEntry({ - name: UpdateStatusBarEntryContribution.NAME, - text: nls.localize('updateStatus.checkingForUpdates', "$(sync~spin) Checking for updates..."), - ariaLabel: nls.localize('updateStatus.checkingForUpdatesAria', "Checking for updates"), - tooltip: this.getCheckingTooltip(), - command: ShowTooltipCommand, - }); + this.updateEntry( + localize('updateStatus.checkingForUpdates', "$(loading~spin) Checking for updates..."), + localize('updateStatus.checkingForUpdatesAria', "Checking for updates"), + ShowTooltipCommand, + ); break; case StateType.AvailableForDownload: - this.updateStatusBarEntry({ - name: UpdateStatusBarEntryContribution.NAME, - text: nls.localize('updateStatus.updateAvailableStatus', "$(circle-filled) Update available, click to download."), - ariaLabel: nls.localize('updateStatus.updateAvailableAria', "Update available, click to download."), - tooltip: this.getAvailableTooltip(state.update), - command: 'update.downloadNow' - }); + this.updateEntry( + localize('updateStatus.updateAvailableStatus', "$(circle-filled) Update available, click to download."), + localize('updateStatus.updateAvailableAria', "Update available, click to download."), + 'update.downloadNow' + ); break; case StateType.Downloading: - this.updateStatusBarEntry({ - name: UpdateStatusBarEntryContribution.NAME, - text: this.getDownloadingText(state), - ariaLabel: nls.localize('updateStatus.downloadingUpdateAria', "Downloading update"), - tooltip: this.getDownloadingTooltip(state), - command: ShowTooltipCommand - }); + this.updateEntry( + this.getDownloadingText(state), + localize('updateStatus.downloadingUpdateAria', "Downloading update"), + ShowTooltipCommand + ); break; case StateType.Downloaded: - this.updateStatusBarEntry({ - name: UpdateStatusBarEntryContribution.NAME, - text: nls.localize('updateStatus.updateReadyStatus', "$(circle-filled) Update downloaded, click to install."), - ariaLabel: nls.localize('updateStatus.updateReadyAria', "Update downloaded, click to install."), - tooltip: this.getReadyToInstallTooltip(state.update), - command: 'update.install' - }); + this.updateEntry( + localize('updateStatus.updateReadyStatus', "$(circle-filled) Update downloaded, click to install."), + localize('updateStatus.updateReadyAria', "Update downloaded, click to install."), + 'update.install' + ); break; case StateType.Updating: - this.updateStatusBarEntry({ - name: UpdateStatusBarEntryContribution.NAME, - text: this.getUpdatingText(state), - ariaLabel: this.getUpdatingText(state), - tooltip: this.getUpdatingTooltip(state), - command: ShowTooltipCommand - }); + this.updateEntry( + this.getUpdatingText(state), + undefined, + ShowTooltipCommand + ); break; - case StateType.Ready: { - - this.updateStatusBarEntry({ - name: UpdateStatusBarEntryContribution.NAME, - text: nls.localize('updateStatus.restartToUpdateStatus', "$(circle-filled) Update is ready, click to restart."), - ariaLabel: nls.localize('updateStatus.restartToUpdateAria', "Update is ready, click to restart."), - tooltip: this.getRestartToUpdateTooltip(state.update), - command: 'update.restart' - }); + case StateType.Ready: + this.updateEntry( + localize('updateStatus.restartToUpdateStatus', "$(circle-filled) Update is ready, click to restart."), + localize('updateStatus.restartToUpdateAria', "Update is ready, click to restart."), + 'update.restart' + ); break; - } case StateType.Overwriting: - this.updateStatusBarEntry({ - name: UpdateStatusBarEntryContribution.NAME, - text: nls.localize('updateStatus.downloadingNewerUpdateStatus', "$(sync~spin) Downloading update..."), - ariaLabel: nls.localize('updateStatus.downloadingNewerUpdateAria', "Downloading a newer update"), - tooltip: this.getOverwritingTooltip(state), - command: ShowTooltipCommand - }); + this.updateEntry( + localize('updateStatus.downloadingNewerUpdateStatus', "$(loading~spin) Downloading update..."), + localize('updateStatus.downloadingNewerUpdateAria', "Downloading a newer update"), + ShowTooltipCommand + ); + break; + + default: + this.accessor.clear(); break; } } - private updateStatusBarEntry(entry: IStatusbarEntry) { - if (this.statusBarEntryAccessor.value) { - this.statusBarEntryAccessor.value.update(entry); + private updateEntry(text: string, ariaLabel: string | undefined, command: string | Command) { + const entry: IStatusbarEntry = { + text, + ariaLabel: ariaLabel ?? text, + name: localize('updateStatus', "Update Status"), + tooltip: this.tooltip?.domNode, + command + }; + + if (this.accessor.value) { + this.accessor.value.update(entry); } else { - this.statusBarEntryAccessor.value = this.statusbarService.addEntry( + this.accessor.value = this.statusbarService.addEntry( entry, 'status.update', StatusbarAlignment.LEFT, @@ -171,401 +151,24 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor } } - private getCheckingTooltip(): TooltipContent { - return { - element: (token: CancellationToken) => { - const store = this.createTooltipDisposableStore(token); - const container = dom.$('.update-status-tooltip'); - - this.appendHeader(container, nls.localize('updateStatus.checkingForUpdatesTitle', "Checking for Updates"), store); - this.appendProductInfo(container); - - const message = dom.append(container, dom.$('.progress-details')); - message.textContent = nls.localize('updateStatus.checkingPleaseWait', "Checking for updates, please wait..."); - - return container; - } - }; - } - - private getAvailableTooltip(update: IUpdate): TooltipContent { - return { - element: (token: CancellationToken) => { - const store = this.createTooltipDisposableStore(token); - const container = dom.$('.update-status-tooltip'); - - this.appendHeader(container, nls.localize('updateStatus.updateAvailableTitle', "Update Available"), store); - this.appendProductInfo(container, update); - this.appendWhatsIncluded(container); - - return container; - } - }; - } - private getDownloadingText({ downloadedBytes, totalBytes }: Downloading): string { if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) { - return nls.localize('updateStatus.downloadUpdateProgressStatus', "$(sync~spin) Downloading update: {0} / {1} • {2}%", + const percent = computeProgressPercent(downloadedBytes, totalBytes) ?? 0; + return localize('updateStatus.downloadUpdateProgressStatus', "$(loading~spin) Downloading update: {0} / {1} • {2}%", formatBytes(downloadedBytes), formatBytes(totalBytes), - getProgressPercent(downloadedBytes, totalBytes) ?? 0); + percent); } else { - return nls.localize('updateStatus.downloadUpdateStatus', "$(sync~spin) Downloading update..."); + return localize('updateStatus.downloadUpdateStatus', "$(loading~spin) Downloading update..."); } } - private getDownloadingTooltip(state: Downloading): TooltipContent { - return { - element: (token: CancellationToken) => { - const store = this.createTooltipDisposableStore(token); - const container = dom.$('.update-status-tooltip'); - - this.appendHeader(container, nls.localize('updateStatus.downloadingUpdateTitle', "Downloading Update"), store); - this.appendProductInfo(container, state.update); - - const { downloadedBytes, totalBytes } = state; - if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) { - const percentage = getProgressPercent(downloadedBytes, totalBytes) ?? 0; - - const progressContainer = dom.append(container, dom.$('.progress-container')); - const progressBar = dom.append(progressContainer, dom.$('.progress-bar')); - const progressFill = dom.append(progressBar, dom.$('.progress-fill')); - progressFill.style.width = `${percentage}%`; - - const progressText = dom.append(progressContainer, dom.$('.progress-text')); - const percentageSpan = dom.append(progressText, dom.$('span')); - percentageSpan.textContent = `${percentage}%`; - - const sizeSpan = dom.append(progressText, dom.$('span')); - sizeSpan.textContent = `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`; - - const speed = computeDownloadSpeed(state); - if (speed !== undefined && speed > 0) { - const speedInfo = dom.append(container, dom.$('.speed-info')); - speedInfo.textContent = nls.localize('updateStatus.downloadSpeed', '{0}/s', formatBytes(speed)); - } - - const timeRemaining = computeDownloadTimeRemaining(state); - if (timeRemaining !== undefined && timeRemaining > 0) { - const timeRemainingNode = dom.append(container, dom.$('.time-remaining')); - timeRemainingNode.textContent = `~${formatTimeRemaining(timeRemaining)} ${nls.localize('updateStatus.timeRemaining', "remaining")}`; - } - } else { - const message = dom.append(container, dom.$('.progress-details')); - message.textContent = nls.localize('updateStatus.downloadingPleaseWait', "Downloading, please wait..."); - } - - return container; - } - }; - } - - private getReadyToInstallTooltip(update: IUpdate): TooltipContent { - return { - element: (token: CancellationToken) => { - const store = this.createTooltipDisposableStore(token); - const container = dom.$('.update-status-tooltip'); - - this.appendHeader(container, nls.localize('updateStatus.updateReadyTitle', "Update is Ready to Install"), store); - this.appendProductInfo(container, update); - this.appendWhatsIncluded(container); - - return container; - } - }; - } - - private getRestartToUpdateTooltip(update: IUpdate): TooltipContent { - return { - element: (token: CancellationToken) => { - const store = this.createTooltipDisposableStore(token); - const container = dom.$('.update-status-tooltip'); - - this.appendHeader(container, nls.localize('updateStatus.updateInstalledTitle', "Update Installed"), store); - this.appendProductInfo(container, update); - this.appendWhatsIncluded(container); - - return container; - } - }; - } - private getUpdatingText({ currentProgress, maxProgress }: Updating): string { - const percentage = getProgressPercent(currentProgress, maxProgress); + const percentage = computeProgressPercent(currentProgress, maxProgress); if (percentage !== undefined) { - return nls.localize('updateStatus.installingUpdateProgressStatus', "$(sync~spin) Installing update: {0}%", percentage); + return localize('updateStatus.installingUpdateProgressStatus', "$(loading~spin) Installing update: {0}%", percentage); } else { - return nls.localize('updateStatus.installingUpdateStatus', "$(sync~spin) Installing update..."); + return localize('updateStatus.installingUpdateStatus', "$(loading~spin) Installing update..."); } } - - private getUpdatingTooltip(state: Updating): TooltipContent { - return { - element: (token: CancellationToken) => { - const store = this.createTooltipDisposableStore(token); - const container = dom.$('.update-status-tooltip'); - - this.appendHeader(container, nls.localize('updateStatus.installingUpdateTitle', "Installing Update"), store); - this.appendProductInfo(container, state.update); - - const { currentProgress, maxProgress } = state; - const percentage = getProgressPercent(currentProgress, maxProgress); - if (percentage !== undefined) { - const progressContainer = dom.append(container, dom.$('.progress-container')); - const progressBar = dom.append(progressContainer, dom.$('.progress-bar')); - const progressFill = dom.append(progressBar, dom.$('.progress-fill')); - progressFill.style.width = `${percentage}%`; - - const progressText = dom.append(progressContainer, dom.$('.progress-text')); - const percentageSpan = dom.append(progressText, dom.$('span')); - percentageSpan.textContent = `${percentage}%`; - } else { - const message = dom.append(container, dom.$('.progress-details')); - message.textContent = nls.localize('updateStatus.installingPleaseWait', "Installing update, please wait..."); - } - - return container; - } - }; - } - - private getOverwritingTooltip(state: Overwriting): TooltipContent { - return { - element: (token: CancellationToken) => { - const store = this.createTooltipDisposableStore(token); - const container = dom.$('.update-status-tooltip'); - - this.appendHeader(container, nls.localize('updateStatus.downloadingNewerUpdateTitle', "Downloading Newer Update"), store); - this.appendProductInfo(container, state.update); - - const message = dom.append(container, dom.$('.progress-details')); - message.textContent = nls.localize('updateStatus.downloadingNewerPleaseWait', "A newer update was released. Downloading, please wait..."); - - return container; - } - }; - } - - private createTooltipDisposableStore(token: CancellationToken): DisposableStore { - const store = new DisposableStore(); - store.add(token.onCancellationRequested(() => store.dispose())); - return store; - } - - private runCommandAndClose(command: string, ...args: unknown[]): void { - this.commandService.executeCommand(command, ...args); - this.hoverService.hideHover(true); - } - - private appendHeader(container: HTMLElement, title: string, store: DisposableStore) { - const header = dom.append(container, dom.$('.header')); - const text = dom.append(header, dom.$('.title')); - text.textContent = title; - - const actionBar = store.add(new ActionBar(header, { hoverDelegate: nativeHoverDelegate })); - actionBar.push([toAction({ - id: 'update.openSettings', - label: nls.localize('updateStatus.settingsTooltip', "Update Settings"), - class: ThemeIcon.asClassName(Codicon.gear), - run: () => this.runCommandAndClose('workbench.action.openSettings', '@id:update*'), - })], { icon: true, label: false }); - } - - private appendProductInfo(container: HTMLElement, update?: IUpdate) { - const productInfo = dom.append(container, dom.$('.product-info')); - - const logoContainer = dom.append(productInfo, dom.$('.product-logo')); - logoContainer.setAttribute('role', 'img'); - logoContainer.setAttribute('aria-label', this.productService.nameLong); - - const details = dom.append(productInfo, dom.$('.product-details')); - - const productName = dom.append(details, dom.$('.product-name')); - productName.textContent = this.productService.nameLong; - - const productVersion = this.productService.version; - if (productVersion) { - const currentVersion = dom.append(details, dom.$('.product-version')); - const currentCommitId = this.productService.commit?.substring(0, 7); - currentVersion.textContent = currentCommitId - ? nls.localize('updateStatus.currentVersionLabelWithCommit', "Current Version: {0} ({1})", productVersion, currentCommitId) - : nls.localize('updateStatus.currentVersionLabel', "Current Version: {0}", productVersion); - } - - const version = update?.productVersion; - if (version) { - const latestVersion = dom.append(details, dom.$('.product-version')); - const updateCommitId = update.version?.substring(0, 7); - latestVersion.textContent = updateCommitId - ? nls.localize('updateStatus.latestVersionLabelWithCommit', "Latest Version: {0} ({1})", version, updateCommitId) - : nls.localize('updateStatus.latestVersionLabel', "Latest Version: {0}", version); - } - - const releaseDate = update?.timestamp ?? tryParseDate(this.productService.date); - if (typeof releaseDate === 'number' && releaseDate > 0) { - const releaseDateNode = dom.append(details, dom.$('.product-release-date')); - releaseDateNode.textContent = nls.localize('updateStatus.releasedLabel', "Released {0}", formatDate(releaseDate)); - } - - const releaseNotesVersion = version ?? productVersion; - if (releaseNotesVersion) { - const link = dom.append(details, dom.$('a.release-notes-link')) as HTMLAnchorElement; - link.textContent = nls.localize('updateStatus.releaseNotesLink', "Release Notes"); - link.href = '#'; - link.addEventListener('click', (e) => { - e.preventDefault(); - this.runCommandAndClose('update.showCurrentReleaseNotes', releaseNotesVersion); - }); - } - } - - private appendWhatsIncluded(container: HTMLElement) { - /* - const whatsIncluded = dom.append(container, dom.$('.whats-included')); - - const sectionTitle = dom.append(whatsIncluded, dom.$('.section-title')); - sectionTitle.textContent = nls.localize('updateStatus.whatsIncludedTitle', "What's Included"); - - const list = dom.append(whatsIncluded, dom.$('ul')); - - const items = [ - nls.localize('updateStatus.featureItem', "New features and functionality"), - nls.localize('updateStatus.bugFixesItem', "Bug fixes and improvements"), - nls.localize('updateStatus.securityItem', "Security fixes and enhancements") - ]; - - for (const item of items) { - const li = dom.append(list, dom.$('li')); - li.textContent = item; - } - */ - } -} - -/** - * Returns the progress percentage based on the current and maximum progress values. - */ -export function getProgressPercent(current: number | undefined, max: number | undefined): number | undefined { - if (current === undefined || max === undefined || max <= 0) { - return undefined; - } else { - return Math.max(Math.min(Math.round((current / max) * 100), 100), 0); - } -} - -/** - * Tries to parse a date string and returns the timestamp or undefined if parsing fails. - */ -export function tryParseDate(date: string | undefined): number | undefined { - if (date === undefined) { - return undefined; - } - const parsed = Date.parse(date); - return isNaN(parsed) ? undefined : parsed; -} - -/** - * Formats a timestamp as a localized date string. - */ -export function formatDate(timestamp: number): string { - return new Date(timestamp).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric' - }); -} - -/** - * Computes an estimate of remaining download time in seconds. - */ -export function computeDownloadTimeRemaining(state: Downloading): number | undefined { - const { downloadedBytes, totalBytes, startTime } = state; - if (downloadedBytes === undefined || totalBytes === undefined || startTime === undefined) { - return undefined; - } - - const elapsedMs = Date.now() - startTime; - if (downloadedBytes <= 0 || totalBytes <= 0 || elapsedMs <= 0) { - return undefined; - } - - const remainingBytes = totalBytes - downloadedBytes; - if (remainingBytes <= 0) { - return 0; - } - - const bytesPerMs = downloadedBytes / elapsedMs; - if (bytesPerMs <= 0) { - return undefined; - } - - const remainingMs = remainingBytes / bytesPerMs; - return Math.ceil(remainingMs / 1000); -} - -/** - * Formats the time remaining as a human-readable string. - */ -export function formatTimeRemaining(seconds: number): string { - const hours = seconds / 3600; - if (hours >= 1) { - const formattedHours = formatDecimal(hours); - return formattedHours === '1' - ? nls.localize('timeRemainingHour', "{0} hour", formattedHours) - : nls.localize('timeRemainingHours', "{0} hours", formattedHours); - } - - const minutes = Math.floor(seconds / 60); - if (minutes >= 1) { - return nls.localize('timeRemainingMinutes', "{0} min", minutes); - } - - return nls.localize('timeRemainingSeconds', "{0}s", seconds); -} - -/** - * Formats a byte count as a human-readable string. - */ -export function formatBytes(bytes: number): string { - if (bytes < 1024) { - return nls.localize('bytes', "{0} B", bytes); - } - - const kb = bytes / 1024; - if (kb < 1024) { - return nls.localize('kilobytes', "{0} KB", formatDecimal(kb)); - } - - const mb = kb / 1024; - if (mb < 1024) { - return nls.localize('megabytes', "{0} MB", formatDecimal(mb)); - } - - const gb = mb / 1024; - return nls.localize('gigabytes', "{0} GB", formatDecimal(gb)); -} - -/** - * Formats a number to 1 decimal place, omitting ".0" for whole numbers. - */ -function formatDecimal(value: number): string { - const rounded = Math.round(value * 10) / 10; - return rounded % 1 === 0 ? rounded.toString() : rounded.toFixed(1); -} - -/** - * Computes the current download speed in bytes per second. - */ -export function computeDownloadSpeed(state: Downloading): number | undefined { - const { downloadedBytes, startTime } = state; - if (downloadedBytes === undefined || startTime === undefined) { - return undefined; - } - - const elapsedMs = Date.now() - startTime; - if (elapsedMs <= 0 || downloadedBytes <= 0) { - return undefined; - } - - return (downloadedBytes / elapsedMs) * 1000; } diff --git a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts new file mode 100644 index 00000000000..93be7e36170 --- /dev/null +++ b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IManagedHoverContent } from '../../../../base/browser/ui/hover/hover.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { isWeb } from '../../../../base/common/platform.js'; +import { localize } from '../../../../nls.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { DisablementReason, IUpdateService, State, StateType } from '../../../../platform/update/common/update.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { computeProgressPercent, tryParseVersion } from '../common/updateUtils.js'; +import './media/updateTitleBarEntry.css'; +import { UpdateTooltip } from './updateTooltip.js'; + +const UPDATE_TITLE_BAR_ACTION_ID = 'workbench.actions.updateIndicator'; +const UPDATE_TITLE_BAR_CONTEXT = new RawContextKey('updateTitleBar', false); +const LAST_KNOWN_VERSION_KEY = 'updateTitleBar/lastKnownVersion'; +const ACTIONABLE_STATES: readonly StateType[] = [StateType.AvailableForDownload, StateType.Downloaded, StateType.Ready]; + +registerAction2(class UpdateIndicatorTitleBarAction extends Action2 { + constructor() { + super({ + id: UPDATE_TITLE_BAR_ACTION_ID, + title: localize('updateIndicatorTitleBarAction', 'Update'), + f1: false, + menu: [{ + id: MenuId.CommandCenter, + order: 10003, + when: UPDATE_TITLE_BAR_CONTEXT, + }] + }); + } + + override async run() { } +}); + +/** + * Displays update status and actions in the title bar. + */ +export class UpdateTitleBarContribution extends Disposable implements IWorkbenchContribution { + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IInstantiationService instantiationService: IInstantiationService, + @IProductService private readonly productService: IProductService, + @IStorageService private readonly storageService: IStorageService, + @IUpdateService updateService: IUpdateService, + ) { + super(); + + if (isWeb) { + return; // Electron only + } + + const context = UPDATE_TITLE_BAR_CONTEXT.bindTo(contextKeyService); + + const updateContext = () => { + const mode = configurationService.getValue('update.titleBar'); + const state = updateService.state.type; + context.set(mode === 'detailed' || mode === 'actionable' && ACTIONABLE_STATES.includes(state)); + }; + + let entry: UpdateTitleBarEntry | undefined; + let showTooltipOnRender = false; + + this._register(actionViewItemService.register( + MenuId.CommandCenter, + UPDATE_TITLE_BAR_ACTION_ID, + (action, options) => { + entry = instantiationService.createInstance(UpdateTitleBarEntry, action, options, updateContext, showTooltipOnRender); + showTooltipOnRender = false; + return entry; + } + )); + + const onStateChange = () => { + if (this.shouldShowTooltip(updateService.state)) { + if (context.get()) { + entry?.showTooltip(); + } else { + context.set(true); + showTooltipOnRender = true; + } + } else { + updateContext(); + } + }; + + this._register(updateService.onStateChange(onStateChange)); + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('update.titleBar')) { + updateContext(); + } + })); + + onStateChange(); + } + + private shouldShowTooltip(state: State): boolean { + switch (state.type) { + case StateType.Disabled: + return state.reason === DisablementReason.InvalidConfiguration || state.reason === DisablementReason.RunningAsAdmin; + case StateType.Idle: + return !!state.error || state.notAvailable || this.isMajorMinorVersionChange(); + case StateType.AvailableForDownload: + case StateType.Downloaded: + case StateType.Ready: + return true; + default: + return false; + } + } + + private isMajorMinorVersionChange(): boolean { + const currentVersion = this.productService.version; + const lastKnownVersion = this.storageService.get(LAST_KNOWN_VERSION_KEY, StorageScope.APPLICATION); + this.storageService.store(LAST_KNOWN_VERSION_KEY, currentVersion, StorageScope.APPLICATION, StorageTarget.MACHINE); + if (!lastKnownVersion) { + return false; + } + + const current = tryParseVersion(currentVersion); + const last = tryParseVersion(lastKnownVersion); + if (!current || !last) { + return false; + } + + return current.major !== last.major || current.minor !== last.minor; + } +} + +/** + * Custom action view item for the update indicator in the title bar. + */ +export class UpdateTitleBarEntry extends BaseActionViewItem { + private content: HTMLElement | undefined; + private readonly tooltip: UpdateTooltip; + + constructor( + action: IAction, + options: IBaseActionViewItemOptions, + private readonly onDisposeTooltip: () => void, + private showTooltipOnRender: boolean, + @ICommandService private readonly commandService: ICommandService, + @IHoverService private readonly hoverService: IHoverService, + @IInstantiationService instantiationService: IInstantiationService, + @IUpdateService private readonly updateService: IUpdateService, + ) { + super(undefined, action, options); + + this.action.run = () => this.runAction(); + this.tooltip = this._register(instantiationService.createInstance(UpdateTooltip)); + + this._register(this.updateService.onStateChange(state => this.updateContent(state))); + } + + public override render(container: HTMLElement) { + super.render(container); + + this.content = dom.append(container, dom.$('.update-indicator')); + this.updateTooltip(); + this.updateContent(this.updateService.state); + + if (this.showTooltipOnRender) { + this.showTooltipOnRender = false; + dom.scheduleAtNextAnimationFrame(dom.getWindow(container), () => this.showTooltip()); + } + } + + protected override getHoverContents(): IManagedHoverContent { + return this.tooltip.domNode; + } + + private runAction() { + switch (this.updateService.state.type) { + case StateType.AvailableForDownload: + this.commandService.executeCommand('update.downloadNow'); + break; + case StateType.Downloaded: + this.commandService.executeCommand('update.install'); + break; + case StateType.Ready: + this.commandService.executeCommand('update.restart'); + break; + default: + this.showTooltip(); + break; + } + } + + public showTooltip() { + if (!this.content?.isConnected) { + return; + } + + this.hoverService.showInstantHover({ + content: this.tooltip.domNode, + target: { + targetElements: [this.content], + dispose: () => this.onDisposeTooltip(), + }, + persistence: { sticky: true }, + appearance: { showPointer: true }, + }, true); + } + + private updateContent(state: State) { + if (!this.content) { + return; + } + + dom.clearNode(this.content); + this.content.classList.remove('prominent', 'progress-indefinite', 'progress-percent', 'update-disabled'); + this.content.style.removeProperty('--update-progress'); + + const label = dom.append(this.content, dom.$('.indicator-label')); + label.textContent = localize('updateIndicator.update', "Update"); + + switch (state.type) { + case StateType.Disabled: + this.content.classList.add('update-disabled'); + break; + + case StateType.CheckingForUpdates: + case StateType.Overwriting: + this.renderProgressState(this.content); + break; + + case StateType.AvailableForDownload: + case StateType.Downloaded: + case StateType.Ready: + this.content.classList.add('prominent'); + break; + + case StateType.Downloading: + this.renderProgressState(this.content, computeProgressPercent(state.downloadedBytes, state.totalBytes)); + break; + + case StateType.Updating: + this.renderProgressState(this.content, computeProgressPercent(state.currentProgress, state.maxProgress)); + break; + } + } + + private renderProgressState(content: HTMLElement, percentage?: number) { + if (percentage !== undefined) { + content.classList.add('progress-percent'); + content.style.setProperty('--update-progress', `${percentage}%`); + } else { + content.classList.add('progress-indefinite'); + } + } +} diff --git a/src/vs/workbench/contrib/update/browser/updateTooltip.ts b/src/vs/workbench/contrib/update/browser/updateTooltip.ts new file mode 100644 index 00000000000..ee3c452c3be --- /dev/null +++ b/src/vs/workbench/contrib/update/browser/updateTooltip.ts @@ -0,0 +1,378 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js'; +import { IMeteredConnectionService } from '../../../../platform/meteredConnection/common/meteredConnection.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { AvailableForDownload, Disabled, DisablementReason, Downloaded, Downloading, Idle, IUpdate, IUpdateService, Overwriting, Ready, State, StateType, Updating } from '../../../../platform/update/common/update.js'; +import { computeDownloadSpeed, computeDownloadTimeRemaining, computeProgressPercent, formatBytes, formatDate, formatTimeRemaining, tryParseDate } from '../common/updateUtils.js'; +import './media/updateTooltip.css'; + +/** + * A stateful tooltip control for the update status. + */ +export class UpdateTooltip extends Disposable { + public readonly domNode: HTMLElement; + + // Header section + private readonly titleNode: HTMLElement; + + // Product info section + private readonly productNameNode: HTMLElement; + private readonly currentVersionNode: HTMLElement; + private readonly latestVersionNode: HTMLElement; + private readonly releaseDateNode: HTMLElement; + private readonly releaseNotesLink: HTMLAnchorElement; + + // Progress section + private readonly progressContainer: HTMLElement; + private readonly progressFill: HTMLElement; + private readonly progressPercentNode: HTMLElement; + private readonly progressSizeNode: HTMLElement; + + // Extra download info + private readonly downloadStatsContainer: HTMLElement; + private readonly timeRemainingNode: HTMLElement; + private readonly speedInfoNode: HTMLElement; + + // State-specific message + private readonly messageNode: HTMLElement; + + private releaseNotesVersion: string | undefined; + + constructor( + @ICommandService private readonly commandService: ICommandService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IHoverService private readonly hoverService: IHoverService, + @IMeteredConnectionService private readonly meteredConnectionService: IMeteredConnectionService, + @IProductService private readonly productService: IProductService, + @IUpdateService updateService: IUpdateService, + ) { + super(); + + this.domNode = dom.$('.update-tooltip'); + + // Header section + const header = dom.append(this.domNode, dom.$('.header')); + this.titleNode = dom.append(header, dom.$('.title')); + + const actionBar = this._register(new ActionBar(header, { hoverDelegate: nativeHoverDelegate })); + actionBar.push(toAction({ + id: 'update.openSettings', + label: localize('updateTooltip.settingsTooltip', "Update Settings"), + class: ThemeIcon.asClassName(Codicon.gear), + run: () => this.runCommandAndClose('workbench.action.openSettings', '@id:update*'), + }), { icon: true, label: false }); + + // Product info section + const productInfo = dom.append(this.domNode, dom.$('.product-info')); + + const logoContainer = dom.append(productInfo, dom.$('.product-logo')); + logoContainer.setAttribute('role', 'img'); + logoContainer.setAttribute('aria-label', this.productService.nameLong); + + const details = dom.append(productInfo, dom.$('.product-details')); + + this.productNameNode = dom.append(details, dom.$('.product-name')); + this.productNameNode.textContent = this.productService.nameLong; + + this.currentVersionNode = dom.append(details, dom.$('.product-version')); + this.latestVersionNode = dom.append(details, dom.$('.product-version')); + this.releaseDateNode = dom.append(details, dom.$('.product-release-date')); + + this.releaseNotesLink = dom.append(details, dom.$('a.release-notes-link')) as HTMLAnchorElement; + this.releaseNotesLink.textContent = localize('updateTooltip.releaseNotesLink', "Release Notes"); + this.releaseNotesLink.href = '#'; + this._register(dom.addDisposableListener(this.releaseNotesLink, 'click', (e) => { + e.preventDefault(); + if (this.releaseNotesVersion) { + this.runCommandAndClose('update.showCurrentReleaseNotes', this.releaseNotesVersion); + } + })); + + // Progress section + this.progressContainer = dom.append(this.domNode, dom.$('.progress-container')); + const progressBar = dom.append(this.progressContainer, dom.$('.progress-bar')); + this.progressFill = dom.append(progressBar, dom.$('.progress-fill')); + + const progressText = dom.append(this.progressContainer, dom.$('.progress-text')); + this.progressPercentNode = dom.append(progressText, dom.$('span')); + this.progressSizeNode = dom.append(progressText, dom.$('span')); + + // Extra download stats + this.downloadStatsContainer = dom.append(this.progressContainer, dom.$('.download-stats')); + this.timeRemainingNode = dom.append(this.downloadStatsContainer, dom.$('.time-remaining')); + this.speedInfoNode = dom.append(this.downloadStatsContainer, dom.$('.speed-info')); + + // State-specific message + this.messageNode = dom.append(this.domNode, dom.$('.state-message')); + + // Populate static product info + this.updateCurrentVersion(); + + // Subscribe to state changes + this._register(updateService.onStateChange(state => this.onStateChange(state))); + this.onStateChange(updateService.state); + } + + private updateCurrentVersion() { + const productVersion = this.productService.version; + if (productVersion) { + const currentCommitId = this.productService.commit?.substring(0, 7); + this.currentVersionNode.textContent = currentCommitId + ? localize('updateTooltip.currentVersionLabelWithCommit', "Current Version: {0} ({1})", productVersion, currentCommitId) + : localize('updateTooltip.currentVersionLabel', "Current Version: {0}", productVersion); + this.currentVersionNode.style.display = ''; + } else { + this.currentVersionNode.style.display = 'none'; + } + } + + private onStateChange(state: State) { + this.progressContainer.style.display = 'none'; + this.speedInfoNode.textContent = ''; + this.timeRemainingNode.textContent = ''; + this.messageNode.style.display = 'none'; + + switch (state.type) { + case StateType.Uninitialized: + this.renderUninitialized(); + break; + case StateType.Disabled: + this.renderDisabled(state); + break; + case StateType.Idle: + this.renderIdle(state); + break; + case StateType.CheckingForUpdates: + this.renderCheckingForUpdates(); + break; + case StateType.AvailableForDownload: + this.renderAvailableForDownload(state); + break; + case StateType.Downloading: + this.renderDownloading(state); + break; + case StateType.Downloaded: + this.renderDownloaded(state); + break; + case StateType.Updating: + this.renderUpdating(state); + break; + case StateType.Ready: + this.renderReady(state); + break; + case StateType.Overwriting: + this.renderOverwriting(state); + break; + } + } + + private renderUninitialized() { + this.renderTitleAndInfo(localize('updateTooltip.initializingTitle', "Initializing")); + this.showMessage(localize('updateTooltip.initializingMessage', "Initializing update service...")); + } + + private renderDisabled({ reason }: Disabled) { + this.renderTitleAndInfo(localize('updateTooltip.updatesDisabledTitle', "Updates Disabled")); + switch (reason) { + case DisablementReason.NotBuilt: + this.showMessage( + localize('updateTooltip.disabledNotBuilt', "Updates are not available for this build."), + Codicon.info); + break; + case DisablementReason.DisabledByEnvironment: + this.showMessage( + localize('updateTooltip.disabledByEnvironment', "Updates are disabled by the --disable-updates command line flag."), + Codicon.warning); + break; + case DisablementReason.ManuallyDisabled: + this.showMessage( + localize('updateTooltip.disabledManually', "Updates are manually disabled. Change the \"update.mode\" setting to enable."), + Codicon.warning); + break; + case DisablementReason.Policy: + this.showMessage( + localize('updateTooltip.disabledByPolicy', "Updates are disabled by organization policy."), + Codicon.info); + break; + case DisablementReason.MissingConfiguration: + this.showMessage( + localize('updateTooltip.disabledMissingConfig', "Updates are disabled because no update URL is configured."), + Codicon.info); + break; + case DisablementReason.InvalidConfiguration: + this.showMessage( + localize('updateTooltip.disabledInvalidConfig', "Updates are disabled because the update URL is invalid."), + Codicon.error); + break; + case DisablementReason.RunningAsAdmin: + this.showMessage( + localize( + 'updateTooltip.disabledRunningAsAdmin', + "Updates are not available when running a user install of {0} as administrator.", + this.productService.nameShort), + Codicon.warning); + break; + default: + this.showMessage(localize('updateTooltip.disabledGeneric', "Updates are disabled."), Codicon.warning); + break; + } + } + + private renderIdle({ error, notAvailable }: Idle) { + if (error) { + this.renderTitleAndInfo(localize('updateTooltip.updateErrorTitle', "Update Error")); + this.showMessage(error, Codicon.error); + return; + } + + if (notAvailable) { + this.renderTitleAndInfo(localize('updateTooltip.noUpdateAvailableTitle', "No Update Available")); + this.showMessage(localize('updateTooltip.noUpdateAvailableMessage', "There are no updates currently available."), Codicon.info); + return; + } + + this.renderTitleAndInfo(localize('updateTooltip.upToDateTitle', "Up to Date")); + switch (this.configurationService.getValue('update.mode')) { + case 'none': + this.showMessage(localize('updateTooltip.autoUpdateNone', "Automatic updates are disabled."), Codicon.warning); + break; + case 'manual': + this.showMessage(localize('updateTooltip.autoUpdateManual', "Automatic updates will be checked but not installed automatically.")); + break; + case 'start': + this.showMessage(localize('updateTooltip.autoUpdateStart', "Updates will be applied on restart.")); + break; + case 'default': + if (this.meteredConnectionService.isConnectionMetered) { + this.showMessage( + localize('updateTooltip.meteredConnectionMessage', "Automatic updates are paused because the network connection is metered."), + Codicon.radioTower); + } else { + this.showMessage( + localize('updateTooltip.autoUpdateDefault', "Automatic updates are enabled. Happy Coding!"), + Codicon.smiley); + } + break; + } + } + + private renderCheckingForUpdates() { + this.renderTitleAndInfo(localize('updateTooltip.checkingForUpdatesTitle', "Checking for Updates")); + this.showMessage(localize('updateTooltip.checkingPleaseWait', "Checking for updates, please wait...")); + } + + private renderAvailableForDownload({ update }: AvailableForDownload) { + this.renderTitleAndInfo(localize('updateTooltip.updateAvailableTitle', "Update Available"), update); + } + + private renderDownloading(state: Downloading) { + this.renderTitleAndInfo(localize('updateTooltip.downloadingUpdateTitle', "Downloading Update"), state.update); + + const { downloadedBytes, totalBytes } = state; + if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) { + const percentage = computeProgressPercent(downloadedBytes, totalBytes) ?? 0; + this.progressFill.style.width = `${percentage}%`; + this.progressPercentNode.textContent = `${percentage}%`; + this.progressSizeNode.textContent = `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`; + this.progressContainer.style.display = ''; + + const speed = computeDownloadSpeed(state); + if (speed !== undefined && speed > 0) { + this.speedInfoNode.textContent = localize('updateTooltip.downloadSpeed', '{0}/s', formatBytes(speed)); + } + + const timeRemaining = computeDownloadTimeRemaining(state); + if (timeRemaining !== undefined && timeRemaining > 0) { + this.timeRemainingNode.textContent = `~${formatTimeRemaining(timeRemaining)} ${localize('updateTooltip.timeRemaining', "remaining")}`; + } + + this.downloadStatsContainer.style.display = ''; + } else { + this.showMessage(localize('updateTooltip.downloadingPleaseWait', "Downloading update, please wait...")); + } + } + + private renderDownloaded({ update }: Downloaded) { + this.renderTitleAndInfo(localize('updateTooltip.updateReadyTitle', "Update is Ready to Install"), update); + } + + private renderUpdating({ update, currentProgress, maxProgress }: Updating) { + this.renderTitleAndInfo(localize('updateTooltip.installingUpdateTitle', "Installing Update"), update); + + const percentage = computeProgressPercent(currentProgress, maxProgress); + if (percentage !== undefined) { + this.progressFill.style.width = `${percentage}%`; + this.progressPercentNode.textContent = `${percentage}%`; + this.progressSizeNode.textContent = ''; + this.progressContainer.style.display = ''; + } else { + this.showMessage(localize('updateTooltip.installingPleaseWait', "Installing update, please wait...")); + } + } + + private renderReady({ update }: Ready) { + this.renderTitleAndInfo(localize('updateTooltip.updateInstalledTitle', "Update Installed"), update); + } + + private renderOverwriting({ update }: Overwriting) { + this.renderTitleAndInfo(localize('updateTooltip.downloadingNewerUpdateTitle', "Downloading Newer Update"), update); + this.showMessage(localize('updateTooltip.downloadingNewerPleaseWait', "A newer update was released. Downloading, please wait...")); + } + + private renderTitleAndInfo(title: string, update?: IUpdate) { + this.titleNode.textContent = title; + + // Latest version + const version = update?.productVersion; + if (version) { + const updateCommitId = update.version?.substring(0, 7); + this.latestVersionNode.textContent = updateCommitId + ? localize('updateTooltip.latestVersionLabelWithCommit', "Latest Version: {0} ({1})", version, updateCommitId) + : localize('updateTooltip.latestVersionLabel', "Latest Version: {0}", version); + this.latestVersionNode.style.display = ''; + } else { + this.latestVersionNode.style.display = 'none'; + } + + // Release date + const releaseDate = update?.timestamp ?? tryParseDate(this.productService.date); + if (typeof releaseDate === 'number' && releaseDate > 0) { + this.releaseDateNode.textContent = localize('updateTooltip.releasedLabel', "Released {0}", formatDate(releaseDate)); + this.releaseDateNode.style.display = ''; + } else { + this.releaseDateNode.style.display = 'none'; + } + + // Release notes link + this.releaseNotesVersion = version ?? this.productService.version; + this.releaseNotesLink.style.display = this.releaseNotesVersion ? '' : 'none'; + } + + private showMessage(message: string, icon?: ThemeIcon) { + dom.clearNode(this.messageNode); + if (icon) { + const iconNode = dom.append(this.messageNode, dom.$('.state-message-icon')); + iconNode.classList.add(...ThemeIcon.asClassNameArray(icon)); + } + dom.append(this.messageNode, document.createTextNode(message)); + this.messageNode.style.display = ''; + } + + private runCommandAndClose(command: string, ...args: unknown[]) { + this.commandService.executeCommand(command, ...args); + this.hoverService.hideHover(true); + } +} diff --git a/src/vs/workbench/contrib/update/common/updateUtils.ts b/src/vs/workbench/contrib/update/common/updateUtils.ts new file mode 100644 index 00000000000..3060873d29e --- /dev/null +++ b/src/vs/workbench/contrib/update/common/updateUtils.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { Downloading } from '../../../../platform/update/common/update.js'; + +/** + * Returns the progress percentage based on the current and maximum progress values. + */ +export function computeProgressPercent(current: number | undefined, max: number | undefined): number | undefined { + if (current === undefined || max === undefined || max <= 0) { + return undefined; + } + + return Math.max(Math.min(Math.round((current / max) * 100), 100), 0); +} + +/** + * Computes an estimate of remaining download time in seconds. + */ +export function computeDownloadTimeRemaining(state: Downloading): number | undefined { + const { downloadedBytes, totalBytes, startTime } = state; + if (downloadedBytes === undefined || totalBytes === undefined || startTime === undefined) { + return undefined; + } + + const elapsedMs = Date.now() - startTime; + if (downloadedBytes <= 0 || totalBytes <= 0 || elapsedMs <= 0) { + return undefined; + } + + const remainingBytes = totalBytes - downloadedBytes; + if (remainingBytes <= 0) { + return 0; + } + + const bytesPerMs = downloadedBytes / elapsedMs; + if (bytesPerMs <= 0) { + return undefined; + } + + const remainingMs = remainingBytes / bytesPerMs; + return Math.ceil(remainingMs / 1000); +} + +/** + * Computes the current download speed in bytes per second. + */ +export function computeDownloadSpeed(state: Downloading): number | undefined { + const { downloadedBytes, startTime } = state; + if (downloadedBytes === undefined || startTime === undefined) { + return undefined; + } + + const elapsedMs = Date.now() - startTime; + if (elapsedMs <= 0 || downloadedBytes <= 0) { + return undefined; + } + + return (downloadedBytes / elapsedMs) * 1000; +} + +/** + * Computes the version to use for fetching update info. + * - If the minor version differs: returns `{major}.{minor}` (e.g., 1.108.2 -> 1.109.5 => 1.109) + * - If the same minor: returns the target version as-is (e.g., 1.109.2 -> 1.109.5 => 1.109.5) + */ +export function computeUpdateInfoVersion(currentVersion: string, targetVersion: string): string | undefined { + const current = tryParseVersion(currentVersion); + const target = tryParseVersion(targetVersion); + if (!current || !target) { + return undefined; + } + + if (current.minor !== target.minor || current.major !== target.major) { + return `${target.major}.${target.minor}`; + } + + return `${target.major}.${target.minor}.${target.patch}`; +} + +/** + * Computes the URL to fetch update info from. + * Follows the release notes URL pattern but with `_update` suffix. + */ +export function getUpdateInfoUrl(version: string): string { + const versionLabel = version.replace(/\./g, '_').replace(/_0$/, ''); + return `https://code.visualstudio.com/raw/v${versionLabel}_update.md`; +} + +/** + * Formats the time remaining as a human-readable string. + */ +export function formatTimeRemaining(seconds: number): string { + const hours = seconds / 3600; + if (hours >= 1) { + const formattedHours = formatDecimal(hours); + if (formattedHours === '1') { + return localize('update.timeRemainingHour', "{0} hour", formattedHours); + } else { + return localize('update.timeRemainingHours', "{0} hours", formattedHours); + } + } + + const minutes = Math.floor(seconds / 60); + if (minutes >= 1) { + return localize('update.timeRemainingMinutes', "{0} min", minutes); + } + + return localize('update.timeRemainingSeconds', "{0}s", seconds); +} + +/** + * Formats a byte count as a human-readable string. + */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return localize('update.bytes', "{0} B", bytes); + } + + const kb = bytes / 1024; + if (kb < 1024) { + return localize('update.kilobytes', "{0} KB", formatDecimal(kb)); + } + + const mb = kb / 1024; + if (mb < 1024) { + return localize('update.megabytes', "{0} MB", formatDecimal(mb)); + } + + const gb = mb / 1024; + return localize('update.gigabytes', "{0} GB", formatDecimal(gb)); +} + +/** + * Tries to parse a date string and returns the timestamp or undefined if parsing fails. + */ +export function tryParseDate(date: string | undefined): number | undefined { + if (date === undefined) { + return undefined; + } + + try { + const parsed = Date.parse(date); + return isNaN(parsed) ? undefined : parsed; + } catch { + return undefined; + } +} + +/** + * Formats a timestamp as a localized date string. + */ +export function formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +} + +/** + * Formats a number to 1 decimal place, omitting ".0" for whole numbers. + */ +export function formatDecimal(value: number): string { + const rounded = Math.round(value * 10) / 10; + return rounded % 1 === 0 ? rounded.toString() : rounded.toFixed(1); +} + +export interface IVersion { + major: number; + minor: number; + patch: number; +} + +/** + * Parses a version string in the format "major.minor.patch" and returns an object with the components. + */ +export function tryParseVersion(version: string | undefined): IVersion | undefined { + if (version === undefined) { + return undefined; + } + + const match = /^(\d{1,10})\.(\d{1,10})\.(\d{1,10})/.exec(version); + if (!match) { + return undefined; + } + + try { + return { + major: parseInt(match[1]), + minor: parseInt(match[2]), + patch: parseInt(match[3]) + }; + } catch { + return undefined; + } +} + +/** + * Processes an error message and returns a user-friendly version of it, or undefined if the error should be ignored. + */ +export function preprocessError(error?: string): string | undefined { + if (!error) { + return undefined; + } + + if (/The request timed out|The network connection was lost/i.test(error)) { + return undefined; + } + + return error.replace( + /See https:\/\/github\.com\/Squirrel\/Squirrel\.Mac\/issues\/182 for more information/, + 'This might mean the application was put on quarantine by macOS. See [this link](https://github.com/microsoft/vscode/issues/7426#issuecomment-425093469) for more information' + ); +} diff --git a/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts b/src/vs/workbench/contrib/update/test/common/updateUtils.test.ts similarity index 56% rename from src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts rename to src/vs/workbench/contrib/update/test/common/updateUtils.test.ts index aa8c2a4693f..cfeb6123415 100644 --- a/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts +++ b/src/vs/workbench/contrib/update/test/common/updateUtils.test.ts @@ -7,9 +7,9 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Downloading, StateType } from '../../../../../platform/update/common/update.js'; -import { computeDownloadSpeed, computeDownloadTimeRemaining, formatBytes, formatDate, formatTimeRemaining, getProgressPercent, tryParseDate } from '../../browser/updateStatusBarEntry.js'; +import { computeDownloadSpeed, computeDownloadTimeRemaining, computeProgressPercent, computeUpdateInfoVersion, formatBytes, formatDate, formatTimeRemaining, getUpdateInfoUrl, tryParseDate } from '../../common/updateUtils.js'; -suite('UpdateStatusBarEntry', () => { +suite('UpdateUtils', () => { ensureNoDisposablesAreLeakedInTestSuite(); let clock: sinon.SinonFakeTimers; @@ -22,30 +22,30 @@ suite('UpdateStatusBarEntry', () => { clock.restore(); }); - function createDownloadingState(downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading { + function DownloadingState(downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading { return { type: StateType.Downloading, explicit: true, overwrite: false, downloadedBytes, totalBytes, startTime }; } - suite('getProgressPercent', () => { + suite('computeProgressPercent', () => { test('handles invalid values', () => { - assert.strictEqual(getProgressPercent(undefined, 100), undefined); - assert.strictEqual(getProgressPercent(50, undefined), undefined); - assert.strictEqual(getProgressPercent(undefined, undefined), undefined); - assert.strictEqual(getProgressPercent(50, 0), undefined); - assert.strictEqual(getProgressPercent(50, -10), undefined); + assert.strictEqual(computeProgressPercent(undefined, 100), undefined); + assert.strictEqual(computeProgressPercent(50, undefined), undefined); + assert.strictEqual(computeProgressPercent(undefined, undefined), undefined); + assert.strictEqual(computeProgressPercent(50, 0), undefined); + assert.strictEqual(computeProgressPercent(50, -10), undefined); }); test('computes correct percentage', () => { - assert.strictEqual(getProgressPercent(0, 100), 0); - assert.strictEqual(getProgressPercent(50, 100), 50); - assert.strictEqual(getProgressPercent(100, 100), 100); - assert.strictEqual(getProgressPercent(1, 3), 33); - assert.strictEqual(getProgressPercent(2, 3), 67); + assert.strictEqual(computeProgressPercent(0, 100), 0); + assert.strictEqual(computeProgressPercent(50, 100), 50); + assert.strictEqual(computeProgressPercent(100, 100), 100); + assert.strictEqual(computeProgressPercent(1, 3), 33); + assert.strictEqual(computeProgressPercent(2, 3), 67); }); test('clamps to 0-100 range', () => { - assert.strictEqual(getProgressPercent(-10, 100), 0); - assert.strictEqual(getProgressPercent(200, 100), 100); + assert.strictEqual(computeProgressPercent(-10, 100), 0); + assert.strictEqual(computeProgressPercent(200, 100), 100); }); }); @@ -54,42 +54,110 @@ suite('UpdateStatusBarEntry', () => { const now = Date.now(); // Missing parameters - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState()), undefined); - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, undefined, now)), undefined); - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(undefined, 1000, now)), undefined); - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 1000, undefined)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState()), undefined); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(500, undefined, now)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(undefined, 1000, now)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(500, 1000, undefined)), undefined); // Zero or negative values - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(0, 1000, now - 1000)), undefined); - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 0, now - 1000)), undefined); - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 1000, now + 1000)), undefined); - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(-100, 1000, now - 1000)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(0, 1000, now - 1000)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(500, 0, now - 1000)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(500, 1000, now + 1000)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(-100, 1000, now - 1000)), undefined); }); test('returns 0 when download is complete or over-downloaded', () => { const now = Date.now(); - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(1000, 1000, now - 1000)), 0); - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(1500, 1000, now - 1000)), 0); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(1000, 1000, now - 1000)), 0); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(1500, 1000, now - 1000)), 0); }); test('computes correct time remaining', () => { const now = Date.now(); // Simple case: Downloaded 500 bytes of 1000 in 1000ms => 1s remaining - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 1000, now - 1000)), 1); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(500, 1000, now - 1000)), 1); // 10 seconds remaining: Downloaded 100MB of 200MB in 10s const downloadedBytes = 100 * 1024 * 1024; const totalBytes = 200 * 1024 * 1024; - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(downloadedBytes, totalBytes, now - 10000)), 10); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(downloadedBytes, totalBytes, now - 10000)), 10); // Rounds up: 900 of 1000 bytes in 900ms => 100ms remaining => rounds to 1s - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(900, 1000, now - 900)), 1); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(900, 1000, now - 900)), 1); // Realistic scenario: 50MB of 100MB in 50s => 50s remaining const downloaded50MB = 50 * 1024 * 1024; const total100MB = 100 * 1024 * 1024; - assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(downloaded50MB, total100MB, now - 50000)), 50); + assert.strictEqual(computeDownloadTimeRemaining(DownloadingState(downloaded50MB, total100MB, now - 50000)), 50); + }); + }); + + + suite('computeDownloadSpeed', () => { + test('returns undefined for invalid or incomplete input', () => { + const now = Date.now(); + assert.strictEqual(computeDownloadSpeed(DownloadingState(undefined, 1000, now - 1000)), undefined); + assert.strictEqual(computeDownloadSpeed(DownloadingState(500, 1000, undefined)), undefined); + assert.strictEqual(computeDownloadSpeed(DownloadingState(undefined, undefined, undefined)), undefined); + }); + + test('returns undefined for zero or negative elapsed time', () => { + const now = Date.now(); + assert.strictEqual(computeDownloadSpeed(DownloadingState(500, 1000, now + 1000)), undefined); + }); + + test('returns undefined for zero downloaded bytes', () => { + const now = Date.now(); + assert.strictEqual(computeDownloadSpeed(DownloadingState(0, 1000, now - 1000)), undefined); + }); + + test('computes correct download speed in bytes per second', () => { + const now = Date.now(); + + // 1000 bytes in 1 second = 1000 B/s + const speed1 = computeDownloadSpeed(DownloadingState(1000, 2000, now - 1000)); + assert.ok(speed1 !== undefined); + assert.ok(Math.abs(speed1 - 1000) < 50); // Allow small timing variance + + // 10 MB in 10 seconds = 1 MB/s = 1048576 B/s + const tenMB = 10 * 1024 * 1024; + const speed2 = computeDownloadSpeed(DownloadingState(tenMB, tenMB * 2, now - 10000)); + assert.ok(speed2 !== undefined); + const expectedSpeed = 1024 * 1024; // 1 MB/s + assert.ok(Math.abs(speed2 - expectedSpeed) < expectedSpeed * 0.01); // Within 1% + }); + }); + + suite('computeUpdateInfoVersion', () => { + test('returns minor .0 version when minor differs', () => { + assert.strictEqual(computeUpdateInfoVersion('1.108.2', '1.109.5'), '1.109'); + assert.strictEqual(computeUpdateInfoVersion('1.108.0', '1.109.0'), '1.109'); + assert.strictEqual(computeUpdateInfoVersion('1.107.3', '1.110.1'), '1.110'); + }); + + test('returns target version as-is when same minor', () => { + assert.strictEqual(computeUpdateInfoVersion('1.109.2', '1.109.5'), '1.109.5'); + assert.strictEqual(computeUpdateInfoVersion('1.109.0', '1.109.3'), '1.109.3'); + }); + + test('returns minor .0 version when major differs', () => { + assert.strictEqual(computeUpdateInfoVersion('1.109.2', '2.0.1'), '2.0'); + }); + + test('returns undefined for invalid versions', () => { + assert.strictEqual(computeUpdateInfoVersion('invalid', '1.109.5'), undefined); + assert.strictEqual(computeUpdateInfoVersion('1.109.2', 'invalid'), undefined); + }); + }); + + suite('getUpdateInfoUrl', () => { + test('constructs correct URL for .0 versions', () => { + assert.strictEqual(getUpdateInfoUrl('1.109.0'), 'https://code.visualstudio.com/raw/v1_109_update.md'); + }); + + test('constructs correct URL for patch versions', () => { + assert.strictEqual(getUpdateInfoUrl('1.109.5'), 'https://code.visualstudio.com/raw/v1_109_5_update.md'); }); }); @@ -177,39 +245,4 @@ suite('UpdateStatusBarEntry', () => { assert.ok(result.includes('2024')); }); }); - - suite('computeDownloadSpeed', () => { - test('returns undefined for invalid or incomplete input', () => { - const now = Date.now(); - assert.strictEqual(computeDownloadSpeed(createDownloadingState(undefined, 1000, now - 1000)), undefined); - assert.strictEqual(computeDownloadSpeed(createDownloadingState(500, 1000, undefined)), undefined); - assert.strictEqual(computeDownloadSpeed(createDownloadingState(undefined, undefined, undefined)), undefined); - }); - - test('returns undefined for zero or negative elapsed time', () => { - const now = Date.now(); - assert.strictEqual(computeDownloadSpeed(createDownloadingState(500, 1000, now + 1000)), undefined); - }); - - test('returns undefined for zero downloaded bytes', () => { - const now = Date.now(); - assert.strictEqual(computeDownloadSpeed(createDownloadingState(0, 1000, now - 1000)), undefined); - }); - - test('computes correct download speed in bytes per second', () => { - const now = Date.now(); - - // 1000 bytes in 1 second = 1000 B/s - const speed1 = computeDownloadSpeed(createDownloadingState(1000, 2000, now - 1000)); - assert.ok(speed1 !== undefined); - assert.ok(Math.abs(speed1 - 1000) < 50); // Allow small timing variance - - // 10 MB in 10 seconds = 1 MB/s = 1048576 B/s - const tenMB = 10 * 1024 * 1024; - const speed2 = computeDownloadSpeed(createDownloadingState(tenMB, tenMB * 2, now - 10000)); - assert.ok(speed2 !== undefined); - const expectedSpeed = 1024 * 1024; // 1 MB/s - assert.ok(Math.abs(speed2 - expectedSpeed) < expectedSpeed * 0.01); // Within 1% - }); - }); }); diff --git a/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css b/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css index 3bd850fb25b..7ac6aaa7c37 100644 --- a/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css +++ b/src/vs/workbench/contrib/workspace/browser/media/workspaceTrustEditor.css @@ -4,10 +4,10 @@ *--------------------------------------------------------------------------------------------*/ .workspace-trust-editor { - max-width: 1000px; - padding-top: 11px; - margin: auto; - height: calc(100% - 11px); + max-width: 1600px; + padding-top: 12px; + margin: 0; + height: calc(100% - 12px); } .workspace-trust-editor:focus { @@ -15,12 +15,16 @@ } .workspace-trust-editor > .workspace-trust-header { - padding: 14px; + padding: 16px 24px; display: flex; flex-direction: column; align-items: center; } +.workspace-trust-editor > .workspace-trust-header:focus:not(:focus-visible) { + outline: none; +} + .workspace-trust-editor .workspace-trust-header .workspace-trust-title { font-size: 24px; font-weight: 600; @@ -35,7 +39,7 @@ } .workspace-trust-editor .workspace-trust-header .workspace-trust-title .workspace-trust-title-icon { - color: var(--workspace-trust-selected-color) !important; + color: var(--workspace-trust-selected-color); } .workspace-trust-editor .workspace-trust-header .workspace-trust-description { @@ -43,7 +47,8 @@ user-select: text; max-width: 600px; text-align: center; - padding: 14px 0; + padding: 8px 0; + line-height: 20px; } .workspace-trust-editor .workspace-trust-section-title { @@ -64,59 +69,67 @@ /** Features List */ .workspace-trust-editor .workspace-trust-features { - padding: 14px; + padding: 24px; cursor: default; user-select: text; display: flex; flex-direction: row; - flex-flow: wrap; - justify-content: space-evenly; + flex-wrap: wrap; + justify-content: center; + gap: 16px; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations { - min-height: 315px; border: 1px solid var(--workspace-trust-unselected-color); - margin: 4px 4px; + border-radius: var(--vscode-cornerRadius-medium); display: flex; flex-direction: column; - padding: 10px 40px; + padding: 8px 36px; } .workspace-trust-editor.trusted .workspace-trust-features .workspace-trust-limitations.trusted, .workspace-trust-editor.untrusted .workspace-trust-features .workspace-trust-limitations.untrusted { - border-width: 2px; - border-color: var(--workspace-trust-selected-color) !important; - padding: 9px 39px; - outline-offset: 2px; + outline: 2px solid var(--workspace-trust-selected-color); + outline-offset: -2px; +} + +.workspace-trust-editor .workspace-trust-features .workspace-trust-limitations:focus:not(:focus-visible) { + outline: none; +} + +.workspace-trust-editor .workspace-trust-features .workspace-trust-limitations:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations ul { list-style: none; padding-inline-start: 0px; + margin: 16px 0; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations li { display: flex; - padding-bottom: 10px; + padding-bottom: 4px; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations .list-item-icon { - padding-right: 5px; line-height: 24px; + padding: 0 6px 0 0; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations.trusted .list-item-icon { - color: var(--workspace-trust-check-color) !important; - font-size: 18px; + color: var(--workspace-trust-check-color); + font-size: 16px; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations.untrusted .list-item-icon { - color: var(--workspace-trust-x-color) !important; - font-size: 20px; + color: var(--workspace-trust-x-color); + font-size: 16px; } .workspace-trust-editor .workspace-trust-features .workspace-trust-limitations .list-item-text { - font-size: 16px; + font-size: 14px; line-height: 24px; } @@ -130,7 +143,7 @@ font-size: 16px; font-weight: 600; line-height: 24px; - padding: 10px 0px; + padding: 16px 0px; display: flex; } @@ -143,12 +156,13 @@ .workspace-trust-editor.trusted .workspace-trust-features .workspace-trust-limitations.trusted .workspace-trust-limitations-header .workspace-trust-limitations-title .workspace-trust-title-icon, .workspace-trust-editor.untrusted .workspace-trust-features .workspace-trust-limitations.untrusted .workspace-trust-limitations-header .workspace-trust-limitations-title .workspace-trust-title-icon { display: unset; - color: var(--workspace-trust-selected-color) !important; + color: var(--workspace-trust-selected-color); } .workspace-trust-editor .workspace-trust-features .workspace-trust-untrusted-description { font-style: italic; - padding-bottom: 10px; + color: var(--vscode-descriptionForeground); + padding-bottom: 8px; } /** Buttons Container */ @@ -170,7 +184,7 @@ } .workspace-trust-editor .workspace-trust-settings .trusted-uris-button-bar { - margin-top: 5px; + margin-top: 8px; } .workspace-trust-editor .workspace-trust-settings .trusted-uris-button-bar .monaco-button { @@ -183,7 +197,7 @@ .workspace-trust-editor .workspace-trust-features .workspace-trust-buttons-row .workspace-trust-buttons > .monaco-button, .workspace-trust-editor .workspace-trust-features .workspace-trust-buttons-row .workspace-trust-buttons > .monaco-button-dropdown, .workspace-trust-editor .workspace-trust-settings .trusted-uris-button-bar .monaco-button { - margin: 4px 5px; /* allows button focus outline to be visible */ + margin: 8px 4px; /* allows button focus outline to be visible */ } .workspace-trust-editor .workspace-trust-features .workspace-trust-buttons-row .workspace-trust-buttons .monaco-button-dropdown .monaco-dropdown-button { @@ -192,7 +206,7 @@ .workspace-trust-limitations { width: 50%; - max-width: 350px; + max-width: 400px; min-width: 250px; flex: 1; } @@ -208,7 +222,7 @@ } .workspace-trust-intro-dialog .workspace-trust-dialog-image-row.badge-row img { - max-height: 40px; + max-height: 36px; padding-right: 10px; } @@ -218,7 +232,9 @@ } .workspace-trust-editor .workspace-trust-settings { - padding: 20px 14px; + padding: 24px 36px; + border-top: 1px solid var(--vscode-editorWidget-border); + margin-top: 8px; } .workspace-trust-editor .workspace-trust-settings .workspace-trusted-folders-title { @@ -229,6 +245,10 @@ display: none; } +.workspace-trust-editor .trusted-uris-table { + margin-top: 16px; +} + .workspace-trust-editor .monaco-table-tr .monaco-table-td .path { width: 100%; } @@ -292,3 +312,28 @@ .workspace-trust-editor .workspace-trust-settings .monaco-list-row:hover .monaco-table-tr .monaco-table-td .actions .monaco-action-bar { display: flex; } + +/** Responsive: single-column layout for narrow widths */ +@container (max-width: 600px) { + .workspace-trust-editor .workspace-trust-features { + flex-direction: column; + align-items: center; + } + + .workspace-trust-limitations { + width: 100%; + max-width: 400px; + } +} + +@media (max-width: 600px) { + .workspace-trust-editor .workspace-trust-features { + flex-direction: column; + align-items: center; + } + + .workspace-trust-limitations { + width: 100%; + max-width: 400px; + } +} diff --git a/src/vs/workbench/electron-browser/desktop.contribution.ts b/src/vs/workbench/electron-browser/desktop.contribution.ts index fd09050e533..4c3893ac7ed 100644 --- a/src/vs/workbench/electron-browser/desktop.contribution.ts +++ b/src/vs/workbench/electron-browser/desktop.contribution.ts @@ -455,6 +455,10 @@ import product from '../../platform/product/common/product.js'; 'remote-debugging-port': { type: 'string', description: localize('argv.remoteDebuggingPort', "Specifies the port to use for remote debugging.") + }, + 'js-flags': { + type: 'string', + description: localize('argv.jsFlags', "Specifies V8 JavaScript engine flags to pass (e.g. \"--max-old-space-size=4096\"). These flags are applied to the main process, renderer and utility processes.") } } }; diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index 7f5c8e64d14..e9e6d9d2cca 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -52,6 +52,11 @@ const apiMenus: IAPIMenu[] = [ id: MenuId.EditorTitle, description: localize('menus.editorTitle', "The editor title menu") }, + { + key: 'modalEditor/editorTitle', + id: MenuId.ModalEditorEditorTitle, + description: localize('menus.modalEditorEditorTitle', "The editor title menu in the modal editor") + }, { key: 'editor/title/run', id: MenuId.EditorTitleRun, @@ -479,6 +484,12 @@ const apiMenus: IAPIMenu[] = [ description: localize('menus.chatEditingSessionChangesToolbar', "The Chat Editing widget toolbar menu for session changes."), proposed: 'chatSessionsProvider' }, + { + key: 'chat/input/editing/sessionApplyActions', + id: MenuId.ChatEditingSessionApplySubmenu, + description: localize('menus.chatEditingSessionApplySubmenu', "Submenu for apply actions in the Chat Editing session changes toolbar."), + proposed: 'chatSessionsProvider' + }, { // TODO: rename this to something like: `chatSessions/item/inline` key: 'chat/chatSessions', diff --git a/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts b/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts index 4a2ca0e6284..2528c9f41ae 100644 --- a/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts +++ b/src/vs/workbench/services/authentication/test/browser/authenticationQueryServiceMocks.ts @@ -64,7 +64,7 @@ export abstract class BaseTestService extends Disposable { /** * Track a method call for verification in tests */ - protected trackCall(method: string, ...args: any[]): void { + protected trackCall(method: string, ...args: unknown[]): void { this._methodCalls.push({ method, args: [...args], diff --git a/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts b/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts index f50672fd913..12dcd6a2b03 100644 --- a/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts +++ b/src/vs/workbench/services/browserView/electron-browser/playwrightWorkbenchService.ts @@ -3,7 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { mainWindow } from '../../../../base/browser/window.js'; +import { IChannel, ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IPlaywrightService } from '../../../../platform/browserView/common/playwrightService.js'; import { registerSharedProcessRemoteService } from '../../../../platform/ipc/electron-browser/services.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; -registerSharedProcessRemoteService(IPlaywrightService, 'playwright'); +class PlaywrightChannelClient { + constructor( + channel: IChannel, + @ILogService logService: ILogService + ) { + /** + * send the current window's ID once via `__initialize`, so the server-side {@link PlaywrightChannel} + * can create a per-window {@link PlaywrightWindowInstance}. All subsequent calls and events are proxied directly. + */ + void channel.call('__initialize', mainWindow.vscodeWindowId).catch((e) => { + logService.error(`Failed to initialize Playwright service`, e); + }); + return ProxyChannel.toService(channel); + } +} + +registerSharedProcessRemoteService(IPlaywrightService, 'playwright', { channelClientCtor: PlaywrightChannelClient }); diff --git a/src/vs/workbench/services/configuration/browser/configurationService.ts b/src/vs/workbench/services/configuration/browser/configurationService.ts index d9491186439..a9813b52755 100644 --- a/src/vs/workbench/services/configuration/browser/configurationService.ts +++ b/src/vs/workbench/services/configuration/browser/configurationService.ts @@ -47,6 +47,8 @@ import { IBrowserWorkbenchEnvironmentService } from '../../environment/browser/e import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { runWhenWindowIdle } from '../../../../base/browser/dom.js'; +import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js'; +import { fixSettingLinks } from '../../preferences/common/preferencesModels.js'; function getLocalUserConfigurationScopes(userDataProfile: IUserDataProfile, hasRemote: boolean): ConfigurationScope[] | undefined { const isDefaultProfile = userDataProfile.isDefault || userDataProfile.useDefaultFlags?.settings; @@ -1176,6 +1178,15 @@ class RegisterConfigurationSchemasContribution extends Disposable implements IWo } private registerConfigurationSchemas(): void { + // Ensure deprecationMessage is plain text for properties where it was derived from + // markdownDeprecationMessage, since the JSON editor diagnostics don't support markdown. + for (const key of Object.keys(allSettings.properties)) { + const prop = allSettings.properties[key]; + if (prop.markdownDeprecationMessage && prop.deprecationMessage === prop.markdownDeprecationMessage) { + prop.deprecationMessage = renderAsPlaintext({ value: fixSettingLinks(prop.markdownDeprecationMessage) }); + } + } + const allSettingsSchema: IJSONSchema = { properties: allSettings.properties, patternProperties: allSettings.patternProperties, diff --git a/src/vs/workbench/services/editor/browser/editorService.ts b/src/vs/workbench/services/editor/browser/editorService.ts index 839e48159e6..29189be6086 100644 --- a/src/vs/workbench/services/editor/browser/editorService.ts +++ b/src/vs/workbench/services/editor/browser/editorService.ts @@ -579,6 +579,16 @@ export class EditorService extends Disposable implements EditorServiceImpl { } } + // Modal group: override `preserveFocus` to move focus into the modal because there is nothing to preserve if this is the first modal editor + if ( + options?.preserveFocus && + this.editorGroupService.activeModalEditorPart?.groups.some(modalGroup => modalGroup.id === group.id) && + this.editorGroupService.activeModalEditorPart.count === 1 && + this.editorGroupService.activeModalEditorPart.groups[0].isEmpty + ) { + options = { ...options, preserveFocus: false }; + } + return group.openEditor(typedEditor, options); } @@ -637,6 +647,16 @@ export class EditorService extends Disposable implements EditorServiceImpl { } } + // Modal group: override `preserveFocus` to move focus into the modal there is nothing to preserve if this is the first modal editor + if ( + typedEditor.options?.preserveFocus && + this.editorGroupService.activeModalEditorPart?.groups.some(modalGroup => modalGroup.id === group.id) && + this.editorGroupService.activeModalEditorPart.count === 1 && + this.editorGroupService.activeModalEditorPart.groups[0].isEmpty + ) { + typedEditor = { ...typedEditor, options: { ...typedEditor.options, preserveFocus: false } }; + } + // Update map of groups to editors let targetGroupEditors = mapGroupToTypedEditors.get(group); if (!targetGroupEditors) { diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index 9fcff97208a..0e25a7e3803 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -543,6 +543,16 @@ export interface IModalEditorPart extends IEditorPart { */ toggleMaximized(): void; + /** + * Size set by the user via resizing, if any. + */ + readonly size: IDimension | undefined; + + /** + * Position set by the user via dragging, if any. + */ + readonly position: { left: number; top: number } | undefined; + /** * The current navigation context, if any. */ diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts index 4b65d8b11ce..7d37cde03e3 100644 --- a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -14,10 +14,14 @@ import { MockScopableContextKeyService } from '../../../../../platform/keybindin import { SideBySideEditorInput } from '../../../../common/editor/sideBySideEditorInput.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; -import { MODAL_GROUP, MODAL_GROUP_TYPE } from '../../common/editorService.js'; +import { IEditorService, MODAL_GROUP, MODAL_GROUP_TYPE } from '../../common/editorService.js'; import { findGroup } from '../../common/editorGroupFinder.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { EditorService } from '../../browser/editorService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { Memento } from '../../../../common/memento.js'; suite('Modal Editor Group', () => { @@ -611,6 +615,64 @@ suite('Modal Editor Group', () => { assert.strictEqual(parts.activeModalEditorPart, undefined); }); + + test('shows tabs when multiple editors are open', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const configurationService = new TestConfigurationService(); + await configurationService.setUserConfiguration('workbench.editor.useModal', 'all'); + instantiationService.stub(IConfigurationService, configurationService); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const editorService = disposables.add(instantiationService.createInstance(EditorService, undefined)); + instantiationService.stub(IEditorService, editorService); + + const input1 = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await editorService.openEditor(input1, { pinned: true }, MODAL_GROUP); + + const modalPart = parts.activeModalEditorPart!; + assert.ok(modalPart); + + // With 1 editor, tabs should be hidden + assert.strictEqual(modalPart.partOptions.showTabs, 'none'); + + // Open a second editor + const input2 = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + await editorService.openEditor(input2, { pinned: true }, MODAL_GROUP); + + // With 2 editors, tabs should be visible + assert.strictEqual(modalPart.partOptions.showTabs, 'multiple'); + + modalPart.close(); + }); + + test('hides tabs when not in all mode even with multiple editors', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const configurationService = new TestConfigurationService(); + await configurationService.setUserConfiguration('workbench.editor.useModal', 'some'); + instantiationService.stub(IConfigurationService, configurationService); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const editorService = disposables.add(instantiationService.createInstance(EditorService, undefined)); + instantiationService.stub(IEditorService, editorService); + + const input1 = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await editorService.openEditor(input1, { pinned: true }, MODAL_GROUP); + + const modalPart = parts.activeModalEditorPart!; + assert.ok(modalPart); + + const input2 = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + await editorService.openEditor(input2, { pinned: true }, MODAL_GROUP); + + // With 'some' mode, tabs should remain hidden even with multiple editors + assert.strictEqual(modalPart.partOptions.showTabs, 'none'); + + modalPart.close(); + }); }); test('modal editor part editors can be moved to another group', async () => { @@ -643,5 +705,66 @@ suite('Modal Editor Group', () => { assert.strictEqual(parts.activeModalEditorPart, undefined); }); + test('openEditor with MODAL_GROUP ignores preserveFocus', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const editorService = disposables.add(instantiationService.createInstance(EditorService, undefined)); + instantiationService.stub(IEditorService, editorService); + + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + const pane = await editorService.openEditor(input, { pinned: true, preserveFocus: true }, MODAL_GROUP); + + assert.ok(pane); + assert.strictEqual(pane.options?.preserveFocus, false); + + parts.activeModalEditorPart?.close(); + }); + + test('modal editor part state is remembered on close and reused on next open', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Create maximized modal and close it + const modalPart1 = await parts.createModalEditorPart({ maximized: true }); + modalPart1.close(); + + // Create a new modal — it should restore maximized state + const modalPart2 = await parts.createModalEditorPart(); + assert.strictEqual(modalPart2.maximized, true); + + modalPart2.close(); + }); + + test('modal editor part state restores from profile storage', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const storageService = instantiationService.get(IStorageService) as TestStorageService; + + // Pre-populate storage with modal state and clear memento cache + // so the next EditorParts instance reads fresh from storage + storageService.store('memento/workbench.editorParts', JSON.stringify({ + 'editorparts.modalState': { + maximized: true, + size: { width: 500, height: 400 }, + position: { left: 100, top: 50 } + } + }), StorageScope.PROFILE, StorageTarget.MACHINE); + Memento.clear(StorageScope.PROFILE); + + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Create modal — it should use state from storage + const modalPart = await parts.createModalEditorPart(); + assert.strictEqual(modalPart.maximized, true); + + modalPart.close(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/workbench/services/environment/browser/environmentService.ts b/src/vs/workbench/services/environment/browser/environmentService.ts index e13904691e8..a42fb531bdf 100644 --- a/src/vs/workbench/services/environment/browser/environmentService.ts +++ b/src/vs/workbench/services/environment/browser/environmentService.ts @@ -322,6 +322,13 @@ export class BrowserWorkbenchEnvironmentService implements IBrowserWorkbenchEnvi case 'inspect-extensions': extensionHostDebugEnvironment.params.port = parseInt(value); break; + case 'extensionEnvironment': + try { + extensionHostDebugEnvironment.params.env = JSON.parse(value); + } catch (error) { + onUnexpectedError(error); + } + break; case 'enableProposedApi': extensionHostDebugEnvironment.extensionEnabledProposedApi = []; break; diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index 2ea95cdb8c4..f9d57d5a53e 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -179,8 +179,8 @@ export const schema: IJSONSchema = { properties: { 'vscode': { type: 'string', - description: nls.localize('vscode.extension.engines.vscode', 'For VS Code extensions, specifies the VS Code version that the extension is compatible with. Cannot be *. For example: ^0.10.5 indicates compatibility with a minimum VS Code version of 0.10.5.'), - default: '^1.22.0', + description: nls.localize('vscode.extension.engines.vscode', 'For VS Code extensions, specifies the VS Code version that the extension is compatible with. Cannot be *. For example: ^1.105.0 indicates compatibility with a minimum VS Code version of 1.105.0.'), + default: '^1.105.0', } } }, diff --git a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts index af93d8558de..93fac9a4889 100644 --- a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts +++ b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts @@ -268,7 +268,7 @@ export class NativeLocalProcessExtensionHost extends Disposable implements IExte const inspectorUrlMatch = output.data && output.data.match(/ws:\/\/([^\s]+):(\d+)\/([^\s]+)/); if (inspectorUrlMatch) { const [, host, port, auth] = inspectorUrlMatch; - const devtoolsUrl = `devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=${host}:${port}/${auth}`; + const devtoolsUrl = `devtools://devtools/bundled/js_app.html?v8only=true&ws=${host}:${port}/${auth}`; if (!this._environmentService.isBuilt && !this._isExtensionDevTestFromCli) { console.debug(`%c[Extension Host] %cdebugger inspector at ${devtoolsUrl}`, 'color: blue', 'color:'); } diff --git a/src/vs/workbench/services/preferences/browser/preferencesService.ts b/src/vs/workbench/services/preferences/browser/preferencesService.ts index a1ca22b3c4a..f871d6dd5d9 100644 --- a/src/vs/workbench/services/preferences/browser/preferencesService.ts +++ b/src/vs/workbench/services/preferences/browser/preferencesService.ts @@ -48,6 +48,7 @@ import { IURLService } from '../../../../platform/url/common/url.js'; import { compareIgnoreCase } from '../../../../base/common/strings.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; +import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; const emptyEditableSettingsContent = '{\n}'; @@ -90,7 +91,8 @@ export class PreferencesService extends Disposable implements IPreferencesServic @ITextEditorService private readonly textEditorService: ITextEditorService, @IURLService urlService: IURLService, @IExtensionService private readonly extensionService: IExtensionService, - @IProgressService private readonly progressService: IProgressService + @IProgressService private readonly progressService: IProgressService, + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, ) { super(); // The default keybindings.json updates based on keyboard layouts, so here we make sure @@ -273,7 +275,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic ...options, focusSearch: true }; - const group = this.getEditorGroupFromOptions(false, options); + const group = this.getEditorGroupFromOptions(options); return this.editorService.openEditor(input, validateSettingsEditorOptions(options), group); } @@ -354,11 +356,11 @@ export class PreferencesService extends Disposable implements IPreferencesServic this.editorService.openEditor({ resource: editableKeybindings, options }, sideEditorGroup.id) ]); } else { - await this.editorService.openEditor({ resource: editableKeybindings, options }, options.groupId); + await this.editorService.openEditor({ resource: editableKeybindings, options }, this.getEditorGroupFromOptions(options)); } } else { - const group = this.getEditorGroupFromOptions(false, options); + const group = this.getEditorGroupFromOptions(options); const editor = (await this.editorService.openEditor(this.instantiationService.createInstance(KeybindingsEditorInput), { ...options }, group)) as IKeybindingsEditorPane; if (options.query) { editor.search(options.query); @@ -371,8 +373,11 @@ export class PreferencesService extends Disposable implements IPreferencesServic return this.editorService.openEditor({ resource: this.defaultKeybindingsResource, label: nls.localize('defaultKeybindings', "Default Keybindings") }); } - private getEditorGroupFromOptions(isTextual: boolean, options: { groupId?: number; openToSide?: boolean }): PreferredGroup { - if (!isTextual && this.configurationService.getValue('workbench.editor.useModal') !== 'off') { + private getEditorGroupFromOptions(options: { groupId?: number; openToSide?: boolean }): PreferredGroup { + if ( + this.configurationService.getValue('workbench.editor.useModal') !== 'off' && // modal editors enabled in settings + !this.environmentService.enableSmokeTestDriver && !this.environmentService.extensionTestsLocationURI // but not in smoke test or extension test environments to reduce flakiness + ) { return MODAL_GROUP; } if (options.openToSide) { @@ -385,7 +390,7 @@ export class PreferencesService extends Disposable implements IPreferencesServic } private async openSettingsJson(resource: URI, options: IOpenSettingsOptions): Promise { - const group = this.getEditorGroupFromOptions(true, options); + const group = this.getEditorGroupFromOptions(options); const editor = await this.doOpenSettingsJson(resource, options, group); if (editor && options?.revealSetting) { await this.revealSetting(options.revealSetting.key, !!options.revealSetting.edit, editor, resource); diff --git a/src/vs/workbench/services/preferences/common/preferencesModels.ts b/src/vs/workbench/services/preferences/common/preferencesModels.ts index 84fa12bc44d..1d399cefc95 100644 --- a/src/vs/workbench/services/preferences/common/preferencesModels.ts +++ b/src/vs/workbench/services/preferences/common/preferencesModels.ts @@ -28,6 +28,14 @@ import { isString } from '../../../../base/common/types.js'; export const nullRange: IRange = { startLineNumber: -1, startColumn: -1, endLineNumber: -1, endColumn: -1 }; function isNullRange(range: IRange): boolean { return range.startLineNumber === -1 && range.startColumn === -1 && range.endLineNumber === -1 && range.endColumn === -1; } +/** + * Strips VS Code's custom `#settingId#` link syntax from a markdown string so the setting key + * remains as inline code (e.g. `` `settingId` ``). Useful for contexts that don't render markdown links. + */ +export function fixSettingLinks(text: string): string { + return text.replace(/`#([^#`]*)#`/g, (_, settingName) => `\`${settingName}\``); +} + abstract class AbstractSettingsModel extends EditorModel { protected _currentResultGroups = new Map(); @@ -1072,13 +1080,11 @@ class SettingsContentBuilder { } private pushSettingDescription(setting: ISetting, indent: string): void { - const fixSettingLink = (line: string) => line.replace(/`#(.*)#`/g, (match, settingName) => `\`${settingName}\``); - setting.descriptionRanges = []; const descriptionPreValue = indent + '// '; const deprecationMessageLines = setting.deprecationMessage?.split(/\n/g) ?? []; for (let line of [...deprecationMessageLines, ...setting.description]) { - line = fixSettingLink(line); + line = fixSettingLinks(line); this._contentByLines.push(descriptionPreValue + line); setting.descriptionRanges.push({ startLineNumber: this.lineCountWithOffset, startColumn: this.lastLine.indexOf(line) + 1, endLineNumber: this.lineCountWithOffset, endColumn: this.lastLine.length }); @@ -1088,7 +1094,7 @@ class SettingsContentBuilder { setting.enumDescriptions.forEach((desc, i) => { const displayEnum = escapeInvisibleChars(String(setting.enum![i])); const line = desc ? - `${displayEnum}: ${fixSettingLink(desc)}` : + `${displayEnum}: ${fixSettingLinks(desc)}` : displayEnum; const lines = line.split(/\n/g); diff --git a/src/vs/workbench/services/userAttention/test/browser/userAttentionService.test.ts b/src/vs/workbench/services/userAttention/test/browser/userAttentionService.test.ts index 07886188bb6..287550a7532 100644 --- a/src/vs/workbench/services/userAttention/test/browser/userAttentionService.test.ts +++ b/src/vs/workbench/services/userAttention/test/browser/userAttentionService.test.ts @@ -44,7 +44,7 @@ suite('UserAttentionService', () => { }; const originalCreateInstance = insta.createInstance; - sinon.stub(insta, 'createInstance').callsFake((ctor: any, ...args: any[]) => { + sinon.stub(insta, 'createInstance').callsFake((ctor: any, ...args: unknown[]) => { if (ctor === UserAttentionServiceEnv) { return hostAdapterMock; } diff --git a/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts new file mode 100644 index 00000000000..2b098ab3158 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts @@ -0,0 +1,709 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../base/common/htmlContent.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { FuzzyScore } from '../../../../base/common/filters.js'; +import { ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js'; +import { EditorMarkdownCodeBlockRenderer } from '../../../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; +import { AgentSessionRenderer, AgentSessionSectionRenderer, IAgentSessionRendererOptions } from '../../../contrib/chat/browser/agentSessions/agentSessionsViewer.js'; +import { AgentSessionStatus, IAgentSession, AgentSessionSection, IAgentSessionSection } from '../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { AgentSessionProviders } from '../../../contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; + +import '../../../contrib/chat/browser/agentSessions/media/agentsessionsviewer.css'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +function createMockSession(overrides: Partial & { label: string; status: AgentSessionStatus; providerType: string }): IAgentSession { + const now = Date.now(); + return new class extends mock() { + override readonly resource = overrides.resource ?? URI.parse(`vscode-chat-session://${overrides.providerType}/session-${Math.random().toString(36).slice(2)}`); + override readonly label = overrides.label; + override readonly status = overrides.status; + override readonly providerType = overrides.providerType; + override readonly providerLabel = overrides.providerLabel ?? overrides.providerType; + override readonly icon = overrides.icon ?? Codicon.vm; + override readonly badge = overrides.badge; + override readonly description = overrides.description; + override readonly tooltip = overrides.tooltip; + override readonly changes = overrides.changes; + override readonly timing = overrides.timing ?? { + created: now - 60 * 60 * 1000, + lastRequestStarted: undefined, + lastRequestEnded: undefined, + }; + override isArchived(): boolean { return overrides.isArchived?.() ?? false; } + override setArchived(): void { } + override isRead(): boolean { return overrides.isRead?.() ?? true; } + override setRead(): void { } + }(); +} + +function wrapAsTreeNode(element: T): ITreeNode { + return { + element, + children: [], + depth: 0, + visibleChildrenCount: 0, + visibleChildIndex: 0, + collapsible: false, + collapsed: false, + visible: true, + filterData: undefined, + }; +} + +const rendererOptions: IAgentSessionRendererOptions = { + disableHover: true, + getHoverPosition: () => HoverPosition.BELOW, +}; + +// ============================================================================ +// Render helpers +// ============================================================================ + +function createMockApprovalModel(sessionResource: URI, info: IAgentSessionApprovalInfo): AgentSessionApprovalModel { + const obs = observableValue('mockApproval', info); + return new class extends mock() { + override getApproval(resource: URI) { + if (resource.toString() === sessionResource.toString()) { + return obs; + } + return observableValue('mockApproval.empty', undefined); + } + }(); +} + +function renderSessionItem(ctx: ComponentFixtureContext, session: IAgentSession, approvalModel?: AgentSessionApprovalModel): void { + const { container, disposableStore } = ctx; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.define(IMarkdownRendererService, MarkdownRendererService); + reg.defineInstance(IProductService, new class extends mock() { + override readonly urlProtocol = 'vscode'; + }()); + }, + }); + + const configService = instantiationService.get(IConfigurationService) as TestConfigurationService; + configService.setUserConfiguration('editor', { fontFamily: 'monospace' }); + const markdownRendererService = instantiationService.get(IMarkdownRendererService); + markdownRendererService.setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer)); + + const renderer = disposableStore.add( + instantiationService.createInstance(AgentSessionRenderer, rendererOptions, approvalModel ?? undefined, observableValue('activeSessionResource', undefined)) + ); + + container.style.width = '350px'; + container.style.height = 'auto'; + container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + container.classList.add('agent-sessions-viewer'); + + const listRow = document.createElement('div'); + listRow.classList.add('monaco-list-row'); + listRow.style.position = 'relative'; + container.appendChild(listRow); + + const template = renderer.renderTemplate(listRow); + renderer.renderElement(wrapAsTreeNode(session), 0, template); +} + +function renderSectionItem(ctx: ComponentFixtureContext, section: IAgentSessionSection): void { + const { container, disposableStore } = ctx; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + }, + }); + + const renderer = instantiationService.createInstance(AgentSessionSectionRenderer); + + container.style.width = '350px'; + container.style.height = 'auto'; + container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + container.classList.add('agent-sessions-viewer'); + + const listRow = document.createElement('div'); + listRow.classList.add('monaco-list-row'); + listRow.style.position = 'relative'; + container.appendChild(listRow); + + const template = renderer.renderTemplate(listRow); + renderer.renderElement(wrapAsTreeNode(section), 0, template); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +const now = Date.now(); + +export default defineThemedFixtureGroup({ + + // --- Status variants --- + + CompletedRead: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Refactor auth middleware', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 2 * 60 * 60 * 1000, + lastRequestStarted: now - 2 * 60 * 60 * 1000, + lastRequestEnded: now - 2 * 60 * 60 * 1000 + 45 * 1000, + }, + })), + }), + + CompletedUnread: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Add unit tests for parser', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + isRead: () => false, + timing: { + created: now - 30 * 60 * 1000, + lastRequestStarted: now - 30 * 60 * 1000, + lastRequestEnded: now - 25 * 60 * 1000, + }, + })), + }), + + InProgress: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Implement dark mode toggle', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 5 * 60 * 1000, + lastRequestStarted: now - 2 * 60 * 1000, + lastRequestEnded: undefined, + }, + })), + }), + + NeedsInput: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Fix CI pipeline configuration', + status: AgentSessionStatus.NeedsInput, + providerType: AgentSessionProviders.Local, + isRead: () => false, + timing: { + created: now - 10 * 60 * 1000, + lastRequestStarted: now - 8 * 60 * 1000, + lastRequestEnded: undefined, + }, + })), + }), + + FailedWithDuration: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Deploy staging environment', + status: AgentSessionStatus.Failed, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 60 * 60 * 1000, + lastRequestStarted: now - 60 * 60 * 1000, + lastRequestEnded: now - 60 * 60 * 1000 + 3 * 60 * 1000, + }, + })), + }), + + FailedWithoutDuration: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Migrate database schema', + status: AgentSessionStatus.Failed, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 3 * 60 * 60 * 1000, + lastRequestStarted: undefined, + lastRequestEnded: undefined, + }, + })), + }), + + // --- Content variants --- + + WithDiffChanges: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Refactor settings page', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + changes: { files: 5, insertions: 142, deletions: 87 }, + timing: { + created: now - 45 * 60 * 1000, + lastRequestStarted: now - 45 * 60 * 1000, + lastRequestEnded: now - 40 * 60 * 1000, + }, + })), + }), + + WithFileChangesList: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Update API endpoints', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Background, + icon: Codicon.worktree, + changes: [ + { modifiedUri: URI.file('/src/api/routes.ts'), insertions: 25, deletions: 10 }, + { modifiedUri: URI.file('/src/api/handlers.ts'), insertions: 50, deletions: 30 }, + { modifiedUri: URI.file('/tests/api.test.ts'), insertions: 40, deletions: 5 }, + ], + timing: { + created: now - 2 * 60 * 60 * 1000, + lastRequestStarted: now - 2 * 60 * 60 * 1000, + lastRequestEnded: now - 90 * 60 * 1000, + }, + })), + }), + + WithBadge: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Optimize build pipeline', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + badge: 'PR #1234', + timing: { + created: now - 4 * 60 * 60 * 1000, + lastRequestStarted: now - 4 * 60 * 60 * 1000, + lastRequestEnded: now - 3.5 * 60 * 60 * 1000, + }, + })), + }), + + WithMarkdownBadge: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Review security patches', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Cloud, + icon: Codicon.cloud, + badge: new MarkdownString('$(shield) Secure'), + timing: { + created: now - 6 * 60 * 60 * 1000, + lastRequestStarted: now - 6 * 60 * 60 * 1000, + lastRequestEnded: now - 5.5 * 60 * 60 * 1000, + }, + })), + }), + + WithDescription: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Upgrade dependencies', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + description: 'Updated 12 packages to latest versions', + timing: { + created: now - 24 * 60 * 60 * 1000, + lastRequestStarted: now - 24 * 60 * 60 * 1000, + lastRequestEnded: now - 23.5 * 60 * 60 * 1000, + }, + })), + }), + + WithMarkdownDescription: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Fix accessibility issues', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + description: new MarkdownString('$(check) All WCAG checks passed'), + timing: { + created: now - 48 * 60 * 60 * 1000, + lastRequestStarted: now - 48 * 60 * 60 * 1000, + lastRequestEnded: now - 47 * 60 * 60 * 1000, + }, + })), + }), + + WithBadgeAndDiff: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Implement search feature', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + badge: 'draft', + changes: { files: 8, insertions: 320, deletions: 45 }, + timing: { + created: now - 3 * 60 * 60 * 1000, + lastRequestStarted: now - 3 * 60 * 60 * 1000, + lastRequestEnded: now - 2.5 * 60 * 60 * 1000, + }, + })), + }), + + // --- State variants --- + + Archived: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Old migration script', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + isArchived: () => true, + timing: { + created: now - 7 * 24 * 60 * 60 * 1000, + lastRequestStarted: now - 7 * 24 * 60 * 60 * 1000, + lastRequestEnded: now - 7 * 24 * 60 * 60 * 1000 + 10 * 60 * 1000, + }, + })), + }), + + ArchivedUnread: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Archived unread task', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Local, + isArchived: () => true, + isRead: () => false, + timing: { + created: now - 5 * 24 * 60 * 60 * 1000, + lastRequestStarted: now - 5 * 24 * 60 * 60 * 1000, + lastRequestEnded: now - 5 * 24 * 60 * 60 * 1000 + 5 * 60 * 1000, + }, + })), + }), + + // --- Provider-type variants --- + + CloudProvider: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Generate API documentation', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Cloud, + icon: Codicon.cloud, + timing: { + created: now - 90 * 60 * 1000, + lastRequestStarted: now - 90 * 60 * 1000, + lastRequestEnded: now - 80 * 60 * 1000, + }, + })), + }), + + BackgroundProvider: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Run linter across codebase', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Background, + icon: Codicon.worktree, + timing: { + created: now - 120 * 60 * 1000, + lastRequestStarted: now - 120 * 60 * 1000, + lastRequestEnded: now - 110 * 60 * 1000, + }, + })), + }), + + ClaudeProvider: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Analyze code complexity', + status: AgentSessionStatus.Completed, + providerType: AgentSessionProviders.Claude, + icon: Codicon.claude, + timing: { + created: now - 150 * 60 * 1000, + lastRequestStarted: now - 150 * 60 * 1000, + lastRequestEnded: now - 140 * 60 * 1000, + }, + })), + }), + + CloudProviderInProgress: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Build integration tests', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Cloud, + icon: Codicon.cloud, + isRead: () => false, + timing: { + created: now - 10 * 60 * 1000, + lastRequestStarted: now - 3 * 60 * 1000, + lastRequestEnded: undefined, + }, + })), + }), + + // --- In-progress with description override --- + + InProgressWithDescription: defineComponentFixture({ + render: (ctx) => renderSessionItem(ctx, createMockSession({ + label: 'Scaffold new microservice', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Background, + icon: Codicon.worktree, + description: 'Installing dependencies...', + timing: { + created: now - 5 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + })), + }), + + // --- Section headers --- + + SectionToday: defineComponentFixture({ + render: (ctx) => renderSectionItem(ctx, { + section: AgentSessionSection.Today, + label: 'Today', + sessions: [], + }), + }), + + SectionYesterday: defineComponentFixture({ + render: (ctx) => renderSectionItem(ctx, { + section: AgentSessionSection.Yesterday, + label: 'Yesterday', + sessions: [], + }), + }), + + SectionLastWeek: defineComponentFixture({ + render: (ctx) => renderSectionItem(ctx, { + section: AgentSessionSection.Week, + label: 'Last 7 days', + sessions: [], + }), + }), + + SectionOlder: defineComponentFixture({ + render: (ctx) => renderSectionItem(ctx, { + section: AgentSessionSection.Older, + label: 'Older', + sessions: [], + }), + }), + + SectionArchived: defineComponentFixture({ + render: (ctx) => renderSectionItem(ctx, { + section: AgentSessionSection.Archived, + label: 'Archived', + sessions: [], + }), + }), + + SectionMore: defineComponentFixture({ + render: (ctx) => renderSectionItem(ctx, { + section: AgentSessionSection.More, + label: 'More', + sessions: [], + }), + }), + + // --- Approval row variants --- + + ApprovalRowJson: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-json'); + const approvalModel = createMockApprovalModel(resource, { + label: '{ "action": "deleteFile", "path": "/src/old-module.ts" }', + languageId: 'json', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Clean up deprecated modules', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 5 * 60 * 1000, + lastRequestStarted: now - 2 * 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRowBash: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-bash'); + const approvalModel = createMockApprovalModel(resource, { + label: 'npm install --save express@latest', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Update server dependencies', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 3 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRowPowerShell: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-powershell'); + const approvalModel = createMockApprovalModel(resource, { + label: 'Start-Job -ScriptBlock { Set-Location \'c:\\some\\path\'; npm install } | Out-Null', + languageId: 'pwsh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Clean up old log files', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 4 * 60 * 1000, + lastRequestStarted: now - 2 * 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRowLongLabel: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-long'); + const approvalModel = createMockApprovalModel(resource, { + label: 'rm -rf node_modules && npm cache clean --force && npm install --legacy-peer-deps --ignore-scripts', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Reset and reinstall all dependencies', + status: AgentSessionStatus.NeedsInput, + providerType: AgentSessionProviders.Cloud, + icon: Codicon.cloud, + isRead: () => false, + timing: { + created: now - 10 * 60 * 1000, + lastRequestStarted: now - 5 * 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRow1Line: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-1line'); + const approvalModel = createMockApprovalModel(resource, { + label: 'npm install --save express@latest', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Install express', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 3 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRow2Lines: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-2lines'); + const approvalModel = createMockApprovalModel(resource, { + label: 'cd /workspace/project\nnpm install', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Setup project dependencies', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 3 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRow3Lines: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-3lines'); + const approvalModel = createMockApprovalModel(resource, { + label: 'cd /workspace/project\nnpm install\nnpm run build', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Build the project', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 2 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRow4Lines: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-4lines'); + const approvalModel = createMockApprovalModel(resource, { + label: 'cd /workspace/project\nnpm install\nnpm run build\nnpm run test -- --coverage', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Build and test project', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 2 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRow3LongLines: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-3longlines'); + const approvalModel = createMockApprovalModel(resource, { + label: 'RUSTFLAGS="-C target-cpu=native -C opt-level=3" cargo build --release --target x86_64-unknown-linux-gnu\nfind ./target/release -name "*.so" -exec strip --strip-unneeded {} \\; && tar czf release-bundle.tar.gz -C target/release .\ncurl -X POST https://deploy.internal.example.com/api/v2/artifacts/upload --header "Authorization: Bearer $DEPLOY_TOKEN" --form "bundle=@release-bundle.tar.gz"', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Build and deploy native release', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 2 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/aiStats.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiStats.fixture.ts index 001d6010501..0ab26885d3e 100644 --- a/src/vs/workbench/test/browser/componentFixtures/aiStats.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/aiStats.fixture.ts @@ -9,12 +9,14 @@ import { ISessionData } from '../../../contrib/editTelemetry/browser/editStats/a import { Random } from '../../../../editor/test/common/core/random.js'; import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'chat/' }, { AiStatsHover: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderAiStatsHover({ ...context, data: createSampleDataWithSessions() }), }), AiStatsHoverNoData: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderAiStatsHover({ ...context, data: createEmptyData() }), }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/baseUI.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/baseUI.fixture.ts index 0c8ab56d71f..2bc08dd7587 100644 --- a/src/vs/workbench/test/browser/componentFixtures/baseUI.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/baseUI.fixture.ts @@ -22,34 +22,42 @@ import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGro export default defineThemedFixtureGroup({ Buttons: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: renderButtons, }), ButtonBar: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: renderButtonBar, }), Toggles: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: renderToggles, }), InputBoxes: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: renderInputBoxes, }), CountBadges: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: renderCountBadges, }), ActionBar: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: renderActionBar, }), ProgressBars: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: renderProgressBars, }), HighlightedLabels: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: renderHighlightedLabels, }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chatProgressContentPart.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chatProgressContentPart.fixture.ts index d22153e5766..cdb3ec7edb9 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chatProgressContentPart.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chatProgressContentPart.fixture.ts @@ -101,8 +101,9 @@ function renderProgressPart( container.appendChild(itemContainer); } -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'chat/' }, { WithSpinner: defineComponentFixture({ + labels: { kind: 'animated' }, render: (ctx) => renderProgressPart( ctx, createProgressMessage('Searching workspace for relevant files...'), @@ -112,6 +113,7 @@ export default defineThemedFixtureGroup({ }), Completed: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (ctx) => renderProgressPart( ctx, createProgressMessage('Found 12 relevant files'), @@ -121,6 +123,7 @@ export default defineThemedFixtureGroup({ }), WithCustomIcon: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (ctx) => renderProgressPart( ctx, createProgressMessage('Running tests...'), @@ -130,6 +133,7 @@ export default defineThemedFixtureGroup({ }), WithInlineCode: defineComponentFixture({ + labels: { kind: 'animated' }, render: (ctx) => renderProgressPart( ctx, createProgressMessage('Reading `src/vs/workbench/contrib/chat/browser/chatWidget.ts`'), @@ -139,6 +143,7 @@ export default defineThemedFixtureGroup({ }), LongMessage: defineComponentFixture({ + labels: { kind: 'animated' }, render: (ctx) => renderProgressPart( ctx, createProgressMessage('Searching across multiple workspace folders for TypeScript files matching the pattern you described, including test files and configuration'), diff --git a/src/vs/workbench/test/browser/componentFixtures/chatQuestionCarousel.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chatQuestionCarousel.fixture.ts index 383f9dea3ab..db26a9199e6 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chatQuestionCarousel.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chatQuestionCarousel.fixture.ts @@ -124,20 +124,24 @@ const multiSelectQuestion: IChatQuestion = { // Fixtures // ============================================================================ -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'chat/' }, { SingleTextQuestion: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderCarousel(context, createCarousel([textQuestion])), }), SingleSelectQuestion: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderCarousel(context, createCarousel([singleSelectQuestion])), }), MultiSelectQuestion: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderCarousel(context, createCarousel([multiSelectQuestion])), }), MultipleQuestions: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderCarousel(context, createCarousel([ textQuestion, singleSelectQuestion, @@ -146,10 +150,12 @@ export default defineThemedFixtureGroup({ }), NoSkip: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderCarousel(context, createCarousel([singleSelectQuestion], false)), }), SubmittedSummary: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => { const carousel = createCarousel([textQuestion, singleSelectQuestion, multiSelectQuestion]); carousel.isUsed = true; @@ -163,6 +169,7 @@ export default defineThemedFixtureGroup({ }), SkippedSummary: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => { const carousel = createCarousel([textQuestion, singleSelectQuestion]); carousel.isUsed = true; diff --git a/src/vs/workbench/test/browser/componentFixtures/codeActionList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/codeActionList.fixture.ts index 76be7d9d682..9e54ad98196 100644 --- a/src/vs/workbench/test/browser/componentFixtures/codeActionList.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/codeActionList.fixture.ts @@ -92,11 +92,13 @@ const simpleFixes: IActionListItem[] = [ { kind: ActionListItemKind.Action, item: 'fix-3', label: 'Add \'await\' to async call', group: { title: 'Quick Fix', icon: Codicon.lightBulb } }, ]; -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'editor/' }, { GroupedCodeActions: defineComponentFixture({ + labels: { kind: 'animated' }, render: (context) => renderCodeActionList({ ...context, items: quickFixItems }), }), SimpleQuickFixes: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderCodeActionList({ ...context, items: simpleFixes }), }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/codeEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/codeEditor.fixture.ts index c752df7da14..03c64b694aa 100644 --- a/src/vs/workbench/test/browser/componentFixtures/codeEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/codeEditor.fixture.ts @@ -68,8 +68,9 @@ function renderCodeEditor({ container, disposableStore, theme }: ComponentFixtur editor.setModel(model); } -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'editor/' }, { CodeEditor: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderCodeEditor(context), }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/findWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/findWidget.fixture.ts index 4ed036840b6..c187a3e530f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/findWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/findWidget.fixture.ts @@ -119,11 +119,13 @@ async function renderFindWidget(options: FindFixtureOptions): Promise { await new Promise(resolve => setTimeout(resolve, 300)); } -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'editor/' }, { Find: defineComponentFixture({ + labels: { kind: 'animated' }, render: (context) => renderFindWidget({ ...context, searchString: 'count' }), }), FindAndReplace: defineComponentFixture({ + labels: { kind: 'animated' }, render: (context) => renderFindWidget({ ...context, searchString: 'count', replaceString: 'value', showReplace: true }), }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index d482e55e701..e5318aaeecf 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -474,6 +474,24 @@ export function createTextModel( // Fixture Adapters // ============================================================================ +export interface ThemedFixtureGroupLabels { + readonly kind?: 'screenshot' | 'animated'; + readonly blocksCi?: true; +} + +function resolveLabels(labels: ThemedFixtureGroupLabels | undefined): string[] { + const result: string[] = []; + if (labels?.kind === 'screenshot') { + result.push('.screenshot'); + } else if (labels?.kind === 'animated') { + result.push('animated'); + } + if (labels?.blocksCi) { + result.push('blocks-ci'); + } + return result; +} + export interface ComponentFixtureContext { container: HTMLElement; disposableStore: DisposableStore; @@ -482,6 +500,7 @@ export interface ComponentFixtureContext { export interface ComponentFixtureOptions { render: (context: ComponentFixtureContext) => void | Promise; + labels?: ThemedFixtureGroupLabels; } type ThemedFixtures = ReturnType; @@ -509,18 +528,33 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed }, }); - return defineFixtureVariants({ + const labels = resolveLabels(options.labels); + return defineFixtureVariants(labels.length > 0 ? { labels } : {}, { Dark: createFixture(darkTheme), Light: createFixture(lightTheme), }); } -type ThemedFixtureGroupInput = Record; +interface ThemedFixtureGroupOptions { + readonly path?: string; + readonly labels?: ThemedFixtureGroupLabels; +} + +type ThemedFixtureGroupFixtures = Record; /** * Creates a nested fixture group from themed fixtures. * E.g., { MergeEditor: { Dark: ..., Light: ... } } becomes a nested group: MergeEditor > Dark/Light */ -export function defineThemedFixtureGroup(group: ThemedFixtureGroupInput): ReturnType { - return defineFixtureGroup(group); +export function defineThemedFixtureGroup(options: ThemedFixtureGroupOptions, fixtures: ThemedFixtureGroupFixtures): ReturnType; +export function defineThemedFixtureGroup(fixtures: ThemedFixtureGroupFixtures): ReturnType; +export function defineThemedFixtureGroup(optionsOrFixtures: ThemedFixtureGroupOptions | ThemedFixtureGroupFixtures, fixtures?: ThemedFixtureGroupFixtures): ReturnType { + if (fixtures) { + const options = optionsOrFixtures as ThemedFixtureGroupOptions; + return defineFixtureGroup({ + labels: resolveLabels(options.labels), + path: options.path, + }, fixtures as ThemedFixtureGroupFixtures); + } + return defineFixtureGroup(optionsOrFixtures as ThemedFixtureGroupFixtures); } diff --git a/src/vs/workbench/test/browser/componentFixtures/inlineCompletions.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/inlineCompletions.fixture.ts index 8d4fe0ae306..8a598804fea 100644 --- a/src/vs/workbench/test/browser/componentFixtures/inlineCompletions.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/inlineCompletions.fixture.ts @@ -107,9 +107,10 @@ function renderInlineEdit(options: InlineEditOptions): void { // Fixtures // ============================================================================ -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'editor/' }, { // Side-by-side view: Multi-line replacement SideBySideView: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderInlineEdit({ ...context, code: `function greet(name) { @@ -123,6 +124,7 @@ export default defineThemedFixtureGroup({ // Word replacement view: Single word change WordReplacementView: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderInlineEdit({ ...context, code: `class BufferData { @@ -139,6 +141,7 @@ export default defineThemedFixtureGroup({ // Insertion view: Insert new content InsertionView: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderInlineEdit({ ...context, code: `class BufferData { diff --git a/src/vs/workbench/test/browser/componentFixtures/inlineCompletionsExtras.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/inlineCompletionsExtras.fixture.ts index bbd8420665c..e9298fcf4d1 100644 --- a/src/vs/workbench/test/browser/componentFixtures/inlineCompletionsExtras.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/inlineCompletionsExtras.fixture.ts @@ -276,17 +276,21 @@ function createLongDistanceEditor(options: { controller?.model?.get(); } -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'editor/' }, { HintsToolbar: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderHintsToolbar(context), }), HintsToolbarHovered: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderHintsToolbar({ ...context, simulateHover: true }), }), JumpToHint: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: renderJumpToHint, }), LongDistanceHint: defineComponentFixture({ + labels: { kind: 'animated' }, render: (context) => createLongDistanceEditor({ ...context, code: LONG_DISTANCE_CODE, diff --git a/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts index 444777d13cd..6e87b84b4d0 100644 --- a/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts @@ -50,8 +50,9 @@ class FixtureQuickInputService extends QuickInputService { } } -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'chat/' }, { PromptFiles: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: context => renderPromptFilePickerFixture({ ...context, type: PromptsType.prompt, @@ -69,6 +70,7 @@ export default defineThemedFixtureGroup({ }), InstructionFilesWithAgentInstructions: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: context => renderPromptFilePickerFixture({ ...context, type: PromptsType.instructions, diff --git a/src/vs/workbench/test/browser/componentFixtures/renameWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/renameWidget.fixture.ts index 82416f4872c..0d85c6e0da4 100644 --- a/src/vs/workbench/test/browser/componentFixtures/renameWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/renameWidget.fixture.ts @@ -93,8 +93,9 @@ function renderRenameWidget(options: RenameFixtureOptions): void { ); } -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'editor/' }, { RenameVariable: defineComponentFixture({ + labels: { kind: 'animated' }, render: (context) => renderRenameWidget({ ...context, cursorLine: 4, @@ -105,6 +106,7 @@ export default defineThemedFixtureGroup({ }), }), RenameClass: defineComponentFixture({ + labels: { kind: 'animated' }, render: (context) => renderRenameWidget({ ...context, cursorLine: 1, diff --git a/src/vs/workbench/test/browser/componentFixtures/suggestWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/suggestWidget.fixture.ts index 623650a7a8d..a5aada2ea26 100644 --- a/src/vs/workbench/test/browser/componentFixtures/suggestWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/suggestWidget.fixture.ts @@ -144,8 +144,9 @@ const mixedKindCompletions: CompletionList = { ], }; -export default defineThemedFixtureGroup({ +export default defineThemedFixtureGroup({ path: 'editor/' }, { MethodCompletions: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderSuggestWidget({ ...context, code: `const element = document.getElementById('app'); @@ -159,6 +160,7 @@ if (element) { }), MixedKinds: defineComponentFixture({ + labels: { kind: 'screenshot' }, render: (context) => renderSuggestWidget({ ...context, code: '', diff --git a/src/vs/workbench/test/browser/parts/editor/breadcrumbModel.test.ts b/src/vs/workbench/test/browser/parts/editor/breadcrumbModel.test.ts index 14d2d9512ef..b48e8fe0349 100644 --- a/src/vs/workbench/test/browser/parts/editor/breadcrumbModel.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/breadcrumbModel.test.ts @@ -20,7 +20,7 @@ suite('Breadcrumb Model', function () { let model: BreadcrumbsModel; const workspaceService = new TestContextService(new Workspace('ffff', [new WorkspaceFolder({ uri: URI.parse('foo:/bar/baz/ws'), name: 'ws', index: 0 })])); const configService = new class extends TestConfigurationService { - override getValue(...args: any[]): T | undefined { + override getValue(...args: Parameters): T | undefined { if (args[0] === 'breadcrumbs.filePath') { return 'on' as T; } diff --git a/src/vs/workbench/test/browser/parts/editor/modalEditorResize.test.ts b/src/vs/workbench/test/browser/parts/editor/modalEditorResize.test.ts new file mode 100644 index 00000000000..8b17d85b0e0 --- /dev/null +++ b/src/vs/workbench/test/browser/parts/editor/modalEditorResize.test.ts @@ -0,0 +1,230 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; + +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; + +interface ISize { + readonly width: number; + readonly height: number; +} + +/** + * Simple test harness that mimics the ModalEditorPartImpl resize behavior + * without requiring the full editor part infrastructure. + */ +class TestModalEditorResizeHost extends Disposable { + + private readonly _onDidChangeMaximized = this._register(new Emitter()); + readonly onDidChangeMaximized = this._onDidChangeMaximized.event; + + private readonly _onDidRequestLayout = this._register(new Emitter()); + readonly onDidRequestLayout = this._onDidRequestLayout.event; + + private _maximized = false; + get maximized(): boolean { return this._maximized; } + + private _size: ISize | undefined; + get size(): ISize | undefined { return this._size; } + set size(value: ISize | undefined) { this._size = value; } + + private _position: { left: number; top: number } | undefined; + get position(): { left: number; top: number } | undefined { return this._position; } + set position(value: { left: number; top: number } | undefined) { this._position = value; } + + private savedSize: ISize | undefined; + private savedPosition: { left: number; top: number } | undefined; + + toggleMaximized(): void { + this._maximized = !this._maximized; + + if (this._maximized) { + this.savedSize = this._size; + this.savedPosition = this._position; + } else { + this._size = this.savedSize; + this._position = this.savedPosition; + this.savedSize = undefined; + this.savedPosition = undefined; + } + + this._onDidChangeMaximized.fire(this._maximized); + } + + handleHeaderDoubleClick(): void { + if (this._maximized) { + this.savedSize = undefined; + this.savedPosition = undefined; + this.toggleMaximized(); // un-maximize to default + } else if (this._size) { + this._size = undefined; + this._position = undefined; + this._onDidRequestLayout.fire(); + } else { + this.toggleMaximized(); // maximize + } + } + +} + +suite('Modal Editor Resize', () => { + + const disposables = new DisposableStore(); + + teardown(() => disposables.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('double-click from default size maximizes', () => { + const host = disposables.add(new TestModalEditorResizeHost()); + + const events: boolean[] = []; + disposables.add(host.onDidChangeMaximized(v => events.push(v))); + + host.handleHeaderDoubleClick(); + + assert.deepStrictEqual( + { maximized: host.maximized, size: host.size, events }, + { maximized: true, size: undefined, events: [true] } + ); + }); + + test('double-click from maximized restores default', () => { + const host = disposables.add(new TestModalEditorResizeHost()); + + host.handleHeaderDoubleClick(); // maximize + + const events: boolean[] = []; + disposables.add(host.onDidChangeMaximized(v => events.push(v))); + + host.handleHeaderDoubleClick(); // restore + + assert.deepStrictEqual( + { maximized: host.maximized, size: host.size, events }, + { maximized: false, size: undefined, events: [false] } + ); + }); + + test('double-click from custom size restores default without firing maximized event', () => { + const host = disposables.add(new TestModalEditorResizeHost()); + + host.size = { width: 800, height: 600 }; + + const maximizedEvents: boolean[] = []; + let layoutRequested = false; + disposables.add(host.onDidChangeMaximized(v => maximizedEvents.push(v))); + disposables.add(host.onDidRequestLayout(() => { layoutRequested = true; })); + + host.handleHeaderDoubleClick(); + + assert.deepStrictEqual( + { maximized: host.maximized, size: host.size, maximizedEvents, layoutRequested }, + { maximized: false, size: undefined, maximizedEvents: [], layoutRequested: true } + ); + }); + + test('double-click cycle: custom → default → maximized → default', () => { + const host = disposables.add(new TestModalEditorResizeHost()); + + const events: boolean[] = []; + disposables.add(host.onDidChangeMaximized(v => events.push(v))); + + // Start with custom size + host.size = { width: 800, height: 600 }; + + // First double-click: custom → default (fires layout, not maximized) + host.handleHeaderDoubleClick(); + assert.strictEqual(host.maximized, false); + assert.strictEqual(host.size, undefined); + + // Second double-click: default → maximized + host.handleHeaderDoubleClick(); + assert.strictEqual(host.maximized, true); + assert.strictEqual(host.size, undefined); + + // Third double-click: maximized → default + host.handleHeaderDoubleClick(); + assert.strictEqual(host.maximized, false); + assert.strictEqual(host.size, undefined); + + assert.deepStrictEqual(events, [true, false]); + }); + + test('toggleMaximized preserves custom state through maximize/un-maximize cycle', () => { + const host = disposables.add(new TestModalEditorResizeHost()); + + host.size = { width: 800, height: 600 }; + host.position = { left: 100, top: 50 }; + + host.toggleMaximized(); + assert.strictEqual(host.maximized, true); + + host.toggleMaximized(); + assert.deepStrictEqual( + { maximized: host.maximized, size: host.size, position: host.position }, + { maximized: false, size: { width: 800, height: 600 }, position: { left: 100, top: 50 } } + ); + }); + + test('double-click from maximized clears saved custom state', () => { + const host = disposables.add(new TestModalEditorResizeHost()); + + // Set custom size then maximize via toggleMaximized (saves state) + host.size = { width: 800, height: 600 }; + host.toggleMaximized(); + assert.strictEqual(host.maximized, true); + + // Double-click to un-maximize: should go to default, not restore custom + host.handleHeaderDoubleClick(); + assert.deepStrictEqual( + { maximized: host.maximized, size: host.size }, + { maximized: false, size: undefined } + ); + }); + + test('double-click clears custom position along with size', () => { + const host = disposables.add(new TestModalEditorResizeHost()); + + host.size = { width: 800, height: 600 }; + host.position = { left: 100, top: 50 }; + + host.handleHeaderDoubleClick(); + + assert.deepStrictEqual( + { size: host.size, position: host.position, maximized: host.maximized }, + { size: undefined, position: undefined, maximized: false } + ); + }); + + test('session persistence: state can be saved and restored across instances', () => { + const host1 = disposables.add(new TestModalEditorResizeHost()); + + host1.size = { width: 900, height: 700 }; + host1.position = { left: 200, top: 100 }; + + // Simulate saving state on close + const savedState = { + size: host1.size, + position: host1.position, + maximized: host1.maximized, + }; + + // Simulate restoring state on new modal + const host2 = disposables.add(new TestModalEditorResizeHost()); + host2.size = savedState.size; + host2.position = savedState.position; + if (savedState.maximized) { + host2.toggleMaximized(); + } + + assert.deepStrictEqual( + { size: host2.size, position: host2.position, maximized: host2.maximized }, + { size: { width: 900, height: 700 }, position: { left: 200, top: 100 }, maximized: false } + ); + }); +}); diff --git a/src/vs/workbench/test/browser/window.test.ts b/src/vs/workbench/test/browser/window.test.ts index f758f9900d8..1290fef45c3 100644 --- a/src/vs/workbench/test/browser/window.test.ts +++ b/src/vs/workbench/test/browser/window.test.ts @@ -41,7 +41,7 @@ suite('Window', () => { function createWindow(id: number, slow?: boolean) { // eslint-disable-next-line local/code-no-any-casts const res = { - setTimeout: function (callback: Function, delay: number, ...args: any[]): number { + setTimeout: function (callback: Function, delay: number, ...args: unknown[]): number { setTimeoutCalls.push(id); return mainWindow.setTimeout(() => callback(id), slow ? delay * 2 : delay, ...args); diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 8291f38e8d1..68821267b86 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -178,7 +178,7 @@ import './contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.js'; // Chat import './contrib/chat/electron-browser/chat.contribution.js'; -import './contrib/inlineChat/electron-browser/inlineChat.contribution.js'; + // Encryption import './contrib/encryption/electron-browser/encryption.contribution.js'; diff --git a/src/vscode-dts/vscode.proposed.chatDebug.d.ts b/src/vscode-dts/vscode.proposed.chatDebug.d.ts index f74f4e7ba11..3fe781d29fc 100644 --- a/src/vscode-dts/vscode.proposed.chatDebug.d.ts +++ b/src/vscode-dts/vscode.proposed.chatDebug.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 2 +// version: 3 declare module 'vscode' { /** @@ -642,6 +642,37 @@ declare module 'vscode' { eventId: string, token: CancellationToken ): ProviderResult; + + /** + * Export the debug log for a chat session as a serialized byte array. + * The extension controls the format (e.g., OTLP JSON with Copilot extensions). + * Core provides the save dialog and writes the returned bytes to disk. + * + * @param sessionResource The resource URI of the chat session to export. + * @param options Export options including core events and session metadata. + * @param token A cancellation token. + * @returns The serialized debug log data, or undefined if export is not available. + */ + provideChatDebugLogExport?( + sessionResource: Uri, + options: ChatDebugLogExportOptions, + token: CancellationToken + ): ProviderResult; + + /** + * Import a previously exported debug log from a serialized byte array. + * Core provides the open dialog and reads the file bytes. + * The extension deserializes the data and returns a session URI that can be + * opened in the debug panel via {@link provideChatDebugLog}. + * + * @param data The serialized debug log data (as returned by {@link provideChatDebugLogExport}). + * @param token A cancellation token. + * @returns The imported session info, or undefined if import failed. + */ + resolveChatDebugLogImport?( + data: Uint8Array, + token: CancellationToken + ): ProviderResult; } export namespace chat { @@ -654,4 +685,36 @@ declare module 'vscode' { */ export function registerChatDebugLogProvider(provider: ChatDebugLogProvider): Disposable; } + + /** + * Options passed to {@link ChatDebugLogProvider.provideChatDebugLogExport}. + */ + export interface ChatDebugLogExportOptions { + /** + * Core-originated debug events (prompt discovery, skill loading, etc.) + * for the session. The extension may include these in the export alongside its own data. + */ + readonly coreEvents: readonly ChatDebugEvent[]; + + /** + * Session title, if available. + * Used to provide a human-readable label in the exported file. + */ + readonly sessionTitle?: string; + } + + /** + * Result of importing a debug log via {@link ChatDebugLogProvider.resolveChatDebugLogImport}. + */ + export interface ChatDebugLogImportResult { + /** + * The session resource URI for the imported session. + */ + readonly uri: Uri; + + /** + * The session title from the imported file, if available. + */ + readonly sessionTitle?: string; + } } diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts index eec28002b77..85323b85156 100644 --- a/src/vscode-dts/vscode.proposed.chatHooks.d.ts +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -10,7 +10,7 @@ declare module 'vscode' { /** * The type of hook to execute. */ - export type ChatHookType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'SubagentStart' | 'SubagentStop' | 'Stop'; + export type ChatHookType = 'SessionStart' | 'SessionEnd' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'SubagentStart' | 'SubagentStop' | 'Stop' | 'ErrorOccurred'; /** * A resolved hook command ready for execution. diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index f78e56bed5a..286b87dd85c 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -839,6 +839,12 @@ declare module 'vscode' { */ readonly completionTokens: number; + /** + * The number of tokens reserved for the response. + * This is rendered specially in the UI to indicate that these tokens aren't used but are reserved. + */ + readonly outputBuffer?: number; + /** * Optional breakdown of prompt token usage by category and label. * If the percentages do not sum to 100%, the remaining will be shown as "Uncategorized". @@ -1059,6 +1065,8 @@ declare module 'vscode' { } export interface ChatRequestModeInstructions { + /** set when the mode a custom agent (not built-in), to be used as identifier */ + readonly uri?: Uri; readonly name: string; readonly content: string; readonly toolReferences?: readonly ChatLanguageModelToolReference[]; diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 16c8ff488a0..839499ef55f 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 14 +// version: 15 declare module 'vscode' { @@ -116,6 +116,13 @@ declare module 'vscode' { */ readonly parentRequestId?: string; + /** + * The permission level for tool auto-approval in this request. + * - `'autoApprove'`: Auto-approve all tool calls and retry on errors. + * - `'autopilot'`: Everything autoApprove does plus continues until the task is done. + */ + readonly permissionLevel?: string; + /** * Whether any hooks are enabled for this request. */ diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts index e683d6ce600..898b0cfbe27 100644 --- a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -103,6 +103,39 @@ declare module 'vscode' { // #region Chat Provider Registration export namespace chat { + /** + * An event that fires when the list of {@link customAgents custom agents} changes. + */ + export const onDidChangeCustomAgents: Event; + + /** + * The list of currently available custom agents. These are `.agent.md` files + * from all sources (workspace, user, and extension-provided). + */ + export const customAgents: readonly ChatResource[]; + + /** + * An event that fires when the list of {@link instructions instructions} changes. + */ + export const onDidChangeInstructions: Event; + + /** + * The list of currently available instructions. These are `.instructions.md` files + * from all sources (workspace, user, and extension-provided). + */ + export const instructions: readonly ChatResource[]; + + /** + * An event that fires when the list of {@link skills skills} changes. + */ + export const onDidChangeSkills: Event; + + /** + * The list of currently available skills. These are `SKILL.md` files + * from all sources (workspace, user, and extension-provided). + */ + export const skills: readonly ChatResource[]; + /** * Register a provider for custom agents. * @param provider The custom agent provider. diff --git a/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts b/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts index ab481a3d9d8..7d3b546e9c2 100644 --- a/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts +++ b/src/vscode-dts/vscode.proposed.languageModelToolSupportsModel.d.ts @@ -42,7 +42,7 @@ declare module 'vscode' { * Registers a language model tool along with its definition. Unlike {@link lm.registerTool}, * this does not require the tool to be present first in the extension's `package.json` contributions. * - * Multiple tools may be registered with the the same name using the API. In any given context, + * Multiple tools may be registered with the same name using the API. In any given context, * the most specific tool (based on the {@link LanguageModelToolDefinition.models}) will be used. * * @param definition The definition of the tool to register. diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow1Line/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow1Line/Dark.png new file mode 100644 index 00000000000..6e97bb89568 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow1Line/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb56239cc915c18dbdf70d98049cc8386350c6e394b988a2df86df95ef10b52c +size 7064 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow1Line/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow1Line/Light.png new file mode 100644 index 00000000000..b8635ead60e --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow1Line/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:163f0620ca91d6a7636ec58362e7dbc53a338fd26d2c9577ddb893c880bf86aa +size 7053 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow2Lines/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow2Lines/Dark.png new file mode 100644 index 00000000000..654a095b444 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow2Lines/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5d405d46064d7ae8cf9917587c51db8b80528590d4c9718729460781aa25ff9 +size 8657 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow2Lines/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow2Lines/Light.png new file mode 100644 index 00000000000..1350551e929 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow2Lines/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:02e137eff8bd38674c35f3d4ab472ff380bfa0c31e0f261e64a66822665c98a3 +size 8717 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3Lines/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3Lines/Dark.png new file mode 100644 index 00000000000..9aa34fcadd4 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3Lines/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:029ca626c8b89d7efce7d86b401774373d473282fa29b45f2a0b6eff314090da +size 8737 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3Lines/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3Lines/Light.png new file mode 100644 index 00000000000..11afd7ebade --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3Lines/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98519a1e1420101c2efb7fedf417432aa9509b3614d0df4fd51e57b02f791a9c +size 8684 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3LongLines/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3LongLines/Dark.png new file mode 100644 index 00000000000..23cae64ce72 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3LongLines/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3619142a6234cafb5b20bae5007daf1af201bb58f8b0f1e7e404621a77d74123 +size 12355 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3LongLines/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3LongLines/Light.png new file mode 100644 index 00000000000..fbe36047f39 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow3LongLines/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:717467794a22315bb378be412b331db00635d0171043deb50fa6e50a3caf9af3 +size 12363 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow4Lines/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow4Lines/Dark.png new file mode 100644 index 00000000000..3f081322682 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow4Lines/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a68b3828da8ddd4254a8ff14d740d07ed9463012a6646d466ad052f917080462 +size 9167 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow4Lines/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow4Lines/Light.png new file mode 100644 index 00000000000..e3e96fe51a8 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRow4Lines/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29a3581395521fe377cb76135f9df237ba8d6d241b5bcee4707a4c92e560e410 +size 9169 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowBash/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowBash/Dark.png new file mode 100644 index 00000000000..c6025168a8c --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowBash/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:349e1054718733508b1e8ccab0c08039e13319458a1a4e730d98531c9f8065a3 +size 7889 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowBash/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowBash/Light.png new file mode 100644 index 00000000000..c9bdeeb188d --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowBash/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:49dd6690cb406f928925ad0000624128efc5e60999a6472f5c14f5d34a048b8a +size 7940 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowJson/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowJson/Dark.png new file mode 100644 index 00000000000..29f4c5e28a7 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowJson/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cfdc23fd51094966957b9487c71aa7dc69d9ace05bc9315adf6e8ae296de3d89 +size 7338 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowJson/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowJson/Light.png new file mode 100644 index 00000000000..7684550a5f6 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowJson/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0fc1e6917fd33d4c46dd70b74d787bc0c2bc2b2d6f38c1d1c8923ca84b11009 +size 7439 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowLongLabel/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowLongLabel/Dark.png new file mode 100644 index 00000000000..40cf1fbcec9 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowLongLabel/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6827c363512a28c506670d9bce96efe463bb2e7b16864c1d3ef78bdb27c5d8b9 +size 7915 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowLongLabel/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowLongLabel/Light.png new file mode 100644 index 00000000000..035820555cc --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowLongLabel/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d90a5b77afd766a79dc78ec15369ae6fd8d37791f3b365cd51293b86089a268 +size 8005 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowPowerShell/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowPowerShell/Dark.png new file mode 100644 index 00000000000..843641053a1 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowPowerShell/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10b958efa32aa1dd506894c1663639bfd8252907b3640e4d2c6fb1893f0798dc +size 7497 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowPowerShell/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowPowerShell/Light.png new file mode 100644 index 00000000000..4f679b73ace --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ApprovalRowPowerShell/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:083d37a41eb68a5f0841c4461d4ee9df3e5e093b7295d069877fce8933c30581 +size 7548 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/Archived/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/Archived/Dark.png new file mode 100644 index 00000000000..18b0f2f3393 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/Archived/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bcb0046c2d6bf62981df0f57af2f04bc21abff8f2c25bda0bce72577c8825234 +size 3955 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/Archived/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/Archived/Light.png new file mode 100644 index 00000000000..0518b7db987 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/Archived/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6955c3424c2907b6b8472c60e5c5cee6ef104dd8482fd932e0c83ba611c3522b +size 3883 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ArchivedUnread/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ArchivedUnread/Dark.png new file mode 100644 index 00000000000..2e9a78f3e9c --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ArchivedUnread/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:761643f249dddaaa1e7f307b3372463e252f67ed30cdd860052ca2b1b9534601 +size 4136 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ArchivedUnread/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ArchivedUnread/Light.png new file mode 100644 index 00000000000..c4edc35a169 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ArchivedUnread/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed79c20b8c4adb9c4253ebed499554e3d8e898b0b5f9d389c8ce865345b85ad6 +size 4059 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/BackgroundProvider/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/BackgroundProvider/Dark.png new file mode 100644 index 00000000000..2f4f1029af5 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/BackgroundProvider/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:211c5915f952e93eede04b6ff57b552ccfd7a524ec17ded20819f531e07289de +size 4323 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/BackgroundProvider/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/BackgroundProvider/Light.png new file mode 100644 index 00000000000..b5a83bc3227 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/BackgroundProvider/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:76981d631106433e066dd7b6354337c3bbba1018587beb26ebd9d662f25093c0 +size 4440 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ClaudeProvider/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ClaudeProvider/Dark.png new file mode 100644 index 00000000000..64427aeaa23 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ClaudeProvider/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd6f5753251dfd4ebad48ac881d2a18baeeb6dac683d8c991b23676d80e79f7d +size 4627 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ClaudeProvider/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ClaudeProvider/Light.png new file mode 100644 index 00000000000..ff344e500fe --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/ClaudeProvider/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:46d1010d543349b526f2b6cdfc17177fdda11cc994a6522c6a827c883755e21b +size 4720 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProvider/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProvider/Dark.png new file mode 100644 index 00000000000..2ffb6d1dfa9 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProvider/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a357eca1ff0edae842d2137e53c4648a8a24dda806056f7cbb047ea02bc05250 +size 4402 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProvider/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProvider/Light.png new file mode 100644 index 00000000000..c7f3c6cdfdb --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProvider/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4127bb1d38d3c49f0393afcf4d04415f328cccbe8473de7e0894b671f5c6ec51 +size 4475 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProviderInProgress/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProviderInProgress/Dark.png new file mode 100644 index 00000000000..94ecc186613 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProviderInProgress/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11e38388dbe35d160b60aa8d8b1b45b2a71c9604caed3b610b318f4fc5beb5c0 +size 3746 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProviderInProgress/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProviderInProgress/Light.png new file mode 100644 index 00000000000..6c780125a43 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CloudProviderInProgress/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:768a1bfc002b37facc1d7e7c4a096148134b2f037f6739611b0ebdac8694515e +size 3741 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedRead/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedRead/Dark.png new file mode 100644 index 00000000000..9379588d303 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedRead/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ef5c71cd24e7527c325436f778498f4ce843c4118fb6df9132d152d195b77b5 +size 4131 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedRead/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedRead/Light.png new file mode 100644 index 00000000000..cc372129852 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedRead/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:461481ef366395b7a3283de9dbac92581b182420f6439d15af3c5ad1b95f315f +size 4236 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedUnread/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedUnread/Dark.png new file mode 100644 index 00000000000..e7b1e95cc9b --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedUnread/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29611b9811d052d0c311abf299aaf4009eb4cf9adc39b50a362f23dbb30e3012 +size 4281 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedUnread/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedUnread/Light.png new file mode 100644 index 00000000000..53e514e305b --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/CompletedUnread/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cafcc1fb16a92c55106474771945cc7063152922633db3d3acc8f455677e93a2 +size 4303 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithDuration/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithDuration/Dark.png new file mode 100644 index 00000000000..0f129c39bf1 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithDuration/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f18d15f95ce04361389e3685c0d04d5b5846ee7ac97f192ad75605879e3ef711 +size 3952 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithDuration/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithDuration/Light.png new file mode 100644 index 00000000000..b2c232971db --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithDuration/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:823534baac31274e97febf864f4c70430672f573a35abe360c406f0ef8394563 +size 3984 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithoutDuration/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithoutDuration/Dark.png new file mode 100644 index 00000000000..60ae250bdb3 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithoutDuration/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:99a4986f49d637f978fdef48c75c7307b1d2fe60c0a301682481a2d9271fe9c0 +size 3473 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithoutDuration/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithoutDuration/Light.png new file mode 100644 index 00000000000..77c3257a44b --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/FailedWithoutDuration/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e0c6543a97ca59eeb9bced9530976e6697e31df4cc0e392dae5721cdef90a35 +size 3590 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgress/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgress/Dark.png new file mode 100644 index 00000000000..cf64cff29d7 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgress/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39e4ce2cd4020f790d3a2f54ab29da693ce34b35a82a4ef9ebbc7e685b6c5f29 +size 3942 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgress/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgress/Light.png new file mode 100644 index 00000000000..4e2ed61233f --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgress/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c0310d172ee83a1f0260a7731e321a8e193dbb4c58afa0115bf3e2246b623db +size 3997 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgressWithDescription/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgressWithDescription/Dark.png new file mode 100644 index 00000000000..75e19d71278 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgressWithDescription/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9a68bfda118629ddd5095f2fdd202c5baaccbc6b92d1d9584ef5053113af328 +size 4693 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgressWithDescription/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgressWithDescription/Light.png new file mode 100644 index 00000000000..bc5cbfc0de7 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/InProgressWithDescription/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:616343f0b523bd7a88c02349345baedebbccb3251b34030903e5afe54c14ee0d +size 4793 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/NeedsInput/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/NeedsInput/Dark.png new file mode 100644 index 00000000000..50cbb3efca4 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/NeedsInput/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea3e3728408c21a161655ad68304442ea348f4c95b613e5abc5be491073d5a11 +size 3767 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/NeedsInput/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/NeedsInput/Light.png new file mode 100644 index 00000000000..82d68f0fd0d --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/NeedsInput/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78eb1956b1a37699a678f73d79cb6c1d7b4deda79e857c03017910054fdbae90 +size 3815 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionArchived/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionArchived/Dark.png new file mode 100644 index 00000000000..60acf6fe15b --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionArchived/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67e30acb57253a7a9c02e93e8120019eb1afc9932a99c59a241968ae75ee3752 +size 896 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionArchived/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionArchived/Light.png new file mode 100644 index 00000000000..44015ee3881 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionArchived/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d6a95e55db9415c49043745f6eb5682e20137d0d487edb6323ef101c2e46016 +size 870 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionLastWeek/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionLastWeek/Dark.png new file mode 100644 index 00000000000..130f5f1c40a --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionLastWeek/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:379ebb29923b4874351469a1a60a7e4bc76397c85e61741dc86361dd123f68d0 +size 1032 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionLastWeek/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionLastWeek/Light.png new file mode 100644 index 00000000000..cc2acbb5916 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionLastWeek/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:109ad01629c5fb42759102c4a9fdf3b9e4f69452d06d6b2f6b478be418261e01 +size 1013 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionMore/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionMore/Dark.png new file mode 100644 index 00000000000..da3fbefb55c --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionMore/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d1c9f3fba50deacb99898e7e403239716b918e4901258b155f3c87558938219 +size 634 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionMore/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionMore/Light.png new file mode 100644 index 00000000000..1fd51cdd988 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionMore/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9083bcd9e6dd0fddf1b86389abbf2c8be5db4c84a80870acee1ebb6209b3734c +size 610 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionOlder/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionOlder/Dark.png new file mode 100644 index 00000000000..5a3f629e0d9 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionOlder/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9fb24f8743a805c51eed4cb0822e6f031f1663ebf3860fa0f555fc9105d6aa47 +size 655 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionOlder/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionOlder/Light.png new file mode 100644 index 00000000000..9a01d0f11bd --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionOlder/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b33d4a2df45aadb2ddd03a4e78107993407390a0fcdcd551cc5e46cfe3bfd79 +size 629 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionToday/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionToday/Dark.png new file mode 100644 index 00000000000..7df9b9dc3e3 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionToday/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8dba102faa5ab22be300c2f3b144ccb9fd7d191b7ce345934aac0cf2a22a4cf2 +size 700 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionToday/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionToday/Light.png new file mode 100644 index 00000000000..97bfabf75bb --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionToday/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0896ebcac18fc2c67051318eccf952a4af9cded093bb8ed22da0d4f369a90fd +size 691 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionYesterday/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionYesterday/Dark.png new file mode 100644 index 00000000000..44f782bea73 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionYesterday/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf4cc56f6e7472b91ee5291f7b7963c88c575152c01914af66b79d3f983072a1 +size 1034 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionYesterday/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionYesterday/Light.png new file mode 100644 index 00000000000..fd7a76b1e55 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/SectionYesterday/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f11009c1529cb97619d437c5563764d6cf9deed1db7d7b539506a8500d58694d +size 1011 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadge/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadge/Dark.png new file mode 100644 index 00000000000..e8a6dcc8dde --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadge/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5dfad96ea810926d39db4d8f689f66c717fd4a2b51de380010046e7610ec14a4 +size 4819 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadge/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadge/Light.png new file mode 100644 index 00000000000..7605ff11511 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadge/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b6b40e3ab4d73221357a136dcac51eb0081c344a98930dd5e738662137af955 +size 4886 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadgeAndDiff/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadgeAndDiff/Dark.png new file mode 100644 index 00000000000..5e3bbe18e1e --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadgeAndDiff/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:768613c86e446829341642343180cd84a0376dfafae7399dc2a50cf3b0e21575 +size 3900 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadgeAndDiff/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadgeAndDiff/Light.png new file mode 100644 index 00000000000..3b1b9440317 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithBadgeAndDiff/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d471d8eeb3bcf0a285a3b80a7dac70d5258c7fee903185377bddad1199562259 +size 4018 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDescription/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDescription/Dark.png new file mode 100644 index 00000000000..842e5652928 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDescription/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83b83d1b89bbee53b1e7f81990b30e8f2124b2099e0da948bcc0e807d2593f1e +size 5389 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDescription/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDescription/Light.png new file mode 100644 index 00000000000..a15244fa8b9 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDescription/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39cd38f065604d4c1ea6564d0da9cd5436e7587547583783408c7e7df3812f80 +size 5507 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDiffChanges/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDiffChanges/Dark.png new file mode 100644 index 00000000000..760959402ec --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDiffChanges/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0ea6dc5746ca3dba73ee8a96df90fd2c4f34625d58ac9d616134ba5d2ff5f87 +size 3473 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDiffChanges/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDiffChanges/Light.png new file mode 100644 index 00000000000..14d28a1f999 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithDiffChanges/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d502f79bc59e00f539745df156fa704cc3db11c16653633943b6edf26401e25 +size 3617 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithFileChangesList/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithFileChangesList/Dark.png new file mode 100644 index 00000000000..10de39113fe --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithFileChangesList/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e652cd030f299556fa1fc6455533c38ac953cae0bd472248aed8034d7f19a12e +size 3129 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithFileChangesList/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithFileChangesList/Light.png new file mode 100644 index 00000000000..980b2d57aba --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithFileChangesList/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a189aa2bcce54111a8c078985ca611efde6e10dde26bb227bf1afafa2d065cc0 +size 3250 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownBadge/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownBadge/Dark.png new file mode 100644 index 00000000000..0a497294064 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownBadge/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c44f892307b93c5bb48b836d47c5c66a318e299ed59ae96ca8107eecb5ad1012 +size 5621 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownBadge/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownBadge/Light.png new file mode 100644 index 00000000000..86f7c7e3145 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownBadge/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82245981f37c430eab84149196ccc9769fc90347b1b8fc28942dcdd7d3a5357c +size 5711 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownDescription/Dark.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownDescription/Dark.png new file mode 100644 index 00000000000..456cc163ea3 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownDescription/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b702e865b2a24a3ebf2028662a7aa1f9dc4176e90761aa50627736a65f4a8000 +size 5368 diff --git a/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownDescription/Light.png b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownDescription/Light.png new file mode 100644 index 00000000000..efa1d0dbda7 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/agentSessionsViewer/WithMarkdownDescription/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5cf08481a25b64169c03ee6321326f2108ed37a08b4ade5e75c7b3ff13e63ce7 +size 5393 diff --git a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Dark.png b/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Dark.png deleted file mode 100644 index 89032243eae..00000000000 --- a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cea3d365efe7e033cfd1fb8bc408fa0853d769af9cf7fcd8c1217d7c1e7982ba -size 15184 diff --git a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Light.png b/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Light.png deleted file mode 100644 index dde67257b60..00000000000 --- a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHover/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3503662354e0c76df5ed180db789547fa2a1b1099892d9628df8f5510a0d385b -size 14312 diff --git a/test/componentFixtures/.screenshots/baseline/chat/aiStats/AiStatsHover/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/aiStats/AiStatsHover/Dark.png new file mode 100644 index 00000000000..bfe8d842cf7 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/aiStats/AiStatsHover/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0f41bf819fed7de2b99f15776cdb0d353f9bfaa4526dbb857f12be7c1343881 +size 15185 diff --git a/test/componentFixtures/.screenshots/baseline/chat/aiStats/AiStatsHover/Light.png b/test/componentFixtures/.screenshots/baseline/chat/aiStats/AiStatsHover/Light.png new file mode 100644 index 00000000000..914374bffd9 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/aiStats/AiStatsHover/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50328c742ab8f5d52b66bc5e693277733be6adb62262925c16527e92b52bf47a +size 14309 diff --git a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/aiStats/AiStatsHoverNoData/Dark.png similarity index 100% rename from test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Dark.png rename to test/componentFixtures/.screenshots/baseline/chat/aiStats/AiStatsHoverNoData/Dark.png diff --git a/test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Light.png b/test/componentFixtures/.screenshots/baseline/chat/aiStats/AiStatsHoverNoData/Light.png similarity index 100% rename from test/componentFixtures/.screenshots/baseline/aiStats/AiStatsHoverNoData/Light.png rename to test/componentFixtures/.screenshots/baseline/chat/aiStats/AiStatsHoverNoData/Light.png diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/Completed/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/Completed/Dark.png new file mode 100644 index 00000000000..7093082f758 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/Completed/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84ff989328d86ec68ab4b2771e8798051794c85c80e830e6061bf7b538dbe342 +size 1998 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/Completed/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/Completed/Light.png new file mode 100644 index 00000000000..e490a13bc02 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/Completed/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d590d4f26fb679fc1c0be2031660fc0568d0d5512e8f7272c81ad6932cde7079 +size 1994 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/LongMessage/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/LongMessage/Dark.png new file mode 100644 index 00000000000..5df4b562cf9 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/LongMessage/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cfc063153b0a031ec15142d02b494acf778323b3f77c2d4a470936c8f52ac481 +size 7895 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/LongMessage/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/LongMessage/Light.png new file mode 100644 index 00000000000..942afa17055 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/LongMessage/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f61d72a6e32d7f82c198c827d999462dbb84f3154220ff9b96b2339b9c2ee4f7 +size 7832 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithCustomIcon/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithCustomIcon/Dark.png new file mode 100644 index 00000000000..993c9cdea97 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithCustomIcon/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f4b1725f22fe74e252757ef5e3011112c86831087eb0762518295af64f085da +size 1604 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithCustomIcon/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithCustomIcon/Light.png new file mode 100644 index 00000000000..fa997f05c87 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithCustomIcon/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ecf877a05b5bab1f506ef7db0369e535434ef4ec712ae6cfc1e34aa62defd492 +size 1567 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithInlineCode/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithInlineCode/Dark.png new file mode 100644 index 00000000000..b09bad6fccd --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithInlineCode/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b0ab968d64c65fbd57da2751037b3795dd44edfdb4288e1b8164affce136d694 +size 4924 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithInlineCode/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithInlineCode/Light.png new file mode 100644 index 00000000000..b55193ca7a0 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithInlineCode/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:715427f1205744485b04ca1e1c2371116d53cc0078ba7b59b39ce492c64499ae +size 4901 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithSpinner/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithSpinner/Dark.png new file mode 100644 index 00000000000..4ce32cefd91 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithSpinner/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5674c1f5e9cfbf497eec8dccb23abb9bace0742e74804ea752b2fd652bdb75a3 +size 3541 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithSpinner/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithSpinner/Light.png new file mode 100644 index 00000000000..65dca4cf368 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatProgressContentPart/WithSpinner/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:857f0f66eac110ae84befc845a8557fd85d64d3c9b3c38fb3f1390b98e5eea47 +size 3567 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultiSelectQuestion/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultiSelectQuestion/Dark.png new file mode 100644 index 00000000000..eed451005b5 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultiSelectQuestion/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7bf0c0deb7567010db027a90da11dbe87b2e0e0e1a19b80bde4ae389363451d3 +size 15762 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultiSelectQuestion/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultiSelectQuestion/Light.png new file mode 100644 index 00000000000..5822d248ad9 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultiSelectQuestion/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1aa1238a25cb27637f4e6505dbbdcb2a4d732a45d6238a46f55205bb9db1227f +size 16001 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultipleQuestions/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultipleQuestions/Dark.png new file mode 100644 index 00000000000..41af30d168d --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultipleQuestions/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d37f98e5c7c4534b0500f0ee559fccea0136b53bafd5ae69cd5c5005fd1bbad +size 9914 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultipleQuestions/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultipleQuestions/Light.png new file mode 100644 index 00000000000..b59acaf8d25 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/MultipleQuestions/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70cb69e09936cfa033136995be7ab2d25ddc73fa4043004953d4c7b685adff1c +size 9803 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/NoSkip/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/NoSkip/Dark.png new file mode 100644 index 00000000000..111aefc0557 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/NoSkip/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1e88af6f572f98d7110e3efd9c7980446aeee5dc46bc30ae033130788da222e +size 24999 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/NoSkip/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/NoSkip/Light.png new file mode 100644 index 00000000000..f74bfcb0b0d --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/NoSkip/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89e45041078c82051075e4882b4a8beffae1298d3610270c9d95049c5f05c5b9 +size 25094 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleSelectQuestion/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleSelectQuestion/Dark.png new file mode 100644 index 00000000000..55185e54f2a --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleSelectQuestion/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15e7ffe28aa3ddcaf263df3d829825c6941274a67d454c3416ebdfdc56c7b2fb +size 25463 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleSelectQuestion/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleSelectQuestion/Light.png new file mode 100644 index 00000000000..1457a5c4359 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleSelectQuestion/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f33c81825ee8ebb528dfdc7dacd1689dd28dedf2250faaf38ca21db82f9bd25 +size 25513 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleTextQuestion/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleTextQuestion/Dark.png new file mode 100644 index 00000000000..a4e786b07a0 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleTextQuestion/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29edbbb4350570a92063105121d358bb82557d02e297783ac9aa94ca5857db99 +size 10082 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleTextQuestion/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleTextQuestion/Light.png new file mode 100644 index 00000000000..fc33b965edf --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SingleTextQuestion/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aafbf7aab60ca0474cd01a36192fa4faeab7eeb3b9e7d432121f1ae977c1441e +size 10033 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SkippedSummary/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SkippedSummary/Dark.png new file mode 100644 index 00000000000..bd57128f488 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SkippedSummary/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:183fd97931b5f9edd0cdf7c6374328f6fa12ac728246c4156b8416f68f9d6960 +size 1954 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SkippedSummary/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SkippedSummary/Light.png new file mode 100644 index 00000000000..3c0dbd6ae6b --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SkippedSummary/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa5471c7656b76e40d6e7123ee8506aeb9a4b42a7157b9bf24a02e117017b6cc +size 1918 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SubmittedSummary/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SubmittedSummary/Dark.png new file mode 100644 index 00000000000..b5cab20b4ff --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SubmittedSummary/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e725fcfce7606554463ee6aa335a3530a6e37935b2a163e78d940b3557e58d6 +size 17933 diff --git a/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SubmittedSummary/Light.png b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SubmittedSummary/Light.png new file mode 100644 index 00000000000..12a8743c86e --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/chatQuestionCarousel/SubmittedSummary/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90b08fe37901ea04d0b815f73014168cb2ec0643c4e7f8fa8993783384e884b3 +size 16311 diff --git a/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/InstructionFilesWithAgentInstructions/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/InstructionFilesWithAgentInstructions/Dark.png new file mode 100644 index 00000000000..b12335959e1 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/InstructionFilesWithAgentInstructions/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4bf1714a905c76cc16aae7c5b7f20c6418018e0820e946831348797c13bb2a4 +size 27743 diff --git a/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/InstructionFilesWithAgentInstructions/Light.png b/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/InstructionFilesWithAgentInstructions/Light.png new file mode 100644 index 00000000000..2d24452c304 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/InstructionFilesWithAgentInstructions/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:153095490477eaa37e4a0648c41e30e7ba8d62dd699a6e3cf3ec04399bbfa91a +size 27090 diff --git a/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/PromptFiles/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/PromptFiles/Dark.png new file mode 100644 index 00000000000..aeab5d11124 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/PromptFiles/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7b2e11c4f828e0fbf8ca8ef0c7607965aa1b974280851806a038a45f04b9de9 +size 23556 diff --git a/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/PromptFiles/Light.png b/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/PromptFiles/Light.png new file mode 100644 index 00000000000..6f13702b87e --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/promptFilePickers/PromptFiles/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb5027255ce762a6de6990d20d43a0aae0365ce3e2ca69e374b9b8791a4d758e +size 23201 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultiSelectQuestion/Dark.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultiSelectQuestion/Dark.png deleted file mode 100644 index b5f1d62d15e..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultiSelectQuestion/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:05f0f518e03f8b91e2178761c977215bd2561b10b04ad69685aa054231cd81be -size 15058 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultiSelectQuestion/Light.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultiSelectQuestion/Light.png deleted file mode 100644 index 8d17c778055..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultiSelectQuestion/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2c5de42c54bcd1d35f59711f2c8c9c24f747df926017f1c3a52e3703c9d69da7 -size 15326 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultipleQuestions/Dark.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultipleQuestions/Dark.png deleted file mode 100644 index 597e7cbbc67..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultipleQuestions/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7510376219beff4c9bb17bccb3de3631a633bda27fe53d6418309de484167688 -size 7506 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultipleQuestions/Light.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultipleQuestions/Light.png deleted file mode 100644 index 93470614a41..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/MultipleQuestions/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8918a56e08760211087b9cd0c798758cb7a55c002fcce1b343d06fd9f078197f -size 7434 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/NoSkip/Dark.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/NoSkip/Dark.png deleted file mode 100644 index 356200049e4..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/NoSkip/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a324c6cde2596228ddc260d04688bf5153860b7e7de5b41f9211b456285ee581 -size 25804 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/NoSkip/Light.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/NoSkip/Light.png deleted file mode 100644 index 2b1bfefce81..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/NoSkip/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0eb74e618241b7d863bf8bac7132204b332c47e44e15a9c0b12270fa31a4fb71 -size 25874 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleSelectQuestion/Dark.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleSelectQuestion/Dark.png deleted file mode 100644 index 1ecda11f8e8..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleSelectQuestion/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1d5ede2eec1393f07f015cd724e9bb0a84492d85b175577705e195ee948e80ab -size 26210 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleSelectQuestion/Light.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleSelectQuestion/Light.png deleted file mode 100644 index 5025b7c37cd..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleSelectQuestion/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:695e11e18a05b7f9d0b73612edc7f4b0288408bedbf7fc259fccb2c7fe5f7dd0 -size 26288 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleTextQuestion/Dark.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleTextQuestion/Dark.png deleted file mode 100644 index 2bc00d0ba84..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleTextQuestion/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d03152872902d21cea55c0d8ba464894c9a6ec77e7a58f68360c3be26a9a62a4 -size 7302 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleTextQuestion/Light.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleTextQuestion/Light.png deleted file mode 100644 index 26a90f10d7a..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SingleTextQuestion/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d727ac8876b301c7bf81d799197b6a8b0962a95bc9eb9b269e28f6c1eb4acd88 -size 7246 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SkippedSummary/Dark.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SkippedSummary/Dark.png deleted file mode 100644 index ccf46b5de69..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SkippedSummary/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a3e31fb939e811c76b4fb03a7211cc84b86794dbdce2ecf26d1d42324dc86fd4 -size 2099 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SkippedSummary/Light.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SkippedSummary/Light.png deleted file mode 100644 index 29bea3c013d..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SkippedSummary/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8d7fd7e2f20d9a7a8e2ea867e0383e88291547b5e9d2c04642a84b5087491f64 -size 2064 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SubmittedSummary/Dark.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SubmittedSummary/Dark.png deleted file mode 100644 index 7b0b53c68a4..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SubmittedSummary/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c84efc6a1c010c303da05377ae980ead28d0f7412d0ce0e49bfc66ce23c766d3 -size 20333 diff --git a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SubmittedSummary/Light.png b/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SubmittedSummary/Light.png deleted file mode 100644 index 858f4e5ccd3..00000000000 --- a/test/componentFixtures/.screenshots/baseline/chatQuestionCarousel/SubmittedSummary/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1cf2233bec9a1ab64995e6168081da43e485d6b2388f868648613d63034160b8 -size 18278 diff --git a/test/componentFixtures/.screenshots/baseline/codeActionList/GroupedCodeActions/Dark.png b/test/componentFixtures/.screenshots/baseline/codeActionList/GroupedCodeActions/Dark.png deleted file mode 100644 index b48c12e655e..00000000000 --- a/test/componentFixtures/.screenshots/baseline/codeActionList/GroupedCodeActions/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b99678e6d41e30c874e0517eb6d97ccb3eaece31c489482fae81dd92904051c9 -size 14963 diff --git a/test/componentFixtures/.screenshots/baseline/codeActionList/GroupedCodeActions/Light.png b/test/componentFixtures/.screenshots/baseline/codeActionList/GroupedCodeActions/Light.png deleted file mode 100644 index 8b2121d3a7c..00000000000 --- a/test/componentFixtures/.screenshots/baseline/codeActionList/GroupedCodeActions/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bfaa85a662524e06f3d8323bf9a4655822d36567ffa438db85719a79addb6cd3 -size 14439 diff --git a/test/componentFixtures/.screenshots/baseline/codeActionList/SimpleQuickFixes/Dark.png b/test/componentFixtures/.screenshots/baseline/codeActionList/SimpleQuickFixes/Dark.png deleted file mode 100644 index f82d6f544e6..00000000000 --- a/test/componentFixtures/.screenshots/baseline/codeActionList/SimpleQuickFixes/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:38e0cc34e642669cd200ac977f5e8948c100c100457f202b20a642ca211ab959 -size 6540 diff --git a/test/componentFixtures/.screenshots/baseline/codeActionList/SimpleQuickFixes/Light.png b/test/componentFixtures/.screenshots/baseline/codeActionList/SimpleQuickFixes/Light.png deleted file mode 100644 index ecbb1c1dc43..00000000000 --- a/test/componentFixtures/.screenshots/baseline/codeActionList/SimpleQuickFixes/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9dfd9431a64258c6939f06c59d9163d33b65278cfd3ace4ae3c269f1e7e12d40 -size 6111 diff --git a/test/componentFixtures/.screenshots/baseline/editor/codeActionList/GroupedCodeActions/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/codeActionList/GroupedCodeActions/Dark.png new file mode 100644 index 00000000000..524b085f8b0 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/codeActionList/GroupedCodeActions/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab8379f105e879912a52e2d25c88e26870df79bd220a6789a3f61e010d2f6788 +size 14944 diff --git a/test/componentFixtures/.screenshots/baseline/editor/codeActionList/GroupedCodeActions/Light.png b/test/componentFixtures/.screenshots/baseline/editor/codeActionList/GroupedCodeActions/Light.png new file mode 100644 index 00000000000..cd0c2d36a62 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/codeActionList/GroupedCodeActions/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e641aa3e70e91cfbaf2db48fd1a095850a422b55b0acbeef4b2fdd74d07674e +size 14446 diff --git a/test/componentFixtures/.screenshots/baseline/editor/codeActionList/SimpleQuickFixes/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/codeActionList/SimpleQuickFixes/Dark.png new file mode 100644 index 00000000000..7527ac7cf76 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/codeActionList/SimpleQuickFixes/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:94aa1fc6a75d1506459f1a0bfe4d29723350a59e3d4c5002a4916461245bcd6b +size 6517 diff --git a/test/componentFixtures/.screenshots/baseline/editor/codeActionList/SimpleQuickFixes/Light.png b/test/componentFixtures/.screenshots/baseline/editor/codeActionList/SimpleQuickFixes/Light.png new file mode 100644 index 00000000000..0de7a3d04ab --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/codeActionList/SimpleQuickFixes/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42e4676c9b49dec849b275057f4e8a06a3acbb7fb54304d9fb9509ff7277459d +size 6110 diff --git a/test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/codeEditor/CodeEditor/Dark.png similarity index 100% rename from test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Dark.png rename to test/componentFixtures/.screenshots/baseline/editor/codeEditor/CodeEditor/Dark.png diff --git a/test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Light.png b/test/componentFixtures/.screenshots/baseline/editor/codeEditor/CodeEditor/Light.png similarity index 100% rename from test/componentFixtures/.screenshots/baseline/codeEditor/CodeEditor/Light.png rename to test/componentFixtures/.screenshots/baseline/editor/codeEditor/CodeEditor/Light.png diff --git a/test/componentFixtures/.screenshots/baseline/editor/findWidget/Find/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/findWidget/Find/Dark.png new file mode 100644 index 00000000000..48dbd2f491c --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/findWidget/Find/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9df123fb04b4c6ecd4337bb19af8626b095c3fd5f149c0bcb2d46d216fa392ad +size 33087 diff --git a/test/componentFixtures/.screenshots/baseline/editor/findWidget/Find/Light.png b/test/componentFixtures/.screenshots/baseline/editor/findWidget/Find/Light.png new file mode 100644 index 00000000000..2c81c27c782 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/findWidget/Find/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:531f9d16ecde8fc7b868eafc0541f04dfa476065095119ef2b02be2ee85a4788 +size 32732 diff --git a/test/componentFixtures/.screenshots/baseline/editor/findWidget/FindAndReplace/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/findWidget/FindAndReplace/Dark.png new file mode 100644 index 00000000000..027b13b8042 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/findWidget/FindAndReplace/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa6ec0238ac1ac4974548b73e205b758e910d0604d1c28e0f26231e1df8129f2 +size 31006 diff --git a/test/componentFixtures/.screenshots/baseline/editor/findWidget/FindAndReplace/Light.png b/test/componentFixtures/.screenshots/baseline/editor/findWidget/FindAndReplace/Light.png new file mode 100644 index 00000000000..82991aea52e --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/findWidget/FindAndReplace/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:416cf9214bac2a0e275f9cf83c41e656e94de14ee870886f55caae230eb8871d +size 30907 diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/InsertionView/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/InsertionView/Dark.png new file mode 100644 index 00000000000..d55a1ec7dfc --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/InsertionView/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e8973161acbdd94816922272fcb0406263e58f624d86c0d7980c329a65a1b24 +size 11278 diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/InsertionView/Light.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/InsertionView/Light.png new file mode 100644 index 00000000000..5a87432a3d4 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/InsertionView/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e6d3166596fd41609c4a622d7f7267fd6f3f9605d893efc34aac65ef7881ad71 +size 11203 diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/SideBySideView/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/SideBySideView/Dark.png new file mode 100644 index 00000000000..9da6404a655 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/SideBySideView/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f39335e6fa03078f804f848b634329c9391e4d8fe099d0fa1e0d4808ba4eb3a7 +size 11117 diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/SideBySideView/Light.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/SideBySideView/Light.png new file mode 100644 index 00000000000..42acebe80c0 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/SideBySideView/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c70bb66d053f20dccf9075399a801dceaa92d5830642e8f2afc9f89c81d9315c +size 11005 diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/WordReplacementView/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/WordReplacementView/Dark.png new file mode 100644 index 00000000000..4f1806f3f3e --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/WordReplacementView/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a54d85c0fcf622977f412e8fc2953c973f1da427c91d319d0708b6e758969f3f +size 10280 diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/WordReplacementView/Light.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/WordReplacementView/Light.png new file mode 100644 index 00000000000..ad3fa141565 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletions/WordReplacementView/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d494bbb9ebfbf2156c5d7a21f39c52e14313a5218af083cd91f2dbc6199f62da +size 10195 diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbar/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbar/Dark.png new file mode 100644 index 00000000000..1dbc63aea44 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbar/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e4226c71af8ae21a5f0c13ba521b50518afb4a39831e832d725ebe0289344ff +size 10266 diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbar/Light.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbar/Light.png new file mode 100644 index 00000000000..224da35d3dd --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbar/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:518ecbddf013bbc749b62a8a74bad39d7683332ebe0d9f2a4b7b13ad54fcabab +size 10104 diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbarHovered/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbarHovered/Dark.png new file mode 100644 index 00000000000..1dbc63aea44 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbarHovered/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e4226c71af8ae21a5f0c13ba521b50518afb4a39831e832d725ebe0289344ff +size 10266 diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbarHovered/Light.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbarHovered/Light.png new file mode 100644 index 00000000000..224da35d3dd --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/HintsToolbarHovered/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:518ecbddf013bbc749b62a8a74bad39d7683332ebe0d9f2a4b7b13ad54fcabab +size 10104 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/JumpToHint/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/JumpToHint/Dark.png similarity index 100% rename from test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/JumpToHint/Dark.png rename to test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/JumpToHint/Dark.png diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/JumpToHint/Light.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/JumpToHint/Light.png similarity index 100% rename from test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/JumpToHint/Light.png rename to test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/JumpToHint/Light.png diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/LongDistanceHint/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/LongDistanceHint/Dark.png new file mode 100644 index 00000000000..19c633f2edd --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/LongDistanceHint/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3923f85be8bcdbfcdecfd80b07abdbd71763edc379de4e6a5e946f20f160db5c +size 55650 diff --git a/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/LongDistanceHint/Light.png b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/LongDistanceHint/Light.png new file mode 100644 index 00000000000..823489ce7fc --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/inlineCompletionsExtras/LongDistanceHint/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e81911488f22e9517b9e2f8bd7b8bc7a14d7fff1b2d012c2368b840d4bcb49cf +size 55028 diff --git a/test/componentFixtures/.screenshots/baseline/renameWidget/RenameClass/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/renameWidget/RenameClass/Dark.png similarity index 100% rename from test/componentFixtures/.screenshots/baseline/renameWidget/RenameClass/Dark.png rename to test/componentFixtures/.screenshots/baseline/editor/renameWidget/RenameClass/Dark.png diff --git a/test/componentFixtures/.screenshots/baseline/renameWidget/RenameClass/Light.png b/test/componentFixtures/.screenshots/baseline/editor/renameWidget/RenameClass/Light.png similarity index 100% rename from test/componentFixtures/.screenshots/baseline/renameWidget/RenameClass/Light.png rename to test/componentFixtures/.screenshots/baseline/editor/renameWidget/RenameClass/Light.png diff --git a/test/componentFixtures/.screenshots/baseline/renameWidget/RenameVariable/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/renameWidget/RenameVariable/Dark.png similarity index 100% rename from test/componentFixtures/.screenshots/baseline/renameWidget/RenameVariable/Dark.png rename to test/componentFixtures/.screenshots/baseline/editor/renameWidget/RenameVariable/Dark.png diff --git a/test/componentFixtures/.screenshots/baseline/renameWidget/RenameVariable/Light.png b/test/componentFixtures/.screenshots/baseline/editor/renameWidget/RenameVariable/Light.png similarity index 100% rename from test/componentFixtures/.screenshots/baseline/renameWidget/RenameVariable/Light.png rename to test/componentFixtures/.screenshots/baseline/editor/renameWidget/RenameVariable/Light.png diff --git a/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MethodCompletions/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MethodCompletions/Dark.png new file mode 100644 index 00000000000..cd464aa178c --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MethodCompletions/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b0b1f65ed8a0963da2b4f7f64b3d06683895a57d8101856f711742954cedc23 +size 23358 diff --git a/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MethodCompletions/Light.png b/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MethodCompletions/Light.png new file mode 100644 index 00000000000..b293ccc538f --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MethodCompletions/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2f348cbe0e5d401778f4f532b6254d981f9ac98220b290d8d5401dcd4526410 +size 22475 diff --git a/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MixedKinds/Dark.png b/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MixedKinds/Dark.png new file mode 100644 index 00000000000..8d9e892d9e3 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MixedKinds/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0222974a073f5b12431b1ebc4f498ce8a795b860016520e103a35d6a68401d98 +size 13689 diff --git a/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MixedKinds/Light.png b/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MixedKinds/Light.png new file mode 100644 index 00000000000..5e93e621faf --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/editor/suggestWidget/MixedKinds/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:998dde573af3df84cb836248c71f2bc141eb6845a7c407852f1a02969b14dec5 +size 13538 diff --git a/test/componentFixtures/.screenshots/baseline/findWidget/Find/Dark.png b/test/componentFixtures/.screenshots/baseline/findWidget/Find/Dark.png deleted file mode 100644 index 55c823622c7..00000000000 --- a/test/componentFixtures/.screenshots/baseline/findWidget/Find/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ed115cf70c25bc400d265a08db2e1d6a4d7596674d7a6ddc932ca1ad33ccaa25 -size 33367 diff --git a/test/componentFixtures/.screenshots/baseline/findWidget/Find/Light.png b/test/componentFixtures/.screenshots/baseline/findWidget/Find/Light.png deleted file mode 100644 index 9e80b63bb48..00000000000 --- a/test/componentFixtures/.screenshots/baseline/findWidget/Find/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0cfebc3c9b7caa2d40cfb8db5b47ae6f5a446bb72883bdce5198ed5d296ae82f -size 32889 diff --git a/test/componentFixtures/.screenshots/baseline/findWidget/FindAndReplace/Dark.png b/test/componentFixtures/.screenshots/baseline/findWidget/FindAndReplace/Dark.png deleted file mode 100644 index cc9b2aa4376..00000000000 --- a/test/componentFixtures/.screenshots/baseline/findWidget/FindAndReplace/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5d3a4d62c5226a9ca16b2327d95337a68286107a4fb12d1d5bc43530f17f6b1d -size 33379 diff --git a/test/componentFixtures/.screenshots/baseline/findWidget/FindAndReplace/Light.png b/test/componentFixtures/.screenshots/baseline/findWidget/FindAndReplace/Light.png deleted file mode 100644 index c8a73252512..00000000000 --- a/test/componentFixtures/.screenshots/baseline/findWidget/FindAndReplace/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:522ba17bdd1d4378c0be262631d8e6bb1fd867f2999c07c06c4991cd9971a4cb -size 33114 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Dark.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Dark.png deleted file mode 100644 index ebee6a6096c..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0286377eace47ff7549c02aca2f00ad5c0200b29187ad187554b16304a93127c -size 11188 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Light.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Light.png deleted file mode 100644 index 63b05939a6d..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletions/InsertionView/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0af0112e3441d983dc0cde0a2bfa60542a32dfe552c1c377d8cf07c08b275429 -size 11067 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Dark.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Dark.png deleted file mode 100644 index ad2595b0483..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fc4b2b1a2972264878f5a4018c1030a2d1f34a5d5f468a1820f48cd041817421 -size 11036 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Light.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Light.png deleted file mode 100644 index 82b0147e7f2..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletions/SideBySideView/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1d8cb9f3a30edccfc6d304365b103c9787d2a1dbd2c7c8b011ef05d37825f326 -size 10906 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Dark.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Dark.png deleted file mode 100644 index 63033ca9918..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9b1e1b59a00dcea1e7f5f360907850f889959158f1ac32a7c276d771e2dea768 -size 10179 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Light.png b/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Light.png deleted file mode 100644 index 4d303775e26..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletions/WordReplacementView/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8438c8892a19b9b479e706efb002ec30c9b0c17cfd1575c0a4a883c6aba74e89 -size 10049 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbar/Dark.png b/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbar/Dark.png deleted file mode 100644 index 201fc1a7bf5..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbar/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a88fb3139a351b8c46df4309f64ed71210c456d4a89aad6b087ee512e26d9e1b -size 9762 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbar/Light.png b/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbar/Light.png deleted file mode 100644 index be9dca0e21b..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbar/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d0679cbdb00bacb421b6c8c65b632211a5dd153ae4c698c7a49903e53632310f -size 9569 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbarHovered/Dark.png b/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbarHovered/Dark.png deleted file mode 100644 index 201fc1a7bf5..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbarHovered/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a88fb3139a351b8c46df4309f64ed71210c456d4a89aad6b087ee512e26d9e1b -size 9762 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbarHovered/Light.png b/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbarHovered/Light.png deleted file mode 100644 index be9dca0e21b..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/HintsToolbarHovered/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d0679cbdb00bacb421b6c8c65b632211a5dd153ae4c698c7a49903e53632310f -size 9569 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/LongDistanceHint/Dark.png b/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/LongDistanceHint/Dark.png deleted file mode 100644 index 83d5938ca6c..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/LongDistanceHint/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:05deae07ae1a6a5f738d6ebd4d475ed2e7d14630977b8b64cdae9030da2885ff -size 55645 diff --git a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/LongDistanceHint/Light.png b/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/LongDistanceHint/Light.png deleted file mode 100644 index 23db3df30fb..00000000000 --- a/test/componentFixtures/.screenshots/baseline/inlineCompletionsExtras/LongDistanceHint/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d9d81860fc8c296f9cc718ed3e6690669eb0f7301c17ae2f15639c984d499178 -size 54993 diff --git a/test/componentFixtures/.screenshots/baseline/promptFilePickers/InstructionFilesWithAgentInstructions/Dark.png b/test/componentFixtures/.screenshots/baseline/promptFilePickers/InstructionFilesWithAgentInstructions/Dark.png deleted file mode 100644 index dba2c92968e..00000000000 --- a/test/componentFixtures/.screenshots/baseline/promptFilePickers/InstructionFilesWithAgentInstructions/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8c54a17b190dde0caa1f175b1ef025850a6ede44f03d8d124859355c309aa28a -size 27557 diff --git a/test/componentFixtures/.screenshots/baseline/promptFilePickers/InstructionFilesWithAgentInstructions/Light.png b/test/componentFixtures/.screenshots/baseline/promptFilePickers/InstructionFilesWithAgentInstructions/Light.png deleted file mode 100644 index 63742c15bcd..00000000000 --- a/test/componentFixtures/.screenshots/baseline/promptFilePickers/InstructionFilesWithAgentInstructions/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:eac6ff34521293e8f1f7e70f3f1e4227cc371861763fe954cce62d49b181955b -size 26846 diff --git a/test/componentFixtures/.screenshots/baseline/promptFilePickers/PromptFiles/Dark.png b/test/componentFixtures/.screenshots/baseline/promptFilePickers/PromptFiles/Dark.png deleted file mode 100644 index ffc64ba9aac..00000000000 --- a/test/componentFixtures/.screenshots/baseline/promptFilePickers/PromptFiles/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:099e8f6e3b7c1c33fae6a75f47e387ab7f87536953607ec0f3a7b5160e0d4d59 -size 23377 diff --git a/test/componentFixtures/.screenshots/baseline/promptFilePickers/PromptFiles/Light.png b/test/componentFixtures/.screenshots/baseline/promptFilePickers/PromptFiles/Light.png deleted file mode 100644 index c9ced02aeb7..00000000000 --- a/test/componentFixtures/.screenshots/baseline/promptFilePickers/PromptFiles/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0e7def912d49d13cd496386ef22a39df862a558e5585b01bfc46fd6f5f3276d4 -size 22969 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/AvailableForDownload/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/AvailableForDownload/Dark.png new file mode 100644 index 00000000000..f6117771773 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/AvailableForDownload/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19c85e77d0f96df99aee39232dec5ae19f247c22f32664689794e3ca34c6ac4a +size 3899 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/AvailableForDownload/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/AvailableForDownload/Light.png new file mode 100644 index 00000000000..f8d4a437c6f --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/AvailableForDownload/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34f56e3559fc17204bd7e7134e8883e31cef957a6bc05780a929cf5a8e23071e +size 3830 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/CheckingForUpdatesHidden/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/CheckingForUpdatesHidden/Dark.png new file mode 100644 index 00000000000..586892704bb --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/CheckingForUpdatesHidden/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3da890451d346196f63be699e367ad2eccf46091167ac005d4eb5adb8169d12d +size 3778 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/CheckingForUpdatesHidden/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/CheckingForUpdatesHidden/Light.png new file mode 100644 index 00000000000..d5f5baa7a48 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/CheckingForUpdatesHidden/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc0b866a49984f2e17b2da44d15fa4525a21519010e4df5a0651ffcc88b594f0 +size 3674 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/DownloadedInstalling/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/DownloadedInstalling/Dark.png new file mode 100644 index 00000000000..a628d1aae9c --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/DownloadedInstalling/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60be48fef4a63200229dda97c1f8664e9410d76be3a113926355867c8f58c3fa +size 3815 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/DownloadedInstalling/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/DownloadedInstalling/Light.png new file mode 100644 index 00000000000..0f6db97e2ab --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/DownloadedInstalling/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44d8727fcac81f4669821e279f370cde522e2be40758d2d2975a9331466e6d49 +size 3716 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Downloading30Percent/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Downloading30Percent/Dark.png new file mode 100644 index 00000000000..f6117771773 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Downloading30Percent/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19c85e77d0f96df99aee39232dec5ae19f247c22f32664689794e3ca34c6ac4a +size 3899 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Downloading30Percent/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Downloading30Percent/Light.png new file mode 100644 index 00000000000..f8d4a437c6f --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Downloading30Percent/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34f56e3559fc17204bd7e7134e8883e31cef957a6bc05780a929cf5a8e23071e +size 3830 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/LoadingSignedOutNoUpdate/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/LoadingSignedOutNoUpdate/Dark.png new file mode 100644 index 00000000000..9c33813aae3 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/LoadingSignedOutNoUpdate/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c282cd99bdd694629caf8bd310013e3aec0f5b25dd5376ac7c1bb897a1b4388 +size 1593 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/LoadingSignedOutNoUpdate/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/LoadingSignedOutNoUpdate/Light.png new file mode 100644 index 00000000000..753352cf64f --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/LoadingSignedOutNoUpdate/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1380daf9f875ed2502b0669af7844bae000168bcba00d89ea5d6634f590637c1 +size 1606 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Overwriting/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Overwriting/Dark.png new file mode 100644 index 00000000000..f6117771773 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Overwriting/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:19c85e77d0f96df99aee39232dec5ae19f247c22f32664689794e3ca34c6ac4a +size 3899 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Overwriting/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Overwriting/Light.png new file mode 100644 index 00000000000..f8d4a437c6f --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Overwriting/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34f56e3559fc17204bd7e7134e8883e31cef957a6bc05780a929cf5a8e23071e +size 3830 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Ready/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Ready/Dark.png new file mode 100644 index 00000000000..aa6921a198c --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Ready/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:263cc0cd0dd5fe9eb7839cd0ff5c1698c26b76fd162c6f20cd9bf048094cb308 +size 4299 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Ready/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Ready/Light.png new file mode 100644 index 00000000000..7a19687be82 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Ready/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:651452ec039ff26c1fce42ccd541d0ce70f39a993457939be3bcd3f1d67ec4cb +size 4202 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedInNoUpdate/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedInNoUpdate/Dark.png new file mode 100644 index 00000000000..586892704bb --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedInNoUpdate/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3da890451d346196f63be699e367ad2eccf46091167ac005d4eb5adb8169d12d +size 3778 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedInNoUpdate/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedInNoUpdate/Light.png new file mode 100644 index 00000000000..d5f5baa7a48 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedInNoUpdate/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc0b866a49984f2e17b2da44d15fa4525a21519010e4df5a0651ffcc88b594f0 +size 3674 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedOutNoUpdate/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedOutNoUpdate/Dark.png new file mode 100644 index 00000000000..3dea19001f2 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedOutNoUpdate/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c6eba3c086a1b7c69b8f1bff63ccd43bbe6bad82a049e8b32230e4ce7ffde07 +size 1367 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedOutNoUpdate/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedOutNoUpdate/Light.png new file mode 100644 index 00000000000..3e8db51c450 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/SignedOutNoUpdate/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7b782263b3ffed17f79b95444cc6f479e9408ed252f5d1c227b4c7f741858b82 +size 1240 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Updating/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Updating/Dark.png new file mode 100644 index 00000000000..cca6f012d85 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Updating/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa8e4d7c2bffaa4b3fd149ce1316b78ba9ac40ce5878a8867dd6422342c5a18f +size 3977 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Updating/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Updating/Light.png new file mode 100644 index 00000000000..c0152452dbf --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/accountWidget/Updating/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13659a984dabca598789efd2b5a3f0753c5d8d8ba705b018c046ee1ebf9d861b +size 3855 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Collapsed/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Collapsed/Dark.png new file mode 100644 index 00000000000..dc686609d7f --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Collapsed/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6332afcc844f37a800f11ba7b49f8d5ef61f160f9ea285a34fcf08df462c8de +size 1749 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Collapsed/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Collapsed/Light.png new file mode 100644 index 00000000000..dbca2be966b --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Collapsed/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c567d79fce21467c90bdf833d5d387c1a7e91070fa908d7fdb53595a0adfcb1 +size 1686 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/CollapsedWithMcpServers/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/CollapsedWithMcpServers/Dark.png new file mode 100644 index 00000000000..3c121b4057c --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/CollapsedWithMcpServers/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b217c46a4e2cfd993def8baf8e2fb2808a825d0b38fad553ec82a65ee1148e72 +size 1936 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/CollapsedWithMcpServers/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/CollapsedWithMcpServers/Light.png new file mode 100644 index 00000000000..08073d4119a --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/CollapsedWithMcpServers/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86dd22c02f0f4d1f4a46ce55f8546dfe70032a199ffa10683a9daa47cecfad59 +size 1889 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Expanded/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Expanded/Dark.png new file mode 100644 index 00000000000..162ecbb0952 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Expanded/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0bdc6ca3ffa8ea88ed6184379239b81d0ecd90cfc56dc3cecd84be616d3c22c0 +size 8122 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Expanded/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Expanded/Light.png new file mode 100644 index 00000000000..03dd01d13f4 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/Expanded/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5ff42ad20ea3c0fa7e43d1db6fac737890f149942caff51e3e0e1d041a3c015 +size 7865 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithCounts/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithCounts/Dark.png new file mode 100644 index 00000000000..2bebcf187a9 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithCounts/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2a797c09dcd81f2128eb937eb53f1f7a28131978a7f654dc95092b39172d098 +size 9388 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithCounts/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithCounts/Light.png new file mode 100644 index 00000000000..556520e8b8b --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithCounts/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a26cc493b80a889baa2495307abd147405ef256c0713a84fcae26cb5918b39e4 +size 9131 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithMcpServers/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithMcpServers/Dark.png new file mode 100644 index 00000000000..621b8b35147 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithMcpServers/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b835e8c51e723ecb0a11cb167b81a76b4a4938895a1b05291ac5fd609446923 +size 8341 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithMcpServers/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithMcpServers/Light.png new file mode 100644 index 00000000000..b5d3ff1d64e --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/aiCustomizationShortcutsWidget/WithMcpServers/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae32840ac5637faa899f21c94acdc7e2f5c065d705e2a3b103a23a381849dfde +size 8065 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverAvailableForDownload/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverAvailableForDownload/Dark.png new file mode 100644 index 00000000000..d06f5a35857 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverAvailableForDownload/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:125f1d570fdad186e94fc580d44e8560b6ecc3de58e3adc86d23045bdda21a8d +size 7784 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverAvailableForDownload/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverAvailableForDownload/Light.png new file mode 100644 index 00000000000..9ce0500c3ba --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverAvailableForDownload/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:afb19ee8b136f65d80f8aa7de11cf983926458999b153568d6fe6303ab5c0236 +size 7692 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverDownloading30Percent/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverDownloading30Percent/Dark.png new file mode 100644 index 00000000000..01d70e70f0a --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverDownloading30Percent/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28bdc58d5f8ec2b28df3b6762fddb4dcc9477fd3dfe98af5a7d4c311ebf4e3c1 +size 7218 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverDownloading30Percent/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverDownloading30Percent/Light.png new file mode 100644 index 00000000000..4ffae5781dc --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverDownloading30Percent/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:977c95e98e10ec21093424a2d4038131b9d95c69d27ef225b501529c4308e497 +size 7076 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverInstalling/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverInstalling/Dark.png new file mode 100644 index 00000000000..571d58ad4c3 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverInstalling/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:595c42b464f87696ead04be8a367c1ba36dd75ec5115fa00ad7ee2ddc80c7644 +size 6875 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverInstalling/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverInstalling/Light.png new file mode 100644 index 00000000000..3c0f774ad10 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverInstalling/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d393b6215ebc0f299a2a7e21076835c6b3ee9035b94917ddc5a7d298222c1b3 +size 6706 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverReady/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverReady/Dark.png new file mode 100644 index 00000000000..acbc58e86db --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverReady/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:abdf653a0b09dd50655217b4c05e55cbb7a1f53ab8768809125d4eff48cace22 +size 7523 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverReady/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverReady/Light.png new file mode 100644 index 00000000000..7755b21fe51 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverReady/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:269f58f05dd9f4cea1b4f09a92f6f58b2cc55345e9252b05297b7328489dd0bf +size 7303 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverSameVersion/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverSameVersion/Dark.png new file mode 100644 index 00000000000..a574cc4e101 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverSameVersion/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:631998b418a3af1a791470fb4b557e2b3fcd35d66d50e6c98e5c70d578b016cb +size 7367 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverSameVersion/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverSameVersion/Light.png new file mode 100644 index 00000000000..9f89acdebc1 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverSameVersion/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f96bd7a669274b547229bce41ffc400983dc4656d97ecf820fc94d65df1a418 +size 7078 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverUpdating/Dark.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverUpdating/Dark.png new file mode 100644 index 00000000000..1b1a0fe693f --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverUpdating/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e15ec5f380f9bc357fc6ddbd02b954930815bbdabd0d86c52b9e05550ad3a21c +size 7101 diff --git a/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverUpdating/Light.png b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverUpdating/Light.png new file mode 100644 index 00000000000..0fd86f37325 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/sessions/updateHoverWidget/UpdateHoverUpdating/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f50b8ee6944db9878190b9ec57fa87c4032d6697cb3827db3fa651f25aa38183 +size 6980 diff --git a/test/componentFixtures/.screenshots/baseline/suggestWidget/MethodCompletions/Dark.png b/test/componentFixtures/.screenshots/baseline/suggestWidget/MethodCompletions/Dark.png deleted file mode 100644 index 20494f46254..00000000000 --- a/test/componentFixtures/.screenshots/baseline/suggestWidget/MethodCompletions/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:869d6db0575e1e0dce468df0227cd68c1725d7341c5efb5b47ab929d3717761d -size 22978 diff --git a/test/componentFixtures/.screenshots/baseline/suggestWidget/MethodCompletions/Light.png b/test/componentFixtures/.screenshots/baseline/suggestWidget/MethodCompletions/Light.png deleted file mode 100644 index aefc052d100..00000000000 --- a/test/componentFixtures/.screenshots/baseline/suggestWidget/MethodCompletions/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cff0734d5bbb48f8900e8cff6892aab851f58206db69a4c0a49425c0981f3967 -size 22208 diff --git a/test/componentFixtures/.screenshots/baseline/suggestWidget/MixedKinds/Dark.png b/test/componentFixtures/.screenshots/baseline/suggestWidget/MixedKinds/Dark.png deleted file mode 100644 index f44709ad5bf..00000000000 --- a/test/componentFixtures/.screenshots/baseline/suggestWidget/MixedKinds/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:b84d2c246a9594012baff0a70212b6768e6b0d784936c4f2f9d37378cf813248 -size 13541 diff --git a/test/componentFixtures/.screenshots/baseline/suggestWidget/MixedKinds/Light.png b/test/componentFixtures/.screenshots/baseline/suggestWidget/MixedKinds/Light.png deleted file mode 100644 index 2f008a94f3e..00000000000 --- a/test/componentFixtures/.screenshots/baseline/suggestWidget/MixedKinds/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3c3aba099609c37eb2e58de791958665aec9fd8d2931b2208ec91515cac41b96 -size 13386 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/AvailableForDownload/Dark.png b/test/componentFixtures/.screenshots/baseline/updateWidget/AvailableForDownload/Dark.png deleted file mode 100644 index 4024eff5bc9..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/AvailableForDownload/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:63aa8f046ab23ac6bd53722db86fd254c7cd88a487f45feb68cc9a9bbd18300f -size 1209 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/AvailableForDownload/Light.png b/test/componentFixtures/.screenshots/baseline/updateWidget/AvailableForDownload/Light.png deleted file mode 100644 index 5baee833287..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/AvailableForDownload/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6d69bf362154015616fbb8c95052466061b7982f54203faea3ae89ad5afeaec3 -size 1221 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/CheckingForUpdates/Dark.png b/test/componentFixtures/.screenshots/baseline/updateWidget/CheckingForUpdates/Dark.png deleted file mode 100644 index 1197206909f..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/CheckingForUpdates/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:43c9b8702922e8892a0cf20afc053e8ac56cdff65c02a9d47cb0183262da7bd4 -size 1908 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/CheckingForUpdates/Light.png b/test/componentFixtures/.screenshots/baseline/updateWidget/CheckingForUpdates/Light.png deleted file mode 100644 index 11d36a29387..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/CheckingForUpdates/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e0a17ec5b2f0e18be88383e491a09260654ce828236e37c11d5028e6ac326daa -size 1924 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloaded/Dark.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Downloaded/Dark.png deleted file mode 100644 index 0e6b503cc23..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloaded/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6897095d4c9d17a31bce693d2ce0f800d13ec56f2e27f4c5ec303ee64c7d19c3 -size 2189 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloaded/Light.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Downloaded/Light.png deleted file mode 100644 index 91af710d879..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloaded/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3491ca579cf94b7148f4a943dc1e1a3eb44dd444d5268963c6a15a949d13bf88 -size 2056 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading0Percent/Dark.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading0Percent/Dark.png deleted file mode 100644 index db4f67e3b5d..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading0Percent/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f3345ce1dc95c59d627fd618ddbf2f4028f0e7b13528bd3325fe6882cfb73891 -size 1781 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading0Percent/Light.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading0Percent/Light.png deleted file mode 100644 index a59cf06f888..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading0Percent/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7d4921395a33a571db185f7015160a23ee6e8ba3f82281a8ab4a935e43950b44 -size 1811 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading100Percent/Dark.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading100Percent/Dark.png deleted file mode 100644 index 28c4dcb5478..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading100Percent/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a7bc3e108e03debccd5881474ff30f42bf5c56a43a7b4525d3d2fc16b075b56d -size 2482 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading100Percent/Light.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading100Percent/Light.png deleted file mode 100644 index 118bab91cc1..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading100Percent/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8df766b159576a0c12683af17f7d9ed391a2bd765743494f3760d8b415380c22 -size 2252 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading30Percent/Dark.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading30Percent/Dark.png deleted file mode 100644 index 9bb317db875..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading30Percent/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1c1f6f054881033c6162405b99e99ce8f049b9b20d320a74abc07793987474d9 -size 2249 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading30Percent/Light.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading30Percent/Light.png deleted file mode 100644 index c97505c005c..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading30Percent/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4b6101c92d7073116c81eba73ddc08c721c2e947b4f7714887c54ffb91284d4e -size 2138 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading65Percent/Dark.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading65Percent/Dark.png deleted file mode 100644 index c1853513bec..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading65Percent/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3179d62298aff75dfc01442a4589a61d83b6ba7f14c4b8441c9a688ca655738d -size 2434 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading65Percent/Light.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading65Percent/Light.png deleted file mode 100644 index 8060149471e..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Downloading65Percent/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5e9dbcbf22233e5b48617067c8993a51c6b10afd940cd499270183e8eb4430f4 -size 2217 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/DownloadingIndeterminate/Dark.png b/test/componentFixtures/.screenshots/baseline/updateWidget/DownloadingIndeterminate/Dark.png deleted file mode 100644 index 4b0fc104ee8..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/DownloadingIndeterminate/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:13295a45a513349c0c8ae6129301d5f2fb89549cae3b4453c6962a770b6290ec -size 3716 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/DownloadingIndeterminate/Light.png b/test/componentFixtures/.screenshots/baseline/updateWidget/DownloadingIndeterminate/Light.png deleted file mode 100644 index d7a897ebda5..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/DownloadingIndeterminate/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:21dbc34d1ec2776453bc176631f2a0e9a03f7fcd528369efcb40fd5c49536c92 -size 3837 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Overwriting/Dark.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Overwriting/Dark.png deleted file mode 100644 index db4f67e3b5d..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Overwriting/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f3345ce1dc95c59d627fd618ddbf2f4028f0e7b13528bd3325fe6882cfb73891 -size 1781 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Overwriting/Light.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Overwriting/Light.png deleted file mode 100644 index a59cf06f888..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Overwriting/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7d4921395a33a571db185f7015160a23ee6e8ba3f82281a8ab4a935e43950b44 -size 1811 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Ready/Dark.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Ready/Dark.png deleted file mode 100644 index 017a1ebcebe..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Ready/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f97568f25aeafee2be621a345675fb29afe6c17a6655eeb2b053e09a671f2fe7 -size 2116 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Ready/Light.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Ready/Light.png deleted file mode 100644 index 230402c3327..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Ready/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:faf0132e6298ffe9f285c4a2d6b7a0ef13c440175a22fa8febaff6b881cb4907 -size 1932 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Updating/Dark.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Updating/Dark.png deleted file mode 100644 index 4024eff5bc9..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Updating/Dark.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:63aa8f046ab23ac6bd53722db86fd254c7cd88a487f45feb68cc9a9bbd18300f -size 1209 diff --git a/test/componentFixtures/.screenshots/baseline/updateWidget/Updating/Light.png b/test/componentFixtures/.screenshots/baseline/updateWidget/Updating/Light.png deleted file mode 100644 index 5baee833287..00000000000 --- a/test/componentFixtures/.screenshots/baseline/updateWidget/Updating/Light.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6d69bf362154015616fbb8c95052466061b7982f54203faea3ae89ad5afeaec3 -size 1221 diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index f3a0ceb0bff..311b01f21bf 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -22,9 +22,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.10", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz", + "integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -489,12 +489,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", + "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -702,9 +702,9 @@ } }, "node_modules/hono": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", - "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", + "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -753,9 +753,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/test/monaco/package-lock.json b/test/monaco/package-lock.json index 088444ad194..3e3df8a1775 100644 --- a/test/monaco/package-lock.json +++ b/test/monaco/package-lock.json @@ -8,6 +8,9 @@ "name": "test-monaco", "version": "1.0.0", "license": "MIT", + "dependencies": { + "postcss": "^8.5.6" + }, "devDependencies": { "@types/chai": "^4.2.14", "axe-playwright": "^2.1.0", @@ -130,9 +133,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", - "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.4.0.tgz", + "integrity": "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==", "dev": true, "license": "MIT", "dependencies": { @@ -388,16 +391,16 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { "type": "github", @@ -422,17 +425,38 @@ } } }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", "peerDependencies": { - "ajv": "^8.8.2" + "ajv": "^6.9.1" } }, "node_modules/assertion-error": { @@ -552,9 +576,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001775", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", - "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", "dev": true, "funding": [ { @@ -727,9 +751,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", "dev": true, "license": "ISC" }, @@ -906,59 +930,6 @@ "webpack": "^4.0.0 || ^5.0.0" } }, - "node_modules/file-loader/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/file-loader/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/file-loader/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/file-loader/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1152,9 +1123,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, @@ -1305,7 +1276,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -1328,9 +1298,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", "dev": true, "license": "MIT" }, @@ -1413,7 +1383,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/pkg-dir": { @@ -1433,7 +1402,6 @@ "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1552,16 +1520,6 @@ "node": ">=6" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -1629,38 +1587,16 @@ "node": ">=8" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", "dev": true, "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" }, "engines": { "node": ">= 10.13.0" @@ -1680,16 +1616,6 @@ "semver": "bin/semver.js" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -1740,7 +1666,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -1837,16 +1762,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", "terser": "^5.31.1" }, "engines": { @@ -1871,6 +1795,63 @@ } } }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -1959,9 +1940,9 @@ } }, "node_modules/webpack": { - "version": "5.105.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.3.tgz", - "integrity": "sha512-LLBBA4oLmT7sZdHiYE/PeVuifOxYyE2uL/V+9VQP7YSYdJU7bSf7H8bZRRxW8kEPMkmVjnrXmoR3oejIdX0xbg==", + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "dev": true, "license": "MIT", "dependencies": { @@ -1975,7 +1956,7 @@ "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.20.0", "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -1987,7 +1968,7 @@ "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.16", + "terser-webpack-plugin": "^5.3.17", "watchpack": "^2.5.1", "webpack-sources": "^3.3.4" }, @@ -2088,6 +2069,63 @@ "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/test/monaco/package.json b/test/monaco/package.json index d5cfcacffac..89902f2304f 100644 --- a/test/monaco/package.json +++ b/test/monaco/package.json @@ -20,5 +20,8 @@ "warnings-to-errors-webpack-plugin": "^2.3.0", "webpack": "^5.105.0", "webpack-cli": "^5.1.4" + }, + "dependencies": { + "postcss": "^8.5.6" } } diff --git a/test/monaco/tsconfig.json b/test/monaco/tsconfig.json index dcc46d24c5a..75bd907e41a 100644 --- a/test/monaco/tsconfig.json +++ b/test/monaco/tsconfig.json @@ -9,6 +9,9 @@ "sourceMap": true, "skipLibCheck": true, "declaration": true, + "types": [ + "mocha" + ], "lib": [ "esnext", // for #201187 "dom" diff --git a/test/sanity/package-lock.json b/test/sanity/package-lock.json index 79d178a5f5c..c441eb7cf18 100644 --- a/test/sanity/package-lock.json +++ b/test/sanity/package-lock.json @@ -918,15 +918,6 @@ "node": ">=18" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -949,33 +940,13 @@ "node": ">=0.10.0" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.4.tgz", + "integrity": "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==", "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/shebang-command": { diff --git a/test/sanity/package.json b/test/sanity/package.json index 0b281e7a2ca..93d586f98e0 100644 --- a/test/sanity/package.json +++ b/test/sanity/package.json @@ -20,5 +20,8 @@ "@types/mocha": "^10.0.10", "@types/node": "22.x", "typescript": "^6.0.0-dev.20251110" + }, + "overrides": { + "serialize-javascript": "^7.0.3" } } diff --git a/test/sanity/src/uiTest.ts b/test/sanity/src/uiTest.ts index 582c4eeab3b..65f8b14a49e 100644 --- a/test/sanity/src/uiTest.ts +++ b/test/sanity/src/uiTest.ts @@ -162,7 +162,7 @@ export class UITest { await installButton.click(); this.context.log('Waiting for extension to be installed'); - await page.locator('.extension-action:not(.disabled)', { hasText: /Uninstall/ }).waitFor({ timeout: 5 * 60_1000 }); + await page.getByRole('button', { name: 'Uninstall' }).first().waitFor({ timeout: 5 * 60_000 }); } /**