diff --git a/.eslint-plugin-local/code-no-icons-in-localized-strings.ts b/.eslint-plugin-local/code-no-icons-in-localized-strings.ts new file mode 100644 index 00000000000..8f4251dfd41 --- /dev/null +++ b/.eslint-plugin-local/code-no-icons-in-localized-strings.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 eslint from 'eslint'; +import { TSESTree } from '@typescript-eslint/utils'; + +/** + * Prevents theme icon syntax `$(iconName)` from appearing inside localized + * string arguments. Localizers may translate or corrupt the icon syntax, + * breaking rendering. Icon references should be kept outside the localized + * string - either prepended via concatenation or passed as a placeholder + * argument. + * + * Examples: + * ❌ localize('key', "$(gear) Settings") + * ✅ '$(gear) ' + localize('key', "Settings") + * ✅ localize('key', "Like {0}", '$(gear)') + * + * ❌ nls.localize('key', "$(loading~spin) Loading...") + * ✅ '$(loading~spin) ' + nls.localize('key', "Loading...") + */ +export default new class NoIconsInLocalizedStrings implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noIconInLocalizedString: 'Theme icon syntax $(…) should not appear inside localized strings. Move it outside the localize call or pass it as a placeholder argument.' + }, + docs: { + description: 'Prevents $(icon) theme icon syntax inside localize() string arguments', + }, + type: 'problem', + schema: false, + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + + // Matches $(iconName) or $(iconName~modifier) but not escaped \$(...) + const iconPattern = /(? checkCallExpression(node as TSESTree.CallExpression) + }; + } +}; diff --git a/.eslint-plugin-local/code-no-static-node-module-import.ts b/.eslint-plugin-local/code-no-static-node-module-import.ts new file mode 100644 index 00000000000..674e5f9e6eb --- /dev/null +++ b/.eslint-plugin-local/code-no-static-node-module-import.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TSESTree } from '@typescript-eslint/typescript-estree'; +import * as eslint from 'eslint'; +import { builtinModules } from 'module'; +import { join, normalize, relative } from 'path'; +import minimatch from 'minimatch'; +import { createImportRuleListener } from './utils.ts'; + +const nodeBuiltins = new Set([ + ...builtinModules, + ...builtinModules.map(m => `node:${m}`) +]); + +const REPO_ROOT = normalize(join(import.meta.dirname, '../')); + +export default new class implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + staticImport: 'Static imports of \'{{module}}\' are not allowed here because they are loaded synchronously on startup. Use a dynamic `await import(...)` or `import type` instead.' + }, + docs: { + description: 'Disallow static imports of node_modules packages to prevent synchronous loading on startup. Allows Node.js built-ins, electron, relative imports, and whitelisted file paths.' + }, + schema: { + type: 'array', + items: { + type: 'string' + } + } + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + const allowedPaths = context.options as string[]; + const filePath = normalize(relative(REPO_ROOT, normalize(context.getFilename()))).replace(/\\/g, '/'); + + // Skip whitelisted files + if (allowedPaths.some(pattern => filePath === pattern || minimatch(filePath, pattern))) { + return {}; + } + + return createImportRuleListener((node, value) => { + // Allow `import type` and `export type` declarations + if (node.parent?.type === TSESTree.AST_NODE_TYPES.ImportDeclaration && node.parent.importKind === 'type') { + return; + } + if (node.parent && 'exportKind' in node.parent && node.parent.exportKind === 'type') { + return; + } + + // Allow relative imports + if (value.startsWith('.')) { + return; + } + + // Allow Node.js built-in modules + if (nodeBuiltins.has(value)) { + return; + } + + // Allow electron + if (value === 'electron') { + return; + } + + context.report({ + loc: node.parent!.loc, + messageId: 'staticImport', + data: { + module: value + } + }); + }); + } +}; diff --git a/.eslint-plugin-local/code-no-telemetry-common-property.ts b/.eslint-plugin-local/code-no-telemetry-common-property.ts new file mode 100644 index 00000000000..2627a09c0a4 --- /dev/null +++ b/.eslint-plugin-local/code-no-telemetry-common-property.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; +import type * as ESTree from 'estree'; + +const telemetryMethods = new Set(['publicLog', 'publicLog2', 'publicLogError', 'publicLogError2']); + +/** + * Common telemetry property names that are automatically added to every event. + * Telemetry events must not set these because they would collide with / be + * overwritten by the common properties that the telemetry pipeline injects. + * + * Collected from: + * - src/vs/platform/telemetry/common/commonProperties.ts (resolveCommonProperties) + * - src/vs/workbench/services/telemetry/common/workbenchCommonProperties.ts + * - src/vs/workbench/services/telemetry/browser/workbenchCommonProperties.ts + */ +const commonTelemetryProperties = new Set([ + 'common.machineid', + 'common.sqmid', + 'common.devdeviceid', + 'sessionid', + 'commithash', + 'version', + 'common.releasedate', + 'common.platformversion', + 'common.platform', + 'common.nodeplatform', + 'common.nodearch', + 'common.product', + 'common.msftinternal', + 'timestamp', + 'common.timesincesessionstart', + 'common.sequence', + 'common.snap', + 'common.platformdetail', + 'common.version.shell', + 'common.version.renderer', + 'common.firstsessiondate', + 'common.lastsessiondate', + 'common.isnewsession', + 'common.remoteauthority', + 'common.cli', + 'common.useragent', + 'common.istouchdevice', +]); + +export default new class NoTelemetryCommonProperty implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noCommonProperty: 'Telemetry events must not contain the common property "{{name}}". Common properties are automatically added by the telemetry pipeline and will be dropped.', + }, + schema: false, + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + + /** + * Check whether any property key in an object expression is a reserved common telemetry property. + */ + function checkObjectForCommonProperties(node: ESTree.ObjectExpression) { + for (const prop of node.properties) { + if (prop.type === 'Property') { + let name: string | undefined; + if (prop.key.type === 'Identifier') { + name = prop.key.name; + } else if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') { + name = prop.key.value; + } + if (name && commonTelemetryProperties.has(name.toLowerCase())) { + context.report({ + node: prop.key, + messageId: 'noCommonProperty', + data: { name }, + }); + } + } + } + } + + return { + ['CallExpression[callee.property.type="Identifier"]'](node: ESTree.CallExpression) { + const callee = node.callee; + if (callee.type !== 'MemberExpression') { + return; + } + const prop = callee.property; + if (prop.type !== 'Identifier' || !telemetryMethods.has(prop.name)) { + return; + } + // The data argument is the second argument for publicLog/publicLog2/publicLogError/publicLogError2 + const dataArg = node.arguments[1]; + if (dataArg && dataArg.type === 'ObjectExpression') { + checkObjectForCommonProperties(dataArg); + } + }, + }; + } +}; diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8d56465c45a..54502973051 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -145,6 +145,7 @@ function f(x: number, y: string): void { } - You MUST NOT use storage keys of another component only to make changes to that component. You MUST come up with proper API to change another component. - Use `IEditorService` to open editors instead of `IEditorGroupsService.activeGroup.openEditor` to ensure that the editor opening logic is properly followed and to avoid bypassing important features such as `revealIfOpened` or `preserveFocus`. - Avoid using `bind()`, `call()` and `apply()` solely to control `this` or partially apply arguments; prefer arrow functions or closures to capture the necessary context, and use these methods only when required by an API or interoperability. +- Avoid using events to drive control flow between components. Instead, prefer direct method calls or service interactions to ensure clearer dependencies and easier traceability of logic. Events should be reserved for broadcasting state changes or notifications rather than orchestrating behavior across components. ## Learnings - Minimize the amount of assertions in tests. Prefer one snapshot-style `assert.deepStrictEqual` over multiple precise assertions, as they are much more difficult to understand and to update. diff --git a/.github/instructions/remoteAgentHost.instructions.md b/.github/instructions/remoteAgentHost.instructions.md new file mode 100644 index 00000000000..aa3f24b290e --- /dev/null +++ b/.github/instructions/remoteAgentHost.instructions.md @@ -0,0 +1,35 @@ +--- +description: Architecture documentation for remote agent host connections. Use when working in `src/vs/sessions/contrib/remoteAgentHost` +applyTo: src/vs/sessions/contrib/remoteAgentHost/** +--- + +# Remote Agent Host + +The remote agent host feature connects the sessions app to agent host processes running on other machines over WebSocket. + +## Key Files + +- `ARCHITECTURE.md` - full architecture documentation (URI conventions, registration flow, data flow diagram) +- `REMOTE_AGENT_HOST_RECONNECTION.md` - reconnection lifecycle spec (15 numbered requirements) +- `browser/remoteAgentHost.contribution.ts` - central orchestrator +- `browser/agentHostFileSystemProvider.ts` - read-only FS provider for remote browsing + +## Architecture Documentation + +When making changes to this feature area, **review and update `ARCHITECTURE.md`** if your changes affect: + +- Connection lifecycle (connect, disconnect, reconnect) +- Agent registration flow +- URI conventions or naming +- Session creation flow +- The data flow diagram + +The doc lives at `src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md`. + +## Related Code Outside This Folder + +- `src/vs/platform/agentHost/common/remoteAgentHostService.ts` - service interface (`IRemoteAgentHostService`) +- `src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts` - Electron implementation +- `src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts` - WebSocket protocol client +- `src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts` - session list sidebar +- `src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts` - session content provider diff --git a/.github/prompts/fix-error.prompt.md b/.github/prompts/fix-error.prompt.md index fcd757363fa..3781f160e76 100644 --- a/.github/prompts/fix-error.prompt.md +++ b/.github/prompts/fix-error.prompt.md @@ -23,15 +23,32 @@ After the fix is validated (compilation clean, tests pass): 1. **Create a branch**: `git checkout -b /` (e.g., `bryanchen-d/fix-notebook-index-error`). 2. **Commit**: Stage changed files and commit with a message like `fix: (#)`. 3. **Push**: `git push -u origin `. -4. **Create a draft PR** with a description that includes: - - A summary of the change. - - What scenarios may trigger the error. - - The code flow explaining why the error gets thrown and goes unhandled. - - Steps a user can follow to manually validate the fix. - - How the fix addresses the issue, with a brief note per changed file. -5. **Monitor the PR** for Copilot review comments. Wait 1-2 minutes after each push for Copilot to leave its review, then check for new comments. Evaluate each comment: - - If valid, apply the fix, amend the commit, and force-push. - - If not applicable, leave a reply explaining why. - - After addressing comments, update the PR description if the changes affect the summary, code flow explanation, or per-file notes. -6. **Repeat monitoring** after each force-push: wait 1-2 minutes, check for new Copilot comments, and address them. Continue this loop until no new comments appear. -7. **Re-run tests** after addressing review comments to confirm nothing regressed. +4. **Create a draft PR** with a description that includes these sections: + - **Summary**: A concise description of what was changed and why. + - **Issue link**: `Fixes #` so GitHub auto-closes the issue when the PR merges. + - **Trigger scenarios**: What user actions or system conditions cause this error to surface. + - **Code flow diagram**: A Mermaid swimlane/sequence diagram showing the call chain from trigger to error. Use participant labels for the key components (e.g., classes, modules, processes). Example: + ```` + ```mermaid + sequenceDiagram + participant A as CallerComponent + participant B as MiddleLayer + participant C as LowLevelUtil + A->>B: someOperation(data) + B->>C: validate(data) + C-->>C: data is invalid + C->>B: throws "error message" + B->>A: unhandled error propagates + ``` + ```` + - **Manual validation steps**: Concrete, step-by-step instructions a reviewer can follow to reproduce the original error and verify the fix. Include specific setup requirements (e.g., file types to open, settings to change, actions to perform). If the error cannot be easily reproduced manually, explain why and describe what alternative validation was performed (e.g., unit tests, code inspection). + - **How the fix works**: A brief explanation of the fix approach, with a note per changed file. +5. **Monitor the PR — BLOCKING**: You MUST NOT complete the task until the monitoring loop below is done. + - Wait 2 minutes after each push, then check for Copilot review comments using `gh pr view --json reviews,comments` and `gh api repos/{owner}/{repo}/pulls/{number}/comments`. + - If there are review comments, evaluate each one: + - If valid, apply the fix in a new commit, push, and **resolve the comment thread** using the GitHub GraphQL API (`resolveReviewThread` mutation with the thread's node ID). + - If not applicable, leave a reply explaining why. + - After addressing comments, update the PR description if the changes affect the summary, diagram, or per-file notes. + - **Re-run tests** after addressing review comments to confirm nothing regressed. + - After each push, repeat the wait-and-check cycle. Continue until **two consecutive checks return zero new comments**. +6. **Verify CI**: After the monitoring loop is done, check that CI checks are passing using `gh pr checks `. If any required checks fail, investigate and fix. Do NOT complete the task with failing CI. diff --git a/.github/skills/chat-customizations-editor/SKILL.md b/.github/skills/chat-customizations-editor/SKILL.md new file mode 100644 index 00000000000..b90fb5b46cf --- /dev/null +++ b/.github/skills/chat-customizations-editor/SKILL.md @@ -0,0 +1,42 @@ +--- +name: chat-customizations-editor +description: Use when working on the Chat Customizations editor — the management UI for agents, skills, instructions, hooks, prompts, MCP servers, and plugins. +--- + +# Chat Customizations Editor + +Split-view management pane for AI customization items across workspace, user, extension, and plugin storage. Supports harness-based filtering (Local, Copilot CLI, Claude). + +## Spec + +**`src/vs/sessions/AI_CUSTOMIZATIONS.md`** — always read before making changes, always update after. + +## Key Folders + +| Folder | What | +|--------|------| +| `src/vs/workbench/contrib/chat/common/` | `ICustomizationHarnessService`, `ISectionOverride`, `IStorageSourceFilter` — shared interfaces and filter helpers | +| `src/vs/workbench/contrib/chat/browser/aiCustomization/` | Management editor, list widgets (prompts, MCP, plugins), harness service registration | +| `src/vs/sessions/contrib/chat/browser/` | Sessions-window overrides (harness service, workspace service) | +| `src/vs/sessions/contrib/sessions/browser/` | Sessions tree view counts and toolbar | + +When changing harness descriptor interfaces or factory functions, verify both core and sessions registrations compile. + +## Key Interfaces + +- **`IHarnessDescriptor`** — drives all UI behavior declaratively (hidden sections, button overrides, file filters, agent gating). See spec for full field reference. +- **`ISectionOverride`** — per-section button customization (command invocation, root file creation, type labels, file extensions). +- **`IStorageSourceFilter`** — controls which storage sources and user roots are visible per harness/type. + +Principle: the UI widgets read everything from the descriptor — no harness-specific conditionals in widget code. + +## Testing + +Component explorer fixtures (see `component-fixtures` skill): `aiCustomizationListWidget.fixture.ts`, `aiCustomizationManagementEditor.fixture.ts` under `src/vs/workbench/test/browser/componentFixtures/`. + +```bash +./scripts/test.sh --grep "applyStorageSourceFilter|customizationCounts" +npm run compile-check-ts-native && npm run valid-layers-check +``` + +See the `sessions` skill for sessions-window specific guidance. diff --git a/.github/skills/fix-ci-failures/SKILL.md b/.github/skills/fix-ci-failures/SKILL.md new file mode 100644 index 00000000000..4e05478ad78 --- /dev/null +++ b/.github/skills/fix-ci-failures/SKILL.md @@ -0,0 +1,269 @@ +--- +name: fix-ci-failures +description: Investigate and fix CI failures on a pull request. Use when CI checks fail on a PR branch — covers finding the PR, identifying failed checks, downloading logs and artifacts, extracting the failure cause, and iterating on a fix. Requires the `gh` CLI. +--- + +# Investigating and Fixing CI Failures + +This skill guides you through diagnosing and fixing CI failures on a PR using the `gh` CLI. The user has the PR branch checked out locally. + +## Workflow Overview + +1. Identify the current branch and its PR +2. Check CI status and find failed checks +3. Download logs for failed jobs +4. Extract and understand the failure +5. Fix the issue and push + +--- + +## Step 1: Identify the Branch and PR + +```bash +# Get the current branch name +git branch --show-current + +# Find the PR for this branch +gh pr view --json number,title,url,statusCheckRollup +``` + +If no PR is found, the user may need to specify the PR number. + +--- + +## Step 2: Check CI Status + +```bash +# List all checks and their status (pass/fail/pending) +gh pr checks --json name,state,link,bucket + +# Filter to only failed checks +gh pr checks --json name,state,link,bucket --jq '.[] | select(.bucket == "fail")' +``` + +The `link` field contains the URL to the GitHub Actions job. Extract the **run ID** from the URL — it's the number after `/runs/`: +``` +https://github.com/microsoft/vscode/actions/runs//job/ +``` + +If checks are still `IN_PROGRESS`, wait for them to complete before downloading logs: +```bash +gh pr checks --watch --fail-fast +``` + +--- + +## Step 3: Get Failed Job Details + +```bash +# List failed jobs in a run (use the run ID from the check link) +gh run view --json jobs --jq '.jobs[] | select(.conclusion == "failure") | {name: .name, id: .databaseId}' +``` + +--- + +## Step 4: Download Failure Logs + +There are two approaches depending on the type of failure. + +### Option A: View Failed Step Logs Directly + +Best for build/compile/lint failures where the error is in the step output: + +```bash +# View only the failed step logs (most useful — shows just the errors) +gh run view --job --log-failed +``` + +> **Important**: `--log-failed` requires the **entire run** to complete, not just the failed job. If other jobs are still running, this command will block or error. Use **Option C** below to get logs for a completed job while the run is still in progress. + +The output can be large. Pipe through `tail` or `grep` to focus: +```bash +# Last 100 lines of failed output +gh run view --job --log-failed | tail -100 + +# Search for common error patterns +gh run view --job --log-failed | grep -E "Error|FAIL|error TS|AssertionError|failing" +``` + +### Option B: Download Artifacts + +Best for integration test failures where detailed logs (terminal logs, ext host logs, crash dumps) are uploaded as artifacts: + +```bash +# List available artifacts for a run +gh run download --pattern '*' --dir /dev/null 2>&1 || gh run view --json jobs --jq '.jobs[].name' + +# Download log artifacts for a specific failed job +# Artifact naming convention: logs---- +# Examples: logs-linux-x64-electron-1, logs-linux-x64-remote-1 +gh run download -n "logs-linux-x64-electron-1" -D /tmp/ci-logs + +# Download crash dumps if available +gh run download -n "crash-dump-linux-x64-electron-1" -D /tmp/ci-crashes +``` + +> **Tip**: Use the test runner name from the failed check (e.g., "Linux / Electron" → `electron`, "Linux / Remote" → `remote`) and platform map ("Windows" → `windows-x64`, "Linux" → `linux-x64`, "macOS" → `macos-arm64`) to construct the artifact name. + +> **Warning**: Log artifacts may be empty if the test runner crashed before producing output (e.g., Electron download failure). In that case, fall back to **Option C**. + +### Option C: Download Per-Job Logs via API (works while run is in progress) + +When the run is still in progress but the failed job has completed, use the GitHub API to download that job's step logs directly: + +```bash +# Save the full job log to a temp file (can be very large — 30k+ lines) +gh api repos/microsoft/vscode/actions/jobs//logs > "$TMPDIR/ci-job-log.txt" +``` + +Then search the saved file. **Start with `##[error]`** — this is the GitHub Actions error annotation that marks the exact line where the step failed: + +```bash +# Step 1: Find the error annotation (fastest path to the failure) +grep -n '##\[error\]' "$TMPDIR/ci-job-log.txt" + +# Step 2: Read context around the error (e.g., if error is on line 34371, read 200 lines before it) +sed -n '34171,34371p' "$TMPDIR/ci-job-log.txt" +``` + +If `##[error]` doesn't reveal enough, use broader patterns: +```bash +# Find test failures, exceptions, and crash indicators +grep -n -E 'HTTPError|ECONNRESET|ETIMEDOUT|502|exit code|Process completed|node:internal|triggerUncaughtException' "$TMPDIR/ci-job-log.txt" | head -20 +``` + +> **Why save to a file?** The API response for a full job log can be 30k+ lines. Tool output gets truncated, so always redirect to a file first, then search. + +### VS Code Log Artifacts Structure + +Downloaded log artifacts typically contain: +``` +logs-linux-x64-electron-1/ + main.log # Main process log + terminal.log # Terminal/pty host log (key for run_in_terminal issues) + window1/ + renderer.log # Renderer process log + exthost/ + exthost.log # Extension host log (key for extension test failures) +``` + +Key files to examine first: +- **Test assertion failures**: Check `exthost.log` for the extension host output and stack traces +- **Terminal/sandbox issues**: Check `terminal.log` for rewriter pipeline, shell integration, and strategy logs +- **Crash/hang**: Check `main.log` and look for crash dumps artifacts + +--- + +## Step 5: Extract the Failure + +### For Test Failures + +Look for the test runner output in the failed step log: +```bash +# Find failing test names and assertion messages +gh run view --job --log-failed | grep -A 5 "failing\|AssertionError\|Expected\|Unexpected" +``` + +Common patterns in VS Code CI: +- **`AssertionError [ERR_ASSERTION]`**: Test assertion failed — check expected vs actual values +- **`Extension host test runner exit code: 1`**: Integration test suite had failures +- **`Command produced no output`**: Shell integration may not have captured command output (see terminal.log) +- **`Error: Timeout`**: Test timed out — could be a hang or slow CI machine + +### For Build Failures + +```bash +# Find TypeScript compilation errors +gh run view --job --log-failed | grep "error TS" + +# Find hygiene/lint errors +gh run view --job --log-failed | grep -E "eslint|stylelint|hygiene" +``` + +--- + +## Step 6: Determine if Failures are Related to the PR + +Before fixing, determine if the failure is caused by the PR changes or is a pre-existing/infrastructure issue: + +1. **Check if the failing test is in code you changed** — if the test is in a completely unrelated area, it may be a flake +2. **Check the test name** — does it relate to the feature area you modified? +3. **Look at the failure output** — does it reference code paths your PR touches? +4. **Check if the same tests fail on main** — if identical failures exist on recent main commits, it's a pre-existing issue +5. **Look for infrastructure failures** — network timeouts, npm registry errors, and machine-level issues are not caused by code changes + +```bash +# Check recent runs on main for the same workflow +gh run list --branch main --workflow pr-linux-test.yml --limit 5 --json databaseId,conclusion,displayTitle +``` + +### Recognizing Infrastructure / Flaky Failures + +Not all CI failures are caused by code changes. Common infrastructure failures: + +**Network / Registry issues**: +- `npm ERR! network`, `ETIMEDOUT`, `ECONNRESET`, `EAI_AGAIN` — npm registry unreachable +- `error: RPC failed; curl 56`, `fetch-pack: unexpected disconnect` — git network failure +- `Error: unable to get local issuer certificate` — TLS/certificate issues +- `rate limit exceeded` — GitHub API rate limiting +- `HTTPError: Request failed with status code 502` on `electron/electron/releases` — Electron CDN download failure (common in the `node.js integration tests` step, which downloads Electron at runtime) + +**Machine / Environment issues**: +- `No space left on device` — CI disk full +- `ENOMEM`, `JavaScript heap out of memory` — CI machine ran out of memory +- `The runner has received a shutdown signal` — CI preemption / timeout +- `Error: The operation was canceled` — GitHub Actions cancelled the job +- `Xvfb failed to start` — display server for headless Linux tests failed + +**Test flakes** (not infrastructure, but not your fault either): +- Timeouts on tests that normally pass — slow CI machine +- Race conditions in async tests +- Shell integration not reporting exit codes (see terminal.log for `exitCode: undefined`) + +**What to do with infrastructure failures**: +1. **Don't change code** — the failure isn't caused by your PR +2. **Re-run the failed jobs** via the GitHub UI or: + ```bash + gh run rerun --failed + ``` +3. If failures persist across re-runs, check if main is also broken: + ```bash + gh run list --branch main --limit 10 --json databaseId,conclusion,displayTitle + ``` +4. If main is broken too, wait for it to be fixed — your PR is not the cause + +--- + +## Step 7: Fix and Iterate + +1. Make the fix locally +2. Verify compilation: check the `VS Code - Build` task or run `npm run compile-check-ts-native` +3. Run relevant unit tests locally: `./scripts/test.sh --grep ""` +4. Commit and push: + ```bash + git add -A + git commit -m "fix: " + git push + ``` +5. Watch CI again: + ```bash + gh pr checks --watch --fail-fast + ``` + +--- + +## Quick Reference + +| Task | Command | +|------|---------| +| Find PR for branch | `gh pr view --json number,url` | +| List all checks | `gh pr checks --json name,state,bucket` | +| List failed checks only | `gh pr checks --json name,state,link,bucket --jq '.[] \| select(.bucket == "fail")'` | +| Watch checks until done | `gh pr checks --watch --fail-fast` | +| Failed jobs in a run | `gh run view --json jobs --jq '.jobs[] \| select(.conclusion == "failure") \| {name, id: .databaseId}'` | +| View failed step logs | `gh run view --job --log-failed` (requires full run to complete) | +| Download job log via API | `gh api repos/microsoft/vscode/actions/jobs//logs > "$TMPDIR/ci-job-log.txt"` (works while run is in progress) | +| Find error line in log | `grep -n '##\[error\]' "$TMPDIR/ci-job-log.txt"` | +| Download log artifacts | `gh run download -n "" -D /tmp/ci-logs` | +| Re-run failed jobs | `gh run rerun --failed` | +| Recent main runs | `gh run list --branch main --workflow .yml --limit 5` | diff --git a/.github/skills/unit-tests/SKILL.md b/.github/skills/unit-tests/SKILL.md new file mode 100644 index 00000000000..f2a8b66c5a3 --- /dev/null +++ b/.github/skills/unit-tests/SKILL.md @@ -0,0 +1,87 @@ +--- +name: unit-tests +description: Use when running unit tests in the VS Code repo. Covers the runTests tool, scripts/test.sh (macOS/Linux) and scripts/test.bat (Windows), and their supported arguments for filtering, globbing, and debugging tests. +--- + +# Running Unit Tests + +## Preferred: Use the `runTests` tool + +If the `runTests` tool is available, **prefer it** over running shell commands. It provides structured output with detailed pass/fail information and supports filtering by file and test name. + +- Pass absolute paths to test files via the `files` parameter. +- Pass test names via the `testNames` parameter to filter which tests run. +- Set `mode="coverage"` to collect coverage. + +Example (conceptual): run tests in `src/vs/editor/test/common/model.test.ts` with test name filter `"should split lines"`. + +## Fallback: Shell scripts + +When the `runTests` tool is not available (e.g. in CLI environments), use the platform-appropriate script from the repo root: + +- **macOS / Linux:** `./scripts/test.sh [options]` +- **Windows:** `.\scripts\test.bat [options]` + +These scripts download Electron if needed and launch the Mocha test runner. + +### Commonly used options + +#### `--run ` - Run tests from a specific file + +Accepts a **source file path** (starting with `src/`). The runner strips the `src/` prefix and the `.ts`/`.js` extension automatically to resolve the compiled module. + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts +``` + +Multiple files can be specified by repeating `--run`: + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --run src/vs/editor/test/common/range.test.ts +``` + +#### `--grep ` (aliases: `-g`, `-f`) - Filter tests by name + +Runs only tests whose full title matches the pattern (passed to Mocha's `--grep`). + +```bash +./scripts/test.sh --grep "should split lines" +``` + +Combine with `--run` to filter tests within a specific file: + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --grep "should split lines" +``` + +#### `--runGlob ` (aliases: `--glob`, `--runGrep`) - Run tests matching a glob + +Runs all test files matching a glob pattern against the compiled output directory. Useful for running all tests under a feature area. + +```bash +./scripts/test.sh --runGlob "**/editor/test/**/*.test.js" +``` + +Note: the glob runs against compiled `.js` files in the output directory, not source `.ts` files. + +#### `--coverage` - Generate a coverage report + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --coverage +``` + +#### `--timeout ` - Set test timeout + +Override the default Mocha timeout for long-running tests. + +```bash +./scripts/test.sh --run src/vs/editor/test/common/model.test.ts --timeout 10000 +``` + +### Integration tests + +Integration tests (files ending in `.integrationTest.ts` or located in `extensions/`) are **not run** by `scripts/test.sh`. Use `scripts/test-integration.sh` (or `scripts/test-integration.bat`) instead. + +### Compilation requirement + +Tests run against compiled JavaScript output. Ensure the `VS Code - Build` watch task is running or that compilation has completed before running tests. Test failures caused by stale output are a common pitfall. diff --git a/.github/workflows/no-package-lock-changes.yml b/.github/workflows/no-package-lock-changes.yml deleted file mode 100644 index 04ea8a43a80..00000000000 --- a/.github/workflows/no-package-lock-changes.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Prevent package-lock.json changes in PRs - -on: pull_request -permissions: {} - -jobs: - main: - name: Prevent package-lock.json changes in PRs - runs-on: ubuntu-latest - steps: - - name: Get file changes - uses: trilom/file-changes-action@ce38c8ce2459ca3c303415eec8cb0409857b4272 - id: file_changes - - name: Check if lockfiles were modified - id: lockfile_check - run: | - if cat $HOME/files.json | jq -e 'any(test("package-lock\\.json$|Cargo\\.lock$"))' > /dev/null; then - echo "lockfiles_modified=true" >> $GITHUB_OUTPUT - echo "Lockfiles were modified in this PR" - else - echo "lockfiles_modified=false" >> $GITHUB_OUTPUT - echo "No lockfiles were modified in this PR" - fi - - name: Prevent Copilot from modifying lockfiles - if: ${{ steps.lockfile_check.outputs.lockfiles_modified == 'true' && github.event.pull_request.user.login == 'Copilot' }} - run: | - echo "Copilot is not allowed to modify package-lock.json or Cargo.lock files." - echo "If you need to update dependencies, please do so manually or through authorized means." - exit 1 - - uses: octokit/request-action@dad4362715b7fb2ddedf9772c8670824af564f0d # v2.4.0 - id: get_permissions - if: ${{ steps.lockfile_check.outputs.lockfiles_modified == 'true' && github.event.pull_request.user.login != 'Copilot' }} - with: - route: GET /repos/microsoft/vscode/collaborators/{username}/permission - username: ${{ github.event.pull_request.user.login }} - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Set control output variable - id: control - if: ${{ steps.lockfile_check.outputs.lockfiles_modified == 'true' && github.event.pull_request.user.login != 'Copilot' }} - run: | - echo "user: ${{ github.event.pull_request.user.login }}" - echo "role: ${{ fromJson(steps.get_permissions.outputs.data).permission }}" - echo "is dependabot: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }}" - echo "should_run: ${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) }}" - echo "should_run=${{ !contains(fromJson('["admin", "maintain", "write"]'), fromJson(steps.get_permissions.outputs.data).permission) && github.event.pull_request.user.login != 'dependabot[bot]' }}" >> $GITHUB_OUTPUT - - name: Check for lockfile changes - if: ${{ steps.lockfile_check.outputs.lockfiles_modified == 'true' && steps.control.outputs.should_run == 'true' }} - run: | - echo "Changes to package-lock.json/Cargo.lock files aren't allowed in PRs." - exit 1 diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index 780a30ded89..619442c637d 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -44,7 +44,9 @@ jobs: libxkbfile-dev \ libkrb5-dev \ libgbm1 \ - rpm + rpm \ + bubblewrap \ + socat sudo cp build/azure-pipelines/linux/xvfb.init /etc/init.d/xvfb sudo chmod +x /etc/init.d/xvfb sudo update-rc.d xvfb defaults diff --git a/.github/workflows/sessions-e2e.yml b/.github/workflows/sessions-e2e.yml index 3d2fa4e4e88..b7047a8e30a 100644 --- a/.github/workflows/sessions-e2e.yml +++ b/.github/workflows/sessions-e2e.yml @@ -1,14 +1,13 @@ name: Sessions E2E Tests -# Disabled: Flaky -# on: -# pull_request: -# branches: -# - main -# - 'release/*' -# paths: -# - 'src/vs/sessions/**' -# - 'scripts/code-sessions-web.*' +on: + pull_request: + branches: + - main + - 'release/*' + paths: + - 'src/vs/sessions/**' + - 'scripts/code-sessions-web.*' permissions: contents: read diff --git a/.npmrc b/.npmrc index 2e08f5efcdd..d0d75ed8d99 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="39.8.2" -ms_build_id="13563792" +target="39.8.3" +ms_build_id="13620978" 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 8927b0b7064..f21e36604fb 100644 --- a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts +++ b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts @@ -112,7 +112,7 @@ export class NpmUpToDateFeature extends vscode.Disposable { } try { const script = path.join(workspaceRoot, 'build', 'npm', 'installStateHash.ts'); - const output = cp.execFileSync(process.execPath, [script], { + const output = cp.execFileSync(process.execPath, [script, '--ignore-node-version'], { cwd: workspaceRoot, timeout: 10_000, encoding: 'utf8', diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index e3ddd3af411..b6e40685d5a 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.112.0\"\n\n$MINE=assignee:@me" + "value": "$MILESTONE=milestone:\"1.113.0\"\n\n$MINE=assignee:@me" }, { "kind": 2, diff --git a/.vscode/notebooks/my-work.github-issues b/.vscode/notebooks/my-work.github-issues index c4bc569e9da..a41aa7f69b7 100644 --- a/.vscode/notebooks/my-work.github-issues +++ b/.vscode/notebooks/my-work.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce repo:microsoft/vscode-copilot-issues repo:microsoft/vscode-extension-samples\n\n// current milestone name\n$MILESTONE=milestone:\"March 2026\"\n" + "value": "// list of repos we work in\n$REPOS=repo:microsoft/lsprotocol repo:microsoft/monaco-editor repo:microsoft/vscode repo:microsoft/vscode-anycode repo:microsoft/vscode-autopep8 repo:microsoft/vscode-black-formatter repo:microsoft/vscode-copilot repo:microsoft/vscode-copilot-release repo:microsoft/vscode-dev repo:microsoft/vscode-dev-chrome-launcher repo:microsoft/vscode-emmet-helper repo:microsoft/vscode-extension-telemetry repo:microsoft/vscode-flake8 repo:microsoft/vscode-github-issue-notebooks repo:microsoft/vscode-hexeditor repo:microsoft/vscode-internalbacklog repo:microsoft/vscode-isort repo:microsoft/vscode-js-debug repo:microsoft/vscode-jupyter repo:microsoft/vscode-jupyter-internal repo:microsoft/vscode-l10n repo:microsoft/vscode-livepreview repo:microsoft/vscode-markdown-languageservice repo:microsoft/vscode-markdown-tm-grammar repo:microsoft/vscode-mypy repo:microsoft/vscode-pull-request-github repo:microsoft/vscode-pylint repo:microsoft/vscode-python repo:microsoft/vscode-python-debugger repo:microsoft/vscode-python-tools-extension-template repo:microsoft/vscode-references-view repo:microsoft/vscode-remote-release repo:microsoft/vscode-remote-repositories-github repo:microsoft/vscode-remote-tunnels repo:microsoft/vscode-remotehub repo:microsoft/vscode-settings-sync-server repo:microsoft/vscode-unpkg repo:microsoft/vscode-vsce repo:microsoft/vscode-copilot-issues repo:microsoft/vscode-extension-samples\n\n// current milestone name\n$MILESTONE=milestone:\"1.113.0\"\n" }, { "kind": 1, diff --git a/.vscode/settings.json b/.vscode/settings.json index d8428d1bd43..99f937bdf9d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,7 +12,6 @@ "chat.tools.edits.autoApprove": { ".github/skills/azure-pipelines/azure-pipeline.ts": false }, - "chat.viewSessions.enabled": true, "chat.editing.explainChanges.enabled": true, // --- Editor --- "editor.insertSpaces": false, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c880e595e15..456e698f558 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -266,7 +266,7 @@ "problemMatcher": [] }, { - "label": "Run and Compile Dev Sessions", + "label": "Run and Compile Sessions - OSS", "type": "shell", "command": "npm run transpile-client && ./scripts/code.sh", "windows": { @@ -278,6 +278,16 @@ "inSessions": true, "problemMatcher": [] }, + { + "label": "Run and Compile Code - OSS", + "type": "shell", + "command": "npm run transpile-client && ./scripts/code.sh", + "windows": { + "command": "npm run transpile-client && .\\scripts\\code.bat" + }, + "inSessions": true, + "problemMatcher": [] + }, { "type": "npm", "script": "electron", diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 0a15b3ff5fc..0922f4228cf 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -2277,7 +2277,7 @@ written authorization of the copyright holder. --------------------------------------------------------- -vscode-codicons 0.0.41 - MIT and Creative Commons Attribution 4.0 +vscode-codicons 0.0.46-0 - MIT and Creative Commons Attribution 4.0 https://github.com/microsoft/vscode-codicons Attribution 4.0 International diff --git a/build/azure-pipelines/common/sanity-tests.yml b/build/azure-pipelines/common/sanity-tests.yml index ce6d95dd7e5..fdf6b2cd3dd 100644 --- a/build/azure-pipelines/common/sanity-tests.yml +++ b/build/azure-pipelines/common/sanity-tests.yml @@ -33,9 +33,9 @@ jobs: outputs: - output: pipelineArtifact targetPath: $(SCREENSHOTS_DIR) - artifactName: screenshots-${{ parameters.name }} + artifactName: screenshots-${{ parameters.name }}-$(System.JobAttempt) displayName: Publish Screenshots - condition: succeededOrFailed() + condition: and(succeededOrFailed(), eq(variables.HAS_SCREENSHOTS, 'true')) continueOnError: true sbomEnabled: false variables: @@ -171,6 +171,25 @@ jobs: condition: and(succeeded(), ne(variables.DOCKER_CACHE_HIT, 'true')) displayName: Save Docker Image + - ${{ if eq(parameters.os, 'windows') }}: + - script: | + @echo off + dir /b "$(SCREENSHOTS_DIR)" 2>nul | findstr . >nul + if %errorlevel%==0 ( + echo ##vso[task.setvariable variable=HAS_SCREENSHOTS]true + ) + exit /b 0 + displayName: Check Screenshots + condition: succeededOrFailed() + + - ${{ else }}: + - bash: | + if [ -n "$(ls -A "$(SCREENSHOTS_DIR)" 2>/dev/null)" ]; then + echo "##vso[task.setvariable variable=HAS_SCREENSHOTS]true" + fi + displayName: Check Screenshots + condition: succeededOrFailed() + - task: PublishTestResults@2 inputs: testResultsFormat: JUnit diff --git a/build/azure-pipelines/linux/product-build-linux-node-modules.yml b/build/azure-pipelines/linux/product-build-linux-node-modules.yml index 290a3fe1b29..ad0e1498160 100644 --- a/build/azure-pipelines/linux/product-build-linux-node-modules.yml +++ b/build/azure-pipelines/linux/product-build-linux-node-modules.yml @@ -41,7 +41,9 @@ jobs: libxkbfile-dev \ libkrb5-dev \ libgbm1 \ - rpm + rpm \ + bubblewrap \ + socat sudo cp build/azure-pipelines/linux/xvfb.init /etc/init.d/xvfb sudo chmod +x /etc/init.d/xvfb sudo update-rc.d xvfb defaults diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index a09758329cb..4b5a5d08cd0 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -48,7 +48,9 @@ steps: libxkbfile-dev \ libkrb5-dev \ libgbm1 \ - rpm + rpm \ + bubblewrap \ + socat sudo cp build/azure-pipelines/linux/xvfb.init /etc/init.d/xvfb sudo chmod +x /etc/init.d/xvfb sudo update-rc.d xvfb defaults diff --git a/build/azure-pipelines/win32/product-build-win32-cli.yml b/build/azure-pipelines/win32/product-build-win32-cli.yml index 78461a959ed..20e49d34866 100644 --- a/build/azure-pipelines/win32/product-build-win32-cli.yml +++ b/build/azure-pipelines/win32/product-build-win32-cli.yml @@ -120,6 +120,9 @@ jobs: SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: ✍️ Codesign + - powershell: Remove-Item -Path "$(Build.ArtifactStagingDirectory)/sign/CodeSignSummary*.md" -Force -ErrorAction SilentlyContinue + displayName: Remove CodeSignSummary + - task: ArchiveFiles@2 displayName: Archive signed CLI inputs: diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 4364e9bfc3e..3a0e930f3f4 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -0f8398b79fb1d6a0036be18c24caef2d48dab9e8980ff6a7f0f658e11df86ca0 *chromedriver-v39.8.2-darwin-arm64.zip -f9995e244e0c703b0c1e06bcad2b1b9feca79d4437901e3b9dfa1f635b03884b *chromedriver-v39.8.2-darwin-x64.zip -45083a530bd03781dd759720519c805c046f392d88e2404268392446f896e265 *chromedriver-v39.8.2-linux-arm64.zip -09a6548e5abc4e1589870031bf35edb00b506da10102bb5d1b52fc069b7c1b34 *chromedriver-v39.8.2-linux-armv7l.zip -713570bbe7877fa950cbb533197cfb12aa7ff85d4db7e1fc9ad6ac57ca5733c9 *chromedriver-v39.8.2-linux-x64.zip -66a0109f235f0dec7d05d95f67f3ab07edebfd3e919d093ce71115484d2cfea2 *chromedriver-v39.8.2-mas-arm64.zip -d124f6440f2ff6de9c26f8764ad461cb8daec8e150699006d2ece850f1ff7125 *chromedriver-v39.8.2-mas-x64.zip -89c57558bf892492f5945415c20dc34cf7836661ed82f0f5816081a9e85b6859 *chromedriver-v39.8.2-win32-arm64.zip -2f5452b92dd26d0262329be08ad185bee3e9ce73536337df961e2a36273e99a9 *chromedriver-v39.8.2-win32-ia32.zip -d18fcd1ee0e2905ea8775470e956cd8ccd357f5e790169820bac26b5d5e5f540 *chromedriver-v39.8.2-win32-x64.zip -b8a2b1464313aa4e3d3e70ba84604879a1e2f21b654ef1feedc244eff294e46f *electron-api.json -e2a63aff66cfae22037682db1b3bdbeb616c9070eb56eac8f0cca58ff67168dd *electron-v39.8.2-darwin-arm64-dsym-snapshot.zip -8eca78b4b567cf258a4cfe6f9277060fbc1533dffe494936cc407453d68afe1a *electron-v39.8.2-darwin-arm64-dsym.zip -540715f221cf9c286c2ba30013bae3900595950e3e32fd7650b670a70f82d472 *electron-v39.8.2-darwin-arm64-symbols.zip -1910b2b857e0ee6d2ebd57ead75c3ace7d367a6bb9ccd6a48f8d2b23d93ffe67 *electron-v39.8.2-darwin-arm64.zip -491c9092487835661006c7d9665f3293ba547af1379ea779458cc7c3a79665a0 *electron-v39.8.2-darwin-x64-dsym-snapshot.zip -4e6a5a65947b7cec21571e8cac7afcd4d548c7c98c42c1107a47451fb35b1057 *electron-v39.8.2-darwin-x64-dsym.zip -942688360848bcf4b371553096e0ad77627acbb92eeea2426ba7885c7e4949b6 *electron-v39.8.2-darwin-x64-symbols.zip -9d80221dd2621a9526047be09379e32bbfc9dd57331e41bc0826aadbb69f632a *electron-v39.8.2-darwin-x64.zip -6ca8338548b63198143e25d9be34fa729763b82b68401b4112a787cf1d08ef60 *electron-v39.8.2-linux-arm64-debug.zip -12e1cae738ee45020249c7f15a3c2fb379425f0c8b6226ce7d3f53db356f3a82 *electron-v39.8.2-linux-arm64-symbols.zip -856848216c549a783b39f8d84dd93668d71da0d804e3bba709265804e5b4ba94 *electron-v39.8.2-linux-arm64.zip -a4b19bc2da1d531c6e689c2ac82af1453d45883197021ac8fd4f25029a9cf995 *electron-v39.8.2-linux-armv7l-debug.zip -e3be10fea936d22abaf70371c093d732a330ed639931ceeb04865edbce4c48bc *electron-v39.8.2-linux-armv7l-symbols.zip -56602fe1579eec07d810389ccf3d10c3d50e994f0319048f4f3057f8b24aa97b *electron-v39.8.2-linux-armv7l.zip -89d9a1c4dd9e632ebb1d7f816e003d152d58722f4b1849ff962df2330aa55edc *electron-v39.8.2-linux-x64-debug.zip -c5ad596d3017e4b2e5c8dd8fe7b7fbfaeca97505462c157f10ceedb1782c8cc2 *electron-v39.8.2-linux-x64-symbols.zip -3977017548b5dfdf78e1342cbe251c7ee7a127e52514903e181fa92143b0fa3a *electron-v39.8.2-linux-x64.zip -559f513006663e18e65dc936c6f50add34cda8fc0d639fd861daf354f017d293 *electron-v39.8.2-mas-arm64-dsym-snapshot.zip -d98d80c47d06169790a68bc72f652c5300235d93612787a97580a3ec6201ff03 *electron-v39.8.2-mas-arm64-dsym.zip -7862973f21e05dc5619110e789ddfc8b3973ae482a3dbcd6f33dfe42c939dc3d *electron-v39.8.2-mas-arm64-symbols.zip -a7dacb1566e909407510437d030904825ad88829186dc31e364d5b7a747b4fc6 *electron-v39.8.2-mas-arm64.zip -ecb9de6b8d7c564e5b0e62f5153d9c61272ef49e7f186fb986c58e225172c2b2 *electron-v39.8.2-mas-x64-dsym-snapshot.zip -3044fa159f9ff6b9c53311a4b4561b726f544240ae9452e1aa8505ccdb08a457 *electron-v39.8.2-mas-x64-dsym.zip -1b5e30679a43faee9a671b67b7c04b9ac464469aa493ce3c14cb8f52debec0f2 *electron-v39.8.2-mas-x64-symbols.zip -dcb094d185447f8bef67c3bf5537b47c61a80e077c2482d4a1a1289121c7cad0 *electron-v39.8.2-mas-x64.zip -d8475aef9e0e5f8f77fb9e3e9547656ec4c7688a432957686761ea8a19fa1a92 *electron-v39.8.2-win32-arm64-pdb.zip -1113ba8fc6dbebbad1a6eb0c0ba3f14698e0b99c16aa9b8cf6d637408f01646a *electron-v39.8.2-win32-arm64-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-arm64-toolchain-profile.zip -d3d478f30002a70da0bf02775436b5f865345b9a25d0e0b75e1b089560bbf7fd *electron-v39.8.2-win32-arm64.zip -2d67e61dce2d50d291305df43e7bd312c2c665b71257d71e5c8cfab6c0ec931e *electron-v39.8.2-win32-ia32-pdb.zip -b5dd932b5ca51089dec5b9830554dfda5abdcee6a3bbd69f8531ae76a27524e6 *electron-v39.8.2-win32-ia32-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-ia32-toolchain-profile.zip -fd8270cb5ba43193d32a371263fa0cf73d112534ab852867fb86d10c6a82db39 *electron-v39.8.2-win32-ia32.zip -31f069c1ebdf46d3dc6704157e3ed60d707aec414f391b9993c6918c0b8ae0fd *electron-v39.8.2-win32-x64-pdb.zip -c732d314d4a7f44c20bcaeb6bc12a74947e7f28e16426fdbe041c9a35759e76f *electron-v39.8.2-win32-x64-symbols.zip -b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.2-win32-x64-toolchain-profile.zip -e5b2c8bda64b65e6587c2f3c97f48857fd02ab894bb7a6e4c73bd4a5bcc10416 *electron-v39.8.2-win32-x64.zip -179d2bf1b64e27cda05128656ff6bbbbd80eaf8b2ff04de3ae0999b850362785 *electron.d.ts -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.2-darwin-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.2-darwin-x64.zip -52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.2-linux-arm64.zip -622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.2-linux-armv7l.zip -ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.2-linux-x64.zip -27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.2-mas-arm64.zip -321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.2-mas-x64.zip -a0b525af0aa198214ba3c29a0b41297b15618fdad8c4f5aa3c42cf6a6ab80bfa *ffmpeg-v39.8.2-win32-arm64.zip -5418269cf6fe82f3d9fc5cbbc6d6f9241462b40046a87178515a36cb45549be1 *ffmpeg-v39.8.2-win32-ia32.zip -10bbd25b3e9af36f26147410b31e6e1d928bf4c25ac28571fa1bbe4eb7fe9af9 *ffmpeg-v39.8.2-win32-x64.zip -122ba5515c3a94b272886d156f0bb174ca120a18be44e21bc8b5f586dd679b6e *hunspell_dictionaries.zip -e8aebe7d361983ce1329598f5541c4dde26d18e72228d0ab1ac526c0e1a40dfc *libcxx-objects-v39.8.2-linux-arm64.zip -0d9e646e77ed3fad4560d10f7964cf316cbdcd9a50114c9163427be2222eb35b *libcxx-objects-v39.8.2-linux-armv7l.zip -928c6ff0761f496deda96203960d1933cae1ff488483ea31283a0e8ffe36426a *libcxx-objects-v39.8.2-linux-x64.zip -c65cf035770b74a8a6be4692be704c286427e63eb577e9d10c226b600f6121cc *libcxx_headers.zip -006ccb83761a3bf5791d165bc9795e4de3308c65df646b4cbcaa61025fe7a6c5 *libcxxabi_headers.zip -b189f37011a77ce5d3b6478474172b4594fee626daa75b63da8feb9d376ad983 *mksnapshot-v39.8.2-darwin-arm64.zip -436daa4ae7ca171c51d265976ddc5a5e8ede5b7c1c9cb5467547f14cef87b0c9 *mksnapshot-v39.8.2-darwin-x64.zip -b25ee4873f0bdb9ad663446f9443aa23faeb9e4e2f2734afe47c383e66b6939b *mksnapshot-v39.8.2-linux-arm64-x64.zip -26ebf5acbec96fd08d58d3d9351c26b8cb1ded51a948e0b0513a636deaf17648 *mksnapshot-v39.8.2-linux-armv7l-x64.zip -619b5349abd00d4b7c91894114b2c2aae94d2467d912f476ebcd8c718031493b *mksnapshot-v39.8.2-linux-x64.zip -ec14eeccd924c97a2716b2de5c8279dc5ecb0588c3a333be1dfe4122d192bebc *mksnapshot-v39.8.2-mas-arm64.zip -503f0b1263ebd86c094140307c02c7da474c219b839079a59fcdb1dc1451986a *mksnapshot-v39.8.2-mas-x64.zip -5d7082a1811e11807f78ce9888b00085db92c3dd721d67f54954fd0192570826 *mksnapshot-v39.8.2-win32-arm64-x64.zip -531ea5cd112438eb9276c6327487f8b1f0845b11c080697c438406968d51a859 *mksnapshot-v39.8.2-win32-ia32.zip -5f3ab3f4c4bb7783cd59235a2fffd22ceb86afdafcecdc9492b595302e03ed3e *mksnapshot-v39.8.2-win32-x64.zip +0ab48e3e8888b5c33950be0c36da939aa989df7609d3c32140c5e5371ea53abb *chromedriver-v39.8.3-darwin-arm64.zip +b7103565ffb4068dc705c50ce3039ed3178cac350301abf82545de54ac3bc849 *chromedriver-v39.8.3-darwin-x64.zip +e7e43ee7a3d14482ce488d0b0bc338a026a00ee544e5a3d55aed220af6b5da0e *chromedriver-v39.8.3-linux-arm64.zip +060223baebe6d8f9e8c7367bf561dd558fca03509edcc3bce660c42f96ad73ea *chromedriver-v39.8.3-linux-armv7l.zip +854a6f921684e59866aed9db0e9f61d28f756f70b7898f947359b4d04dba76db *chromedriver-v39.8.3-linux-x64.zip +f70ea58bc5e4e51eec51f65e153cfd36eea568ecd571c2815a4c05a457b6923d *chromedriver-v39.8.3-mas-arm64.zip +8e3e1450bc544bff712ffab0ba365d1ed2c9b79116b4ec4750a46c8607242ed4 *chromedriver-v39.8.3-mas-x64.zip +c07e35a2a5a673c8902452571f3436ca8b774fa4628ad9e42f179d3c935f4ed7 *chromedriver-v39.8.3-win32-arm64.zip +d0361344208d8bdf58500d08ae1bb723b9ccdc66fc736c2fc6c9f011bcc6e47d *chromedriver-v39.8.3-win32-ia32.zip +e2e91fd7d97e3e9806d22c4990253cbd5e466cdfa1a8e4c86c72431f7d3a8d0f *chromedriver-v39.8.3-win32-x64.zip +f004c879e159edf3eb403bd43bc76c3069b0b375c6dfae5b249b96d543e51e26 *electron-api.json +21a5324aaed782fead97b2e50f833373148392d4c13ec818f80f142e800c6158 *electron-v39.8.3-darwin-arm64-dsym-snapshot.zip +bb9c14900f48aabb7d272149ba4b60813b366f1e67f95b510da73355e15ba78c *electron-v39.8.3-darwin-arm64-dsym.zip +8a42b50a0841e7bfefc49e704f5cfdb3cbb7b9a507ac74b9982004a9350a202d *electron-v39.8.3-darwin-arm64-symbols.zip +e1b9e03a56fc27ad567c8d2bb32a21e0e2afe6a095f71c26df5b8b8ed8dd8d4c *electron-v39.8.3-darwin-arm64.zip +5b474116e398286a80def6509fa255481ab88fbb52b1770dfd5d39ddff124c6b *electron-v39.8.3-darwin-x64-dsym-snapshot.zip +14648a98eef5a28c1158f0580a813617d9ce6d77a8b7881c389acfff34d328cd *electron-v39.8.3-darwin-x64-dsym.zip +231e13b26c39cceecec359e74c00e4d6a13de3ae9fb6459f18846f91f214074f *electron-v39.8.3-darwin-x64-symbols.zip +22cf6f6147d5d632e2a8ad5207504a18db94a8c96e3f4f65f792822eaed7bf1c *electron-v39.8.3-darwin-x64.zip +fdf25df8857e1ef2cdb0a5be71b78dfb9923a6061cf11336577c6a4368ecfdcd *electron-v39.8.3-linux-arm64-debug.zip +731bf3f908a1efe871e862852087b67027c791427284f057d42376634d4d53d3 *electron-v39.8.3-linux-arm64-symbols.zip +e1a0e6939fe2d10c1f807888f74dbbb9f28a2cfc25e28bb8168f5513513fc124 *electron-v39.8.3-linux-arm64.zip +65893fd03097eadb0c89eb95ded97e97a9910bbc53634f12170cfb40b9165832 *electron-v39.8.3-linux-armv7l-debug.zip +997acce3540d16f9e0551cde811021999a4276c970bfe42ed77c3fd769ba6d05 *electron-v39.8.3-linux-armv7l-symbols.zip +5d5825966a3b2678c50121c81ed3fb8c39d35c3798dd0413a19afaac04109ef9 *electron-v39.8.3-linux-armv7l.zip +52b44ef60f73ef7b7c8461f520a1048da3601d9cc869262ec63f507cd6591e78 *electron-v39.8.3-linux-x64-debug.zip +c4e1fa21d21724ab7f5bcdb6c1bfc03dca837ebcca00d6af56944041499d35a9 *electron-v39.8.3-linux-x64-symbols.zip +5866d6c6f8fcf15967279107d2387edfa4589c5a00ad52d4b770d7504106a734 *electron-v39.8.3-linux-x64.zip +3ff4c9fb99f40dda465486fa6fa23eaedd89b87dcd9cce402a171accfcacc9bd *electron-v39.8.3-mas-arm64-dsym-snapshot.zip +9401101eabaf5e55063b9fad94bf3ac2fe9d743ff88ec638a3c6c665b2266564 *electron-v39.8.3-mas-arm64-dsym.zip +0905e57da501b64436dff51b1378bae311cb1276372dc39dedb7aef44f1b947a *electron-v39.8.3-mas-arm64-symbols.zip +1af2cdee3405c0b8e1c8145a65891b249ac737dd35d959cebd6833970ad5eb08 *electron-v39.8.3-mas-arm64.zip +9e8c6b7b880ac726cda52aaf2b02bc9f0750559be85ef1583789d52b617914b9 *electron-v39.8.3-mas-x64-dsym-snapshot.zip +483ce280606a61c7ed4394e99008ad6fc7e2ce9c35149c5ad745bce9ee78a7a2 *electron-v39.8.3-mas-x64-dsym.zip +1e16529ab1ee8404b1623df611077abcbbbabc1f825a57e93e2ef2b1f7ba788a *electron-v39.8.3-mas-x64-symbols.zip +19df399b352db2c3b3f26f830700b17adf697b70e4d361b1e0f20790e6e703b6 *electron-v39.8.3-mas-x64.zip +bdfd01e7c55ea4beb90afd4285ab3639e3a66808ec993389e9eaf62c3edcb5e9 *electron-v39.8.3-win32-arm64-pdb.zip +7329264c9d308a78081509cb4173f0bd931522655d2f434ad858555e735e5721 *electron-v39.8.3-win32-arm64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-arm64-toolchain-profile.zip +699933ff8c4d7216fc0318f239a5f593f06487c0dc9c3722b8744f6a44fca94e *electron-v39.8.3-win32-arm64.zip +1ffab5a8419a1a93476e2edc09754a52bbe9f3d39915e097f2a1ee50ffdbbd13 *electron-v39.8.3-win32-ia32-pdb.zip +6445047728d64a09db80205c24135f140ba60c25433d833f581c57092638b875 *electron-v39.8.3-win32-ia32-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-ia32-toolchain-profile.zip +b80bb96a4eda2c2b6bd223d2d8b6abfc39abdebac0b36cf74cd70661d43258a5 *electron-v39.8.3-win32-ia32.zip +c8e3cab205bdfe42f916a4428fe0a5e88b6f90e8482e297dadfe1234420abb8f *electron-v39.8.3-win32-x64-pdb.zip +eda4a2f01e388eccfc2ecc7587b0987d123ae01e5b832b73a0a76bb62680bd7c *electron-v39.8.3-win32-x64-symbols.zip +b701e63ca5d443d9bd1b653ea0e2b7479f0d834a3d1bd9f10a3b745d29607154 *electron-v39.8.3-win32-x64-toolchain-profile.zip +12eabd7c5f08823525034c1ff3ab286f271af802928e0f224b458235e2689c5a *electron-v39.8.3-win32-x64.zip +d5345fc0cb336a425f7a25885f67969452746cbf30cc1e95449f7a68221aab07 *electron.d.ts +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.3-darwin-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.3-darwin-x64.zip +52ae6eccbdb4a9403a6c3eb46b356a28940ec25958b6b9181fb2f38e612e40ed *ffmpeg-v39.8.3-linux-arm64.zip +622cb781fb1e3b9617e7e60c36384427f7b0d9b5ad888e9bc356a83b050e13f1 *ffmpeg-v39.8.3-linux-armv7l.zip +ba441851788008362f013bf2983b22b0042af8df31bf90123328f928cc067492 *ffmpeg-v39.8.3-linux-x64.zip +27cf8e375bc22ceea6b3d42132f2927ea544edac2b8b2c5dc3c10b5df8dfb027 *ffmpeg-v39.8.3-mas-arm64.zip +321d9c07f74c6cf77027ec07d888fb7b634d6589207e3c9e016c43e277ca9944 *ffmpeg-v39.8.3-mas-x64.zip +06d402e51bf66fd1a0eddc7e8329b31eb8a1bc6c829e5bc13694708a010feb07 *ffmpeg-v39.8.3-win32-arm64.zip +4b1c2ddedebbf32b7792fb788ddce577f2dc6f8ecccd913e72e842068b2f81e5 *ffmpeg-v39.8.3-win32-ia32.zip +0cf0f521a452d6fdfe1313b81284d646e991954e91c356d805f5c066f2ecf278 *ffmpeg-v39.8.3-win32-x64.zip +bb2d6ec64c43e5a41da5bc55a3daa2b7ef8a0dee722dc73f4fa31bcae5487cb2 *hunspell_dictionaries.zip +7fd663a5eeaf4d0a93785ea4ea715d21464c70c1341b7d8629c96a7bfe24044a *libcxx-objects-v39.8.3-linux-arm64.zip +bdd7e9f732b97b6be2c8293d9391bce3a5cd60feae1c762a0dda0790493da7a2 *libcxx-objects-v39.8.3-linux-armv7l.zip +0de009f84fed7c1bba087ff161674177ca91951fca2f4c60850be0bffb42dfdf *libcxx-objects-v39.8.3-linux-x64.zip +8ae7fd5c3bdc332f9f49830a9316e470d43f17e6ad6adbd05ac629d03d1718c2 *libcxx_headers.zip +9b988e2bb379c6d3094872f600944ad3284510cf225f86368c4f43270b89673c *libcxxabi_headers.zip +d504296ed183e3f460028a73b4a5e2bcd99bc4a3c74b8dc73ba987719c005458 *mksnapshot-v39.8.3-darwin-arm64.zip +b18b8a0e902cf86961d53486826fb07feb3ac98e018b2849cf2bb13150077b13 *mksnapshot-v39.8.3-darwin-x64.zip +7d9dc2ceb3f88d8d532af5b90387479ade571a0370489429571871d386c12322 *mksnapshot-v39.8.3-linux-arm64-x64.zip +84114ba67259f52ae462210544e815b602d70b71162f7f70982a7c36db54b4fa *mksnapshot-v39.8.3-linux-armv7l-x64.zip +be08392f0964d2166bb84212d225c5380fde9b12e622599cb040f45524ff7882 *mksnapshot-v39.8.3-linux-x64.zip +ffebb01a6fe568ec51391f9585e8abf1a93a566a7c991b2abacc33cc2e94d705 *mksnapshot-v39.8.3-mas-arm64.zip +858a8ee80b6f826b1d24a9458b8acb0fcc9805ee3c309652d60ed5c07b578113 *mksnapshot-v39.8.3-mas-x64.zip +dbc82c573f1ba098b6d321ad79c6580f27066d94e13fd93c0b7650a54150eb5c *mksnapshot-v39.8.3-win32-arm64-x64.zip +c8f7c43741b20da99558db596d32c86ebff3483aaf6b3e2539cd85a471b92043 *mksnapshot-v39.8.3-win32-ia32.zip +000941eeb8e1169d581120df7d42aa0b76e33de99a06528a9c4767bcffb74cbf *mksnapshot-v39.8.3-win32-x64.zip diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts index 0ad08ab6fbe..f3ba46d9f89 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -34,6 +34,7 @@ import * as cp from 'child_process'; import log from 'fancy-log'; import buildfile from './buildfile.ts'; import { fetchUrls, fetchGithub } from './lib/fetch.ts'; +import { getCopilotExcludeFilter, copyCopilotNativeDeps } from './lib/copilot.ts'; import jsonEditor from 'gulp-json-editor'; @@ -343,6 +344,7 @@ function packageTask(type: string, platform: string, arch: string, sourceFolderN .pipe(filter(['**', '!**/package-lock.json', '!**/*.{js,css}.map'])) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))) + .pipe(filter(getCopilotExcludeFilter(platform, arch))) .pipe(jsFilter) .pipe(util.stripSourceMappingURL()) .pipe(jsFilter.restore); @@ -461,6 +463,13 @@ function patchWin32DependenciesTask(destinationFolderName: string) { }; } +function copyCopilotNativeDepsTaskREH(platform: string, arch: string, destinationFolderName: string) { + return async () => { + const nodeModulesDir = path.join(BUILD_ROOT, destinationFolderName, 'node_modules'); + copyCopilotNativeDeps(platform, arch, nodeModulesDir); + }; +} + /** * @param product The parsed product.json file contents */ @@ -509,7 +518,8 @@ function tweakProductForServerWeb(product: typeof import('../product.json')) { compileNativeExtensionsBuildTask, gulp.task(`node-${platform}-${arch}`) as task.Task, util.rimraf(path.join(BUILD_ROOT, destinationFolderName)), - packageTask(type, platform, arch, sourceFolderName, destinationFolderName) + packageTask(type, platform, arch, sourceFolderName, destinationFolderName), + copyCopilotNativeDepsTaskREH(platform, arch, destinationFolderName) ]; if (platform === 'win32') { diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 35f0d93a6e9..336a8947fbb 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -31,6 +31,7 @@ import minimist from 'minimist'; import { compileBuildWithoutManglingTask, compileBuildWithManglingTask } from './gulpfile.compile.ts'; import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask } from './gulpfile.extensions.ts'; import { copyCodiconsTask } from './lib/compilation.ts'; +import { getCopilotExcludeFilter, copyCopilotNativeDeps } from './lib/copilot.ts'; import type { EmbeddedProductInfo } from './lib/embeddedType.ts'; import { useEsbuildTranspile } from './buildConfig.ts'; import { promisify } from 'util'; @@ -318,38 +319,6 @@ function computeChecksum(filename: string): string { return hash; } -const copilotPlatforms = [ - 'darwin-arm64', 'darwin-x64', - 'linux-arm64', 'linux-x64', - 'win32-arm64', 'win32-x64', -]; - -/** - * Returns a glob filter that strips @github/copilot platform packages and - * prebuilt native modules for architectures other than the build target. - * On stable builds, all copilot SDK dependencies are stripped entirely. - */ -function getCopilotExcludeFilter(platform: string, arch: string, quality: string | undefined): string[] { - const targetPlatformArch = `${platform}-${arch}`; - const nonTargetPlatforms = copilotPlatforms.filter(p => p !== targetPlatformArch); - - // Strip wrong-architecture @github/copilot-{platform} packages. - // All copilot prebuilds are stripped by .moduleignore; VS Code's own - // node-pty is copied into the prebuilds location by a post-packaging task. - const excludes = nonTargetPlatforms.map(p => `!**/node_modules/@github/copilot-${p}/**`); - - // Strip agent host SDK dependencies entirely from stable builds - if (quality === 'stable') { - excludes.push( - '!**/node_modules/@github/copilot/**', - '!**/node_modules/@github/copilot-sdk/**', - '!**/node_modules/@github/copilot-*/**', - ); - } - - return ['**', ...excludes]; -} - function packageTask(platform: string, arch: string, sourceFolderName: string, destinationFolderName: string, _opts?: { stats?: boolean }) { const destination = path.join(path.dirname(root), destinationFolderName); platform = platform || process.platform; @@ -469,7 +438,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d .pipe(filter(depFilterPattern)) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore'))) .pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`))) - .pipe(filter(getCopilotExcludeFilter(platform, arch, quality))) + .pipe(filter(getCopilotExcludeFilter(platform, arch))) .pipe(jsFilter) .pipe(util.rewriteSourceMappingURL(sourceMappingURLBase)) .pipe(jsFilter.restore) @@ -713,27 +682,10 @@ function patchWin32DependenciesTask(destinationFolderName: string) { }; } -/** - * Copies VS Code's own node-pty binaries into the copilot SDK's - * expected locations so the copilot CLI subprocess can find them at runtime. - * The copilot-bundled prebuilds are stripped by .moduleignore; - * this replaces them with the same binaries VS Code already ships, avoiding - * new system dependency requirements. - * - * node-pty: `prebuilds/{platform}-{arch}/` (pty.node + spawn-helper) - */ function copyCopilotNativeDepsTask(platform: string, arch: string, destinationFolderName: string) { const outputDir = path.join(path.dirname(root), destinationFolderName); return async () => { - const quality = (product as { quality?: string }).quality; - - // On stable builds the copilot SDK is stripped entirely -- nothing to copy into. - if (quality === 'stable') { - console.log(`[copyCopilotNativeDeps] Skipping -- stable build`); - return; - } - // On Windows with win32VersionedUpdate, app resources live under a // commit-hash prefix: {output}/{commitHash}/resources/app/ const versionedResourcesFolder = util.getVersionedResourcesFolder(platform, commit!); @@ -741,24 +693,7 @@ function copyCopilotNativeDepsTask(platform: string, arch: string, destinationFo ? path.join(outputDir, `${product.nameLong}.app`, 'Contents', 'Resources', 'app') : path.join(outputDir, versionedResourcesFolder, 'resources', 'app'); - // Source and destination are both in node_modules/, which exists as a real - // directory on disk on all platforms after packaging. - const nodeModulesDir = path.join(appBase, 'node_modules'); - const copilotBase = path.join(nodeModulesDir, '@github', 'copilot'); - const platformArch = `${platform === 'win32' ? 'win32' : platform}-${arch}`; - - const nodePtySource = path.join(nodeModulesDir, 'node-pty', 'build', 'Release'); - - // Fail-fast: source binaries must exist on non-stable builds. - if (!fs.existsSync(nodePtySource)) { - throw new Error(`[copyCopilotNativeDeps] node-pty source not found at ${nodePtySource}`); - } - - // Copy node-pty (pty.node + spawn-helper) into copilot prebuilds - const copilotPrebuildsDir = path.join(copilotBase, 'prebuilds', platformArch); - fs.mkdirSync(copilotPrebuildsDir, { recursive: true }); - fs.cpSync(nodePtySource, copilotPrebuildsDir, { recursive: true }); - console.log(`[copyCopilotNativeDeps] Copied node-pty from ${nodePtySource} to ${copilotPrebuildsDir}`); + copyCopilotNativeDeps(platform, arch, path.join(appBase, 'node_modules')); }; } diff --git a/build/gulpfile.vscode.win32.ts b/build/gulpfile.vscode.win32.ts index 0f81323c98d..c393e8247f1 100644 --- a/build/gulpfile.vscode.win32.ts +++ b/build/gulpfile.vscode.win32.ts @@ -122,6 +122,7 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask { definitions['ProxyExeBasename'] = embedded.nameShort; definitions['ProxyAppUserId'] = embedded.win32AppUserModelId; definitions['ProxyNameLong'] = embedded.nameLong; + definitions['ProxyExeUrlProtocol'] = embedded.urlProtocol; } if (quality === 'stable' || quality === 'insider') { diff --git a/build/lib/copilot.ts b/build/lib/copilot.ts new file mode 100644 index 00000000000..f182c9829a9 --- /dev/null +++ b/build/lib/copilot.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * 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 'fs'; +import * as path from 'path'; + +/** + * The platforms that @github/copilot ships platform-specific packages for. + * These are the `@github/copilot-{platform}` optional dependency packages. + */ +export const copilotPlatforms = [ + 'darwin-arm64', 'darwin-x64', + 'linux-arm64', 'linux-x64', + 'win32-arm64', 'win32-x64', +]; + +/** + * Converts VS Code build platform/arch to the values that Node.js reports + * at runtime via `process.platform` and `process.arch`. + * + * The copilot SDK's `loadNativeModule` looks up native binaries under + * `prebuilds/${process.platform}-${process.arch}/`, so the directory names + * must match these runtime values exactly. + */ +function toNodePlatformArch(platform: string, arch: string): { nodePlatform: string; nodeArch: string } { + // alpine is musl-linux; Node still reports process.platform === 'linux' + let nodePlatform = platform === 'alpine' ? 'linux' : platform; + let nodeArch = arch; + + if (arch === 'armhf') { + // VS Code build uses 'armhf'; Node reports process.arch === 'arm' + nodeArch = 'arm'; + } else if (arch === 'alpine') { + // Legacy: { platform: 'linux', arch: 'alpine' } means alpine-x64 + nodePlatform = 'linux'; + nodeArch = 'x64'; + } + + return { nodePlatform, nodeArch }; +} + +/** + * Returns a glob filter that strips @github/copilot platform packages + * for architectures other than the build target. + * + * For platforms the copilot SDK doesn't natively support (e.g. alpine, armhf), + * ALL platform packages are stripped - that's fine because the SDK doesn't ship + * binaries for those platforms anyway, and we replace them with VS Code's own. + */ +export function getCopilotExcludeFilter(platform: string, arch: string): string[] { + const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); + const targetPlatformArch = `${nodePlatform}-${nodeArch}`; + const nonTargetPlatforms = copilotPlatforms.filter(p => p !== targetPlatformArch); + + // Strip wrong-architecture @github/copilot-{platform} packages. + // All copilot prebuilds are stripped by .moduleignore; VS Code's own + // node-pty is copied into the prebuilds location by a post-packaging task. + const excludes = nonTargetPlatforms.map(p => `!**/node_modules/@github/copilot-${p}/**`); + + return ['**', ...excludes]; +} + +/** + * Copies VS Code's own node-pty binaries into the copilot SDK's + * expected locations so the copilot CLI subprocess can find them at runtime. + * The copilot-bundled prebuilds are stripped by .moduleignore; + * this replaces them with the same binaries VS Code already ships, avoiding + * new system dependency requirements. + * + * This works even for platforms the copilot SDK doesn't natively support + * (e.g. alpine, armhf) because the SDK's native module loader simply + * looks for `prebuilds/{process.platform}-{process.arch}/pty.node` - it + * doesn't validate the platform against a supported list. + * + * Failures are logged but do not throw, to avoid breaking the build on + * platforms where something unexpected happens. + * + * @param nodeModulesDir Absolute path to the node_modules directory that + * contains both the source binaries (node-pty) and the copilot SDK + * target directories. + */ +export function copyCopilotNativeDeps(platform: string, arch: string, nodeModulesDir: string): void { + const { nodePlatform, nodeArch } = toNodePlatformArch(platform, arch); + const platformArch = `${nodePlatform}-${nodeArch}`; + + const copilotBase = path.join(nodeModulesDir, '@github', 'copilot'); + if (!fs.existsSync(copilotBase)) { + console.warn(`[copyCopilotNativeDeps] @github/copilot not found at ${copilotBase}, skipping`); + return; + } + + const nodePtySource = path.join(nodeModulesDir, 'node-pty', 'build', 'Release'); + if (!fs.existsSync(nodePtySource)) { + console.warn(`[copyCopilotNativeDeps] node-pty source not found at ${nodePtySource}, skipping`); + return; + } + + try { + // Copy node-pty (pty.node + spawn-helper on Unix, conpty.node + conpty/ on Windows) + // into copilot prebuilds so the SDK finds them via loadNativeModule. + const copilotPrebuildsDir = path.join(copilotBase, 'prebuilds', platformArch); + fs.mkdirSync(copilotPrebuildsDir, { recursive: true }); + fs.cpSync(nodePtySource, copilotPrebuildsDir, { recursive: true }); + console.log(`[copyCopilotNativeDeps] Copied node-pty from ${nodePtySource} to ${copilotPrebuildsDir}`); + } catch (err) { + console.warn(`[copyCopilotNativeDeps] Failed to copy node-pty for ${platformArch}: ${err}`); + } +} diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 26e1e273027..ade53b78639 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -668,6 +668,10 @@ "name": "vs/sessions/contrib/logs", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/remoteAgentHost", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/sessions", "project": "vscode-sessions" @@ -679,6 +683,10 @@ { "name": "vs/sessions/contrib/welcome", "project": "vscode-sessions" + }, + { + "name": "vs/sessions/contrib/chatDebug", + "project": "vscode-sessions" } ] } diff --git a/build/next/index.ts b/build/next/index.ts index 77388b57a26..565bafc72ec 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -129,6 +129,7 @@ const serverEntryPoints = [ 'vs/workbench/api/node/extensionHostProcess', 'vs/platform/files/node/watcher/watcherMain', 'vs/platform/terminal/node/ptyHostMain', + 'vs/platform/agentHost/node/agentHostMain', ]; // Bootstrap files per target diff --git a/build/npm/installStateHash.ts b/build/npm/installStateHash.ts index f52c0a4696d..0b3d9898015 100644 --- a/build/npm/installStateHash.ts +++ b/build/npm/installStateHash.ts @@ -87,7 +87,7 @@ function hashContent(content: string): string { return hash.digest('hex'); } -export function computeState(): PostinstallState { +export function computeState(options?: { ignoreNodeVersion?: boolean }): PostinstallState { const fileHashes: Record = {}; for (const filePath of collectInputFiles()) { const key = path.relative(root, filePath); @@ -97,7 +97,7 @@ export function computeState(): PostinstallState { // file may not be readable } } - return { nodeVersion: process.versions.node, fileHashes }; + return { nodeVersion: options?.ignoreNodeVersion ? '' : process.versions.node, fileHashes }; } export function computeContents(): Record { @@ -141,18 +141,23 @@ 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]) { - if (process.argv[2] === '--normalize-file') { - const filePath = process.argv[3]; + const args = new Set(process.argv.slice(2)); + + if (args.has('--normalize-file')) { + const filePath = process.argv[process.argv.indexOf('--normalize-file') + 1]; if (!filePath) { process.exit(1); } process.stdout.write(normalizeFileContent(filePath)); } else { + const ignoreNodeVersion = args.has('--ignore-node-version'); + const current = computeState({ ignoreNodeVersion }); + const saved = readSavedState(); console.log(JSON.stringify({ root, stateContentsFile, - current: computeState(), - saved: readSavedState(), + current, + saved: saved && ignoreNodeVersion ? { nodeVersion: '', fileHashes: saved.fileHashes } : saved, files: [...collectInputFiles(), stateFile], })); } diff --git a/build/npm/update-localization-extension.ts b/build/npm/update-localization-extension.ts index cb7981b9388..45371dd9cd0 100644 --- a/build/npm/update-localization-extension.ts +++ b/build/npm/update-localization-extension.ts @@ -120,7 +120,7 @@ function update(options: Options) { }); }); } -if (path.basename(process.argv[1]) === 'update-localization-extension.js') { +if (path.basename(process.argv[1]) === 'update-localization-extension.ts') { const options = minimist(process.argv.slice(2), { string: ['location', 'externalExtensionsLocation'] }) as Options; diff --git a/build/package-lock.json b/build/package-lock.json index 644e16f901b..cc1acf90b97 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -3494,22 +3494,9 @@ "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.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "dev": true, "funding": [ { @@ -3519,8 +3506,25 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz", + "integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -5100,6 +5104,22 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -6145,9 +6165,9 @@ } }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz", + "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", "dev": true, "funding": [ { @@ -6556,9 +6576,9 @@ "license": "MIT" }, "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "dev": true, "license": "MIT" }, diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index 72f036bc0df..8d0937b8faf 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -1379,9 +1379,9 @@ } }, "node_modules/@vscode/component-explorer": { - "version": "0.1.1-24", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-24.tgz", - "integrity": "sha512-o+uFX1bqD6dvAALx+Y32Gf7FmQehPsjGAI1Bm+5PvaV/++RIqsniM+VXIwqwjtuUvOyAMOz2TEOPYy3Uju//Qw==", + "version": "0.1.1-27", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-27.tgz", + "integrity": "sha512-FAxC9WlYOaRx6XfU3/ceI2bCOPQp45CpQehUyhG3AbDxLuM8Kv2VyJYJiSsQ0Z2a8cah/3CB729oIiINO19mdw==", "dev": true, "license": "MIT", "dependencies": { @@ -1391,9 +1391,9 @@ } }, "node_modules/@vscode/component-explorer-vite-plugin": { - "version": "0.1.1-24", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-24.tgz", - "integrity": "sha512-XHccBmg4mnIHahBTmoIBaJwvDZM0QOIbDm/qxZAw8Zr1xSfTCRQNBwBAYNrOZe4/XK52N5DLMBmjpFroEtY2WQ==", + "version": "0.1.1-27", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-27.tgz", + "integrity": "sha512-wX2Z9e5E3ZdiPRTRIUYOBaReYOfGXd+iseNPAcdfx8gNKJiXrceco4gdKCQv3+WEyvLI3uT/oGdP9ecDcR6mbw==", "dev": true, "license": "MIT", "dependencies": { @@ -2117,9 +2117,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, diff --git a/build/win32/code.iss b/build/win32/code.iss index a61eef9c066..53016d814ae 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1294,6 +1294,15 @@ Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValu Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}"; ValueType: expandsz; ValueName: "Icon"; ValueData: "{app}\{#ExeBasename}.exe"; Check: ShouldInstallLegacyFolderContextMenu Root: {#SoftwareClassesRootKey}; Subkey: "Software\Classes\Drive\shell\{#RegValueName}\command"; ValueType: expandsz; ValueName: ""; ValueData: """{app}\{#ExeBasename}.exe"" ""%V"""; Check: ShouldInstallLegacyFolderContextMenu +; URL Protocol handler for proxy executable +#ifdef ProxyExeBasename +#ifdef ProxyExeUrlProtocol +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}"; ValueType: string; ValueName: ""; ValueData: "URL:{#ProxyExeUrlProtocol}"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\{#ProxyExeUrlProtocol}\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#ProxyExeBasename}.exe"" --open-url -- ""%1"""; Flags: uninsdeletekey +#endif +#endif + ; Environment #if "user" == InstallTarget #define EnvironmentRootKey "HKCU" diff --git a/cgmanifest.json b/cgmanifest.json index 2502a1b1ed7..a617dd1c78c 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "8e0f534873e9fdba5b365879bbdf6b47a0a64e1d", - "tag": "39.8.2" + "commitHash": "e6928c13198c854aa014c319d72eea599e2e0ee7", + "tag": "39.8.3" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "39.8.2" + "version": "39.8.3" }, { "component": { @@ -606,7 +606,7 @@ } }, "license": "MIT and Creative Commons Attribution 4.0", - "version": "0.0.41" + "version": "0.0.46-0" }, { "component": { diff --git a/cli/Cargo.lock b/cli/Cargo.lock index afe353213b1..e50f85de23a 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -2865,9 +2865,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 423224e10c5..6f54ec61cbb 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -53,7 +53,7 @@ cfg-if = "1.0.0" pin-project = "1.1.0" console = "0.15.7" bytes = "1.11.1" -tar = "0.4.38" +tar = "0.4.45" [build-dependencies] serde = { version="1.0.163", features = ["derive"] } diff --git a/cli/src/commands/agent_host.rs b/cli/src/commands/agent_host.rs index b5330e4df76..955e13f7c68 100644 --- a/cli/src/commands/agent_host.rs +++ b/cli/src/commands/agent_host.rs @@ -21,7 +21,7 @@ use crate::constants::VSCODE_CLI_QUALITY; use crate::download_cache::DownloadCache; use crate::log; use crate::options::Quality; -use crate::tunnels::paths::SERVER_FOLDER_NAME; +use crate::tunnels::paths::{get_server_folder_name, SERVER_FOLDER_NAME}; use crate::tunnels::shutdown_signal::ShutdownRequest; use crate::update_service::{ unzip_downloaded_release, Platform, Release, TargetKind, UpdateService, @@ -74,8 +74,13 @@ pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result< // Eagerly resolve the latest version so the first connection is fast. // Skip when using a dev override since updates don't apply. if option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH").is_none() { - if let Err(e) = manager.get_latest_release().await { - warning!(ctx.log, "Error resolving initial server version: {}", e); + match manager.get_latest_release().await { + Ok(release) => { + if let Err(e) = manager.ensure_downloaded(&release).await { + warning!(ctx.log, "Error downloading latest server version: {}", e); + } + } + Err(e) => warning!(ctx.log, "Error resolving initial server version: {}", e), } // Start background update checker @@ -253,9 +258,12 @@ impl AgentHostManager { cmd.stdin(std::process::Stdio::null()); cmd.stderr(std::process::Stdio::piped()); cmd.stdout(std::process::Stdio::piped()); + cmd.arg("--socket-path"); + cmd.arg(get_socket_name()); cmd.arg("--agent-host-path"); cmd.arg(&agent_host_socket); cmd.args([ + "--start-server", "--accept-server-license-terms", "--enable-remote-auto-shutdown", ]); @@ -394,7 +402,8 @@ impl AgentHostManager { // Best case: the latest known release is already downloaded if let Some((_, release)) = &*self.latest_release.lock().await { - if let Some(dir) = self.cache.exists(&release.commit) { + let name = get_server_folder_name(release.quality, &release.commit); + if let Some(dir) = self.cache.exists(&name) { return Ok((release.clone(), dir)); } } @@ -405,15 +414,23 @@ impl AgentHostManager { Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) })?; - // Fall back to any cached version (still instant, just not the newest) - for commit in self.cache.get() { - if let Some(dir) = self.cache.exists(&commit) { + // Fall back to any cached version (still instant, just not the newest). + // Cache entries are named "-" via get_server_folder_name. + for entry in self.cache.get() { + if let Some(dir) = self.cache.exists(&entry) { + let (entry_quality, commit) = match entry.split_once('-') { + Some((q, c)) => match Quality::try_from(q.to_lowercase().as_str()) { + Ok(parsed) => (parsed, c.to_string()), + Err(_) => (quality, entry.clone()), + }, + None => (quality, entry.clone()), + }; let release = Release { name: String::new(), commit, platform: self.platform, target: TargetKind::Server, - quality, + quality: entry_quality, }; return Ok((release, dir)); } @@ -428,7 +445,8 @@ impl AgentHostManager { /// Ensures the release is downloaded, returning the server directory. async fn ensure_downloaded(&self, release: &Release) -> Result { - if let Some(dir) = self.cache.exists(&release.commit) { + let cache_name = get_server_folder_name(release.quality, &release.commit); + if let Some(dir) = self.cache.exists(&cache_name) { return Ok(dir); } @@ -436,9 +454,8 @@ impl AgentHostManager { let release = release.clone(); let log = self.log.clone(); let update_service = self.update_service.clone(); - let commit = release.commit.clone(); self.cache - .create(&commit, |target_dir| async move { + .create(&cache_name, |target_dir| async move { let tmpdir = tempfile::tempdir().unwrap(); let response = update_service.get_download_stream(&release).await?; let name = response.url_path_basename().unwrap(); @@ -449,7 +466,8 @@ impl AgentHostManager { response, ) .await?; - unzip_downloaded_release(&archive_path, &target_dir, SilentCopyProgress())?; + let server_dir = target_dir.join(SERVER_FOLDER_NAME); + unzip_downloaded_release(&archive_path, &server_dir, SilentCopyProgress())?; Ok(()) }) .await @@ -504,7 +522,8 @@ impl AgentHostManager { }; // Check if we already have this version - if self.cache.exists(&new_release.commit).is_some() { + let name = get_server_folder_name(new_release.quality, &new_release.commit); + if self.cache.exists(&name).is_some() { continue; } @@ -562,7 +581,10 @@ async fn handle_request( let rw = match get_socket_rw_stream(&socket_path).await { Ok(rw) => rw, Err(e) => { - error!(manager.log, "Error connecting to agent host socket: {:?}", e); + error!( + manager.log, + "Error connecting to agent host socket: {:?}", e + ); return Ok(Response::builder() .status(503) .body(Body::from(format!("Error connecting to agent host: {e:?}"))) diff --git a/eslint.config.js b/eslint.config.js index 955fe0033bd..187dcd85864 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -92,6 +92,7 @@ export default tseslint.config( 'local/code-no-localized-model-description': 'warn', 'local/code-policy-localization-key-match': 'warn', 'local/code-no-localization-template-literals': 'error', + 'local/code-no-icons-in-localized-strings': 'warn', 'local/code-no-http-import': ['warn', { target: 'src/vs/**' }], 'local/code-no-deep-import-of-internal': ['error', { '.*Internal': true, 'searchExtTypesInternal': false }], 'local/code-layering': [ @@ -183,6 +184,18 @@ export default tseslint.config( ] } }, + // Disallow common telemetry properties in event data + { + files: [ + 'src/**/*.ts', + ], + plugins: { + 'local': pluginLocal, + }, + rules: { + 'local/code-no-telemetry-common-property': 'warn', + } + }, // Disallow 'in' operator except in type predicates { files: [ @@ -1042,6 +1055,33 @@ export default tseslint.config( ] } }, + // electron-main layer: prevent static imports of heavy node_modules + // that would be synchronously loaded on startup + { + files: [ + 'src/vs/code/electron-main/**/*.ts', + 'src/vs/code/node/**/*.ts', + 'src/vs/platform/*/electron-main/**/*.ts', + 'src/vs/platform/*/node/**/*.ts', + ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + 'local': pluginLocal, + }, + rules: { + 'local/code-no-static-node-module-import': [ + 'error', + // Files that run in separate processes, not on the electron-main startup path + 'src/vs/platform/agentHost/node/copilot/**/*.ts', + 'src/vs/platform/files/node/watcher/**/*.ts', + 'src/vs/platform/terminal/node/**/*.ts', + // Files that use small, safe modules + 'src/vs/platform/environment/node/argv.ts', + ] + } + }, // browser/electron-browser layer { files: [ @@ -2346,6 +2386,10 @@ export default tseslint.config( 'selector': `NewExpression[callee.object.name='Intl']`, 'message': 'Use safeIntl helper instead for safe and lazy use of potentially expensive Intl methods.' }, + { + 'selector': 'TSAsExpression[typeAnnotation.type="TSTypeReference"][typeAnnotation.typeName.type="TSQualifiedName"][typeAnnotation.typeName.left.type="Identifier"][typeAnnotation.typeName.left.name="sinon"][typeAnnotation.typeName.right.name="SinonStub"]', + 'message': `Avoid casting with 'as sinon.SinonStub'. Prefer typed stubs from 'sinon.stub(...)' or capture the stub in a typed variable.` + }, ], } }); diff --git a/extensions/git/src/diagnostics.ts b/extensions/git/src/diagnostics.ts index a8c1a3deea3..64bf11076fe 100644 --- a/extensions/git/src/diagnostics.ts +++ b/extensions/git/src/diagnostics.ts @@ -85,7 +85,11 @@ export class GitCommitInputBoxDiagnosticsManager { const threshold = index === 0 ? inputValidationSubjectLength ?? inputValidationLength : inputValidationLength; if (line.text.length > threshold) { - const diagnostic = new Diagnostic(line.range, l10n.t('{0} characters over {1} in current line', line.text.length - threshold, threshold), this.severity); + const charactersOver = line.text.length - threshold; + const lineLengthMessage = charactersOver === 1 + ? l10n.t('{0} character over {1} in current line', charactersOver, threshold) + : l10n.t('{0} characters over {1} in current line', charactersOver, threshold); + const diagnostic = new Diagnostic(line.range, lineLengthMessage, this.severity); diagnostic.code = DiagnosticCodes.line_length; diagnostics.push(diagnostic); diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 01530f41151..a20a8b0002f 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -465,9 +465,9 @@ class DotGitWatcher implements IFileWatcher { const rootWatcher = watch(repository.dotGit.path); this.disposables.push(rootWatcher); - // Ignore changes to the "index.lock" file, and watchman fsmonitor hook (https://git-scm.com/docs/githooks#_fsmonitor_watchman) cookie files. + // Ignore changes to the "index.lock" file (including worktree index.lock files), and watchman fsmonitor hook (https://git-scm.com/docs/githooks#_fsmonitor_watchman) cookie files. // Watchman creates a cookie file inside the git directory whenever a query is run (https://facebook.github.io/watchman/docs/cookies.html). - const filteredRootWatcher = filterEvent(rootWatcher.event, uri => uri.scheme === 'file' && !/\/\.git(\/index\.lock)?$|\/\.watchman-cookie-/.test(uri.path)); + const filteredRootWatcher = filterEvent(rootWatcher.event, uri => uri.scheme === 'file' && !/\/\.git(\/index\.lock|\/worktrees\/[^/]+\/index\.lock)?$|\/\.watchman-cookie-/.test(uri.path)); this.event = anyEvent(filteredRootWatcher, this.emitter.event); repository.onDidRunGitStatus(this.updateTransientWatchers, this, this.disposables); @@ -932,7 +932,7 @@ export class Repository implements Disposable { // FS changes should trigger `git status`: // - any change inside the repository working tree - // - any change whithin the first level of the `.git` folder, except the folder itself and `index.lock` + // - any change within the first level of the `.git` folder, except the folder itself and `index.lock` (repository and worktree) const onFileChange = anyEvent(onRepositoryWorkingTreeFileChange, onRepositoryDotGitFileChange); onFileChange(this.onFileChange, this, this.disposables); @@ -3418,7 +3418,7 @@ function retargetTaskToWorktree(task: Task, worktreePath: string): Task | undefi worktreeTask.detail = task.detail; worktreeTask.group = task.group; worktreeTask.isBackground = task.isBackground; - worktreeTask.presentationOptions = { reveal: TaskRevealKind.Never, panel: TaskPanelKind.New, ...task.presentationOptions }; + worktreeTask.presentationOptions = { ...task.presentationOptions, reveal: TaskRevealKind.Never, panel: TaskPanelKind.New }; worktreeTask.runOptions = { ...task.runOptions }; return worktreeTask; diff --git a/extensions/grunt/src/main.ts b/extensions/grunt/src/main.ts index b94b00c4462..9c2fee82b50 100644 --- a/extensions/grunt/src/main.ts +++ b/extensions/grunt/src/main.ts @@ -18,9 +18,9 @@ function exists(file: string): Promise { }); } -function exec(command: string, options: cp.ExecOptions): Promise<{ stdout: string; stderr: string }> { +function exec(command: string, args: string[], options: cp.ExecFileOptions): Promise<{ stdout: string; stderr: string }> { return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { - cp.exec(command, options, (error, stdout, stderr) => { + cp.execFile(command, args, options, (error, stdout, stderr) => { if (error) { reject({ error, stdout, stderr }); } @@ -143,9 +143,9 @@ class FolderDetector { return emptyTasks; } - const commandLine = `${await this._gruntCommand} --help --no-color`; + const gruntCommand = await this._gruntCommand; try { - const { stdout, stderr } = await exec(commandLine, { cwd: rootPath }); + const { stdout, stderr } = await exec(gruntCommand, ['--help', '--no-color'], { cwd: rootPath }); if (stderr) { getOutputChannel().appendLine(stderr); showError(); diff --git a/extensions/json-language-features/server/src/jsonServer.ts b/extensions/json-language-features/server/src/jsonServer.ts index 811cbcd2e91..edfb856aacc 100644 --- a/extensions/json-language-features/server/src/jsonServer.ts +++ b/extensions/json-language-features/server/src/jsonServer.ts @@ -11,7 +11,7 @@ import { import { runSafe, runSafeAsync } from './utils/runner'; import { DiagnosticsSupport, registerDiagnosticsPullSupport, registerDiagnosticsPushSupport } from './utils/validation'; -import { TextDocument, JSONDocument, JSONSchema, getLanguageService, DocumentLanguageSettings, SchemaConfiguration, ClientCapabilities, Range, Position, SortOptions } from 'vscode-json-languageservice'; +import { TextDocument, JSONDocument, JSONSchema, getLanguageService, DocumentLanguageSettings, SchemaConfiguration, ClientCapabilities, Range, Position, SortOptions, SeverityLevel } from 'vscode-json-languageservice'; import { getLanguageModelCache } from './languageModelCache'; import { Utils, URI } from 'vscode-uri'; import * as l10n from '@vscode/l10n'; @@ -216,7 +216,13 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) schemas?: JSONSchemaSettings[]; format?: { enable?: boolean }; keepLines?: { enable?: boolean }; - validate?: { enable?: boolean }; + validate?: { + enable?: boolean; + comments?: SeverityLevel; + trailingCommas?: SeverityLevel; + schemaValidation?: SeverityLevel; + schemaRequest?: SeverityLevel; + }; resultLimit?: number; jsonFoldingLimit?: number; jsoncFoldingLimit?: number; @@ -242,6 +248,10 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) let schemaAssociations: ISchemaAssociations | SchemaConfiguration[] | undefined = undefined; let formatterRegistrations: Thenable[] | null = null; let validateEnabled = true; + let commentsSeverity: SeverityLevel | undefined = undefined; + let trailingCommasSeverity: SeverityLevel | undefined = undefined; + let schemaValidationSeverity: SeverityLevel | undefined = undefined; + let schemaRequestSeverity: SeverityLevel | undefined = undefined; let keepLinesEnabled = false; // The settings have changed. Is sent on server activation as well. @@ -250,6 +260,10 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) runtime.configureHttpRequests?.(settings?.http?.proxy, !!settings.http?.proxyStrictSSL); jsonConfigurationSettings = settings.json?.schemas; validateEnabled = !!settings.json?.validate?.enable; + commentsSeverity = settings.json?.validate?.comments; + trailingCommasSeverity = settings.json?.validate?.trailingCommas; + schemaValidationSeverity = settings.json?.validate?.schemaValidation; + schemaRequestSeverity = settings.json?.validate?.schemaRequest; keepLinesEnabled = settings.json?.keepLines?.enable || false; updateConfiguration(); @@ -388,7 +402,12 @@ export function startServer(connection: Connection, runtime: RuntimeEnvironment) return []; // ignore empty documents } const jsonDocument = getJSONDocument(textDocument); - const documentSettings: DocumentLanguageSettings = textDocument.languageId === 'jsonc' ? { comments: 'ignore', trailingCommas: 'warning' } : { comments: 'error', trailingCommas: 'error' }; + const documentSettings: DocumentLanguageSettings = { + comments: commentsSeverity ?? (textDocument.languageId === 'jsonc' ? 'ignore' : 'error'), + trailingCommas: trailingCommasSeverity ?? (textDocument.languageId === 'jsonc' ? 'warning' : 'error'), + schemaValidation: schemaValidationSeverity, + schemaRequest: schemaRequestSeverity + }; return await languageService.doValidation(textDocument, jsonDocument, documentSettings); } diff --git a/extensions/simple-browser/package.json b/extensions/simple-browser/package.json index 59a0c8677fe..bf4dfcb250a 100644 --- a/extensions/simple-browser/package.json +++ b/extensions/simple-browser/package.json @@ -42,6 +42,14 @@ "category": "Simple Browser" } ], + "menus": { + "commandPalette": [ + { + "command": "simpleBrowser.show", + "when": "isWeb" + } + ] + }, "configuration": [ { "title": "Simple Browser", @@ -51,12 +59,6 @@ "default": true, "title": "Focus Lock Indicator Enabled", "description": "%configuration.focusLockIndicator.enabled.description%" - }, - "simpleBrowser.useIntegratedBrowser": { - "type": "boolean", - "default": true, - "markdownDescription": "%configuration.useIntegratedBrowser.description%", - "scope": "application" } } } diff --git a/extensions/simple-browser/package.nls.json b/extensions/simple-browser/package.nls.json index 0b88b068fbc..496dc28dfdd 100644 --- a/extensions/simple-browser/package.nls.json +++ b/extensions/simple-browser/package.nls.json @@ -1,6 +1,5 @@ { "displayName": "Simple Browser", "description": "A very basic built-in webview for displaying web content.", - "configuration.focusLockIndicator.enabled.description": "Enable/disable the floating indicator that shows when focused in the simple browser.", - "configuration.useIntegratedBrowser.description": "When enabled, the `simpleBrowser.show` command will open URLs in the integrated browser instead of the Simple Browser webview. **Note:** This setting is only available on desktop." + "configuration.focusLockIndicator.enabled.description": "Enable/disable the floating indicator that shows when focused in the simple browser." } diff --git a/extensions/simple-browser/src/extension.ts b/extensions/simple-browser/src/extension.ts index 75ee87d4da7..ddcdc52b42d 100644 --- a/extensions/simple-browser/src/extension.ts +++ b/extensions/simple-browser/src/extension.ts @@ -15,7 +15,6 @@ declare class URL { const openApiCommand = 'simpleBrowser.api.open'; const showCommand = 'simpleBrowser.show'; const integratedBrowserCommand = 'workbench.action.browser.open'; -const useIntegratedBrowserSetting = 'simpleBrowser.useIntegratedBrowser'; const enabledHosts = new Set([ 'localhost', @@ -37,12 +36,6 @@ const openerId = 'simpleBrowser.open'; * Checks if the integrated browser should be used instead of the simple browser */ async function shouldUseIntegratedBrowser(): Promise { - const config = vscode.workspace.getConfiguration(); - if (!config.get(useIntegratedBrowserSetting, true)) { - return false; - } - - // Verify that the integrated browser command is available const commands = await vscode.commands.getCommands(true); return commands.includes(integratedBrowserCommand); } diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index 616dc5c2688..9427640c404 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -57,7 +57,7 @@ "list.activeSelectionForeground": "#ededed", "list.inactiveSelectionBackground": "#2C2D2E", "list.inactiveSelectionForeground": "#ededed", - "list.hoverBackground": "#262728", + "list.hoverBackground": "#FFFFFF0D", "list.hoverForeground": "#bfbfbf", "list.dropBackground": "#3994BC1A", "toolbar.hoverBackground": "#FFFFFF18", diff --git a/extensions/typescript-language-features/src/typescriptServiceClient.ts b/extensions/typescript-language-features/src/typescriptServiceClient.ts index 507f9e6628a..ae18dff741c 100644 --- a/extensions/typescript-language-features/src/typescriptServiceClient.ts +++ b/extensions/typescript-language-features/src/typescriptServiceClient.ts @@ -1217,9 +1217,9 @@ export default class TypeScriptServiceClient extends Disposable implements IType /* __GDPR__ "typingsInstalled" : { "owner": "mjbvz", - "installedPackages" : { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight" }, - "installSuccess": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, - "typingsInstallerVersion": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "installedPackages": { "classification": "PublicNonPersonalData", "purpose": "FeatureInsight" }, + "installSuccess": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "typingsInstallerVersion": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "${include}": [ "${TypeScriptCommonProperties}" ] diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts index fc0bcbb66bf..0791391e6af 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; -import { window, ViewColumn } from 'vscode'; +import { window, commands, ViewColumn } from 'vscode'; import { assertNoRpc, closeAllEditors } from '../utils'; (vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('vscode API - browser', () => { @@ -73,6 +73,16 @@ import { assertNoRpc, closeAllEditors } from '../utils'; assert.strictEqual(window.browserTabs.length, countBefore - 1); }); + test('Can move a browser tab to a new group and close it successfully', async () => { + const tab = await window.openBrowserTab('about:blank'); + assert.ok(window.browserTabs.includes(tab)); + + await commands.executeCommand('workbench.action.moveEditorToNextGroup'); + + await tab.close(); + assert.ok(!window.browserTabs.includes(tab)); + }); + // #endregion // #region onDidOpenBrowserTab diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts new file mode 100644 index 00000000000..f3b7f7e9f81 --- /dev/null +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -0,0 +1,387 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import 'mocha'; +import * as vscode from 'vscode'; +import { DeferredPromise, assertNoRpc, closeAllEditors, disposeAll } from '../utils'; + +const isWindows = process.platform === 'win32'; +const isMacOS = process.platform === 'darwin'; +const sandboxFileSystemSetting = isMacOS + ? 'chat.tools.terminal.sandbox.macFileSystem' + : 'chat.tools.terminal.sandbox.linuxFileSystem'; + +/** + * Extracts all text content from a LanguageModelToolResult. + */ +function extractTextContent(result: vscode.LanguageModelToolResult): string { + return result.content + .filter((c): c is vscode.LanguageModelTextPart => c instanceof vscode.LanguageModelTextPart) + .map(c => c.value) + .join(''); +} + +(vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('chat - run_in_terminal', () => { + + let disposables: vscode.Disposable[] = []; + + setup(async () => { + disposables = []; + + // Register a dummy default model required for participant requests + disposables.push(vscode.lm.registerLanguageModelChatProvider('copilot', { + async provideLanguageModelChatInformation(_options, _token) { + return [{ + id: 'test-lm', + name: 'test-lm', + family: 'test', + version: '1.0.0', + maxInputTokens: 100, + maxOutputTokens: 100, + isDefault: true, + isUserSelectable: true, + capabilities: {} + }]; + }, + async provideLanguageModelChatResponse(_model, _messages, _options, _progress, _token) { + return undefined; + }, + async provideTokenCount(_model, _text, _token) { + return 1; + }, + })); + + // Enable global auto-approve + skip the confirmation dialog via test-mode context key + const chatToolsConfig = vscode.workspace.getConfiguration('chat.tools.global'); + await chatToolsConfig.update('autoApprove', true, vscode.ConfigurationTarget.Global); + await vscode.commands.executeCommand('setContext', 'vscode.chat.tools.global.autoApprove.testMode', true); + }); + + teardown(async () => { + assertNoRpc(); + await closeAllEditors(); + disposeAll(disposables); + participantRegistered = false; + pendingResult = undefined; + pendingCommand = undefined; + pendingTimeout = undefined; + + const chatToolsConfig = vscode.workspace.getConfiguration('chat.tools.global'); + await chatToolsConfig.update('autoApprove', undefined, vscode.ConfigurationTarget.Global); + await vscode.commands.executeCommand('setContext', 'vscode.chat.tools.global.autoApprove.testMode', undefined); + }); + + /** + * Helper: invokes run_in_terminal via a chat participant and returns the tool result text. + * Each call creates a new chat session to avoid participant re-registration issues. + */ + let participantRegistered = false; + let pendingResult: DeferredPromise | undefined; + let pendingCommand: string | undefined; + let pendingTimeout: number | undefined; + + function setupParticipant() { + if (participantRegistered) { + return; + } + participantRegistered = true; + const participant = vscode.chat.createChatParticipant('api-test.participant', async (request, _context, _progress, _token) => { + if (!pendingResult || !pendingCommand) { + return {}; + } + const currentResult = pendingResult; + const currentCommand = pendingCommand; + const currentTimeout = pendingTimeout ?? 15000; + pendingResult = undefined; + pendingCommand = undefined; + pendingTimeout = undefined; + try { + const result = await vscode.lm.invokeTool('run_in_terminal', { + input: { + command: currentCommand, + explanation: 'Integration test command', + goal: 'Test run_in_terminal output', + isBackground: false, + timeout: currentTimeout + }, + toolInvocationToken: request.toolInvocationToken, + }); + currentResult.complete(result); + } catch (e) { + currentResult.error(e); + } + return {}; + }); + disposables.push(participant); + } + + async function invokeRunInTerminal(command: string, timeout = 15000): Promise { + setupParticipant(); + + const resultPromise = new DeferredPromise(); + pendingResult = resultPromise; + pendingCommand = command; + pendingTimeout = timeout; + + await vscode.commands.executeCommand('workbench.action.chat.newChat'); + vscode.commands.executeCommand('workbench.action.chat.open', { query: '@participant test' }); + + const result = await resultPromise.p; + return extractTextContent(result); + } + + test('tool should be registered with expected schema', async function () { + this.timeout(15000); + // The run_in_terminal tool is registered asynchronously (it needs to + // resolve terminal profiles), so poll until it appears. + let tool: vscode.LanguageModelToolInformation | undefined; + for (let i = 0; i < 50; i++) { + tool = vscode.lm.tools.find(t => t.name === 'run_in_terminal'); + if (tool) { + break; + } + await new Promise(r => setTimeout(r, 200)); + } + assert.ok(tool, 'run_in_terminal tool should be registered'); + assert.ok(tool.inputSchema, 'Tool should have an input schema'); + + const schema = tool.inputSchema as { properties?: Record }; + assert.ok(schema.properties?.['command'], 'Schema should have a command property'); + assert.ok(schema.properties?.['explanation'], 'Schema should have an explanation property'); + assert.ok(schema.properties?.['goal'], 'Schema should have a goal property'); + assert.ok(schema.properties?.['isBackground'], 'Schema should have an isBackground property'); + }); + + // --- Shell integration OFF (fast idle polling) --- + + suite('shell integration off', () => { + + setup(async () => { + const termConfig = vscode.workspace.getConfiguration('terminal.integrated'); + await termConfig.update('shellIntegration.enabled', false, vscode.ConfigurationTarget.Global); + await termConfig.update('shellIntegration.timeout', 0, vscode.ConfigurationTarget.Global); + + const toolConfig = vscode.workspace.getConfiguration('chat.tools.terminal'); + await toolConfig.update('idlePollInterval', 100, vscode.ConfigurationTarget.Global); + }); + + teardown(async () => { + const termConfig = vscode.workspace.getConfiguration('terminal.integrated'); + await termConfig.update('shellIntegration.enabled', undefined, vscode.ConfigurationTarget.Global); + await termConfig.update('shellIntegration.timeout', undefined, vscode.ConfigurationTarget.Global); + + const toolConfig = vscode.workspace.getConfiguration('chat.tools.terminal'); + await toolConfig.update('idlePollInterval', undefined, vscode.ConfigurationTarget.Global); + }); + + defineTests(false); + }); + + // --- Shell integration ON --- + + suite('shell integration on', () => { + defineTests(true); + }); + + function defineTests(hasShellIntegration: boolean) { + + // --- Sandbox OFF tests --- + + suite('sandbox off', () => { + + test('echo command returns exactly the echoed text', async function () { + this.timeout(60000); + + const marker = `MARKER_${Date.now()}_ECHO`; + const output = await invokeRunInTerminal(`echo ${marker}`); + + assert.strictEqual(output.trim(), marker); + }); + + test('no-output command reports empty output, not prompt echo (issue #303531)', async function () { + this.timeout(60000); + + // `true` on Unix exits 0 with no output; `cmd /c rem` on Windows is a no-op + const command = isWindows ? 'cmd /c rem' : 'true'; + const output = await invokeRunInTerminal(command); + + assert.strictEqual(output.trim(), 'Command produced no output'); + }); + + test('multi-line output preserves all lines in order', async function () { + this.timeout(60000); + + const m1 = `M1_${Date.now()}`; + const m2 = `M2_${Date.now()}`; + const m3 = `M3_${Date.now()}`; + // Use `;` on Windows (PowerShell) since `&&` is rewritten to `;` + const sep = isWindows ? ';' : '&&'; + const output = await invokeRunInTerminal(`echo ${m1} ${sep} echo ${m2} ${sep} echo ${m3}`); + + // Without shell integration, idle polling may miss the + // output on slow CI machines. + const acceptable = [ + `${m1}\n${m2}\n${m3}`, + ...(!hasShellIntegration ? ['Command produced no output'] : []), + ]; + assert.ok(acceptable.includes(output.trim()), `Unexpected output: ${JSON.stringify(output.trim())}`); + }); + + (isWindows ? test : test.skip)('&& operators are converted to ; on PowerShell', async function () { + this.timeout(60000); + + const m1 = `CHAIN_${Date.now()}_A`; + const m2 = `CHAIN_${Date.now()}_B`; + const output = await invokeRunInTerminal(`echo ${m1} && echo ${m2}`); + + // The rewriter prepends a note explaining the simplification + const trimmed = output.trim(); + assert.ok(trimmed.startsWith('Note: The tool simplified the command to'), `Expected rewrite note, got: ${trimmed}`); + assert.ok(trimmed.endsWith(`${m1}\n${m2}`), `Expected markers at end, got: ${trimmed}`); + }); + + test('non-zero exit code is reported', async function () { + this.timeout(60000); + + // Use a subshell so we don't kill the shared terminal + const command = isWindows ? 'cmd /c exit 42' : 'bash -c "exit 42"'; + const output = await invokeRunInTerminal(command); + + // Without shell integration, exit codes are unavailable. + // On Windows with shell integration, `cmd /c exit 42` may report + // exit code 1 instead of 42 due to how PowerShell propagates + // cmd.exe exit codes through shell integration sequences. + const acceptable = [ + 'Command produced no output\nCommand exited with code 42', + ...(!hasShellIntegration ? ['Command produced no output'] : []), + ...(isWindows && hasShellIntegration ? ['Command produced no output\nCommand exited with code 1'] : []), + ]; + assert.ok(acceptable.includes(output.trim()), `Unexpected output: ${JSON.stringify(output.trim())}`); + }); + + test('output with special characters is captured verbatim', async function () { + this.timeout(60000); + + const marker = `SP_${Date.now()}`; + const output = await invokeRunInTerminal(`echo "${marker} hello & world"`); + + assert.strictEqual(output.trim(), `${marker} hello & world`); + }); + + }); + + // --- Sandbox ON tests (macOS and Linux only) --- + + (isWindows ? suite.skip : suite)('sandbox on', () => { + + setup(async () => { + const sandboxConfig = vscode.workspace.getConfiguration('chat.tools.terminal.sandbox'); + await sandboxConfig.update('enabled', true, vscode.ConfigurationTarget.Global); + }); + + teardown(async () => { + const sandboxConfig = vscode.workspace.getConfiguration('chat.tools.terminal.sandbox'); + await sandboxConfig.update('enabled', undefined, vscode.ConfigurationTarget.Global); + }); + + test('echo works in sandbox and output is clean', async function () { + this.timeout(60000); + + const marker = `SANDBOX_ECHO_${Date.now()}`; + const output = await invokeRunInTerminal(`echo ${marker}`); + + assert.strictEqual(output.trim(), marker); + }); + + test('network requests are blocked', async function () { + this.timeout(60000); + + const output = await invokeRunInTerminal('curl -s --max-time 5 https://example.com'); + + // Without shell integration, exit code is unavailable and + // curl produces no sandbox-specific error strings, so the + // sandbox analyzer may not trigger. + const acceptable = [ + [ + 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:', + `- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${sandboxFileSystemSetting}, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.`, + '- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user \u2014 setting this flag automatically shows a confirmation prompt to the user.', + '', + 'Here is the output of the command:', + '', + '', + '', + 'Command produced no output', + 'Command exited with code 56', + ].join('\n'), + ...(!hasShellIntegration ? ['Command produced no output'] : []), + ]; + assert.ok(acceptable.includes(output.trim()), `Unexpected output: ${JSON.stringify(output.trim())}`); + }); + + test('cannot write to /tmp', async function () { + this.timeout(60000); + + const marker = `SANDBOX_TMP_${Date.now()}`; + const output = await invokeRunInTerminal(`echo "${marker}" > /tmp/${marker}.txt`); + + // macOS sandbox-exec returns "Operation not permitted" via /bin/bash; + // Linux read-only bind mount returns "Read-only file system" via /usr/bin/bash. + // Some shells include "line N:" in the error (e.g. "/usr/bin/bash: line 1: …"). + const shellError = isMacOS + ? `/bin/bash: /tmp/${marker}.txt: Operation not permitted` + : `/usr/bin/bash: line 1: /tmp/${marker}.txt: Read-only file system`; + const sandboxBody = [ + `- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${sandboxFileSystemSetting}, or to add required domains to chat.tools.terminal.sandbox.network.allowedDomains.`, + '- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user \u2014 setting this flag automatically shows a confirmation prompt to the user.', + '', + 'Here is the output of the command:', + '', + shellError, + ].join('\n'); + const acceptable = [ + // With shell integration: known failure with exit code + `Command failed while running in sandboxed mode. If the command failed due to sandboxing:\n${sandboxBody}\n\nCommand exited with code 1`, + // Without shell integration: heuristic detection, no exit code + ...(!hasShellIntegration ? [`Command ran in sandboxed mode and may have been blocked by the sandbox. If the command failed due to sandboxing:\n${sandboxBody}`] : []), + ]; + assert.ok(acceptable.includes(output.trim()), `Unexpected output: ${JSON.stringify(output.trim())}`); + }); + + test('can read files outside the workspace', async function () { + this.timeout(60000); + + const output = await invokeRunInTerminal('head -1 /etc/shells'); + + const trimmed = output.trim(); + // macOS: "# List of acceptable shells for chpass(1)." + // Linux: "# /etc/shells: valid login shells" + assert.ok( + trimmed.startsWith('#'), + `Expected a comment line from /etc/shells, got: ${trimmed}` + ); + }); + + test('can write inside the workspace folder', async function () { + this.timeout(60000); + + const marker = `SANDBOX_WS_${Date.now()}`; + const output = await invokeRunInTerminal(`echo "${marker}" > .sandbox-test-${marker}.tmp && cat .sandbox-test-${marker}.tmp && rm .sandbox-test-${marker}.tmp`); + + assert.strictEqual(output.trim(), marker); + }); + + test('$TMPDIR is writable inside the sandbox', async function () { + this.timeout(60000); + + const marker = `SANDBOX_TMPDIR_${Date.now()}`; + const output = await invokeRunInTerminal(`echo "${marker}" > "$TMPDIR/${marker}.tmp" && cat "$TMPDIR/${marker}.tmp" && rm "$TMPDIR/${marker}.tmp"`); + + assert.strictEqual(output.trim(), marker); + }); + }); + } +}); 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 6ed6c911718..863f18bb29e 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.test.ts @@ -60,6 +60,7 @@ suite('chat', () => { test('participant and slash command history', async () => { const onRequest = setupParticipant(); + await commands.executeCommand('workbench.action.chat.newChat'); commands.executeCommand('workbench.action.chat.open', { query: '@participant /hello friend' }); const deferred = new DeferredPromise(); diff --git a/package-lock.json b/package-lock.json index be4365a44b6..5073bc93695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,19 +10,19 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-14", + "@vscode/codicons": "^0.0.46-0", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/sqlite3": "5.1.12-vscode", @@ -106,7 +106,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.2", + "electron": "39.8.3", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -419,15 +419,15 @@ } }, "node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.23.tgz", - "integrity": "sha512-Np0VRH6D71cGoJZvd8hCz1LMfwg9ERJovrOJSCz5aSQSQJPWPNIFPV1wfc8oAhJpStOuYkot+EmXOkRRxuGMCQ==", + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz", + "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", "@types/lodash-es": "^4.17.12", "commander": "^12.1.0", - "lodash-es": "^4.17.21", + "lodash-es": "^4.17.23", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, @@ -4315,15 +4315,15 @@ ] }, "node_modules/@vscode/codicons": { - "version": "0.0.45-14", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-14.tgz", - "integrity": "sha512-EdrK2NnxNGluUm9ZlU1C5VTLfG1cpO4C0CCXloS+8bDuTbidE1qtwaF5lHPcoDE102WBqBzWA09nVKFoN8RSOA==", + "version": "0.0.46-0", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-0.tgz", + "integrity": "sha512-6/ddrtzpxvUK4JSVHI0SCNXw1tI4S7KnIb28X+HWexJIv9oNQK6Mhe2qydnoGlCYt+RzqaQ0pjRS8U3smE9rrA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { - "version": "0.1.1-24", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-24.tgz", - "integrity": "sha512-o+uFX1bqD6dvAALx+Y32Gf7FmQehPsjGAI1Bm+5PvaV/++RIqsniM+VXIwqwjtuUvOyAMOz2TEOPYy3Uju//Qw==", + "version": "0.1.1-27", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-27.tgz", + "integrity": "sha512-FAxC9WlYOaRx6XfU3/ceI2bCOPQp45CpQehUyhG3AbDxLuM8Kv2VyJYJiSsQ0Z2a8cah/3CB729oIiINO19mdw==", "dev": true, "license": "MIT", "dependencies": { @@ -4333,9 +4333,9 @@ } }, "node_modules/@vscode/component-explorer-cli": { - "version": "0.1.1-20", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.1.1-20.tgz", - "integrity": "sha512-pD/1wujsryCcdEvIaHZCxAyVZSmNl/Cic2+C2MY+PV/gzyRkJs9ek0/vah8oayVMW7d02YB02v4Hhifw2Zj7Hw==", + "version": "0.1.1-23", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.1.1-23.tgz", + "integrity": "sha512-3Xx57+3Qcj4bPgGoZBiUHzwrq5fMrEtKbGcKCKGFOYY9h04FUn4o3H8ic3uTG0Xe8utRxeLTh9er/+Jo/DOBbA==", "dev": true, "license": "MIT", "dependencies": { @@ -4779,9 +4779,9 @@ } }, "node_modules/@vscode/proxy-agent": { - "version": "0.39.1", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.39.1.tgz", - "integrity": "sha512-Au6ra1oVBNlxgroyr58VuaO2mVH02xidPfN6o0znYr/NQ8OvXNm4CVM44iwyCP1sOzhLKjMoPEeWIbKDpLgtFg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.40.0.tgz", + "integrity": "sha512-G2OUy5b2vxYXoRWo38BwxBKW1GCjwno9tivcshJNBWkeHjwcidLkL6KFaVRgIDDxJjojPkoxy9AivTDU/ksJ6g==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", @@ -8640,9 +8640,9 @@ "dev": true }, "node_modules/electron": { - "version": "39.8.2", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.2.tgz", - "integrity": "sha512-uwNJHeqm8pzQEZf/KX4XM1fJctZpHcA0Za/MlP9mOg0FAWHbKo6yRC33QbdfLX7PeNjYZC3I3nnVhE5L2CLqxw==", + "version": "39.8.3", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.3.tgz", + "integrity": "sha512-ZhetvWz2qbI2WbBHdK/utR8I5bi1pYWJdit9tP0sGzs42CpsAFyu/FirXE88NWSh+3U8X6Wuf9jjDEYvAyrxNw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -9938,22 +9938,9 @@ "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==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "dev": true, "funding": [ { @@ -9963,8 +9950,25 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -10310,10 +10314,11 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" }, "node_modules/flush-write-stream": { "version": "1.1.1", @@ -16626,6 +16631,22 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -20342,9 +20363,9 @@ "dev": true }, "node_modules/undici": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.0.tgz", - "integrity": "sha512-jxytwMHhsbdpBXxLAcuu0fzlQeXCNnWdDyRHpvWsUl8vd98UwYdl9YTyn8/HcpcJPC3pwUveefsa3zTxyD/ERg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -20918,9 +20939,9 @@ } }, "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==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index d1988b1e43d..6702e74092e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.113.0", - "distro": "c056f4dce23e765e319d32692385fa0192028f7b", + "distro": "a981e7362565b4058f3bfd7604f8ab8c4f85101b", "author": { "name": "Microsoft Corporation" }, @@ -85,19 +85,19 @@ "install-latest-component-explorer": "npm install @vscode/component-explorer@next @vscode/component-explorer-cli@next && cd build/vite && npm install @vscode/component-explorer-vite-plugin@next && npm install @vscode/component-explorer@next" }, "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-14", + "@vscode/codicons": "^0.0.46-0", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/sqlite3": "5.1.12-vscode", @@ -181,7 +181,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "39.8.2", + "electron": "39.8.3", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", diff --git a/remote/.npmrc b/remote/.npmrc index 8310ec94634..311a50d94c5 100644 --- a/remote/.npmrc +++ b/remote/.npmrc @@ -1,6 +1,6 @@ disturl="https://nodejs.org/dist" target="22.22.1" -ms_build_id="420065" +ms_build_id="422160" runtime="node" build_from_source="true" legacy-peer-deps="true" diff --git a/remote/package-lock.json b/remote/package-lock.json index ee126bb8a8e..e58902bd620 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,7 +8,7 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", @@ -17,7 +17,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", @@ -52,15 +52,15 @@ } }, "node_modules/@anthropic-ai/sandbox-runtime": { - "version": "0.0.23", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.23.tgz", - "integrity": "sha512-Np0VRH6D71cGoJZvd8hCz1LMfwg9ERJovrOJSCz5aSQSQJPWPNIFPV1wfc8oAhJpStOuYkot+EmXOkRRxuGMCQ==", + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.42.tgz", + "integrity": "sha512-kJpuhU4hHMumeygIkKvNhscEsTtQK1sat1kZwhb6HLYBznwjMGOdnuBI/RM9HeFwxArn71/ciD2WJbxttXBMHw==", "license": "Apache-2.0", "dependencies": { "@pondwader/socks5-server": "^1.0.10", "@types/lodash-es": "^4.17.12", "commander": "^12.1.0", - "lodash-es": "^4.17.21", + "lodash-es": "^4.17.23", "shell-quote": "^1.8.3", "zod": "^3.24.1" }, @@ -608,9 +608,9 @@ "license": "MIT" }, "node_modules/@vscode/proxy-agent": { - "version": "0.39.1", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.39.1.tgz", - "integrity": "sha512-Au6ra1oVBNlxgroyr58VuaO2mVH02xidPfN6o0znYr/NQ8OvXNm4CVM44iwyCP1sOzhLKjMoPEeWIbKDpLgtFg==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.40.0.tgz", + "integrity": "sha512-G2OUy5b2vxYXoRWo38BwxBKW1GCjwno9tivcshJNBWkeHjwcidLkL6KFaVRgIDDxJjojPkoxy9AivTDU/ksJ6g==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/remote/package.json b/remote/package.json index 0142cf0cb92..0fbecc3c5a4 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "dependencies": { - "@anthropic-ai/sandbox-runtime": "0.0.23", + "@anthropic-ai/sandbox-runtime": "0.0.42", "@github/copilot": "^1.0.4-0", "@github/copilot-sdk": "^0.1.32", "@microsoft/1ds-core-js": "^3.2.13", @@ -12,7 +12,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.39.1", + "@vscode/proxy-agent": "^0.40.0", "@vscode/ripgrep": "^1.17.1", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 3e36911e4dc..fcfcacd3574 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-14", + "@vscode/codicons": "^0.0.46-0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.45-14", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-14.tgz", - "integrity": "sha512-EdrK2NnxNGluUm9ZlU1C5VTLfG1cpO4C0CCXloS+8bDuTbidE1qtwaF5lHPcoDE102WBqBzWA09nVKFoN8RSOA==", + "version": "0.0.46-0", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-0.tgz", + "integrity": "sha512-6/ddrtzpxvUK4JSVHI0SCNXw1tI4S7KnIb28X+HWexJIv9oNQK6Mhe2qydnoGlCYt+RzqaQ0pjRS8U3smE9rrA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index b358e1e3267..e363ada58d0 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-14", + "@vscode/codicons": "^0.0.46-0", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", diff --git a/scripts/sync-agent-host-protocol.ts b/scripts/sync-agent-host-protocol.ts index 750e55686af..02469d17051 100644 --- a/scripts/sync-agent-host-protocol.ts +++ b/scripts/sync-agent-host-protocol.ts @@ -9,12 +9,10 @@ // npx tsx scripts/sync-agent-host-protocol.ts // // Transformations applied: -// 1. Converts `const enum` to `const` object + string literal union (VS Code -// tsconfig uses `preserveConstEnums` which makes `const enum` nominal). -// 2. Converts 2-space indentation to tabs. -// 3. Merges duplicate imports from the same module. -// 4. Formats with the project's tsfmt.json settings. -// 5. Adds Microsoft copyright header. +// 1. Converts 2-space indentation to tabs. +// 2. Merges duplicate imports from the same module. +// 3. Formats with the project's tsfmt.json settings. +// 4. Adds Microsoft copyright header. // // URI stays as `string` (the protocol's canonical representation). VS Code code // should call `URI.parse()` at point-of-use where a URI class is needed. @@ -70,6 +68,8 @@ const BANNER = '// allow-any-unicode-comment-file\n// DO NOT EDIT -- auto-genera const FILES: { src: string; dest: string }[] = [ { src: 'state.ts', dest: 'state.ts' }, { src: 'actions.ts', dest: 'actions.ts' }, + { src: 'action-origin.generated.ts', dest: 'action-origin.generated.ts' }, + { src: 'reducers.ts', dest: 'reducers.ts' }, { src: 'commands.ts', dest: 'commands.ts' }, { src: 'errors.ts', dest: 'errors.ts' }, { src: 'notifications.ts', dest: 'notifications.ts' }, @@ -168,99 +168,14 @@ function mergeDuplicateImports(content: string): string { }).join('\n'); } -// Global enum definitions collected from all files before per-file processing -let globalEnumDefs = new Map>(); -function collectAllEnumDefs(): void { - globalEnumDefs = new Map(); - for (const file of FILES) { - const srcPath = path.join(TYPES_DIR, file.src); - if (!fs.existsSync(srcPath)) { - continue; - } - const content = fs.readFileSync(srcPath, 'utf-8'); - content.replace( - /export const enum (\w+) \{([^}]+)\}/g, - (_match, name: string, body: string) => { - const members = new Map(); - for (const line of body.split('\n')) { - const memberMatch = line.match(/^\s*(\w+)\s*=\s*'([^']+)'/); - if (memberMatch) { - members.set(memberMatch[1], memberMatch[2]); - } - } - if (members.size > 0) { - globalEnumDefs.set(name, members); - } - return _match; - } - ); - } -} -/** - * Converts `const enum Foo { A = 'a', B = 'b' }` into: - * ``` - * export const Foo = { A: 'a', B: 'b' } as const; - * export type Foo = typeof Foo[keyof typeof Foo]; - * ``` - * Then replaces `Foo.A` in type positions with the string literal `'a'`, - * using the global enum definitions collected from all protocol files. - */ -function convertConstEnums(content: string): string { - // Replace the const enum declarations in this file - content = content.replace( - /export const enum (\w+) \{([^}]+)\}/g, - (_match, name: string) => { - const members = globalEnumDefs.get(name); - if (!members) { - return _match; - } - const objEntries = [...members.entries()].map(([k, v]) => ` ${k}: '${v}'`).join(',\n'); - return `export const ${name} = {\n${objEntries},\n} as const;\nexport type ${name} = typeof ${name}[keyof typeof ${name}];`; - } - ); - // Replace Enum.Member references with their resolved string literals - for (const [enumName, members] of globalEnumDefs) { - for (const [memberName, value] of members) { - const ref = `${enumName}.${memberName}`; - content = content.split(ref).join(`'${value}'`); - } - } - - // Remove value imports of enums that are no longer referenced as values - content = content.replace( - /import \{([^}]+)\} from '([^']+)';/g, - (_match, names: string, from: string) => { - const parts = names.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0); - const remaining = parts.filter((name: string) => { - if (!globalEnumDefs.has(name)) { - return true; - } - const uses = content.split(name).length - 1; - return uses > 1; - }); - if (remaining.length === 0) { - return ''; - } - if (remaining.length === parts.length) { - return _match; - } - return `import { ${remaining.join(', ')} } from '${from}';`; - } - ); - - return content; -} function processFile(src: string, dest: string, commitHash: string): void { let content = fs.readFileSync(src, 'utf-8'); content = stripExistingHeader(content); - // Convert `const enum` to plain `const` object + string literal union - content = convertConstEnums(content); - // Merge duplicate imports from the same module content = mergeDuplicateImports(content); @@ -297,10 +212,6 @@ function main() { console.log(` Dest: ${DEST_DIR}`); console.log(); - // Collect all enum definitions across all protocol files - collectAllEnumDefs(); - console.log(` Collected ${globalEnumDefs.size} const enums`); - // Copy protocol files for (const file of FILES) { const srcPath = path.join(TYPES_DIR, file.src); diff --git a/src/vs/base/browser/ui/button/button.css b/src/vs/base/browser/ui/button/button.css index 8496f1b2284..0ec4cbb4712 100644 --- a/src/vs/base/browser/ui/button/button.css +++ b/src/vs/base/browser/ui/button/button.css @@ -110,6 +110,7 @@ border: 1px solid var(--vscode-button-border, transparent); border-left-width: 0 !important; border-radius: 0 4px 4px 0; + padding: 0 4px; display: flex; align-items: center; } diff --git a/src/vs/base/browser/ui/hover/hoverWidget.css b/src/vs/base/browser/ui/hover/hoverWidget.css index 85379221cf2..c2837659d5f 100644 --- a/src/vs/base/browser/ui/hover/hoverWidget.css +++ b/src/vs/base/browser/ui/hover/hoverWidget.css @@ -139,6 +139,7 @@ .monaco-hover .hover-row.status-bar .actions .action-container .action .icon { padding-right: 4px; vertical-align: middle; + font-size: inherit; } .monaco-hover .hover-row.status-bar .actions .action-container a { diff --git a/src/vs/base/browser/ui/tree/asyncDataTree.ts b/src/vs/base/browser/ui/tree/asyncDataTree.ts index 6c604269ac5..9d6d200f68f 100644 --- a/src/vs/base/browser/ui/tree/asyncDataTree.ts +++ b/src/vs/base/browser/ui/tree/asyncDataTree.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IDragAndDropData } from '../../dnd.js'; -import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, IListMouseEvent, IListTouchEvent, IListVirtualDelegate } from '../list/list.js'; +import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListDragAndDrop, IListDragOverReaction, IListMouseEvent, IListTouchEvent, IListVirtualDelegate, NotSelectableGroupIdType } from '../list/list.js'; import { ElementsDragAndDropData, ListViewTargetSector } from '../list/listView.js'; import { IListStyles } from '../list/listWidget.js'; import { ComposedTreeDelegate, TreeFindMode, IAbstractTreeOptions, IAbstractTreeOptionsUpdate, TreeFindMatchType, AbstractTreePart, LabelFuzzyScore, FindFilter, FindController, ITreeFindToggleChangeEvent, IFindControllerOptions, IStickyScrollDelegate, AbstractTree } from './abstractTree.js'; @@ -1309,7 +1309,10 @@ export class AsyncDataTree implements IDisposable diffIdentityProvider: options.diffIdentityProvider && { getId(node: IAsyncDataTreeNode): { toString(): string } { return options.diffIdentityProvider!.getId(node.element as T); - } + }, + getGroupId: options.diffIdentityProvider!.getGroupId ? (node: IAsyncDataTreeNode): number | NotSelectableGroupIdType => { + return options.diffIdentityProvider!.getGroupId!(node.element as T); + } : undefined } }; diff --git a/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts b/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts index e4adc832676..0bcaa01c426 100644 --- a/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts +++ b/src/vs/base/browser/ui/tree/compressedObjectTreeModel.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IIdentityProvider } from '../list/list.js'; +import { IIdentityProvider, NotSelectableGroupIdType } from '../list/list.js'; import { getVisibleState, IIndexTreeModelSpliceOptions, isFilterResult } from './indexTreeModel.js'; import { IObjectTreeModel, IObjectTreeModelOptions, IObjectTreeModelSetChildrenOptions, ObjectTreeModel } from './objectTreeModel.js'; import { ICollapseStateChangeEvent, IObjectTreeElement, ITreeListSpliceData, ITreeModel, ITreeModelSpliceEvent, ITreeNode, TreeError, TreeFilterResult, TreeVisibility, WeakMapper } from './tree.js'; @@ -113,7 +113,10 @@ interface ICompressedObjectTreeModelOptions extends IObjectTreeM const wrapIdentityProvider = (base: IIdentityProvider): IIdentityProvider> => ({ getId(node) { return node.elements.map(e => base.getId(e).toString()).join('\0'); - } + }, + getGroupId: base.getGroupId ? (node: ICompressedTreeNode): number | NotSelectableGroupIdType => { + return base.getGroupId!(node.elements[node.elements.length - 1]); + } : undefined }); // Exported only for test reasons, do not use directly @@ -380,7 +383,10 @@ function mapOptions(compressedNodeUnwrapper: CompressedNodeUnwra identityProvider: options.identityProvider && { getId(node: ICompressedTreeNode): { toString(): string } { return options.identityProvider!.getId(compressedNodeUnwrapper(node)); - } + }, + getGroupId: options.identityProvider!.getGroupId ? (node: ICompressedTreeNode): number | NotSelectableGroupIdType => { + return options.identityProvider!.getGroupId!(compressedNodeUnwrapper(node)); + } : undefined }, sorter: options.sorter && { compare(node: ICompressedTreeNode, otherNode: ICompressedTreeNode): number { diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 1a2d78bcd70..65b43437870 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -656,4 +656,5 @@ export const codiconsLibrary = { claude: register('claude', 0xec82), openInWindow: register('open-in-window', 0xec83), newSession: register('new-session', 0xec84), + terminalSecure: register('terminal-secure', 0xec85), } as const; diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index a9d495ab6b0..929ed2f9e03 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -1029,7 +1029,8 @@ class LeakageMonitor { console.warn(message); console.warn(topStack); - const error = new ListenerLeakError(message, topStack); + const kind = topCount / listenerCount > 0.3 ? 'dominated' : 'popular'; + const error = new ListenerLeakError(kind, message, topStack); this._errorHandler(error); } @@ -1071,9 +1072,16 @@ class Stacktrace { // error that is logged when going over the configured listener threshold export class ListenerLeakError extends Error { - constructor(message: string, stack: string) { - super(message); + /** + * The detailed message including listener count and most frequent stack. + * Available locally for debugging but intentionally not used as the error + * `message` so that all leak errors group under the same title in telemetry. + */ + readonly details: string; + constructor(kind: 'dominated' | 'popular', details: string, stack: string) { + super(`potential listener LEAK detected, ${kind}`); this.name = 'ListenerLeakError'; + this.details = details; this.stack = stack; } } @@ -1081,9 +1089,16 @@ export class ListenerLeakError extends Error { // SEVERE error that is logged when having gone way over the configured listener // threshold so that the emitter refuses to accept more listeners export class ListenerRefusalError extends Error { - constructor(message: string, stack: string) { - super(message); + /** + * The detailed message including listener count and most frequent stack. + * Available locally for debugging but intentionally not used as the error + * `message` so that all leak errors group under the same title in telemetry. + */ + readonly details: string; + constructor(kind: 'dominated' | 'popular', details: string, stack: string) { + super(`potential listener LEAK detected, ${kind} (REFUSED to add)`); this.name = 'ListenerRefusalError'; + this.details = details; this.stack = stack; } } @@ -1221,7 +1236,8 @@ export class Emitter { console.warn(message); const tuple = this._leakageMon.getMostFrequentStack() ?? ['UNKNOWN stack', -1]; - const error = new ListenerRefusalError(`${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0]); + const kind = tuple[1] / this._size > 0.3 ? 'dominated' : 'popular'; + const error = new ListenerRefusalError(kind, `${message}. HINT: Stack shows most frequent listener (${tuple[1]}-times)`, tuple[0]); const errorHandler = this._options?.onListenerError || onUnexpectedError; errorHandler(error); diff --git a/src/vs/base/common/resources.ts b/src/vs/base/common/resources.ts index 3b8370c160b..a064a287736 100644 --- a/src/vs/base/common/resources.ts +++ b/src/vs/base/common/resources.ts @@ -190,8 +190,8 @@ export class ExtUri implements IExtUri { return basename(resource) || resource.authority; } - basename(resource: URI): string { - return paths.posix.basename(resource.path); + basename(resource: URI, suffix?: string): string { + return paths.posix.basename(resource.path, suffix); } extname(resource: URI): string { diff --git a/src/vs/base/test/browser/ui/tree/objectTree.test.ts b/src/vs/base/test/browser/ui/tree/objectTree.test.ts index aa11fbe6036..8902791afce 100644 --- a/src/vs/base/test/browser/ui/tree/objectTree.test.ts +++ b/src/vs/base/test/browser/ui/tree/objectTree.test.ts @@ -8,6 +8,7 @@ import { IIdentityProvider, IListVirtualDelegate } from '../../../../browser/ui/ import { ICompressedTreeNode } from '../../../../browser/ui/tree/compressedObjectTreeModel.js'; import { CompressibleObjectTree, ICompressibleTreeRenderer, ObjectTree } from '../../../../browser/ui/tree/objectTree.js'; import { ITreeNode, ITreeRenderer } from '../../../../browser/ui/tree/tree.js'; +import { runWithFakedTimers } from '../../../common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; function getRowsTextContent(container: HTMLElement): string[] { @@ -16,6 +17,17 @@ function getRowsTextContent(container: HTMLElement): string[] { return rows.map(row => row.querySelector('.monaco-tl-contents')!.textContent!); } +function clickElement(element: HTMLElement, ctrlKey = false): void { + element.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, ctrlKey, button: 0 })); + element.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey, button: 0 })); +} + +function dispatchKeydown(element: HTMLElement, key: string, code: string, keyCode: number): void { + const keyboardEvent = new KeyboardEvent('keydown', { bubbles: true, key, code }); + Object.defineProperty(keyboardEvent, 'keyCode', { get: () => keyCode }); + element.dispatchEvent(keyboardEvent); +} + suite('ObjectTree', function () { suite('TreeNavigator', function () { @@ -231,6 +243,84 @@ suite('ObjectTree', function () { tree.setChildren(null, [{ element: 100 }, { element: 101 }, { element: 102 }, { element: 103 }]); assert.deepStrictEqual(tree.getFocus(), [101]); }); + + test('updateOptions preserves wrapped identity provider in view options', function () { + const container = document.createElement('div'); + container.style.width = '200px'; + container.style.height = '200px'; + + const delegate = new Delegate(); + const renderer = new Renderer(); + const identityProvider = { + getId(element: number): { toString(): string } { + return `${element}`; + }, + getGroupId(element: number): number { + return element % 2; + } + }; + + const tree = new ObjectTree('test', container, delegate, [renderer], { identityProvider }); + + try { + tree.layout(200); + tree.setChildren(null, [{ element: 0 }, { element: 1 }, { element: 2 }, { element: 3 }]); + + const firstRow = container.querySelector('.monaco-list-row[data-index="0"]') as HTMLElement; + const secondRow = container.querySelector('.monaco-list-row[data-index="1"]') as HTMLElement; + clickElement(firstRow); + assert.deepStrictEqual(tree.getSelection(), [0]); + + tree.updateOptions({ indent: 12 }); + + clickElement(secondRow, true); + + assert.deepStrictEqual(tree.getSelection(), [1]); + } finally { + tree.dispose(); + } + }); + + test('updateOptions preserves wrapped accessibility provider for type navigation re-announce', async function () { + const container = document.createElement('div'); + container.style.width = '200px'; + container.style.height = '200px'; + + const delegate = new Delegate(); + const renderer = new Renderer(); + const accessibilityProvider = { + getAriaLabel(element: number): string { + assert.strictEqual(typeof element, 'number'); + return `aria ${element}`; + }, + getWidgetAriaLabel(): string { + return 'tree'; + } + }; + + const tree = new ObjectTree('test', container, delegate, [renderer], { + accessibilityProvider, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: () => 'a' + } + }); + + try { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + tree.layout(200); + tree.setChildren(null, [{ element: 0 }]); + tree.setFocus([0]); + tree.domFocus(); + + tree.updateOptions({ indent: 12 }); + + dispatchKeydown(tree.getHTMLElement(), 'a', 'KeyA', 65); + await Promise.resolve(); + }); + } finally { + tree.dispose(); + } + }); }); suite('CompressibleObjectTree', function () { diff --git a/src/vs/base/test/common/sinonUtils.ts b/src/vs/base/test/common/sinonUtils.ts new file mode 100644 index 00000000000..ef256b115a0 --- /dev/null +++ b/src/vs/base/test/common/sinonUtils.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from 'sinon'; + +export function asSinonMethodStub unknown>(method: T): sinon.SinonStubbedMember { + return method as unknown as sinon.SinonStubbedMember; +} diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index ce7744ff5a2..2f57f7bf1d5 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -743,6 +743,7 @@ export class CodeApplication extends Disposable { const openables: IWindowOpenable[] = []; const urls: IProtocolUrl[] = []; + for (const protocolUrl of protocolUrls) { if (!protocolUrl) { continue; // invalid @@ -750,6 +751,12 @@ export class CodeApplication extends Disposable { const windowOpenable = this.getWindowOpenableFromProtocolUrl(protocolUrl.uri); if (windowOpenable) { + // Sessions app: skip all window openables (file/folder/workspace) + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.trace('app#resolveInitialProtocolUrls() sessions app skipping window openable:', protocolUrl.uri.toString(true)); + continue; + } + if (await this.shouldBlockOpenable(windowOpenable, windowsMainService, dialogMainService)) { this.logService.trace('app#resolveInitialProtocolUrls() protocol url was blocked:', protocolUrl.uri.toString(true)); @@ -895,10 +902,30 @@ export class CodeApplication extends Disposable { private async handleProtocolUrl(windowsMainService: IWindowsMainService, dialogMainService: IDialogMainService, urlService: IURLService, uri: URI, options?: IOpenURLOptions): Promise { this.logService.trace('app#handleProtocolUrl():', uri.toString(true), options); + // Sessions app: ensure the sessions window is open, then let other handlers process the URL. + if ((process as INodeProcess).isEmbeddedApp) { + this.logService.trace('app#handleProtocolUrl() sessions app handling protocol URL:', uri.toString(true)); + + // Skip window openables (file/folder/workspace) for security + const windowOpenable = this.getWindowOpenableFromProtocolUrl(uri); + if (windowOpenable) { + this.logService.trace('app#handleProtocolUrl() sessions app skipping window openable:', uri.toString(true)); + return true; + } + + // Ensure sessions window is open to receive the URL + const windows = await windowsMainService.openSessionsWindow({ context: OpenContext.LINK, contextWindowId: undefined }); + const window = windows.at(0); + await window?.ready(); + + // Return false to let subsequent handlers (e.g., URLHandlerChannelClient) forward the URL + return false; + } + // Support 'workspace' URLs (https://github.com/microsoft/vscode/issues/124263) if (uri.scheme === this.productService.urlProtocol && uri.path === 'workspace') { uri = uri.with({ - authority: 'file', + authority: Schemas.file, path: URI.parse(uri.query).path, query: '' }); @@ -1582,13 +1609,38 @@ export class CodeApplication extends Disposable { const customApp = app as AppWithNetworkProcessEvents; - this._register(Event.fromNodeEventEmitter(customApp, 'network-process-launched', (_event, details) => details)(details => { - this.logService.info(`[network process] launched with pid ${details.pid}`); - })); + instantiationService.invokeFunction(accessor => { + const telemetryService = accessor.get(ITelemetryService); - this._register(Event.fromNodeEventEmitter(customApp, 'network-process-gone', (_event, details) => details)(details => { - this.logService.info(`[network process] gone - pid: ${details.pid}, exitCode: ${details.exitCode}, crashed: ${details.crashed}, crashedPreIPC: ${details.crashedPreIPC}`); - })); + type NetworkProcessLaunchedClassification = { + owner: 'deepak1556'; + comment: 'Tracks network process launch events.'; + }; + + type NetworkProcessGoneClassification = { + exitCode: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'The exit code of the network process.' }; + crashed: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the network process crashed.' }; + crashedPreIPC: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the network process crashed before IPC was established.' }; + owner: 'deepak1556'; + comment: 'Tracks network process gone events for reliability insights.'; + }; + + this._register(Event.fromNodeEventEmitter(customApp, 'network-process-launched', (_event, details) => details)(details => { + this.logService.info(`[network process] launched with pid ${details.pid}`); + + telemetryService.publicLog2<{}, NetworkProcessLaunchedClassification>('networkProcess.launched', {}); + })); + + this._register(Event.fromNodeEventEmitter(customApp, 'network-process-gone', (_event, details) => details)(details => { + this.logService.info(`[network process] gone - pid: ${details.pid}, exitCode: ${details.exitCode}, crashed: ${details.crashed}, crashedPreIPC: ${details.crashedPreIPC}`); + + telemetryService.publicLog2<{ exitCode: number; crashed: boolean; crashedPreIPC: boolean }, NetworkProcessGoneClassification>('networkProcess.gone', { + exitCode: details.exitCode, + crashed: details.crashed, + crashedPreIPC: details.crashedPreIPC + }); + })); + }); } } diff --git a/src/vs/editor/browser/view/domLineBreaksComputer.ts b/src/vs/editor/browser/view/domLineBreaksComputer.ts index ba4de747797..1c88653206f 100644 --- a/src/vs/editor/browser/view/domLineBreaksComputer.ts +++ b/src/vs/editor/browser/view/domLineBreaksComputer.ts @@ -307,7 +307,7 @@ function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent: try { discoverBreaks(range, spans, charOffsets, 0, null, lineContent.length - 1, null, breakOffsets); } catch (err) { - console.log(err); + console.error(err); return null; } diff --git a/src/vs/editor/common/core/ranges/offsetRange.ts b/src/vs/editor/common/core/ranges/offsetRange.ts index e279b382078..72d116282e2 100644 --- a/src/vs/editor/common/core/ranges/offsetRange.ts +++ b/src/vs/editor/common/core/ranges/offsetRange.ts @@ -257,7 +257,7 @@ export class OffsetRangeSet { } /** - * Returns of there is a value that is contained in this instance and the given range. + * Returns if there is a value that is contained in this instance and the given range. */ public intersectsStrict(other: OffsetRange): boolean { // TODO use binary search diff --git a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts index c9f7c4479de..4964af49280 100644 --- a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts @@ -22,6 +22,8 @@ import { CodeActionKind, CodeActionSet, CodeActionTrigger, CodeActionTriggerSour import { MarkerController, NextMarkerAction } from '../../gotoError/browser/gotoError.js'; import { HoverAnchor, HoverAnchorType, IEditorHoverParticipant, IEditorHoverRenderContext, IHoverPart, IRenderedHoverPart, IRenderedHoverParts, RenderedHoverParts } from './hoverTypes.js'; import * as nls from '../../../../nls.js'; +import { IMenuService, MenuId, MenuItemAction } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IMarker, IMarkerData, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; @@ -65,6 +67,8 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant { + for (const action of menuActions) { + context.statusBar.addAction({ + label: action.label, + commandId: action.id, + iconClass: action.class, + run: () => { + context.hide(); + this._editor.setSelection(Range.lift(markerHover.range)); + action.run(); + } + }); + } + }; + if (!this._editor.getOption(EditorOption.readOnly)) { const quickfixPlaceholderElement = context.statusBar.append($('div')); if (this.recentMarkerCodeActionsInfo) { if (IMarkerData.makeKey(this.recentMarkerCodeActionsInfo.marker) === IMarkerData.makeKey(markerHover.marker)) { if (!this.recentMarkerCodeActionsInfo.hasCodeActions) { - quickfixPlaceholderElement.textContent = nls.localize('noQuickFixes', "No quick fixes available"); + if (menuActions.length === 0) { + quickfixPlaceholderElement.textContent = nls.localize('noQuickFixes', "No quick fixes available"); + } } } else { this.recentMarkerCodeActionsInfo = undefined; @@ -230,7 +260,12 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant { * Optional hover configuration shown when focusing/hovering over the item. */ readonly hover?: IActionListItemHover; + /** + * Optional actions shown in a nested submenu panel, triggered by a chevron + * indicator on the right side of the item. When set, hovering or clicking + * the chevron opens an inline submenu with these actions. + */ + readonly submenuActions?: IAction[]; readonly keybinding?: ResolvedKeybinding; canPreview?: boolean | undefined; readonly hideIcon?: boolean; @@ -111,6 +119,7 @@ interface IActionMenuTemplateData { readonly description?: HTMLElement; readonly keybinding: KeybindingLabel; readonly toolbar: HTMLElement; + readonly submenuIndicator: HTMLElement; readonly elementDisposables: DisposableStore; previousClassName?: string; } @@ -182,6 +191,9 @@ class ActionItemRenderer implements IListRenderer, IAction constructor( private readonly _supportsPreview: boolean, private readonly _onRemoveItem: ((item: IActionListItem) => void) | undefined, + private readonly _onSubmenuIndicatorHover: ((element: IActionListItem, indicator: HTMLElement, disposables: DisposableStore) => void) | undefined, + private _hasAnySubmenuActions: boolean, + private readonly _linkHandler: ((uri: URI, item: IActionListItem) => void) | undefined, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IOpenerService private readonly _openerService: IOpenerService, ) { } @@ -211,9 +223,13 @@ class ActionItemRenderer implements IListRenderer, IAction toolbar.className = 'action-list-item-toolbar'; container.append(toolbar); + const submenuIndicator = document.createElement('div'); + submenuIndicator.className = 'action-list-submenu-indicator'; + container.append(submenuIndicator); + const elementDisposables = new DisposableStore(); - return { container, icon, text, badge, description, keybinding, toolbar, elementDisposables }; + return { container, icon, text, badge, description, keybinding, toolbar, submenuIndicator, elementDisposables }; } renderElement(element: IActionListItem, _index: number, data: IActionMenuTemplateData): void { @@ -269,7 +285,12 @@ class ActionItemRenderer implements IListRenderer, IAction } else { const rendered = renderMarkdown(element.description, { actionHandler: (content: string) => { - this._openerService.open(URI.parse(content), { allowCommands: true }); + const uri = URI.parse(content); + if (this._linkHandler) { + this._linkHandler(uri, element); + } else { + void this._openerService.open(uri, { allowCommands: true }); + } } }); data.elementDisposables.add(rendered); @@ -321,6 +342,21 @@ class ActionItemRenderer implements IListRenderer, IAction data.elementDisposables.add(actionBar); actionBar.push(toolbarActions, { icon: true, label: false }); } + + // Show submenu indicator for items with submenu actions + const hasSubmenu = !!element.submenuActions?.length; + if (hasSubmenu) { + data.submenuIndicator.className = 'action-list-submenu-indicator has-submenu ' + ThemeIcon.asClassName(Codicon.chevronRight); + data.submenuIndicator.style.display = ''; + this._onSubmenuIndicatorHover?.(element, data.submenuIndicator, data.elementDisposables); + } else if (this._hasAnySubmenuActions) { + // Reserve space for alignment when other items have submenus + data.submenuIndicator.className = 'action-list-submenu-indicator'; + data.submenuIndicator.style.display = ''; + } else { + // No items have submenu actions — hide completely + data.submenuIndicator.style.display = 'none'; + } } disposeTemplate(templateData: IActionMenuTemplateData): void { @@ -374,6 +410,17 @@ export interface IActionListOptions { */ readonly minWidth?: number; + /** + * Optional handler for markdown links activated in item descriptions or hovers. + * When unset, links open via the opener service with command links allowed. + */ + readonly linkHandler?: (uri: URI, item: IActionListItem) => void; + + /** + * Optional callback fired when a section's collapsed state changes. + */ + readonly onDidToggleSection?: (section: string, collapsed: boolean) => void; + /** * When true, descriptions are rendered as subtext below the title * instead of inline to the right. @@ -388,56 +435,58 @@ export interface IActionListOptions { readonly focusFilterOnOpen?: boolean; } -export class ActionList extends Disposable { +/** + * A standalone action list widget that handles core list rendering, filtering, + * hover, submenu, and section management without depending on IContextViewService + * or anchor-based positioning. Suitable for embedding directly in any container. + */ +export class ActionListWidget extends Disposable { public readonly domNode: HTMLElement; private readonly _list: List>; - private readonly _actionLineHeight: number; - private readonly _headerLineHeight = 24; - private readonly _separatorLineHeight = 8; + protected readonly _actionLineHeight: number; + protected readonly _headerLineHeight = 24; + protected readonly _separatorLineHeight = 8; - private _allMenuItems: IActionListItem[]; + protected _allMenuItems: IActionListItem[]; private readonly cts = this._register(new CancellationTokenSource()); private _hover = this._register(new MutableDisposable()); + private readonly _submenuDisposables = this._register(new DisposableStore()); + private readonly _submenuContainer: HTMLElement; + private _submenuHideTimeout: ReturnType | undefined; + private _currentSubmenuWidget: ActionListWidget | undefined; + private _currentSubmenuElement: IActionListItem | undefined; + private readonly _collapsedSections = new Set(); private _filterText = ''; private _suppressHover = false; private readonly _filterInput: HTMLInputElement | undefined; private readonly _filterContainer: HTMLElement | undefined; - private _lastMinWidth = 0; - private _cachedMaxWidth: number | undefined; - private _hasLaidOut = false; - private _showAbove: boolean | undefined; + + private readonly _onDidRequestLayout = this._register(new Emitter()); /** - * Returns the resolved anchor position after the first layout. - * Used by the context view delegate to lock the dropdown direction. + * Fired when the widget's visible item set changes and the parent should + * re-layout (e.g. after filtering or collapsing a section). */ - get anchorPosition(): AnchorPosition | undefined { - if (this._showAbove === undefined) { - return undefined; - } - return this._showAbove ? AnchorPosition.ABOVE : AnchorPosition.BELOW; - } + readonly onDidRequestLayout = this._onDidRequestLayout.event; constructor( user: string, preview: boolean, items: readonly IActionListItem[], - private readonly _delegate: IActionListDelegate, + protected readonly _delegate: IActionListDelegate, accessibilityProvider: Partial>> | undefined, - private readonly _options: IActionListOptions | undefined, - private readonly _anchor: HTMLElement | StandardMouseEvent | IAnchor, - @IContextViewService private readonly _contextViewService: IContextViewService, + protected readonly _options: IActionListOptions | undefined, @IKeybindingService private readonly _keybindingService: IKeybindingService, - @ILayoutService private readonly _layoutService: ILayoutService, @IHoverService private readonly _hoverService: IHoverService, @IOpenerService private readonly _openerService: IOpenerService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); this.domNode = document.createElement('div'); @@ -447,6 +496,20 @@ export class ActionList extends Disposable { } this._actionLineHeight = this._options?.descriptionBelow ? 48 : 24; + // Create submenu container appended to domNode + this._submenuContainer = document.createElement('div'); + this._submenuContainer.className = 'action-list-submenu-panel action-widget'; + this._submenuContainer.style.display = 'none'; + this.domNode.append(this._submenuContainer); + + this._register(dom.addDisposableListener(this._submenuContainer, 'mouseenter', () => { + this._cancelSubmenuHide(); + })); + this._register(dom.addDisposableListener(this._submenuContainer, 'mouseleave', () => { + this._scheduleSubmenuHide(); + })); + this._register(toDisposable(() => this._cancelSubmenuHide())); + // Initialize collapsed sections if (this._options?.collapsedByDefault) { for (const section of this._options.collapsedByDefault) { @@ -469,8 +532,10 @@ export class ActionList extends Disposable { }; + const hasAnySubmenuActions = items.some(item => !!item.submenuActions?.length); + this._list = this._register(new List(user, this.domNode, virtualDelegate, [ - new ActionItemRenderer(preview, (item) => this._removeItem(item), this._keybindingService, this._openerService), + new ActionItemRenderer(preview, (item) => this._removeItem(item), (element, indicator, disposables) => this._wireSubmenuIndicator(element, indicator, disposables), hasAnySubmenuActions, this._options?.linkHandler, this._keybindingService, this._openerService), new HeaderRenderer(), new SeparatorRenderer(), ], { @@ -549,6 +614,24 @@ export class ActionList extends Disposable { this._focusCheckedOrFirst(); } + // ArrowRight opens submenu for the focused item and moves focus into it + this._register(dom.addDisposableListener(this.domNode, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'ArrowRight') { + const focused = this._list.getFocus(); + if (focused.length > 0) { + const element = this._list.element(focused[0]); + if (element?.submenuActions?.length) { + dom.EventHelper.stop(e, true); + const rowElement = this._getRowElement(focused[0]); + if (rowElement) { + this._showSubmenuForElement(element, rowElement); + this._currentSubmenuWidget?.focus(); + } + } + } + } + })); + // When the list has focus and user types a printable character, // forward it to the filter input so search begins automatically. if (this._filterInput) { @@ -572,6 +655,7 @@ export class ActionList extends Disposable { } else { this._collapsedSections.add(section); } + this._options?.onDidToggleSection?.(section, this._collapsedSections.has(section)); this._applyFilter(); } @@ -650,35 +734,32 @@ export class ActionList extends Disposable { this._list.splice(0, this._list.length, visible); - // Re-layout to adjust height after items changed - if (this._hasLaidOut) { - this.layout(this._lastMinWidth); - // Restore focus after splice destroyed DOM elements, - // otherwise the blur handler in ActionWidgetService closes the widget. - // Keep focus on the filter input if the user is typing a filter. - if (filterInputHasFocus) { - this._filterInput?.focus(); - // Keep a highlighted item in the list so Enter works without pressing DownArrow first - this._focusCheckedOrFirst(); - } else { - this._list.domFocus(); - // Restore focus to the previously focused item - if (focusedItem) { - const focusedItemId = (focusedItem.item as { id?: string })?.id; - if (focusedItemId) { - for (let i = 0; i < this._list.length; i++) { - const el = this._list.element(i); - if ((el.item as { id?: string })?.id === focusedItemId) { - this._list.setFocus([i]); - this._list.reveal(i); - break; - } + // Notify the parent that a re-layout is needed + this._onDidRequestLayout.fire(); + + // Restore focus after splice destroyed DOM elements, + // otherwise the blur handler in ActionWidgetService closes the widget. + // Keep focus on the filter input if the user is typing a filter. + if (filterInputHasFocus) { + this._filterInput?.focus(); + // Keep a highlighted item in the list so Enter works without pressing DownArrow first + this._focusCheckedOrFirst(); + } else { + this._list.domFocus(); + // Restore focus to the previously focused item + if (focusedItem) { + const focusedItemId = (focusedItem.item as { id?: string })?.id; + if (focusedItemId) { + for (let i = 0; i < this._list.length; i++) { + const el = this._list.element(i); + if ((el.item as { id?: string })?.id === focusedItemId) { + this._list.setFocus([i]); + this._list.reveal(i); + break; } } } } - // Reposition the context view so the widget grows in the correct direction - this._contextViewService.layout(); } } @@ -690,8 +771,6 @@ export class ActionList extends Disposable { return this._filterContainer; } - - get filterInput(): HTMLInputElement | undefined { return this._filterInput; } @@ -711,6 +790,14 @@ export class ActionList extends Disposable { this._focusCheckedOrFirst(); } + getFocusedElement(): IActionListItem | undefined { + const focused = this._list.getFocus(); + if (focused.length > 0) { + return this._list.element(focused[0]); + } + return undefined; + } + private _focusCheckedOrFirst(): void { this._suppressHover = true; try { @@ -738,7 +825,7 @@ export class ActionList extends Disposable { this._delegate.onHide(didCancel); this.cts.cancel(); this._hover.clear(); - this._contextViewService.hideContextView(); + this._hideSubmenu(); } clearFilter(): boolean { @@ -751,15 +838,42 @@ export class ActionList extends Disposable { return false; } - private hasDynamicHeight(): boolean { + /** + * Whether this widget uses dynamic height (has filter or collapsible sections). + */ + get hasDynamicHeight(): boolean { if (this._options?.showFilter) { return true; } return this._allMenuItems.some(item => item.isSectionToggle); } - private computeHeight(): number { - // Compute height based on currently visible items in the list + /** + * The height of a single action row in pixels. + */ + get lineHeight(): number { + return this._actionLineHeight; + } + + /** + * Computes the total height of all items (including collapsed/filtered items). + */ + computeFullHeight(): number { + let fullHeight = 0; + for (const item of this._allMenuItems) { + switch (item.kind) { + case ActionListItemKind.Header: fullHeight += this._headerLineHeight; break; + case ActionListItemKind.Separator: fullHeight += this._separatorLineHeight; break; + default: fullHeight += this._actionLineHeight; break; + } + } + return fullHeight; + } + + /** + * Computes the total height of visible items in the list. + */ + computeListHeight(): number { const visibleCount = this._list.length; let listHeight = 0; for (let i = 0; i < visibleCount; i++) { @@ -776,47 +890,23 @@ export class ActionList extends Disposable { break; } } - - const filterHeight = this._filterContainer ? 36 : 0; - const padding = 10; - const targetWindow = dom.getWindow(this.domNode); - let availableHeight; - - if (this.hasDynamicHeight()) { - const viewportHeight = targetWindow.innerHeight; - const anchorRect = getAnchorRect(this._anchor); - const anchorTopInViewport = anchorRect.top - targetWindow.pageYOffset; - const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - padding; - const spaceAbove = anchorTopInViewport - padding; - - // Lock the direction on first layout based on whether the full - // unconstrained list fits below. Once decided, the dropdown stays - // in the same position even when the visible item count changes. - if (this._showAbove === undefined) { - let fullHeight = filterHeight; - for (const item of this._allMenuItems) { - switch (item.kind) { - case ActionListItemKind.Header: fullHeight += this._headerLineHeight; break; - case ActionListItemKind.Separator: fullHeight += this._separatorLineHeight; break; - default: fullHeight += this._actionLineHeight; break; - } - } - this._showAbove = fullHeight > spaceBelow && spaceAbove > spaceBelow; - } - availableHeight = this._showAbove ? spaceAbove : spaceBelow; - } else { - const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; - const widgetTop = this.domNode.getBoundingClientRect().top; - availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; - } - - 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; + return listHeight; } - private computeMaxWidth(minWidth: number): number { + /** + * Lays out the list widget with the given explicit dimensions. + */ + layout(height: number, width?: number): void { + this._list.layout(height, width); + this.domNode.style.height = `${height}px`; + + // Place filter container on the preferred side. + if (this._filterContainer && this._filterContainer.parentElement) { + this._filterContainer.parentElement.insertBefore(this._filterContainer, this.domNode); + } + } + + computeMaxWidth(minWidth: number): number { const visibleCount = this._list.length; const effectiveMinWidth = Math.max(minWidth, this._options?.minWidth ?? 0); let maxWidth = effectiveMinWidth; @@ -826,10 +916,6 @@ export class ActionList extends Disposable { return Math.max(380, effectiveMinWidth); } - if (this._cachedMaxWidth !== undefined) { - return this._cachedMaxWidth; - } - if (totalItemCount > visibleCount) { // Temporarily splice in all items to measure widths, // preventing width jumps when expanding/collapsing sections. @@ -882,25 +968,6 @@ export class ActionList extends Disposable { return Math.max(...itemWidths, effectiveMinWidth); } - layout(minWidth: number): number { - this._hasLaidOut = true; - this._lastMinWidth = minWidth; - - const listHeight = this.computeHeight(); - this._list.layout(listHeight); - - this._cachedMaxWidth = this.computeMaxWidth(minWidth); - this._list.layout(listHeight, this._cachedMaxWidth); - this.domNode.style.height = `${listHeight}px`; - - // Place filter container on the preferred side. - if (this._filterContainer && this._filterContainer.parentElement) { - this._filterContainer.parentElement.insertBefore(this._filterContainer, this.domNode); - } - - return this._cachedMaxWidth; - } - focusPrevious() { if (this._filterInput && dom.isActiveElement(this._filterInput)) { this._list.domFocus(); @@ -1037,6 +1104,14 @@ export class ActionList extends Disposable { this._list.setSelection([]); return; } + // Don't select when clicking the submenu indicator + if (element.submenuActions?.length && dom.isMouseEvent(e.browserEvent)) { + const target = e.browserEvent.target; + if (dom.isHTMLElement(target) && target.closest('.action-list-submenu-indicator')) { + this._list.setSelection([]); + return; + } + } if (element.item && this.focusCondition(element)) { this._delegate.onSelect(element.item, e.browserEvent instanceof PreviewSelectedEvent); } else { @@ -1086,50 +1161,211 @@ export class ActionList extends Disposable { } private _showHoverForElement(element: IActionListItem, index: number): void { - let newHover: IHoverWidget | undefined; + if (this._currentSubmenuElement === element) { + return; + } + this._submenuDisposables.clear(); - // Show hover if the element has hover content - if (element.hover?.content) { - // The List widget separates data models from DOM elements, so we need to - // look up the actual DOM node to use as the hover target. - const rowElement = this._getRowElement(index); - if (rowElement) { - const markdown = typeof element.hover.content === 'string' ? new MarkdownString(element.hover.content) : element.hover.content; - newHover = this._hoverService.showDelayedHover({ - content: markdown ?? '', - target: rowElement, - additionalClasses: ['action-widget-hover'], - position: { - hoverPosition: HoverPosition.LEFT, - forcePosition: false, - ...element.hover.position, - }, - appearance: { - showPointer: true, - }, - }, { groupId: `actionListHover` }); + const rowElement = this._getRowElement(index); + if (!rowElement) { + this._hover.clear(); + return; + } + + const hasHoverContent = !!element.hover?.content; + + if (!hasHoverContent) { + this._hover.clear(); + return; + } + + const markdown = typeof element.hover!.content === 'string' ? new MarkdownString(element.hover!.content) : element.hover!.content; + const linkHandler = this._options?.linkHandler; + this._hover.value = this._hoverService.showDelayedHover({ + content: markdown ?? '', + target: rowElement, + additionalClasses: ['action-widget-hover'], + linkHandler: linkHandler ? (url: string) => { + linkHandler(URI.parse(url), element); + } : undefined, + position: { + hoverPosition: HoverPosition.LEFT, + forcePosition: false, + ...element.hover!.position, + }, + appearance: { + showPointer: true, + }, + }, { groupId: `actionListHover` }); + } + + private _wireSubmenuIndicator(element: IActionListItem, indicator: HTMLElement, disposables: DisposableStore): void { + disposables.add(dom.addDisposableListener(indicator, 'mouseenter', () => { + this._cancelSubmenuHide(); + this._showSubmenuForElement(element, indicator); + })); + disposables.add(dom.addDisposableListener(indicator, 'mouseleave', () => { + this._scheduleSubmenuHide(); + })); + } + + private _showSubmenuForElement(element: IActionListItem, indicator: HTMLElement): void { + this._submenuDisposables.clear(); + this._hover.clear(); + this._currentSubmenuElement = element; + dom.clearNode(this._submenuContainer); + + // Convert submenu actions into ActionListWidget items + const submenuItems: IActionListItem[] = []; + for (const action of element.submenuActions!) { + if (action instanceof SubmenuAction) { + // Add header for the group + submenuItems.push({ + kind: ActionListItemKind.Header, + group: { title: action.label }, + label: action.label, + }); + // Add each child action as a selectable item + for (const child of action.actions) { + submenuItems.push({ + item: child, + kind: ActionListItemKind.Action, + label: child.label, + description: child.tooltip || undefined, + group: { title: '', icon: ThemeIcon.fromId(child.checked ? Codicon.check.id : Codicon.blank.id) }, + hideIcon: false, + }); + } } } - this._hover.value = newHover; + const submenuDelegate: IActionListDelegate = { + onHide: () => { }, + onSelect: (action) => { + action.run(); + this._hideSubmenu(); + this.hide(); + }, + }; + + // Show container before creating widget so List can measure during construction + this._submenuContainer.style.display = ''; + this._submenuContainer.style.position = 'absolute'; + + // Position: prefer right side, fall back to left if not enough space + const indicatorRect = indicator.getBoundingClientRect(); + const parentRect = this.domNode.getBoundingClientRect(); + + const submenuWidget = this._submenuDisposables.add(this._instantiationService.createInstance( + ActionListWidget, + 'submenu', + false, + submenuItems, + submenuDelegate, + undefined, + undefined, + )); + this._submenuContainer.appendChild(submenuWidget.domNode); + this._currentSubmenuWidget = submenuWidget; + + // Layout: first pass renders items, second pass measures true width + const totalHeight = submenuWidget.computeListHeight(); + submenuWidget.layout(totalHeight); + const maxWidth = submenuWidget.computeMaxWidth(0); + submenuWidget.layout(totalHeight, maxWidth); + submenuWidget.domNode.style.width = `${maxWidth}px`; + + // Position: prefer right side, fall back to left if not enough space + const targetWindow = dom.getWindow(this.domNode); + const viewportWidth = targetWindow.innerWidth; + const spaceRight = viewportWidth - indicatorRect.right; + const spaceLeft = parentRect.left; + const submenuWidth = maxWidth + 10; // account for border/padding + + if (spaceRight >= submenuWidth || spaceRight >= spaceLeft) { + // Show on the right + this._submenuContainer.style.left = `${indicatorRect.right - parentRect.left}px`; + } else { + // Show on the left + this._submenuContainer.style.left = `${-submenuWidth}px`; + } + this._submenuContainer.style.top = `${indicatorRect.top - parentRect.top - 4}px`; + + // Keyboard navigation in submenu + this._submenuDisposables.add(dom.addDisposableListener(submenuWidget.domNode, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'ArrowLeft' || e.key === 'Escape') { + dom.EventHelper.stop(e, true); + this._hideSubmenu(); + this._list.domFocus(); + } else if (e.key === 'Enter') { + dom.EventHelper.stop(e, true); + const focused = submenuWidget.getFocusedElement(); + if (focused?.item) { + focused.item.run(); + this._hideSubmenu(); + this.hide(); + } + } else if (e.key === 'ArrowDown') { + dom.EventHelper.stop(e, true); + submenuWidget.focusNext(); + } else if (e.key === 'ArrowUp') { + dom.EventHelper.stop(e, true); + submenuWidget.focusPrevious(); + } + })); + } + + private _hideSubmenu(): void { + this._cancelSubmenuHide(); + this._submenuDisposables.clear(); + this._currentSubmenuWidget = undefined; + this._currentSubmenuElement = undefined; + dom.clearNode(this._submenuContainer); + this._submenuContainer.style.display = 'none'; + } + + private _scheduleSubmenuHide(): void { + this._cancelSubmenuHide(); + this._submenuHideTimeout = setTimeout(() => { + this._hideSubmenu(); + }, 300); + } + + private _cancelSubmenuHide(): void { + if (this._submenuHideTimeout !== undefined) { + clearTimeout(this._submenuHideTimeout); + this._submenuHideTimeout = undefined; + } } private async onListHover(e: IListMouseEvent>) { const element = e.element; if (element && element.item && this.focusCondition(element)) { - // Check if the hover target is inside a toolbar - if so, skip the splice - // to avoid re-rendering which would destroy the toolbar mid-hover + // Check if the hover target is inside a toolbar or submenu indicator - if so, skip the splice + // to avoid re-rendering which would destroy the element mid-hover const isHoveringToolbar = dom.isHTMLElement(e.browserEvent.target) && e.browserEvent.target.closest('.action-list-item-toolbar') !== null; + const submenuIndicator = dom.isHTMLElement(e.browserEvent.target) ? e.browserEvent.target.closest('.action-list-submenu-indicator') as HTMLElement | null : null; if (isHoveringToolbar) { this._list.setFocus([]); return; } + if (submenuIndicator && element.submenuActions?.length) { + this._list.setFocus(typeof e.index === 'number' ? [e.index] : []); + this._cancelSubmenuHide(); + this._showSubmenuForElement(element, submenuIndicator); + return; + } // Set focus immediately for responsive hover feedback this._list.setFocus(typeof e.index === 'number' ? [e.index] : []); + if (this._currentSubmenuElement === element) { + this._cancelSubmenuHide(); + } else { + this._hideSubmenu(); + } - if (this._delegate.onHover && !element.disabled && element.kind === ActionListItemKind.Action) { + if (this._delegate.onHover && !element.disabled && element.kind === ActionListItemKind.Action && this._currentSubmenuElement !== element) { const result = await this._delegate.onHover(element.item, this.cts.token); const canPreview = result ? result.canPreview : undefined; if (canPreview !== element.canPreview) { @@ -1147,6 +1383,15 @@ export class ActionList extends Disposable { } private onListClick(e: IListMouseEvent>): void { + // Click on submenu indicator opens/keeps the submenu + if (e.element && e.element.submenuActions?.length) { + const submenuIndicator = dom.isHTMLElement(e.browserEvent.target) ? e.browserEvent.target.closest('.action-list-submenu-indicator') as HTMLElement | null : null; + if (submenuIndicator) { + this._cancelSubmenuHide(); + this._showSubmenuForElement(e.element, submenuIndicator); + return; + } + } if (e.element && e.element.isSectionToggle && e.element.section) { const section = e.element.section; queueMicrotask(() => this._toggleSection(section)); @@ -1158,6 +1403,167 @@ export class ActionList extends Disposable { } } +/** + * An action list that wraps {@link ActionListWidget} with context-view positioning + * and anchor-based height computation. + */ +export class ActionList extends Disposable { + + private readonly _widget: ActionListWidget; + + private readonly _anchor: HTMLElement | StandardMouseEvent | IAnchor; + private _lastMinWidth = 0; + private _cachedMaxWidth: number | undefined; + private _hasLaidOut = false; + private _showAbove: boolean | undefined; + + get domNode(): HTMLElement { + return this._widget.domNode; + } + + get filterContainer(): HTMLElement | undefined { + return this._widget.filterContainer; + } + + get filterInput(): HTMLInputElement | undefined { + return this._widget.filterInput; + } + + /** + * Returns the resolved anchor position after the first layout. + * Used by the context view delegate to lock the dropdown direction. + */ + get anchorPosition(): AnchorPosition | undefined { + if (this._showAbove === undefined) { + return undefined; + } + return this._showAbove ? AnchorPosition.ABOVE : AnchorPosition.BELOW; + } + + constructor( + user: string, + preview: boolean, + items: readonly IActionListItem[], + _delegate: IActionListDelegate, + accessibilityProvider: Partial>> | undefined, + options: IActionListOptions | undefined, + anchor: HTMLElement | StandardMouseEvent | IAnchor, + @IContextViewService private readonly _contextViewService: IContextViewService, + @ILayoutService private readonly _layoutService: ILayoutService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._anchor = anchor; + + this._widget = this._register(instantiationService.createInstance( + ActionListWidget, + user, + preview, + items, + _delegate, + accessibilityProvider, + options, + )); + + this._register(this._widget.onDidRequestLayout(() => { + if (this._hasLaidOut) { + this.layout(this._lastMinWidth); + this._contextViewService.layout(); + } + })); + } + + focus(): void { + this._widget.focus(); + } + + hide(didCancel?: boolean): void { + this._widget.hide(didCancel); + this._contextViewService.hideContextView(); + } + + clearFilter(): boolean { + return this._widget.clearFilter(); + } + + focusPrevious(): void { + this._widget.focusPrevious(); + } + + focusNext(): void { + this._widget.focusNext(); + } + + collapseFocusedSection(): void { + this._widget.collapseFocusedSection(); + } + + expandFocusedSection(): void { + this._widget.expandFocusedSection(); + } + + toggleFocusedSection(): boolean { + return this._widget.toggleFocusedSection(); + } + + acceptSelected(preview?: boolean): void { + this._widget.acceptSelected(preview); + } + + private hasDynamicHeight(): boolean { + return this._widget.hasDynamicHeight; + } + + private computeHeight(): number { + const listHeight = this._widget.computeListHeight(); + + const filterHeight = this._widget.filterContainer ? 36 : 0; + const padding = 10; + const targetWindow = dom.getWindow(this.domNode); + let availableHeight; + + if (this.hasDynamicHeight()) { + const viewportHeight = targetWindow.innerHeight; + const anchorRect = getAnchorRect(this._anchor); + const anchorTopInViewport = anchorRect.top - targetWindow.pageYOffset; + const spaceBelow = viewportHeight - anchorTopInViewport - anchorRect.height - padding; + const spaceAbove = anchorTopInViewport - padding; + + // Lock the direction on first layout based on whether the full + // unconstrained list fits below. Once decided, the dropdown stays + // in the same position even when the visible item count changes. + if (this._showAbove === undefined) { + const fullHeight = filterHeight + this._widget.computeFullHeight(); + this._showAbove = fullHeight > spaceBelow && spaceAbove > spaceBelow; + } + availableHeight = this._showAbove ? spaceAbove : spaceBelow; + } else { + const windowHeight = this._layoutService.getContainer(targetWindow).clientHeight; + const widgetTop = this.domNode.getBoundingClientRect().top; + availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; + } + + const viewportMaxHeight = Math.floor(targetWindow.innerHeight * 0.6); + const actionLineHeight = this._widget.lineHeight; + const maxHeight = Math.min(Math.max(availableHeight, actionLineHeight * 3 + filterHeight), viewportMaxHeight); + const height = Math.min(listHeight + filterHeight, maxHeight); + return height - filterHeight; + } + + layout(minWidth: number): number { + this._hasLaidOut = true; + this._lastMinWidth = minWidth; + + const listHeight = this.computeHeight(); + this._widget.layout(listHeight); + + this._cachedMaxWidth = this._widget.computeMaxWidth(minWidth); + this._widget.layout(listHeight, this._cachedMaxWidth); + + return this._cachedMaxWidth; + } +} + function stripNewlines(str: string): string { return str.replace(/\r\n|\r|\n/g, ' '); } diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index 8957a85b01f..3e00163854e 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -310,3 +310,44 @@ .action-widget .action-list-filter-actions .action-label:hover { background-color: var(--vscode-toolbar-hoverBackground); } + +/* Anchor for the absolutely-positioned submenu panel */ +.action-widget .actionList { + position: relative; +} + +.action-widget .action-list-submenu-indicator { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + border-radius: 4px; + margin-right: 2px; +} + +.action-widget .action-list-submenu-indicator.has-submenu { + cursor: pointer; + opacity: 0.6; +} + +.action-widget .monaco-list-row.action .action-list-submenu-indicator.codicon { + display: flex; + font-size: 16px; +} + +.action-widget .action-list-submenu-indicator.has-submenu:hover { + opacity: 1; + background-color: var(--vscode-toolbar-hoverBackground); +} + +.action-list-submenu-panel { + background-color: var(--vscode-menu-background); + color: var(--vscode-menu-foreground); + border: 1px solid var(--vscode-menu-border, var(--vscode-editorHoverWidget-border)); + border-radius: 5px; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + z-index: 50; + width: fit-content; +} diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 58792a384ff..32af92b7733 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -174,9 +174,9 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { const focusTracker = renderDisposables.add(dom.trackFocus(element)); renderDisposables.add(focusTracker.onDidBlur(() => { - // Don't hide if focus moved to a hover that belongs to this action widget + // Don't hide if focus moved to a hover or submenu that belongs to this action widget const activeElement = dom.getActiveElement(); - if (activeElement?.closest('.action-widget-hover')) { + if (activeElement?.closest('.action-widget-hover') || activeElement?.closest('.action-list-submenu-panel')) { return; } this.hide(true); diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 9fff2b44ce0..539c9cfe1df 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -309,6 +309,7 @@ export class MenuId { static readonly ChatViewSessionTitleNavigationToolbar = new MenuId('ChatViewSessionTitleNavigationToolbar'); static readonly ChatViewSessionTitleToolbar = new MenuId('ChatViewSessionTitleToolbar'); static readonly ChatContextUsageActions = new MenuId('ChatContextUsageActions'); + static readonly MarkerHoverStatusBar = new MenuId('MarkerHoverParticipant.StatusBar'); /** * Create or reuse a `MenuId` with the given identifier diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 9907d4e4da8..94b2c6cb56d 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oauth.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; import type { IBrowseDirectoryResult, IStateSnapshot } from './state/sessionProtocol.js'; +import { AttachmentType, PermissionKind, type PolicyState } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -30,6 +32,7 @@ export interface IAgentSessionMetadata { readonly startTime: number; readonly modifiedTime: number; readonly summary?: string; + readonly workingDirectory?: string; } export type AgentProvider = string; @@ -39,10 +42,55 @@ export interface IAgentDescriptor { readonly provider: AgentProvider; readonly displayName: string; readonly description: string; - /** Whether the renderer should push a GitHub auth token for this agent. */ + /** + * Whether the renderer should push a GitHub auth token for this agent. + * @deprecated Use {@link IResourceMetadata.resources} from {@link IAgentService.getResourceMetadata} instead. + */ readonly requiresAuth: boolean; } +// ---- Auth types (RFC 9728 / RFC 6750 inspired) ----------------------------- + +/** + * Describes the agent host as an OAuth 2.0 protected resource. + * Uses {@link IAuthorizationProtectedResourceMetadata} from RFC 9728 + * to describe auth requirements, enabling clients to resolve tokens + * using the standard VS Code authentication service. + * + * Returned from the server via {@link IAgentService.getResourceMetadata}. + */ +export interface IResourceMetadata { + /** + * Protected resources the agent host requires authentication for. + * Each entry uses the standard RFC 9728 shape so clients can resolve + * tokens via {@link IAuthenticationService.getOrActivateProviderIdForServer}. + */ + readonly resources: readonly IAuthorizationProtectedResourceMetadata[]; +} + +/** + * Parameters for the `authenticate` command. + * Analogous to sending `Authorization: Bearer ` (RFC 6750 section 2.1). + */ +export interface IAuthenticateParams { + /** + * The `resource` identifier from the server's + * {@link IAuthorizationProtectedResourceMetadata} that this token targets. + */ + readonly resource: string; + + /** The bearer token value (RFC 6750). */ + readonly token: string; +} + +/** + * Result of the `authenticate` command. + */ +export interface IAuthenticateResult { + /** Whether the token was accepted. */ + readonly authenticated: boolean; +} + export interface IAgentCreateSessionConfig { readonly provider?: AgentProvider; readonly model?: string; @@ -52,7 +100,7 @@ export interface IAgentCreateSessionConfig { /** Serializable attachment passed alongside a message to the agent host. */ export interface IAgentAttachment { - readonly type: 'file' | 'directory' | 'selection'; + readonly type: AttachmentType; readonly path: string; readonly displayName?: string; /** For selections: the selected text. */ @@ -74,7 +122,7 @@ export interface IAgentModelInfo { readonly supportsReasoningEffort: boolean; readonly supportedReasoningEfforts?: readonly string[]; readonly defaultReasoningEffort?: string; - readonly policyState?: 'enabled' | 'disabled' | 'unconfigured'; + readonly policyState?: PolicyState; readonly billingMultiplier?: number; } @@ -190,7 +238,7 @@ export interface IAgentPermissionRequestEvent extends IAgentProgressEventBase { /** Unique ID for correlating the response. */ readonly requestId: string; /** The kind of permission being requested. */ - readonly permissionKind: 'shell' | 'write' | 'mcp' | 'read' | 'url'; + readonly permissionKind: PermissionKind; /** The tool call ID that triggered this permission request. */ readonly toolCallId?: string; /** File path involved (for read/write). */ @@ -300,8 +348,14 @@ export interface IAgent { /** List persisted sessions from this provider. */ listSessions(): Promise; - /** Set the authentication token for this provider. */ - setAuthToken(token: string): Promise; + /** Declare protected resources this agent requires auth for (RFC 9728). */ + getProtectedResources(): IAuthorizationProtectedResourceMetadata[]; + + /** + * Authenticate for a specific resource. Returns true if accepted. + * The `resource` matches {@link IAuthorizationProtectedResourceMetadata.resource}. + */ + authenticate(resource: string, token: string): Promise; /** Gracefully shut down all sessions. */ shutdown(): Promise; @@ -328,8 +382,18 @@ export interface IAgentService { /** Discover available agent backends from the agent host. */ listAgents(): Promise; - /** Set the GitHub auth token used by the Copilot SDK. */ - setAuthToken(token: string): Promise; + /** + * Retrieve the resource metadata describing auth requirements. + * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). + */ + getResourceMetadata(): Promise; + + /** + * Authenticate for a protected resource on the server. + * The {@link IAuthenticateParams.resource} must match a resource from + * {@link getResourceMetadata}. Analogous to RFC 6750 bearer token delivery. + */ + authenticate(params: IAuthenticateParams): Promise; /** * Refresh the model list from all providers, publishing updated diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts new file mode 100644 index 00000000000..4c38cb047de --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 409b385 + +// Generated from types/actions.ts — do not edit +// Run `npm run generate` to regenerate. + +import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionPermissionRequestAction, type ISessionPermissionResolvedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction } from './actions.js'; + + +// ─── Root vs Session Action Unions ─────────────────────────────────────────── + +/** Union of all root-scoped actions. */ +export type IRootAction = + | IRootAgentsChangedAction + | IRootActiveSessionsChangedAction + ; + +/** Union of all session-scoped actions. */ +export type ISessionAction = + | ISessionReadyAction + | ISessionCreationFailedAction + | ISessionTurnStartedAction + | ISessionDeltaAction + | ISessionResponsePartAction + | ISessionToolCallStartAction + | ISessionToolCallDeltaAction + | ISessionToolCallReadyAction + | ISessionToolCallConfirmedAction + | ISessionToolCallCompleteAction + | ISessionToolCallResultConfirmedAction + | ISessionPermissionRequestAction + | ISessionPermissionResolvedAction + | ISessionTurnCompleteAction + | ISessionTurnCancelledAction + | ISessionErrorAction + | ISessionTitleChangedAction + | ISessionUsageAction + | ISessionReasoningAction + | ISessionModelChangedAction + | ISessionServerToolsChangedAction + | ISessionActiveClientChangedAction + | ISessionActiveClientToolsChangedAction + ; + +/** Union of session actions that clients may dispatch. */ +export type IClientSessionAction = + | ISessionTurnStartedAction + | ISessionToolCallConfirmedAction + | ISessionToolCallCompleteAction + | ISessionToolCallResultConfirmedAction + | ISessionPermissionResolvedAction + | ISessionTurnCancelledAction + | ISessionModelChangedAction + | ISessionActiveClientChangedAction + | ISessionActiveClientToolsChangedAction + ; + +/** Union of session actions that only the server may produce. */ +export type IServerSessionAction = + | ISessionReadyAction + | ISessionCreationFailedAction + | ISessionDeltaAction + | ISessionResponsePartAction + | ISessionToolCallStartAction + | ISessionToolCallDeltaAction + | ISessionToolCallReadyAction + | ISessionPermissionRequestAction + | ISessionTurnCompleteAction + | ISessionErrorAction + | ISessionTitleChangedAction + | ISessionUsageAction + | ISessionReasoningAction + | ISessionServerToolsChangedAction + ; + +// ─── Client-Dispatchable Map ───────────────────────────────────────────────── + +/** + * Exhaustive map indicating which action types may be dispatched by clients. + * Adding a new action to IStateAction without adding it here is a compile error. + */ +export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boolean } = { + [ActionType.RootAgentsChanged]: false, + [ActionType.RootActiveSessionsChanged]: false, + [ActionType.SessionReady]: false, + [ActionType.SessionCreationFailed]: false, + [ActionType.SessionTurnStarted]: true, + [ActionType.SessionDelta]: false, + [ActionType.SessionResponsePart]: false, + [ActionType.SessionToolCallStart]: false, + [ActionType.SessionToolCallDelta]: false, + [ActionType.SessionToolCallReady]: false, + [ActionType.SessionToolCallConfirmed]: true, + [ActionType.SessionToolCallComplete]: true, + [ActionType.SessionToolCallResultConfirmed]: true, + [ActionType.SessionPermissionRequest]: false, + [ActionType.SessionPermissionResolved]: true, + [ActionType.SessionTurnComplete]: false, + [ActionType.SessionTurnCancelled]: true, + [ActionType.SessionError]: false, + [ActionType.SessionTitleChanged]: false, + [ActionType.SessionUsage]: false, + [ActionType.SessionReasoning]: false, + [ActionType.SessionModelChanged]: true, + [ActionType.SessionServerToolsChanged]: false, + [ActionType.SessionActiveClientChanged]: true, + [ActionType.SessionActiveClientToolsChanged]: true, +}; diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 157daf6b119..3b5eb0b636e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -5,9 +5,9 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 409b385 -import { ToolCallConfirmationReason, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type IPermissionRequest } from './state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type IPermissionRequest } from './state.js'; // ─── Action Type Enum ──────────────────────────────────────────────────────── @@ -17,34 +17,33 @@ import { ToolCallConfirmationReason, type URI, type StringOrMarkdown, type IAgen * * @category Actions */ -export const ActionType = { - RootAgentsChanged: 'root/agentsChanged', - RootActiveSessionsChanged: 'root/activeSessionsChanged', - SessionReady: 'session/ready', - SessionCreationFailed: 'session/creationFailed', - SessionTurnStarted: 'session/turnStarted', - SessionDelta: 'session/delta', - SessionResponsePart: 'session/responsePart', - SessionToolCallStart: 'session/toolCallStart', - SessionToolCallDelta: 'session/toolCallDelta', - SessionToolCallReady: 'session/toolCallReady', - SessionToolCallConfirmed: 'session/toolCallConfirmed', - SessionToolCallComplete: 'session/toolCallComplete', - SessionToolCallResultConfirmed: 'session/toolCallResultConfirmed', - SessionPermissionRequest: 'session/permissionRequest', - SessionPermissionResolved: 'session/permissionResolved', - SessionTurnComplete: 'session/turnComplete', - SessionTurnCancelled: 'session/turnCancelled', - SessionError: 'session/error', - SessionTitleChanged: 'session/titleChanged', - SessionUsage: 'session/usage', - SessionReasoning: 'session/reasoning', - SessionModelChanged: 'session/modelChanged', - SessionServerToolsChanged: 'session/serverToolsChanged', - SessionActiveClientChanged: 'session/activeClientChanged', - SessionActiveClientToolsChanged: 'session/activeClientToolsChanged', -} as const; -export type ActionType = typeof ActionType[keyof typeof ActionType]; +export const enum ActionType { + RootAgentsChanged = 'root/agentsChanged', + RootActiveSessionsChanged = 'root/activeSessionsChanged', + SessionReady = 'session/ready', + SessionCreationFailed = 'session/creationFailed', + SessionTurnStarted = 'session/turnStarted', + SessionDelta = 'session/delta', + SessionResponsePart = 'session/responsePart', + SessionToolCallStart = 'session/toolCallStart', + SessionToolCallDelta = 'session/toolCallDelta', + SessionToolCallReady = 'session/toolCallReady', + SessionToolCallConfirmed = 'session/toolCallConfirmed', + SessionToolCallComplete = 'session/toolCallComplete', + SessionToolCallResultConfirmed = 'session/toolCallResultConfirmed', + SessionPermissionRequest = 'session/permissionRequest', + SessionPermissionResolved = 'session/permissionResolved', + SessionTurnComplete = 'session/turnComplete', + SessionTurnCancelled = 'session/turnCancelled', + SessionError = 'session/error', + SessionTitleChanged = 'session/titleChanged', + SessionUsage = 'session/usage', + SessionReasoning = 'session/reasoning', + SessionModelChanged = 'session/modelChanged', + SessionServerToolsChanged = 'session/serverToolsChanged', + SessionActiveClientChanged = 'session/activeClientChanged', + SessionActiveClientToolsChanged = 'session/activeClientToolsChanged', +} // ─── Action Envelope ───────────────────────────────────────────────────────── @@ -99,7 +98,7 @@ interface IToolCallActionBase { * @version 1 */ export interface IRootAgentsChangedAction { - type: 'root/agentsChanged'; + type: ActionType.RootAgentsChanged; /** Updated agent list */ agents: IAgentInfo[]; } @@ -111,7 +110,7 @@ export interface IRootAgentsChangedAction { * @version 1 */ export interface IRootActiveSessionsChangedAction { - type: 'root/activeSessionsChanged'; + type: ActionType.RootActiveSessionsChanged; /** Current count of active sessions */ activeSessions: number; } @@ -125,7 +124,7 @@ export interface IRootActiveSessionsChangedAction { * @version 1 */ export interface ISessionReadyAction { - type: 'session/ready'; + type: ActionType.SessionReady; /** Session URI */ session: URI; } @@ -137,7 +136,7 @@ export interface ISessionReadyAction { * @version 1 */ export interface ISessionCreationFailedAction { - type: 'session/creationFailed'; + type: ActionType.SessionCreationFailed; /** Session URI */ session: URI; /** Error details */ @@ -152,7 +151,7 @@ export interface ISessionCreationFailedAction { * @clientDispatchable */ export interface ISessionTurnStartedAction { - type: 'session/turnStarted'; + type: ActionType.SessionTurnStarted; /** Session URI */ session: URI; /** Turn identifier */ @@ -168,7 +167,7 @@ export interface ISessionTurnStartedAction { * @version 1 */ export interface ISessionDeltaAction { - type: 'session/delta'; + type: ActionType.SessionDelta; /** Session URI */ session: URI; /** Turn identifier */ @@ -184,7 +183,7 @@ export interface ISessionDeltaAction { * @version 1 */ export interface ISessionResponsePartAction { - type: 'session/responsePart'; + type: ActionType.SessionResponsePart; /** Session URI */ session: URI; /** Turn identifier */ @@ -204,7 +203,7 @@ export interface ISessionResponsePartAction { * @version 1 */ export interface ISessionToolCallStartAction extends IToolCallActionBase { - type: 'session/toolCallStart'; + type: ActionType.SessionToolCallStart; /** Internal tool name (for debugging/logging) */ toolName: string; /** Human-readable tool name */ @@ -223,7 +222,7 @@ export interface ISessionToolCallStartAction extends IToolCallActionBase { * @version 1 */ export interface ISessionToolCallDeltaAction extends IToolCallActionBase { - type: 'session/toolCallDelta'; + type: ActionType.SessionToolCallDelta; /** Partial parameter content to append */ content: string; /** Updated progress message */ @@ -242,7 +241,7 @@ export interface ISessionToolCallDeltaAction extends IToolCallActionBase { * @version 1 */ export interface ISessionToolCallReadyAction extends IToolCallActionBase { - type: 'session/toolCallReady'; + type: ActionType.SessionToolCallReady; /** Message describing what the tool will do */ invocationMessage: StringOrMarkdown; /** Raw tool input */ @@ -259,7 +258,7 @@ export interface ISessionToolCallReadyAction extends IToolCallActionBase { * @clientDispatchable */ export interface ISessionToolCallApprovedAction extends IToolCallActionBase { - type: 'session/toolCallConfirmed'; + type: ActionType.SessionToolCallConfirmed; /** The tool call was approved */ approved: true; /** How the tool was confirmed */ @@ -277,11 +276,11 @@ export interface ISessionToolCallApprovedAction extends IToolCallActionBase { * @clientDispatchable */ export interface ISessionToolCallDeniedAction extends IToolCallActionBase { - type: 'session/toolCallConfirmed'; + type: ActionType.SessionToolCallConfirmed; /** The tool call was denied */ approved: false; /** Why the tool was cancelled */ - reason: 'denied' | 'skipped'; + reason: ToolCallCancellationReason.Denied | ToolCallCancellationReason.Skipped; /** What the user suggested doing instead */ userSuggestion?: IUserMessage; /** Optional explanation for the denial */ @@ -316,7 +315,7 @@ export type ISessionToolCallConfirmedAction = * @clientDispatchable */ export interface ISessionToolCallCompleteAction extends IToolCallActionBase { - type: 'session/toolCallComplete'; + type: ActionType.SessionToolCallComplete; /** Execution result */ result: IToolCallResult; /** If true, the result requires client approval before finalizing */ @@ -333,7 +332,7 @@ export interface ISessionToolCallCompleteAction extends IToolCallActionBase { * @clientDispatchable */ export interface ISessionToolCallResultConfirmedAction extends IToolCallActionBase { - type: 'session/toolCallResultConfirmed'; + type: ActionType.SessionToolCallResultConfirmed; /** Whether the result was approved */ approved: boolean; } @@ -345,7 +344,7 @@ export interface ISessionToolCallResultConfirmedAction extends IToolCallActionBa * @version 1 */ export interface ISessionPermissionRequestAction { - type: 'session/permissionRequest'; + type: ActionType.SessionPermissionRequest; /** Session URI */ session: URI; /** Turn identifier */ @@ -362,7 +361,7 @@ export interface ISessionPermissionRequestAction { * @clientDispatchable */ export interface ISessionPermissionResolvedAction { - type: 'session/permissionResolved'; + type: ActionType.SessionPermissionResolved; /** Session URI */ session: URI; /** Turn identifier */ @@ -380,7 +379,7 @@ export interface ISessionPermissionResolvedAction { * @version 1 */ export interface ISessionTurnCompleteAction { - type: 'session/turnComplete'; + type: ActionType.SessionTurnComplete; /** Session URI */ session: URI; /** Turn identifier */ @@ -395,7 +394,7 @@ export interface ISessionTurnCompleteAction { * @clientDispatchable */ export interface ISessionTurnCancelledAction { - type: 'session/turnCancelled'; + type: ActionType.SessionTurnCancelled; /** Session URI */ session: URI; /** Turn identifier */ @@ -409,7 +408,7 @@ export interface ISessionTurnCancelledAction { * @version 1 */ export interface ISessionErrorAction { - type: 'session/error'; + type: ActionType.SessionError; /** Session URI */ session: URI; /** Turn identifier */ @@ -425,7 +424,7 @@ export interface ISessionErrorAction { * @version 1 */ export interface ISessionTitleChangedAction { - type: 'session/titleChanged'; + type: ActionType.SessionTitleChanged; /** Session URI */ session: URI; /** New title */ @@ -439,7 +438,7 @@ export interface ISessionTitleChangedAction { * @version 1 */ export interface ISessionUsageAction { - type: 'session/usage'; + type: ActionType.SessionUsage; /** Session URI */ session: URI; /** Turn identifier */ @@ -455,7 +454,7 @@ export interface ISessionUsageAction { * @version 1 */ export interface ISessionReasoningAction { - type: 'session/reasoning'; + type: ActionType.SessionReasoning; /** Session URI */ session: URI; /** Turn identifier */ @@ -472,7 +471,7 @@ export interface ISessionReasoningAction { * @clientDispatchable */ export interface ISessionModelChangedAction { - type: 'session/modelChanged'; + type: ActionType.SessionModelChanged; /** Session URI */ session: URI; /** New model ID */ @@ -488,7 +487,7 @@ export interface ISessionModelChangedAction { * @version 1 */ export interface ISessionServerToolsChangedAction { - type: 'session/serverToolsChanged'; + type: ActionType.SessionServerToolsChanged; /** Session URI */ session: URI; /** Updated server tools list (full replacement) */ @@ -508,7 +507,7 @@ export interface ISessionServerToolsChangedAction { * @clientDispatchable */ export interface ISessionActiveClientChangedAction { - type: 'session/activeClientChanged'; + type: ActionType.SessionActiveClientChanged; /** Session URI */ session: URI; /** The new active client, or `null` to unset */ @@ -527,7 +526,7 @@ export interface ISessionActiveClientChangedAction { * @clientDispatchable */ export interface ISessionActiveClientToolsChangedAction { - type: 'session/activeClientToolsChanged'; + type: ActionType.SessionActiveClientToolsChanged; /** Session URI */ session: URI; /** Updated client tools list (full replacement) */ diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index d6c9c009a74..34c445623f7 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 409b385 import type { URI, ISnapshot, ISessionSummary, ITurn } from './state.js'; import type { IActionEnvelope, IStateAction } from './actions.js'; @@ -56,11 +56,10 @@ export interface IInitializeResult { * * @category Commands */ -export const ReconnectResultType = { - Replay: 'replay', - Snapshot: 'snapshot', -} as const; -export type ReconnectResultType = typeof ReconnectResultType[keyof typeof ReconnectResultType]; +export const enum ReconnectResultType { + Replay = 'replay', + Snapshot = 'snapshot', +} /** * Re-establishes a dropped connection. The server replays missed actions or @@ -89,7 +88,7 @@ export interface IReconnectParams { */ export interface IReconnectReplayResult { /** Discriminant */ - type: 'replay'; + type: ReconnectResultType.Replay; /** Missed action envelopes since `lastSeenServerSeq` */ actions: IActionEnvelope[]; } @@ -99,7 +98,7 @@ export interface IReconnectReplayResult { */ export interface IReconnectSnapshotResult { /** Discriminant */ - type: 'snapshot'; + type: ReconnectResultType.Snapshot; /** Fresh snapshots for each subscription */ snapshots: ISnapshot[]; } @@ -173,7 +172,7 @@ export interface ICreateSessionParams { /** Model ID to use */ model?: string; /** Working directory for the session */ - workingDirectory?: string; + workingDirectory?: URI; } // ─── disposeSession ────────────────────────────────────────────────────────── @@ -227,11 +226,10 @@ export interface IListSessionsResult { * * @category Commands */ -export const ContentEncoding = { - Base64: 'base64', - Utf8: 'utf-8', -} as const; -export type ContentEncoding = typeof ContentEncoding[keyof typeof ContentEncoding]; +export const enum ContentEncoding { + Base64 = 'base64', + Utf8 = 'utf-8', +} /** * Fetches large content referenced by a `ContentRef` in the state tree. @@ -257,25 +255,31 @@ export type ContentEncoding = typeof ContentEncoding[keyof typeof ContentEncodin * { "jsonrpc": "2.0", "id": 10, "result": { * "data": "iVBORw0KGgo...", * "encoding": "base64", - * "mimeType": "image/png" + * "contentType": "image/png" * }} * ``` */ export interface IFetchContentParams { /** Content URI from a `ContentRef` */ uri: string; + /** Preferred encoding for the returned data (default: server-chosen) */ + encoding?: ContentEncoding; } /** * Result of the `fetchContent` command. + * + * The server SHOULD honor the `encoding` requested in the params. If the + * server cannot provide the requested encoding, it MUST fall back to either + * `base64` or `utf-8`. */ export interface IFetchContentResult { /** Content encoded as a string */ data: string; /** How `data` is encoded */ encoding: ContentEncoding; - /** MIME type of the content */ - mimeType?: string; + /** Content type (e.g. `"image/png"`, `"text/plain"`) */ + contentType?: string; } // ─── browseDirectory ──────────────────────────────────────────────────────── @@ -429,3 +433,54 @@ export interface IBrowseDirectoryEntry { /** Whether this entry is a directory */ isDirectory: boolean; } + +// ─── authenticate ──────────────────────────────────────────────────────────── + +/** + * Pushes a Bearer token for a protected resource. The `resource` field MUST + * match an `IProtectedResourceMetadata.resource` value declared by an agent + * in `IAgentInfo.protectedResources`. + * + * Tokens are delivered using [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750) + * (Bearer Token Usage) semantics. The client obtains the token from the + * authorization server(s) listed in the resource's metadata and pushes it + * to the server via this command. + * + * @category Commands + * @method authenticate + * @direction Client → Server + * @messageType Request + * @version 1 + * @see {@link /specification/authentication | Authentication} + * @example + * ```jsonc + * // Client → Server + * { "jsonrpc": "2.0", "id": 3, "method": "authenticate", + * "params": { "resource": "https://api.github.com", "token": "gho_xxxx" } } + * + * // Server → Client (success) + * { "jsonrpc": "2.0", "id": 3, "result": {} } + * + * // Server → Client (failure — invalid token) + * { "jsonrpc": "2.0", "id": 3, "error": { "code": -32007, "message": "Invalid token" } } + * ``` + */ +export interface IAuthenticateParams { + /** + * The protected resource identifier. MUST match a `resource` value from + * `IProtectedResourceMetadata` declared in `IAgentInfo.protectedResources`. + */ + resource: string; + /** Bearer token obtained from the resource's authorization server */ + token: string; +} + +/** + * Result of the `authenticate` command. + * + * An empty object on success. If the token is invalid or the resource is + * unrecognized, the server MUST return a JSON-RPC error (e.g. `AuthRequired` + * `-32007` or `InvalidParams` `-32602`). + */ +export interface IAuthenticateResult { +} diff --git a/src/vs/platform/agentHost/common/state/protocol/errors.ts b/src/vs/platform/agentHost/common/state/protocol/errors.ts index 071725afbf7..d8f1d609b78 100644 --- a/src/vs/platform/agentHost/common/state/protocol/errors.ts +++ b/src/vs/platform/agentHost/common/state/protocol/errors.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 409b385 // ─── Standard JSON-RPC Codes ───────────────────────────────────────────────── @@ -48,6 +48,15 @@ export const AhpErrorCodes = { UnsupportedProtocolVersion: -32005, /** The requested content URI does not exist */ ContentNotFound: -32006, + /** + * A command failed because the client has not authenticated for a required + * protected resource. The `data` field of the JSON-RPC error SHOULD contain + * an `IProtectedResourceMetadata[]` array describing the resources that + * require authentication. + * + * @see {@link /specification/authentication | Authentication} + */ + AuthRequired: -32007, } as const; /** Union type of all AHP application error codes. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts index 740d30c04a6..edbb71701d1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -5,9 +5,9 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 409b385 -import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams } from './commands.js'; +import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js'; import type { IActionEnvelope } from './actions.js'; import type { IProtocolNotification } from './notifications.js'; @@ -67,6 +67,7 @@ export interface ICommandMap { 'fetchContent': { params: IFetchContentParams; result: IFetchContentResult }; 'browseDirectory': { params: IBrowseDirectoryParams; result: IBrowseDirectoryResult }; 'fetchTurns': { params: IFetchTurnsParams; result: IFetchTurnsResult }; + 'authenticate': { params: IAuthenticateParams; result: IAuthenticateResult }; } // ─── Notification Maps ─────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/notifications.ts index 140858822eb..ea497c9127b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/notifications.ts @@ -5,10 +5,22 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 409b385 import type { URI, ISessionSummary } from './state.js'; +/** + * Reason why authentication is required. + * + * @category Protocol Notifications + */ +export const enum AuthRequiredReason { + /** The client has not yet authenticated for the resource */ + Required = 'required', + /** A previously valid token has expired or been revoked */ + Expired = 'expired', +} + // ─── Protocol Notifications ────────────────────────────────────────────────── /** @@ -16,11 +28,11 @@ import type { URI, ISessionSummary } from './state.js'; * * @category Protocol Notifications */ -export const NotificationType = { - SessionAdded: 'notify/sessionAdded', - SessionRemoved: 'notify/sessionRemoved', -} as const; -export type NotificationType = typeof NotificationType[keyof typeof NotificationType]; +export const enum NotificationType { + SessionAdded = 'notify/sessionAdded', + SessionRemoved = 'notify/sessionRemoved', + AuthRequired = 'notify/authRequired', +} /** * Broadcast to all connected clients when a new session is created. @@ -49,7 +61,7 @@ export type NotificationType = typeof NotificationType[keyof typeof Notification * ``` */ export interface ISessionAddedNotification { - type: 'notify/sessionAdded'; + type: NotificationType.SessionAdded; /** Summary of the new session */ summary: ISessionSummary; } @@ -74,14 +86,49 @@ export interface ISessionAddedNotification { * ``` */ export interface ISessionRemovedNotification { - type: 'notify/sessionRemoved'; + type: NotificationType.SessionRemoved; /** URI of the removed session */ session: URI; } +/** + * Sent by the server when a protected resource requires (re-)authentication. + * + * This notification is sent when a previously valid token expires or is + * revoked, or when the server discovers a new authentication requirement. + * Clients should obtain a fresh token and push it via the `authenticate` + * command. + * + * @category Protocol Notifications + * @version 1 + * @see {@link /specification/authentication | Authentication} + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "notification", + * "params": { + * "notification": { + * "type": "notify/authRequired", + * "resource": "https://api.github.com", + * "reason": "expired" + * } + * } + * } + * ``` + */ +export interface IAuthRequiredNotification { + type: NotificationType.AuthRequired; + /** The protected resource identifier that requires authentication */ + resource: string; + /** Why authentication is required */ + reason?: AuthRequiredReason; +} + /** * Discriminated union of all protocol notifications. */ export type IProtocolNotification = | ISessionAddedNotification - | ISessionRemovedNotification; + | ISessionRemovedNotification + | IAuthRequiredNotification; diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts new file mode 100644 index 00000000000..4aa21b64e8b --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -0,0 +1,491 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts +// Synced from agent-host-protocol @ 409b385 + +import { ActionType } from './actions.js'; +import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, type IRootState, type ISessionState, type IToolCallState, type IToolCallCompletedState, type IToolCallCancelledState, type ITurn } from './state.js'; +import { IS_CLIENT_DISPATCHABLE, type IRootAction, type ISessionAction, type IClientSessionAction } from './action-origin.generated.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Soft assertion for exhaustiveness checking. Place in the `default` branch of + * a switch on a discriminated union so the compiler errors when a new variant + * is added but not handled. + * + * At runtime, logs a warning instead of throwing so that forward-compatible + * clients receiving unknown actions from a newer server degrade gracefully. + */ +export function softAssertNever(value: never, log?: (msg: string) => void): void { + const msg = `Unhandled action type: ${(value as { type: string }).type}`; + (log ?? console.warn)(msg); +} + +/** Extracts the common base fields shared by all tool call lifecycle states. */ +function tcBase(tc: IToolCallState) { + return { + toolCallId: tc.toolCallId, + toolName: tc.toolName, + displayName: tc.displayName, + toolClientId: tc.toolClientId, + _meta: tc._meta, + }; +} + +/** + * Ends the active turn, finalizing it into a completed turn record. + */ +function endTurn( + state: ISessionState, + turnId: string, + turnState: TurnState, + summaryStatus: SessionStatus, + error?: { errorType: string; message: string; stack?: string }, +): ISessionState { + if (!state.activeTurn || state.activeTurn.id !== turnId) { + return state; + } + const active = state.activeTurn; + + const toolCalls: (IToolCallCompletedState | IToolCallCancelledState)[] = []; + for (const tc of Object.values(active.toolCalls)) { + if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { + toolCalls.push(tc); + } else { + // Force non-terminal tool calls into cancelled state. + toolCalls.push({ + status: ToolCallStatus.Cancelled, + ...tcBase(tc), + invocationMessage: tc.status === ToolCallStatus.Streaming ? (tc.invocationMessage ?? '') : tc.invocationMessage, + toolInput: tc.status === ToolCallStatus.Streaming ? undefined : tc.toolInput, + reason: ToolCallCancellationReason.Skipped, + }); + } + } + + const turn: ITurn = { + id: active.id, + userMessage: active.userMessage, + responseText: active.streamingText, + responseParts: active.responseParts, + toolCalls, + usage: active.usage, + state: turnState, + error, + }; + + return { + ...state, + turns: [...state.turns, turn], + activeTurn: undefined, + summary: { ...state.summary, status: summaryStatus, modifiedAt: Date.now() }, + }; +} + +/** + * Immutably updates a single tool call in the active turn's toolCalls map. + * Returns `state` unchanged if the active turn or tool call doesn't match. + */ +function updateToolCall( + state: ISessionState, + turnId: string, + toolCallId: string, + updater: (tc: IToolCallState) => IToolCallState, +): ISessionState { + const activeTurn = state.activeTurn; + if (!activeTurn || activeTurn.id !== turnId) { + return state; + } + + const existing = activeTurn.toolCalls[toolCallId]; + if (!existing) { + return state; + } + + return { + ...state, + activeTurn: { + ...activeTurn, + toolCalls: { + ...activeTurn.toolCalls, + [toolCallId]: updater(existing), + }, + }, + }; +} + +// ─── Root Reducer ──────────────────────────────────────────────────────────── + +/** + * Pure reducer for root state. Handles all {@link IRootAction} variants. + */ +export function rootReducer(state: IRootState, action: IRootAction, log?: (msg: string) => void): IRootState { + switch (action.type) { + case ActionType.RootAgentsChanged: + return { ...state, agents: action.agents }; + + case ActionType.RootActiveSessionsChanged: + return { ...state, activeSessions: action.activeSessions }; + + default: + softAssertNever(action, log); + return state; + } +} + +// ─── Session Reducer ───────────────────────────────────────────────────────── + +/** + * Pure reducer for session state. Handles all {@link ISessionAction} variants. + */ +export function sessionReducer(state: ISessionState, action: ISessionAction, log?: (msg: string) => void): ISessionState { + switch (action.type) { + // ── Lifecycle ────────────────────────────────────────────────────────── + + case ActionType.SessionReady: + return { + ...state, + lifecycle: SessionLifecycle.Ready, + summary: { ...state.summary, status: SessionStatus.Idle }, + }; + + case ActionType.SessionCreationFailed: + return { + ...state, + lifecycle: SessionLifecycle.CreationFailed, + creationError: action.error, + }; + + // ── Turn Lifecycle ──────────────────────────────────────────────────── + + case ActionType.SessionTurnStarted: + return { + ...state, + summary: { ...state.summary, status: SessionStatus.InProgress, modifiedAt: Date.now() }, + activeTurn: { + id: action.turnId, + userMessage: action.userMessage, + streamingText: '', + responseParts: [], + toolCalls: {}, + pendingPermissions: {}, + reasoning: '', + usage: undefined, + }, + }; + + case ActionType.SessionDelta: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + streamingText: state.activeTurn.streamingText + action.content, + }, + }; + + case ActionType.SessionResponsePart: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + responseParts: [...state.activeTurn.responseParts, action.part], + }, + }; + + case ActionType.SessionTurnComplete: + return endTurn(state, action.turnId, TurnState.Complete, SessionStatus.Idle); + + case ActionType.SessionTurnCancelled: + return endTurn(state, action.turnId, TurnState.Cancelled, SessionStatus.Idle); + + case ActionType.SessionError: + return endTurn(state, action.turnId, TurnState.Error, SessionStatus.Error, action.error); + + // ── Tool Call State Machine ─────────────────────────────────────────── + + case ActionType.SessionToolCallStart: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + toolCalls: { + ...state.activeTurn.toolCalls, + [action.toolCallId]: { + toolCallId: action.toolCallId, + toolName: action.toolName, + displayName: action.displayName, + toolClientId: action.toolClientId, + _meta: action._meta, + status: ToolCallStatus.Streaming, + }, + }, + }, + }; + + case ActionType.SessionToolCallDelta: + return updateToolCall(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.Streaming) { + return tc; + } + return { + ...tc, + partialInput: (tc.partialInput ?? '') + action.content, + invocationMessage: action.invocationMessage ?? tc.invocationMessage, + }; + }); + + case ActionType.SessionToolCallReady: + return updateToolCall(state, action.turnId, action.toolCallId, tc => { + const base = tcBase(tc); + if (action.confirmed) { + return { + status: ToolCallStatus.Running, + ...base, + invocationMessage: action.invocationMessage, + toolInput: action.toolInput, + confirmed: action.confirmed, + }; + } + return { + status: ToolCallStatus.PendingConfirmation, + ...base, + invocationMessage: action.invocationMessage, + toolInput: action.toolInput, + }; + }); + + case ActionType.SessionToolCallConfirmed: + return updateToolCall(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.PendingConfirmation) { + return tc; + } + const base = tcBase(tc); + if (action.approved) { + return { + status: ToolCallStatus.Running, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed: action.confirmed, + }; + } + return { + status: ToolCallStatus.Cancelled, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + reason: action.reason, + reasonMessage: action.reasonMessage, + userSuggestion: action.userSuggestion, + }; + }); + + case ActionType.SessionToolCallComplete: + return updateToolCall(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.PendingConfirmation) { + return tc; + } + const base = tcBase(tc); + const confirmed = tc.status === ToolCallStatus.Running + ? tc.confirmed + : ToolCallConfirmationReason.NotNeeded; + if (action.requiresResultConfirmation) { + return { + status: ToolCallStatus.PendingResultConfirmation, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed, + ...action.result, + }; + } + return { + status: ToolCallStatus.Completed, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed, + ...action.result, + }; + }); + + case ActionType.SessionToolCallResultConfirmed: + return updateToolCall(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.PendingResultConfirmation) { + return tc; + } + const base = tcBase(tc); + if (action.approved) { + return { + status: ToolCallStatus.Completed, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed: tc.confirmed, + success: tc.success, + pastTenseMessage: tc.pastTenseMessage, + content: tc.content, + structuredContent: tc.structuredContent, + error: tc.error, + }; + } + return { + status: ToolCallStatus.Cancelled, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + reason: ToolCallCancellationReason.ResultDenied, + }; + }); + + // ── Permissions ─────────────────────────────────────────────────────── + + case ActionType.SessionPermissionRequest: { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const pendingPermissions = { + ...state.activeTurn.pendingPermissions, + [action.request.requestId]: action.request, + }; + // If the permission is tied to a tool call, transition it to pending-confirmation + let toolCalls = state.activeTurn.toolCalls; + if (action.request.toolCallId) { + const tc = toolCalls[action.request.toolCallId]; + if (tc && (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Streaming)) { + toolCalls = { + ...toolCalls, + [action.request.toolCallId]: { + ...tc, + status: ToolCallStatus.PendingConfirmation, + invocationMessage: tc.invocationMessage ?? '', + }, + }; + } + } + return { + ...state, + activeTurn: { ...state.activeTurn, pendingPermissions, toolCalls }, + }; + } + + case ActionType.SessionPermissionResolved: { + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + const resolved = state.activeTurn.pendingPermissions[action.requestId]; + const { [action.requestId]: _, ...pendingPermissions } = state.activeTurn.pendingPermissions; + // If the permission was tied to a tool call, transition it based on approval + let toolCalls = state.activeTurn.toolCalls; + if (resolved?.toolCallId) { + const tc = toolCalls[resolved.toolCallId]; + if (tc && tc.status === ToolCallStatus.PendingConfirmation) { + const base = tcBase(tc); + const updated: IToolCallState = action.approved + ? { + status: ToolCallStatus.Running, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + confirmed: ToolCallConfirmationReason.UserAction, + } + : { + status: ToolCallStatus.Cancelled, + ...base, + invocationMessage: tc.invocationMessage, + toolInput: tc.toolInput, + reason: ToolCallCancellationReason.Denied, + }; + toolCalls = { ...toolCalls, [resolved.toolCallId]: updated }; + } + } + return { + ...state, + activeTurn: { ...state.activeTurn, pendingPermissions, toolCalls }, + }; + } + + // ── Metadata ────────────────────────────────────────────────────────── + + case ActionType.SessionTitleChanged: + return { + ...state, + summary: { ...state.summary, title: action.title, modifiedAt: Date.now() }, + }; + + case ActionType.SessionUsage: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { ...state.activeTurn, usage: action.usage }, + }; + + case ActionType.SessionReasoning: + if (!state.activeTurn || state.activeTurn.id !== action.turnId) { + return state; + } + return { + ...state, + activeTurn: { + ...state.activeTurn, + reasoning: state.activeTurn.reasoning + action.content, + }, + }; + + case ActionType.SessionModelChanged: + return { + ...state, + summary: { ...state.summary, model: action.model, modifiedAt: Date.now() }, + }; + + case ActionType.SessionServerToolsChanged: + return { ...state, serverTools: action.tools }; + + case ActionType.SessionActiveClientChanged: + return { + ...state, + activeClient: action.activeClient ?? undefined, + }; + + case ActionType.SessionActiveClientToolsChanged: + if (!state.activeClient) { + return state; + } + return { + ...state, + activeClient: { ...state.activeClient, tools: action.tools }, + }; + + default: + softAssertNever(action, log); + return state; + } +} + +// ─── Dispatch Validation ───────────────────────────────────────────────────── + +/** + * Type guard that checks whether an action may be dispatched by a client. + * + * Servers SHOULD call this to validate incoming `dispatchAction` requests + * and reject any action the client is not allowed to originate. + */ +export function isClientDispatchable(action: ISessionAction): action is IClientSessionAction { + return IS_CLIENT_DISPATCHABLE[action.type]; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 60e7672e77e..a2d6e1f8a50 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -5,7 +5,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 +// Synced from agent-host-protocol @ 409b385 // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -20,6 +20,72 @@ export type URI = string; */ export type StringOrMarkdown = string | { markdown: string }; +// ─── Protected Resource Metadata (RFC 9728) ───────────────────────────────── + +/** + * Describes a protected resource's authentication requirements using + * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) (OAuth 2.0 + * Protected Resource Metadata) semantics. + * + * Field names use snake_case to match the RFC 9728 JSON format. + * + * @category Authentication + * @see {@link https://datatracker.ietf.org/doc/html/rfc9728 | RFC 9728} + */ +export interface IProtectedResourceMetadata { + /** + * REQUIRED. The protected resource's resource identifier, a URL using the + * `https` scheme with no fragment component (e.g. `"https://api.github.com"`). + */ + resource: string; + + /** OPTIONAL. Human-readable name of the protected resource. */ + resource_name?: string; + + /** OPTIONAL. JSON array of OAuth authorization server identifier URLs. */ + authorization_servers?: string[]; + + /** OPTIONAL. URL of the protected resource's JWK Set document. */ + jwks_uri?: string; + + /** RECOMMENDED. JSON array of OAuth 2.0 scope values used in authorization requests. */ + scopes_supported?: string[]; + + /** OPTIONAL. JSON array of Bearer Token presentation methods supported. */ + bearer_methods_supported?: string[]; + + /** OPTIONAL. JSON array of JWS signing algorithms supported. */ + resource_signing_alg_values_supported?: string[]; + + /** OPTIONAL. JSON array of JWE encryption algorithms (alg) supported. */ + resource_encryption_alg_values_supported?: string[]; + + /** OPTIONAL. JSON array of JWE encryption algorithms (enc) supported. */ + resource_encryption_enc_values_supported?: string[]; + + /** OPTIONAL. URL of human-readable documentation for the resource. */ + resource_documentation?: string; + + /** OPTIONAL. URL of the resource's data-usage policy. */ + resource_policy_uri?: string; + + /** OPTIONAL. URL of the resource's terms of service. */ + resource_tos_uri?: string; + + /** + * AHP extension. Whether authentication is required for this resource. + * + * - `true` (default) — the agent cannot be used without a valid token. + * The server SHOULD return `AuthRequired` (`-32007`) if the client + * attempts to use the agent without authenticating. + * - `false` — the agent works without authentication but MAY offer + * enhanced capabilities when a token is provided. + * + * Clients SHOULD treat an absent field the same as `true`. + */ + required?: boolean; +} + // ─── Root State ────────────────────────────────────────────────────────────── /** @@ -27,12 +93,11 @@ export type StringOrMarkdown = string | { markdown: string }; * * @category Root State */ -export const PolicyState = { - Enabled: 'enabled', - Disabled: 'disabled', - Unconfigured: 'unconfigured', -} as const; -export type PolicyState = typeof PolicyState[keyof typeof PolicyState]; +export const enum PolicyState { + Enabled = 'enabled', + Disabled = 'disabled', + Unconfigured = 'unconfigured', +} /** * Global state shared with every client subscribed to `agenthost:/root`. @@ -58,6 +123,18 @@ export interface IAgentInfo { description: string; /** Available models for this agent */ models: ISessionModelInfo[]; + /** + * Protected resources this agent requires authentication for. + * + * Each entry describes an OAuth 2.0 protected resource using + * [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) semantics. + * Clients should obtain tokens from the declared `authorization_servers` + * and push them via the `authenticate` command before creating sessions + * with this agent. + * + * @see {@link /specification/authentication | Authentication} + */ + protectedResources?: IProtectedResourceMetadata[]; } /** @@ -85,24 +162,22 @@ export interface ISessionModelInfo { * * @category Session State */ -export const SessionLifecycle = { - Creating: 'creating', - Ready: 'ready', - CreationFailed: 'creationFailed', -} as const; -export type SessionLifecycle = typeof SessionLifecycle[keyof typeof SessionLifecycle]; +export const enum SessionLifecycle { + Creating = 'creating', + Ready = 'ready', + CreationFailed = 'creationFailed', +} /** * Current session status. * * @category Session State */ -export const SessionStatus = { - Idle: 'idle', - InProgress: 'in-progress', - Error: 'error', -} as const; -export type SessionStatus = typeof SessionStatus[keyof typeof SessionStatus]; +export const enum SessionStatus { + Idle = 'idle', + InProgress = 'in-progress', + Error = 'error', +} /** * Full state for a single session, loaded when a client subscribes to the session's URI. @@ -120,6 +195,8 @@ export interface ISessionState { serverTools?: IToolDefinition[]; /** The client currently providing tools and interactive capabilities to this session */ activeClient?: ISessionActiveClient; + /** The working directory URI for this session */ + workingDirectory?: URI; /** Completed turns */ turns: ITurn[]; /** Currently in-progress turn */ @@ -161,6 +238,8 @@ export interface ISessionSummary { modifiedAt: number; /** Currently selected model */ model?: string; + /** The working directory URI for this session */ + workingDirectory?: URI; } // ─── Turn Types ────────────────────────────────────────────────────────────── @@ -170,24 +249,22 @@ export interface ISessionSummary { * * @category Turn Types */ -export const TurnState = { - Complete: 'complete', - Cancelled: 'cancelled', - Error: 'error', -} as const; -export type TurnState = typeof TurnState[keyof typeof TurnState]; +export const enum TurnState { + Complete = 'complete', + Cancelled = 'cancelled', + Error = 'error', +} /** * Type of a message attachment. * * @category Turn Types */ -export const AttachmentType = { - File: 'file', - Directory: 'directory', - Selection: 'selection', -} as const; -export type AttachmentType = typeof AttachmentType[keyof typeof AttachmentType]; +export const enum AttachmentType { + File = 'file', + Directory = 'directory', + Selection = 'selection', +} /** * A completed request/response cycle. @@ -266,18 +343,17 @@ export interface IMessageAttachment { * * @category Response Parts */ -export const ResponsePartKind = { - Markdown: 'markdown', - ContentRef: 'contentRef', -} as const; -export type ResponsePartKind = typeof ResponsePartKind[keyof typeof ResponsePartKind]; +export const enum ResponsePartKind { + Markdown = 'markdown', + ContentRef = 'contentRef', +} /** * @category Response Parts */ export interface IMarkdownResponsePart { /** Discriminant */ - kind: 'markdown'; + kind: ResponsePartKind.Markdown; /** Markdown content */ content: string; } @@ -289,7 +365,7 @@ export interface IMarkdownResponsePart { */ export interface IContentRef { /** Discriminant */ - kind: 'contentRef'; + kind: ResponsePartKind.ContentRef; /** Content URI */ uri: string; /** Approximate size in bytes */ @@ -310,15 +386,14 @@ export type IResponsePart = IMarkdownResponsePart | IContentRef; * * @category Tool Call Types */ -export const ToolCallStatus = { - Streaming: 'streaming', - PendingConfirmation: 'pending-confirmation', - Running: 'running', - PendingResultConfirmation: 'pending-result-confirmation', - Completed: 'completed', - Cancelled: 'cancelled', -} as const; -export type ToolCallStatus = typeof ToolCallStatus[keyof typeof ToolCallStatus]; +export const enum ToolCallStatus { + Streaming = 'streaming', + PendingConfirmation = 'pending-confirmation', + Running = 'running', + PendingResultConfirmation = 'pending-result-confirmation', + Completed = 'completed', + Cancelled = 'cancelled', +} /** * How a tool call was confirmed for execution. @@ -329,24 +404,22 @@ export type ToolCallStatus = typeof ToolCallStatus[keyof typeof ToolCallStatus]; * * @category Tool Call Types */ -export const ToolCallConfirmationReason = { - NotNeeded: 'not-needed', - UserAction: 'user-action', - Setting: 'setting', -} as const; -export type ToolCallConfirmationReason = typeof ToolCallConfirmationReason[keyof typeof ToolCallConfirmationReason]; +export const enum ToolCallConfirmationReason { + NotNeeded = 'not-needed', + UserAction = 'user-action', + Setting = 'setting', +} /** * Why a tool call was cancelled. * * @category Tool Call Types */ -export const ToolCallCancellationReason = { - Denied: 'denied', - Skipped: 'skipped', - ResultDenied: 'result-denied', -} as const; -export type ToolCallCancellationReason = typeof ToolCallCancellationReason[keyof typeof ToolCallCancellationReason]; +export const enum ToolCallCancellationReason { + Denied = 'denied', + Skipped = 'skipped', + ResultDenied = 'result-denied', +} /** * Metadata common to all tool call states. @@ -428,7 +501,7 @@ export interface IToolCallResult { * @category Tool Call Types */ export interface IToolCallStreamingState extends IToolCallBase { - status: 'streaming'; + status: ToolCallStatus.Streaming; /** Partial parameters accumulated so far */ partialInput?: string; /** Progress message shown while parameters are streaming */ @@ -441,7 +514,7 @@ export interface IToolCallStreamingState extends IToolCallBase { * @category Tool Call Types */ export interface IToolCallPendingConfirmationState extends IToolCallBase, IToolCallParameterFields { - status: 'pending-confirmation'; + status: ToolCallStatus.PendingConfirmation; } /** @@ -450,7 +523,7 @@ export interface IToolCallPendingConfirmationState extends IToolCallBase, IToolC * @category Tool Call Types */ export interface IToolCallRunningState extends IToolCallBase, IToolCallParameterFields { - status: 'running'; + status: ToolCallStatus.Running; /** How the tool was confirmed for execution */ confirmed: ToolCallConfirmationReason; } @@ -461,7 +534,7 @@ export interface IToolCallRunningState extends IToolCallBase, IToolCallParameter * @category Tool Call Types */ export interface IToolCallPendingResultConfirmationState extends IToolCallBase, IToolCallParameterFields, IToolCallResult { - status: 'pending-result-confirmation'; + status: ToolCallStatus.PendingResultConfirmation; /** How the tool was confirmed for execution */ confirmed: ToolCallConfirmationReason; } @@ -472,7 +545,7 @@ export interface IToolCallPendingResultConfirmationState extends IToolCallBase, * @category Tool Call Types */ export interface IToolCallCompletedState extends IToolCallBase, IToolCallParameterFields, IToolCallResult { - status: 'completed'; + status: ToolCallStatus.Completed; /** How the tool was confirmed for execution */ confirmed: ToolCallConfirmationReason; } @@ -483,7 +556,7 @@ export interface IToolCallCompletedState extends IToolCallBase, IToolCallParamet * @category Tool Call Types */ export interface IToolCallCancelledState extends IToolCallBase, IToolCallParameterFields { - status: 'cancelled'; + status: ToolCallStatus.Cancelled; /** Why the tool was cancelled */ reason: ToolCallCancellationReason; /** Optional message explaining the cancellation */ @@ -584,11 +657,11 @@ export interface IToolAnnotations { * * @category Tool Result Content */ -export const ToolResultContentType = { - Text: 'text', - Binary: 'binary', -} as const; -export type ToolResultContentType = typeof ToolResultContentType[keyof typeof ToolResultContentType]; +export const enum ToolResultContentType { + Text = 'text', + Binary = 'binary', + FileEdit = 'fileEdit', +} /** * Text content in a tool result. @@ -598,7 +671,7 @@ export type ToolResultContentType = typeof ToolResultContentType[keyof typeof To * @category Tool Result Content */ export interface IToolResultTextContent { - type: 'text'; + type: ToolResultContentType.Text; /** The text content */ text: string; } @@ -611,24 +684,48 @@ export interface IToolResultTextContent { * @category Tool Result Content */ export interface IToolResultBinaryContent { - type: 'binary'; + type: ToolResultContentType.Binary; /** Base64-encoded data */ data: string; /** Content type (e.g. `"image/png"`, `"application/pdf"`) */ contentType: string; } +/** + * Describes a file modification performed by a tool. + * + * Clients can use the `beforeURI`/`afterURI` pair to render a diff view. + * + * @category Tool Result Content + */ +export interface IToolResultFileEditContent { + type: ToolResultContentType.FileEdit; + /** URI of the file content before the edit */ + beforeURI: URI; + /** URI of the file content after the edit */ + afterURI: URI; + /** Optional diff display metadata */ + diff?: { + /** Number of items added (e.g., lines for text files, cells for notebooks) */ + added?: number; + /** Number of items removed (e.g., lines for text files, cells for notebooks) */ + removed?: number; + }; +} + /** * Content block in a tool result. * - * Mirrors the content blocks in MCP `CallToolResult.content`, plus `IContentRef` - * for lazy-loading large results (an AHP extension). + * Mirrors the content blocks in MCP `CallToolResult.content`, plus + * `IContentRef` for lazy-loading large results and `IToolResultFileEditContent` + * for file edit diffs (AHP extensions). * * @category Tool Result Content */ export type IToolResultContent = | IToolResultTextContent | IToolResultBinaryContent + | IToolResultFileEditContent | IContentRef; // ─── Permission Types ──────────────────────────────────────────────────────── @@ -638,14 +735,13 @@ export type IToolResultContent = * * @category Permission Types */ -export const PermissionKind = { - Shell: 'shell', - Write: 'write', - Mcp: 'mcp', - Read: 'read', - Url: 'url', -} as const; -export type PermissionKind = typeof PermissionKind[keyof typeof PermissionKind]; +export const enum PermissionKind { + Shell = 'shell', + Write = 'write', + Mcp = 'mcp', + Read = 'read', + Url = 'url', +} /** * @category Permission Types diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index e2d6d44e452..1e6dcd41b1c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -5,12 +5,10 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -// Synced from agent-host-protocol @ a566419 - -import type { IStateAction } from '../actions.js'; - -import type { IProtocolNotification } from '../notifications.js'; +// Synced from agent-host-protocol @ 409b385 +import { ActionType, type IStateAction } from '../actions.js'; +import { NotificationType, type IProtocolNotification } from '../notifications.js'; // ─── Protocol Version Constants ────────────────────────────────────────────── @@ -27,31 +25,31 @@ export const MIN_PROTOCOL_VERSION = 1; * Adding a new action to `IStateAction` without adding it here is a compile error. */ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: number } = { - ['root/agentsChanged']: 1, - ['root/activeSessionsChanged']: 1, - ['session/ready']: 1, - ['session/creationFailed']: 1, - ['session/turnStarted']: 1, - ['session/delta']: 1, - ['session/responsePart']: 1, - ['session/toolCallStart']: 1, - ['session/toolCallDelta']: 1, - ['session/toolCallReady']: 1, - ['session/toolCallConfirmed']: 1, - ['session/toolCallComplete']: 1, - ['session/toolCallResultConfirmed']: 1, - ['session/permissionRequest']: 1, - ['session/permissionResolved']: 1, - ['session/turnComplete']: 1, - ['session/turnCancelled']: 1, - ['session/error']: 1, - ['session/titleChanged']: 1, - ['session/usage']: 1, - ['session/reasoning']: 1, - ['session/modelChanged']: 1, - ['session/serverToolsChanged']: 1, - ['session/activeClientChanged']: 1, - ['session/activeClientToolsChanged']: 1, + [ActionType.RootAgentsChanged]: 1, + [ActionType.RootActiveSessionsChanged]: 1, + [ActionType.SessionReady]: 1, + [ActionType.SessionCreationFailed]: 1, + [ActionType.SessionTurnStarted]: 1, + [ActionType.SessionDelta]: 1, + [ActionType.SessionResponsePart]: 1, + [ActionType.SessionToolCallStart]: 1, + [ActionType.SessionToolCallDelta]: 1, + [ActionType.SessionToolCallReady]: 1, + [ActionType.SessionToolCallConfirmed]: 1, + [ActionType.SessionToolCallComplete]: 1, + [ActionType.SessionToolCallResultConfirmed]: 1, + [ActionType.SessionPermissionRequest]: 1, + [ActionType.SessionPermissionResolved]: 1, + [ActionType.SessionTurnComplete]: 1, + [ActionType.SessionTurnCancelled]: 1, + [ActionType.SessionError]: 1, + [ActionType.SessionTitleChanged]: 1, + [ActionType.SessionUsage]: 1, + [ActionType.SessionReasoning]: 1, + [ActionType.SessionModelChanged]: 1, + [ActionType.SessionServerToolsChanged]: 1, + [ActionType.SessionActiveClientChanged]: 1, + [ActionType.SessionActiveClientToolsChanged]: 1, }; /** @@ -69,8 +67,9 @@ export function isActionKnownToVersion(action: IStateAction, clientVersion: numb * is a compile error. */ export const NOTIFICATION_INTRODUCED_IN: { readonly [K in IProtocolNotification['type']]: number } = { - ['notify/sessionAdded']: 1, - ['notify/sessionRemoved']: 1, + [NotificationType.SessionAdded]: 1, + [NotificationType.SessionRemoved]: 1, + [NotificationType.AuthRequired]: 1, }; /** diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index ab2d5b3c085..7cf64cba44a 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -49,22 +49,20 @@ export { export { NotificationType, + AuthRequiredReason, type ISessionAddedNotification, type ISessionRemovedNotification, + type IAuthRequiredNotification, } from './protocol/notifications.js'; // ---- Local aliases for short names ------------------------------------------ // Consumers use these shorter names; they're type-only aliases. import type { - IActionEnvelope as _IActionEnvelope, IRootAgentsChangedAction, IRootActiveSessionsChangedAction, - ISessionCreationFailedAction, ISessionDeltaAction, - ISessionErrorAction, ISessionModelChangedAction, - ISessionReadyAction, ISessionReasoningAction, ISessionResponsePartAction, ISessionPermissionRequestAction, @@ -80,18 +78,20 @@ import type { ISessionTurnCompleteAction, ISessionTurnStartedAction, ISessionUsageAction, - ISessionServerToolsChangedAction, - ISessionActiveClientChangedAction, - ISessionActiveClientToolsChangedAction, IStateAction, } from './protocol/actions.js'; import type { IProtocolNotification } from './protocol/notifications.js'; +import type { IRootAction as IRootAction_, ISessionAction as ISessionAction_, IClientSessionAction as IClientSessionAction_, IServerSessionAction as IServerSessionAction_ } from './protocol/action-origin.generated.js'; + +export type IRootAction = IRootAction_; +export type ISessionAction = ISessionAction_; +export type IClientSessionAction = IClientSessionAction_; +export type IServerSessionAction = IServerSessionAction_; // Root actions export type IAgentsChangedAction = IRootAgentsChangedAction; export type IActiveSessionsChangedAction = IRootActiveSessionsChangedAction; -export type IRootAction = IAgentsChangedAction | IActiveSessionsChangedAction; // Session actions — short aliases export type ITurnStartedAction = ISessionTurnStartedAction; @@ -114,32 +114,6 @@ export type IUsageAction = ISessionUsageAction; export type IReasoningAction = ISessionReasoningAction; export type IModelChangedAction = ISessionModelChangedAction; -/** Union of all session-scoped actions. */ -export type ISessionAction = - | ISessionReadyAction - | ISessionCreationFailedAction - | ISessionTurnStartedAction - | ISessionDeltaAction - | ISessionResponsePartAction - | ISessionToolCallStartAction - | ISessionToolCallDeltaAction - | ISessionToolCallReadyAction - | ISessionToolCallConfirmedAction - | ISessionToolCallCompleteAction - | ISessionToolCallResultConfirmedAction - | ISessionPermissionRequestAction - | ISessionPermissionResolvedAction - | ISessionTurnCompleteAction - | ISessionTurnCancelledAction - | ISessionErrorAction - | ISessionTitleChangedAction - | ISessionUsageAction - | ISessionReasoningAction - | ISessionModelChangedAction - | ISessionServerToolsChangedAction - | ISessionActiveClientChangedAction - | ISessionActiveClientToolsChangedAction; - // Notifications export type INotification = IProtocolNotification; diff --git a/src/vs/platform/agentHost/common/state/sessionClientState.ts b/src/vs/platform/agentHost/common/state/sessionClientState.ts index 744ab7611a1..f40695bcf05 100644 --- a/src/vs/platform/agentHost/common/state/sessionClientState.ts +++ b/src/vs/platform/agentHost/common/state/sessionClientState.ts @@ -21,6 +21,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IActionEnvelope, INotification, ISessionAction, isRootAction, isSessionAction, IStateAction } from './sessionActions.js'; import { rootReducer, sessionReducer } from './sessionReducers.js'; import { IRootState, ISessionState, ROOT_STATE_URI } from './sessionState.js'; +import { ILogService } from '../../../log/common/log.js'; // ---- Pending action tracking ------------------------------------------------ @@ -49,6 +50,7 @@ interface IPendingAction { export class SessionClientState extends Disposable { private readonly _clientId: string; + private readonly _log: (msg: string) => void; private _nextClientSeq = 1; private _lastSeenServerSeq = 0; @@ -72,9 +74,10 @@ export class SessionClientState extends Disposable { private readonly _onDidReceiveNotification = this._register(new Emitter()); readonly onDidReceiveNotification: Event = this._onDidReceiveNotification.event; - constructor(clientId: string) { + constructor(clientId: string, logService: ILogService) { super(); this._clientId = clientId; + this._log = msg => logService.warn(`[SessionClientState] ${msg}`); } get clientId(): string { @@ -208,13 +211,13 @@ export class SessionClientState extends Disposable { private _applyToConfirmed(action: IStateAction): void { if (isRootAction(action) && this._confirmedRootState) { - this._confirmedRootState = rootReducer(this._confirmedRootState, action); + this._confirmedRootState = rootReducer(this._confirmedRootState, action, this._log); } if (isSessionAction(action)) { const key = action.session.toString(); const state = this._confirmedSessionStates.get(key); if (state) { - this._confirmedSessionStates.set(key, sessionReducer(state, action)); + this._confirmedSessionStates.set(key, sessionReducer(state, action, this._log)); } } } @@ -223,7 +226,7 @@ export class SessionClientState extends Disposable { const key = action.session.toString(); const state = this._optimisticSessionStates.get(key); if (state) { - const newState = sessionReducer(state, action); + const newState = sessionReducer(state, action, this._log); this._optimisticSessionStates.set(key, newState); this._onDidChangeSessionState.fire({ session: action.session, state: newState }); } @@ -266,7 +269,7 @@ export class SessionClientState extends Disposable { let state = confirmed; for (const pending of this._pendingActions) { if (isSessionAction(pending.action) && pending.action.session === session) { - state = sessionReducer(state, pending.action); + state = sessionReducer(state, pending.action, this._log); } } diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts index e49688b0f44..deca30524f4 100644 --- a/src/vs/platform/agentHost/common/state/sessionProtocol.ts +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -80,6 +80,7 @@ export const AHP_SESSION_ALREADY_EXISTS = -32003 as const; export const AHP_TURN_IN_PROGRESS = -32004 as const; export const AHP_UNSUPPORTED_PROTOCOL_VERSION = -32005 as const; export const AHP_CONTENT_NOT_FOUND = -32006 as const; +export const AHP_AUTH_REQUIRED = -32007 as const; // ---- Type guards ----------------------------------------------------------- @@ -101,9 +102,10 @@ export function isJsonRpcResponse(msg: IProtocolMessage): msg is IAhpSuccessResp /** * Error with a JSON-RPC error code for protocol-level failures. + * Optionally carries a `data` payload for structured error details. */ export class ProtocolError extends Error { - constructor(readonly code: number, message: string) { + constructor(readonly code: number, message: string, readonly data?: unknown) { super(message); } } diff --git a/src/vs/platform/agentHost/common/state/sessionReducers.ts b/src/vs/platform/agentHost/common/state/sessionReducers.ts index d39c72ab58f..3b02a189e5d 100644 --- a/src/vs/platform/agentHost/common/state/sessionReducers.ts +++ b/src/vs/platform/agentHost/common/state/sessionReducers.ts @@ -3,457 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// Pure reducer functions for the sessions process protocol. -// See protocol.md -> Reducers for the full design. -// -// Both the server and clients run the same reducers. This is what makes -// write-ahead possible: the client can locally predict the result of its -// own action using the exact same logic the server will run. -// -// IMPORTANT: Reducers must be pure — no side effects, no I/O, no service -// calls. Server-side effects (e.g. forwarding to the Copilot SDK) are -// handled by a separate dispatch layer. +// Re-exports the protocol reducers and adds VS Code-specific helpers. +// The actual reducer logic lives in the auto-generated protocol layer. -import type { IRootAction, ISessionAction } from './sessionActions.js'; -import { - type ICompletedToolCall, - type IErrorInfo, - type IRootState, - type ISessionState, - type IToolCallState, - type ITurn, - createActiveTurn, - SessionLifecycle, - SessionStatus, - TurnState, -} from './sessionState.js'; +import type { IToolCallState, ICompletedToolCall } from './sessionState.js'; -// ---- Helper: extract common base fields from a tool call state -------------- - -function tcBase(tc: IToolCallState) { - return { - toolCallId: tc.toolCallId, - toolName: tc.toolName, - displayName: tc.displayName, - _meta: tc._meta, - }; -} - -// ---- Root reducer ----------------------------------------------------------- - -/** - * Reduces root-level actions into a new RootState. - * Root actions are server-only (clients observe but cannot produce them). - */ -export function rootReducer(state: IRootState, action: IRootAction): IRootState { - switch (action.type) { - case 'root/agentsChanged': { - return { ...state, agents: [...action.agents] }; - } - case 'root/activeSessionsChanged': { - return { ...state, activeSessions: action.activeSessions }; - } - } -} - -// ---- Session reducer -------------------------------------------------------- - -/** - * Reduces session-level actions into a new SessionState. - * Handles lifecycle, turn lifecycle, streaming deltas, tool calls, permissions. - */ -export function sessionReducer(state: ISessionState, action: ISessionAction): ISessionState { - switch (action.type) { - case 'session/ready': { - return { ...state, lifecycle: SessionLifecycle.Ready }; - } - case 'session/creationFailed': { - return { - ...state, - lifecycle: SessionLifecycle.CreationFailed, - creationError: action.error, - }; - } - case 'session/turnStarted': { - const activeTurn = createActiveTurn(action.turnId, action.userMessage); - return { - ...state, - activeTurn, - summary: { ...state.summary, status: SessionStatus.InProgress }, - }; - } - case 'session/delta': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - streamingText: state.activeTurn.streamingText + action.content, - }, - }; - } - case 'session/responsePart': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - responseParts: [...state.activeTurn.responseParts, action.part], - }, - }; - } - case 'session/toolCallStart': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { - ...state.activeTurn.toolCalls, - [action.toolCallId]: { - status: 'streaming', - toolCallId: action.toolCallId, - toolName: action.toolName, - displayName: action.displayName, - _meta: action._meta, - }, - }, - }, - }; - } - case 'session/toolCallDelta': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const tc = state.activeTurn.toolCalls[action.toolCallId]; - if (!tc || tc.status !== 'streaming') { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { - ...state.activeTurn.toolCalls, - [action.toolCallId]: { - ...tc, - partialInput: (tc.partialInput ?? '') + action.content, - invocationMessage: action.invocationMessage ?? tc.invocationMessage, - }, - }, - }, - }; - } - case 'session/toolCallReady': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const tc = state.activeTurn.toolCalls[action.toolCallId]; - if (!tc) { - return state; - } - const base = tcBase(tc); - const updated: IToolCallState = action.confirmed - ? { - status: 'running', - ...base, - invocationMessage: action.invocationMessage, - toolInput: action.toolInput, - confirmed: action.confirmed, - } - : { - status: 'pending-confirmation', - ...base, - invocationMessage: action.invocationMessage, - toolInput: action.toolInput, - }; - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, - }, - }; - } - case 'session/toolCallConfirmed': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const tc = state.activeTurn.toolCalls[action.toolCallId]; - if (!tc || tc.status !== 'pending-confirmation') { - return state; - } - const base = tcBase(tc); - const updated: IToolCallState = action.approved - ? { - status: 'running', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - confirmed: action.confirmed, - } - : { - status: 'cancelled', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - reason: action.reason, - reasonMessage: action.reasonMessage, - userSuggestion: action.userSuggestion, - }; - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, - }, - }; - } - case 'session/toolCallComplete': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const tc = state.activeTurn.toolCalls[action.toolCallId]; - if (!tc || (tc.status !== 'running' && tc.status !== 'pending-confirmation')) { - return state; - } - const base = tcBase(tc); - const confirmed = tc.status === 'running' ? tc.confirmed : 'not-needed'; - const updated: IToolCallState = action.requiresResultConfirmation - ? { - status: 'pending-result-confirmation', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - confirmed, - ...action.result, - } - : { - status: 'completed', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - confirmed, - ...action.result, - }; - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, - }, - }; - } - case 'session/toolCallResultConfirmed': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const tc = state.activeTurn.toolCalls[action.toolCallId]; - if (!tc || tc.status !== 'pending-result-confirmation') { - return state; - } - const base = tcBase(tc); - const updated: IToolCallState = action.approved - ? { - status: 'completed', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - confirmed: tc.confirmed, - success: tc.success, - pastTenseMessage: tc.pastTenseMessage, - content: tc.content, - structuredContent: tc.structuredContent, - error: tc.error, - } - : { - status: 'cancelled', - ...base, - invocationMessage: tc.invocationMessage, - toolInput: tc.toolInput, - reason: 'result-denied', - }; - return { - ...state, - activeTurn: { - ...state.activeTurn, - toolCalls: { ...state.activeTurn.toolCalls, [action.toolCallId]: updated }, - }, - }; - } - case 'session/permissionRequest': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const pendingPermissions = { ...state.activeTurn.pendingPermissions, [action.request.requestId]: action.request }; - let toolCalls = state.activeTurn.toolCalls; - if (action.request.toolCallId) { - const toolCall = toolCalls[action.request.toolCallId]; - if (toolCall && (toolCall.status === 'running' || toolCall.status === 'streaming')) { - toolCalls = { - ...toolCalls, - [action.request.toolCallId]: { - ...toolCall, - status: 'pending-confirmation', - invocationMessage: toolCall.invocationMessage ?? '', - }, - }; - } - } - return { - ...state, - activeTurn: { ...state.activeTurn, pendingPermissions, toolCalls }, - }; - } - case 'session/permissionResolved': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - const resolved = state.activeTurn.pendingPermissions[action.requestId]; - const { [action.requestId]: _, ...pendingPermissions } = state.activeTurn.pendingPermissions; - let toolCalls = state.activeTurn.toolCalls; - if (resolved?.toolCallId) { - const toolCall = toolCalls[resolved.toolCallId]; - if (toolCall && toolCall.status === 'pending-confirmation') { - const base = tcBase(toolCall); - const updated: IToolCallState = action.approved - ? { - status: 'running', - ...base, - invocationMessage: toolCall.invocationMessage, - toolInput: toolCall.toolInput, - confirmed: 'user-action', - } - : { - status: 'cancelled', - ...base, - invocationMessage: toolCall.invocationMessage, - toolInput: toolCall.toolInput, - reason: 'denied', - }; - toolCalls = { ...toolCalls, [resolved.toolCallId]: updated }; - } - } - return { - ...state, - activeTurn: { ...state.activeTurn, pendingPermissions, toolCalls }, - }; - } - case 'session/turnComplete': { - return finalizeTurn(state, action.turnId, TurnState.Complete); - } - case 'session/turnCancelled': { - return finalizeTurn(state, action.turnId, TurnState.Cancelled); - } - case 'session/error': { - return finalizeTurn(state, action.turnId, TurnState.Error, action.error); - } - case 'session/titleChanged': { - return { - ...state, - summary: { ...state.summary, title: action.title, modifiedAt: Date.now() }, - }; - } - case 'session/modelChanged': { - return { - ...state, - summary: { ...state.summary, model: action.model, modifiedAt: Date.now() }, - }; - } - case 'session/usage': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - usage: action.usage, - }, - }; - } - case 'session/reasoning': { - if (!state.activeTurn || state.activeTurn.id !== action.turnId) { - return state; - } - return { - ...state, - activeTurn: { - ...state.activeTurn, - reasoning: state.activeTurn.reasoning + action.content, - }, - }; - } - case 'session/serverToolsChanged': { - return { ...state, serverTools: action.tools }; - } - case 'session/activeClientChanged': { - return { ...state, activeClient: action.activeClient ?? undefined }; - } - case 'session/activeClientToolsChanged': { - if (!state.activeClient) { - return state; - } - return { ...state, activeClient: { ...state.activeClient, tools: action.tools } }; - } - } -} - -// ---- Helpers ---------------------------------------------------------------- - -/** - * Moves the active turn into the completed turns array and clears `activeTurn`. - */ -function finalizeTurn(state: ISessionState, turnId: string, turnState: TurnState, error?: IErrorInfo): ISessionState { - if (!state.activeTurn || state.activeTurn.id !== turnId) { - return state; - } - const active = state.activeTurn; - - const completedToolCalls: ICompletedToolCall[] = []; - for (const tc of Object.values(active.toolCalls)) { - if (tc.status === 'completed') { - completedToolCalls.push(tc); - } else if (tc.status === 'cancelled') { - completedToolCalls.push(tc); - } else { - // For tool calls that are not in a terminal state when the turn - // finishes (e.g. still streaming or running), force them into - // a cancelled state so they are persisted properly. - completedToolCalls.push({ - status: 'cancelled', - ...tcBase(tc), - invocationMessage: tc.status === 'streaming' ? (tc.invocationMessage ?? '') : tc.invocationMessage, - toolInput: tc.status === 'streaming' ? undefined : tc.toolInput, - reason: 'skipped', - }); - } - } - - const finalizedTurn: ITurn = { - id: active.id, - userMessage: active.userMessage, - responseText: active.streamingText, - responseParts: active.responseParts, - toolCalls: completedToolCalls, - usage: active.usage, - state: turnState, - error, - }; - - return { - ...state, - turns: [...state.turns, finalizedTurn], - activeTurn: undefined, - summary: { ...state.summary, status: SessionStatus.Idle, modifiedAt: Date.now() }, - }; -} +// Re-export reducers from the protocol layer +export { rootReducer, sessionReducer, softAssertNever, isClientDispatchable } from './protocol/reducers.js'; // ---- Tool call metadata helpers (VS Code extensions via _meta) -------------- diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 0120f77d1c0..f63a17bde4b 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -11,17 +11,19 @@ // helpers and re-exports. import { hasKey } from '../../../../base/common/types.js'; -import type { - IActiveTurn, - IRootState, - ISessionState, - ISessionSummary, - IToolCallCancelledState, - IToolCallCompletedState, - IToolCallResult, - IToolCallState, - IToolResultTextContent, - IUserMessage, +import { + SessionLifecycle, + ToolResultContentType, + type IActiveTurn, + type IRootState, + type ISessionState, + type ISessionSummary, + type IToolCallCancelledState, + type IToolCallCompletedState, + type IToolCallResult, + type IToolCallState, + type IToolResultTextContent, + type IUserMessage, } from './protocol/state.js'; // Re-export everything from the protocol state module @@ -58,7 +60,9 @@ export { type IUserMessage, type StringOrMarkdown, type URI, + AttachmentType, PolicyState, + PermissionKind, ResponsePartKind, SessionLifecycle, SessionStatus, @@ -100,7 +104,7 @@ export function getToolOutputText(result: IToolCallResult): string | undefined { } const textParts: IToolResultTextContent[] = []; for (const c of result.content) { - if (hasKey(c, { type: true }) && c.type === 'text') { + if (hasKey(c, { type: true }) && c.type === ToolResultContentType.Text) { textParts.push(c); } } @@ -122,7 +126,7 @@ export function createRootState(): IRootState { export function createSessionState(summary: ISessionSummary): ISessionState { return { summary, - lifecycle: 'creating', + lifecycle: SessionLifecycle.Creating, turns: [], activeTurn: undefined, }; diff --git a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts index ed6aa21ebcd..5412b4f608b 100644 --- a/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts +++ b/src/vs/platform/agentHost/common/state/versions/versionRegistry.ts @@ -54,6 +54,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe export const NOTIFICATION_INTRODUCED_IN: { readonly [K in INotification['type']]: number } = { 'notify/sessionAdded': 1, 'notify/sessionRemoved': 1, + 'notify/authRequired': 1, }; // ---- Runtime filtering helpers ---------------------------------------------- diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index d0667c84b85..da706baae2f 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -13,7 +13,7 @@ import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js' import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata } from '../common/agentService.js'; +import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import type { IBrowseDirectoryResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { revive } from '../../../base/common/marshalling.js'; @@ -83,8 +83,11 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { // ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ---- - setAuthToken(token: string): Promise { - return this._proxy.setAuthToken(token); + getResourceMetadata(): Promise { + return this._proxy.getResourceMetadata(); + } + authenticate(params: IAuthenticateParams): Promise { + return this._proxy.authenticate(params); } listAgents(): Promise { return this._proxy.listAgents(); diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index c05b080a7a4..b2cfa7eee0c 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -14,7 +14,7 @@ import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata } from '../common/agentService.js'; +import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; @@ -87,7 +87,17 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC clientId: this._clientId, }); this._serverSeq = result.serverSeq; - this._defaultDirectory = result.defaultDirectory; + // defaultDirectory arrives from the protocol as either a URI string + // (e.g. "file:///Users/roblou") or a serialized URI object + // ({ scheme, path, ... }). Extract just the filesystem path. + if (result.defaultDirectory) { + const dir = result.defaultDirectory; + if (typeof dir === 'string') { + this._defaultDirectory = URI.parse(dir).path; + } else { + this._defaultDirectory = URI.revive(dir).path; + } + } } /** @@ -128,10 +138,17 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } /** - * Push a GitHub auth token to the remote agent host. + * Retrieve the server's resource metadata describing auth requirements. */ - async setAuthToken(token: string): Promise { - this._sendExtensionNotification('setAuthToken', { token }); + async getResourceMetadata(): Promise { + return await this._sendExtensionRequest('getResourceMetadata') as IResourceMetadata; + } + + /** + * Authenticate with the remote agent host using a specific scheme. + */ + async authenticate(params: IAuthenticateParams): Promise { + return await this._sendExtensionRequest('authenticate', params) as IAuthenticateResult; } /** @@ -172,6 +189,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC startTime: s.createdAt, modifiedTime: s.modifiedAt, summary: s.title, + workingDirectory: typeof s.workingDirectory === 'string' ? s.workingDirectory : undefined, })); } @@ -227,13 +245,6 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._transport.send({ jsonrpc: '2.0' as const, method, params } as IProtocolMessage); } - /** Send a JSON-RPC notification for a VS Code extension method (not in the protocol spec). */ - private _sendExtensionNotification(method: string, params?: unknown): void { - // Cast: extension methods aren't in the typed protocol maps yet - // eslint-disable-next-line local/code-no-dangerous-type-assertions - this._transport.send({ jsonrpc: '2.0', method, params } as unknown as IJsonRpcResponse); - } - /** Send a typed JSON-RPC request for a protocol-defined method. */ private _sendRequest(method: M, params: ICommandMap[M]['params']): Promise { const id = this._nextRequestId++; diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index 0af8235aeba..5abe4cd03aa 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -72,7 +72,12 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt const store = new DisposableStore(); store.add(client); - store.add(this.utilityProcess.onStderr(data => this._logService.error(`[AgentHost:stderr] ${data}`))); + store.add(this.utilityProcess.onStderr(data => { + if (this._isExpectedStderr(data)) { + return; + } + this._logService.error(`[AgentHost:stderr] ${data}`); + })); store.add(toDisposable(() => { this.utilityProcess?.kill(); this.utilityProcess?.dispose(); @@ -103,4 +108,15 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt e.sender.postMessage('vscode:createAgentHostMessageChannelResult', nonce, [port]); } + + private static readonly _expectedStderrPatterns = [ + 'Most NODE_OPTIONs are not supported in packaged apps', + 'Debugger listening on ws://', + 'For help, see: https://nodejs.org/en/docs/inspector', + 'ExperimentalWarning: SQLite is an experimental feature', + ]; + + private _isExpectedStderr(data: string): boolean { + return ElectronAgentHostStarter._expectedStderrPatterns.some(pattern => data.includes(pattern)); + } } diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts index b886efeec0f..5f378e7b739 100644 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -14,20 +14,21 @@ import type { IAgentDeltaEvent, IAgentTitleChangedEvent, } from '../common/agentService.js'; -import type { - ISessionAction, - IDeltaAction, - IToolCallStartAction, - IToolCallReadyAction, - IToolCallCompleteAction, - ITurnCompleteAction, - ISessionErrorAction, - IUsageAction, - ITitleChangedAction, - IPermissionRequestAction, - IReasoningAction, +import { + ActionType, + type ISessionAction, + type IDeltaAction, + type IToolCallStartAction, + type IToolCallReadyAction, + type IToolCallCompleteAction, + type ITurnCompleteAction, + type ISessionErrorAction, + type IUsageAction, + type ITitleChangedAction, + type IPermissionRequestAction, + type IReasoningAction, } from '../common/state/sessionActions.js'; -import type { URI } from '../common/state/sessionState.js'; +import { ToolCallConfirmationReason, ToolResultContentType, type URI } from '../common/state/sessionState.js'; /** * Maps a flat {@link IAgentProgressEvent} from the agent host into @@ -41,7 +42,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U switch (event.type) { case 'delta': return { - type: 'session/delta', + type: ActionType.SessionDelta, session, turnId, content: (event as IAgentDeltaEvent).content, @@ -53,7 +54,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U // (params complete → running with auto-confirm) as a pair. const e = event as IAgentToolStartEvent; const startAction: IToolCallStartAction = { - type: 'session/toolCallStart', + type: ActionType.SessionToolCallStart, session, turnId, toolCallId: e.toolCallId, @@ -62,13 +63,13 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U _meta: { toolKind: e.toolKind, language: e.language }, }; const readyAction: IToolCallReadyAction = { - type: 'session/toolCallReady', + type: ActionType.SessionToolCallReady, session, turnId, toolCallId: e.toolCallId, invocationMessage: e.invocationMessage, toolInput: e.toolInput, - confirmed: 'not-needed', + confirmed: ToolCallConfirmationReason.NotNeeded, }; return [startAction, readyAction]; } @@ -76,14 +77,14 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'tool_complete': { const e = event as IAgentToolCompleteEvent; return { - type: 'session/toolCallComplete', + type: ActionType.SessionToolCallComplete, session, turnId, toolCallId: e.toolCallId, result: { success: e.success, pastTenseMessage: e.pastTenseMessage, - content: e.toolOutput !== undefined ? [{ type: 'text' as const, text: e.toolOutput }] : undefined, + content: e.toolOutput !== undefined ? [{ type: ToolResultContentType.Text, text: e.toolOutput }] : undefined, error: e.error, }, } satisfies IToolCallCompleteAction; @@ -91,7 +92,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'idle': return { - type: 'session/turnComplete', + type: ActionType.SessionTurnComplete, session, turnId, } satisfies ITurnCompleteAction; @@ -99,7 +100,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'error': { const e = event as IAgentErrorEvent; return { - type: 'session/error', + type: ActionType.SessionError, session, turnId, error: { @@ -113,7 +114,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'usage': { const e = event as IAgentUsageEvent; return { - type: 'session/usage', + type: ActionType.SessionUsage, session, turnId, usage: { @@ -127,7 +128,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'title_changed': return { - type: 'session/titleChanged', + type: ActionType.SessionTitleChanged, session, title: (event as IAgentTitleChangedEvent).title, } satisfies ITitleChangedAction; @@ -135,7 +136,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'permission_request': { const e = event as IAgentPermissionRequestEvent; return { - type: 'session/permissionRequest', + type: ActionType.SessionPermissionRequest, session, turnId, request: { @@ -154,7 +155,7 @@ export function mapProgressEventToActions(event: IAgentProgressEvent, session: U case 'reasoning': return { - type: 'session/reasoning', + type: ActionType.SessionReasoning, session, turnId, content: (event as IAgentReasoningEvent).content, diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 498155d598b..20df9b72167 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -145,10 +145,15 @@ async function startWebSocketServer(agentService: AgentService, logService: ILog status: SessionStatus.Idle, createdAt: s.startTime, modifiedAt: s.modifiedTime, + workingDirectory: s.workingDirectory, })); }, - handleSetAuthToken(token) { - agentService.setAuthToken(token); + + handleGetResourceMetadata() { + return agentService.getResourceMetadataSync(); + }, + async handleAuthenticate(params) { + return agentService.authenticate(params); }, handleBrowseDirectory(uri) { return agentService.browseDirectory(URI.parse(uri)); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 8c1fce8fdd3..7dad86b7ad3 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -7,10 +7,10 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { observableValue } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; -import { ILogService } from '../../log/common/log.js'; import { IFileService } from '../../files/common/files.js'; -import { AgentProvider, IAgentCreateSessionConfig, IAgent, IAgentService, IAgentSessionMetadata, AgentSession, IAgentDescriptor } from '../common/agentService.js'; -import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentDescriptor, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { ActionType, IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import type { IBrowseDirectoryResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { SessionStatus, type ISessionSummary } from '../common/state/sessionState.js'; import { AgentSideEffects } from './agentSideEffects.js'; @@ -89,13 +89,28 @@ export class AgentService extends Disposable implements IAgentService { return [...this._providers.values()].map(p => p.getDescriptor()); } - async setAuthToken(token: string): Promise { - this._logService.trace('[AgentService] setAuthToken called'); - const promises: Promise[] = []; + async getResourceMetadata(): Promise { + const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); + return { resources }; + } + + getResourceMetadataSync(): IResourceMetadata { + const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); + return { resources }; + } + + async authenticate(params: IAuthenticateParams): Promise { + this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`); for (const provider of this._providers.values()) { - promises.push(provider.setAuthToken(token)); + const resources = provider.getProtectedResources(); + if (resources.some(r => r.resource === params.resource)) { + const accepted = await provider.authenticate(params.resource, params.token); + if (accepted) { + return { authenticated: true }; + } + } } - await Promise.all(promises); + return { authenticated: false }; } // ---- session management ------------------------------------------------- @@ -138,9 +153,10 @@ export class AgentService extends Disposable implements IAgentService { status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + workingDirectory: config?.workingDirectory, }; this._stateManager.createSession(summary); - this._stateManager.dispatchServerAction({ type: 'session/ready', session: session.toString() }); + this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: session.toString() }); return session; } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 73adeba5a01..efe3e9c8276 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -3,18 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as os from 'os'; import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { autorun, IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; -import * as os from 'os'; import { IFileService } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { IAgent, IAgentAttachment } from '../common/agentService.js'; -import type { ISessionAction } from '../common/state/sessionActions.js'; -import { IBrowseDirectoryResult, ICreateSessionParams, AHP_PROVIDER_NOT_FOUND, JSON_RPC_INTERNAL_ERROR, ProtocolError, IDirectoryEntry } from '../common/state/sessionProtocol.js'; +import { IAgent, IAgentAttachment, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { ActionType, ISessionAction } from '../common/state/sessionActions.js'; +import { AHP_PROVIDER_NOT_FOUND, IBrowseDirectoryResult, ICreateSessionParams, IDirectoryEntry, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../common/state/sessionProtocol.js'; import { + SessionStatus, type ISessionModelInfo, - SessionStatus, type ISessionSummary, type URI as ProtocolURI, + type ISessionSummary, type URI as ProtocolURI, } from '../common/state/sessionState.js'; import { mapProgressEventToActions } from './agentEventMapper.js'; import type { IProtocolSideEffectHandler } from './protocolServerHandler.js'; @@ -79,7 +80,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH } return { provider: d.provider, displayName: d.displayName, description: d.description, models }; })); - this._stateManager.dispatchServerAction({ type: 'root/agentsChanged', agents: infos }); + this._stateManager.dispatchServerAction({ type: ActionType.RootAgentsChanged, agents: infos }); } // ---- Agent registration ------------------------------------------------- @@ -119,11 +120,11 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH handleAction(action: ISessionAction): void { switch (action.type) { - case 'session/turnStarted': { + case ActionType.SessionTurnStarted: { const agent = this._options.getAgent(action.session); if (!agent) { this._stateManager.dispatchServerAction({ - type: 'session/error', + type: ActionType.SessionError, session: action.session, turnId: action.turnId, error: { errorType: 'noAgent', message: 'No agent found for session' }, @@ -138,7 +139,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH agent.sendMessage(URI.parse(action.session), action.userMessage.text, attachments).catch(err => { this._logService.error('[AgentSideEffects] sendMessage failed', err); this._stateManager.dispatchServerAction({ - type: 'session/error', + type: ActionType.SessionError, session: action.session, turnId: action.turnId, error: { errorType: 'sendFailed', message: String(err) }, @@ -146,7 +147,7 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH }); break; } - case 'session/permissionResolved': { + case ActionType.SessionPermissionResolved: { const providerId = this._pendingPermissions.get(action.requestId); if (providerId) { this._pendingPermissions.delete(action.requestId); @@ -157,14 +158,14 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH } break; } - case 'session/turnCancelled': { + case ActionType.SessionTurnCancelled: { const agent = this._options.getAgent(action.session); agent?.abortSession(URI.parse(action.session)).catch(err => { this._logService.error('[AgentSideEffects] abortSession failed', err); }); break; } - case 'session/modelChanged': { + case ActionType.SessionModelChanged: { const agent = this._options.getAgent(action.session); agent?.changeModel?.(URI.parse(action.session), action.model).catch(err => { this._logService.error('[AgentSideEffects] changeModel failed', err); @@ -198,9 +199,10 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH status: SessionStatus.Idle, createdAt: Date.now(), modifiedAt: Date.now(), + workingDirectory: command.workingDirectory, }; this._stateManager.createSession(summary); - this._stateManager.dispatchServerAction({ type: 'session/ready', session }); + this._stateManager.dispatchServerAction({ type: ActionType.SessionReady, session }); } handleDisposeSession(session: ProtocolURI): void { @@ -228,12 +230,22 @@ export class AgentSideEffects extends Disposable implements IProtocolSideEffectH return allSessions; } - handleSetAuthToken(token: string): void { + handleGetResourceMetadata(): IResourceMetadata { + const resources = this._options.agents.get().flatMap(a => a.getProtectedResources()); + return { resources }; + } + + async handleAuthenticate(params: IAuthenticateParams): Promise { for (const agent of this._options.agents.get()) { - agent.setAuthToken(token).catch(err => { - this._logService.error('[AgentSideEffects] setAuthToken failed', err); - }); + const resources = agent.getProtectedResources(); + if (resources.some(r => r.resource === params.resource)) { + const accepted = await agent.authenticate(params.resource, params.token); + if (accepted) { + return { authenticated: true }; + } + } } + return { authenticated: false }; } async handleBrowseDirectory(uri: ProtocolURI): Promise { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 6f8ef0cd5b9..95c0d966482 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -4,18 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { CopilotClient, CopilotSession, type SessionEvent, type SessionEventPayload } from '@github/copilot-sdk'; +import { rgPath } from '@vscode/ripgrep'; import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../base/common/network.js'; +import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { delimiter, dirname } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; -import { rgPath } from '@vscode/ripgrep'; import { generateUuid } from '../../../../base/common/uuid.js'; import { ILogService } from '../../../log/common/log.js'; -import { IAgentCreateSessionConfig, IAgentModelInfo, IAgentProgressEvent, IAgentMessageEvent, IAgent, IAgentSessionMetadata, IAgentToolStartEvent, IAgentToolCompleteEvent, AgentSession, IAgentDescriptor, IAgentAttachment } from '../../common/agentService.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isHiddenTool } from './copilotToolDisplay.js'; +import { AgentSession, IAgent, IAgentAttachment, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentModelInfo, IAgentProgressEvent, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { PermissionKind, type PolicyState } from '../../common/state/sessionState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; +import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isHiddenTool } from './copilotToolDisplay.js'; function tryStringify(value: unknown): string | undefined { try { @@ -62,7 +64,19 @@ export class CopilotAgent extends Disposable implements IAgent { }; } - async setAuthToken(token: string): Promise { + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + return [{ + resource: 'https://api.github.com', + resource_name: 'GitHub Copilot', + authorization_servers: ['https://github.com/login/oauth'], + scopes_supported: ['read:user', 'user:email'], + }]; + } + + async authenticate(resource: string, token: string): Promise { + if (resource !== 'https://api.github.com') { + return false; + } const tokenChanged = this._githubToken !== token; this._githubToken = token; this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); @@ -73,6 +87,7 @@ export class CopilotAgent extends Disposable implements IAgent { this._clientStarting = undefined; await client.stop(); } + return true; } // ---- client lifecycle --------------------------------------------------- @@ -141,11 +156,12 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.info('[Copilot] Listing sessions...'); const client = await this._ensureClient(); const sessions = await client.listSessions(); - const result = sessions.map(s => ({ + const result: IAgentSessionMetadata[] = sessions.map(s => ({ session: AgentSession.uri(this.id, s.sessionId), startTime: s.startTime.getTime(), modifiedTime: s.modifiedTime.getTime(), summary: s.summary, + workingDirectory: typeof s.context?.cwd === 'string' ? s.context.cwd : undefined, })); this._logService.info(`[Copilot] Found ${result.length} sessions`); return result; @@ -164,7 +180,7 @@ export class CopilotAgent extends Disposable implements IAgent { supportsReasoningEffort: m.capabilities.supports.reasoningEffort, supportedReasoningEfforts: m.supportedReasoningEfforts, defaultReasoningEffort: m.defaultReasoningEffort, - policyState: m.policy?.state, + policyState: m.policy?.state as PolicyState | undefined, billingMultiplier: m.billing?.multiplier, })); this._logService.info(`[Copilot] Found ${result.length} models`); @@ -304,9 +320,9 @@ export class CopilotAgent extends Disposable implements IAgent { const deferred = new DeferredPromise(); this._pendingPermissions.set(requestId, { sessionId: invocation.sessionId, deferred }); - const permissionKind = (['shell', 'write', 'mcp', 'read', 'url'] as const).includes(request.kind as 'shell') - ? request.kind as 'shell' | 'write' | 'mcp' | 'read' | 'url' - : 'read'; // Treat unknown kinds as read (safest default) + const permissionKind = ([PermissionKind.Shell, PermissionKind.Write, PermissionKind.Mcp, PermissionKind.Read, PermissionKind.Url] as const).includes(request.kind as PermissionKind) + ? request.kind as PermissionKind + : PermissionKind.Read; // Treat unknown kinds as read (safest default) // Fire the event so the renderer can handle it this._onDidSessionProgress.fire({ diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index a2fb3c2d748..a7e1ecc5b59 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; +import type { IAgentDescriptor, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { ICommandMap } from '../common/state/protocol/messages.js'; import { IActionEnvelope, INotification, isSessionAction, type ISessionAction } from '../common/state/sessionActions.js'; import { isActionKnownToVersion, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; @@ -21,7 +22,6 @@ import { type IInitializeParams, type IJsonRpcResponse, type IReconnectParams, - type ISetAuthTokenParams, type IStateSnapshot, } from '../common/state/sessionProtocol.js'; import { ROOT_STATE_URI, type ISessionSummary, type URI } from '../common/state/sessionState.js'; @@ -37,15 +37,24 @@ function jsonRpcSuccess(id: number, result: unknown): IJsonRpcResponse { } /** Build a JSON-RPC error response suitable for transport.send(). */ -function jsonRpcError(id: number, code: number, message: string): IJsonRpcResponse { - return { jsonrpc: '2.0', id, error: { code, message } }; +function jsonRpcError(id: number, code: number, message: string, data?: unknown): IJsonRpcResponse { + return { jsonrpc: '2.0', id, error: { code, message, ...(data !== undefined ? { data } : {}) } }; +} + +/** Build a JSON-RPC error response from an unknown thrown value, preserving {@link ProtocolError} fields. */ +function jsonRpcErrorFrom(id: number, err: unknown): IJsonRpcResponse { + if (err instanceof ProtocolError) { + return jsonRpcError(id, err.code, err.message, err.data); + } + const message = err instanceof Error ? (err.stack ?? err.message) : String(err); + return jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, message); } /** * Methods handled by the request dispatcher. Excludes `initialize` and * `reconnect` which are handled during the handshake phase. */ -type RequestMethod = Exclude; +type RequestMethod = Exclude; /** * Typed handler map: each key is a request method, each value is a handler @@ -119,9 +128,7 @@ export class ProtocolServerHandler extends Disposable { client = result.client; transport.send(jsonRpcSuccess(msg.id, result.response)); } catch (err) { - const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; - const message = err instanceof Error ? err.message : String(err); - transport.send(jsonRpcError(msg.id, code, message)); + transport.send(jsonRpcErrorFrom(msg.id, err)); } return; } @@ -131,9 +138,7 @@ export class ProtocolServerHandler extends Disposable { client = result.client; transport.send(jsonRpcSuccess(msg.id, result.response)); } catch (err) { - const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; - const message = err instanceof Error ? err.message : String(err); - transport.send(jsonRpcError(msg.id, code, message)); + transport.send(jsonRpcErrorFrom(msg.id, err)); } return; } @@ -160,15 +165,6 @@ export class ProtocolServerHandler extends Disposable { this._sideEffectHandler.handleAction(action); } break; - default: { - // VS Code extension: setAuthToken (not part of the protocol spec) - const method = msg.method as string; - if (method === 'setAuthToken') { - const p = msg.params as unknown as ISetAuthTokenParams; - this._sideEffectHandler.handleSetAuthToken(p.token); - } - break; - } } } // Responses from the client (if any) are ignored on the server side. @@ -336,23 +332,52 @@ export class ProtocolServerHandler extends Disposable { private _handleRequest(client: IConnectedClient, method: string, params: unknown, id: number): void { const handler = this._requestHandlers.hasOwnProperty(method) ? this._requestHandlers[method as RequestMethod] : undefined; - if (!handler) { - client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, `Unknown method: ${method}`)); + if (handler) { + (handler as (client: IConnectedClient, params: unknown) => Promise)(client, params).then(result => { + this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); + client.transport.send(jsonRpcSuccess(id, result ?? null)); + }).catch(err => { + this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); + client.transport.send(jsonRpcErrorFrom(id, err)); + }); return; } - (handler as (client: IConnectedClient, params: unknown) => Promise)(client, params).then(result => { - this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); - client.transport.send(jsonRpcSuccess(id, result ?? null)); - }).catch(err => { - this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); - const code = err instanceof ProtocolError ? err.code : JSON_RPC_INTERNAL_ERROR; - const message = err instanceof ProtocolError - ? err.message - : err instanceof Error && err.stack - ? err.stack - : String(err?.message ?? err); - client.transport.send(jsonRpcError(id, code, message)); - }); + + // VS Code extension methods (not in the typed protocol maps yet) + const extensionResult = this._handleExtensionRequest(method, params); + if (extensionResult) { + extensionResult.then(result => { + client.transport.send(jsonRpcSuccess(id, result ?? null)); + }).catch(err => { + this._logService.error(`[ProtocolServer] Extension request '${method}' failed`, err); + client.transport.send(jsonRpcErrorFrom(id, err)); + }); + return; + } + + client.transport.send(jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, `Unknown method: ${method}`)); + } + + /** + * Handle VS Code extension methods that are not yet part of the typed + * protocol. Returns a Promise if the method was recognized, undefined + * otherwise. + */ + private _handleExtensionRequest(method: string, params: unknown): Promise | undefined { + switch (method) { + case 'getResourceMetadata': + return Promise.resolve(this._sideEffectHandler.handleGetResourceMetadata()); + case 'authenticate': + return this._sideEffectHandler.handleAuthenticate(params as IAuthenticateParams); + case 'refreshModels': + return this._sideEffectHandler.handleRefreshModels?.() ?? Promise.resolve(null); + case 'listAgents': + return Promise.resolve(this._sideEffectHandler.handleListAgents?.() ?? []); + case 'shutdown': + return this._sideEffectHandler.handleShutdown?.() ?? Promise.resolve(null); + default: + return undefined; + } } // ---- Broadcasting ------------------------------------------------------- @@ -407,8 +432,15 @@ export interface IProtocolSideEffectHandler { handleCreateSession(command: ICreateSessionParams): Promise; handleDisposeSession(session: URI): void; handleListSessions(): Promise; - handleSetAuthToken(token: string): void; + handleGetResourceMetadata(): IResourceMetadata; + handleAuthenticate(params: IAuthenticateParams): Promise; handleBrowseDirectory(uri: URI): Promise; /** Returns the server's default browsing directory, if available. */ getDefaultDirectory?(): URI; + /** Refresh models from all providers (VS Code extension method). */ + handleRefreshModels?(): Promise; + /** List agent descriptors (VS Code extension method). */ + handleListAgents?(): IAgentDescriptor[]; + /** Shut down all providers (VS Code extension method). */ + handleShutdown?(): Promise; } diff --git a/src/vs/platform/agentHost/node/sessionStateManager.ts b/src/vs/platform/agentHost/node/sessionStateManager.ts index 02561fc0948..c9ad9235595 100644 --- a/src/vs/platform/agentHost/node/sessionStateManager.ts +++ b/src/vs/platform/agentHost/node/sessionStateManager.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; -import { IActionEnvelope, IActionOrigin, INotification, ISessionAction, IRootAction, IStateAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js'; +import { ActionType, NotificationType, IActionEnvelope, IActionOrigin, INotification, ISessionAction, IRootAction, IStateAction, isRootAction, isSessionAction } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, type IRootState, type ISessionState, type ISessionSummary, type URI, ROOT_STATE_URI } from '../common/state/sessionState.js'; @@ -40,6 +40,8 @@ export class SessionStateManager extends Disposable { super(); this._rootState = createRootState(); } + private readonly _log = (msg: string) => this._logService.warn(`[SessionStateManager] ${msg}`); + get hasActiveSessions(): boolean { return this._activeTurnToSession.size > 0; } @@ -105,7 +107,7 @@ export class SessionStateManager extends Disposable { this._logService.trace(`[SessionStateManager] Created session: ${key}`); this._onDidEmitNotification.fire({ - type: 'notify/sessionAdded', + type: NotificationType.SessionAdded, summary, }); @@ -130,7 +132,7 @@ export class SessionStateManager extends Disposable { this._logService.trace(`[SessionStateManager] Removed session: ${session}`); this._onDidEmitNotification.fire({ - type: 'notify/sessionRemoved', + type: NotificationType.SessionRemoved, session, }); } @@ -173,7 +175,7 @@ export class SessionStateManager extends Disposable { let resultingState: unknown = undefined; // Apply to state if (isRootAction(action)) { - this._rootState = rootReducer(this._rootState, action as IRootAction); + this._rootState = rootReducer(this._rootState, action as IRootAction, this._log); resultingState = this._rootState; } @@ -182,20 +184,20 @@ export class SessionStateManager extends Disposable { const key = sessionAction.session; const state = this._sessionStates.get(key); if (state) { - const newState = sessionReducer(state, sessionAction); + const newState = sessionReducer(state, sessionAction, this._log); this._sessionStates.set(key, newState); // Track active turn for turn lifecycle - if (sessionAction.type === 'session/turnStarted') { + if (sessionAction.type === ActionType.SessionTurnStarted) { this._activeTurnToSession.set(sessionAction.turnId, key); - this.dispatchServerAction({ type: 'root/activeSessionsChanged', activeSessions: this._activeTurnToSession.size }); + this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size }); } else if ( - sessionAction.type === 'session/turnComplete' || - sessionAction.type === 'session/turnCancelled' || - sessionAction.type === 'session/error' + sessionAction.type === ActionType.SessionTurnComplete || + sessionAction.type === ActionType.SessionTurnCancelled || + sessionAction.type === ActionType.SessionError ) { this._activeTurnToSession.delete(sessionAction.turnId); - this.dispatchServerAction({ type: 'root/activeSessionsChanged', activeSessions: this._activeTurnToSession.size }); + this.dispatchServerAction({ type: ActionType.RootActiveSessionsChanged, activeSessions: this._activeTurnToSession.size }); } resultingState = newState; diff --git a/src/vs/platform/agentHost/test/auth-rework.md b/src/vs/platform/agentHost/test/auth-rework.md new file mode 100644 index 00000000000..4533c3b4e59 --- /dev/null +++ b/src/vs/platform/agentHost/test/auth-rework.md @@ -0,0 +1,454 @@ +# Auth Rework: Standards-Based Authentication for the Agent Host Protocol + +## Problem + +The current authentication mechanism is imperative and VS Code-specific: + +1. The renderer discovers agents via `listAgents()` and checks `IAgentDescriptor.requiresAuth`. +2. It obtains a GitHub OAuth token from VS Code's built-in authentication service. +3. It pushes the token via `setAuthToken(token)` — a fire-and-forget JSON-RPC notification. +4. The agent host fans the token out to all registered `IAgent` providers. + +This couples the agent host to VS Code internals. An external client (CLI tool, web app, another editor) connecting over WebSocket has no way to know _what_ authentication is required, _where_ to get a token, or _what scopes_ are needed. The client must have out-of-band knowledge that "this server needs a GitHub OAuth token." + +## Design Goals + +- **Self-describing**: The server declares its auth requirements so arbitrary clients can discover them without prior knowledge of the server's internals. +- **Standards-aligned**: Use the semantics and vocabulary of RFC 6750 (Bearer Token Usage) and RFC 9728 (OAuth 2.0 Protected Resource Metadata) adapted for JSON-RPC. +- **Challenge-on-failure**: When auth is missing or invalid, the server responds with a structured challenge (like `WWW-Authenticate`) that tells the client exactly what to do. +- **Transport-agnostic**: Works over WebSocket JSON-RPC and MessagePort IPC alike. +- **Multi-provider**: Supports multiple independent auth requirements (e.g. GitHub + a future enterprise IdP) each with their own scopes and authorization servers. +- **Non-breaking migration**: Can coexist with `setAuthToken` during a transition period. + +## Relevant Standards + +### RFC 6750 — Bearer Token Usage + +Defines how bearer tokens are transmitted (`Authorization: Bearer `) and how servers challenge clients when auth is missing or invalid: + +``` +WWW-Authenticate: Bearer realm="example", + error="invalid_token", + error_description="The access token expired" +``` + +Key error codes: `invalid_request`, `invalid_token`, `insufficient_scope`. + +### RFC 9728 — OAuth 2.0 Protected Resource Metadata + +Defines a metadata document that a protected resource publishes to describe itself: + +```json +{ + "resource": "https://resource.example.com", + "authorization_servers": ["https://as.example.com"], + "scopes_supported": ["profile", "email"], + "bearer_methods_supported": ["header"] +} +``` + +Clients discover this metadata either via a well-known URL or via the `resource_metadata` parameter in a `WWW-Authenticate` challenge. This tells the client _where_ to get a token and _what scopes_ to request. + +## Proposed Design + +### Overview + +The authentication flow has three phases, mirroring the HTTP flow from RFC 9728 §5: + +``` +┌─────────┐ ┌──────────────┐ ┌─────────────────┐ +│ Client │ │ Agent Host │ │ Authorization │ +│ │ │ (Server) │ │ Server │ +└────┬─────┘ └──────┬───────┘ └────────┬────────┘ + │ │ │ + │ 1. initialize │ │ + │ ───────────────────────────────────> │ │ + │ │ │ + │ 2. initialize result │ │ + │ { auth: [{ scheme, resource, │ │ + │ authorization_servers, │ │ + │ scopes_supported }] } │ │ + │ <─────────────────────────────────── │ │ + │ │ │ + │ 3. Obtain token from AS │ │ + │ ─────────────────────────────────────────────────────────────────> │ + │ │ │ + │ 4. Token │ │ + │ <───────────────────────────────────────────────────────────────── │ + │ │ │ + │ 5. authenticate { scheme, token } │ │ + │ ───────────────────────────────────> │ │ + │ │ │ + │ 6. { authenticated: true } │ │ + │ <─────────────────────────────────── │ │ + │ │ │ + │ 7. createSession / other commands │ │ + │ ───────────────────────────────────> │ │ +``` + +### Phase 1: Discovery (in `initialize` response) + +The `initialize` result is extended with a `resourceMetadata` field, modeled on RFC 9728 §2: + +```typescript +interface IInitializeResult { + protocolVersion: number; + serverSeq: number; + snapshots: ISnapshot[]; + defaultDirectory?: URI; + + /** RFC 9728-style resource metadata describing auth requirements. */ + resourceMetadata?: IResourceMetadata; +} + +/** + * Describes the agent host as an OAuth 2.0 protected resource. + * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). + */ +interface IResourceMetadata { + /** + * Identifier for this resource (the agent host). + * Analogous to RFC 9728 `resource`. + */ + resource: string; + + /** + * Independent auth requirements. Each entry describes one + * authentication scheme the server accepts. A client must + * satisfy at least one to use authenticated features. + */ + authSchemes: IAuthScheme[]; +} + +/** + * A single authentication scheme the server accepts. + */ +interface IAuthScheme { + /** + * The auth scheme name. Initially only "bearer" (RFC 6750). + * Future schemes (e.g. "dpop", "device_code") can be added. + */ + scheme: 'bearer'; + + /** + * An opaque identifier for this auth requirement, used to + * correlate `authenticate` calls and challenges. Allows the + * server to require multiple independent tokens (e.g. one + * per agent provider). + * + * Example: "github" for GitHub Copilot auth. + */ + id: string; + + /** + * Human-readable label for display in auth UIs. + * Analogous to RFC 9728 `resource_name`. + */ + label: string; + + /** + * Authorization server issuer identifiers (RFC 8414). + * Tells the client where to obtain tokens. + * Analogous to RFC 9728 `authorization_servers`. + * + * Example: ["https://github.com/login/oauth"] + */ + authorizationServers: string[]; + + /** + * OAuth scopes the server needs. + * Analogous to RFC 9728 `scopes_supported`. + * + * Example: ["read:user", "user:email", "repo", "workflow"] + */ + scopesSupported?: string[]; + + /** + * Whether this auth requirement is mandatory for any + * functionality, or only for specific agents/features. + */ + required?: boolean; +} +``` + +**Why in `initialize`?** RFC 9728 publishes metadata at a well-known URL. In our JSON-RPC world, the `initialize` handshake _is_ the well-known endpoint — it's the first thing every client calls, and it's already where we exchange capabilities. This avoids an extra round-trip and keeps the discovery atomic. + +### Phase 2: Token Delivery (`authenticate` command) + +Replace the fire-and-forget `setAuthToken` notification with a proper JSON-RPC **request** so the client gets confirmation: + +```typescript +/** + * Client → Server request to authenticate. + * Analogous to sending `Authorization: Bearer ` (RFC 6750 §2.1). + */ +interface IAuthenticateParams { + /** + * The auth scheme identifier from the server's resourceMetadata. + * Correlates to IAuthScheme.id. + */ + schemeId: string; + + /** The scheme type (initially always "bearer"). */ + scheme: 'bearer'; + + /** The bearer token value (RFC 6750). */ + token: string; +} + +interface IAuthenticateResult { + /** Whether the token was accepted. */ + authenticated: boolean; +} +``` + +This is a **request** (not a notification) so: +- The client knows immediately if the token was accepted or rejected. +- The server can validate the token before returning success. +- Errors use structured challenges (see Phase 3). + +The client can call `authenticate` multiple times (e.g. when a token is refreshed), and can authenticate for multiple scheme IDs independently. + +### Phase 3: Challenges on Failure + +When a command fails because authentication is missing or invalid, the server returns a JSON-RPC error with structured challenge data in the `data` field, modeled on RFC 6750 §3: + +```typescript +/** + * JSON-RPC error data for authentication failures. + * Modeled on RFC 6750 WWW-Authenticate challenge parameters. + */ +interface IAuthChallenge { + /** The scheme ID that needs (re-)authentication. */ + schemeId: string; + + /** RFC 6750 §3.1 error code. */ + error: 'invalid_request' | 'invalid_token' | 'insufficient_scope'; + + /** Human-readable error description (RFC 6750 §3 error_description). */ + errorDescription?: string; + + /** Required scopes, if the error is insufficient_scope (RFC 6750 §3 scope). */ + scope?: string; +} +``` + +This is returned as the `data` payload of a JSON-RPC error response: + +```json +{ + "jsonrpc": "2.0", + "id": 5, + "error": { + "code": -32007, + "message": "Authentication required", + "data": { + "challenges": [ + { + "schemeId": "github", + "error": "invalid_token", + "errorDescription": "The access token expired" + } + ] + } + } +} +``` + +A dedicated error code (`-32007 AHP_AUTH_REQUIRED`) signals this is an auth error so clients can handle it programmatically without parsing the message string. + +### Phase 4: Auth State Notifications + +The server pushes auth state changes via notifications so clients know when auth expires or the required scopes change: + +```typescript +/** + * Server → Client notification when auth state changes. + */ +interface IAuthStateNotification { + type: 'notify/authRequired'; + + /** The scheme ID whose auth state changed. */ + schemeId: string; + + /** The new state. */ + state: 'authenticated' | 'expired' | 'revoked' | 'required'; + + /** Optional challenge with details (e.g. new scopes needed). */ + challenge?: IAuthChallenge; +} +``` + +This replaces the implicit "push a token whenever you see an account change" model with an explicit server-driven signal. + +## Concrete Example: GitHub Copilot Auth + +### Server-side (CopilotAgent) + +When the Copilot agent registers, it publishes an auth scheme: + +```typescript +// In CopilotAgent.getAuthSchemes(): +[{ + scheme: 'bearer', + id: 'github', + label: 'GitHub', + authorizationServers: ['https://github.com/login/oauth'], + scopesSupported: ['read:user', 'user:email'], + required: true, +}] +``` + +The agent host aggregates auth schemes from all agents into `IInitializeResult.resourceMetadata`. + +### Client-side (VS Code renderer) + +```typescript +// After initialize: +const metadata = initResult.resourceMetadata; +if (metadata) { + for (const scheme of metadata.authSchemes) { + if (scheme.scheme === 'bearer' && scheme.authorizationServers.some( + as => as.includes('github.com') + )) { + // We know how to handle GitHub auth + const token = await this._getGitHubToken(scheme.scopesSupported); + await agentHostService.authenticate({ + schemeId: scheme.id, + scheme: 'bearer', + token, + }); + } + } +} +``` + +### Client-side (generic external client) + +A CLI tool connecting over WebSocket: + +```typescript +const ws = new WebSocket('ws://localhost:3000'); +const initResult = await rpc.request('initialize', { protocolVersion: 1, clientId: 'cli-1' }); + +for (const scheme of initResult.resourceMetadata?.authSchemes ?? []) { + if (scheme.scheme === 'bearer') { + console.log(`Auth required: ${scheme.label}`); + console.log(`Get a token from: ${scheme.authorizationServers[0]}`); + console.log(`Scopes: ${scheme.scopesSupported?.join(', ')}`); + + // Client can use any OAuth library to get the token + const token = await doOAuthFlow(scheme.authorizationServers[0], scheme.scopesSupported); + await rpc.request('authenticate', { schemeId: scheme.id, scheme: 'bearer', token }); + } +} +``` + +## Protocol Changes Summary + +### New JSON-RPC request: `authenticate` + +| Direction | Type | Params | Result | +|---|---|---|---| +| Client → Server | Request | `IAuthenticateParams` | `IAuthenticateResult` | + +### New JSON-RPC error code + +| Code | Name | When | +|---|---|---| +| `-32007` | `AHP_AUTH_REQUIRED` | A command failed because auth is missing or invalid | + +### Extended: `initialize` result + +| Field | Type | Description | +|---|---|---| +| `resourceMetadata` | `IResourceMetadata` | Optional. Auth and resource information. | + +### New notification + +| Type | Direction | When | +|---|---|---| +| `notify/authRequired` | Server → Client | Auth state changed (expired, revoked, new requirements) | + +### Deprecated + +| Item | Replacement | Migration | +|---|---|---| +| `setAuthToken` notification | `authenticate` request | Keep accepting `setAuthToken` for one version, log deprecation | +| `IAgentDescriptor.requiresAuth` | `IResourceMetadata.authSchemes` | Derive from `authSchemes` during transition | + +## Interface Changes in `agentService.ts` + +### `IAgentService` + +```diff + interface IAgentService { +- setAuthToken(token: string): Promise; ++ authenticate(params: IAuthenticateParams): Promise; + } +``` + +### `IAgent` + +```diff + interface IAgent { +- setAuthToken(token: string): Promise; ++ /** Declare auth schemes this agent requires. */ ++ getAuthSchemes(): IAuthScheme[]; ++ /** Authenticate with a specific scheme. Returns true if accepted. */ ++ authenticate(schemeId: string, token: string): Promise; + } +``` + +### `IAgentDescriptor` + +```diff + interface IAgentDescriptor { + readonly provider: AgentProvider; + readonly displayName: string; + readonly description: string; +- readonly requiresAuth: boolean; + } +``` + +`requiresAuth` is removed — clients discover auth requirements from `IResourceMetadata` instead of per-agent descriptors. + +## Design Decisions + +### Why not `WWW-Authenticate` headers literally? + +We're not using HTTP. Embedding RFC 6750's string-encoded header format in JSON-RPC would be awkward. Instead, we use JSON-native equivalents with the same semantics: `IAuthChallenge` mirrors the `WWW-Authenticate` parameters, and `IResourceMetadata` mirrors RFC 9728's metadata document. + +### Why in `initialize` and not a separate `getResourceMetadata` command? + +Fewer round-trips. Every client calls `initialize` first — embedding auth requirements there means the client knows what auth is needed from the very first response. A separate command would add latency and complexity for zero benefit, since the metadata is small and always needed. + +### Why `schemeId` and not just the `scheme` name? + +A server might need multiple bearer tokens from different authorization servers (e.g. GitHub + an enterprise IdP). The `schemeId` lets the client and server correlate tokens to specific requirements. It also makes `authenticate` calls idempotent and unambiguous. + +### Why a request instead of a notification for `authenticate`? + +The current `setAuthToken` is fire-and-forget — the client has no idea if the token was accepted, expired, or for the wrong provider. Making `authenticate` a request with a response lets the client react immediately (retry with different scopes, prompt the user, etc.). + +### What about Device Code / OAuth flows that the server drives? + +This proposal covers the "client already has a token" case (RFC 6750 bearer). For server-driven flows (device code, authorization code with redirect), the `authorizationServers` metadata tells the client which AS to talk to. The actual OAuth flow is client-side — the server just declares requirements. + +A future extension could add an `IAuthScheme` with `scheme: 'device_code'` that includes a device authorization endpoint, letting the server guide the client through a device flow. This is out of scope for the initial implementation. + +## Migration Plan + +1. **Phase A**: Add `resourceMetadata` to `IInitializeResult` and the `authenticate` command. Keep `setAuthToken` working as-is. +2. **Phase B**: Update VS Code renderer to use `authenticate` instead of `setAuthToken`. External clients can start using the new flow. +3. **Phase C**: Remove `setAuthToken`, `requiresAuth`, and the old imperative push model. Bump protocol version. + +## Open Questions + +1. **Token validation**: Should the server validate tokens eagerly on `authenticate` (e.g. call a GitHub API endpoint), or defer validation to when a command actually needs it? Eager validation gives better error messages; deferred is simpler and avoids extra network calls. + +2. **Per-agent vs. global auth**: The current design has one `resourceMetadata` for the whole server. Should auth schemes be per-agent-provider instead? Per-agent gives finer control (e.g. "Copilot needs GitHub, MockAgent needs nothing") but complicates the protocol. The current proposal uses global metadata with `schemeId` correlation, which the server can internally route to the right agent. + +3. **Token refresh**: Should the server expose token expiry information so clients can proactively refresh, or rely on `notify/authRequired` to signal when a refresh is needed? Proactive refresh avoids interruptions but requires the server to parse tokens (which it shouldn't have to for opaque tokens). + +4. **Multiple tokens**: Can a client authenticate multiple scheme IDs simultaneously? (Proposed: yes.) Can multiple clients each send their own token? (Proposed: yes, last-writer-wins per schemeId, which matches current behavior.) diff --git a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts index e58eac8dece..fe8993fea3e 100644 --- a/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts +++ b/src/vs/platform/agentHost/test/node/agentEventMapper.test.ts @@ -31,6 +31,7 @@ import type { ITurnCompleteAction, IUsageAction, } from '../../common/state/sessionActions.js'; +import { PermissionKind } from '../../common/state/sessionState.js'; import { mapProgressEventToActions } from '../../node/agentEventMapper.js'; /** Helper: flatten the result of mapProgressEventToActions into an array. */ @@ -188,7 +189,7 @@ suite('AgentEventMapper', () => { session, type: 'permission_request', requestId: 'perm-1', - permissionKind: 'shell', + permissionKind: PermissionKind.Shell, toolCallId: 'tc-2', fullCommandText: 'rm -rf /', intention: 'Delete all files', diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 8754a80bb71..72b776d5e86 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { FileService } from '../../../files/common/fileService.js'; import { AgentSession } from '../../common/agentService.js'; -import { IActionEnvelope } from '../../common/state/sessionActions.js'; +import { ActionType, IActionEnvelope } from '../../common/state/sessionActions.js'; import { AgentService } from '../../node/agentService.js'; import { MockAgent } from './mockAgent.js'; @@ -51,7 +51,7 @@ suite('AgentService (node dispatcher)', () => { // Start a turn so there's an active turn to map events to service.dispatchAction( - { type: 'session/turnStarted', session: session.toString(), turnId: 'turn-1', userMessage: { text: 'hello' } }, + { type: ActionType.SessionTurnStarted, session: session.toString(), turnId: 'turn-1', userMessage: { text: 'hello' } }, 'test-client', 1, ); @@ -59,7 +59,7 @@ suite('AgentService (node dispatcher)', () => { disposables.add(service.onDidAction(e => envelopes.push(e))); copilotAgent.fireProgress({ session, type: 'delta', messageId: 'msg-1', content: 'hello' }); - assert.ok(envelopes.some(e => e.action.type === 'session/delta')); + assert.ok(envelopes.some(e => e.action.type === ActionType.SessionDelta)); }); }); @@ -126,20 +126,6 @@ suite('AgentService (node dispatcher)', () => { }); }); - // ---- setAuthToken --------------------------------------------------- - - suite('setAuthToken', () => { - - test('broadcasts token to all registered providers', async () => { - service.registerProvider(copilotAgent); - - await service.setAuthToken('my-token'); - - assert.strictEqual(copilotAgent.setAuthTokenCalls.length, 1); - assert.strictEqual(copilotAgent.setAuthTokenCalls[0], 'my-token'); - }); - }); - // ---- listSessions / listModels -------------------------------------- suite('aggregation', () => { @@ -164,11 +150,59 @@ suite('AgentService (node dispatcher)', () => { // Model fetch is async inside AgentSideEffects — wait for it await new Promise(r => setTimeout(r, 50)); - const agentsChanged = envelopes.find(e => e.action.type === 'root/agentsChanged'); + const agentsChanged = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged); assert.ok(agentsChanged); }); }); + // ---- getResourceMetadata -------------------------------------------- + + suite('getResourceMetadata', () => { + + test('aggregates protected resources from all providers', async () => { + service.registerProvider(copilotAgent); + + const mockAgent = new MockAgent('other'); + disposables.add(toDisposable(() => mockAgent.dispose())); + service.registerProvider(mockAgent); + + const metadata = await service.getResourceMetadata(); + // copilot agent returns one resource (https://api.github.com), + // generic MockAgent('other') returns empty + assert.deepStrictEqual(metadata, { + resources: [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }], + }); + }); + + test('returns empty resources when no providers registered', async () => { + const metadata = await service.getResourceMetadata(); + assert.deepStrictEqual(metadata, { resources: [] }); + }); + }); + + // ---- authenticate --------------------------------------------------- + + suite('authenticate', () => { + + test('routes token to provider matching the resource', async () => { + service.registerProvider(copilotAgent); + + const result = await service.authenticate({ resource: 'https://api.github.com', token: 'ghp_test123' }); + + assert.deepStrictEqual(result, { authenticated: true }); + assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'ghp_test123' }]); + }); + + test('returns not authenticated for unknown resource', async () => { + service.registerProvider(copilotAgent); + + const result = await service.authenticate({ resource: 'https://unknown.example.com', token: 'tok' }); + + assert.deepStrictEqual(result, { authenticated: false }); + assert.strictEqual(copilotAgent.authenticateCalls.length, 0); + }); + }); + // ---- shutdown ------------------------------------------------------- suite('shutdown', () => { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index fc1772e5d37..72536646a26 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -14,8 +14,8 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgent } from '../../common/agentService.js'; -import { IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; -import { SessionStatus } from '../../common/state/sessionState.js'; +import { ActionType, IActionEnvelope, ISessionAction } from '../../common/state/sessionActions.js'; +import { PermissionKind, SessionStatus } from '../../common/state/sessionState.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; import { SessionStateManager } from '../../node/sessionStateManager.js'; import { MockAgent } from './mockAgent.js'; @@ -42,12 +42,12 @@ suite('AgentSideEffects', () => { createdAt: Date.now(), modifiedAt: Date.now(), }); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri.toString() }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri.toString() }); } function startTurn(turnId: string): void { stateManager.dispatchClientAction( - { type: 'session/turnStarted', session: sessionUri.toString(), turnId, userMessage: { text: 'hello' } }, + { type: ActionType.SessionTurnStarted, session: sessionUri.toString(), turnId, userMessage: { text: 'hello' } }, { clientId: 'test', clientSeq: 1 }, ); } @@ -84,7 +84,7 @@ suite('AgentSideEffects', () => { test('calls sendMessage on the agent', async () => { setupSession(); const action: ISessionAction = { - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri.toString(), turnId: 'turn-1', userMessage: { text: 'hello world' }, @@ -109,13 +109,13 @@ suite('AgentSideEffects', () => { disposables.add(stateManager.onDidEmitEnvelope(e => envelopes.push(e))); noAgentSideEffects.handleAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri.toString(), turnId: 'turn-1', userMessage: { text: 'hello' }, }); - const errorAction = envelopes.find(e => e.action.type === 'session/error'); + const errorAction = envelopes.find(e => e.action.type === ActionType.SessionError); assert.ok(errorAction, 'should dispatch session/error'); }); }); @@ -127,7 +127,7 @@ suite('AgentSideEffects', () => { test('calls abortSession on the agent', async () => { setupSession(); sideEffects.handleAction({ - type: 'session/turnCancelled', + type: ActionType.SessionTurnCancelled, session: sessionUri.toString(), turnId: 'turn-1', }); @@ -152,14 +152,14 @@ suite('AgentSideEffects', () => { session: sessionUri, type: 'permission_request', requestId: 'perm-1', - permissionKind: 'write', + permissionKind: PermissionKind.Write, path: 'file.ts', rawRequest: '{}', }); // Now resolve it sideEffects.handleAction({ - type: 'session/permissionResolved', + type: ActionType.SessionPermissionResolved, session: sessionUri.toString(), turnId: 'turn-1', requestId: 'perm-1', @@ -177,7 +177,7 @@ suite('AgentSideEffects', () => { test('calls changeModel on the agent', async () => { setupSession(); sideEffects.handleAction({ - type: 'session/modelChanged', + type: ActionType.SessionModelChanged, session: sessionUri.toString(), model: 'gpt-5', }); @@ -202,7 +202,7 @@ suite('AgentSideEffects', () => { agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'hi' }); - assert.ok(envelopes.some(e => e.action.type === 'session/delta')); + assert.ok(envelopes.some(e => e.action.type === ActionType.SessionDelta)); }); test('returns a disposable that stops listening', () => { @@ -214,11 +214,11 @@ suite('AgentSideEffects', () => { const listener = sideEffects.registerProgressListener(agent); agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-1', content: 'before' }); - assert.strictEqual(envelopes.filter(e => e.action.type === 'session/delta').length, 1); + assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionDelta).length, 1); listener.dispose(); agent.fireProgress({ session: sessionUri, type: 'delta', messageId: 'msg-2', content: 'after' }); - assert.strictEqual(envelopes.filter(e => e.action.type === 'session/delta').length, 1); + assert.strictEqual(envelopes.filter(e => e.action.type === ActionType.SessionDelta).length, 1); }); }); @@ -232,7 +232,7 @@ suite('AgentSideEffects', () => { await sideEffects.handleCreateSession({ session: sessionUri.toString(), provider: 'mock' }); - const ready = envelopes.find(e => e.action.type === 'session/ready'); + const ready = envelopes.find(e => e.action.type === ActionType.SessionReady); assert.ok(ready, 'should dispatch session/ready'); }); @@ -318,8 +318,47 @@ suite('AgentSideEffects', () => { // Model fetch is async — wait for it await new Promise(r => setTimeout(r, 50)); - const action = envelopes.find(e => e.action.type === 'root/agentsChanged'); + const action = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged); assert.ok(action, 'should dispatch root/agentsChanged'); }); }); + + // ---- handleGetResourceMetadata / handleAuthenticate ----------------- + + suite('auth', () => { + + test('handleGetResourceMetadata aggregates resources from agents', () => { + agentList.set([agent], undefined); + + const metadata = sideEffects.handleGetResourceMetadata(); + assert.strictEqual(metadata.resources.length, 0, 'mock agent has no protected resources'); + }); + + test('handleGetResourceMetadata returns resources when agent declares them', () => { + const copilotAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => copilotAgent.dispose())); + agentList.set([copilotAgent], undefined); + + const metadata = sideEffects.handleGetResourceMetadata(); + assert.strictEqual(metadata.resources.length, 1); + assert.strictEqual(metadata.resources[0].resource, 'https://api.github.com'); + }); + + test('handleAuthenticate returns authenticated for matching resource', async () => { + const copilotAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => copilotAgent.dispose())); + agentList.set([copilotAgent], undefined); + + const result = await sideEffects.handleAuthenticate({ resource: 'https://api.github.com', token: 'test-token' }); + assert.deepStrictEqual(result, { authenticated: true }); + assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'test-token' }]); + }); + + test('handleAuthenticate returns not authenticated for non-matching resource', async () => { + agentList.set([agent], undefined); + + const result = await sideEffects.handleAuthenticate({ resource: 'https://unknown.example.com', token: 'test-token' }); + assert.deepStrictEqual(result, { authenticated: false }); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 6ddf3ac28c3..0bd861655f6 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -5,7 +5,9 @@ import { Emitter } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; +import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; +import { PermissionKind } from '../../common/state/sessionState.js'; /** * General-purpose mock agent for unit tests. Tracks all method calls @@ -18,12 +20,13 @@ export class MockAgent implements IAgent { private readonly _sessions = new Map(); private _nextId = 1; - readonly setAuthTokenCalls: string[] = []; + readonly sendMessageCalls: { session: URI; prompt: string }[] = []; readonly disposeSessionCalls: URI[] = []; readonly abortSessionCalls: URI[] = []; readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; readonly changeModelCalls: { session: URI; model: string }[] = []; + readonly authenticateCalls: { resource: string; token: string }[] = []; constructor(readonly id: AgentProvider = 'mock') { } @@ -31,6 +34,13 @@ export class MockAgent implements IAgent { return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, requiresAuth: this.id === 'copilot' }; } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + if (this.id === 'copilot') { + return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }]; + } + return []; + } + async listModels(): Promise { return [{ provider: this.id, id: `${this.id}-model`, name: `${this.id} Model`, maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; } @@ -71,8 +81,9 @@ export class MockAgent implements IAgent { this.changeModelCalls.push({ session, model }); } - async setAuthToken(token: string): Promise { - this.setAuthTokenCalls.push(token); + async authenticate(resource: string, token: string): Promise { + this.authenticateCalls.push({ resource, token }); + return true; } async shutdown(): Promise { } @@ -104,6 +115,10 @@ export class ScriptedMockAgent implements IAgent { return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', requiresAuth: false }; } + getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + return []; + } + async listModels(): Promise { return [{ provider: 'mock', id: 'mock-model', name: 'Mock Model', maxContextWindow: 128000, supportsVision: false, supportsReasoningEffort: false }]; } @@ -149,10 +164,10 @@ export class ScriptedMockAgent implements IAgent { type: 'permission_request', session, requestId: 'perm-1', - permissionKind: 'shell', + permissionKind: PermissionKind.Shell, fullCommandText: 'echo test', intention: 'Run a test command', - rawRequest: JSON.stringify({ permissionKind: 'shell', fullCommandText: 'echo test', intention: 'Run a test command' }), + rawRequest: JSON.stringify({ permissionKind: PermissionKind.Shell, fullCommandText: 'echo test', intention: 'Run a test command' }), }; setTimeout(() => this._onDidSessionProgress.fire(permEvent), 10); this._pendingPermissions.set('perm-1', (approved) => { @@ -223,7 +238,9 @@ export class ScriptedMockAgent implements IAgent { } } - async setAuthToken(_token: string): Promise { } + async authenticate(_resource: string, _token: string): Promise { + return true; + } async shutdown(): Promise { } diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 3c6a3b83fed..3eac4562547 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -9,7 +9,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import type { ISessionAction } from '../../common/state/sessionActions.js'; +import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js'; import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type ICreateSessionParams, type IInitializeResult, type IProtocolMessage, type IAhpNotification, type IReconnectResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; @@ -74,7 +74,8 @@ class MockSideEffectHandler implements IProtocolSideEffectHandler { async handleCreateSession(_command: ICreateSessionParams): Promise { /* session created via state manager */ } handleDisposeSession(_session: string): void { } async handleListSessions(): Promise { return []; } - handleSetAuthToken(_token: string): void { } + handleGetResourceMetadata() { return { resources: [] }; } + async handleAuthenticate(_params: { resource: string; token: string }) { return { authenticated: true }; } async handleBrowseDirectory(uri: string): Promise<{ entries: { name: string; type: 'file' | 'directory' }[] }> { this.browsedUris.push(URI.parse(uri)); const error = this.browseErrors.get(uri); @@ -206,7 +207,7 @@ suite('ProtocolServerHandler', () => { test('client action is dispatched and echoed', () => { stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const transport = connectClient('client-1', [sessionUri]); transport.sent.length = 0; @@ -214,7 +215,7 @@ suite('ProtocolServerHandler', () => { transport.simulateMessage(notification('dispatchAction', { clientSeq: 1, action: { - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri, turnId: 'turn-1', userMessage: { text: 'hello' }, @@ -224,7 +225,7 @@ suite('ProtocolServerHandler', () => { const actionMsgs = findNotifications(transport.sent, 'action'); const turnStarted = actionMsgs.find(m => { const envelope = m.params as unknown as { action: { type: string } }; - return envelope.action.type === 'session/turnStarted'; + return envelope.action.type === ActionType.SessionTurnStarted; }); assert.ok(turnStarted, 'should have echoed turnStarted'); const envelope = turnStarted!.params as unknown as { origin: { clientId: string; clientSeq: number } }; @@ -234,7 +235,7 @@ suite('ProtocolServerHandler', () => { test('actions are scoped to subscribed sessions', () => { stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const transportA = connectClient('client-a', [sessionUri]); const transportB = connectClient('client-b'); @@ -243,7 +244,7 @@ suite('ProtocolServerHandler', () => { transportB.sent.length = 0; stateManager.dispatchServerAction({ - type: 'session/titleChanged', + type: ActionType.SessionTitleChanged, session: sessionUri, title: 'New Title', }); @@ -267,15 +268,15 @@ suite('ProtocolServerHandler', () => { test('reconnect replays missed actions', () => { stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const transport1 = connectClient('client-r', [sessionUri]); const resp = findResponse(transport1.sent, 1); const initSeq = (resp as { result: IInitializeResult }).result.serverSeq; transport1.simulateClose(); - stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Title A' }); - stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Title B' }); + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title A' }); + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Title B' }); const transport2 = new MockProtocolTransport(); server.simulateConnection(transport2); @@ -296,13 +297,13 @@ suite('ProtocolServerHandler', () => { test('reconnect sends fresh snapshots when gap too large', () => { stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const transport1 = connectClient('client-g', [sessionUri]); transport1.simulateClose(); for (let i = 0; i < 1100; i++) { - stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: `Title ${i}` }); + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: `Title ${i}` }); } const transport2 = new MockProtocolTransport(); @@ -324,14 +325,14 @@ suite('ProtocolServerHandler', () => { test('client disconnect cleans up', () => { stateManager.createSession(makeSessionSummary()); - stateManager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const transport = connectClient('client-d', [sessionUri]); transport.sent.length = 0; transport.simulateClose(); - stateManager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'After Disconnect' }); + stateManager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'After Disconnect' }); assert.strictEqual(transport.sent.length, 0); }); @@ -380,4 +381,50 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(resp.error!.code, JSON_RPC_INTERNAL_ERROR); assert.match(resp.error!.message, /Directory not found/); }); + + // ---- Extension methods: auth ---------------------------------------- + + test('getResourceMetadata returns resource metadata via extension request', async () => { + const transport = connectClient('client-metadata'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'getResourceMetadata')); + const resp = await responsePromise as { result?: { resources: unknown[] } }; + + assert.ok(resp?.result); + assert.ok(Array.isArray(resp.result!.resources)); + }); + + test('authenticate returns result via extension request', async () => { + const transport = connectClient('client-auth'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'authenticate', { resource: 'https://api.github.com', token: 'test-token' })); + const resp = await responsePromise as { result?: { authenticated: boolean } }; + + assert.ok(resp?.result); + assert.strictEqual(resp.result!.authenticated, true); + }); + + test('extension request preserves ProtocolError code and data', async () => { + // Override handleAuthenticate to throw a ProtocolError with data + const origHandler = sideEffects.handleAuthenticate; + sideEffects.handleAuthenticate = async () => { throw new ProtocolError(-32007, 'Auth required', { hint: 'sign in' }); }; + + const transport = connectClient('client-auth-error'); + transport.sent.length = 0; + + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'authenticate', { resource: 'test', token: 'bad' })); + const resp = await responsePromise as { error?: { code: number; message: string; data?: unknown } }; + + assert.ok(resp?.error); + assert.strictEqual(resp.error!.code, -32007); + assert.strictEqual(resp.error!.message, 'Auth required'); + assert.deepStrictEqual(resp.error!.data, { hint: 'sign in' }); + + sideEffects.handleAuthenticate = origHandler; + }); }); diff --git a/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts index 989f61d6338..10b2db4d8c9 100644 --- a/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionStateManager.test.ts @@ -8,7 +8,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import type { IActionEnvelope, INotification } from '../../common/state/sessionActions.js'; +import { ActionType, NotificationType, type IActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; import { ISessionSummary, ROOT_STATE_URI, SessionLifecycle, SessionStatus, type ISessionState } from '../../common/state/sessionState.js'; import { SessionStateManager } from '../../node/sessionStateManager.js'; @@ -76,7 +76,7 @@ suite('SessionStateManager', () => { disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction({ - type: 'session/ready', + type: ActionType.SessionReady, session: sessionUri, }); @@ -85,7 +85,7 @@ suite('SessionStateManager', () => { assert.strictEqual(state.lifecycle, SessionLifecycle.Ready); assert.strictEqual(envelopes.length, 1); - assert.strictEqual(envelopes[0].action.type, 'session/ready'); + assert.strictEqual(envelopes[0].action.type, ActionType.SessionReady); assert.strictEqual(envelopes[0].serverSeq, 1); assert.strictEqual(envelopes[0].origin, undefined); }); @@ -96,8 +96,8 @@ suite('SessionStateManager', () => { const envelopes: IActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); - manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); - manager.dispatchServerAction({ type: 'session/titleChanged', session: sessionUri, title: 'Updated' }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionTitleChanged, session: sessionUri, title: 'Updated' }); assert.strictEqual(envelopes.length, 2); assert.strictEqual(envelopes[0].serverSeq, 1); @@ -113,7 +113,7 @@ suite('SessionStateManager', () => { const origin = { clientId: 'renderer-1', clientSeq: 42 }; manager.dispatchClientAction( - { type: 'session/ready', session: sessionUri }, + { type: ActionType.SessionReady, session: sessionUri }, origin, ); @@ -132,7 +132,7 @@ suite('SessionStateManager', () => { assert.strictEqual(manager.getSessionState(sessionUri), undefined); assert.strictEqual(manager.getSnapshot(sessionUri), undefined); assert.strictEqual(notifications.length, 1); - assert.strictEqual(notifications[0].type, 'notify/sessionRemoved'); + assert.strictEqual(notifications[0].type, NotificationType.SessionRemoved); }); test('createSession emits sessionAdded notification', () => { @@ -142,17 +142,17 @@ suite('SessionStateManager', () => { manager.createSession(makeSessionSummary()); assert.strictEqual(notifications.length, 1); - assert.strictEqual(notifications[0].type, 'notify/sessionAdded'); + assert.strictEqual(notifications[0].type, NotificationType.SessionAdded); }); test('getActiveTurnId returns active turn id after turnStarted', () => { manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); assert.strictEqual(manager.getActiveTurnId(sessionUri), undefined); manager.dispatchServerAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri, turnId: 'turn-1', userMessage: { text: 'hello' }, @@ -169,19 +169,19 @@ suite('SessionStateManager', () => { test('turnStarted dispatches root/activeSessionsChanged with correct count', () => { manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); const envelopes: IActionEnvelope[] = []; disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri, turnId: 'turn-1', userMessage: { text: 'hello' }, }); - const activeChanged = envelopes.filter(e => e.action.type === 'root/activeSessionsChanged'); + const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); assert.strictEqual(activeChanged.length, 1); assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 1); assert.strictEqual(manager.rootState.activeSessions, 1); @@ -189,9 +189,9 @@ suite('SessionStateManager', () => { test('turnComplete dispatches root/activeSessionsChanged back to 0', () => { manager.createSession(makeSessionSummary()); - manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); manager.dispatchServerAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri, turnId: 'turn-1', userMessage: { text: 'hello' }, @@ -201,12 +201,12 @@ suite('SessionStateManager', () => { disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); manager.dispatchServerAction({ - type: 'session/turnComplete', + type: ActionType.SessionTurnComplete, session: sessionUri, turnId: 'turn-1', }); - const activeChanged = envelopes.filter(e => e.action.type === 'root/activeSessionsChanged'); + const activeChanged = envelopes.filter(e => e.action.type === ActionType.RootActiveSessionsChanged); assert.strictEqual(activeChanged.length, 1); assert.strictEqual((activeChanged[0].action as { activeSessions: number }).activeSessions, 0); assert.strictEqual(manager.rootState.activeSessions, 0); @@ -216,17 +216,17 @@ suite('SessionStateManager', () => { const session2Uri = URI.from({ scheme: 'copilot', path: '/test-session-2' }).toString(); manager.createSession(makeSessionSummary(sessionUri)); manager.createSession(makeSessionSummary(session2Uri)); - manager.dispatchServerAction({ type: 'session/ready', session: sessionUri }); - manager.dispatchServerAction({ type: 'session/ready', session: session2Uri }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + manager.dispatchServerAction({ type: ActionType.SessionReady, session: session2Uri }); manager.dispatchServerAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: sessionUri, turnId: 'turn-1', userMessage: { text: 'a' }, }); manager.dispatchServerAction({ - type: 'session/turnStarted', + type: ActionType.SessionTurnStarted, session: session2Uri, turnId: 'turn-2', userMessage: { text: 'b' }, @@ -234,14 +234,14 @@ suite('SessionStateManager', () => { assert.strictEqual(manager.rootState.activeSessions, 2); manager.dispatchServerAction({ - type: 'session/turnComplete', + type: ActionType.SessionTurnComplete, session: sessionUri, turnId: 'turn-1', }); assert.strictEqual(manager.rootState.activeSessions, 1); manager.dispatchServerAction({ - type: 'session/turnComplete', + type: ActionType.SessionTurnComplete, session: session2Uri, turnId: 'turn-2', }); diff --git a/src/vs/platform/browserElements/common/browserElements.ts b/src/vs/platform/browserElements/common/browserElements.ts index 29735127134..7e8d1beac19 100644 --- a/src/vs/platform/browserElements/common/browserElements.ts +++ b/src/vs/platform/browserElements/common/browserElements.ts @@ -81,3 +81,122 @@ export function getDisplayNameFromOuterHTML(outerHTML: string): string { const className = classMatch ? `.${classMatch[1].replace(/\s+/g, '.')}` : ''; return `${tagName}${id}${className}`; } + +/** + * Format an array of element ancestors into a CSS-selector-like path string. + */ +export function formatElementPath(ancestors: readonly IElementAncestor[] | undefined): string | undefined { + if (!ancestors || ancestors.length === 0) { + return undefined; + } + + return ancestors + .map(ancestor => { + const classes = ancestor.classNames?.length ? `.${ancestor.classNames.join('.')}` : ''; + const id = ancestor.id ? `#${ancestor.id}` : ''; + return `${ancestor.tagName}${id}${classes}`; + }) + .join(' > '); +} + +/** + * Collapse margin-top/right/bottom/left or padding-top/right/bottom/left + * into a single shorthand value, removing the individual entries from the map. + */ +function createBoxShorthand(entries: Map, propertyName: 'margin' | 'padding'): string | undefined { + const topKey = `${propertyName}-top`; + const rightKey = `${propertyName}-right`; + const bottomKey = `${propertyName}-bottom`; + const leftKey = `${propertyName}-left`; + + const top = entries.get(topKey); + const right = entries.get(rightKey); + const bottom = entries.get(bottomKey); + const left = entries.get(leftKey); + + if (top === undefined || right === undefined || bottom === undefined || left === undefined) { + return undefined; + } + + entries.delete(topKey); + entries.delete(rightKey); + entries.delete(bottomKey); + entries.delete(leftKey); + + return `${top} ${right} ${bottom} ${left}`; +} + +/** + * Format a key-value record into a markdown-style list, + * collapsing margin/padding into shorthand values. + */ +export function formatElementMap(entries: Readonly> | undefined): string | undefined { + if (!entries || Object.keys(entries).length === 0) { + return undefined; + } + + const normalizedEntries = new Map(Object.entries(entries)); + const lines: string[] = []; + + const marginShorthand = createBoxShorthand(normalizedEntries, 'margin'); + if (marginShorthand) { + lines.push(`- margin: ${marginShorthand}`); + } + + const paddingShorthand = createBoxShorthand(normalizedEntries, 'padding'); + if (paddingShorthand) { + lines.push(`- padding: ${paddingShorthand}`); + } + + for (const [name, value] of Array.from(normalizedEntries.entries()).sort(([a], [b]) => a.localeCompare(b))) { + lines.push(`- ${name}: ${value}`); + } + + return lines.join('\n'); +} + +/** + * Build a structured text representation of element data for use as chat context. + */ +export function createElementContextValue(elementData: IElementData, displayName: string, attachCss: boolean): string { + const sections: string[] = []; + sections.push('Attached Element Context from Integrated Browser'); + sections.push(`Element: ${displayName}`); + + const htmlPath = formatElementPath(elementData.ancestors); + if (htmlPath) { + sections.push(`HTML Path:\n${htmlPath}`); + } + + const attributeTable = formatElementMap(elementData.attributes); + if (attributeTable) { + sections.push(`Attributes:\n${attributeTable}`); + } + + if (attachCss) { + const computedStyleTable = formatElementMap(elementData.computedStyles); + if (computedStyleTable) { + sections.push(`Computed Styles:\n${computedStyleTable}`); + } + } + + if (elementData.dimensions) { + const { top, left, width, height } = elementData.dimensions; + sections.push( + `Dimensions:\n- top: ${Math.round(top)}px\n- left: ${Math.round(left)}px\n- width: ${Math.round(width)}px\n- height: ${Math.round(height)}px` + ); + } + + const innerText = elementData.innerText?.trim(); + if (innerText) { + sections.push(`Inner Text:\n\`\`\`text\n${innerText}\n\`\`\``); + } + + sections.push(`Outer HTML:\n\`\`\`html\n${elementData.outerHTML}\n\`\`\``); + + if (attachCss) { + sections.push(`Full Computed CSS:\n\`\`\`css\n${elementData.computedStyle}\n\`\`\``); + } + + return sections.join('\n\n'); +} diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index cb5a7fb8017..1cf026d1c75 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -10,21 +10,37 @@ import { localize } from '../../../nls.js'; const commandPrefix = 'workbench.action.browser'; export enum BrowserViewCommandId { + // Tab management Open = `${commandPrefix}.open`, NewTab = `${commandPrefix}.newTab`, + QuickOpen = `${commandPrefix}.quickOpen`, + CloseAll = `${commandPrefix}.closeAll`, + CloseAllInGroup = `${commandPrefix}.closeAllInGroup`, + + // Navigation GoBack = `${commandPrefix}.goBack`, GoForward = `${commandPrefix}.goForward`, Reload = `${commandPrefix}.reload`, HardReload = `${commandPrefix}.hardReload`, + + // Editor actions FocusUrlInput = `${commandPrefix}.focusUrlInput`, + OpenExternal = `${commandPrefix}.openExternal`, + OpenSettings = `${commandPrefix}.openSettings`, + + // Chat actions AddElementToChat = `${commandPrefix}.addElementToChat`, AddConsoleLogsToChat = `${commandPrefix}.addConsoleLogsToChat`, + + // Dev Tools ToggleDevTools = `${commandPrefix}.toggleDevTools`, - OpenExternal = `${commandPrefix}.openExternal`, + + // Storage ClearGlobalStorage = `${commandPrefix}.clearGlobalStorage`, ClearWorkspaceStorage = `${commandPrefix}.clearWorkspaceStorage`, ClearEphemeralStorage = `${commandPrefix}.clearEphemeralStorage`, - OpenSettings = `${commandPrefix}.openSettings`, + + // Find in page ShowFind = `${commandPrefix}.showFind`, HideFind = `${commandPrefix}.hideFind`, FindNext = `${commandPrefix}.findNext`, diff --git a/src/vs/platform/browserView/common/browserViewTelemetry.ts b/src/vs/platform/browserView/common/browserViewTelemetry.ts index 66853e50999..0f5037b877b 100644 --- a/src/vs/platform/browserView/common/browserViewTelemetry.ts +++ b/src/vs/platform/browserView/common/browserViewTelemetry.ts @@ -17,6 +17,10 @@ export type IntegratedBrowserOpenSource = /** Opened via the "Open Integrated Browser" command with a URL argument. * This typically means another extension or component invoked the command programmatically. */ | 'commandWithUrl' + /** Opened via the quick open feature with no initial URL. */ + | 'quickOpenWithoutUrl' + /** Opened via the quick open feature with an initial URL. */ + | 'quickOpenWithUrl' /** Opened via the "New Tab" command from an existing tab. */ | 'newTabCommand' /** Opened via the localhost link opener when the `workbench.browser.openLocalhostLinks` setting diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index 1f40d86cdf8..1a21770d068 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -10,11 +10,10 @@ import { IApplicationStorageMainService } from '../../storage/electron-main/stor import { BrowserViewStorageScope } from '../common/browserView.js'; import { BrowserSessionTrust, IBrowserSessionTrust } from './browserSessionTrust.js'; -// Same as webviews +// Same as webviews, minus clipboard-read const allowedPermissions = new Set([ 'pointerLock', 'notifications', - 'clipboard-read', 'clipboard-sanitized-write' ]); diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 085229a7c5d..4113a4e7b36 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -683,7 +683,6 @@ export class BrowserView extends Disposable implements ICDPTarget { const isArrowKey = keyCode >= KeyCode.LeftArrow && keyCode <= KeyCode.DownArrow; const isNonEditingKey = - keyCode === KeyCode.Enter || keyCode === KeyCode.Escape || keyCode >= KeyCode.F1 && keyCode <= KeyCode.F24 || keyCode >= KeyCode.AudioVolumeMute; diff --git a/src/vs/platform/editor/common/editor.ts b/src/vs/platform/editor/common/editor.ts index d0751cb4bc9..b43d8d53137 100644 --- a/src/vs/platform/editor/common/editor.ts +++ b/src/vs/platform/editor/common/editor.ts @@ -334,6 +334,11 @@ export interface IModalEditorPartOptions { */ readonly maximized?: boolean; + /** + * Minimum width of the modal editor part in pixels. + */ + readonly minWidth?: number; + /** * Size of the modal editor part unless it is maximized. */ diff --git a/src/vs/platform/environment/common/argv.ts b/src/vs/platform/environment/common/argv.ts index 7af7bce71bc..72752cf9ed1 100644 --- a/src/vs/platform/environment/common/argv.ts +++ b/src/vs/platform/environment/common/argv.ts @@ -24,6 +24,7 @@ export interface NativeParsedArgs { }; }; 'serve-web'?: INativeCliOptions; + 'agent-host'?: INativeCliOptions; chat?: { _: string[]; 'add-file'?: string[]; @@ -109,6 +110,7 @@ export interface NativeParsedArgs { 'disable-telemetry'?: boolean; 'export-default-configuration'?: string; 'export-policy-data'?: string; + 'export-default-keybindings'?: string; 'install-source'?: string; 'add-mcp'?: string[]; 'disable-updates'?: boolean; diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index 137a08dab33..883ed24b0fc 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -149,6 +149,7 @@ export interface INativeEnvironmentService extends IEnvironmentService { crossOriginIsolated?: boolean; exportPolicyData?: string; + exportDefaultKeybindings?: string; // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // diff --git a/src/vs/platform/environment/common/environmentService.ts b/src/vs/platform/environment/common/environmentService.ts index c6869a109f1..3502a718fa0 100644 --- a/src/vs/platform/environment/common/environmentService.ts +++ b/src/vs/platform/environment/common/environmentService.ts @@ -264,6 +264,10 @@ export abstract class AbstractNativeEnvironmentService implements INativeEnviron return this.args['export-policy-data']; } + get exportDefaultKeybindings(): string | undefined { + return this.args['export-default-keybindings']; + } + get continueOn(): string | undefined { return this.args['continueOn']; } diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 9a2575a40f4..3b7f625a6ab 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -45,7 +45,7 @@ export type OptionDescriptions = { Subcommand }; -export const NATIVE_CLI_COMMANDS = ['tunnel', 'serve-web'] as const; +export const NATIVE_CLI_COMMANDS = ['tunnel', 'serve-web', 'agent-host'] as const; export const OPTIONS: OptionDescriptions> = { 'chat': { @@ -71,6 +71,15 @@ export const OPTIONS: OptionDescriptions> = { 'telemetry-level': { type: 'string' }, } }, + 'agent-host': { + type: 'subcommand', + description: 'Run a server that hosts agents.', + options: { + 'cli-data-dir': { type: 'string', args: 'dir', description: localize('cliDataDir', "Directory where CLI metadata should be stored.") }, + 'disable-telemetry': { type: 'boolean' }, + 'telemetry-level': { type: 'string' }, + } + }, 'tunnel': { type: 'subcommand', description: 'Make the current machine accessible from vscode.dev or other machines through a secure tunnel.', @@ -166,6 +175,7 @@ export const OPTIONS: OptionDescriptions> = { 'inspect-brk-sharedprocess': { type: 'string', allowEmptyValue: true }, 'export-default-configuration': { type: 'string' }, 'export-policy-data': { type: 'string', allowEmptyValue: true }, + 'export-default-keybindings': { type: 'string', allowEmptyValue: true }, 'install-source': { type: 'string' }, 'enable-smoke-test-driver': { type: 'boolean' }, 'skip-sessions-welcome': { type: 'boolean' }, diff --git a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts index e9595bd406a..426df59122b 100644 --- a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts +++ b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts @@ -167,11 +167,20 @@ export abstract class AbstractExtensionManagementService extends CommontExtensio const results = await this.installGalleryExtensions([{ extension, options }]); const result = results.find(({ identifier }) => areSameExtensions(identifier, extension.identifier)); if (result?.local) { - return result?.local; + return result.local; } if (result?.error) { throw result.error; } + // Extension might have been redirected due to deprecation (e.g., github.copilot -> github.copilot-chat) + // In this case, the result will have the redirected extension's identifier + const redirectedResult = results[0]; + if (redirectedResult?.local) { + return redirectedResult.local; + } + if (redirectedResult?.error) { + throw redirectedResult.error; + } throw new ExtensionManagementError(`Unknown error while installing extension ${extension.identifier.id}`, ExtensionManagementErrorCode.Unknown); } catch (error) { throw toExtensionManagementError(error); diff --git a/src/vs/platform/extensions/common/extensionHostStarter.ts b/src/vs/platform/extensions/common/extensionHostStarter.ts index 3560e56c19c..81574ba47f9 100644 --- a/src/vs/platform/extensions/common/extensionHostStarter.ts +++ b/src/vs/platform/extensions/common/extensionHostStarter.ts @@ -9,6 +9,7 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IExtensionHostStarter = createDecorator('extensionHostStarter'); export const ipcExtensionHostStarterChannelName = 'extensionHostStarter'; +export const extensionHostGraceTimeMs = 6000; export interface IExtensionHostProcessOptions { responseWindowId: number; @@ -31,6 +32,7 @@ export interface IExtensionHostStarter { createExtensionHost(): Promise<{ id: string }>; start(id: string, opts: IExtensionHostProcessOptions): Promise<{ pid: number | undefined }>; enableInspectPort(id: string): Promise; + waitForExit(id: string, maxWaitTimeMs: number): Promise; kill(id: string): Promise; } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index d62aa30b912..9b1af5d17b8 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -48,7 +48,7 @@ const _allApiProposals = { }, chatDebug: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatDebug.d.ts', - version: 3 + version: 4 }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', @@ -487,6 +487,9 @@ const _allApiProposals = { tokenInformation: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.tokenInformation.d.ts', }, + toolInvocationApproveCombination: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.toolInvocationApproveCombination.d.ts', + }, toolProgress: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.toolProgress.d.ts', }, diff --git a/src/vs/platform/extensions/electron-main/extensionHostStarter.ts b/src/vs/platform/extensions/electron-main/extensionHostStarter.ts index 97a0519a493..d5cddd2c8cd 100644 --- a/src/vs/platform/extensions/electron-main/extensionHostStarter.ts +++ b/src/vs/platform/extensions/electron-main/extensionHostStarter.ts @@ -7,7 +7,7 @@ import { Promises } from '../../../base/common/async.js'; import { canceled } from '../../../base/common/errors.js'; import { Event } from '../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; -import { IExtensionHostProcessOptions, IExtensionHostStarter } from '../common/extensionHostStarter.js'; +import { extensionHostGraceTimeMs, IExtensionHostProcessOptions, IExtensionHostStarter } from '../common/extensionHostStarter.js'; import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; @@ -121,7 +121,7 @@ export class ExtensionHostStarter extends Disposable implements IDisposable, IEx allowLoadingUnsignedLibraries: true, respondToAuthRequestsFromMainProcess: true, windowLifecycleBound: true, - windowLifecycleGraceTime: 6000, + windowLifecycleGraceTime: extensionHostGraceTimeMs, correlationId: id }); const pid = await Event.toPromise(extHost.onSpawn); @@ -151,6 +151,17 @@ export class ExtensionHostStarter extends Disposable implements IDisposable, IEx extHostProcess.kill(); } + async waitForExit(id: string, maxWaitTimeMs: number): Promise { + if (this._shutdown) { + throw canceled(); + } + const extHostProcess = this._extHosts.get(id); + if (!extHostProcess) { + return; + } + await extHostProcess.waitForExit(maxWaitTimeMs); + } + async _killAllNow(): Promise { for (const [, extHost] of this._extHosts) { extHost.kill(); diff --git a/src/vs/platform/keybinding/common/keybindingsRegistry.ts b/src/vs/platform/keybinding/common/keybindingsRegistry.ts index 05660234e61..097d8a73a84 100644 --- a/src/vs/platform/keybinding/common/keybindingsRegistry.ts +++ b/src/vs/platform/keybinding/common/keybindingsRegistry.ts @@ -77,6 +77,7 @@ export interface IKeybindingsRegistry { setExtensionKeybindings(rules: IExtensionKeybindingRule[]): void; registerCommandAndKeybindingRule(desc: ICommandAndKeybindingRule): IDisposable; getDefaultKeybindings(): IKeybindingItem[]; + getDefaultKeybindingsForOS(os: OperatingSystem): IKeybindingItem[]; } /** @@ -85,24 +86,23 @@ export interface IKeybindingsRegistry { class KeybindingsRegistryImpl implements IKeybindingsRegistry { private _coreKeybindings: LinkedList; + private _coreKeybindingRules: LinkedList; private _extensionKeybindings: IKeybindingItem[]; private _cachedMergedKeybindings: IKeybindingItem[] | null; constructor() { this._coreKeybindings = new LinkedList(); + this._coreKeybindingRules = new LinkedList(); this._extensionKeybindings = []; this._cachedMergedKeybindings = null; } - /** - * Take current platform into account and reduce to primary & secondary. - */ - private static bindToCurrentPlatform(kb: IKeybindings): { primary?: number; secondary?: number[] } { - if (OS === OperatingSystem.Windows) { + private static bindToPlatform(kb: IKeybindings, os: OperatingSystem): { primary?: number; secondary?: number[] } { + if (os === OperatingSystem.Windows) { if (kb && kb.win) { return kb.win; } - } else if (OS === OperatingSystem.Macintosh) { + } else if (os === OperatingSystem.Macintosh) { if (kb && kb.mac) { return kb.mac; } @@ -111,10 +111,16 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { return kb.linux; } } - return kb; } + /** + * Take current platform into account and reduce to primary & secondary. + */ + private static bindToCurrentPlatform(kb: IKeybindings): { primary?: number; secondary?: number[] } { + return KeybindingsRegistryImpl.bindToPlatform(kb, OS); + } + public registerKeybindingRule(rule: IKeybindingRule): IDisposable { const actualKb = KeybindingsRegistryImpl.bindToCurrentPlatform(rule); const result = new DisposableStore(); @@ -135,6 +141,10 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { } } } + + const removeRule = this._coreKeybindingRules.push(rule); + result.add(toDisposable(() => { removeRule(); })); + return result; } @@ -193,6 +203,51 @@ class KeybindingsRegistryImpl implements IKeybindingsRegistry { } return this._cachedMergedKeybindings.slice(0); } + + public getDefaultKeybindingsForOS(os: OperatingSystem): IKeybindingItem[] { + const result: IKeybindingItem[] = []; + for (const rule of this._coreKeybindingRules) { + const actualKb = KeybindingsRegistryImpl.bindToPlatform(rule, os); + + if (actualKb && actualKb.primary) { + const kk = decodeKeybinding(actualKb.primary, os); + if (kk) { + result.push({ + keybinding: kk, + command: rule.id, + commandArgs: rule.args, + when: rule.when, + weight1: rule.weight, + weight2: 0, + extensionId: null, + isBuiltinExtension: false + }); + } + } + + if (actualKb && Array.isArray(actualKb.secondary)) { + for (let i = 0, len = actualKb.secondary.length; i < len; i++) { + const k = actualKb.secondary[i]; + const kk = decodeKeybinding(k, os); + if (kk) { + result.push({ + keybinding: kk, + command: rule.id, + commandArgs: rule.args, + when: rule.when, + weight1: rule.weight, + weight2: -i - 1, + extensionId: null, + isBuiltinExtension: false + }); + } + } + } + } + + result.sort(sorter); + return result; + } } export const KeybindingsRegistry: IKeybindingsRegistry = new KeybindingsRegistryImpl(); diff --git a/src/vs/platform/quickinput/browser/quickInputList.ts b/src/vs/platform/quickinput/browser/quickInputList.ts index 8580b264014..f71937903f3 100644 --- a/src/vs/platform/quickinput/browser/quickInputList.ts +++ b/src/vs/platform/quickinput/browser/quickInputList.ts @@ -321,14 +321,26 @@ class QuickInputAccessibilityProvider implements IListAccessibilityProvider implements ITreeRenderer { +abstract class BaseQuickInputListRenderer extends Disposable implements ITreeRenderer { abstract templateId: string; + private readonly _onDidDisposeFocusedElement = this._register(new Emitter()); + + /** + * This event is emitted when the renderer disposes an element that has focus. + * This allows the list to re-focus itself and prevent focus from being lost + * (potentially causing quickinput to dismiss itself) when an element is + * removed while focused. + */ + readonly onDidDisposeFocusedElement = this._onDidDisposeFocusedElement.event; + constructor( private readonly hoverDelegate: IHoverDelegate | undefined, private readonly toggleStyles: IToggleStyles, private readonly contextMenuService: IContextMenuService - ) { } + ) { + super(); + } // TODO: only do the common stuff here and have a subclass handle their specific stuff renderTemplate(container: HTMLElement): IQuickInputItemTemplateData { @@ -392,6 +404,9 @@ abstract class BaseQuickInputListRenderer implement } disposeElement(_element: ITreeNode, _index: number, data: IQuickInputItemTemplateData): void { + if (dom.isAncestorOfActiveElement(data.entry)) { + this._onDidDisposeFocusedElement.fire(); + } data.toDisposeElement.clear(); data.toolBar.setActions([]); } @@ -746,8 +761,8 @@ export class QuickInputList extends Disposable { ) { super(); this._container = dom.append(this.parent, $('.quick-input-list')); - this._separatorRenderer = instantiationService.createInstance(QuickPickSeparatorElementRenderer, hoverDelegate, this.styles.toggle); - this._itemRenderer = instantiationService.createInstance(QuickPickItemElementRenderer, hoverDelegate, this.styles.toggle); + this._separatorRenderer = this._register(instantiationService.createInstance(QuickPickSeparatorElementRenderer, hoverDelegate, this.styles.toggle)); + this._itemRenderer = this._register(instantiationService.createInstance(QuickPickItemElementRenderer, hoverDelegate, this.styles.toggle)); this._tree = this._register(instantiationService.createInstance( WorkbenchObjectTree, 'QuickInput', @@ -786,6 +801,8 @@ export class QuickInputList extends Disposable { } )); this._tree.getHTMLElement().id = id; + this._register(this._itemRenderer.onDidDisposeFocusedElement(() => this._tree.domFocus())); + this._register(this._separatorRenderer.onDidDisposeFocusedElement(() => this._tree.domFocus())); this._registerListeners(); } diff --git a/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts b/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts index 896564c768a..893665466a9 100644 --- a/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts +++ b/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts @@ -117,6 +117,9 @@ export class QuickInputTreeController extends Disposable { identityProvider: new QuickInputTreeIdentityProvider() } )); + this._register(this._renderer.onDidDisposeFocusedElement(() => { + this._tree.domFocus(); + })); this.registerCheckboxStateListeners(); this.registerOnDidChangeFocus(); } @@ -297,18 +300,22 @@ export class QuickInputTreeController extends Disposable { })); this._register(this._checkboxStateHandler.onDidChangeCheckboxState(e => { - this.updateCheckboxState(e.item, e.checked === true); + this.updateCheckboxState(e.item, e.checked === true, true); + this._tree.setFocus([e.item]); + this._tree.setSelection([e.item]); })); } - private updateCheckboxState(item: IQuickTreeItem, newState: boolean): void { + private updateCheckboxState(item: IQuickTreeItem, newState: boolean, skipItemRerender = false): void { if ((item.checked ?? false) === newState) { return; // No change } // Handle checked item item.checked = newState; - this._tree.rerender(item); + if (!skipItemRerender) { + this._tree.rerender(item); + } // Handle children of the checked item const updateSet = new Set(); diff --git a/src/vs/platform/quickinput/browser/tree/quickInputTreeRenderer.ts b/src/vs/platform/quickinput/browser/tree/quickInputTreeRenderer.ts index 1cc5c821591..362c6ac18bc 100644 --- a/src/vs/platform/quickinput/browser/tree/quickInputTreeRenderer.ts +++ b/src/vs/platform/quickinput/browser/tree/quickInputTreeRenderer.ts @@ -48,6 +48,16 @@ export class QuickInputTreeRenderer extends Disposable static readonly ID = 'quickInputTreeElement'; templateId = QuickInputTreeRenderer.ID; + private readonly _onDidDisposeFocusedElement = this._register(new Emitter()); + + /** + * This event is emitted when the renderer disposes an element that has focus. + * This allows the list to re-focus itself and prevent focus from being lost + * (potentially causing quickinput to dismiss itself) when an element is + * removed while focused. + */ + public readonly onDidDisposeFocusedElement = this._onDidDisposeFocusedElement.event; + constructor( private readonly _hoverDelegate: IHoverDelegate | undefined, private readonly _buttonTriggeredEmitter: Emitter>, @@ -172,6 +182,9 @@ export class QuickInputTreeRenderer extends Disposable } disposeElement(_element: ITreeNode, _index: number, templateData: IQuickTreeTemplateData, _details?: ITreeElementRenderDetails): void { + if (dom.isAncestorOfActiveElement(templateData.entry)) { + this._onDidDisposeFocusedElement.fire(); + } templateData.toDisposeElement.clear(); templateData.actionBar.setActions([]); } diff --git a/src/vs/platform/remote/common/remoteAgentEnvironment.ts b/src/vs/platform/remote/common/remoteAgentEnvironment.ts index 51cb401dcfb..e63de4e540f 100644 --- a/src/vs/platform/remote/common/remoteAgentEnvironment.ts +++ b/src/vs/platform/remote/common/remoteAgentEnvironment.ts @@ -12,6 +12,7 @@ export interface IRemoteAgentEnvironment { pid: number; connectionToken: string; appRoot: URI; + execPath: string; tmpDir: URI; settingsPath: URI; mcpResource: URI; diff --git a/src/vs/platform/theme/electron-main/themeMainService.ts b/src/vs/platform/theme/electron-main/themeMainService.ts index 531fc6171e6..94c46d84185 100644 --- a/src/vs/platform/theme/electron-main/themeMainService.ts +++ b/src/vs/platform/theme/electron-main/themeMainService.ts @@ -23,4 +23,12 @@ export interface IThemeMainService { getWindowSplash(workspace: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | undefined): IPartsSplash | undefined; getColorScheme(): IColorScheme; + + /** + * Whether OS color-scheme auto-detection is active. + * Returns `true` when the `window.autoDetectColorScheme` setting is enabled, + * or for fresh installs where no theme has been stored yet and the user + * has not explicitly configured the setting (e.g. via settings sync). + */ + isAutoDetectColorScheme(): boolean; } diff --git a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts index 25e5fe37f77..56c103875a6 100644 --- a/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts +++ b/src/vs/platform/theme/electron-main/themeMainServiceImpl.ts @@ -113,7 +113,7 @@ export class ThemeMainService extends Disposable implements IThemeMainService { } private updateSystemColorTheme(): void { - if (isLinux || Setting.DETECT_COLOR_SCHEME.getValue(this.configurationService)) { + if (isLinux || this.isAutoDetectColorScheme()) { electron.nativeTheme.themeSource = 'system'; // only with `system` we can detect the system color scheme } else { switch (Setting.SYSTEM_COLOR_THEME.getValue(this.configurationService)) { @@ -174,13 +174,26 @@ export class ThemeMainService extends Disposable implements IThemeMainService { return colorScheme.dark ? ThemeTypeSelector.HC_BLACK : ThemeTypeSelector.HC_LIGHT; } - if (Setting.DETECT_COLOR_SCHEME.getValue(this.configurationService)) { + if (this.isAutoDetectColorScheme()) { return colorScheme.dark ? ThemeTypeSelector.VS_DARK : ThemeTypeSelector.VS; } return undefined; } + isAutoDetectColorScheme(): boolean { + if (Setting.DETECT_COLOR_SCHEME.getValue(this.configurationService)) { + return true; + } + // For new installs with no stored theme, auto-detect OS color scheme by default + // unless the user has explicitly configured the setting (e.g. via settings sync) + if (!this.stateService.getItem(THEME_STORAGE_KEY)) { + const { userValue } = this.configurationService.inspect(Setting.DETECT_COLOR_SCHEME.key); + return userValue === undefined; + } + return false; + } + getBackgroundColor(): string { const preferred = this.getPreferredBaseTheme(); const stored = this.getStoredBaseTheme(); diff --git a/src/vs/platform/url/common/urlGlob.ts b/src/vs/platform/url/common/urlGlob.ts index 9cfd6f530d2..9202ee672cd 100644 --- a/src/vs/platform/url/common/urlGlob.ts +++ b/src/vs/platform/url/common/urlGlob.ts @@ -131,7 +131,10 @@ function doUrlPartMatch( if (!['/', ':'].includes(urlPart[urlOffset])) { options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset + 1, globUrlOffset)); } - options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset, globUrlOffset + 2)); + // Only skip *. if we're at the start (bare domain) or at a dot boundary + if (urlOffset === 0 || urlPart[urlOffset - 1] === '.') { + options.push(doUrlPartMatch(memo, includePortLogic, urlPart, globUrlPart, urlOffset, globUrlOffset + 2)); + } } if (globUrlPart[globUrlOffset] === '*') { diff --git a/src/vs/platform/url/electron-main/electronUrlListener.ts b/src/vs/platform/url/electron-main/electronUrlListener.ts index 49c508e8450..fe9ce0b757d 100644 --- a/src/vs/platform/url/electron-main/electronUrlListener.ts +++ b/src/vs/platform/url/electron-main/electronUrlListener.ts @@ -7,7 +7,7 @@ import { app, Event as ElectronEvent } from 'electron'; import { disposableTimeout } from '../../../base/common/async.js'; import { Event } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import { isWindows } from '../../../base/common/platform.js'; +import { INodeProcess, isWindows } from '../../../base/common/platform.js'; import { URI } from '../../../base/common/uri.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { ILogService } from '../../log/common/log.js'; @@ -50,8 +50,9 @@ export class ElectronURLListener extends Disposable { // Windows: install as protocol handler // Skip in portable mode: the registered command wouldn't preserve - // portable mode settings, causing issues with OAuth flows - if (isWindows && !environmentMainService.isPortable) { + // portable mode settings, causing issues with OAuth flows. + // Skip for embedded apps: protocol handler is registered at install time. + if (isWindows && !environmentMainService.isPortable && !(process as INodeProcess).isEmbeddedApp) { const windowsParameters = environmentMainService.isBuilt ? [] : [`"${environmentMainService.appRoot}"`]; windowsParameters.push('--open-url', '--'); app.setAsDefaultProtocolClient(productService.urlProtocol, process.execPath, windowsParameters); diff --git a/src/vs/platform/url/test/common/urlGlob.test.ts b/src/vs/platform/url/test/common/urlGlob.test.ts index 83534f62ad6..90fee896069 100644 --- a/src/vs/platform/url/test/common/urlGlob.test.ts +++ b/src/vs/platform/url/test/common/urlGlob.test.ts @@ -56,8 +56,29 @@ suite('urlGlob', () => { assert.strictEqual(testUrlMatchesGlob('https://sub.example.com', 'https://*.example.com'), true); assert.strictEqual(testUrlMatchesGlob('https://sub.domain.example.com', 'https://*.example.com'), true); assert.strictEqual(testUrlMatchesGlob('https://example.com', 'https://*.example.com'), true); - // *. matches any number of characters before the domain, including other domains - assert.strictEqual(testUrlMatchesGlob('https://notexample.com', 'https://*.example.com'), true); + }); + + test('subdomain wildcard must match on dot boundary', () => { + // Should NOT match: no dot boundary before the domain + assert.strictEqual(testUrlMatchesGlob('https://notexample.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evil-microsoft.com', 'https://*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evilmicrosoft.com', 'https://*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://evil-example.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://myexample.com', 'https://*.example.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://notexample.com/path', 'https://*.example.com/path'), false); + + // Should match: proper subdomain with dot boundary + assert.strictEqual(testUrlMatchesGlob('https://sub.microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://a.b.c.microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://microsoft.com', 'https://*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('https://sub.example.com/path', 'https://*.example.com/path'), true); + }); + + test('subdomain wildcard without scheme must match on dot boundary', () => { + assert.strictEqual(testUrlMatchesGlob('https://evil-microsoft.com', '*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('http://evil-microsoft.com', '*.microsoft.com'), false); + assert.strictEqual(testUrlMatchesGlob('https://sub.microsoft.com', '*.microsoft.com'), true); + assert.strictEqual(testUrlMatchesGlob('http://sub.microsoft.com', '*.microsoft.com'), true); }); test('port matching', () => { diff --git a/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts b/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts index ae70e341c0c..f5d2edb2796 100644 --- a/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts +++ b/src/vs/platform/webContentExtractor/node/sharedWebContentExtractorService.ts @@ -31,7 +31,7 @@ export class SharedWebContentExtractorService implements ISharedWebContentExtrac const content = VSBuffer.wrap(await (response as unknown as { bytes: () => Promise> } /* workaround https://github.com/microsoft/TypeScript/issues/61826 */).bytes()); return content; } catch (err) { - console.log(err); + console.error(err); return undefined; } } diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index 9771a607efe..ec4b29a101b 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -1561,7 +1561,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic os: { release: release(), hostname: hostname(), arch: arch() }, autoDetectHighContrast: windowConfig?.autoDetectHighContrast ?? true, - autoDetectColorScheme: windowConfig?.autoDetectColorScheme ?? false, + autoDetectColorScheme: windowConfig?.autoDetectColorScheme ?? this.themeMainService.isAutoDetectColorScheme(), accessibilitySupport: app.accessibilitySupportEnabled, colorScheme: this.themeMainService.getColorScheme(), policiesData: this.policyService.serialize(), diff --git a/src/vs/server/node/remoteAgentEnvironmentImpl.ts b/src/vs/server/node/remoteAgentEnvironmentImpl.ts index 6505a5aa7d8..640c74695a5 100644 --- a/src/vs/server/node/remoteAgentEnvironmentImpl.ts +++ b/src/vs/server/node/remoteAgentEnvironmentImpl.ts @@ -112,6 +112,7 @@ export class RemoteAgentEnvironmentChannel implements IServerChannel { pid: process.pid, connectionToken: (this._connectionToken.type !== ServerConnectionTokenType.None ? this._connectionToken.value : ''), appRoot: URI.file(this._environmentService.appRoot), + execPath: process.execPath, tmpDir: this._environmentService.tmpDir, settingsPath: this._environmentService.machineSettingsResource, mcpResource: this._environmentService.mcpResource, diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 98fbccd9873..acb2203f9af 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -15,18 +15,21 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ ├── aiCustomizationManagementEditor.ts # SplitView list/editor ├── aiCustomizationManagementEditorInput.ts # Singleton input ├── aiCustomizationListWidget.ts # Search + grouped list + harness toggle +├── aiCustomizationListWidgetUtils.ts # List item helpers (truncation, etc.) ├── aiCustomizationDebugPanel.ts # Debug diagnostics panel ├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl -├── customizationHarnessService.ts # Core harness service impl (VS Code harness) +├── customizationHarnessService.ts # Core harness service impl (agent-gated) ├── customizationCreatorService.ts # AI-guided creation flow -├── mcpListWidget.ts # MCP servers section +├── customizationGroupHeaderRenderer.ts # Collapsible group header renderer +├── mcpListWidget.ts # MCP servers section (Extensions + Built-in groups) +├── pluginListWidget.ts # Agent plugins section ├── aiCustomizationIcons.ts # Icons └── media/ └── aiCustomizationManagement.css src/vs/workbench/contrib/chat/common/ -├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter -└── customizationHarnessService.ts # ICustomizationHarnessService + CustomizationHarness enum +├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter + BUILTIN_STORAGE +└── customizationHarnessService.ts # ICustomizationHarnessService + ISectionOverride + helpers ``` The tree view and overview live in `vs/sessions` (sessions window only): @@ -46,9 +49,10 @@ Sessions-specific overrides: ``` src/vs/sessions/contrib/chat/browser/ ├── aiCustomizationWorkspaceService.ts # Sessions workspace service override -├── customizationHarnessService.ts # Sessions harness service (CLI + Claude harnesses) +├── customizationHarnessService.ts # Sessions harness service (CLI harness only) └── promptsService.ts # AgenticPromptsService (CLI user roots) src/vs/sessions/contrib/sessions/browser/ +├── aiCustomizationShortcutsWidget.ts # Shortcuts widget ├── customizationCounts.ts # Source count utilities (type-aware) └── customizationsToolbar.contribution.ts # Sidebar customization links ``` @@ -59,7 +63,7 @@ The `IAICustomizationWorkspaceService` interface controls per-window behavior: | Property / Method | Core VS Code | Sessions Window | |----------|-------------|----------| -| `managementSections` | All sections except Models | Same minus MCP | +| `managementSections` | All sections except Models | All sections except Models | | `getStorageSourceFilter(type)` | Delegates to `ICustomizationHarnessService` | Delegates to `ICustomizationHarnessService` | | `isSessionsWindow` | `false` | `true` | | `activeProjectRoot` | First workspace folder | Active session worktree | @@ -71,19 +75,34 @@ Storage answers "where did this come from?"; harness answers "who consumes it?". The service is defined in `common/customizationHarnessService.ts` which also provides: - **`CustomizationHarnessServiceBase`** — reusable base class handling active-harness state, the observable list, and `getStorageSourceFilter` dispatch. -- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor` — parameterized by an `extras` array (the additional storage sources beyond `local`, `user`, `plugin`). Core passes `[PromptsStorage.extension]`; sessions passes `[BUILTIN_STORAGE]`. -- **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge so it isn't duplicated. +- **`ISectionOverride`** — per-section UI customization: `commandId` (command invocation), `rootFile` + `label` (root-file creation), `typeLabel` (custom type name), `fileExtension` (override default), `rootFileShortcuts` (dropdown shortcuts). +- **Factory functions** — `createVSCodeHarnessDescriptor`, `createCliHarnessDescriptor`, `createClaudeHarnessDescriptor`. The VS Code harness receives `[PromptsStorage.extension, BUILTIN_STORAGE]` as extras; CLI and Claude in core receive `[]` (no extension source). Sessions CLI receives `[BUILTIN_STORAGE]`. +- **Well-known root helpers** — `getCliUserRoots(userHome)` and `getClaudeUserRoots(userHome)` centralize the `~/.copilot`, `~/.claude`, `~/.agents` path knowledge. +- **Filter helpers** — `matchesWorkspaceSubpath()` for segment-safe subpath matching; `matchesInstructionFileFilter()` for filename/path-prefix pattern matching. Available harnesses: | Harness | Label | Description | |---------|-------|-------------| -| `vscode` | VS Code | Shows all storage sources (default in core) | +| `vscode` | Local | Shows all storage sources (default in core) | | `cli` | Copilot CLI | Restricts user roots to `~/.copilot`, `~/.claude`, `~/.agents` | -| `claude` | Claude | Restricts user roots to `~/.claude` | +| `claude` | Claude | Restricts user roots to `~/.claude`; hides Prompts + Plugins sections | -In core VS Code, all three harnesses are registered; VS Code is the default. -In sessions, `cli` and `claude` harnesses are registered with a toggle bar above the list. +In core VS Code, all three harnesses are registered but CLI and Claude only appear when their respective agents are registered (`requiredAgentId` checked via `IChatAgentService`). VS Code is the default. +In sessions, only CLI is registered (single harness, toggle bar hidden). + +### IHarnessDescriptor + +Key properties on the harness descriptor: + +| Property | Purpose | +|----------|--------| +| `hiddenSections` | Sidebar sections to hide (e.g. Claude: `[Prompts, Plugins]`) | +| `workspaceSubpaths` | Restrict file creation/display to directories (e.g. Claude: `['.claude']`) | +| `hideGenerateButton` | Replace "Generate X" sparkle button with "New X" | +| `sectionOverrides` | Per-section `ISectionOverride` map for button behavior | +| `requiredAgentId` | Agent ID that must be registered for harness to appear | +| `instructionFileFilter` | Filename/path patterns to filter instruction items | ### IStorageSourceFilter @@ -99,9 +118,7 @@ interface IStorageSourceFilter { The shared `applyStorageSourceFilter()` helper applies this filter to any `{uri, storage}` array. -**Sessions filter behavior by harness and type:** - -CLI harness: +**Sessions filter behavior (CLI harness):** | Type | sources | includedUserFileRoots | |------|---------|----------------------| @@ -109,15 +126,46 @@ CLI harness: | Prompts | `[local, user, plugin, builtin]` | `undefined` (all roots) | | Agents, Skills, Instructions | `[local, user, plugin, builtin]` | `[~/.copilot, ~/.claude, ~/.agents]` | -Claude harness: +**Core VS Code filter behavior:** + +Local harness: all types use `[local, user, extension, plugin, builtin]` with no user root filter. Items from the default chat extension (`productService.defaultChatAgent.chatExtensionId`) are grouped under "Built-in" via `groupKey` override in the list widget. + +CLI harness (core): | Type | sources | includedUserFileRoots | |------|---------|----------------------| | Hooks | `[local, plugin]` | N/A | -| Prompts | `[local, user, plugin, builtin]` | `undefined` (all roots) | -| Agents, Skills, Instructions | `[local, user, plugin, builtin]` | `[~/.claude]` | +| Prompts | `[local, user, plugin]` | `undefined` (all roots) | +| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.copilot, ~/.claude, ~/.agents]` | -**Core VS Code:** All types use `[local, user, extension, plugin]` with no user root filter. +Claude harness (core): + +| Type | sources | includedUserFileRoots | +|------|---------|----------------------| +| Hooks | `[local, plugin]` | N/A | +| Prompts | `[local, user, plugin]` | `undefined` (all roots) | +| Agents, Skills, Instructions | `[local, user, plugin]` | `[~/.claude]` | + +Claude additionally applies: +- `hiddenSections: [Prompts, Plugins]` +- `instructionFileFilter: ['CLAUDE.md', 'CLAUDE.local.md', '.claude/rules/', 'copilot-instructions.md']` +- `workspaceSubpaths: ['.claude']` (instruction files matching `instructionFileFilter` are exempt) +- `sectionOverrides`: Hooks → `copilot.claude.hooks` command; Instructions → "Add CLAUDE.md" primary, "Rule" type label, `.md` file extension + +### Built-in Extension Grouping (Core VS Code) + +In core VS Code, customization items contributed by the default chat extension (`productService.defaultChatAgent.chatExtensionId`, typically `GitHub.copilot-chat`) are grouped under the "Built-in" header in the management editor list widget, separate from third-party "Extensions". + +This follows the same pattern as the MCP list widget, which determines grouping at the UI layer by inspecting collection sources. The list widget uses `IProductService` to identify the chat extension and sets `groupKey: BUILTIN_STORAGE` on matching items: + +- **Agents**: checks `agent.source.extensionId` against the chat extension ID +- **Skills**: builds a URI→ExtensionIdentifier lookup from `listPromptFiles(PromptsType.skill)`, then checks each skill's URI +- **Prompts**: checks `command.promptPath.extension?.identifier` +- **Instructions/Hooks**: checks `item.extension?.identifier` via `IPromptPath` + +The underlying `storage` remains `PromptsStorage.extension` — the grouping is a UI-level override via `groupKey` that keeps `applyStorageSourceFilter` working with existing storage types while visually distinguishing chat-extension items from third-party extension items. + +`BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility. ### AgenticPromptsService (Sessions) @@ -149,7 +197,11 @@ Prompt files bundled with the Sessions app live in `src/vs/sessions/prompts/`. T | Skills | `findAgentSkills()` | Parsed skills with frontmatter | | Prompts | `getPromptSlashCommands()` | Filters out skill-type commands | | Instructions | `listPromptFiles()` + `listAgentInstructions()` | Includes AGENTS.md, CLAUDE.md etc. | -| Hooks | `listPromptFiles()` | Raw hook files | +| Hooks | `listPromptFiles()` | Individual hooks parsed via `parseHooksFromFile()` | + +### Item Badges + +`IAICustomizationListItem.badge` is an optional string that renders as a small inline tag next to the item name (same visual style as the MCP "Bridged" badge). For context instructions, this badge shows the raw `applyTo` pattern (e.g. a glob like `**/*.ts`), while the tooltip (`badgeTooltip`) explains the behavior. The badge text is also included in search filtering. ### Debug Panel @@ -175,8 +227,9 @@ All commands and UI respect `ChatContextKeys.enabled` and the `chat.customizatio ## Settings -Settings use the `chat.customizationsMenu.` namespace: +Settings use the `chat.customizationsMenu.` and `chat.customizations.` namespaces: | Setting | Default | Description | |---------|---------|-------------| | `chat.customizationsMenu.enabled` | `true` | Show the Chat Customizations editor in the Command Palette | +| `chat.customizations.harnessSelector.enabled` | `true` | Show the harness selector dropdown in the sidebar | diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index ad54a086c7f..08e5510cf6e 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -374,7 +374,7 @@ The Agent Sessions workbench uses specialized part implementations that extend t | Configuration listening | Many settings | Minimal | | Context menu actions | Full set | Simplified | | Title bar | Full support | Sidebar: `hasTitle: true` (with footer); ChatBar: `hasTitle: false`; Auxiliary Bar & Panel: `hasTitle: true` | -| Visual margins | None | Auxiliary Bar: 8px top/bottom/right (card appearance); Panel: 8px bottom/left/right (card appearance); Sidebar: 0 (flush) | +| Visual margins | None | Auxiliary Bar: 16px top/right, 18px bottom (card appearance); Panel: 18px bottom, 16px left/right (card appearance); Sidebar: 0 (flush) | ### 9.3 Part Creation diff --git a/src/vs/sessions/browser/collapsedPartWidgets.ts b/src/vs/sessions/browser/collapsedPartWidgets.ts new file mode 100644 index 00000000000..e5c1789651b --- /dev/null +++ b/src/vs/sessions/browser/collapsedPartWidgets.ts @@ -0,0 +1,313 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/collapsedPanelWidget.css'; +import { $, addDisposableListener, append, EventType } from '../../base/browser/dom.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../base/common/lifecycle.js'; +import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; +import { IHoverService } from '../../platform/hover/browser/hover.js'; +import { createInstantHoverDelegate } from '../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { localize } from '../../nls.js'; +import { ThemeIcon } from '../../base/common/themables.js'; +import { Codicon } from '../../base/common/codicons.js'; +import { IAgentSessionsService } from '../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionStatus, getAgentChangesSummary, IAgentSession } from '../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { ICommandService } from '../../platform/commands/common/commands.js'; +import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; +import { ViewContainerLocation } from '../../workbench/common/views.js'; +import { URI } from '../../base/common/uri.js'; +import { Event } from '../../base/common/event.js'; + +// Duplicated from vs/sessions/contrib/changes/browser/changesView.ts to avoid a layering import. +const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; + +/** + * Collapsed widget shown in the bottom-left corner when the sidebar is hidden. + * Shows session status counts (active, errors, completed) and a new session button. + */ +export class CollapsedSidebarWidget extends Disposable { + + private readonly element: HTMLElement; + private readonly indicatorContainer: HTMLElement; + private readonly indicatorDisposables = this._register(new DisposableStore()); + private readonly hoverDelegate = this._register(createInstantHoverDelegate()); + + constructor( + parent: HTMLElement, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IHoverService private readonly hoverService: IHoverService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @ICommandService private readonly commandService: ICommandService, + ) { + super(); + + this.element = append(parent, $('.collapsed-panel-widget.collapsed-sidebar-widget')); + + // Sidebar toggle button (leftmost) + this._register(this.createSidebarToggleButton()); + + // New session button (next to panel toggle) + this._register(this.createNewSessionButton()); + + // Session status indicators (rightmost) + this.indicatorContainer = append(this.element, $('.collapsed-panel-button.collapsed-sidebar-status')); + + // Listen for session changes + this._register(this.agentSessionsService.model.onDidChangeSessions(() => this.rebuildIndicators())); + + // Initial build + this.rebuildIndicators(); + + this.hide(); + } + + private createNewSessionButton(): DisposableStore { + const store = new DisposableStore(); + const btn = append(this.element, $('.collapsed-panel-button.collapsed-sidebar-new-session')); + append(btn, $(ThemeIcon.asCSSSelector(Codicon.newSession))); + + store.add(this.hoverService.setupManagedHover(this.hoverDelegate, btn, localize('newSession', "New Session"))); + + store.add(addDisposableListener(btn, EventType.CLICK, () => { + this.commandService.executeCommand('workbench.action.sessions.newChat'); + })); + + return store; + } + + private createSidebarToggleButton(): DisposableStore { + const store = new DisposableStore(); + const btn = append(this.element, $('.collapsed-panel-button.collapsed-sidebar-panel-toggle')); + let iconElement: HTMLElement | undefined; + + const updateIcon = () => { + const sidebarVisible = this.layoutService.isVisible(Parts.SIDEBAR_PART); + const icon = sidebarVisible ? Codicon.layoutSidebarLeft : Codicon.layoutSidebarLeftOff; + iconElement?.remove(); + iconElement = append(btn, $(ThemeIcon.asCSSSelector(icon))); + }; + + updateIcon(); + + store.add(this.hoverService.setupManagedHover(this.hoverDelegate, btn, localize('toggleSidebar', "Toggle Side Bar"))); + + store.add(addDisposableListener(btn, EventType.CLICK, () => { + this.commandService.executeCommand('workbench.action.agentToggleSidebarVisibility'); + })); + + store.add(this.layoutService.onDidChangePartVisibility(e => { + if (e.partId === Parts.SIDEBAR_PART) { + updateIcon(); + } + })); + + return store; + } + + private rebuildIndicators(): void { + this.indicatorDisposables.clear(); + this.indicatorContainer.textContent = ''; + + const sessions = this.agentSessionsService.model.sessions; + const counts = this.countSessionsByStatus(sessions); + + const tooltipParts: string[] = []; + + // In-progress (matches agentSessionsViewer: sessionInProgress) + if (counts.inProgress > 0) { + this.appendStatusSegment(Codicon.sessionInProgress, `${counts.inProgress}`, 'collapsed-sidebar-indicator-active'); + tooltipParts.push(localize('sessionsInProgress', "{0} session(s) in progress", counts.inProgress)); + } + + // Needs input (matches agentSessionsViewer: circleFilled) + if (counts.needsInput > 0) { + this.appendStatusSegment(Codicon.circleFilled, `${counts.needsInput}`, 'collapsed-sidebar-indicator-input'); + tooltipParts.push(localize('sessionsNeedInput', "{0} session(s) need input", counts.needsInput)); + } + + // Failed (matches agentSessionsViewer: error) + if (counts.failed > 0) { + this.appendStatusSegment(Codicon.error, `${counts.failed}`, 'collapsed-sidebar-indicator-error'); + tooltipParts.push(localize('sessionsFailed', "{0} session(s) with errors", counts.failed)); + } + + // Unread (matches agentSessionsViewer: circleFilled with textLink-foreground) + if (counts.unread > 0) { + this.appendStatusSegment(Codicon.circleFilled, `${counts.unread}`, 'collapsed-sidebar-indicator-unread'); + tooltipParts.push(localize('sessionsUnread', "{0} unread session(s)", counts.unread)); + } + + // If no sessions at all + if (sessions.length === 0) { + this.appendStatusSegment(Codicon.commentDiscussion, '0', 'collapsed-sidebar-indicator-empty'); + tooltipParts.push(localize('noSessions', "No sessions")); + } + + if (tooltipParts.length > 0) { + this.indicatorDisposables.add(this.hoverService.setupManagedHover( + this.hoverDelegate, this.indicatorContainer, tooltipParts.join('\n') + )); + + this.indicatorDisposables.add(addDisposableListener(this.indicatorContainer, EventType.CLICK, () => { + this.layoutService.setPartHidden(false, Parts.SIDEBAR_PART); + })); + } + } + + private appendStatusSegment(icon: ThemeIcon, count: string, className: string): void { + const segment = append(this.indicatorContainer, $(`span.collapsed-sidebar-segment.${className}`)); + append(segment, $(ThemeIcon.asCSSSelector(icon))); + const label = append(segment, $('span.collapsed-sidebar-count')); + label.textContent = count; + } + + private countSessionsByStatus(sessions: IAgentSession[]): { inProgress: number; needsInput: number; failed: number; unread: number } { + let inProgress = 0; + let needsInput = 0; + let failed = 0; + let unread = 0; + + for (const session of sessions) { + if (session.isArchived()) { + continue; + } + switch (session.status) { + case AgentSessionStatus.InProgress: + inProgress++; + break; + case AgentSessionStatus.NeedsInput: + needsInput++; + break; + case AgentSessionStatus.Failed: + failed++; + break; + case AgentSessionStatus.Completed: + if (!session.isRead()) { + unread++; + } + break; + } + } + + return { inProgress, needsInput, failed, unread }; + } + + show(): void { + this.element.classList.remove('collapsed-panel-hidden'); + } + + hide(): void { + this.element.classList.add('collapsed-panel-hidden'); + } +} + +/** + * Widget shown in the titlebar right area showing file change counts + * (files, insertions, deletions) from the active session. + * Always visible — acts as a toggle for the auxiliary bar. + */ +export class CollapsedAuxiliaryBarWidget extends Disposable { + + private readonly element: HTMLElement; + private readonly changesBtn: HTMLElement; + private readonly indicatorDisposables = this._register(new DisposableStore()); + private readonly hoverDelegate = this._register(createInstantHoverDelegate()); + private activeSessionResource: (() => URI | undefined) | undefined; + private readonly activeSessionDisposable = this._register(new MutableDisposable()); + + constructor( + parent: HTMLElement, + windowControlsContainer: HTMLElement | undefined, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IHoverService private readonly hoverService: IHoverService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IPaneCompositePartService private readonly paneCompositeService: IPaneCompositePartService, + ) { + super(); + + this.element = $('div.collapsed-panel-widget.collapsed-auxbar-widget'); + + // Insert before the window-controls-container so the widget is not + // hidden behind the WCO on Windows. + if (windowControlsContainer && windowControlsContainer.parentElement === parent) { + parent.insertBefore(this.element, windowControlsContainer); + } else { + append(parent, this.element); + } + + this._register(toDisposable(() => this.element.remove())); + + const indicatorContainer = append(this.element, $('.collapsed-panel-buttons')); + this.changesBtn = append(indicatorContainer, $('.collapsed-panel-button.collapsed-auxbar-indicator')); + + // Click handler lives on the persistent button + this._register(addDisposableListener(this.changesBtn, EventType.CLICK, () => { + const isVisible = !this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); + this.layoutService.setPartHidden(!isVisible, Parts.AUXILIARYBAR_PART); + if (isVisible) { + this.paneCompositeService.openPaneComposite(CHANGES_VIEW_CONTAINER_ID, ViewContainerLocation.AuxiliaryBar); + } + })); + + // Listen for session changes to update indicators + this._register(this.agentSessionsService.model.onDidChangeSessions(() => this.rebuildIndicators())); + + // Initial build + this.rebuildIndicators(); + } + + /** + * Bind an active-session provider so indicators reflect the currently + * selected session rather than aggregating all sessions. + */ + setActiveSessionProvider(getResource: () => URI | undefined, onDidChange: Event): void { + this.activeSessionResource = getResource; + this.activeSessionDisposable.value = onDidChange(() => this.rebuildIndicators()); + this.rebuildIndicators(); + } + + private rebuildIndicators(): void { + this.indicatorDisposables.clear(); + this.changesBtn.textContent = ''; + + // Get change summary from the active session + const resource = this.activeSessionResource?.(); + const session = resource ? this.agentSessionsService.getSession(resource) : undefined; + const summary = session ? getAgentChangesSummary(session.changes) : undefined; + + // Rebuild inner content: [diff icon] +insertions -deletions + append(this.changesBtn, $(ThemeIcon.asCSSSelector(Codicon.diffMultiple))); + + if (summary && summary.insertions > 0) { + const insLabel = append(this.changesBtn, $('span.collapsed-auxbar-count.collapsed-auxbar-insertions')); + insLabel.textContent = `+${summary.insertions}`; + } + + if (summary && summary.deletions > 0) { + const delLabel = append(this.changesBtn, $('span.collapsed-auxbar-count.collapsed-auxbar-deletions')); + delLabel.textContent = `-${summary.deletions}`; + } + + if (summary) { + this.indicatorDisposables.add(this.hoverService.setupManagedHover( + this.hoverDelegate, this.changesBtn, + localize('changesSummary', "{0} file(s) changed, {1} insertion(s), {2} deletion(s)", summary.files, summary.insertions, summary.deletions) + )); + } else { + this.indicatorDisposables.add(this.hoverService.setupManagedHover( + this.hoverDelegate, this.changesBtn, + localize('showChanges', "Show Changes") + )); + } + } + + /** + * Update the active visual state of the widget based on + * whether the auxiliary bar is currently visible. + */ + updateActiveState(auxiliaryBarVisible: boolean): void { + this.element.classList.toggle('active', auxiliaryBarVisible); + } +} diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index c9bf983b7c9..8f6701aecb4 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -19,10 +19,6 @@ import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/ import { SessionsWelcomeVisibleContext } from '../common/contextkeys.js'; // Register Icons -const panelLeftIcon = registerIcon('agent-panel-left', Codicon.layoutSidebarLeft, localize('panelLeft', "Represents a side bar in the left position")); -const panelLeftOffIcon = registerIcon('agent-panel-left-off', Codicon.layoutSidebarLeftOff, localize('panelLeftOff', "Represents a side bar in the left position that is hidden")); -const panelRightIcon = registerIcon('agent-panel-right', Codicon.layoutSidebarRight, localize('panelRight', "Represents a secondary side bar in the right position")); -const panelRightOffIcon = registerIcon('agent-panel-right-off', Codicon.layoutSidebarRightOff, localize('panelRightOff', "Represents a secondary side bar in the right position that is hidden")); const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); class ToggleSidebarVisibilityAction extends Action2 { @@ -34,13 +30,7 @@ class ToggleSidebarVisibilityAction extends Action2 { super({ id: ToggleSidebarVisibilityAction.ID, title: localize2('toggleSidebar', 'Toggle Primary Side Bar Visibility'), - icon: panelLeftOffIcon, - toggled: { - condition: SideBarVisibleContext, - icon: panelLeftIcon, - title: localize('primary sidebar', "Primary Side Bar"), - mnemonicTitle: localize({ key: 'primary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Primary Side Bar"), - }, + icon: panelCloseIcon, metadata: { description: localize('openAndCloseSidebar', 'Open/Show and Close/Hide Sidebar'), }, @@ -52,10 +42,10 @@ class ToggleSidebarVisibilityAction extends Action2 { }, menu: [ { - id: Menus.TitleBarLeftLayout, + id: Menus.SidebarTitle, group: 'navigation', - order: 0, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) + order: 100, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SideBarVisibleContext, SessionsWelcomeVisibleContext.toNegated()) }, { id: Menus.TitleBarContext, @@ -90,13 +80,7 @@ class ToggleSecondarySidebarVisibilityAction extends Action2 { super({ id: ToggleSecondarySidebarVisibilityAction.ID, title: localize2('toggleSecondarySidebar', 'Toggle Secondary Side Bar Visibility'), - icon: panelRightOffIcon, - toggled: { - condition: AuxiliaryBarVisibleContext, - icon: panelRightIcon, - title: localize('secondary sidebar', "Secondary Side Bar"), - mnemonicTitle: localize({ key: 'secondary sidebar mnemonic', comment: ['&& denotes a mnemonic'] }, "&&Secondary Side Bar"), - }, + icon: panelCloseIcon, metadata: { description: localize('openAndCloseSecondarySidebar', 'Open/Show and Close/Hide Secondary Side Bar'), }, @@ -104,10 +88,10 @@ class ToggleSecondarySidebarVisibilityAction extends Action2 { f1: true, menu: [ { - id: Menus.TitleBarRightLayout, + id: Menus.AuxiliaryBarTitle, group: 'navigation', - order: 10, - when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated()) + order: 100, + when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), AuxiliaryBarVisibleContext, SessionsWelcomeVisibleContext.toNegated()) }, { id: Menus.TitleBarContext, diff --git a/src/vs/sessions/browser/media/collapsedPanelWidget.css b/src/vs/sessions/browser/media/collapsedPanelWidget.css new file mode 100644 index 00000000000..b7d2d4e78c4 --- /dev/null +++ b/src/vs/sessions/browser/media/collapsedPanelWidget.css @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Collapsed Part Widgets (shared, inline in titlebar) ---- */ + +.agent-sessions-workbench .collapsed-panel-widget { + display: flex; + flex-direction: row; + align-items: center; + gap: 2px; + padding: 0 4px; + height: 100%; + position: relative; + z-index: 2500; /* Above titlebar toolbar actions so widgets remain clickable */ + -webkit-app-region: no-drag; +} + +.agent-sessions-workbench .collapsed-panel-widget.collapsed-panel-hidden { + display: none; +} + +/* ---- Sidebar widget (in titlebar-left) ---- */ + +.agent-sessions-workbench .collapsed-sidebar-widget { + order: 10; + padding-left: 8px; +} + +/* ---- Auxiliary Bar widget (in titlebar-right) ---- */ + +.agent-sessions-workbench .collapsed-auxbar-widget { + order: 1; +} + +.agent-sessions-workbench .collapsed-auxbar-widget.active .collapsed-panel-button { + background: var(--vscode-toolbar-activeBackground); + border-radius: var(--vscode-cornerRadius-medium); +} + +/* ---- Buttons (match titlebar action-item sizing) ---- */ + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-buttons { + display: flex; + flex-direction: row; + align-items: center; + gap: 0; + height: 100%; +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button { + display: flex; + align-items: center; + justify-content: center; + height: 22px; + padding: 0 4px; + border-radius: var(--vscode-cornerRadius-medium); + cursor: pointer; + color: inherit; + gap: 3px; +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button:active { + background: var(--vscode-toolbar-activeBackground); +} + +.agent-sessions-workbench .collapsed-panel-widget .collapsed-panel-button .codicon { + font-size: 16px; + color: inherit; +} + +/* ---- Consolidated session status button ---- */ + +.agent-sessions-workbench .collapsed-panel-button.collapsed-sidebar-status { + gap: 6px; +} + +.agent-sessions-workbench .collapsed-sidebar-segment { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.agent-sessions-workbench .collapsed-panel-button .collapsed-sidebar-segment .codicon { + font-size: 14px; +} + +/* ---- Sidebar indicators ---- */ + +.agent-sessions-workbench .collapsed-sidebar-count, +.agent-sessions-workbench .collapsed-auxbar-count { + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 16px; + color: inherit; +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-active .codicon { + color: var(--vscode-textLink-foreground); +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-error { + color: var(--vscode-errorForeground); +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-input { + color: var(--vscode-list-warningForeground); +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-input .codicon { + animation: collapsed-sidebar-needs-input-pulse 2s ease-in-out infinite; +} + +@keyframes collapsed-sidebar-needs-input-pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-input .codicon { + animation: none; + } +} + +.agent-sessions-workbench .collapsed-sidebar-segment.collapsed-sidebar-indicator-unread { + color: var(--vscode-textLink-foreground); +} + +/* ---- Panel toggle button ---- */ + +.agent-sessions-workbench .collapsed-sidebar-panel-toggle { + opacity: 0.7; +} + +.agent-sessions-workbench .collapsed-sidebar-panel-toggle:hover { + opacity: 1; +} + +/* ---- Auxiliary bar indicators ---- */ + +.agent-sessions-workbench .collapsed-auxbar-insertions { + color: var(--vscode-gitDecoration-addedResourceForeground); +} + +.agent-sessions-workbench .collapsed-auxbar-deletions { + color: var(--vscode-gitDecoration-deletedResourceForeground); +} + +.agent-sessions-workbench span.collapsed-auxbar-count.collapsed-auxbar-insertions, +.agent-sessions-workbench span.collapsed-auxbar-count.collapsed-auxbar-deletions { + font-weight: 600; +} diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 10c2dfa67da..ca601ca71db 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -15,27 +15,72 @@ * * Margin values (must match the constants in the Part classes): * Sidebar: no card (flush, spans full height) - * Auxiliary bar: top=8, bottom=8, right=8 - * Panel: bottom=8, left=8, right=8 + * Auxiliary bar: top=16, bottom=18, right=16 + * Panel: bottom=18, left=16, right=16 */ .agent-sessions-workbench .part.sidebar { background: var(--vscode-sideBar-background); border-right: 1px solid var(--vscode-sideBar-border, transparent); + animation: sessions-card-enter-left 250ms cubic-bezier(0.0, 0.0, 0.2, 1) both; } .agent-sessions-workbench .part.auxiliarybar { - margin: 8px 8px 8px 0; + margin: 0 16px 2px 0; background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-radius: 8px; + animation: sessions-card-enter-right 250ms cubic-bezier(0.0, 0.0, 0.2, 1) both; } .agent-sessions-workbench .part.panel { - margin: 0 8px 8px 8px; + margin: 0 16px 18px 16px; background: var(--part-background); border: 1px solid var(--part-border-color, transparent); border-radius: 8px; + animation: sessions-card-enter-up 250ms cubic-bezier(0.0, 0.0, 0.2, 1) both; +} + +/* Card entrance animations */ +@keyframes sessions-card-enter-left { + from { + opacity: 0; + transform: translateX(-12px) scale(0.97); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes sessions-card-enter-right { + from { + opacity: 0; + transform: translateX(12px) scale(0.97); + } + to { + opacity: 1; + transform: translateX(0) scale(1); + } +} + +@keyframes sessions-card-enter-up { + from { + opacity: 0; + transform: translateY(12px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .part.sidebar, + .agent-sessions-workbench .part.auxiliarybar, + .agent-sessions-workbench .part.panel { + animation: none; + } } /* Grid background matches the chat bar / sidebar background */ @@ -54,16 +99,16 @@ .agent-sessions-workbench .interactive-session .interactive-item-container { max-width: 950px; margin: 0 auto; - padding-left: 8px; - padding-right: 8px; + padding-left: 16px; + padding-right: 16px; 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; + padding-left: 16px; + padding-right: 16px; box-sizing: border-box; } @@ -79,7 +124,7 @@ margin: 0 auto !important; display: inherit !important; /* Align with panel (terminal) card margin */ - padding: 4px 8px 6px 8px !important; + padding: 4px 16px 16px 16px !important; box-sizing: border-box; } @@ -88,3 +133,45 @@ .agent-sessions-workbench .agent-session-title { color: var(--vscode-list-activeSelectionForeground); } + +/* ---- Modal Editor Block ---- */ + +.agent-sessions-workbench .monaco-modal-editor-block { + background: rgba(0, 0, 0, 0.5); +} + +/* Hide the file icon in modal editor titles */ +.agent-sessions-workbench .modal-editor-title .monaco-icon-label::before, +.agent-sessions-workbench .modal-editor-title .monaco-icon-label > .monaco-icon-label-iconpath { + display: none; +} + +/* ---- Customization Empty State ---- */ + +/* Icon + title side by side in a row, description underneath */ +.agent-sessions-workbench .ai-customization-list-widget .list-empty-state .empty-state-header { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; +} + +.agent-sessions-workbench .ai-customization-list-widget .list-empty-state .empty-state-header > .empty-state-icon { + font-size: 16px; + margin-bottom: 0; + flex-shrink: 0; +} + +/* MCP / Plugin empty state: icon + title side by side */ +.agent-sessions-workbench .mcp-empty-state .empty-state-header { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; +} + +.agent-sessions-workbench .mcp-empty-state .empty-state-header > .empty-icon { + font-size: 16px; + margin-bottom: 0; + flex-shrink: 0; +} diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index cef38cedc82..fcc12cec6fd 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import '../../../workbench/browser/parts/auxiliarybar/media/auxiliaryBarPart.css'; +import './media/auxiliaryBarPart.css'; import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; @@ -47,9 +48,9 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { static readonly viewContainersWorkspaceStateKey = 'workbench.agentsession.auxiliarybar.viewContainersWorkspaceState'; /** Visual margin values for the card-like appearance */ - static readonly MARGIN_TOP = 8; - static readonly MARGIN_BOTTOM = 8; - static readonly MARGIN_RIGHT = 8; + static readonly MARGIN_TOP = 16; + static readonly MARGIN_BOTTOM = 2; + static readonly MARGIN_RIGHT = 16; // Action ID for run script - defined here to avoid layering issues private static readonly RUN_SCRIPT_ACTION_ID = 'workbench.action.agentSessions.runScript'; @@ -82,7 +83,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { return undefined; } - return Math.max(width, 340); + return Math.max(width, 380); } readonly priority = LayoutPriority.Low; diff --git a/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css b/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css new file mode 100644 index 00000000000..e7399a2f851 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/auxiliaryBarPart.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +/* ===== Modern action label styling for sessions auxiliary bar ===== */ + +/* Base label: lowercase text + heavier weight + pill padding */ +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item:not(.icon) .action-label { + text-transform: capitalize; + font-weight: 500; + border-radius: 4px; + padding: 0px 8px; + font-size: 12px; + line-height: 22px; +} + +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item { + padding-left: 0; + padding-right: 0; +} + +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .badge { + margin-left: -4px; + padding-right: 6px; +} + +.agent-sessions-workbench .part.auxiliarybar > .title { + padding-left: 4px; + padding-right: 2px; +} + +/* Hide the underline indicator entirely */ +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator:before { + display: none !important; +} + +/* Active/checked state: background container instead of underline */ +.agent-sessions-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { + background-color: var(--vscode-activityBarTop-activeBackground, color-mix(in srgb, var(--vscode-sideBarTitle-foreground) 5%, transparent)) !important; + border-radius: 4px; +} diff --git a/src/vs/sessions/browser/parts/media/panelPart.css b/src/vs/sessions/browser/parts/media/panelPart.css index 92e2987bb32..552f93966fb 100644 --- a/src/vs/sessions/browser/parts/media/panelPart.css +++ b/src/vs/sessions/browser/parts/media/panelPart.css @@ -7,3 +7,26 @@ .monaco-workbench .part.panel.bottom .composite.title { border-top-width: 0; } + +/* ===== Modern action label styling for sessions panel ===== */ + +/* Hide the underline indicator entirely */ +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .active-item-indicator:before { + display: none !important; +} + +/* Make icon action items 24px tall */ +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon { + height: 24px; +} + +.agent-sessions-workbench .part.panel > .title { + padding-left: 6px; + padding-right: 6px; +} + +/* Active/checked state: background container instead of underline */ +.agent-sessions-workbench .part.panel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.checked { + background-color: var(--vscode-activityBarTop-activeBackground, color-mix(in srgb, var(--vscode-sideBarTitle-foreground) 5%, transparent)) !important; + border-radius: 4px; +} diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index 146ed67fa61..644cbbf6c6a 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -54,13 +54,37 @@ height: 100%; } +/* Layout actions toolbar appears after the diff widget */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container { + order: 2; + /* Always render so we can animate in/out instead of display:none */ + display: flex !important; + align-items: center; + overflow: hidden; + width: 0; + opacity: 0; +} + +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container:not(.has-no-actions) { + /* TODO: Hardcoded to separator (9px) + single action button (28px). + Update if more actions are added to TitleBarRightLayout. */ + width: 37px; + opacity: 1; +} + +@media (prefers-reduced-motion: no-preference) { + .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container { + transition: width 0.15s ease-out, opacity 0.15s ease-out; + } +} + .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 { +/* Separator before layout actions toolbar */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-layout-actions-container:not(.has-no-actions)::before { content: ''; width: 1px; height: 16px; @@ -68,6 +92,12 @@ background-color: var(--vscode-disabledForeground); } +/* Toggled action buttons in session actions toolbar */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container .action-label.checked { + background: var(--vscode-toolbar-activeBackground); + border-radius: var(--vscode-cornerRadius-medium); +} + .monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-actions-container .codicon { color: inherit; } @@ -109,6 +139,11 @@ display: none !important; } +/* Remove the titlebar shadow in agent sessions */ +.agent-sessions-workbench.monaco-workbench .part.titlebar { + box-shadow: none; +} + /* macOS native: the spacer uses window-controls-container but should not block dragging */ .agent-sessions-workbench.mac .part.titlebar .window-controls-container { -webkit-app-region: drag; diff --git a/src/vs/sessions/browser/parts/panelPart.ts b/src/vs/sessions/browser/parts/panelPart.ts index 2fccc865f15..1413af2585f 100644 --- a/src/vs/sessions/browser/parts/panelPart.ts +++ b/src/vs/sessions/browser/parts/panelPart.ts @@ -69,9 +69,9 @@ export class PanelPart extends AbstractPaneCompositePart { static readonly activePanelSettingsKey = 'workbench.agentsession.panelpart.activepanelid'; /** Visual margin values for the card-like appearance */ - static readonly MARGIN_BOTTOM = 8; - static readonly MARGIN_LEFT = 8; - static readonly MARGIN_RIGHT = 8; + static readonly MARGIN_BOTTOM = 18; + static readonly MARGIN_LEFT = 16; + static readonly MARGIN_RIGHT = 16; constructor( @INotificationService notificationService: INotificationService, diff --git a/src/vs/sessions/browser/parts/titlebarPart.ts b/src/vs/sessions/browser/parts/titlebarPart.ts index 18c2d2867f0..736b483b5d8 100644 --- a/src/vs/sessions/browser/parts/titlebarPart.ts +++ b/src/vs/sessions/browser/parts/titlebarPart.ts @@ -81,6 +81,10 @@ export class TitlebarPart extends Part implements ITitlebarPart { private centerContent!: HTMLElement; private rightContent!: HTMLElement; + get leftContainer(): HTMLElement { return this.leftContent; } + get rightContainer(): HTMLElement { return this.rightContent; } + get rightWindowControlsContainer(): HTMLElement | undefined { return this.windowControlsContainer; } + private readonly titleBarStyle: TitlebarStyle; private isInactive: boolean = false; @@ -207,6 +211,7 @@ export class TitlebarPart extends Part implements ITitlebarPart { 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, + hiddenItemStrategy: HiddenItemStrategy.NoHide, telemetrySource: 'titlePart.right', toolbarOptions: { primaryGroup: () => true }, })); diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index b94d04f64be..2436595fd59 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -5,6 +5,7 @@ import '../../workbench/browser/style.js'; import './media/style.css'; +import { CollapsedSidebarWidget, CollapsedAuxiliaryBarWidget } from './collapsedPartWidgets.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; @@ -22,7 +23,7 @@ import { IEditorService } from '../../workbench/services/editor/common/editorSer import { IPaneCompositePartService } from '../../workbench/services/panecomposite/browser/panecomposite.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../workbench/common/views.js'; import { ILogService } from '../../platform/log/common/log.js'; -import { IInstantiationService, ServicesAccessor } from '../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor, createDecorator } from '../../platform/instantiation/common/instantiation.js'; import { ITitleService } from '../../workbench/services/title/browser/titleService.js'; import { mainWindow, CodeWindow } from '../../base/browser/window.js'; import { coalesce } from '../../base/common/arrays.js'; @@ -60,7 +61,19 @@ import { NotificationsToasts } from '../../workbench/browser/parts/notifications import { IMarkdownRendererService } from '../../platform/markdown/browser/markdownRenderer.js'; import { EditorMarkdownCodeBlockRenderer } from '../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; -import { TitleService } from './parts/titlebarPart.js'; +import { TitleService, TitlebarPart } from './parts/titlebarPart.js'; +import { URI } from '../../base/common/uri.js'; +import { IObservable } from '../../base/common/observable.js'; + +/** + * Minimal typing for ISessionsManagementService resolved dynamically to avoid + * a layering import from vs/sessions/contrib/. + */ +interface IMinimalSessionsManagementService { + getActiveSession(): { resource: URI } | undefined; + readonly activeSession: IObservable; +} +const _ISessionsManagementService = createDecorator('sessionsManagementService'); //#region Workbench Options @@ -231,6 +244,9 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { private chatBarPartView!: ISerializableView; + private collapsedSidebarWidget: CollapsedSidebarWidget | undefined; + private collapsedAuxiliaryBarWidget: CollapsedAuxiliaryBarWidget | undefined; + private readonly partVisibility: IPartVisibilityState = { sidebar: true, auxiliaryBar: false, @@ -369,6 +385,35 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Layout this.layout(); + // Collapsed Sidebar Widget (shown when sidebar is hidden) + const titlebarPart = this.getPart(Parts.TITLEBAR_PART) as TitlebarPart; + this.collapsedSidebarWidget = this._register(instantiationService.createInstance(CollapsedSidebarWidget, titlebarPart.leftContainer)); + if (!this.partVisibility.sidebar) { + this.collapsedSidebarWidget.show(); + } + + // Auxiliary bar changes widget (always visible, acts as a toggle) + this.collapsedAuxiliaryBarWidget = this._register(instantiationService.createInstance(CollapsedAuxiliaryBarWidget, titlebarPart.rightContainer, titlebarPart.rightWindowControlsContainer)); + this.collapsedAuxiliaryBarWidget.updateActiveState(this.partVisibility.auxiliaryBar); + + // Wire active session provider after restore, when ISessionsManagementService is available. + // Resolved via createDecorator to avoid a layering import from vs/sessions/contrib/. + // Note: whenRestored is a deferred promise that resolves inside restore() below. + const auxWidget = this.collapsedAuxiliaryBarWidget; + this.whenRestored.then(() => { + instantiationService.invokeFunction(accessor => { + try { + const svc = accessor.get(_ISessionsManagementService); + auxWidget.setActiveSessionProvider( + () => svc.getActiveSession()?.resource, + Event.fromObservableLight(svc.activeSession) + ); + } catch { + // Service not registered — indicators will remain empty + } + }); + }); + // Restore this.restore(lifecycleService); }); @@ -783,7 +828,7 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // Default sizes const sideBarSize = 300; - const auxiliaryBarSize = 340; + const auxiliaryBarSize = 380; const panelSize = 300; const titleBarHeight = this.titleBarPartView?.minimumHeight ?? 30; @@ -1060,6 +1105,13 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { !hidden, ); + // Toggle collapsed sidebar widget + if (hidden) { + this.collapsedSidebarWidget?.show(); + } else { + this.collapsedSidebarWidget?.hide(); + } + // If sidebar becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); @@ -1089,6 +1141,9 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { !hidden, ); + // Update collapsed auxiliary bar widget active state + this.collapsedAuxiliaryBarWidget?.updateActiveState(!hidden); + // If auxiliary bar becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.AuxiliaryBar); @@ -1123,6 +1178,8 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { this.workbenchGrid.exitMaximizedView(); } + const panelHadFocus = !hidden || this.hasFocus(Parts.PANEL_PART); + this.partVisibility.panel = !hidden; this.mainContainer.classList.toggle(LayoutClasses.PANEL_HIDDEN, hidden); @@ -1135,15 +1192,24 @@ export class Workbench extends Disposable implements IWorkbenchLayoutService { // If panel becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Panel); + + // Focus the chat bar when hiding the panel if it had focus + if (panelHadFocus) { + this.focusPart(Parts.CHATBAR_PART); + } } - // If panel becomes visible, show last active panel or default - if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { - const panelToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Panel) ?? - this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Panel)?.id; - if (panelToOpen) { - this.paneCompositeService.openPaneComposite(panelToOpen, ViewContainerLocation.Panel); + // If panel becomes visible, show last active panel or default and focus it + if (!hidden) { + if (!this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { + const panelToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Panel) ?? + this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.Panel)?.id; + if (panelToOpen) { + this.paneCompositeService.openPaneComposite(panelToOpen, ViewContainerLocation.Panel); + } } + + this.focusPart(Parts.PANEL_PART); } } diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css index bed8e201a24..3d65db075b8 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountWidget.css @@ -106,6 +106,7 @@ overflow: hidden; text-overflow: ellipsis; min-width: 0; + font-weight: 500; } .account-widget-account .account-widget-account-button { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts index 7ff02612cc1..bcae4ce6990 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts @@ -6,17 +6,78 @@ import './agentFeedbackEditorInputContribution.js'; import './agentFeedbackEditorWidgetContribution.js'; import './agentFeedbackOverviewRulerContribution.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { AgentFeedbackService, IAgentFeedbackService } from './agentFeedbackService.js'; import { AgentFeedbackAttachmentContribution } from './agentFeedbackAttachment.js'; import { AgentFeedbackAttachmentWidget } from './agentFeedbackAttachmentWidget.js'; import { AgentFeedbackEditorOverlay } from './agentFeedbackEditorOverlay.js'; -import { registerAgentFeedbackEditorActions } from './agentFeedbackEditorActions.js'; +import { hasActiveSessionAgentFeedback, registerAgentFeedbackEditorActions, submitActiveSessionFeedbackActionId } from './agentFeedbackEditorActions.js'; import { IChatAttachmentWidgetRegistry } from '../../../../workbench/contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.js'; import { IAgentFeedbackVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +/** + * Sets the `hasActiveSessionAgentFeedback` context key to true when the + * currently active session has pending agent feedback items. + */ +class ActiveSessionFeedbackContextContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.activeSessionFeedbackContext'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IAgentFeedbackService agentFeedbackService: IAgentFeedbackService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + ) { + super(); + + const contextKey = hasActiveSessionAgentFeedback.bindTo(contextKeyService); + const menuRegistration = this._register(new MutableDisposable()); + + const feedbackChanged = observableFromEvent( + this, + agentFeedbackService.onDidChangeFeedback, + e => e, + ); + + this._register(autorun(reader => { + feedbackChanged.read(reader); + const activeSession = sessionManagementService.activeSession.read(reader); + menuRegistration.clear(); + if (!activeSession) { + contextKey.set(false); + return; + } + const feedback = agentFeedbackService.getFeedback(activeSession.resource); + const count = feedback.length; + contextKey.set(count > 0); + + if (count > 0) { + menuRegistration.value = MenuRegistry.appendMenuItem(MenuId.ChatEditingSessionApplySubmenu, { + command: { + id: submitActiveSessionFeedbackActionId, + icon: Codicon.comment, + title: localize('agentFeedback.submitFeedbackCount', "Submit Feedback ({0})", count), + }, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and(IsSessionsWindowContext, hasActiveSessionAgentFeedback), + }); + } + })); + } +} + +registerWorkbenchContribution2(ActiveSessionFeedbackContextContribution.ID, ActiveSessionFeedbackContextContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackEditorOverlay.ID, AgentFeedbackEditorOverlay, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeedbackAttachmentContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts index 6365407f923..961b2a0c571 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts @@ -69,6 +69,7 @@ export class AgentFeedbackAttachmentContribution extends Disposable { range: f.range, codeSelection: f.codeSelection, diffHunks: f.diffHunks, + sourcePRReviewCommentId: f.sourcePRReviewCommentId, })), value, }; @@ -91,6 +92,9 @@ export class AgentFeedbackAttachmentContribution extends Disposable { : `${item.range.startLineNumber}-${item.range.endLineNumber}`; let part = `[${fileName}:${lineRef}]`; + if (item.sourcePRReviewCommentId) { + part += `\n(PR review comment, thread ID: ${item.sourcePRReviewCommentId} — resolve this thread when addressed)`; + } if (item.codeSelection) { part += `\nSelection:\n\`\`\`\n${item.codeSelection}\n\`\`\``; } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts index 3a55a70b754..f7808619d75 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorActions.ts @@ -24,6 +24,7 @@ import { IChatEditingService } from '../../../../workbench/contrib/chat/common/e import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; import { getSessionEditorComments } from './sessionEditorComments.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; export const submitFeedbackActionId = 'agentFeedbackEditor.action.submit'; export const navigatePreviousFeedbackActionId = 'agentFeedbackEditor.action.navigatePrevious'; @@ -32,6 +33,8 @@ 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); +export const hasActiveSessionAgentFeedback = new RawContextKey('agentFeedbackEditor.hasActiveSessionAgentFeedback', false); +export const submitActiveSessionFeedbackActionId = 'agentFeedbackEditor.action.submitActiveSession'; abstract class AgentFeedbackEditorAction extends Action2 { @@ -122,7 +125,7 @@ class SubmitFeedbackAction extends AgentFeedbackEditorAction { await editorService.closeEditors(editorsToClose); } - await widget.acceptInput('/act-on-feedback'); + await widget.acceptInput('act on feedback'); // move to use /act-on-feedback when the bug is fixed } } @@ -190,8 +193,66 @@ class ClearAllFeedbackAction extends AgentFeedbackEditorAction { } } +class SubmitActiveSessionFeedbackAction extends Action2 { + + static readonly ID = submitActiveSessionFeedbackActionId; + + constructor() { + super({ + id: SubmitActiveSessionFeedbackAction.ID, + title: localize2('agentFeedback.submitFeedback', 'Submit Feedback'), + icon: Codicon.comment, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, hasActiveSessionAgentFeedback), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const agentFeedbackService = accessor.get(IAgentFeedbackService); + const chatWidgetService = accessor.get(IChatWidgetService); + const editorService = accessor.get(IEditorService); + const logService = accessor.get(ILogService); + + const activeSession = sessionManagementService.getActiveSession(); + if (!activeSession) { + return; + } + + const sessionResource = activeSession.resource; + const feedbackItems = agentFeedbackService.getFeedback(sessionResource); + if (feedbackItems.length === 0) { + return; + } + + const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); + if (!widget) { + logService.error('[AgentFeedback] Cannot submit feedback: no chat widget found for session', sessionResource.toString()); + return; + } + + // Close all editors belonging to the session resource + const editorsToClose: IEditorIdentifier[] = []; + for (const { editor, groupId } of editorService.getEditors(EditorsOrder.SEQUENTIAL)) { + const candidates = getActiveResourceCandidates(editor); + const belongsToSession = candidates.some(uri => + isEqual(agentFeedbackService.getMostRecentSessionForResource(uri), sessionResource) + ); + if (belongsToSession) { + editorsToClose.push({ editor, groupId }); + } + } + if (editorsToClose.length) { + await editorService.closeEditors(editorsToClose); + } + + await widget.acceptInput('act on feedback'); + } +} + export function registerAgentFeedbackEditorActions(): void { registerAction2(SubmitFeedbackAction); + registerAction2(SubmitActiveSessionFeedbackAction); registerAction2(class extends NavigateFeedbackAction { constructor() { super(false); } }); registerAction2(class extends NavigateFeedbackAction { constructor() { super(true); } }); registerAction2(ClearAllFeedbackAction); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 1002f0c95a7..395fef3071f 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -521,11 +521,20 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements return undefined; } - const lineNumber = selection.getStartPosition().lineNumber; + const position = selection.getStartPosition(); + const lineNumber = position.lineNumber; const isModifiedEditor = diffEditor.getModifiedEditor() === this._editor; for (const change of diffResult.changes2) { const lineRange = isModifiedEditor ? change.modified : change.original; if (!lineRange.isEmpty && lineRange.contains(lineNumber)) { + // Don't show when cursor is at the start or end position of the hunk + const isAtHunkStart = lineNumber === lineRange.startLineNumber && position.column === 1; + const lastHunkLine = lineRange.endLineNumberExclusive - 1; + const model = this._editor.getModel(); + const isAtHunkEnd = model && lineNumber === lastHunkLine && position.column === model.getLineMaxColumn(lastHunkLine); + if (isAtHunkStart || isAtHunkEnd) { + return undefined; + } return { startLineNumber: lineRange.startLineNumber, endLineNumberExclusive: lineRange.endLineNumberExclusive, diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts index 379cdc484fb..678dacfee38 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts @@ -16,7 +16,7 @@ import { IEditorContribution, IEditorDecorationsCollection, ScrollType } from '. import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { $, addDisposableListener, clearNode, getTotalWidth } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, addStandardDisposableListener, clearNode, getTotalWidth } from '../../../../base/browser/dom.js'; import { URI } from '../../../../base/common/uri.js'; import { Range } from '../../../../editor/common/core/range.js'; import { overviewRulerRangeHighlight } from '../../../../editor/common/core/editorColorRegistry.js'; @@ -36,6 +36,13 @@ 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'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; + +interface ICommentItemActions { + editAction: Action; + convertAction: Action | undefined; + removeAction: Action; +} /** * Widget that displays agent feedback comments for a group of nearby feedback items. @@ -191,22 +198,42 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid const actionBarContainer = $('div.agent-feedback-widget-item-actions'); const actionBar = this._eventStore.add(new ActionBar(actionBarContainer)); + + const itemActions: ICommentItemActions = { editAction: undefined!, convertAction: undefined, removeAction: undefined! }; + + // Edit action — only disabled for PR review comments + const isEditable = comment.source !== SessionEditorCommentSource.PRReview; + const editTooltip = isEditable + ? nls.localize('editComment', "Edit") + : nls.localize('editPRCommentDisabled', "PR review comments cannot be edited"); + itemActions.editAction = new Action( + 'agentFeedback.widget.edit', + editTooltip, + ThemeIcon.asClassName(Codicon.edit), + isEditable, + (): void => { this._startEditing(comment, text, itemActions); }, + ); + actionBar.push(itemActions.editAction, { icon: true, label: false }); + if (comment.canConvertToAgentFeedback) { - actionBar.push(new Action( + itemActions.convertAction = 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(itemActions.convertAction, { icon: true, label: false }); } - actionBar.push(new Action( + itemActions.removeAction = new Action( 'agentFeedback.widget.remove', nls.localize('removeComment', "Remove"), ThemeIcon.asClassName(Codicon.close), true, () => this._removeComment(comment), - ), { icon: true, label: false }); + ); + actionBar.push(itemActions.removeAction, { icon: true, label: false }); + itemHeader.appendChild(actionBarContainer); item.appendChild(itemHeader); @@ -295,11 +322,98 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._agentFeedbackService.removeFeedback(this._sessionResource, comment.sourceId); } + private _startEditing(comment: ISessionEditorComment, textContainer: HTMLElement, actions: ICommentItemActions): void { + if (comment.source === SessionEditorCommentSource.PRReview) { + return; + } + + // Disable all actions while editing + actions.editAction.enabled = false; + if (actions.convertAction) { + actions.convertAction.enabled = false; + } + actions.removeAction.enabled = false; + + const editStore = new DisposableStore(); + this._eventStore.add(editStore); + + clearNode(textContainer); + textContainer.classList.add('editing'); + + const textarea = $('textarea.agent-feedback-widget-edit-textarea') as HTMLTextAreaElement; + textarea.value = comment.text; + textarea.rows = 1; + textContainer.appendChild(textarea); + + // Auto-size the textarea + const autoSize = () => { + textarea.style.height = 'auto'; + textarea.style.height = `${textarea.scrollHeight}px`; + this._editor.layoutOverlayWidget(this); + }; + autoSize(); + + editStore.add(addDisposableListener(textarea, 'input', autoSize)); + + editStore.add(addStandardDisposableListener(textarea, 'keydown', (e) => { + if (e.keyCode === KeyCode.Enter && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const newText = textarea.value.trim(); + if (newText) { + this._saveEdit(comment, newText); + } + // Widget will be rebuilt by the change event + } else if (e.keyCode === KeyCode.Escape) { + e.preventDefault(); + e.stopPropagation(); + this._stopEditing(comment, textContainer, editStore, actions); + } + })); + + // Stop editing when focus is lost + editStore.add(addDisposableListener(textarea, 'blur', () => { + this._stopEditing(comment, textContainer, editStore, actions); + })); + + textarea.focus(); + } + + private _saveEdit(comment: ISessionEditorComment, newText: string): void { + if (comment.source === SessionEditorCommentSource.AgentFeedback) { + this._agentFeedbackService.updateFeedback(this._sessionResource, comment.sourceId, newText); + } else if (comment.source === SessionEditorCommentSource.CodeReview) { + this._codeReviewService.updateComment(this._sessionResource, comment.sourceId, newText); + } + } + + private _stopEditing(comment: ISessionEditorComment, textContainer: HTMLElement, editStore: DisposableStore, actions: ICommentItemActions): void { + editStore.dispose(); + + // Re-enable actions + actions.editAction.enabled = comment.source !== SessionEditorCommentSource.PRReview; + if (actions.convertAction) { + actions.convertAction.enabled = true; + } + actions.removeAction.enabled = true; + + textContainer.classList.remove('editing'); + clearNode(textContainer); + const rendered = this._markdownRendererService.render(new MarkdownString(comment.text)); + this._eventStore.add(rendered); + textContainer.appendChild(rendered.element); + this._editor.layoutOverlayWidget(this); + } + private _convertToAgentFeedback(comment: ISessionEditorComment): void { if (!comment.canConvertToAgentFeedback) { return; } + const sourcePRReviewCommentId = comment.source === SessionEditorCommentSource.PRReview + ? comment.sourceId + : undefined; + const feedback = this._agentFeedbackService.addFeedback( this._sessionResource, comment.resourceUri, @@ -307,10 +421,13 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid comment.text, comment.suggestion, createAgentFeedbackContext(this._editor, this._codeEditorService, comment.resourceUri, comment.range), + sourcePRReviewCommentId, ); this._agentFeedbackService.setNavigationAnchor(this._sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedback.id)); if (comment.source === SessionEditorCommentSource.CodeReview) { this._codeReviewService.removeComment(this._sessionResource, comment.sourceId); + } else if (comment.source === SessionEditorCommentSource.PRReview) { + this._codeReviewService.markPRReviewCommentConverted(this._sessionResource, comment.sourceId); } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 9d03437a572..75dfb9f219c 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -32,6 +32,8 @@ export interface IAgentFeedback { readonly suggestion?: ICodeReviewSuggestion; readonly codeSelection?: string; readonly diffHunks?: string; + /** When this feedback was converted from a PR review comment, the original thread ID. */ + readonly sourcePRReviewCommentId?: string; } export interface INavigableSessionComment { @@ -61,13 +63,18 @@ export interface IAgentFeedbackService { /** * Add a feedback item for the given session. */ - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext): IAgentFeedback; + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): IAgentFeedback; /** * Remove a single feedback item. */ removeFeedback(sessionResource: URI, feedbackId: string): void; + /** + * Update the text of an existing feedback item. + */ + updateFeedback(sessionResource: URI, feedbackId: string, newText: string): void; + /** * Get all feedback items for a session. */ @@ -110,7 +117,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, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext): Promise; + addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): Promise; } // --- Implementation ----------------------------------------------------------- @@ -141,7 +148,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe super(); } - addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext): IAgentFeedback { + addFeedback(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): IAgentFeedback { const key = sessionResource.toString(); let feedbackItems = this._feedbackBySession.get(key); if (!feedbackItems) { @@ -158,6 +165,7 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe suggestion, codeSelection: context?.codeSelection, diffHunks: context?.diffHunks, + sourcePRReviewCommentId, }; // Insert at the correct sorted position. @@ -220,6 +228,25 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe } } + updateFeedback(sessionResource: URI, feedbackId: string, newText: string): void { + const key = sessionResource.toString(); + const feedbackItems = this._feedbackBySession.get(key); + if (!feedbackItems) { + return; + } + + const idx = feedbackItems.findIndex(f => f.id === feedbackId); + if (idx >= 0) { + const existing = feedbackItems[idx]; + feedbackItems[idx] = { + ...existing, + text: newText, + }; + this._sessionUpdatedOrder.set(key, ++this._sessionUpdatedSequence); + this._onDidChangeFeedback.fire({ sessionResource, feedbackItems }); + } + } + getFeedback(sessionResource: URI): readonly IAgentFeedback[] { return this._feedbackBySession.get(sessionResource.toString()) ?? []; } @@ -419,8 +446,8 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe this._onDidChangeFeedback.fire({ sessionResource, feedbackItems: [] }); } - async addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext): Promise { - this.addFeedback(sessionResource, resourceUri, range, text, suggestion, context); + async addFeedbackAndSubmit(sessionResource: URI, resourceUri: URI, range: IRange, text: string, suggestion?: ICodeReviewSuggestion, context?: IAgentFeedbackContext, sourcePRReviewCommentId?: string): Promise { + this.addFeedback(sessionResource, resourceUri, range, text, suggestion, context, sourcePRReviewCommentId); // Wait for the attachment contribution to update the chat widget's attachment model const widget = this._chatWidgetService.getWidgetBySessionResource(sessionResource); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css index 57a7c9785c6..ef551fd0870 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorWidget.css @@ -286,3 +286,28 @@ z-index: 5; border-left: 2px solid var(--vscode-editorGutter-modifiedBackground); } + +/* Inline edit textarea */ +.agent-feedback-widget-text.editing { + padding: 0; +} + +.agent-feedback-widget-edit-textarea { + width: 100%; + min-height: 22px; + padding: 4px 6px; + border: 1px solid var(--vscode-focusBorder); + border-radius: 4px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-size: 12px; + line-height: 1.4; + resize: none; + overflow: hidden; + box-sizing: border-box; +} + +.agent-feedback-widget-edit-textarea:focus { + outline: none; + border-color: var(--vscode-focusBorder); +} diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts index f7bb4f0aa5b..4e003899579 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/sessionEditorComments.test.ts @@ -7,8 +7,8 @@ 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'; +import { ICodeReviewState, CodeReviewStateKind, IPRReviewState, PRReviewStateKind } from '../../../codeReview/browser/codeReviewService.js'; type ICodeReviewResultState = Extract; @@ -23,7 +23,9 @@ suite('SessionEditorComments', () => { return { kind: CodeReviewStateKind.Result, version: 'v1', + reviewCount: 1, comments, + didProduceComments: comments.length > 0, }; } diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts index d090de54576..3258fa7a595 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.ts @@ -7,8 +7,8 @@ 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 { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; -import { AICustomizationItemTypeContextKey } from './aiCustomizationTreeViewViews.js'; +import { AI_CUSTOMIZATION_VIEW_ID, AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; +import { AICustomizationItemDisabledContextKey, AICustomizationItemStorageContextKey, AICustomizationItemTypeContextKey, AICustomizationViewPane } from './aiCustomizationTreeViewViews.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -17,6 +17,9 @@ import { IEditorService } from '../../../../workbench/services/editor/common/edi import { IFileService, FileSystemProviderCapabilities } from '../../../../platform/files/common/files.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; //#region Utilities @@ -24,13 +27,13 @@ import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; * Type for context passed to actions from tree context menus. * Handles both direct URI arguments and serialized context objects. */ -type URIContext = { uri: URI | string;[key: string]: unknown } | URI | string; +type ItemContext = { uri: URI | string; promptType?: string; disabled?: boolean;[key: string]: unknown } | URI | string; /** * Extracts a URI from various context formats. * Context can be a URI, string, or an object with uri property. */ -function extractURI(context: URIContext): URI { +function extractURI(context: ItemContext): URI { if (URI.isUri(context)) { return context; } @@ -57,7 +60,7 @@ registerAction2(class extends Action2 { icon: Codicon.goToFile, }); } - async run(accessor: ServicesAccessor, context: URIContext): Promise { + async run(accessor: ServicesAccessor, context: ItemContext): Promise { const editorService = accessor.get(IEditorService); await editorService.openEditor({ resource: extractURI(context) @@ -76,7 +79,7 @@ registerAction2(class extends Action2 { icon: Codicon.play, }); } - async run(accessor: ServicesAccessor, context: URIContext): Promise { + async run(accessor: ServicesAccessor, context: ItemContext): Promise { const commandService = accessor.get(ICommandService); await commandService.executeCommand('workbench.action.chat.run.prompt.current', extractURI(context)); } @@ -92,7 +95,7 @@ registerAction2(class extends Action2 { icon: Codicon.trash, }); } - async run(accessor: ServicesAccessor, context: URIContext): Promise { + async run(accessor: ServicesAccessor, context: ItemContext): Promise { const fileService = accessor.get(IFileService); const dialogService = accessor.get(IDialogService); const uri = extractURI(context); @@ -124,7 +127,7 @@ registerAction2(class extends Action2 { icon: Codicon.clippy, }); } - async run(accessor: ServicesAccessor, context: URIContext): Promise { + async run(accessor: ServicesAccessor, context: ItemContext): Promise { const clipboardService = accessor.get(IClipboardService); const uri = extractURI(context); const textToCopy = uri.scheme === 'file' ? uri.fsPath : uri.toString(true); @@ -167,4 +170,114 @@ MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { order: 10, }); +// Disable item action +const DISABLE_AI_CUSTOMIZATION_ITEM_ID = 'aiCustomization.disableItem'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: DISABLE_AI_CUSTOMIZATION_ITEM_ID, + title: localize2('disable', "Disable"), + icon: Codicon.eyeClosed, + }); + } + async run(accessor: ServicesAccessor, context: ItemContext): Promise { + if (typeof context !== 'object' || URI.isUri(context)) { + return; + } + const promptsService = accessor.get(IPromptsService); + const viewsService = accessor.get(IViewsService); + const uri = extractURI(context); + const promptType = context.promptType as PromptsType | undefined; + if (!promptType) { + return; + } + + const disabled = promptsService.getDisabledPromptFiles(promptType); + disabled.add(uri); + promptsService.setDisabledPromptFiles(promptType, disabled); + + const view = viewsService.getActiveViewWithId(AI_CUSTOMIZATION_VIEW_ID); + view?.refresh(); + } +}); + +// Enable item action +const ENABLE_AI_CUSTOMIZATION_ITEM_ID = 'aiCustomization.enableItem'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: ENABLE_AI_CUSTOMIZATION_ITEM_ID, + title: localize2('enable', "Enable"), + icon: Codicon.eye, + }); + } + async run(accessor: ServicesAccessor, context: ItemContext): Promise { + if (typeof context !== 'object' || URI.isUri(context)) { + return; + } + const promptsService = accessor.get(IPromptsService); + const viewsService = accessor.get(IViewsService); + const uri = extractURI(context); + const promptType = context.promptType as PromptsType | undefined; + if (!promptType) { + return; + } + + const disabled = promptsService.getDisabledPromptFiles(promptType); + disabled.delete(uri); + promptsService.setDisabledPromptFiles(promptType, disabled); + + const view = viewsService.getActiveViewWithId(AI_CUSTOMIZATION_VIEW_ID); + view?.refresh(); + } +}); + +// Context menu: Disable (shown when builtin item is enabled) +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: DISABLE_AI_CUSTOMIZATION_ITEM_ID, title: localize('disable', "Disable") }, + group: '4_toggle', + order: 1, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AICustomizationItemDisabledContextKey.key, false), + ContextKeyExpr.equals(AICustomizationItemStorageContextKey.key, BUILTIN_STORAGE), + ContextKeyExpr.equals(AICustomizationItemTypeContextKey.key, PromptsType.skill), + ), +}); + +// Context menu: Enable (shown when builtin item is disabled) +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: ENABLE_AI_CUSTOMIZATION_ITEM_ID, title: localize('enable', "Enable") }, + group: '4_toggle', + order: 1, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AICustomizationItemDisabledContextKey.key, true), + ContextKeyExpr.equals(AICustomizationItemStorageContextKey.key, BUILTIN_STORAGE), + ContextKeyExpr.equals(AICustomizationItemTypeContextKey.key, PromptsType.skill), + ), +}); + +// Inline hover: Disable (shown when builtin item is enabled) +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: DISABLE_AI_CUSTOMIZATION_ITEM_ID, title: localize('disable', "Disable"), icon: Codicon.eyeClosed }, + group: 'inline', + order: 5, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AICustomizationItemDisabledContextKey.key, false), + ContextKeyExpr.equals(AICustomizationItemStorageContextKey.key, BUILTIN_STORAGE), + ContextKeyExpr.equals(AICustomizationItemTypeContextKey.key, PromptsType.skill), + ), +}); + +// Inline hover: Enable (shown when builtin item is disabled) +MenuRegistry.appendMenuItem(AICustomizationItemMenuId, { + command: { id: ENABLE_AI_CUSTOMIZATION_ITEM_ID, title: localize('enable', "Enable"), icon: Codicon.eye }, + group: 'inline', + order: 5, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AICustomizationItemDisabledContextKey.key, true), + ContextKeyExpr.equals(AICustomizationItemStorageContextKey.key, BUILTIN_STORAGE), + ContextKeyExpr.equals(AICustomizationItemTypeContextKey.key, PromptsType.skill), + ), +}); + //#endregion diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts index ab51ad600a2..f5cfae860f4 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -54,6 +54,16 @@ export const AICustomizationIsEmptyContextKey = new RawContextKey('aiCu */ export const AICustomizationItemTypeContextKey = new RawContextKey('aiCustomizationItemType', ''); +/** + * Context key indicating whether the current item is disabled. + */ +export const AICustomizationItemDisabledContextKey = new RawContextKey('aiCustomizationItemDisabled', false); + +/** + * Context key for the current item's storage type in context menus. + */ +export const AICustomizationItemStorageContextKey = new RawContextKey('aiCustomizationItemStorage', ''); + //#endregion //#region Tree Item Types @@ -98,6 +108,7 @@ interface IAICustomizationFileItem { readonly description?: string; readonly storage: AICustomizationPromptsStorage; readonly promptType: PromptsType; + readonly disabled: boolean; } /** @@ -240,6 +251,9 @@ class AICustomizationFileRenderer implements ITreeRenderer { const cached = this.cache.get(promptType); + const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); - // For skills, use the cached skills data + // For skills, use the cached skills data and merge in disabled skills if (promptType === PromptsType.skill) { const skills = cached?.skills || []; const filtered = skills.filter(skill => skill.storage === storage); - return filtered + const seenUris = new Set(); + const result: IAICustomizationFileItem[] = filtered .map(skill => { + seenUris.add(skill.uri.toString()); // Use skill name from frontmatter, or fallback to parent folder name const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri); return { @@ -530,8 +549,30 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource 0) { + const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None); + for (const file of allSkillFiles) { + if (file.storage === storage && !seenUris.has(file.uri.toString()) && disabledUris.has(file.uri)) { + result.push({ + type: 'file' as const, + id: file.uri.toString(), + uri: file.uri, + name: file.name || basename(dirname(file.uri)) || basename(file.uri), + description: file.description, + storage: file.storage, + promptType, + disabled: true, + }); + } + } + } + + return result; } // Use cached files data (already fetched in getStorageGroups) @@ -544,6 +585,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource; private readonly itemTypeContextKey: IContextKey; + private readonly itemDisabledContextKey: IContextKey; + private readonly itemStorageContextKey: IContextKey; constructor( options: IViewPaneOptions, @@ -590,6 +634,8 @@ export class AICustomizationViewPane extends ViewPane { // Initialize context keys this.isEmptyContextKey = AICustomizationIsEmptyContextKey.bindTo(contextKeyService); this.itemTypeContextKey = AICustomizationItemTypeContextKey.bindTo(contextKeyService); + this.itemDisabledContextKey = AICustomizationItemDisabledContextKey.bindTo(contextKeyService); + this.itemStorageContextKey = AICustomizationItemStorageContextKey.bindTo(contextKeyService); // Subscribe to prompt service events to refresh tree this._register(this.promptsService.onDidChangeCustomAgents(() => this.refresh())); @@ -648,10 +694,13 @@ export class AICustomizationViewPane extends ViewPane { if (element.type === 'group') { return element.label; } - // For files, include description if available - return element.description + // For files, include description and disabled state + const nameAndDesc = element.description ? localize('fileAriaLabel', "{0}, {1}", element.name, element.description) : element.name; + return element.disabled + ? localize('fileAriaLabelDisabled', "{0}, disabled", nameAndDesc) + : nameAndDesc; }, getWidgetAriaLabel: () => localize('aiCustomizationTree', "Chat Customization Items"), }, @@ -729,14 +778,17 @@ export class AICustomizationViewPane extends ViewPane { const element = e.element; - // Set context key for the item type so menu items can use `when` clauses + // Set context keys for the item so menu items can use `when` clauses this.itemTypeContextKey.set(element.promptType); + this.itemDisabledContextKey.set(element.disabled); + this.itemStorageContextKey.set(element.storage); // Get menu actions from the menu service const context = { uri: element.uri.toString(), name: element.name, promptType: element.promptType, + disabled: element.disabled, }; const menu = this.menuService.getMenuActions(AICustomizationItemMenuId, this.contextKeyService, { arg: context, shouldForwardArgs: true }); const { secondary } = getContextMenuActions(menu, 'inline'); @@ -748,8 +800,10 @@ export class AICustomizationViewPane extends ViewPane { getActions: () => secondary, getActionsContext: () => context, onHide: () => { - // Clear the context key when menu closes + // Clear the context keys when menu closes this.itemTypeContextKey.reset(); + this.itemDisabledContextKey.reset(); + this.itemStorageContextKey.reset(); }, }); } diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css index 3e3d9be3b35..d9e40517d16 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/media/aiCustomizationTreeView.css @@ -103,6 +103,11 @@ white-space: nowrap; } +/* Disabled items */ +.ai-customization-view .ai-customization-tree-item.disabled { + opacity: 0.5; +} + /* Empty state */ .ai-customization-view .empty-message { padding: 10px; diff --git a/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts index 9da044d818d..ceb8a6032d1 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.contribution.ts @@ -10,9 +10,10 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; -import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; +import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, changesContainerTitle, ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; import './changesViewActions.js'; -import { ToggleChangesViewContribution } from './toggleChangesView.js'; +import './fixCIChecksAction.js'; +import { ChangesViewController } from './changesViewController.js'; const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); @@ -20,7 +21,7 @@ const viewContainersRegistry = Registry.as(ViewContaine const changesViewContainer = viewContainersRegistry.registerViewContainer({ id: CHANGES_VIEW_CONTAINER_ID, - title: localize2('changes', 'Changes'), + title: changesContainerTitle, ctorDescriptor: new SyncDescriptor(ChangesViewPaneContainer), icon: changesViewIcon, order: 10, @@ -42,4 +43,4 @@ viewsRegistry.registerViews([{ windowVisibility: WindowVisibility.Sessions }], changesViewContainer); -registerWorkbenchContribution2(ToggleChangesViewContribution.ID, ToggleChangesViewContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChangesViewController.ID, ChangesViewController, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 889b669796d..10fbf326d8b 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -12,18 +12,19 @@ import { IObjectTreeElement, ITreeNode } from '../../../../base/browser/ui/tree/ 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, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, ObservablePromise, observableValue } from '../../../../base/common/observable.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, ObservablePromise, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js'; import { basename, dirname } from '../../../../base/common/path.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, localize2 } from '../../../../nls.js'; +import { ILocalizedString } from '../../../../platform/action/common/action.js'; import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.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 { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { FileKind } from '../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -48,21 +49,20 @@ import { IViewsService } from '../../../../workbench/services/views/common/views 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 { 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 { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; -import { IActivityService, NumberBadge } from '../../../../workbench/services/activity/common/activity.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { IActiveSessionItem, ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; -import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionProject.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionWorkspace.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'; +import { arrayEqualsC } from '../../../../base/common/equals.js'; const $ = dom.$; @@ -70,6 +70,16 @@ const $ = dom.$; export const CHANGES_VIEW_CONTAINER_ID = 'workbench.view.agentSessions.changesContainer'; export const CHANGES_VIEW_ID = 'workbench.view.agentSessions.changes'; + +// Dynamic title for the Changes view container tab. +// Uses a getter so that ViewContainerModel.updateContainerInfo() picks up +// the latest value each time it re-reads viewContainer.title.value. +let _changesContainerTitleValue = localize('changes', 'Changes'); +export const changesContainerTitle: ILocalizedString = { + original: 'Changes', + get value() { return _changesContainerTitleValue; } +}; + const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run'; // --- View Mode @@ -85,14 +95,14 @@ const changesViewModeContextKey = new RawContextKey('changesVie const enum ChangesVersionMode { AllChanges = 'allChanges', - LastTurn = 'lastTurn', - Uncommitted = 'uncommitted' + LastTurn = 'lastTurn' } const changesVersionModeContextKey = new RawContextKey('sessions.changesVersionMode', ChangesVersionMode.AllChanges); const isMergeBaseBranchProtectedContextKey = new RawContextKey('sessions.isMergeBaseBranchProtected', false); const hasOpenPullRequestContextKey = new RawContextKey('sessions.hasOpenPullRequest', false); -const hasUncommittedChangesContextKey = new RawContextKey('sessions.hasUncommittedChanges', false); +const hasIncomingChangesContextKey = new RawContextKey('sessions.hasIncomingChanges', false); +const hasOutgoingChangesContextKey = new RawContextKey('sessions.hasOutgoingChanges', false); // --- List Item @@ -204,6 +214,101 @@ function buildTreeChildren(items: IChangesFileItem[]): IObjectTreeElement; + readonly activeSessionResourceObs: IObservable; + readonly activeSessionRepositoryObs: IObservableWithChange; + readonly activeSessionChangesObs: IObservable; + + readonly versionModeObs: ISettableObservable; + setVersionMode(mode: ChangesVersionMode): void { + if (this.versionModeObs.get() === mode) { + return; + } + this.versionModeObs.set(mode, undefined); + } + + readonly viewModeObs: ISettableObservable; + setViewMode(mode: ChangesViewMode): void { + if (this.viewModeObs.get() === mode) { + return; + } + this.viewModeObs.set(mode, undefined); + this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER); + } + + constructor( + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IGitService private readonly gitService: IGitService, + @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + + // Active session changes + this.sessionsChangedSignal = observableSignalFromEvent(this, + this.agentSessionsService.model.onDidChangeSessions); + + // Active session resource + this.activeSessionResourceObs = derivedOpts({ equalsFn: isEqual }, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.resource; + }); + + // Active session changes + this.activeSessionChangesObs = derivedOpts({ + equalsFn: arrayEqualsC() + }, reader => { + const sessionResource = this.activeSessionResourceObs.read(reader); + if (!sessionResource) { + return Iterable.empty(); + } + + this.sessionsChangedSignal.read(reader); + const model = this.agentSessionsService.getSession(sessionResource); + return model?.changes instanceof Array ? model.changes : Iterable.empty(); + }); + + // Active session repository + const activeSessionRepositoryPromiseObs = derived(reader => { + const activeSessionResource = this.activeSessionResourceObs.read(reader); + if (!activeSessionResource) { + return constObservable(undefined); + } + + const activeSession = this.sessionManagementService.getActiveSession(); + if (!activeSession?.worktree) { + return constObservable(undefined); + } + + return new ObservablePromise(this.gitService.openRepository(activeSession.worktree)).resolvedValue; + }); + + this.activeSessionRepositoryObs = derived(reader => { + const activeSessionRepositoryPromise = activeSessionRepositoryPromiseObs.read(reader); + if (activeSessionRepositoryPromise === undefined) { + return undefined; + } + + return activeSessionRepositoryPromise.read(reader); + }); + + // Version mode + this.versionModeObs = observableValue(this, ChangesVersionMode.AllChanges); + + this._register(runOnChange(this.activeSessionResourceObs, () => { + this.setVersionMode(ChangesVersionMode.AllChanges); + })); + + // View mode + const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); + const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; + this.viewModeObs = observableValue(this, initialMode); + } +} + // --- View Pane export class ChangesViewPane extends ViewPane { @@ -226,45 +331,7 @@ export class ChangesViewPane extends ViewPane { private currentBodyHeight = 0; private currentBodyWidth = 0; - // View mode (list vs tree) - private readonly viewModeObs: ReturnType>; - private readonly viewModeContextKey: IContextKey; - - get viewMode(): ChangesViewMode { return this.viewModeObs.get(); } - set viewMode(mode: ChangesViewMode) { - if (this.viewModeObs.get() === mode) { - return; - } - this.viewModeObs.set(mode, undefined); - this.viewModeContextKey.set(mode); - 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; - - get activeSessionHasChanges(): IObservable { - return this.activeSessionHasChangesObs; - } - - // Badge for file count - private readonly badgeDisposable = this._register(new MutableDisposable()); + readonly viewModel: ChangesViewModel; constructor( options: IViewPaneOptions, @@ -277,150 +344,47 @@ export class ChangesViewPane extends ViewPane { @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, - @IChatEditingService private readonly chatEditingService: IChatEditingService, @IEditorService private readonly editorService: IEditorService, - @IActivityService private readonly activityService: IActivityService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @ILabelService private readonly labelService: ILabelService, - @IStorageService private readonly storageService: IStorageService, @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); - // View mode - const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); - const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; - this.viewModeObs = observableValue(this, initialMode); - this.viewModeContextKey = changesViewModeContextKey.bindTo(contextKeyService); - this.viewModeContextKey.set(initialMode); + this.viewModel = this.instantiationService.createInstance(ChangesViewModel); + this._register(this.viewModel); // 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), - }, reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - if (!activeSession?.resource) { - return undefined; - } - - return activeSession; - }).recomputeInitiallyAndOnChange(this._store); - - // Track active session repository changes - const activeSessionRepositoryPromiseObs = derived(reader => { - const activeSessionWorktree = this.activeSession.read(reader)?.worktree; - if (!activeSessionWorktree) { - return constObservable(undefined); - } - - return new ObservablePromise(this.gitService.openRepository(activeSessionWorktree)).resolvedValue; - }); - - this.activeSessionRepositoryObs = derived(reader => { - const activeSessionRepositoryPromise = activeSessionRepositoryPromiseObs.read(reader); - if (activeSessionRepositoryPromise === undefined) { - return undefined; - } - - return activeSessionRepositoryPromise.read(reader); - }); - - this.activeSessionRepositoryChangesObs = derived(reader => { - const repository = this.activeSessionRepositoryObs.read(reader); - if (!repository) { - return undefined; - } - - const state = repository.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); - - // 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. - const viewSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); - this._register(autorun(reader => { - const activeSession = this.activeSession.read(reader); - viewSessionTypeKey.set(activeSession?.providerType ?? ''); + this._register(bindContextKey(changesVersionModeContextKey, this.scopedContextKeyService, reader => { + return this.viewModel.versionModeObs.read(reader); })); - } - private createActiveSessionFileCountObservable(): IObservableWithChange { - const activeSessionResource = this.activeSession.map(a => a?.resource); + // View mode + this._register(bindContextKey(changesViewModeContextKey, this.scopedContextKeyService, reader => { + return this.viewModel.viewModeObs.read(reader); + })); - const sessionsChangedSignal = observableFromEvent( - this, - this.agentSessionsService.model.onDidChangeSessions, - () => ({}), - ); + // 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. + this._register(bindContextKey(ChatContextKeys.agentSessionType, this.scopedContextKeyService, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.providerType ?? ''; + })); - const sessionFileChangesObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); - sessionsChangedSignal.read(reader); - if (!sessionResource) { - return Iterable.empty(); + // Fallback title update: when the view is not visible (renderDisposables + // cleared), keep the container title in sync with the raw session changes + // so the tab still shows a count when the user switches sessions. + this._register(autorun(reader => { + if (this.isBodyVisible()) { + // onVisible() drives the title from topLevelStats while visible + return; } - - const model = this.agentSessionsService.getSession(sessionResource); - return model?.changes instanceof Array ? model.changes : Iterable.empty(); - }); - - return derived(reader => { - const activeSession = this.activeSession.read(reader); - if (!activeSession) { - return 0; - } - - let editingSessionCount = 0; - if (activeSession.providerType !== AgentSessionProviders.Background) { - const sessions = this.chatEditingService.editingSessionsObs.read(reader); - const session = sessions.find(candidate => isEqual(candidate.chatSessionResource, activeSession.resource)); - editingSessionCount = session ? session.entries.read(reader).length : 0; - } - - const sessionFiles = [...sessionFileChangesObs.read(reader)]; - const sessionFilesCount = sessionFiles.length; - - return editingSessionCount + sessionFilesCount; - }).recomputeInitiallyAndOnChange(this._store); - } - - private updateBadge(fileCount: number): void { - if (fileCount > 0) { - const message = fileCount === 1 - ? localize('changesView.oneFileChanged', '1 file changed') - : localize('changesView.filesChanged', '{0} files changed', fileCount); - this.badgeDisposable.value = this.activityService.showViewActivity(CHANGES_VIEW_ID, { badge: new NumberBadge(fileCount, () => message) }); - } else { - this.badgeDisposable.clear(); - } + const changes = this.viewModel.activeSessionChangesObs.read(reader); + this.updateContainerTitle(changes.length); + })); } protected override renderBody(container: HTMLElement): void { @@ -474,82 +438,33 @@ export class ChangesViewPane extends ViewPane { } } + private updateContainerTitle(fileCount: number): void { + let nextTitle: string; + if (fileCount === 0) { + nextTitle = localize('changes', 'Changes'); + } else if (fileCount === 1) { + nextTitle = localize('changesView.titleWithCountOne', '1 Change'); + } else { + nextTitle = localize('changesView.titleWithCount', '{0} Changes', fileCount); + } + + if (nextTitle === _changesContainerTitleValue) { + return; + } + + _changesContainerTitleValue = nextTitle; + const viewContainer = this.viewDescriptorService.getViewContainerById(CHANGES_VIEW_CONTAINER_ID); + if (viewContainer) { + this.viewDescriptorService.getViewContainerModel(viewContainer).refreshContainerInfo(); + } + } + private onVisible(): void { this.renderDisposables.clear(); - const activeSessionResource = this.activeSession.map(a => a?.resource); - - // Create observable for the active editing session - // Note: We must read editingSessionsObs to establish a reactive dependency, - // so that the view updates when a new editing session is added (e.g., cloud sessions) - const activeEditingSessionObs = derived(reader => { - const activeSession = this.activeSession.read(reader); - if (!activeSession) { - return undefined; - } - const sessions = this.chatEditingService.editingSessionsObs.read(reader); - return sessions.find(candidate => isEqual(candidate.chatSessionResource, activeSession.resource)); - }); - - // Create observable for edit session entries from the ACTIVE session only (local editing sessions) - const editSessionEntriesObs = derived(reader => { - const activeSession = this.activeSession.read(reader); - - // Background chat sessions render the working set based on the session files, not the editing session - if (activeSession?.providerType === AgentSessionProviders.Background) { - return []; - } - - const session = activeEditingSessionObs.read(reader); - if (!session) { - return []; - } - - const entries = session.entries.read(reader); - const items: IChangesFileItem[] = []; - - for (const entry of entries) { - const isDeletion = entry.isDeletion ?? false; - const linesAdded = entry.linesAdded?.read(reader) ?? 0; - const linesRemoved = entry.linesRemoved?.read(reader) ?? 0; - - items.push({ - type: 'file', - uri: entry.modifiedURI, - originalUri: entry.originalURI, - state: entry.state.read(reader), - isDeletion, - changeType: isDeletion ? 'deleted' : 'modified', - linesAdded, - linesRemoved, - reviewCommentCount: 0, - }); - } - - return items; - }); - - // Signal observable that triggers when sessions data changes - const sessionsChangedSignal = observableFromEvent( - this.renderDisposables, - this.agentSessionsService.model.onDidChangeSessions, - () => ({}), - ); - - // Observable for session file changes from agentSessionsService (cloud/background sessions) - // Reactive to both activeSession changes AND session data changes - const sessionFileChangesObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); - sessionsChangedSignal.read(reader); - if (!sessionResource) { - return Iterable.empty(); - } - const model = this.agentSessionsService.getSession(sessionResource); - return model?.changes instanceof Array ? model.changes : Iterable.empty(); - }); const reviewCommentCountByFileObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); - const sessionChanges = [...sessionFileChangesObs.read(reader)]; + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); + const changes = [...this.viewModel.activeSessionChangesObs.read(reader)]; if (!sessionResource) { return new Map(); @@ -564,11 +479,11 @@ export class ChangesViewPane extends ViewPane { } } - if (sessionChanges.length === 0) { + if (changes.length === 0) { return result; } - const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); + const reviewFiles = getCodeReviewFilesFromSessionChanges(changes as readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]); const reviewVersion = getCodeReviewVersion(reviewFiles); const reviewState = this.codeReviewService.getReviewState(sessionResource).read(reader); @@ -587,8 +502,9 @@ export class ChangesViewPane extends ViewPane { // Convert session file changes to list items (cloud/background sessions) const sessionFilesObs = derived(reader => { const reviewCommentCountByFile = reviewCommentCountByFileObs.read(reader); + const changes = [...this.viewModel.activeSessionChangesObs.read(reader)]; - return [...sessionFileChangesObs.read(reader)].map((entry): IChangesFileItem => { + return changes.map((entry): IChangesFileItem => { const isDeletion = entry.modifiedUri === undefined; const isAddition = entry.originalUri === undefined; const uri = isIChatSessionFileChange2(entry) @@ -608,45 +524,71 @@ export class ChangesViewPane extends ViewPane { }); }); - // 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); + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); return repository?.state.read(reader)?.HEAD?.commit; }); + const lastCheckpointRefObs = derived(reader => { + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); + if (!sessionResource) { + return undefined; + } + + this.viewModel.sessionsChangedSignal.read(reader); + const model = this.agentSessionsService.getSession(sessionResource); + + return model?.metadata?.lastCheckpointRef as string | undefined; + }); + const lastTurnChangesObs = derived(reader => { - const repository = this.activeSessionRepositoryObs.read(reader); + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); const headCommit = headCommitObs.read(reader); + if (!repository || !headCommit) { return constObservable(undefined); } - return new ObservablePromise(repository.diffBetweenWithStats(`${headCommit}^`, headCommit)).resolvedValue; + const lastCheckpointRef = lastCheckpointRefObs.read(reader); + + return lastCheckpointRef + ? new ObservablePromise(repository.diffBetweenWithStats(`${lastCheckpointRef}^`, lastCheckpointRef)).resolvedValue + : new ObservablePromise(repository.diffBetweenWithStats(`${headCommit}^`, headCommit)).resolvedValue; }); // Combine both entry sources for display const combinedEntriesObs = derived(reader => { const headCommit = headCommitObs.read(reader); - const versionMode = this.versionModeObs.read(reader); - const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); - const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? []; const lastTurnDiffChanges = lastTurnChangesObs.read(reader).read(reader); + const versionMode = this.viewModel.versionModeObs.read(reader); let sourceEntries: IChangesFileItem[]; - if (versionMode === ChangesVersionMode.Uncommitted) { - sourceEntries = repositoryFiles; - } else if (versionMode === ChangesVersionMode.LastTurn) { + if (versionMode === ChangesVersionMode.LastTurn) { const diffChanges = lastTurnDiffChanges ?? []; - const parentRef = headCommit ? `${headCommit}^` : ''; + const lastCheckpointRef = lastCheckpointRefObs.read(undefined); + + const ref = lastCheckpointRef + ? lastCheckpointRef + : headCommit; + + const parentRef = lastCheckpointRef + ? `${lastCheckpointRef}^` + : headCommit ? `${headCommit}^` : undefined; + 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 }) }) + const uri = change.modifiedUri ?? change.uri; + const fileUri = isDeletion + ? uri + : ref + ? uri.with({ scheme: 'git', query: JSON.stringify({ path: uri.fsPath, ref }) }) + : uri; + const originalUri = isAddition + ? change.originalUri + : parentRef + ? fileUri.with({ scheme: 'git', query: JSON.stringify({ path: fileUri.fsPath, ref: parentRef }) }) : change.originalUri; return { type: 'file', @@ -661,7 +603,7 @@ export class ChangesViewPane extends ViewPane { } satisfies IChangesFileItem; }); } else { - sourceEntries = [...editEntries, ...sessionFiles, ...repositoryFiles]; + sourceEntries = [...sessionFiles]; } const resources = new Set(); @@ -677,9 +619,6 @@ export class ChangesViewPane extends ViewPane { // 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; @@ -689,85 +628,59 @@ export class ChangesViewPane extends ViewPane { removed += entry.linesRemoved; } - const files = entries.length; - const isSessionMenu = editEntries.length === 0 && (sessionFiles.length > 0 || repositoryFiles.length > 0); - - return { files, added, removed, isSessionMenu }; + return { files: entries.length, added, removed }; }); // Setup context keys and actions toolbar if (this.actionsContainer) { dom.clearNode(this.actionsContainer); - const scopedInstantiationService = this.renderDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, this.scopedContextKeyService]))); - - // Set the chat session type context key reactively so that menu items with - // `chatSessionType == copilotcli` (e.g. Create Pull Request) are shown - const chatSessionTypeKey = this.scopedContextKeyService.createKey(ChatContextKeys.agentSessionType.key, ''); - this.renderDisposables.add(autorun(reader => { - const activeSession = this.activeSession.read(reader); - chatSessionTypeKey.set(activeSession?.providerType ?? ''); - })); - - // Bind required context keys for the menu buttons - this.renderDisposables.add(bindContextKey(hasUndecidedChatEditingResourceContextKey, this.scopedContextKeyService, r => { - const session = activeEditingSessionObs.read(r); - if (!session) { - return false; - } - const entries = session.entries.read(r); - return entries.some(entry => entry.state.read(r) === ModifiedFileEntryState.Modified); - })); - - this.renderDisposables.add(bindContextKey(hasAppliedChatEditsContextKey, this.scopedContextKeyService, r => { - const session = activeEditingSessionObs.read(r); - if (!session) { - return false; - } - const entries = session.entries.read(r); - return entries.length > 0; - })); - - const hasAgentSessionChangesObs = derived(reader => { + this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, reader => { const { files } = topLevelStats.read(reader); return files > 0; - }); + })); - this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, r => hasAgentSessionChangesObs.read(r))); - - const hasUncommittedChangesObs = derived(reader => { - const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader); - return (repositoryFiles?.length ?? 0) > 0; - }); - - this.renderDisposables.add(bindContextKey(hasUncommittedChangesContextKey, this.scopedContextKeyService, r => hasUncommittedChangesObs.read(r))); - - const isMergeBaseBranchProtectedObs = derived(reader => { - const activeSession = this.activeSession.read(reader); + this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); return activeSession?.worktreeBaseBranchProtected === true; - }); + })); - this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, r => isMergeBaseBranchProtectedObs.read(r))); - - const hasOpenPullRequestObs = derived(reader => { - const sessionResource = activeSessionResource.read(reader); + this.renderDisposables.add(bindContextKey(hasOpenPullRequestContextKey, this.scopedContextKeyService, reader => { + this.viewModel.sessionsChangedSignal.read(reader); + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); if (!sessionResource) { return false; } - sessionsChangedSignal.read(reader); - const metadata = this.agentSessionsService.getSession(sessionResource)?.metadata; - return !!metadata?.pullRequestUrl; + return metadata?.pullRequestUrl !== undefined; + })); + + this.renderDisposables.add(bindContextKey(hasIncomingChangesContextKey, this.scopedContextKeyService, reader => { + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + const repositoryState = repository?.state.read(reader); + return (repositoryState?.HEAD?.behind ?? 0) > 0; + })); + + const outgoingChangesObs = derived(reader => { + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + const repositoryState = repository?.state.read(reader); + return repositoryState?.HEAD?.ahead ?? 0; }); - this.renderDisposables.add(bindContextKey(hasOpenPullRequestContextKey, this.scopedContextKeyService, r => hasOpenPullRequestObs.read(r))); + this.renderDisposables.add(bindContextKey(hasOutgoingChangesContextKey, this.scopedContextKeyService, reader => { + const outgoingChanges = outgoingChangesObs.read(reader); + return outgoingChanges > 0; + })); + + const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); + const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); + this.renderDisposables.add(scopedInstantiationService); 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; + const { added, removed } = topLevelStats.read(reader); + const outgoingChanges = outgoingChangesObs.read(reader); + const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); // Read code review state to update the button label dynamically let reviewCommentCount: number | undefined; @@ -797,11 +710,11 @@ export class ChangesViewPane extends ViewPane { reader.store.add(scopedInstantiationService.createInstance( MenuWorkbenchButtonBar, this.actionsContainer!, - menuId, + MenuId.ChatEditingSessionChangesToolbar, { telemetrySource: 'changesView', - disableWhileRunning: isSessionMenu, - menuOptions: isSessionMenu && sessionResource + disableWhileRunning: true, + menuOptions: sessionResource ? { args: [sessionResource, this.agentSessionsService.getSession(sessionResource)?.metadata] } : { shouldForwardArgs: true }, buttonConfigProvider: (action) => { @@ -827,6 +740,13 @@ export class ChangesViewPane extends ViewPane { if (action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR') { return { showIcon: true, showLabel: true, isSecondary: false }; } + if (action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR') { + const customLabel = outgoingChanges > 0 + ? localize('updatePRWithOutgoingChanges', 'Update Pull Request {0}↑', outgoingChanges) + : localize('updatePR', 'Update Pull Request'); + + return { customLabel, showIcon: true, showLabel: true, isSecondary: false }; + } if (action.id === 'github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR') { return { showIcon: true, showLabel: false, isSecondary: true }; } @@ -850,12 +770,12 @@ export class ChangesViewPane extends ViewPane { dom.setVisibility(!hasEntries, this.welcomeContainer!); })); - // Update badge when file count changes + // Update inline title count from the same stats the tree uses this.renderDisposables.add(autorun(reader => { - this.updateBadge(topLevelStats.read(reader).files); + this.updateContainerTitle(topLevelStats.read(reader).files); })); - // Update summary text (line counts only, file count is shown in badge) + // Update summary text (line counts only) if (this.summaryContainer) { dom.clearNode(this.summaryContainer); @@ -915,7 +835,7 @@ export class ChangesViewPane extends ViewPane { }, compressionEnabled: true, twistieAdditionalCssClass: (e: unknown) => { - return this.viewMode === ChangesViewMode.List + return this.viewModel.viewModeObs.get() === ChangesViewMode.List ? 'force-no-twistie' : undefined; }, @@ -1010,7 +930,7 @@ export class ChangesViewPane extends ViewPane { // Update tree data with combined entries this.renderDisposables.add(autorun(reader => { const entries = combinedEntriesObs.read(reader); - const viewMode = this.viewModeObs.read(reader); + const viewMode = this.viewModel.viewModeObs.read(reader); if (!this.tree) { return; @@ -1119,6 +1039,7 @@ export class ChangesViewPane extends ViewPane { } override dispose(): void { + this.updateContainerTitle(0); this.tree?.dispose(); this.tree = undefined; super.dispose(); @@ -1357,7 +1278,7 @@ class SetChangesListViewModeAction extends ViewAction { } async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { - view.viewMode = ChangesViewMode.List; + view.viewModel.setViewMode(ChangesViewMode.List); } } @@ -1380,7 +1301,7 @@ class SetChangesTreeViewModeAction extends ViewAction { } async runInView(_: ServicesAccessor, view: ChangesViewPane): Promise { - view.viewMode = ChangesViewMode.Tree; + view.viewModel.setViewMode(ChangesViewMode.Tree); } } @@ -1416,7 +1337,7 @@ class AllChangesAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const viewsService = accessor.get(IViewsService); const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); - view?.setVersionMode(ChangesVersionMode.AllChanges); + view?.viewModel.setVersionMode(ChangesVersionMode.AllChanges); } } registerAction2(AllChangesAction); @@ -1439,31 +1360,7 @@ class LastTurnChangesAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const viewsService = accessor.get(IViewsService); const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); - view?.setVersionMode(ChangesVersionMode.LastTurn); + view?.viewModel.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/toggleChangesView.ts b/src/vs/sessions/contrib/changes/browser/changesViewController.ts similarity index 58% rename from src/vs/sessions/contrib/changes/browser/toggleChangesView.ts rename to src/vs/sessions/contrib/changes/browser/changesViewController.ts index abc40780aa6..95a090d25cc 100644 --- a/src/vs/sessions/contrib/changes/browser/toggleChangesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewController.ts @@ -3,16 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { autorun, derivedOpts, IReader } from '../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; -import { IChatEditingService } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; -import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; @@ -23,39 +20,53 @@ interface IPendingTurnState { readonly submittedAt: number; } -export class ToggleChangesViewContribution extends Disposable { +export class ChangesViewController extends Disposable { - static readonly ID = 'workbench.contrib.toggleChangesView'; + static readonly ID = 'workbench.contrib.changesViewController'; private readonly pendingTurnStateByResource = new ResourceMap(); constructor( @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, - @IChatEditingService private readonly chatEditingService: IChatEditingService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatService private readonly chatService: IChatService, @IViewsService private readonly viewsService: IViewsService, ) { super(); - const activeSessionResourceObs = derivedOpts({ - equalsFn: isEqual, - }, (reader) => { - return this.sessionManagementService.activeSession.map(activeSession => activeSession?.resource).read(reader); - }).recomputeInitiallyAndOnChange(this._store); + const activeSessionChangedSignal = observableSignalFromEvent(this, + this.agentSessionsService.model.onDidChangeSessions); - this._register(this.chatService.onDidSubmitRequest(({ chatSessionResource }) => { - this.pendingTurnStateByResource.set(chatSessionResource, { - hadChangesBeforeSend: this.hasSessionChanges(chatSessionResource), - submittedAt: Date.now(), - }); + const activeSessionResourceObs = derivedOpts({ equalsFn: isEqual, }, reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.resource; + }); + + const activeSessionHasChangesObs = derived(reader => { + const sessionResource = activeSessionResourceObs.read(reader); + if (!sessionResource) { + return false; + } + + activeSessionChangedSignal.read(reader); + const model = this.agentSessionsService.getSession(sessionResource); + const changes = model?.changes instanceof Array ? model.changes : []; + return changes.length > 0; + }); + + // Switch between sessions + this._register(autorun(reader => { + const activeSessionHasChanges = activeSessionHasChangesObs.read(reader); + this.syncAuxiliaryBarVisibility(activeSessionHasChanges); })); - // When a turn is completed, check if there were changes before the turn and if there are changes after the turn. - // If there were no changes before the turn and there are changes after the turn, show the auxiliary bar. + // When a turn is completed, check if there were changes before the turn and + // if there are changes after the turn. If there were no changes before the + // turn and there are changes after the turn, show the auxiliary bar. this._register(autorun((reader) => { const activeSessionResource = activeSessionResourceObs.read(reader); + const activeSessionHasChanges = activeSessionHasChangesObs.read(reader); if (!activeSessionResource) { return; } @@ -71,43 +82,21 @@ export class ToggleChangesViewContribution extends Disposable { return; } - const hasChangesAfterTurn = this.hasSessionChanges(activeSessionResource, reader); - if (!pendingTurnState.hadChangesBeforeSend && hasChangesAfterTurn) { + if (!pendingTurnState.hadChangesBeforeSend && activeSessionHasChanges) { this.layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); } this.pendingTurnStateByResource.delete(activeSessionResource); })); - // When the session is switched, show the auxiliary bar if there are pending changes from the session - this._register(autorun(reader => { - const sessionResource = activeSessionResourceObs.read(reader); - if (!sessionResource) { - this.syncAuxiliaryBarVisibility(false); - return; - } - - const hasChanges = this.hasSessionChanges(sessionResource, reader); - this.syncAuxiliaryBarVisibility(hasChanges); + this._register(this.chatService.onDidSubmitRequest(({ chatSessionResource }) => { + this.pendingTurnStateByResource.set(chatSessionResource, { + hadChangesBeforeSend: activeSessionHasChangesObs.get(), + submittedAt: Date.now(), + }); })); } - private hasSessionChanges(sessionResource: URI, reader?: IReader): boolean { - const isBackgroundSession = getChatSessionType(sessionResource) === AgentSessionProviders.Background; - - let editingSessionCount = 0; - if (!isBackgroundSession) { - const sessions = this.chatEditingService.editingSessionsObs.read(reader); - const editingSession = sessions.find(candidate => isEqual(candidate.chatSessionResource, sessionResource)); - editingSessionCount = editingSession ? editingSession.entries.read(reader).length : 0; - } - - const session = this.agentSessionsService.getSession(sessionResource); - const sessionFilesCount = session?.changes instanceof Array ? session.changes.length : 0; - - return editingSessionCount + sessionFilesCount > 0; - } - private syncAuxiliaryBarVisibility(hasChanges: boolean): void { if (hasChanges) { this.viewsService.openView(CHANGES_VIEW_ID, false); diff --git a/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts index 9dec9927ee8..9b81b8bf26c 100644 --- a/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts +++ b/src/vs/sessions/contrib/changes/browser/ciStatusWidget.ts @@ -23,16 +23,10 @@ import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../ 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'; +import { CICheckGroup, buildFixChecksPrompt, getCheckGroup, getCheckStateLabel, getFailedChecks } from './fixCIChecksAction.js'; const $ = dom.$; -const enum CICheckGroup { - Running, - Pending, - Failed, - Successful, -} - interface ICICheckListItem { readonly check: IGitHubCICheck; readonly group: CICheckGroup; @@ -397,17 +391,6 @@ function compareChecks(a: IGitHubCICheck, b: IGitHubCICheck): number { 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; @@ -434,10 +417,6 @@ function getCheckCounts(checks: readonly IGitHubCICheck[]): ICICheckCounts { 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[] = []; @@ -469,37 +448,10 @@ function getChecksSummary(checks: readonly IGitHubCICheck[]): string { 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: Codicon.loading, className: 'ci-status-running' }; + return { icon: Codicon.clock, className: 'ci-status-running' }; } switch (overallStatus) { @@ -517,7 +469,7 @@ function getHeaderIconAndClass(checks: readonly IGitHubCICheck[], overallStatus: function getCheckIcon(check: IGitHubCICheck): ThemeIcon { switch (check.status) { case GitHubCheckStatus.InProgress: - return Codicon.loading; + return Codicon.clock; case GitHubCheckStatus.Queued: return Codicon.circleFilled; case GitHubCheckStatus.Completed: @@ -552,22 +504,3 @@ function getCheckStatusClass(check: IGitHubCICheck): string { 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/changes/browser/fixCIChecksAction.ts b/src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts new file mode 100644 index 00000000000..a818cdb020a --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/fixCIChecksAction.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { derived } from '../../../../base/common/observable.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ChatViewPaneTarget, 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 { IGitHubService } from '../../github/browser/githubService.js'; +import { GitHubCheckConclusion, GitHubCheckStatus, IGitHubCICheck } from '../../github/common/types.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; + +export const hasActiveSessionFailedCIChecks = new RawContextKey('sessions.hasActiveSessionFailedCIChecks', false); + +// --- Shared CI check utilities ------------------------------------------------ + +export const enum CICheckGroup { + Running, + Pending, + Failed, + Successful, +} + +export function isFailedConclusion(conclusion: GitHubCheckConclusion | undefined): boolean { + return conclusion === GitHubCheckConclusion.Failure + || conclusion === GitHubCheckConclusion.TimedOut + || conclusion === GitHubCheckConclusion.ActionRequired; +} + +export 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; + } +} + +export 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"); + } +} + +export function getFailedChecks(checks: readonly IGitHubCICheck[]): readonly IGitHubCICheck[] { + return checks.filter(check => getCheckGroup(check) === CICheckGroup.Failed); +} + +export 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'); +} + +/** + * Sets the `hasActiveSessionFailedCIChecks` context key to true when the + * active session has a PR with CI checks and at least one has failed. + */ +class ActiveSessionFailedCIChecksContextContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.activeSessionFailedCIChecksContext'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @ISessionsManagementService sessionManagementService: ISessionsManagementService, + @IGitHubService gitHubService: IGitHubService, + ) { + super(); + + const ciModelObs = derived(this, reader => { + const session = sessionManagementService.activeSession.read(reader); + if (!session) { + return undefined; + } + const context = sessionManagementService.getGitHubContextForSession(session.resource); + if (!context || context.prNumber === undefined) { + return undefined; + } + const prModel = gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + const pr = prModel.pullRequest.read(reader); + if (!pr) { + return undefined; + } + return gitHubService.getPullRequestCI(context.owner, context.repo, pr.headRef); + }); + + this._register(bindContextKey(hasActiveSessionFailedCIChecks, contextKeyService, reader => { + const ciModel = ciModelObs.read(reader); + if (!ciModel) { + return false; + } + const checks = ciModel.checks.read(reader); + return getFailedChecks(checks).length > 0; + })); + } +} + +class FixCIChecksAction extends Action2 { + + static readonly ID = 'sessions.action.fixCIChecks'; + + constructor() { + super({ + id: FixCIChecksAction.ID, + title: localize2('fixCIChecks', 'Fix CI Checks'), + icon: Codicon.lightbulbAutofix, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, hasActiveSessionFailedCIChecks), + menu: [{ + id: MenuId.ChatEditingSessionApplySubmenu, + group: 'navigation', + order: 4, + when: ContextKeyExpr.and(IsSessionsWindowContext, hasActiveSessionFailedCIChecks), + }], + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const sessionManagementService = accessor.get(ISessionsManagementService); + const gitHubService = accessor.get(IGitHubService); + const chatWidgetService = accessor.get(IChatWidgetService); + const logService = accessor.get(ILogService); + + const activeSession = sessionManagementService.getActiveSession(); + if (!activeSession) { + return; + } + + const sessionResource = activeSession.resource; + const context = sessionManagementService.getGitHubContextForSession(sessionResource); + if (!context || context.prNumber === undefined) { + return; + } + + const prModel = gitHubService.getPullRequest(context.owner, context.repo, context.prNumber); + const pr = prModel.pullRequest.get(); + if (!pr) { + return; + } + + const ciModel = gitHubService.getPullRequestCI(context.owner, context.repo, pr.headRef); + const checks = ciModel.checks.get(); + const failedChecks = getFailedChecks(checks); + if (failedChecks.length === 0) { + return; + } + + const failedCheckDetails = await Promise.all(failedChecks.map(async check => { + const annotations = await ciModel.getCheckRunAnnotations(check.id); + return { check, annotations }; + })); + + const prompt = buildFixChecksPrompt(failedCheckDetails); + const chatWidget = chatWidgetService.getWidgetBySessionResource(sessionResource) + ?? await chatWidgetService.openSession(sessionResource, ChatViewPaneTarget); + if (!chatWidget) { + logService.error('[FixCIChecks] Cannot fix CI checks: no chat widget found for session', sessionResource.toString()); + return; + } + + await chatWidget.acceptInput(prompt, { noCommandDetection: true }); + } +} + +registerWorkbenchContribution2(ActiveSessionFailedCIChecksContextContribution.ID, ActiveSessionFailedCIChecksContextContribution, WorkbenchPhase.AfterRestored); +registerAction2(FixCIChecksAction); diff --git a/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css index 87983d53cb6..451457dd543 100644 --- a/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css +++ b/src/vs/sessions/contrib/changes/browser/media/ciStatusWidget.css @@ -32,12 +32,15 @@ /* Title - single line, overflow ellipsis */ .ci-status-widget-title { flex: 1; + display: flex; + align-items: center; overflow: hidden; color: var(--vscode-foreground); } .ci-status-widget-title .monaco-icon-label { width: 100%; + height: 18px; } .ci-status-widget-title .monaco-icon-label-container, diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index d7c33c437c4..ccc6c567ee3 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -51,9 +51,9 @@ export class OpenSessionWorktreeInVSCodeAction extends Action2 { icon: Codicon.vscodeInsiders, precondition: IsActiveSessionBackgroundProviderContext, menu: [{ - id: Menus.TitleBarSessionMenu, + id: Menus.TitleBarRightLayout, group: 'navigation', - order: 10, + order: 0, when: ContextKeyExpr.and(IsAuxiliaryWindowContext.toNegated(), SessionsWelcomeVisibleContext.toNegated(), IsActiveSessionBackgroundProviderContext), }] }); diff --git a/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts b/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts new file mode 100644 index 00000000000..ef48023c1eb --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/chatSessionHeader.ts @@ -0,0 +1,264 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatSessionHeader.css'; +import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { basename } from '../../../../base/common/resources.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; +import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; +import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionsPicker } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderIcon } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { ChatViewId } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; + +/** + * Renders a PR-style header at the top of the chat messages area. + * Displays: session title + folder name (no diff numbers). + */ +class ChatSessionHeaderContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.chatSessionHeader'; + + private readonly headerElement: HTMLElement; + private readonly titleElement: HTMLElement; + private readonly repoElement: HTMLElement; + private readonly iconElement: HTMLElement; + private readonly markDoneButton: Button; + private readonly modelChangeListener = this._register(new MutableDisposable()); + private lastRenderState: string | undefined; + private isRendering = false; + + constructor( + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @IChatService private readonly chatService: IChatService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IViewsService private readonly viewsService: IViewsService, + ) { + super(); + + // Create header DOM (will be inserted when a chat container is found) + this.headerElement = $('.chat-session-header'); + + const headerContent = append(this.headerElement, $('.chat-session-header-content')); + headerContent.setAttribute('role', 'button'); + headerContent.setAttribute('aria-label', localize('showSessions', "Show Sessions")); + headerContent.tabIndex = 0; + + // Title row: title + done button + const titleRow = append(headerContent, $('.chat-session-header-title-row')); + this.titleElement = append(titleRow, $('span.chat-session-header-title')); + + // Mark as Done button + const buttonContainer = append(titleRow, $('.chat-session-header-actions')); + this._register(addDisposableListener(buttonContainer, EventType.CLICK, e => { + e.stopPropagation(); + })); + this.markDoneButton = this._register(new Button(buttonContainer, { supportIcons: true, ...defaultButtonStyles })); + this.markDoneButton.label = `$(check) ${localize('markAsDone', "Mark as Done")}`; + this._register(this.markDoneButton.onDidClick(() => this.markAsDone())); + + // Repo row: icon + folder name + const repoRow = append(headerContent, $('span.chat-session-header-repo-row')); + this.iconElement = append(repoRow, $('span.chat-session-header-icon')); + this.repoElement = append(repoRow, $('span.chat-session-header-repo')); + + // Click handler — show sessions picker (same as titlebar) + this._register(addDisposableListener(headerContent, EventType.CLICK, e => { + e.preventDefault(); + e.stopPropagation(); + this.showSessionsPicker(); + })); + + this._register(addDisposableListener(headerContent, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this.showSessionsPicker(); + } + })); + + // Watch active session changes + this._register(autorun(reader => { + const activeSession = this.sessionsManagementService.activeSession.read(reader); + this.trackModelChanges(activeSession?.resource); + this.lastRenderState = undefined; + this.render(); + })); + + // Watch session data changes + this._register(this.agentSessionsService.model.onDidChangeSessions(() => { + this.lastRenderState = undefined; + this.render(); + })); + + // Periodically try to inject into the DOM (chat widget may not exist yet) + this.ensureInjected(); + } + + private tryInject(): boolean { + const view = this.viewsService.getViewWithId(ChatViewId); + if (!view?.element) { + return false; + } + // Re-inject if the header is not a child of the current view element + // (view may have been recreated on session switch) + if (this.headerElement.parentElement !== view.element) { + view.element.insertBefore(this.headerElement, view.element.firstChild); + } + return true; + } + + private ensureInjected(): void { + if (!this.tryInject()) { + // Retry when the chat view becomes visible + this._register(this.viewsService.onDidChangeViewVisibility(e => { + if (e.id === ChatViewId && e.visible) { + this.tryInject(); + } + })); + } + } + + private render(): void { + if (this.isRendering) { + return; + } + this.isRendering = true; + try { + // Ensure header is in the DOM (may have been created before the view mounted) + this.tryInject(); + + const label = this.getLabel(); + const icon = this.getIcon(); + const repoLabel = this.getRepoLabel(); + + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}`; + if (this.lastRenderState === renderState) { + return; + } + this.lastRenderState = renderState; + + // Icon + this.iconElement.className = 'chat-session-header-icon'; + if (icon) { + this.iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); + this.iconElement.style.display = ''; + } else { + this.iconElement.style.display = 'none'; + } + + // Title + this.titleElement.textContent = label; + + // Repo folder + if (repoLabel) { + this.repoElement.textContent = repoLabel; + this.repoElement.style.display = ''; + } else { + this.repoElement.style.display = 'none'; + } + + // Show the button only when there is an active session with an agent session + const activeSession = this.sessionsManagementService.getActiveSession(); + const hasAgentSession = activeSession ? !!this.agentSessionsService.getSession(activeSession.resource) : false; + this.markDoneButton.element.style.display = hasAgentSession ? '' : 'none'; + } finally { + this.isRendering = false; + } + } + + private getLabel(): string { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (activeSession?.label) { + return activeSession.label; + } + if (activeSession) { + const model = this.chatService.getSession(activeSession.resource); + if (model?.title) { + return model.title; + } + } + return localize('newSession', "New Session"); + } + + private getIcon(): ThemeIcon | undefined { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (!activeSession) { + return undefined; + } + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + if (agentSession) { + if (agentSession.providerType === AgentSessionProviders.Background) { + const hasWorktree = typeof agentSession.metadata?.worktreePath === 'string'; + return hasWorktree ? Codicon.worktree : Codicon.folder; + } + return agentSession.icon; + } + const provider = getAgentSessionProvider(activeSession.resource); + if (provider !== undefined) { + return getAgentSessionProviderIcon(provider); + } + return undefined; + } + + private getRepoLabel(): string | undefined { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (!activeSession?.repository) { + return undefined; + } + return basename(activeSession.repository); + } + + private markAsDone(): void { + const activeSession = this.sessionsManagementService.getActiveSession(); + if (!activeSession) { + return; + } + const agentSession = this.agentSessionsService.getSession(activeSession.resource); + if (agentSession) { + agentSession.setArchived(true); + } + this.sessionsManagementService.openNewSessionView(); + } + + private showSessionsPicker(): void { + const picker = this.instantiationService.createInstance(AgentSessionsPicker, undefined, { + overrideSessionOpen: (session, openOptions) => this.sessionsManagementService.openSession(session.resource, openOptions) + }); + picker.pickAgentSession(); + } + + private trackModelChanges(resource: URI | undefined): void { + this.modelChangeListener.clear(); + if (!resource) { + return; + } + const model = this.chatService.getSession(resource); + if (!model) { + return; + } + this.modelChangeListener.value = model.onDidChange(e => { + if (e.kind === 'setCustomTitle' || e.kind === 'addRequest') { + this.lastRenderState = undefined; + this.render(); + } + }); + } +} + +registerWorkbenchContribution2(ChatSessionHeaderContribution.ID, ChatSessionHeaderContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css b/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css new file mode 100644 index 00000000000..f0e0a6ea769 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/chatSessionHeader.css @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Chat Session Header (PR-style) ---- */ + +.agent-sessions-workbench .chat-session-header { + position: relative; + width: 100%; + max-width: 950px; + margin: 0 auto; + padding: 0px 8px; + box-sizing: border-box; +} + +.agent-sessions-workbench .chat-session-header-content { + display: flex; + flex-direction: column; + min-width: 0; + cursor: pointer; + padding: 2px 8px 6px; +} + +.agent-sessions-workbench .chat-session-header-content:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + border-radius: 4px; +} + +.agent-sessions-workbench .chat-session-header-title-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 6px; +} + +/* Title — large by default, like a PR title */ +.agent-sessions-workbench .chat-session-header-title { + align-self: flex-start; + font-size: 22px; + font-weight: 600; + line-height: 1.3; + color: var(--vscode-foreground); + word-break: break-word; + border-radius: 4px 4px 4px 0; + padding: 2px 4px; + transition: background 150ms ease; +} + +.agent-sessions-workbench .chat-session-header-title:hover, +.agent-sessions-workbench .chat-session-header-content:has(.chat-session-header-repo-row:hover) .chat-session-header-title { + background: var(--vscode-toolbar-hoverBackground); +} + +.agent-sessions-workbench .chat-session-header-repo-row:hover, +.agent-sessions-workbench .chat-session-header-content:has(.chat-session-header-title:hover) .chat-session-header-repo-row { + background: var(--vscode-toolbar-hoverBackground); +} + +/* Repo row: icon + folder name */ +.agent-sessions-workbench .chat-session-header-repo-row { + align-self: flex-start; + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + border-radius: 0 0 4px 4px; + padding: 2px 4px; + transition: background 150ms ease; +} + +/* Icon */ +.agent-sessions-workbench .chat-session-header-icon { + font-size: 14px; + opacity: 0.6; + flex-shrink: 0; +} + +/* Repo folder — secondary text */ +.agent-sessions-workbench .chat-session-header-repo { + font-size: 13px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +/* ---- Mark as Done button ---- */ + +.agent-sessions-workbench .chat-session-header-actions { + flex-shrink: 0; +} + +.agent-sessions-workbench .chat-session-header-actions .monaco-button { + white-space: nowrap; + padding: 4px 6px 4px 0px; + margin-top: 6px; + background: none !important; + border: none !important; + color: var(--vscode-textLink-foreground) !important; + font-size: 12px; + cursor: pointer; +} + +.agent-sessions-workbench .chat-session-header-actions .monaco-button:hover { + color: var(--vscode-textLink-activeForeground) !important; + text-decoration: underline; + outline: 1px solid var(--vscode-focusBorder); + border-radius: 4px; +} + +/* ---- Reduced motion ---- */ + +@media (prefers-reduced-motion: reduce) { + .agent-sessions-workbench .chat-session-header-title, + .agent-sessions-workbench .chat-session-header-repo-row { + transition: none; + } +} diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css index 9ff4dde299b..8a643bc79a4 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -12,7 +12,7 @@ height: 100%; box-sizing: border-box; overflow: hidden; - padding: 0 12px 48px 12px; + padding: 0 16px 48px 16px; container-type: size; } @@ -178,7 +178,7 @@ } /* Prominent project picker button */ -.sessions-chat-picker-slot.sessions-chat-project-picker .action-label { +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label { height: auto; padding: 8px 20px; font-size: 15px; @@ -188,16 +188,22 @@ border-radius: 6px; } -.sessions-chat-picker-slot.sessions-chat-project-picker .action-label:hover { +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label:hover { background-color: var(--vscode-button-secondaryHoverBackground); } -.sessions-chat-picker-slot.sessions-chat-project-picker .action-label .codicon { +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .codicon { font-size: 18px; - margin-right: 6px; + margin-right: 2px; } -.sessions-chat-picker-slot.sessions-chat-project-picker .action-label .sessions-chat-dropdown-label { +.sessions-chat-picker-slot.sessions-chat-project-picker .action-label .codicon-chevron-down { + margin-right: 0; + position: relative; + top: 1px; +} + +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-label { font-size: 15px; } @@ -220,7 +226,7 @@ padding: 3px 3px 3px 6px; background-color: transparent; border: none; - color: var(--vscode-descriptionForeground); + color: var(--vscode-icon-foreground); font-size: 13px; cursor: pointer; white-space: nowrap; @@ -242,7 +248,7 @@ .sessions-chat-picker-slot.disabled .action-label:hover { background-color: transparent; - color: var(--vscode-descriptionForeground); + color: var(--vscode-icon-foreground); } .sessions-chat-picker-slot.loading .action-label { @@ -279,7 +285,7 @@ .sessions-chat-picker-slot .action-label .codicon-chevron-down { font-size: 12px; - margin-left: 2px; + margin-left: 6px; } .sessions-chat-picker-slot .action-label .chat-session-option-label { @@ -291,6 +297,34 @@ margin-left: 2px; } +.sessions-chat-picker-slot .action-label.warning { + color: var(--vscode-problemsWarningIcon-foreground); + opacity: 0.75; +} + +.sessions-chat-picker-slot .action-label.warning .codicon { + color: var(--vscode-problemsWarningIcon-foreground) !important; +} + +.sessions-chat-picker-slot .action-label.warning:hover { + color: var(--vscode-problemsWarningIcon-foreground); + opacity: 1; +} + +.sessions-chat-picker-slot .action-label.info { + color: var(--vscode-problemsInfoIcon-foreground); + opacity: 0.75; +} + +.sessions-chat-picker-slot .action-label.info .codicon { + color: var(--vscode-problemsInfoIcon-foreground) !important; +} + +.sessions-chat-picker-slot .action-label.info:hover { + color: var(--vscode-problemsInfoIcon-foreground); + opacity: 1; +} + /* Sync indicator: a slim non-interactive-looking separator before the button */ .sessions-chat-sync-indicator { margin-left: 4px; diff --git a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts index 9acac072639..0c264d25f9a 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatContextAttachments.ts @@ -15,6 +15,9 @@ import { localize } from '../../../../nls.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { registerOpenEditorListeners } from '../../../../platform/editor/browser/editor.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; +import { IChatImageCarouselService } from '../../../../workbench/contrib/chat/browser/chatImageCarouselService.js'; +import { coerceImageBuffer } from '../../../../workbench/contrib/chat/common/chatImageExtraction.js'; import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; @@ -83,6 +86,7 @@ export class NewChatContextAttachments extends Disposable { @IInstantiationService private readonly instantiationService: IInstantiationService, @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, + @IChatImageCarouselService private readonly chatImageCarouselService: IChatImageCarouselService, ) { super(); this._resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); @@ -133,8 +137,19 @@ export class NewChatContextAttachments extends Disposable { } } - // Click to open the resource - if (resource) { + // Click to open the resource or image + const imageData = entry.kind === 'image' ? coerceImageBuffer(entry.value) : undefined; + if (imageData) { + pill.style.cursor = 'pointer'; + this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { + if (this.configurationService.getValue(ChatConfiguration.ImageCarouselEnabled)) { + const imageResource = resource ?? URI.from({ scheme: 'data', path: entry.name }); + await this.chatImageCarouselService.openCarouselAtResource(imageResource, imageData); + } else if (resource) { + await this.openerService.open(resource, { fromUserGesture: true }); + } + })); + } else if (resource) { pill.style.cursor = 'pointer'; this._renderDisposables.add(registerOpenEditorListeners(pill, async () => { await this.openerService.open(resource, { fromUserGesture: true }); diff --git a/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts index 83c775b304b..e708b57051b 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts @@ -9,7 +9,7 @@ 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 { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } 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'; @@ -151,6 +151,7 @@ export class NewChatPermissionPicker extends Disposable { onHide: () => { triggerElement.focus(); }, }; + const listOptions: IActionListOptions = { descriptionBelow: true, minWidth: 255 }; this.actionWidgetService.show( 'permissionPicker', false, @@ -163,6 +164,7 @@ export class NewChatPermissionPicker extends Disposable { getAriaLabel: (item) => item.label ?? '', getWidgetAriaLabel: () => localize('permissionPicker.ariaLabel', "Permission Picker"), }, + listOptions, ); } @@ -253,5 +255,8 @@ export class NewChatPermissionPicker extends Disposable { const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); labelSpan.textContent = label; dom.append(trigger, renderIcon(Codicon.chevronDown)); + + trigger.classList.toggle('warning', this._currentLevel === ChatPermissionLevel.Autopilot); + trigger.classList.toggle('info', this._currentLevel === ChatPermissionLevel.AutoApprove); } } diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 4875fa441bd..331edec68ab 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -37,7 +37,7 @@ import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js' import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; import * as aria from '../../../../base/browser/ui/aria/aria.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { ChatSessionPosition, getResourceForNewChatSession } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../../../workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.js'; @@ -54,13 +54,12 @@ import { ContextMenuController } from '../../../../editor/contrib/contextmenu/br import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; import { NewChatContextAttachments } from './newChatContextAttachments.js'; import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; -import { TargetPicker } from './sessionTargetPicker.js'; +import { SessionTypePicker, IsolationPicker } from './sessionTargetPicker.js'; import { BranchPicker } from './branchPicker.js'; -import { SyncIndicator } from './syncIndicator.js'; -import { INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js'; +import { AgentHostNewSession, INewSession, ISessionOptionGroup, RemoteNewSession } from './newSession.js'; import { CloudModelPicker } from './modelPicker.js'; -import { ProjectPicker } from './projectPicker.js'; -import { SessionProject } from '../../sessions/common/sessionProject.js'; +import { WorkspacePicker } from './workspacePicker.js'; +import { SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; import { ModePicker } from './modePicker.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; import { SlashCommandHandler } from './slashCommands.js'; @@ -71,6 +70,8 @@ import { ChatHistoryNavigator } from '../../../../workbench/contrib/chat/common/ import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; import { NewChatPermissionPicker } from './newChatPermissionPicker.js'; import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { getRemoteAgentHostSessionTarget } from '../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; const MIN_EDITOR_HEIGHT = 50; @@ -99,10 +100,10 @@ interface INewChatWidgetOptions { */ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { - private readonly _projectPicker: ProjectPicker; - private readonly _targetPicker: TargetPicker; + private readonly _workspacePicker: WorkspacePicker; + private readonly _sessionTypePicker: SessionTypePicker; private readonly _branchPicker: BranchPicker; - private readonly _syncIndicator: SyncIndicator; + private readonly _isolationPicker: IsolationPicker; private readonly _options: INewChatWidgetOptions; // IHistoryNavigationWidget @@ -181,21 +182,22 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { @IGitService private readonly gitService: IGitService, @IStorageService private readonly storageService: IStorageService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, ) { super(); this._history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); - this._projectPicker = this._register(this.instantiationService.createInstance(ProjectPicker)); + this._workspacePicker = this._register(this.instantiationService.createInstance(WorkspacePicker)); this._permissionPicker = this._register(this.instantiationService.createInstance(NewChatPermissionPicker)); this._cloudModelPicker = this._register(this.instantiationService.createInstance(CloudModelPicker)); this._modePicker = this._register(this.instantiationService.createInstance(ModePicker)); - this._targetPicker = this._register(this.instantiationService.createInstance(TargetPicker)); + this._sessionTypePicker = this._register(this.instantiationService.createInstance(SessionTypePicker)); this._branchPicker = this._register(this.instantiationService.createInstance(BranchPicker)); - this._syncIndicator = this._register(this.instantiationService.createInstance(SyncIndicator)); + this._isolationPicker = this._register(this.instantiationService.createInstance(IsolationPicker)); this._options = options; // When a project is selected, infer the target and create a new session - this._register(this._projectPicker.onDidSelectProject(async (project) => { + this._register(this._workspacePicker.onDidSelectProject(async (project) => { await this._onProjectSelected(project); this._updateDraftState(); this._focusEditor(); @@ -208,15 +210,26 @@ 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._targetPicker.onDidChange((mode) => { - this._newSession.value?.setTargetMode(mode); + this._register(this._sessionTypePicker.onDidChange((target) => { + if (target === 'cloud') { + this._isolationPicker.setVisible(false); + this._branchPicker.setVisible(false); + } else { + this._newSession.value?.setIsolationMode(this._isolationPicker.isolationMode); + this._isolationPicker.setVisible(true); + this._branchPicker.setVisible(this._isolationPicker.isWorktree); + } + this._updateDraftState(); + this._focusEditor(); + })); + + this._register(this._isolationPicker.onDidChange((mode) => { + this._newSession.value?.setIsolationMode(mode); this._branchPicker.setVisible(mode === 'worktree'); - this._syncIndicator.setVisible(mode === 'worktree'); this._updateDraftState(); this._focusEditor(); })); @@ -281,12 +294,12 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { // Isolation mode and branch pickers (below the input, shown when Local target is selected) const isolationContainer = dom.append(welcomeElement, dom.$('.chat-full-welcome-local-mode')); - this._targetPicker.render(isolationContainer); + this._sessionTypePicker.render(isolationContainer); this._permissionPicker.render(isolationContainer); dom.append(isolationContainer, dom.$('.sessions-chat-local-mode-spacer')); const branchContainer = dom.append(isolationContainer, dom.$('.sessions-chat-local-mode-right')); + this._isolationPicker.render(branchContainer); this._branchPicker.render(branchContainer); - this._syncIndicator.render(branchContainer); // Render project picker & extension pickers this._renderOptionGroupPickers(); @@ -298,7 +311,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._restoreState(); // Create initial session - const restoredProject = this._projectPicker.selectedProject; + const restoredProject = this._workspacePicker.selectedProject; if (restoredProject) { this._onProjectSelected(restoredProject); } else { @@ -314,8 +327,21 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { }, { once: true })); } - private async _createNewSession(project?: SessionProject): Promise { - const target = project?.isRepo ? AgentSessionProviders.Cloud : AgentSessionProviders.Background; + private async _createNewSession(project?: SessionWorkspace): Promise { + const isAgentHost = project?.isRemoteAgentHost ?? false; + let target: AgentSessionTarget; + if (isAgentHost) { + // Find the matching remote agent host session type from the URI authority + // TODO@roblourens HACK - view should not do this + const remoteTarget = getRemoteAgentHostSessionTarget(this.remoteAgentHostService.connections, project!.uri.authority); + if (!remoteTarget) { + this.logService.error(`Failed to find remote agent host session type for authority: ${project!.uri.authority}`); + return; + } + target = remoteTarget; + } else { + target = project?.isRepo ? AgentSessionProviders.Cloud : AgentSessionProviders.Background; + } const resource = getResourceForNewChatSession({ type: target, @@ -324,7 +350,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { }); try { - const session = await this.sessionsManagementService.createNewSessionForTarget(target, resource); + const session = await this.sessionsManagementService.createNewSessionForTarget(target, resource, { agentHost: isAgentHost }); if (project) { session.setProject(project); } @@ -358,8 +384,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } })); - if (session instanceof RemoteNewSession) { - this._targetPicker.setProject(session.project); + this._sessionTypePicker.setProject(session.project); + + if (session instanceof AgentHostNewSession) { + this._renderAgentHostSessionPickers(); + } else if (session instanceof RemoteNewSession) { this._renderRemoteSessionPickers(session, true); listeners.add(session.onDidChangeOptionGroups(() => { this._renderRemoteSessionPickers(session); @@ -382,7 +411,6 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._repositoryLoading = true; this._updateInputLoadingState(); this._branchPicker.setRepository(undefined); - this._syncIndicator.setRepository(undefined); this._modePicker.reset(); this.gitService.openRepository(folderUri).then(repository => { @@ -397,11 +425,10 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { session.setProject(session.project.withRepository(repository)); } - this._targetPicker.setProject(session?.project); + this._sessionTypePicker.setProject(session?.project); + this._isolationPicker.setHasGitRepo(!!repository); this._branchPicker.setRepository(repository); - this._branchPicker.setVisible(!!repository && this._targetPicker.isWorktree); - this._syncIndicator.setRepository(repository); - this._syncIndicator.setVisible(!!repository && this._targetPicker.isWorktree); + this._branchPicker.setVisible(!!repository && this._sessionTypePicker.isCli && this._isolationPicker.isWorktree); this._modePicker.reset(); }).catch(e => { if (cts.token.isCancellationRequested) { @@ -410,11 +437,10 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this.logService.warn(`Failed to open repository at ${folderUri.toString()}`, getErrorMessage(e)); this._repositoryLoading = false; this._updateInputLoadingState(); - this._targetPicker.setProject(undefined); + this._sessionTypePicker.setProject(undefined); + this._isolationPicker.setHasGitRepo(false); this._branchPicker.setRepository(undefined); this._branchPicker.setVisible(false); - this._syncIndicator.setRepository(undefined); - this._syncIndicator.setVisible(false); this._modePicker.reset(); }); } @@ -677,7 +703,25 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { const pickersRow = dom.append(this._pickersContainer, dom.$('.chat-full-welcome-pickers')); // Project picker (unified folder + repo picker) - this._projectPicker.render(pickersRow); + this._workspacePicker.render(pickersRow); + } + + // --- Agent Host session pickers --- + + /** + * Agent Host sessions use the standard model picker and mode picker + * but don't need repo, folder, isolation, branch, or cloud option pickers. + */ + private _renderAgentHostSessionPickers(): void { + this._clearAllPickers(); + if (this._localModelPickerContainer) { + this._localModelPickerContainer.style.display = ''; + } + this._modePicker.setVisible(true); + this._permissionPicker.setVisible(false); + this._cloudModelPicker.setVisible(false); + this._branchPicker.setVisible(false); + this._isolationPicker.setVisible(false); } // --- Local session pickers --- @@ -703,7 +747,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { this._modePicker.setVisible(false); this._permissionPicker.setVisible(false); this._branchPicker.setVisible(false); - this._syncIndicator.setVisible(false); + this._isolationPicker.setVisible(false); this._cloudModelPicker.setSession(session); this._cloudModelPicker.setVisible(true); @@ -952,25 +996,25 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { * For Local/Background targets, checks the folder picker. * For other targets, checks extension-contributed repo/folder option groups. */ - private _hasRequiredRepoOrFolderSelection(_sessionType: AgentSessionProviders): boolean { + private _hasRequiredRepoOrFolderSelection(_sessionType: AgentSessionTarget): boolean { return !!this._newSession.value?.project; } - private _openRepoOrFolderPicker(_sessionType: AgentSessionProviders): void { - this._projectPicker.showPicker(); + private _openRepoOrFolderPicker(_sessionType: AgentSessionTarget): void { + this._workspacePicker.showPicker(); } - private async _requestFolderTrust(folderUri: URI, previousProject?: SessionProject): Promise { + private async _requestFolderTrust(folderUri: URI, previousProject?: SessionWorkspace): 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._projectPicker.removeFromRecents(folderUri); + this._workspacePicker.removeFromRecents(folderUri); if (previousProject) { - this._projectPicker.setSelectedProject(previousProject, false); + this._workspacePicker.setSelectedProject(previousProject, false); } else { - this._projectPicker.clearSelection(); + this._workspacePicker.clearSelection(); } } return !!trusted; @@ -996,8 +1040,8 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } if (draft.projectUri) { try { - const project = new SessionProject(URI.revive(draft.projectUri)); - this._projectPicker.setSelectedProject(project, false); + const project = new SessionWorkspace(URI.revive(draft.projectUri)); + this._workspacePicker.setSelectedProject(project, false); } catch { /* ignore */ } } } @@ -1054,7 +1098,7 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { * Infers the session target from the selection kind, creates a new session, * and shows/hides pickers accordingly. */ - private async _onProjectSelected(project: SessionProject): Promise { + private async _onProjectSelected(project: SessionWorkspace): Promise { // Cancel any in-flight project selection this._projectSelectionCts.value?.cancel(); const cts = this._projectSelectionCts.value = new CancellationTokenSource(); @@ -1087,6 +1131,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } } + setProject(projectUri: URI): void { + const project = new SessionWorkspace(projectUri); + this._workspacePicker.setSelectedProject(project, true); + } + sendQuery(text: string): void { const model = this._editor?.getModel(); if (model) { @@ -1154,6 +1203,10 @@ export class NewChatViewPane extends ViewPane { this._widget?.sendQuery(text); } + setProject(projectUri: URI): void { + this._widget?.setProject(projectUri); + } + override setVisible(visible: boolean): void { super.setVisible(visible); if (visible) { diff --git a/src/vs/sessions/contrib/chat/browser/newSession.ts b/src/vs/sessions/contrib/chat/browser/newSession.ts index e4e33ef5dd2..d567b8ffbb8 100644 --- a/src/vs/sessions/contrib/chat/browser/newSession.ts +++ b/src/vs/sessions/contrib/chat/browser/newSession.ts @@ -6,17 +6,16 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { TargetMode } from './sessionTargetPicker.js'; -import { SessionProject } from '../../sessions/common/sessionProject.js'; -import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IsolationMode } from './sessionTargetPicker.js'; +import { SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; 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' | 'targetMode' | 'branch' | 'options' | 'disabled' | 'agent'; +export type NewSessionChangeType = 'repoUri' | 'isolationMode' | 'branch' | 'options' | 'disabled' | 'agent'; /** * Represents a resolved option group with its current selected value. @@ -28,14 +27,14 @@ export interface ISessionOptionGroup { /** * A new session represents a session being configured before the first - * request is sent. It holds the user's selections (repoUri, targetMode) + * request is sent. It holds the user's selections (repoUri, isolationMode) * and fires a single event when any property changes. */ export interface INewSession extends IDisposable { readonly resource: URI; - readonly target: AgentSessionProviders; - readonly project: SessionProject | undefined; - readonly targetMode: TargetMode; + readonly target: AgentSessionTarget; + readonly project: SessionWorkspace | undefined; + readonly isolationMode: IsolationMode | undefined; readonly branch: string | undefined; readonly modelId: string | undefined; readonly mode: IChatMode | undefined; @@ -44,8 +43,8 @@ export interface INewSession extends IDisposable { readonly selectedOptions: ReadonlyMap; readonly disabled: boolean; readonly onDidChange: Event; - setProject(project: SessionProject): void; - setTargetMode(mode: TargetMode): void; + setProject(project: SessionWorkspace): void; + setIsolationMode(mode: IsolationMode): void; setBranch(branch: string | undefined): void; setModelId(modelId: string | undefined): void; setMode(mode: IChatMode | undefined): void; @@ -61,14 +60,14 @@ const AGENT_OPTION_ID = 'agent'; /** * Local new session for Background agent sessions. - * Fires `onDidChange` for both `repoUri` and `targetMode` changes. + * Fires `onDidChange` for both `repoUri` and `isolationMode` changes. * Notifies the extension service with session options for each property change. */ -export class LocalNewSession extends Disposable implements INewSession { +export class CopilotCLISession extends Disposable implements INewSession { private _repoUri: URI | undefined; - private _project: SessionProject | undefined; - private _targetMode: TargetMode; + private _project: SessionWorkspace | undefined; + private _isolationMode: IsolationMode; private _branch: string | undefined; private _modelId: string | undefined; private _mode: IChatMode | undefined; @@ -81,8 +80,8 @@ export class LocalNewSession extends Disposable implements INewSession { readonly target = AgentSessionProviders.Background; readonly selectedOptions = new Map(); - get project(): SessionProject | undefined { return this._project; } - get targetMode(): TargetMode { return this._targetMode; } + get project(): SessionWorkspace | undefined { return this._project; } + 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; } @@ -92,7 +91,7 @@ export class LocalNewSession extends Disposable implements INewSession { if (!this._repoUri) { return true; } - if (this._targetMode === 'worktree' && !this._branch) { + if (this._isolationMode === 'worktree' && !this._branch) { return true; } return false; @@ -102,31 +101,30 @@ export class LocalNewSession extends Disposable implements INewSession { readonly resource: URI, defaultRepoUri: URI | undefined, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, - @ILogService private readonly logService: ILogService, ) { super(); if (defaultRepoUri) { this._repoUri = defaultRepoUri; this.setOption(REPOSITORY_OPTION_ID, defaultRepoUri.fsPath); } - this._targetMode = 'worktree'; + this._isolationMode = 'worktree'; this.setOption(ISOLATION_OPTION_ID, 'worktree'); } - setProject(project: SessionProject): void { + setProject(project: SessionWorkspace): void { this._project = project; this._repoUri = project.uri; - this.setTargetMode('worktree'); + this.setIsolationMode('worktree'); this._branch = undefined; this._onDidChange.fire('repoUri'); this._onDidChange.fire('disabled'); this.setOption(REPOSITORY_OPTION_ID, project.uri.fsPath); } - setTargetMode(mode: TargetMode): void { - if (this._targetMode !== mode) { - this._targetMode = mode; - this._onDidChange.fire('targetMode'); + setIsolationMode(mode: IsolationMode): void { + if (this._isolationMode !== mode) { + this._isolationMode = mode; + this._onDidChange.fire('isolationMode'); this._onDidChange.fire('disabled'); this.setOption(ISOLATION_OPTION_ID, mode); } @@ -168,10 +166,7 @@ export class LocalNewSession extends Disposable implements INewSession { } else { this.selectedOptions.set(optionId, value); } - this.chatSessionsService.notifySessionOptionsChange( - this.resource, - [{ optionId, value }] - ).catch((err) => this.logService.error(`Failed to notify session option ${optionId} change:`, err)); + this.chatSessionsService.setSessionOption(this.resource, optionId, value); } } @@ -183,7 +178,7 @@ export class LocalNewSession extends Disposable implements INewSession { export class RemoteNewSession extends Disposable implements INewSession { private _repoUri: URI | undefined; - private _project: SessionProject | undefined; + private _project: SessionWorkspace | undefined; private _modelId: string | undefined; private _query: string | undefined; private _attachedContext: IChatRequestVariableEntry[] | undefined; @@ -196,8 +191,8 @@ export class RemoteNewSession extends Disposable implements INewSession { readonly selectedOptions = new Map(); - get project(): SessionProject | undefined { return this._project; } - get targetMode(): TargetMode { return 'cloud'; } + get project(): SessionWorkspace | undefined { return this._project; } + get isolationMode(): undefined { return undefined; } get branch(): string | undefined { return undefined; } get modelId(): string | undefined { return this._modelId; } get mode(): IChatMode | undefined { return undefined; } @@ -211,10 +206,9 @@ export class RemoteNewSession extends Disposable implements INewSession { constructor( readonly resource: URI, - readonly target: AgentSessionProviders, + readonly target: AgentSessionTarget, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, - @ILogService private readonly logService: ILogService, ) { super(); @@ -232,7 +226,7 @@ export class RemoteNewSession extends Disposable implements INewSession { })); } - setProject(project: SessionProject): void { + setProject(project: SessionWorkspace): void { this._project = project; this._repoUri = project.uri; this._onDidChange.fire('repoUri'); @@ -241,7 +235,7 @@ export class RemoteNewSession extends Disposable implements INewSession { this.setOption('repositories', { id, name: id }); } - setTargetMode(_mode: TargetMode): void { + setIsolationMode(_mode: IsolationMode): void { // No-op for remote sessions } @@ -272,10 +266,7 @@ export class RemoteNewSession extends Disposable implements INewSession { } this._onDidChange.fire('options'); this._onDidChange.fire('disabled'); - this.chatSessionsService.notifySessionOptionsChange( - this.resource, - [{ optionId, value }] - ).catch((err) => this.logService.error(`Failed to notify extension of ${optionId} change:`, err)); + this.chatSessionsService.setSessionOption(this.resource, optionId, value); } // --- Option group accessors --- @@ -374,3 +365,78 @@ function isModelOptionGroup(group: IChatSessionProviderOptionGroup): boolean { function isRepositoriesOptionGroup(group: IChatSessionProviderOptionGroup): boolean { return group.id === 'repositories'; } + +/** + * New session for agent host sessions (local or remote agent host processes). + * Agent host sessions use local model and mode pickers but don't need + * isolation mode, branch selection, or cloud option groups. + */ +export class AgentHostNewSession extends Disposable implements INewSession { + + private _project: SessionWorkspace | undefined; + private _modelId: string | undefined; + private _mode: IChatMode | undefined; + private _query: string | undefined; + private _attachedContext: IChatRequestVariableEntry[] | undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + readonly selectedOptions = new Map(); + + get project(): SessionWorkspace | undefined { return this._project; } + get isolationMode(): undefined { return undefined; } + get branch(): undefined { return undefined; } + 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 { return false; } + + constructor( + readonly resource: URI, + readonly target: AgentSessionTarget, + ) { + super(); + } + + setProject(project: SessionWorkspace): void { + this._project = project; + this._onDidChange.fire('repoUri'); + } + + setIsolationMode(_mode: IsolationMode): void { + // No-op for agent host sessions + } + + setBranch(_branch: string | undefined): void { + // No-op for agent host sessions + } + + setModelId(modelId: string | undefined): void { + this._modelId = modelId; + } + + setMode(mode: IChatMode | undefined): void { + if (this._mode?.id !== mode?.id) { + this._mode = mode; + this._onDidChange.fire('agent'); + } + } + + setQuery(query: string): void { + this._query = query; + } + + setAttachedContext(context: IChatRequestVariableEntry[] | undefined): void { + this._attachedContext = context; + } + + setOption(optionId: string, value: IChatSessionProviderOptionItem | string): void { + if (typeof value === 'string') { + this.selectedOptions.set(optionId, { id: value, name: value }); + } else { + this.selectedOptions.set(optionId, value); + } + } +} diff --git a/src/vs/sessions/contrib/chat/browser/promptsService.ts b/src/vs/sessions/contrib/chat/browser/promptsService.ts index 8cc51fa370e..22c9e661fb6 100644 --- a/src/vs/sessions/contrib/chat/browser/promptsService.ts +++ b/src/vs/sessions/contrib/chat/browser/promptsService.ts @@ -155,6 +155,8 @@ export class AgenticPromptsService extends PromptsService { uri: s.uri, storage: BUILTIN_STORAGE, type: PromptsType.skill, + name: s.name, + description: s.description, })); } @@ -175,7 +177,8 @@ export class AgenticPromptsService extends PromptsService { // Collect names already present from other sources const existingNames = new Set(baseResult.map(s => s.name)); - const nonOverridden = builtinSkills.filter(s => !existingNames.has(s.name)); + const disabledSkills = this.getDisabledPromptFiles(PromptsType.skill); + const nonOverridden = builtinSkills.filter(s => !existingNames.has(s.name) && !disabledSkills.has(s.uri)); if (nonOverridden.length === 0) { return baseResult; } diff --git a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts index 6a3e15911b2..74cf99e76a4 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptAction.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptAction.ts @@ -219,7 +219,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr constructor() { super({ id: CONFIGURE_DEFAULT_RUN_ACTION_ID, - title: localize2('configureDefaultRunAction', "Add Action..."), + title: localize2('configureDefaultRunAction', "Add Task..."), category: SessionsCategories.Sessions, icon: Codicon.add, precondition: configureScriptPrecondition, @@ -243,7 +243,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr constructor() { super({ id: GENERATE_RUN_ACTION_ID, - title: localize2('generateRunAction', "Generate New Action..."), + title: localize2('generateRunAction', "Generate New Task..."), category: SessionsCategories.Sessions, precondition: IsActiveSessionBackgroundProviderContext, menu: [{ @@ -283,7 +283,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr items.push({ type: 'separator', label: localize('custom', "Custom") }); items.push({ - label: localize('enterCustomCommand', "Enter Custom Command..."), + label: localize('createNewTask', "Create new Task..."), description: localize('enterCustomCommandDesc', "Create a new shell task"), }); @@ -300,7 +300,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr } const picked = await this._quickInputService.pick(items, { - placeHolder: localize('pickRunAction', "Select a task or enter a custom command"), + placeHolder: localize('pickRunAction', "Select or create a task"), }); if (!picked) { @@ -382,12 +382,12 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr const quickWidget = disposables.add(this._quickInputService.createQuickWidget()); quickWidget.title = isConfigureMode - ? localize('configureActionWidgetTitle', "Configure Action...") + ? localize('configureActionWidgetTitle', "Configure Task...") : existingTask - ? localize('addExistingActionWidgetTitle', "Add Existing Action...") - : localize('addActionWidgetTitle', "Add Action..."); + ? localize('addExistingActionWidgetTitle', "Add Existing Task...") + : localize('addActionWidgetTitle', "Add Task..."); quickWidget.description = isConfigureMode - ? localize('configureActionWidgetDescription', "Update how this action is named, saved, and run") + ? localize('configureActionWidgetDescription', "Update how this task is named, saved, and run") : existingTask ? localize('addExistingActionWidgetDescription', "Enable an existing task for sessions and configure when it should run") : localize('addActionWidgetDescription', "Create a shell task and configure how it should be saved and run"); @@ -400,7 +400,7 @@ export class RunScriptContribution extends Disposable implements IWorkbenchContr target: existingTask?.target, targetDisabledReason: existingTask && !isConfigureMode ? localize('existingTaskTargetLocked', "This existing task cannot be moved between workspace and user storage.") : workspaceTargetDisabledReason, runOn: existingTask?.task.runOptions?.runOn === 'worktreeCreated' ? 'worktreeCreated' : undefined, - submitLabel: isConfigureMode ? localize('confirmConfigureAction', "Save Changes") : undefined, + mode: isConfigureMode ? 'configure' : existingTask ? 'add-existing' : 'add', })); quickWidget.widget = widget.domNode; @@ -479,7 +479,7 @@ class RunScriptActionViewItem extends BaseActionViewItem { })); // Dropdown with categorized actions and per-item toolbars - const dropdownAction = this._register(new Action('agentSessions.runScriptDropdown', localize('runDropdown', "More Run Actions..."))); + const dropdownAction = this._register(new Action('agentSessions.runScriptDropdown', localize('runDropdown', "More Tasks..."))); this._dropdown = this._register(new ChevronActionWidgetDropdown( dropdownAction, { @@ -633,16 +633,16 @@ class RunScriptActionViewItem extends BaseActionViewItem { }); } - // "Add Action..." action + // "Add Task..." action const canConfigure = !!(session.worktree ?? session.repository); actions.push({ id: 'runScript.addAction', - label: localize('configureDefaultRunAction', "Add Action..."), + label: localize('configureDefaultRunAction', "Add Task..."), tooltip: '', hover: { content: canConfigure - ? localize('addActionTooltip', "Add a new action") - : localize('addActionTooltipDisabled', "Cannot add actions to this session because workspace storage is unavailable"), + ? localize('addActionTooltip', "Add a new task") + : localize('addActionTooltipDisabled', "Cannot add tasks to this session because workspace storage is unavailable"), position: { hoverPosition: HoverPosition.LEFT } }, icon: Codicon.add, @@ -657,13 +657,13 @@ class RunScriptActionViewItem extends BaseActionViewItem { }, }); - // "Generate New Action..." action + // "Generate New Task..." action actions.push({ id: 'runScript.generateAction', - label: localize('generateRunAction', "Generate New Action..."), + label: localize('generateRunAction', "Generate New Task..."), tooltip: '', hover: { - content: localize('generateRunActionTooltip', "Generate a new workspace action"), + content: localize('generateRunActionTooltip', "Generate a new workspace task"), position: { hoverPosition: HoverPosition.LEFT }, }, icon: Codicon.sparkle, @@ -713,7 +713,7 @@ class RunScriptNotAvailableAction extends Action2 { super({ id: 'workbench.action.agentSessions.runScript.notAvailable', title: localize2('run', "Run"), - tooltip: localize('runScriptNotAvailableTooltip', "Run Script is not available for this session type"), + tooltip: localize('runScriptNotAvailableTooltip', "Run Task is not available for this session type"), icon: Codicon.play, precondition: ContextKeyExpr.false(), menu: [{ diff --git a/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts b/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts index d9cec03dc1e..5d40dfdef39 100644 --- a/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/runScriptCustomTaskWidget.ts @@ -28,7 +28,7 @@ export interface IRunScriptCustomTaskWidgetState { readonly target?: TaskStorageTarget; readonly targetDisabledReason?: string; readonly runOn?: typeof WORKTREE_CREATED_RUN_ON; - readonly submitLabel?: string; + readonly mode?: 'add' | 'add-existing' | 'configure'; } export interface IRunScriptCustomTaskWidgetResult { @@ -51,6 +51,12 @@ export class RunScriptCustomTaskWidget extends Disposable { private readonly _labelLocked: boolean; private readonly _commandLocked: boolean; private readonly _targetLocked: boolean; + private readonly _isExistingTask: boolean; + private readonly _isAddExistingTask: boolean; + private readonly _initialLabel: string; + private readonly _initialCommand: string; + private readonly _initialRunOn: boolean; + private readonly _initialTarget: TaskStorageTarget; private _selectedTarget: TaskStorageTarget; private readonly _onDidSubmit = this._register(new Emitter()); @@ -65,7 +71,13 @@ export class RunScriptCustomTaskWidget extends Disposable { this._labelLocked = !!state.labelDisabledReason; this._commandLocked = !!state.commandDisabledReason; this._targetLocked = !!state.targetDisabledReason && state.target !== undefined; + this._isExistingTask = state.mode === 'configure'; + this._isAddExistingTask = state.mode === 'add-existing'; this._selectedTarget = state.target ?? (state.targetDisabledReason ? 'user' : 'workspace'); + this._initialLabel = state.label ?? ''; + this._initialCommand = state.command ?? ''; + this._initialRunOn = state.runOn === WORKTREE_CREATED_RUN_ON; + this._initialTarget = this._selectedTarget; this.domNode = dom.$('.run-script-action-widget'); @@ -73,7 +85,7 @@ export class RunScriptCustomTaskWidget extends Disposable { dom.append(labelSection, dom.$('label.run-script-action-label', undefined, localize('labelFieldLabel', "Name"))); const labelInputContainer = dom.append(labelSection, dom.$('.run-script-action-input')); this._labelInput = this._register(new InputBox(labelInputContainer, undefined, { - placeholder: localize('enterLabelPlaceholder', "Enter a name for this action (optional)"), + placeholder: localize('enterLabelPlaceholder', "Enter a name for this task (optional)"), tooltip: state.labelDisabledReason, ariaLabel: localize('enterLabelAriaLabel', "Task name"), inputBoxStyles: defaultInputBoxStyles, @@ -102,7 +114,7 @@ export class RunScriptCustomTaskWidget extends Disposable { const runOnRow = dom.append(runOnSection, dom.$('.run-script-action-option-row')); this._runOnCheckbox = this._register(new Checkbox(localize('runOnWorktreeCreated', "Run When Worktree Is Created"), state.runOn === WORKTREE_CREATED_RUN_ON, defaultCheckboxStyles)); runOnRow.appendChild(this._runOnCheckbox.domNode); - const runOnText = dom.append(runOnRow, dom.$('span.run-script-action-option-text', undefined, localize('runOnWorktreeCreatedDescription', "Automatically run this action when the session worktree is created"))); + const runOnText = dom.append(runOnRow, dom.$('span.run-script-action-option-text', undefined, localize('runOnWorktreeCreatedDescription', "Automatically run this task when the session worktree is created"))); this._register(dom.addDisposableListener(runOnText, dom.EventType.CLICK, () => this._runOnCheckbox.checked = !this._runOnCheckbox.checked)); const storageSection = dom.append(this.domNode, dom.$('.run-script-action-section')); @@ -113,13 +125,13 @@ export class RunScriptCustomTaskWidget extends Disposable { items: [ { text: localize('workspaceStorageLabel', "Workspace"), - tooltip: storageDisabledReason ?? localize('workspaceStorageTooltip', "Save this action in the current workspace"), + tooltip: storageDisabledReason ?? localize('workspaceStorageTooltip', "Save this task in the current workspace"), isActive: this._selectedTarget === 'workspace', disabled: workspaceTargetDisabled, }, { text: localize('userStorageLabel', "User"), - tooltip: this._targetLocked ? storageDisabledReason : localize('userStorageTooltip', "Save this action in your user tasks and make it available in all sessions"), + tooltip: this._targetLocked ? storageDisabledReason : localize('userStorageTooltip', "Save this task in your user tasks and make it available in all sessions"), isActive: this._selectedTarget === 'user', disabled: this._targetLocked, } @@ -135,13 +147,15 @@ export class RunScriptCustomTaskWidget extends Disposable { this._cancelButton = this._register(new Button(buttonRow, { ...defaultButtonStyles, secondary: true })); this._cancelButton.label = localize('cancelAddAction', "Cancel"); this._submitButton = this._register(new Button(buttonRow, defaultButtonStyles)); - this._submitButton.label = state.submitLabel ?? localize('confirmAddAction', "Add Action"); + this._submitButton.label = this._getSubmitLabel(); - this._register(this._labelInput.onDidChange(() => this._updateButtonEnablement())); - this._register(this._commandInput.onDidChange(() => this._updateButtonEnablement())); + this._register(this._labelInput.onDidChange(() => this._updateButtonState())); + this._register(this._commandInput.onDidChange(() => this._updateButtonState())); this._register(this._storageOptions.onDidSelect(index => { this._selectedTarget = index === 0 ? 'workspace' : 'user'; + this._updateButtonState(); })); + this._register(this._runOnCheckbox.onChange(() => this._updateButtonState())); this._register(this._submitButton.onDidClick(() => this._submit())); this._register(this._cancelButton.onDidClick(() => this._onDidCancel.fire())); this._register(dom.addDisposableListener(this._labelInput.inputElement, dom.EventType.KEY_DOWN, event => { @@ -169,7 +183,7 @@ export class RunScriptCustomTaskWidget extends Disposable { } })); - this._updateButtonEnablement(); + this._updateButtonState(); } focus(): void { @@ -199,7 +213,31 @@ export class RunScriptCustomTaskWidget extends Disposable { }); } - private _updateButtonEnablement(): void { + private _updateButtonState(): void { this._submitButton.enabled = this._commandInput.value.trim().length > 0; + this._submitButton.label = this._getSubmitLabel(); + } + + private _getSubmitLabel(): string { + if (this._isAddExistingTask) { + return localize('confirmAddToSessions', "Add to Sessions Window"); + } + if (!this._isExistingTask) { + return localize('confirmAddTask', "Add Task"); + } + + const targetChanged = this._selectedTarget !== this._initialTarget; + const labelChanged = this._labelInput.value !== this._initialLabel; + const commandChanged = this._commandInput.value !== this._initialCommand; + const runOnChanged = this._runOnCheckbox.checked !== this._initialRunOn; + const otherChanged = labelChanged || commandChanged || runOnChanged; + + if (targetChanged && otherChanged) { + return localize('confirmMoveAndUpdateTask', "Move and Update Task"); + } + if (targetChanged) { + return localize('confirmMoveTask', "Move Task"); + } + return localize('confirmUpdateTask', "Update Task"); } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts index 373444fb378..c0dccecf85d 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTargetPicker.ts @@ -12,49 +12,173 @@ 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 { SessionProject } from '../../sessions/common/sessionProject.js'; +import { SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; + +// #region --- Types --- + +export type SessionTargetType = 'copilot-cli' | 'cloud'; +export type IsolationMode = 'worktree' | 'workspace'; + +// #endregion // #region --- Target Picker --- -export type TargetMode = 'worktree' | 'workspace' | 'cloud'; - /** - * A self-contained widget for selecting the session target mode. + * A self-contained widget for selecting the session target type. * * Options: - * - **Worktree** (`worktree`) — shown when a folder with a git repo is selected - * - **Folder** (`workspace`) — shown only when isolation option is enabled - * - **Cloud** (`cloud`) — shown and auto-selected when a repository is picked; disabled + * - **Copilot CLI** (`cli`) — local agent session + * - **Cloud** (`cloud`) — remote/cloud agent session * - * Emits `onDidChange` with the selected `TargetMode` when the user picks an option. + * The target is determined by the project type (folder → CLI, repo → Cloud). + * Emits `onDidChange` with the selected `SessionTargetType` when the target changes. */ -export class TargetPicker extends Disposable { +export class SessionTypePicker extends Disposable { - private _targetMode: TargetMode = 'worktree'; - private _project: SessionProject | undefined; - private _isolationOptionEnabled: boolean = true; + private _sessionTarget: SessionTargetType = 'copilot-cli'; + private _project: SessionWorkspace | undefined; - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; private readonly _renderDisposables = this._register(new DisposableStore()); private _slotElement: HTMLElement | undefined; private _triggerElement: HTMLElement | undefined; - get targetMode(): TargetMode { - return this._targetMode; + get sessionTarget(): SessionTargetType { + return this._sessionTarget; } - get isWorktree(): boolean { - return this._targetMode === 'worktree'; - } - - get isFolder(): boolean { - return this._targetMode === 'workspace'; + get isCli(): boolean { + return this._sessionTarget === 'copilot-cli'; } get isCloud(): boolean { - return this._targetMode === 'cloud'; + return this._sessionTarget === 'cloud'; + } + + constructor( + ) { + super(); + } + + /** + * Sets the current project context. Determines the target type: + * - Repo project → cloud + * - Folder project → cli + * - No project → retains current target + */ + setProject(project: SessionWorkspace | undefined): void { + this._project = project; + this._updateTarget(); + this._updateTriggerLabel(); + } + + private _updateTarget(): void { + if (this._project?.isRepo) { + this._setTarget('cloud'); + return; + } + + if (this._project?.isFolder) { + this._setTarget('copilot-cli'); + return; + } + } + + render(container: HTMLElement): void { + 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 = -1; + trigger.role = 'button'; + trigger.setAttribute('aria-disabled', 'true'); + this._triggerElement = trigger; + this._updateTriggerLabel(); + } + + private _setTarget(target: SessionTargetType): void { + if (this._sessionTarget !== target) { + this._sessionTarget = target; + this._updateTriggerLabel(); + this._onDidChange.fire(target); + } + } + + private _updateTriggerLabel(): void { + if (!this._triggerElement) { + return; + } + + dom.clearNode(this._triggerElement); + + let modeIcon; + let modeLabel: string; + + switch (this._sessionTarget) { + case 'cloud': + modeIcon = Codicon.cloud; + modeLabel = localize('sessionTarget.cloud', "Cloud"); + break; + case 'copilot-cli': + default: + modeIcon = Codicon.worktree; + modeLabel = localize('sessionTarget.cli', "Copilot CLI"); + break; + } + + dom.append(this._triggerElement, renderIcon(modeIcon)); + const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + labelSpan.textContent = modeLabel; + + this._slotElement?.classList.toggle('disabled', true); + } +} + +// #endregion + +// #region --- Isolation Picker --- + +/** + * A self-contained widget for selecting the isolation mode. + * + * Options: + * - **Worktree** (`worktree`) — run in a git worktree + * - **Folder** (`workspace`) — run directly in the folder + * + * Only visible when isolation option is enabled, project has a git repo, + * and the target is CLI. + * + * Emits `onDidChange` with the selected `IsolationMode` when the user picks an option. + */ +export class IsolationPicker extends Disposable { + + private _isolationMode: IsolationMode = 'worktree'; + private _hasGitRepo = false; + private _visible = true; + private _isolationOptionEnabled: boolean; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private readonly _renderDisposables = this._register(new DisposableStore()); + private _slotElement: HTMLElement | undefined; + private _triggerElement: HTMLElement | undefined; + + get isolationMode(): IsolationMode { + return this._isolationMode; + } + + get isWorktree(): boolean { + return this._isolationMode === 'worktree'; + } + + get isFolder(): boolean { + return this._isolationMode === 'workspace'; } constructor( @@ -67,35 +191,37 @@ export class TargetPicker extends Disposable { this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('github.copilot.chat.cli.isolationOption.enabled')) { this._isolationOptionEnabled = this.configurationService.getValue('github.copilot.chat.cli.isolationOption.enabled') !== false; - this._updateMode(); + if (!this._isolationOptionEnabled) { + // Reset to worktree when isolation option is disabled + this._setMode('worktree'); + } + this._updateVisibility(); this._updateTriggerLabel(); } })); } /** - * Sets the current project context. Determines the available target modes: - * - Repo project → cloud mode (disabled picker) - * - Folder with git repo → retains current local mode (worktree/folder) - * - Folder without git repo → folder mode only - * - No project → retains current mode + * Sets whether the project has a git repository. + * Resets isolation mode to the appropriate default. */ - setProject(project: SessionProject | undefined): void { - this._project = project; - this._updateMode(); + setHasGitRepo(hasRepo: boolean): void { + this._hasGitRepo = hasRepo; + if (!hasRepo) { + this._setMode('workspace'); + } else { + this._setMode('worktree'); + } + this._updateVisibility(); this._updateTriggerLabel(); } - private _updateMode(): void { - if (this._project?.isRepo) { - this._setMode('cloud'); - return; - } - - if (this._project?.isFolder) { - this._setMode(this._project.repository ? 'worktree' : 'workspace'); - return; - } + /** + * Sets external visibility (e.g. hidden when target is Cloud). + */ + setVisible(visible: boolean): void { + this._visible = visible; + this._updateVisibility(); } render(container: HTMLElement): void { @@ -110,6 +236,7 @@ export class TargetPicker extends Disposable { trigger.role = 'button'; this._triggerElement = trigger; this._updateTriggerLabel(); + this._updateVisibility(); this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.CLICK, (e) => { dom.EventHelper.stop(e, true); @@ -125,35 +252,31 @@ export class TargetPicker extends Disposable { } private _showPicker(): void { - if (!this._triggerElement || this.actionWidgetService.isVisible || this._targetMode === 'cloud') { + if (!this._triggerElement || this.actionWidgetService.isVisible) { return; } - // No picker when there's no git repo — only Folder mode is available - if (!this._project?.repository) { + if (!this._hasGitRepo || !this._isolationOptionEnabled) { return; } - const items: IActionListItem[] = [ + const items: IActionListItem[] = [ { kind: ActionListItemKind.Action, - label: localize('targetMode.worktree', "Worktree"), + label: localize('isolationMode.worktree', "Worktree"), group: { title: '', icon: Codicon.worktree }, item: 'worktree', }, - ]; - - if (this._isolationOptionEnabled) { - items.push({ + { kind: ActionListItemKind.Action, - label: localize('targetMode.folder', "Folder"), + label: localize('isolationMode.folder', "Folder"), group: { title: '', icon: Codicon.folder }, item: 'workspace', - }); - } + }, + ]; const triggerElement = this._triggerElement; - const delegate: IActionListDelegate = { + const delegate: IActionListDelegate = { onSelect: (mode) => { this.actionWidgetService.hide(); this._setMode(mode); @@ -161,8 +284,8 @@ export class TargetPicker extends Disposable { onHide: () => { triggerElement.focus(); }, }; - this.actionWidgetService.show( - 'targetPicker', + this.actionWidgetService.show( + 'isolationPicker', false, items, delegate, @@ -171,19 +294,27 @@ export class TargetPicker extends Disposable { [], { getAriaLabel: (item) => item.label ?? '', - getWidgetAriaLabel: () => localize('targetPicker.ariaLabel', "Target"), + getWidgetAriaLabel: () => localize('isolationPicker.ariaLabel', "Isolation Mode"), }, ); } - private _setMode(mode: TargetMode): void { - if (this._targetMode !== mode) { - this._targetMode = mode; + private _setMode(mode: IsolationMode): void { + if (this._isolationMode !== mode) { + this._isolationMode = mode; this._updateTriggerLabel(); this._onDidChange.fire(mode); } } + private _updateVisibility(): void { + if (!this._slotElement) { + return; + } + const shouldShow = this._visible && this._hasGitRepo && this._isolationOptionEnabled; + this._slotElement.style.display = shouldShow ? '' : 'none'; + } + private _updateTriggerLabel(): void { if (!this._triggerElement) { return; @@ -193,25 +324,16 @@ export class TargetPicker extends Disposable { let modeIcon; let modeLabel: string; - let isDisabled: boolean = true; - if (this._project?.isFolder && this._project.repository) { - isDisabled = !this._isolationOptionEnabled; - } - - switch (this._targetMode) { - case 'cloud': - modeIcon = Codicon.cloud; - modeLabel = localize('targetMode.cloud', "Cloud"); - break; + switch (this._isolationMode) { case 'workspace': modeIcon = Codicon.folder; - modeLabel = localize('targetMode.folder', "Folder"); + modeLabel = localize('isolationMode.folder', "Folder"); break; case 'worktree': default: modeIcon = Codicon.worktree; - modeLabel = localize('targetMode.worktree', "Worktree"); + modeLabel = localize('isolationMode.worktree', "Worktree"); break; } @@ -219,12 +341,6 @@ export class TargetPicker extends Disposable { const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); labelSpan.textContent = modeLabel; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); - - this._slotElement?.classList.toggle('disabled', isDisabled); - if (this._triggerElement) { - this._triggerElement.tabIndex = isDisabled ? -1 : 0; - this._triggerElement.setAttribute('aria-disabled', String(isDisabled)); - } } } diff --git a/src/vs/sessions/contrib/chat/browser/projectPicker.ts b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts similarity index 62% rename from src/vs/sessions/contrib/chat/browser/projectPicker.ts rename to src/vs/sessions/contrib/chat/browser/workspacePicker.ts index 068df6f6c62..83d70b4c131 100644 --- a/src/vs/sessions/contrib/chat/browser/projectPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/workspacePicker.ts @@ -17,7 +17,11 @@ import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs. import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { GITHUB_REMOTE_FILE_SCHEME, SessionProject } from '../../sessions/common/sessionProject.js'; +import { GITHUB_REMOTE_FILE_SCHEME, SessionWorkspace } from '../../sessions/common/sessionWorkspace.js'; +import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { agentHostAuthority } from '../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; +import { AGENT_HOST_FS_SCHEME, agentHostUri } from '../../remoteAgentHost/browser/agentHostFileSystemProvider.js'; const OPEN_REPO_COMMAND = 'github.copilot.chat.cloudSessions.openRepository'; const STORAGE_KEY_LAST_PROJECT = 'sessions.lastPickedProject'; @@ -33,6 +37,7 @@ const LEGACY_STORAGE_KEY_RECENT_REPOS = 'agentSessions.recentlyPickedRepos'; const COMMAND_BROWSE_FOLDERS = 'command:browseFolders'; const COMMAND_BROWSE_REPOS = 'command:browseRepos'; +const COMMAND_BROWSE_REMOTE_AGENT_HOSTS = 'command:browseRemoteAgentHosts'; /** * Serializable form of a project entry for storage. @@ -40,6 +45,8 @@ const COMMAND_BROWSE_REPOS = 'command:browseRepos'; interface IStoredProject { readonly uri: UriComponents; readonly checked?: boolean; + /** Cached display name for remote agent host connections. */ + readonly remoteName?: string; } /** @@ -51,18 +58,18 @@ interface IStoredProject { * - "Browse Folders..." — opens a folder dialog * - "Browse Repositories..." — runs the cloud repository picker command */ -export class ProjectPicker extends Disposable { +export class WorkspacePicker extends Disposable { - private readonly _onDidSelectProject = this._register(new Emitter()); - readonly onDidSelectProject: Event = this._onDidSelectProject.event; + private readonly _onDidSelectProject = this._register(new Emitter()); + readonly onDidSelectProject: Event = this._onDidSelectProject.event; - private _selectedProject: SessionProject | undefined; + private _selectedProject: SessionWorkspace | undefined; private _recentProjects: IStoredProject[] = []; private _triggerElement: HTMLElement | undefined; private readonly _renderDisposables = this._register(new DisposableStore()); - get selectedProject(): SessionProject | undefined { + get selectedProject(): SessionWorkspace | undefined { return this._selectedProject; } @@ -72,6 +79,8 @@ export class ProjectPicker extends Disposable { @IFileDialogService private readonly fileDialogService: IFileDialogService, @ICommandService private readonly commandService: ICommandService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, + @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, + @IQuickInputService private readonly quickInputService: IQuickInputService, ) { super(); @@ -134,7 +143,7 @@ export class ProjectPicker extends Disposable { try { const lastFolder = this.storageService.get(LEGACY_STORAGE_KEY_LAST_FOLDER, StorageScope.PROFILE); if (lastFolder) { - this._selectedProject = new SessionProject(URI.parse(lastFolder)); + this._selectedProject = new SessionWorkspace(URI.parse(lastFolder)); return; } } catch { /* ignore */ } @@ -143,7 +152,7 @@ export class ProjectPicker extends Disposable { const lastRepo = this.storageService.get(LEGACY_STORAGE_KEY_LAST_REPO, StorageScope.PROFILE); if (lastRepo) { const repo: { id: string; name: string } = JSON.parse(lastRepo); - this._selectedProject = new SessionProject(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${repo.id}/HEAD` })); + this._selectedProject = new SessionWorkspace(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${repo.id}/HEAD` })); } } catch { /* ignore */ } } @@ -155,7 +164,7 @@ export class ProjectPicker extends Disposable { render(container: HTMLElement): HTMLElement { this._renderDisposables.clear(); - const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-project-picker')); + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-workspace-picker')); this._renderDisposables.add({ dispose: () => slot.remove() }); const trigger = dom.append(slot, dom.$('a.action-label')); @@ -200,6 +209,8 @@ export class ProjectPicker extends Disposable { this._browseForFolder(); } else if (uriStr === COMMAND_BROWSE_REPOS) { this._browseForRepo(); + } else if (uriStr === COMMAND_BROWSE_REMOTE_AGENT_HOSTS) { + this._browseForRemoteAgentHost(); } else { this._selectProject(this._fromStored(item)); } @@ -207,10 +218,10 @@ export class ProjectPicker extends Disposable { onHide: () => { triggerElement.focus(); }, }; - const listOptions = showFilter ? { showFilter: true, filterPlaceholder: localize('projectPicker.filter', "Filter projects...") } : undefined; + const listOptions = showFilter ? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces...") } : undefined; this.actionWidgetService.show( - 'projectPicker', + 'workspacePicker', false, items, delegate, @@ -219,7 +230,7 @@ export class ProjectPicker extends Disposable { [], { getAriaLabel: (item) => item.label ?? '', - getWidgetAriaLabel: () => localize('projectPicker.ariaLabel', "Project Picker"), + getWidgetAriaLabel: () => localize('workspacePicker.ariaLabel', "Workspace Picker"), }, listOptions, ); @@ -229,7 +240,7 @@ export class ProjectPicker extends Disposable { * Programmatically set the selected project. * @param fireEvent Whether to fire the onDidSelectProject event. Defaults to true. */ - setSelectedProject(project: SessionProject, fireEvent = true): void { + setSelectedProject(project: SessionWorkspace, fireEvent = true): void { this._selectProject(project, fireEvent); } @@ -254,9 +265,9 @@ export class ProjectPicker extends Disposable { } } - private _selectProject(project: SessionProject, fireEvent = true): void { + private _selectProject(project: SessionWorkspace, fireEvent = true): void { this._selectedProject = project; - const stored = this._toStored(project); + const stored = this._withCachedRemoteName(this._toStored(project)); this._addToRecents(stored); this.storageService.store(STORAGE_KEY_LAST_PROJECT, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE); this._updateTriggerLabel(); @@ -274,7 +285,7 @@ export class ProjectPicker extends Disposable { title: localize('selectFolder', "Select Folder"), }); if (selected?.[0]) { - this._selectProject(new SessionProject(selected[0])); + this._selectProject(new SessionWorkspace(selected[0])); } } catch { // dialog was cancelled or failed @@ -285,14 +296,72 @@ export class ProjectPicker extends Disposable { try { const result: string | undefined = await this.commandService.executeCommand(OPEN_REPO_COMMAND); if (result) { - this._selectProject(new SessionProject(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${result}/HEAD` }))); + this._selectProject(new SessionWorkspace(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: `/${result}/HEAD` }))); } } catch { // command was cancelled or failed } } + private async _browseForRemoteAgentHost(): Promise { + const connections = this.remoteAgentHostService.connections; + if (connections.length === 0) { + return; + } + + // Show remote picker even with a single connection so the user + // can see which remote they are connecting to. + let selectedAddress: string; + let selectedName: string; + let defaultDirectory: string | undefined; + { + const picks = connections.map(c => ({ + label: c.name, + description: c.address, + address: c.address, + defaultDirectory: c.defaultDirectory, + })); + + const picked = await this.quickInputService.pick(picks, { + title: localize('selectRemote', "Select Remote"), + placeHolder: localize('selectRemotePlaceholder', "Choose a remote agent host"), + }); + if (!picked) { + return; + } + selectedAddress = picked.address; + selectedName = picked.label; + defaultDirectory = picked.defaultDirectory; + } + + // Open a folder picker scoped to the remote filesystem. + // The defaultUri carries both the scheme (agenthost) and authority + // (sanitized address), so SimpleFileDialog stays scoped to this + // particular remote connection. + const authority = agentHostAuthority(selectedAddress); + const defaultUri = defaultDirectory + ? agentHostUri(authority, defaultDirectory) + : agentHostUri(authority, '/'); + + try { + const selected = await this.fileDialogService.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: localize('selectRemoteFolder', "Select Folder on {0}", selectedName), + availableFileSystems: [AGENT_HOST_FS_SCHEME], + defaultUri, + }); + if (selected?.[0]) { + this._selectProject(new SessionWorkspace(selected[0])); + } + } catch { + // dialog was cancelled or failed + } + } + private _addToRecents(stored: IStoredProject): void { + stored = this._withCachedRemoteName(stored); this._recentProjects = [ stored, ...this._recentProjects.filter(p => !this._isSameProject(p, stored)), @@ -305,51 +374,65 @@ export class ProjectPicker extends Disposable { } private _buildItems(): IActionListItem[] { - const seen = new Set(); const items: IActionListItem[] = []; // Collect all projects (current + recents), deduped const allProjects: IStoredProject[] = []; if (this._selectedProject) { - const stored = this._toStored(this._selectedProject); - seen.add(this._projectKey(stored)); + const stored = this._withCachedRemoteName(this._toStored(this._selectedProject)); allProjects.push(stored); } for (const project of this._recentProjects) { - const key = this._projectKey(project); - if (!seen.has(key)) { - seen.add(key); + if (!allProjects.some(p => this._isSameProject(p, project))) { allProjects.push(project); } } - // Split into folders and repos, sort each group alphabetically - const isStoredFolder = (p: IStoredProject) => URI.revive(p.uri).scheme !== GITHUB_REMOTE_FILE_SCHEME; + // Split into folders, repos, and remotes, sort each group alphabetically + const isStoredFolder = (p: IStoredProject) => { + const scheme = URI.revive(p.uri).scheme; + return scheme !== GITHUB_REMOTE_FILE_SCHEME && scheme !== AGENT_HOST_FS_SCHEME; + }; + const isStoredRemote = (p: IStoredProject) => URI.revive(p.uri).scheme === AGENT_HOST_FS_SCHEME; const folders = allProjects.filter(p => isStoredFolder(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); - const repos = allProjects.filter(p => !isStoredFolder(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); + const repos = allProjects.filter(p => !isStoredFolder(p) && !isStoredRemote(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); + const remotes = allProjects.filter(p => isStoredRemote(p)).sort((a, b) => this._getStoredProjectLabel(a).localeCompare(this._getStoredProjectLabel(b))); - const selectedKey = this._selectedProject ? this._projectKey(this._toStored(this._selectedProject)) : undefined; + const selectedStored = this._selectedProject ? this._toStored(this._selectedProject) : undefined; + const isSelected = (p: IStoredProject) => !!selectedStored && this._isSameProject(p, selectedStored); // Folders first for (const project of folders) { - const isSelected = selectedKey !== undefined && this._projectKey(project) === selectedKey; + const selected = isSelected(project); items.push({ kind: ActionListItemKind.Action, label: this._getStoredProjectLabel(project), group: { title: '', icon: Codicon.folder }, - item: isSelected ? { ...project, checked: true } : project, + item: selected ? { ...project, checked: true } : project, onRemove: () => this._removeProject(project), }); } // Then repos for (const project of repos) { - const isSelected = selectedKey !== undefined && this._projectKey(project) === selectedKey; + const selected = isSelected(project); items.push({ kind: ActionListItemKind.Action, label: this._getStoredProjectLabel(project), group: { title: '', icon: Codicon.repo }, - item: isSelected ? { ...project, checked: true } : project, + item: selected ? { ...project, checked: true } : project, + onRemove: () => this._removeProject(project), + }); + } + + // Then remotes + for (const project of remotes) { + const selected = isSelected(project); + items.push({ + kind: ActionListItemKind.Action, + label: this._getStoredProjectLabel(project), + group: { title: '', icon: Codicon.remote }, + item: selected ? { ...project, checked: true } : project, onRemove: () => this._removeProject(project), }); } @@ -370,6 +453,14 @@ export class ProjectPicker extends Disposable { group: { title: '', icon: Codicon.repo }, item: { uri: URI.parse(COMMAND_BROWSE_REPOS).toJSON() }, }); + if (this.remoteAgentHostService.connections.length > 0) { + items.push({ + kind: ActionListItemKind.Action, + label: localize('browseRemotes', "Browse Remotes..."), + group: { title: '', icon: Codicon.remote }, + item: { uri: URI.parse(COMMAND_BROWSE_REMOTE_AGENT_HOSTS).toJSON() }, + }); + } return items; } @@ -386,8 +477,10 @@ export class ProjectPicker extends Disposable { dom.clearNode(this._triggerElement); const project = this._selectedProject; - const label = project ? this._getProjectLabel(project) : localize('pickProject', "Pick a Project"); - const icon = project ? (project.isFolder ? Codicon.folder : Codicon.repo) : Codicon.project; + const label = project ? this._getProjectLabel(project) : localize('pickWorkspace', "Pick a Workspace"); + const icon = project + ? (project.isRemoteAgentHost ? Codicon.remote : project.isFolder ? Codicon.folder : Codicon.repo) + : Codicon.project; dom.append(this._triggerElement, renderIcon(icon)); const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); @@ -395,12 +488,18 @@ export class ProjectPicker extends Disposable { dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); } - private _getProjectLabel(project: SessionProject): string { - return this._getStoredProjectLabel({ uri: project.uri.toJSON() }); + private _getProjectLabel(project: SessionWorkspace): string { + return this._getStoredProjectLabel(this._withCachedRemoteName(this._toStored(project))); } private _getStoredProjectLabel(project: IStoredProject): string { const uri = URI.revive(project.uri); + // TODO@roblourens HACK + if (uri.scheme === AGENT_HOST_FS_SCHEME) { + const folderName = basename(uri) || uri.path || '/'; + const remoteName = this._getRemoteName(uri.authority) ?? project.remoteName ?? uri.authority; + return `${folderName} [${remoteName}]`; + } if (uri.scheme !== GITHUB_REMOTE_FILE_SCHEME) { return basename(uri); } @@ -408,18 +507,46 @@ export class ProjectPicker extends Disposable { return uri.path.substring(1).replace(/\/HEAD$/, ''); } - private _toStored(project: SessionProject): IStoredProject { - return { - uri: project.uri.toJSON(), - }; + /** + * Resolves a sanitized authority back to a user-facing remote name. + */ + private _getRemoteName(authority: string): string | undefined { + for (const conn of this.remoteAgentHostService.connections) { + if (agentHostAuthority(conn.address) === authority) { + return conn.name; + } + } + return undefined; } - private _fromStored(stored: IStoredProject): SessionProject { - return new SessionProject(URI.revive(stored.uri)); + private _toStored(project: SessionWorkspace): IStoredProject { + const uri = project.uri; + const stored: IStoredProject = { uri: uri.toJSON() }; + if (uri.scheme === AGENT_HOST_FS_SCHEME) { + const remoteName = this._getRemoteName(uri.authority); + if (remoteName) { + return { ...stored, remoteName }; + } + } + return stored; } - private _projectKey(project: IStoredProject): string { - return URI.revive(project.uri).toString(); + private _fromStored(stored: IStoredProject): SessionWorkspace { + return new SessionWorkspace(URI.revive(stored.uri)); + } + + /** + * If the stored project is missing a cached remoteName, tries to recover + * it from the recents list so labels remain stable across restarts. + */ + private _withCachedRemoteName(stored: IStoredProject): IStoredProject { + if (!stored.remoteName && URI.revive(stored.uri).scheme === AGENT_HOST_FS_SCHEME) { + const cached = this._recentProjects.find(p => this._isSameProject(p, stored)); + if (cached?.remoteName) { + return { ...stored, remoteName: cached.remoteName }; + } + } + return stored; } private _isSameProject(a: IStoredProject, b: IStoredProject): boolean { diff --git a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts index fb3efadd52f..a4eb5afd410 100644 --- a/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts +++ b/src/vs/sessions/contrib/chat/common/builtinPromptsStorage.ts @@ -4,19 +4,12 @@ *--------------------------------------------------------------------------------------------*/ 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'; +import { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/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'; +// Re-export from common for backward compatibility +export type { AICustomizationPromptsStorage } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +export { BUILTIN_STORAGE } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; /** * Prompt path for built-in prompts bundled with the Sessions app. @@ -25,4 +18,6 @@ export interface IBuiltinPromptPath { readonly uri: URI; readonly storage: AICustomizationPromptsStorage; readonly type: PromptsType; + readonly name?: string; + readonly description?: string; } diff --git a/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts new file mode 100644 index 00000000000..60f669931b3 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/workspacePicker.test.ts @@ -0,0 +1,196 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../base/common/event.js'; +import { URI } from '../../../../../base/common/uri.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 { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ExtUri } from '../../../../../base/common/resources.js'; +import { IRemoteAgentHostService, IRemoteAgentHostConnectionInfo } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { WorkspacePicker } from '../../browser/workspacePicker.js'; +import { SessionWorkspace, GITHUB_REMOTE_FILE_SCHEME } from '../../../sessions/common/sessionWorkspace.js'; +import { AGENT_HOST_FS_SCHEME, agentHostUri } from '../../../remoteAgentHost/browser/agentHostFileSystemProvider.js'; +import { agentHostAuthority } from '../../../remoteAgentHost/browser/remoteAgentHost.contribution.js'; + +suite('WorkspacePicker', () => { + + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + let instantiationService: TestInstantiationService; + let connections: IRemoteAgentHostConnectionInfo[]; + + setup(() => { + instantiationService = ds.add(new TestInstantiationService()); + connections = []; + + instantiationService.stub(IStorageService, ds.add(new InMemoryStorageService())); + instantiationService.stub(IActionWidgetService, new class extends mock() { + override get isVisible() { return false; } + }); + instantiationService.stub(IFileDialogService, new class extends mock() { }); + instantiationService.stub(ICommandService, new class extends mock() { }); + instantiationService.stub(IUriIdentityService, new class extends mock() { + override readonly extUri = new ExtUri(uri => false); + }); + instantiationService.stub(IRemoteAgentHostService, new class extends mock() { + override readonly onDidChangeConnections = Event.None; + override get connections() { return connections; } + override getConnection() { return undefined; } + }); + instantiationService.stub(IQuickInputService, new class extends mock() { }); + }); + + function createPicker(): WorkspacePicker { + return ds.add(instantiationService.createInstance(WorkspacePicker)); + } + + test('setSelectedProject with local folder', () => { + const picker = createPicker(); + const folder = new SessionWorkspace(URI.file('/home/user/project')); + + picker.setSelectedProject(folder); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.isFolder, true); + assert.strictEqual(picker.selectedProject.uri.path, '/home/user/project'); + }); + + test('setSelectedProject with remote agent host URI', () => { + const picker = createPicker(); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/home/user/project'); + const project = new SessionWorkspace(remoteUri); + + picker.setSelectedProject(project); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.isRemoteAgentHost, true); + assert.strictEqual(picker.selectedProject.uri.scheme, AGENT_HOST_FS_SCHEME); + assert.strictEqual(picker.selectedProject.uri.path, '/home/user/project'); + }); + + test('setSelectedProject with GitHub repo URI', () => { + const picker = createPicker(); + const repoUri = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/owner/repo/HEAD' }); + const project = new SessionWorkspace(repoUri); + + picker.setSelectedProject(project); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.isRepo, true); + }); + + test('onDidSelectProject fires when project is selected', () => { + const picker = createPicker(); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/remote/path'); + const project = new SessionWorkspace(remoteUri); + + let fired: SessionWorkspace | undefined; + ds.add(picker.onDidSelectProject(p => { fired = p; })); + + picker.setSelectedProject(project, true); + + assert.ok(fired); + assert.strictEqual(fired.isRemoteAgentHost, true); + assert.strictEqual(fired.uri.path, '/remote/path'); + }); + + test('onDidSelectProject does not fire when fireEvent is false', () => { + const picker = createPicker(); + const project = new SessionWorkspace(URI.file('/some/folder')); + + let fired = false; + ds.add(picker.onDidSelectProject(() => { fired = true; })); + + picker.setSelectedProject(project, false); + + assert.strictEqual(fired, false); + assert.ok(picker.selectedProject); + }); + + test('clearSelection clears the selected project', () => { + const picker = createPicker(); + picker.setSelectedProject(new SessionWorkspace(URI.file('/folder')), false); + + assert.ok(picker.selectedProject); + + picker.clearSelection(); + + assert.strictEqual(picker.selectedProject, undefined); + }); + + test('removeFromRecents clears selection if it matches', () => { + const picker = createPicker(); + const uri = URI.file('/folder'); + picker.setSelectedProject(new SessionWorkspace(uri), false); + + picker.removeFromRecents(uri); + + assert.strictEqual(picker.selectedProject, undefined); + }); + + test('removeFromRecents preserves selection if it does not match', () => { + const picker = createPicker(); + const selectedUri = URI.file('/selected'); + picker.setSelectedProject(new SessionWorkspace(selectedUri), false); + + picker.removeFromRecents(URI.file('/other')); + + assert.ok(picker.selectedProject); + assert.strictEqual(picker.selectedProject.uri.path, '/selected'); + }); + + test('remote project persists and restores from storage', () => { + const storageService = ds.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); + + // Create picker and select a remote project + const picker1 = ds.add(instantiationService.createInstance(WorkspacePicker)); + const authority = agentHostAuthority('http://myremote:3000'); + const remoteUri = agentHostUri(authority, '/home/user/project'); + picker1.setSelectedProject(new SessionWorkspace(remoteUri), false); + + // Create a second picker -- it should restore from storage + const picker2 = ds.add(instantiationService.createInstance(WorkspacePicker)); + assert.ok(picker2.selectedProject); + assert.strictEqual(picker2.selectedProject.isRemoteAgentHost, true); + assert.strictEqual(picker2.selectedProject.uri.path, '/home/user/project'); + assert.strictEqual(picker2.selectedProject.uri.authority, authority); + }); + + test('trigger label uses cached remoteName when connection is unavailable', () => { + const storageService = ds.add(new InMemoryStorageService()); + instantiationService.stub(IStorageService, storageService); + + const address = 'http://myremote:3000'; + const authority = agentHostAuthority(address); + + // Simulate a live connection so remoteName gets cached + connections = [{ address, name: 'macbook', clientId: 'test-client' }]; + const picker1 = ds.add(instantiationService.createInstance(WorkspacePicker)); + const remoteUri = agentHostUri(authority, '/home/user/project'); + picker1.setSelectedProject(new SessionWorkspace(remoteUri), false); + + // Simulate startup with no connections available + connections = []; + const picker2 = ds.add(instantiationService.createInstance(WorkspacePicker)); + + // Render and check the trigger label uses cached "macbook", not encoded authority + const container = document.createElement('div'); + picker2.render(container); + const label = container.querySelector('.sessions-chat-dropdown-label'); + assert.ok(label); + assert.strictEqual(label.textContent, 'project [macbook]'); + }); +}); diff --git a/src/vs/sessions/contrib/chatDebug/browser/chatDebug.contribution.ts b/src/vs/sessions/contrib/chatDebug/browser/chatDebug.contribution.ts new file mode 100644 index 00000000000..dbb92718c71 --- /dev/null +++ b/src/vs/sessions/contrib/chatDebug/browser/chatDebug.contribution.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { localize, localize2 } from '../../../../nls.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.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'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IViewContainersRegistry, IViewDescriptor, IViewsRegistry, ViewContainerLocation, Extensions as ViewContainerExtensions, WindowVisibility } from '../../../../workbench/common/views.js'; + +const COPILOT_CHAT_VIEW_CONTAINER_ID = 'workbench.view.extension.copilot-chat'; +const COPILOT_CHAT_VIEW_ID = 'copilot-chat'; +const SESSIONS_CHAT_DEBUG_CONTAINER_ID = 'workbench.sessions.panel.chatDebugContainer'; + +const chatDebugViewIcon = registerIcon('sessions-chat-debug-view-icon', Codicon.debug, localize('sessionsChatDebugViewIcon', 'View icon of the chat debug view in the sessions window.')); + +class RegisterChatDebugViewContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.registerChatDebugView'; + + constructor() { + super(); + + const viewContainerRegistry = Registry.as(ViewContainerExtensions.ViewContainersRegistry); + const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); + + // The copilot-chat view is contributed by the Copilot Chat extension, + // which may register after this contribution runs. Handle both cases. + if (!this.tryMoveView(viewContainerRegistry, viewsRegistry)) { + const listener = viewsRegistry.onViewsRegistered(e => { + for (const { views } of e) { + if (views.some(v => v.id === COPILOT_CHAT_VIEW_ID)) { + if (this.tryMoveView(viewContainerRegistry, viewsRegistry)) { + listener.dispose(); + } + break; + } + } + }); + this._register(listener); + } + } + + private tryMoveView(viewContainerRegistry: IViewContainersRegistry, viewsRegistry: IViewsRegistry): boolean { + const viewContainer = viewContainerRegistry.get(COPILOT_CHAT_VIEW_CONTAINER_ID); + if (!viewContainer) { + return false; + } + + const view = viewsRegistry.getView(COPILOT_CHAT_VIEW_ID); + if (!view) { + return false; + } + + // Deregister the view from its original extension container + viewsRegistry.deregisterViews([view], viewContainer); + viewContainerRegistry.deregisterViewContainer(viewContainer); + + // Register a new chat debug view container in the Panel for the sessions window + const chatDebugViewContainer = viewContainerRegistry.registerViewContainer({ + id: SESSIONS_CHAT_DEBUG_CONTAINER_ID, + title: localize2('chatDebug', "Chat Debug"), + icon: chatDebugViewIcon, + order: 3, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [SESSIONS_CHAT_DEBUG_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), + storageId: SESSIONS_CHAT_DEBUG_CONTAINER_ID, + hideIfEmpty: true, + windowVisibility: WindowVisibility.Sessions, + }, ViewContainerLocation.Panel, { doNotRegisterOpenCommand: true }); + + // Re-register the view inside the new sessions container + const sessionsView: IViewDescriptor = { + ...view, + canMoveView: false, + windowVisibility: WindowVisibility.Sessions, + }; + viewsRegistry.registerViews([sessionsView], chatDebugViewContainer); + + return true; + } +} + +registerWorkbenchContribution2(RegisterChatDebugViewContribution.ID, RegisterChatDebugViewContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts index 1efe64e6aa2..551cbc4d128 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -5,7 +5,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; +import { autorun, observableSignalFromEvent } 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'; @@ -19,10 +19,10 @@ import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browse 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'; +import { CodeReviewService, CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, MAX_CODE_REVIEWS_PER_SESSION_VERSION, PRReviewStateKind } from './codeReviewService.js'; registerSingleton(ICodeReviewService, CodeReviewService, InstantiationType.Delayed); @@ -82,6 +82,7 @@ function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disp // 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 reviewCount = reviewState.kind !== CodeReviewStateKind.Idle && reviewState.version === version ? reviewState.reviewCount : 0; const codeReviewCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === version ? reviewState.comments.length : 0; const prReviewCount = prReviewState.kind === PRReviewStateKind.Loaded ? prReviewState.comments.length : 0; @@ -99,6 +100,10 @@ function registerSessionCodeReviewAction(tooltip: string, icon: ThemeIcon): Disp return; } + if (reviewCount >= MAX_CODE_REVIEWS_PER_SESSION_VERSION) { + return; + } + codeReviewService.requestReview(resource, version, files); } @@ -122,7 +127,7 @@ class CodeReviewToolbarContribution extends Disposable implements IWorkbenchCont super(); const canRunCodeReviewContext = canRunSessionCodeReviewContextKey.bindTo(contextKeyService); - const sessionsChangedSignal = observableFromEvent(this, this._agentSessionsService.model.onDidChangeSessions, () => undefined); + const sessionsChangedSignal = observableSignalFromEvent(this, this._agentSessionsService.model.onDidChangeSessions); this._register(autorun(reader => { const activeSession = this._sessionManagementService.activeSession.read(reader); @@ -147,6 +152,7 @@ class CodeReviewToolbarContribution extends Disposable implements IWorkbenchCont const version = getCodeReviewVersion(files); const reviewState = this._codeReviewService.getReviewState(sessionResource).read(reader); const prReviewState = this._codeReviewService.getPRReviewState(sessionResource).read(reader); + const reviewCount = reviewState.kind !== CodeReviewStateKind.Idle && reviewState.version === version ? reviewState.reviewCount : 0; const codeReviewCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === version ? reviewState.comments.length : 0; const prReviewCount = prReviewState.kind === PRReviewStateKind.Loaded ? prReviewState.comments.length : 0; @@ -166,10 +172,16 @@ class CodeReviewToolbarContribution extends Disposable implements IWorkbenchCont 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) { + } else if (reviewCount >= MAX_CODE_REVIEWS_PER_SESSION_VERSION) { canRunCodeReview = false; - tooltip = localize('sessions.runCodeReview.tooltip.allResolved', "All review comments have been addressed."); - icon = Codicon.comment; + tooltip = localize('sessions.runCodeReview.tooltip.limitReached', "Maximum of {0} code reviews reached for this session version.", MAX_CODE_REVIEWS_PER_SESSION_VERSION); + icon = Codicon.codeReview; + } else if (reviewState.kind === CodeReviewStateKind.Result && reviewState.version === version) { + canRunCodeReview = true; + tooltip = reviewState.didProduceComments + ? localize('sessions.runCodeReview.tooltip.runAgain', "Run another code review.") + : localize('sessions.runCodeReview.tooltip.noCommentsRunAgain', "Previous code review produced no comments. Run code review again."); + icon = reviewState.didProduceComments ? Codicon.comment : Codicon.codeReview; } canRunCodeReviewContext.set(canRunCodeReview); diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts index 689c409ffe4..d7cb80766a7 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReviewService.ts @@ -70,6 +70,8 @@ export function getCodeReviewVersion(files: readonly ICodeReviewFile[]): string return `v1:${stableFileList.length}:${hash(stableFileList)}`; } +export const MAX_CODE_REVIEWS_PER_SESSION_VERSION = 5; + export const enum CodeReviewStateKind { Idle = 'idle', Loading = 'loading', @@ -79,9 +81,9 @@ export const enum CodeReviewStateKind { 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 }; + | { readonly kind: CodeReviewStateKind.Loading; readonly version: string; readonly reviewCount: number } + | { readonly kind: CodeReviewStateKind.Result; readonly version: string; readonly reviewCount: number; readonly comments: readonly ICodeReviewComment[]; readonly didProduceComments: boolean } + | { readonly kind: CodeReviewStateKind.Error; readonly version: string; readonly reviewCount: number; readonly reason: string }; // --- PR Review Types --------------------------------------------------------- @@ -169,7 +171,8 @@ export interface ICodeReviewService { /** * 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. + * progress or there are still unresolved review comments for this version, + * this is a no-op. */ requestReview(sessionResource: URI, version: string, files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[]): void; @@ -178,6 +181,11 @@ export interface ICodeReviewService { */ removeComment(sessionResource: URI, commentId: string): void; + /** + * Update the body text of a single code review comment. + */ + updateComment(sessionResource: URI, commentId: string, newBody: string): void; + /** * Dismiss/clear the review for a session entirely. */ @@ -193,12 +201,21 @@ export interface ICodeReviewService { * Resolve a PR review thread on GitHub and remove it from local state. */ resolvePRReviewThread(sessionResource: URI, threadId: string): Promise; + + /** + * Mark a PR review comment as locally converted to agent feedback. + * The comment is hidden from the PR review state until the session is + * cleaned up. + */ + markPRReviewCommentConverted(sessionResource: URI, commentId: string): void; } // --- Storage Types ----------------------------------------------------------- interface IStoredCodeReview { readonly version: string; + readonly reviewCount?: number; + readonly didProduceComments?: boolean; readonly comments: readonly IStoredCodeReviewComment[]; } @@ -291,6 +308,8 @@ export class CodeReviewService extends Disposable implements ICodeReviewService private readonly _reviewsBySession = new Map(); private readonly _prReviewBySession = new Map(); + /** PR review comment IDs that have been converted to agent feedback (per session). */ + private readonly _convertedPRCommentsBySession = new Map>(); constructor( @ICommandService private readonly _commandService: ICommandService, @@ -342,16 +361,20 @@ export class CodeReviewService extends Disposable implements ICodeReviewService requestReview(sessionResource: URI, version: string, files: readonly { readonly currentUri: URI; readonly baseUri?: URI }[]): void { const data = this._getOrCreateData(sessionResource); const currentState = data.state.get(); + const currentReviewCount = currentState.kind !== CodeReviewStateKind.Idle && currentState.version === version ? currentState.reviewCount : 0; - // Don't re-request if already loading or completed for this version + // Don't re-request if already loading or unresolved comments remain for this version. if (currentState.kind === CodeReviewStateKind.Loading && currentState.version === version) { return; } - if (currentState.kind === CodeReviewStateKind.Result && currentState.version === version) { + if (currentReviewCount >= MAX_CODE_REVIEWS_PER_SESSION_VERSION) { + return; + } + if (currentState.kind === CodeReviewStateKind.Result && currentState.version === version && currentState.comments.length > 0) { return; } - data.state.set({ kind: CodeReviewStateKind.Loading, version }, undefined); + data.state.set({ kind: CodeReviewStateKind.Loading, version, reviewCount: currentReviewCount + 1 }, undefined); this._executeReview(sessionResource, version, files, data); } @@ -368,7 +391,23 @@ export class CodeReviewService extends Disposable implements ICodeReviewService } const filtered = state.comments.filter(c => c.id !== commentId); - data.state.set({ kind: CodeReviewStateKind.Result, version: state.version, comments: filtered }, undefined); + data.state.set({ kind: CodeReviewStateKind.Result, version: state.version, reviewCount: state.reviewCount, comments: filtered, didProduceComments: state.didProduceComments }, undefined); + this._saveToStorage(); + } + + updateComment(sessionResource: URI, commentId: string, newBody: string): void { + const data = this._reviewsBySession.get(sessionResource.toString()); + if (!data) { + return; + } + + const state = data.state.get(); + if (state.kind !== CodeReviewStateKind.Result) { + return; + } + + const updated = state.comments.map(c => c.id === commentId ? { ...c, body: newBody } : c); + data.state.set({ kind: CodeReviewStateKind.Result, version: state.version, reviewCount: state.reviewCount, comments: updated, didProduceComments: state.didProduceComments }, undefined); this._saveToStorage(); } @@ -419,7 +458,7 @@ export class CodeReviewService extends Disposable implements ICodeReviewService } if (result.type === 'error') { - data.state.set({ kind: CodeReviewStateKind.Error, version, reason: result.reason ?? 'Unknown error' }, undefined); + data.state.set({ kind: CodeReviewStateKind.Error, version, reviewCount: currentState.reviewCount, reason: result.reason ?? 'Unknown error' }, undefined); return; } @@ -435,14 +474,14 @@ export class CodeReviewService extends Disposable implements ICodeReviewService })); transaction(tx => { - data.state.set({ kind: CodeReviewStateKind.Result, version, comments }, tx); + data.state.set({ kind: CodeReviewStateKind.Result, version, reviewCount: currentState.reviewCount, comments, didProduceComments: comments.length > 0 }, 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); + data.state.set({ kind: CodeReviewStateKind.Error, version, reviewCount: currentState.reviewCount, reason: String(err) }, undefined); } } } @@ -466,10 +505,10 @@ export class CodeReviewService extends Disposable implements ICodeReviewService suggestion: c.suggestion, })); const data = this._getOrCreateData(URI.parse(key)); - data.state.set({ kind: CodeReviewStateKind.Result, version: review.version, comments }, undefined); + data.state.set({ kind: CodeReviewStateKind.Result, version: review.version, reviewCount: review.reviewCount ?? 1, comments, didProduceComments: review.didProduceComments ?? comments.length > 0 }, undefined); } } catch { - // Corrupted storage data — ignore + // Corrupted storage data - ignore } } @@ -480,6 +519,8 @@ export class CodeReviewService extends Disposable implements ICodeReviewService if (state.kind === CodeReviewStateKind.Result) { stored[key] = { version: state.version, + reviewCount: state.reviewCount, + didProduceComments: state.didProduceComments, comments: state.comments.map(c => ({ id: c.id, uri: c.uri.toJSON(), @@ -524,14 +565,14 @@ export class CodeReviewService extends Disposable implements ICodeReviewService const session = this._agentSessionsService.getSession(URI.parse(key)); if (!session) { - // Session no longer exists — clean up + // 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 + // Session has no file-level changes - clean up data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); changed = true; continue; @@ -540,7 +581,7 @@ export class CodeReviewService extends Disposable implements ICodeReviewService const files = getCodeReviewFilesFromSessionChanges(session.changes); const currentVersion = getCodeReviewVersion(files); if (state.version !== currentVersion) { - // Version mismatch — review is stale + // Version mismatch - review is stale data.state.set({ kind: CodeReviewStateKind.Idle }, undefined); changed = true; } @@ -578,6 +619,26 @@ export class CodeReviewService extends Disposable implements ICodeReviewService } } + markPRReviewCommentConverted(sessionResource: URI, commentId: string): void { + const key = sessionResource.toString(); + let converted = this._convertedPRCommentsBySession.get(key); + if (!converted) { + converted = new Set(); + this._convertedPRCommentsBySession.set(key, converted); + } + converted.add(commentId); + + // Immediately filter the comment from the observable PR review state + const data = this._prReviewBySession.get(key); + if (data) { + const currentState = data.state.get(); + if (currentState.kind === PRReviewStateKind.Loaded) { + const filtered = currentState.comments.filter(c => c.id !== commentId); + data.state.set({ kind: PRReviewStateKind.Loaded, comments: filtered }, undefined); + } + } + } + private _getOrCreatePRReviewData(sessionResource: URI): IPRSessionReviewData { const key = sessionResource.toString(); let data = this._prReviewBySession.get(key); @@ -611,12 +672,17 @@ export class CodeReviewService extends Disposable implements ICodeReviewService // Watch the PR model's review threads and map to local state data.disposables.add(autorun(reader => { const threads = prModel.reviewThreads.read(reader); + const converted = this._convertedPRCommentsBySession.get(sessionResource.toString()); const comments: IPRReviewComment[] = []; for (const thread of threads) { if (thread.isResolved) { continue; } + const threadId = String(thread.id); + if (converted?.has(threadId)) { + continue; + } const fileUri = this._sessionsManagementService.resolveSessionFileUri(sessionResource, thread.path); if (!fileUri) { continue; @@ -645,6 +711,7 @@ export class CodeReviewService extends Disposable implements ICodeReviewService private _disposePRReview(sessionResource: URI): void { const key = sessionResource.toString(); + this._convertedPRCommentsBySession.delete(key); const data = this._prReviewBySession.get(key); if (data) { data.disposables.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 index f3423804b8a..f1db904ebdb 100644 --- a/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts +++ b/src/vs/sessions/contrib/codeReview/test/browser/codeReviewService.test.ts @@ -18,9 +18,9 @@ import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } 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'; +import { ICodeReviewService, CodeReviewService, CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion } from '../../browser/codeReviewService.js'; suite('CodeReviewService', () => { @@ -249,6 +249,7 @@ suite('CodeReviewService', () => { assert.strictEqual(state.kind, CodeReviewStateKind.Loading); if (state.kind === CodeReviewStateKind.Loading) { assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.reviewCount, 1); } // Resolve to avoid leaking @@ -301,6 +302,7 @@ suite('CodeReviewService', () => { assert.strictEqual(state.kind, CodeReviewStateKind.Result); if (state.kind === CodeReviewStateKind.Result) { assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.reviewCount, 1); assert.strictEqual(state.comments.length, 2); assert.strictEqual(state.comments[0].body, 'Bug found'); assert.strictEqual(state.comments[0].kind, 'bug'); @@ -320,6 +322,7 @@ suite('CodeReviewService', () => { assert.strictEqual(state.kind, CodeReviewStateKind.Error); if (state.kind === CodeReviewStateKind.Error) { assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.reviewCount, 1); assert.strictEqual(state.reason, 'Auth failed'); } }); @@ -354,6 +357,7 @@ suite('CodeReviewService', () => { const state = service.getReviewState(session).get(); assert.strictEqual(state.kind, CodeReviewStateKind.Error); if (state.kind === CodeReviewStateKind.Error) { + assert.strictEqual(state.reviewCount, 1); assert.ok(state.reason.includes('Network error')); } }); @@ -372,8 +376,8 @@ suite('CodeReviewService', () => { commandService.resolveExecution({ type: 'success', comments: [] }); }); - test('requestReview is a no-op when result exists for the same version', async () => { - commandService.result = { type: 'success', comments: [] }; + test('requestReview is a no-op when unresolved comments exist for the same version', 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(); @@ -383,6 +387,71 @@ suite('CodeReviewService', () => { // Should still have the result const state = service.getReviewState(session).get(); assert.strictEqual(state.kind, CodeReviewStateKind.Result); + if (state.kind === CodeReviewStateKind.Result) { + assert.strictEqual(state.comments.length, 1); + } + }); + + test('requestReview reruns when previous result for the same version had no comments', async () => { + commandService.result = { type: 'success', comments: [] }; + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + + commandService.resolveExecution({ type: 'success', comments: [] }); + await tick(); + }); + + test('requestReview reruns when all comments for the same version were removed', 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 initialState = service.getReviewState(session).get(); + assert.strictEqual(initialState.kind, CodeReviewStateKind.Result); + if (initialState.kind !== CodeReviewStateKind.Result) { + return; + } + + service.removeComment(session, initialState.comments[0].id); + + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + const state = service.getReviewState(session).get(); + assert.strictEqual(state.kind, CodeReviewStateKind.Loading); + + commandService.resolveExecution({ type: 'success', comments: [] }); + await tick(); + }); + + test('requestReview is a no-op after five reviews for the same version', async () => { + commandService.result = { type: 'success', comments: [] }; + + for (let i = 0; i < 5; i++) { + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + await tick(); + } + + const stateBefore = service.getReviewState(session).get(); + assert.strictEqual(stateBefore.kind, CodeReviewStateKind.Result); + if (stateBefore.kind === CodeReviewStateKind.Result) { + assert.strictEqual(stateBefore.reviewCount, 5); + } + + commandService.deferNextExecution(); + service.requestReview(session, 'v1', [{ currentUri: fileA }]); + + const stateAfter = service.getReviewState(session).get(); + assert.strictEqual(stateAfter.kind, CodeReviewStateKind.Result); + if (stateAfter.kind === CodeReviewStateKind.Result) { + assert.strictEqual(stateAfter.reviewCount, 5); + } }); test('requestReview for a new version replaces loading state', async () => { @@ -759,6 +828,7 @@ suite('CodeReviewService', () => { const reviewData = stored[session.toString()]; assert.ok(reviewData); assert.strictEqual(reviewData.version, 'v1'); + assert.strictEqual(reviewData.reviewCount, 1); assert.strictEqual(reviewData.comments.length, 1); assert.strictEqual(reviewData.comments[0].body, 'Persisted comment'); }); @@ -777,6 +847,7 @@ suite('CodeReviewService', () => { assert.strictEqual(state.kind, CodeReviewStateKind.Result); if (state.kind === CodeReviewStateKind.Result) { assert.strictEqual(state.version, 'v1'); + assert.strictEqual(state.reviewCount, 1); assert.strictEqual(state.comments.length, 1); assert.strictEqual(state.comments[0].body, 'Restored comment'); assert.strictEqual(state.comments[0].uri.toString(), fileA.toString()); diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index 5703b65da88..04711b328a9 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -28,6 +28,12 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'extensions.ignoreRecommendations': true, 'files.autoSave': 'afterDelay', + 'files.watcherExclude': { + '**/.git/objects/**': true, + '**/.git/subtree-cache/**': true, + '**/node_modules/*/**': true /* TODO@bpasero see if this helps improve perf */, + '**/.hg/store/**': true + }, 'git.autofetch': true, 'git.branchRandomName.enable': true, @@ -50,6 +56,8 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'search.quickOpen.includeHistory': false, + 'task.notifyWindowOnTaskCompletion': -1, + 'terminal.integrated.initialHint': false, 'workbench.editor.doubleClickTabToToggleEditorGroupSizes': 'maximize', @@ -58,6 +66,7 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'workbench.tips.enabled': false, 'workbench.layoutControl.type': 'toggles', 'workbench.editor.useModal': 'all', + 'workbench.editor.modalMinWidth': 600, 'workbench.panel.showLabels': false, 'workbench.colorTheme': 'VS Code Dark', diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts index d82aad57f12..d9cf0cffe71 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.contribution.ts @@ -8,7 +8,7 @@ import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { GitHubFileSystemProvider } from './githubFileSystemProvider.js'; -import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionProject.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionWorkspace.js'; // --- View registration is currently disabled in favor of the "Add Context" picker. // The Files view will be re-enabled once we finalize the sessions auxiliary bar layout. diff --git a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts index 11230079baa..6b7d81ac7b3 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/fileTreeView.ts @@ -44,7 +44,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { ILogService } from '../../../../platform/log/common/log.js'; import { ISessionsManagementService, IActiveSessionItem } from '../../sessions/browser/sessionsManagementService.js'; import { getGitHubRemoteFileDisplayName } from './githubFileSystemProvider.js'; -import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionProject.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionWorkspace.js'; import { basename } from '../../../../base/common/path.js'; import { isEqual } from '../../../../base/common/resources.js'; diff --git a/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts index 25e0089681c..4d567f570ab 100644 --- a/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts +++ b/src/vs/sessions/contrib/fileTreeView/browser/githubFileSystemProvider.ts @@ -12,7 +12,7 @@ import { IAuthenticationService } from '../../../../workbench/services/authentic import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionProject.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../sessions/common/sessionWorkspace.js'; /** * Derives a display name from a github-remote-file URI. diff --git a/src/vs/sessions/contrib/git/browser/git.contribution.ts b/src/vs/sessions/contrib/git/browser/git.contribution.ts deleted file mode 100644 index ad28526fdb6..00000000000 --- a/src/vs/sessions/contrib/git/browser/git.contribution.ts +++ /dev/null @@ -1,137 +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, IDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, constObservable, derived, ObservablePromise, observableValue } from '../../../../base/common/observable.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; -import { localize } from '../../../../nls.js'; -import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; -import { GitBranch, GitRepositoryState, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; -import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; - -const hasUpstreamBranchContextKey = new RawContextKey('agentSessionGitHasUpstreamBranch', false, { - type: 'boolean', - description: localize('agentSessionGitHasUpstreamBranch', "True when the active agent session worktree has an upstream branch."), -}); - -class GitSyncContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'sessions.contrib.gitSync'; - - private readonly _isSyncingObs = observableValue(this, false); - - constructor( - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, - @IGitService private readonly gitService: IGitService, - ) { - super(); - - const hasUpstreamBranch = hasUpstreamBranchContextKey.bindTo(this.contextKeyService); - - const activeSessionWorktreeObs = derived(reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - return activeSession?.worktree; - }); - - const activeSessionRepositoryPromiseObs = derived(reader => { - const worktreeUri = activeSessionWorktreeObs.read(reader); - if (!worktreeUri) { - return constObservable(undefined); - } - - return new ObservablePromise(this.gitService.openRepository(worktreeUri)).resolvedValue; - }); - - const activeSessionRepositoryStateObs = derived(reader => { - const activeSessionRepository = activeSessionRepositoryPromiseObs.read(reader).read(reader); - if (activeSessionRepository === undefined) { - return undefined; - } - - return activeSessionRepository.state.read(reader); - }); - - this._register(autorun(reader => { - const isSyncing = this._isSyncingObs.read(reader); - const activeSessionRepositoryState = activeSessionRepositoryStateObs.read(reader); - if (!activeSessionRepositoryState) { - hasUpstreamBranch.set(false); - return; - } - - const head = activeSessionRepositoryState.HEAD; - hasUpstreamBranch.set(head?.upstream !== undefined); - - if (!head?.upstream) { - return; - } - - reader.store.add(registerSyncAction(head, isSyncing, (syncing) => { - this._isSyncingObs.set(syncing, undefined); - })); - })); - } -} - -function registerSyncAction(branch: GitBranch, isSyncing: boolean, setSyncing: (syncing: boolean) => void): IDisposable { - const ahead = branch.ahead ?? 0; - const behind = branch.behind ?? 0; - - const titleSegments = [localize('synchronizeChangesTitle', "Sync Changes")]; - if (behind > 0) { - titleSegments.push(`${behind}↓`); - } - if (ahead > 0) { - titleSegments.push(`${ahead}↑`); - } - - const icon = isSyncing - ? ThemeIcon.modify(Codicon.sync, 'spin') - : Codicon.sync; - - class SynchronizeChangesAction extends Action2 { - static readonly ID = 'chatEditing.synchronizeChanges'; - - constructor() { - super({ - id: SynchronizeChangesAction.ID, - title: titleSegments.join(' '), - tooltip: localize('synchronizeChanges', "Synchronize Changes with Git (Behind {0}, Ahead {1})", behind, ahead), - icon, - category: CHAT_CATEGORY, - menu: [ - { - id: MenuId.ChatEditingSessionApplySubmenu, - group: 'navigation', - order: 0, - when: hasUpstreamBranchContextKey, - }, - ], - }); - } - - override async run(accessor: ServicesAccessor): Promise { - const commandService = accessor.get(ICommandService); - const sessionManagementService = accessor.get(ISessionsManagementService); - const worktreeUri = sessionManagementService.getActiveSession()?.worktree; - setSyncing(true); - try { - await commandService.executeCommand('git.sync', worktreeUri); - } finally { - setSyncing(false); - } - } - } - return registerAction2(SynchronizeChangesAction); -} - -registerWorkbenchContribution2(GitSyncContribution.ID, GitSyncContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/github/test/browser/githubService.test.ts b/src/vs/sessions/contrib/github/test/browser/githubService.test.ts index 9aa427dc2fe..5bbb3c83059 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubService.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubService.test.ts @@ -10,7 +10,7 @@ import { NullLogService, ILogService } from '../../../../../platform/log/common/ 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 '../../../sessions/common/sessionProject.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../../../sessions/common/sessionWorkspace.js'; import { IActiveSessionItem } from '../../../sessions/browser/sessionsManagementService.js'; suite('GitHubService', () => { diff --git a/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md new file mode 100644 index 00000000000..b45c0aaf863 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/ARCHITECTURE.md @@ -0,0 +1,313 @@ +# Remote Agent Host Chat Agents - Architecture + +This document describes how remote agent host chat agents are registered, how +sessions are created, and the URI/target conventions used throughout the system. + +## Overview + +A **remote agent host** is a VS Code agent host process running on another +machine, connected over WebSocket. The user configures remote addresses in the +`chat.remoteAgentHosts` setting. Each remote host may expose one or more agent +backends (currently only the `copilot` provider is supported). The system +discovers these agents, dynamically registers them as chat session types, and +creates sessions that stream turns via the agent host protocol. + +``` +┌─────────────┐ WebSocket ┌───────────────────┐ +│ VS Code │ ◄──────────────► │ Remote Agent Host │ +│ (client) │ AHP protocol │ (server) │ +└─────────────┘ └───────────────────┘ +``` + +## Connection Lifecycle + +### 1. Configuration + +Connections are configured via the `chat.remoteAgentHosts` setting: + +```jsonc +"chat.remoteAgentHosts": [ + { "address": "http://192.168.1.10:3000", "name": "dev-box", "connectionToken": "..." } +] +``` + +Each entry is an `IRemoteAgentHostEntry` with `address`, `name`, and optional +`connectionToken`. + +### 2. Service Layer + +`IRemoteAgentHostService` (`src/vs/platform/agentHost/common/remoteAgentHostService.ts`) +manages WebSocket connections. The Electron implementation reads the setting, +creates `RemoteAgentHostProtocolClient` instances for each address, and fires +`onDidChangeConnections` when connections are established or lost. + +Each connection satisfies the `IAgentConnection` interface (which extends +`IAgentService`), providing: + +- `subscribe(resource)` / `unsubscribe(resource)` - state subscriptions +- `dispatchAction(action, clientId, seq)` - send client actions +- `onDidAction` / `onDidNotification` - receive server events +- `createSession(config)` - create a new backend session +- `browseDirectory(uri)` - list remote filesystem contents +- `clientId` - unique connection identifier for optimistic reconciliation + +### 3. Connection Metadata + +Each active connection exposes `IRemoteAgentHostConnectionInfo`: + +```typescript +{ + address: string; // e.g. "http://192.168.1.10:3000" + name: string; // e.g. "dev-box" (from setting) + clientId: string; // assigned during handshake + defaultDirectory?: string; // home directory on the remote machine +} +``` + +## Agent Discovery + +### Root State Subscription + +`RemoteAgentHostContribution` (`src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts`) +is the central orchestrator. For each connection, it subscribes to `ROOT_STATE_URI` +(`agenthost:/root`) to discover available agents. + +The root state (`IRootState`) contains: + +```typescript +{ + agents: IAgentInfo[]; // discovered agent backends + activeSessions?: number; // count of active sessions +} +``` + +Each `IAgentInfo` describes an agent: + +```typescript +{ + provider: string; // e.g. "copilot" + displayName: string; // e.g. "Copilot" + description: string; + models: ISessionModelInfo[]; // available language models +} +``` + +### Authority Encoding + +Remote addresses are encoded into URI-safe authority strings via +`agentHostAuthority(address)`: + +- Alphanumeric addresses pass through unchanged +- "Normal" addresses (`[a-zA-Z0-9.:-]`) get colons replaced with `__` +- Everything else is url-safe base64 encoded with a `b64-` prefix + +Examples: +- `localhost:8081` → `localhost__8081` +- `192.168.1.1:8080` → `192.168.1.1__8080` +- `http://127.0.0.1:3000` → `b64-aHR0cDovLzEyNy4wLjAuMTozMDAw` + +## Agent Registration + +When `_registerAgent()` is called for a discovered copilot agent from address `X`: + +### Naming Conventions + +| Concept | Value | Example | +|---------|-------|---------| +| **Authority** | `agentHostAuthority(address)` | `localhost__8081` | +| **Session type** | `remote-${authority}-${provider}` | `remote-localhost__8081-copilot` | +| **Agent ID** | same as session type | `remote-localhost__8081-copilot` | +| **Vendor** | same as session type | `remote-localhost__8081-copilot` | +| **Display name** | `configuredName \|\| "${displayName} (${address})"` | `dev-box` | + +### Four Registrations Per Agent + +1. **Chat session contribution** - via `IChatSessionsService.registerChatSessionContribution()`: + ```typescript + { type: sessionType, name: agentId, displayName, canDelegate: true, requiresCustomModels: true } + ``` + +2. **Session list controller** - `AgentHostSessionListController` handles the + sidebar session list. Lists sessions via `connection.listSessions()`, listens + for `notify/sessionAdded` and `notify/sessionRemoved` notifications. + +3. **Session handler** - `AgentHostSessionHandler` implements + `IChatSessionContentProvider`, bridging the agent host protocol to chat UI + progress events. Also registers a _dynamic chat agent_ via + `IChatAgentService.registerDynamicAgent()`. + +4. **Language model provider** - `AgentHostLanguageModelProvider` registers + models under the vendor descriptor. Model IDs are prefixed with the session + type (e.g., `remote-localhost__8081-copilot:claude-sonnet-4-20250514`). + +## URI Conventions + +| Context | Scheme | Format | Example | +|---------|--------|--------|---------| +| New session resource | `` | `:/untitled-` | `remote-localhost__8081-copilot:/untitled-abc` | +| Existing session | `` | `:/` | `remote-localhost__8081-copilot:/abc-123` | +| Backend session state | `` | `:/` | `copilot:/abc-123` | +| Root state subscription | (string) | `agenthost:/root` | - | +| Remote filesystem | `agenthost` | `agenthost:///` | `agenthost://localhost__8081/home/user/project` | +| Language model ID | - | `:` | `remote-localhost__8081-copilot:claude-sonnet-4-20250514` | + +### Key distinction: session resource vs backend session URI + +- The **session resource** URI uses the session type as its scheme + (e.g., `remote-localhost__8081-copilot:/untitled-abc`). This is the URI visible to + the chat UI and session management. +- The **backend session** URI uses the provider as its scheme + (e.g., `copilot:/abc-123`). This is sent over the agent host protocol to the + server. The `AgentSession.uri(provider, rawId)` helper creates these. + +The `AgentHostSessionHandler` translates between the two: +```typescript +private _resolveSessionUri(sessionResource: URI): URI { + const rawId = sessionResource.path.substring(1); + return AgentSession.uri(this._config.provider, rawId); +} +``` + +## Session Creation Flow + +### 1. User Selects a Remote Workspace + +In the `WorkspacePicker`, the user clicks **"Browse Remotes..."**, selects a +remote host, then picks a folder on the remote filesystem. This produces a +`SessionWorkspace` with an `agenthost://` URI: + +``` +agenthost://localhost__8081/home/user/myproject + ↑ authority ↑ remote filesystem path +``` + +### 2. Session Target Resolution + +`NewChatWidget._createNewSession()` detects `project.isRemoteAgentHost` and +resolves the matching session type via `getRemoteAgentHostSessionTarget()` +(defined in `remoteAgentHost.contribution.ts`): + +```typescript +// authority "localhost__8081" → find connection → "remote-localhost__8081-copilot" +const target = getRemoteAgentHostSessionTarget(connections, authority); +``` + +### 3. Resource URI Generation + +`getResourceForNewChatSession()` creates the session resource: + +```typescript +URI.from({ scheme: target, path: `/untitled-${generateUuid()}` }) +// → remote-localhost__8081-copilot:/untitled-abc-123 +``` + +### 4. Session Object Creation + +`SessionsManagementService.createNewSessionForTarget()` creates an +`AgentHostNewSession` (when the `agentHost` option is set). This is a +lightweight `INewSession` that supports local model and mode pickers but +skips isolation mode, branch, and cloud option groups. +The project URI is set on the session, making it available as +`activeSessionItem.repository`. + +### 5. Backend Session Creation (Deferred) + +`AgentHostSessionHandler` defers backend session creation until the first turn +(for "untitled" sessions), so the user-selected model is available: + +```typescript +const session = await connection.createSession({ + model: rawModelId, + provider: 'copilot', + workingDirectory: '/home/user/myproject', // from activeSession.repository.path +}); +``` + +### 6. Working Directory Resolution + +The `resolveWorkingDirectory` callback in `RemoteAgentHostContribution` reads +the active session's repository URI path: + +```typescript +const resolveWorkingDirectory = (resourceKey: string): string | undefined => { + const activeSessionItem = this._sessionsManagementService.getActiveSession(); + if (activeSessionItem?.repository) { + return activeSessionItem.repository.path; + // For agenthost://authority/home/user/project → "/home/user/project" + } + return undefined; +}; +``` + +## Turn Handling + +When the user sends a message, `AgentHostSessionHandler._handleTurn()`: + +1. Converts variable entries to `IAgentAttachment[]` (file, directory, selection) +2. Dispatches `session/modelChanged` if the model differs from current +3. Dispatches `session/turnStarted` with the user message + attachments +4. Listens to `SessionClientState.onDidChangeSessionState` and translates + the `activeTurn` state changes into `IChatProgress[]` events: + +| Server State | Chat Progress | +|-------------|---------------| +| `streamingText` | `markdownContent` | +| `reasoning` | `thinking` | +| `toolCalls` (new) | `ChatToolInvocation` created | +| `toolCalls` (completed) | `ChatToolInvocation` finalized | +| `pendingPermissions` | `awaitConfirmation()` prompt | + +5. On cancellation, dispatches `session/turnCancelled` + +## Filesystem Provider + +`AgentHostFileSystemProvider` is a read-only `IFileSystemProvider` registered +under the `agenthost` scheme. It proxies `stat` and `readdir` calls through +`connection.browseDirectory(uri)` RPC. + +- The URI authority identifies the remote connection (sanitized address) +- The URI path is the remote filesystem path +- Authority-to-address mappings are registered by `RemoteAgentHostContribution` + via `registerAuthority(authority, address)` + +## Data Flow Diagram + +``` +Settings (chat.remoteAgentHosts) + │ + ▼ +RemoteAgentHostService (WebSocket connections) + │ + ▼ +RemoteAgentHostContribution + │ + ├─► subscribe(ROOT_STATE_URI) → IRootState.agents + │ │ + │ ▼ + │ _registerAgent() for each copilot agent: + │ ├─► registerChatSessionContribution() + │ ├─► registerChatSessionItemController() + │ ├─► registerChatSessionContentProvider() + │ └─► registerLanguageModelProvider() + │ + └─► registerProvider(AGENT_HOST_FS_SCHEME, fsProvider) + +User picks remote workspace in WorkspacePicker + │ + ▼ +NewChatWidget._createNewSession(project) + │ target = getRemoteAgentHostSessionTarget(connections, authority) + ▼ +SessionsManagementService.createNewSessionForTarget() + │ creates AgentHostNewSession + ▼ +User sends message + │ + ▼ +AgentHostSessionHandler._handleTurn() + │ resolves working directory + │ creates backend session (if untitled) + │ dispatches session/turnStarted + ▼ +connection ← streams state changes → IChatProgress[] +``` diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts index 4b6b21adaea..97ec8078107 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/agentHostFileSystemProvider.ts @@ -33,7 +33,8 @@ export const AGENT_HOST_FS_SCHEME = 'agenthost'; * Build an agenthost URI for a given address and path. */ export function agentHostUri(authority: string, path: string): URI { - return URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority, path: path || '/' }); + const normalizedPath = !path ? '/' : path.startsWith('/') ? path : `/${path}`; + return URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority, path: normalizedPath }); } /** diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index e4eca99f515..545dbb83d16 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -15,9 +15,10 @@ import { type AgentProvider, type IAgentConnection } from '../../../../platform/ import { isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionClientState } from '../../../../platform/agentHost/common/state/sessionClientState.js'; import { ROOT_STATE_URI, type IAgentInfo, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js'; -import { IRemoteAgentHostService } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { AgentHostLanguageModelProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js'; @@ -25,22 +26,50 @@ import { AgentHostSessionListController } from '../../../../workbench/contrib/ch import { ISessionsManagementService } from '../../../contrib/sessions/browser/sessionsManagementService.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { AGENT_HOST_FS_SCHEME, AgentHostFileSystemProvider } from './agentHostFileSystemProvider.js'; +import * as nls from '../../../../nls.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; /** * Encode a remote address into an identifier that is safe for use in * both URI schemes and URI authorities, and is collision-free. * - * If the address contains only alphanumeric characters it is returned as-is. - * Otherwise it is url-safe base64-encoded (no padding) to guarantee the - * result contains only `[A-Za-z0-9_-]`. + * Three tiers: + * 1. Purely alphanumeric addresses are returned as-is. + * 2. "Normal" addresses containing only `[a-zA-Z0-9.:-]` get colons + * replaced with `__` (double underscore) for human readability. + * Addresses containing `_` skip this tier to keep the encoding + * collision-free (`__` can only appear from colon replacement). + * 3. Everything else is url-safe base64-encoded with a `b64-` prefix. */ export function agentHostAuthority(address: string): string { if (/^[a-zA-Z0-9]+$/.test(address)) { return address; } + if (/^[a-zA-Z0-9.:\-]+$/.test(address)) { + return address.replaceAll(':', '__'); + } return 'b64-' + encodeBase64(VSBuffer.fromString(address), false, true); } +/** + * Given a sanitized URI authority, resolves the corresponding agent host + * session target string by looking up the matching connection. + * + * Returns `undefined` if no connection matches the authority. + */ +export function getRemoteAgentHostSessionTarget( + connections: readonly IRemoteAgentHostConnectionInfo[], + authority: string, +): AgentSessionTarget | undefined { + for (const conn of connections) { + if (agentHostAuthority(conn.address) === authority) { + return `remote-${agentHostAuthority(conn.address)}-copilot`; + } + } + return undefined; +} + /** Per-connection state bundle, disposed when a connection is removed. */ class ConnectionState extends Disposable { readonly store = this._register(new DisposableStore()); @@ -51,9 +80,10 @@ class ConnectionState extends Disposable { constructor( clientId: string, readonly name: string | undefined, + logService: ILogService, ) { super(); - this.clientState = this.store.add(new SessionClientState(clientId)); + this.clientState = this.store.add(new SessionClientState(clientId, logService)); } } @@ -102,8 +132,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc })); // Push auth token whenever the default account or sessions change - this._register(this._defaultAccountService.onDidChangeDefaultAccount(() => this._pushAuthTokenToAll())); - this._register(this._authenticationService.onDidChangeSessions(() => this._pushAuthTokenToAll())); + this._register(this._defaultAccountService.onDidChangeDefaultAccount(() => this._authenticateAllConnections())); + this._register(this._authenticationService.onDidChangeSessions(() => this._authenticateAllConnections())); // Initial setup for already-connected remotes this._reconcileConnections(); @@ -142,7 +172,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc return; } - const connState = new ConnectionState(connection.clientId, name); + const connState = new ConnectionState(connection.clientId, name, this._logService); this._connections.set(address, connState); const store = connState.store; @@ -179,8 +209,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc this._logService.error(`[RemoteAgentHost] Failed to subscribe to root state for ${address}`, err); }); - // Push auth token to this new connection - this._pushAuthToken(connection); + // Authenticate with this new connection + this._authenticateWithConnection(connection); } private _handleRootStateChange(address: string, connection: IAgentConnection, rootState: IRootState): void { @@ -282,6 +312,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc extensionId: 'vscode.remote-agent-host', extensionDisplayName: 'Remote Agent Host', resolveWorkingDirectory, + resolveAuthentication: () => this._resolveAuthenticationInteractively(connection), })); agentStore.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -298,30 +329,93 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc this._logService.info(`[RemoteAgentHost] Registered agent ${agent.provider} from ${address} as ${sessionType}`); } - private _pushAuthTokenToAll(): void { + private _authenticateAllConnections(): void { for (const address of this._connections.keys()) { const connection = this._remoteAgentHostService.getConnection(address); if (connection) { - this._pushAuthToken(connection); + this._authenticateWithConnection(connection); } } } - private async _pushAuthToken(connection: IAgentConnection): Promise { + /** + * Discover auth requirements from the connection's resource metadata + * and authenticate using matching tokens resolved via the standard + * VS Code authentication service (same flow as MCP auth). + */ + private async _authenticateWithConnection(connection: IAgentConnection): Promise { try { - const account = await this._defaultAccountService.getDefaultAccount(); - if (!account) { - return; + const metadata = await connection.getResourceMetadata(); + for (const resource of metadata.resources) { + const resourceUri = URI.parse(resource.resource); + const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`); + await connection.authenticate({ resource: resource.resource, token }); + } else { + this._logService.info(`[RemoteAgentHost] No token resolved for resource: ${resource.resource}`); + } + } + } catch (err) { + this._logService.error('[RemoteAgentHost] Failed to authenticate with connection', err); + } + } + + /** + * Resolve a bearer token for a set of authorization servers using the + * standard VS Code authentication service provider resolution. + */ + private async _resolveTokenForResource(resourceServer: URI, authorizationServers: readonly string[], scopes: readonly string[]): Promise { + for (const server of authorizationServers) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceServer); + if (!providerId) { + this._logService.trace(`[RemoteAgentHost] No auth provider found for server: ${server}`); + continue; } - const sessions = await this._authenticationService.getSessions(account.authenticationProvider.id); - const session = sessions.find(s => s.id === account.sessionId); - if (session) { - await connection.setAuthToken(session.accessToken); + const sessions = await this._authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); + if (sessions.length > 0) { + return sessions[0].accessToken; } - } catch { - // best-effort } + return undefined; + } + + /** + * Interactively prompt the user to authenticate when the server requires it. + * Returns true if authentication succeeded. + */ + private async _resolveAuthenticationInteractively(connection: IAgentConnection): Promise { + try { + const metadata = await connection.getResourceMetadata(); + for (const resource of metadata.resources) { + for (const server of resource.authorization_servers ?? []) { + const serverUri = URI.parse(server); + const resourceUri = URI.parse(resource.resource); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri); + if (!providerId) { + continue; + } + + const scopes = [...(resource.scopes_supported ?? [])]; + const session = await this._authenticationService.createSession(providerId, scopes, { + activateImmediate: true, + authorizationServer: serverUri, + }); + + await connection.authenticate({ + resource: resource.resource, + token: session.accessToken, + }); + this._logService.info(`[RemoteAgentHost] Interactive authentication succeeded for ${resource.resource}`); + return true; + } + } + } catch (err) { + this._logService.error('[RemoteAgentHost] Interactive authentication failed', err); + } + return false; } private _traceIpc(address: string, method: string, data?: unknown): void { @@ -354,3 +448,23 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } registerWorkbenchContribution2(RemoteAgentHostContribution.ID, RemoteAgentHostContribution, WorkbenchPhase.AfterRestored); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + properties: { + [RemoteAgentHostsSettingId]: { + type: 'array', + items: { + type: 'object', + properties: { + address: { type: 'string', description: nls.localize('chat.remoteAgentHosts.address', "The address of the remote agent host (e.g. \"localhost:3000\").") }, + name: { type: 'string', description: nls.localize('chat.remoteAgentHosts.name', "A display name for this remote agent host.") }, + connectionToken: { type: 'string', description: nls.localize('chat.remoteAgentHosts.connectionToken', "An optional connection token for authenticating with the remote agent host.") }, + }, + required: ['address', 'name'], + }, + description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."), + default: [], + tags: ['experimental', 'advanced'], + }, + }, +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts index b4396b54d59..6761d7455c3 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/agentHostFileSystemProvider.test.ts @@ -25,6 +25,13 @@ suite('AgentHostFileSystemProvider - URI helpers', () => { assert.strictEqual(uri.path, '/'); }); + test('agentHostUri normalizes path without leading slash', () => { + const uri = agentHostUri('localhost:8081', 'home/user/project'); + assert.strictEqual(uri.scheme, AGENT_HOST_FS_SCHEME); + assert.strictEqual(uri.authority, 'localhost:8081'); + assert.strictEqual(uri.path, '/home/user/project'); + }); + test('agentHostRemotePath extracts the path component', () => { const uri = URI.from({ scheme: AGENT_HOST_FS_SCHEME, authority: 'host', path: '/some/path' }); assert.strictEqual(agentHostRemotePath(uri), '/some/path'); @@ -45,13 +52,26 @@ suite('AgentHostAuthority - encoding', () => { assert.strictEqual(agentHostAuthority('localhost'), 'localhost'); }); - test('address with special characters is base64-encoded', () => { - const authority = agentHostAuthority('localhost:8081'); - assert.ok(authority.startsWith('b64-')); + test('normal host:port address uses human-readable encoding', () => { + assert.strictEqual(agentHostAuthority('localhost:8081'), 'localhost__8081'); + assert.strictEqual(agentHostAuthority('192.168.1.1:8080'), '192.168.1.1__8080'); + assert.strictEqual(agentHostAuthority('my-host:9090'), 'my-host__9090'); + assert.strictEqual(agentHostAuthority('host.name:80'), 'host.name__80'); + }); + + test('address with underscore falls through to base64', () => { + const authority = agentHostAuthority('host_name:8080'); + assert.ok(authority.startsWith('b64-'), `expected base64 for underscore address, got: ${authority}`); + }); + + test('address with exotic characters is base64-encoded', () => { + assert.ok(agentHostAuthority('user@host:8080').startsWith('b64-')); + assert.ok(agentHostAuthority('host with spaces').startsWith('b64-')); + assert.ok(agentHostAuthority('http://myhost:3000').startsWith('b64-')); }); test('different addresses produce different authorities', () => { - const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80']; + const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80', 'host_name:80', 'user@host:8080']; const results = cases.map(agentHostAuthority); const unique = new Set(results); assert.strictEqual(unique.size, cases.length, 'all authorities must be unique'); diff --git a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts index 58c814528b3..fe6f55d5fa0 100644 --- a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -76,7 +76,7 @@ export class AICustomizationShortcutsWidget extends Disposable { })); headerButton.element.classList.add('customization-link-button', 'sidebar-action-button'); headerButton.element.setAttribute('aria-expanded', String(!isCollapsed)); - headerButton.label = localize('customizations', "CUSTOMIZATIONS"); + headerButton.label = localize('customizations', "Customizations"); const chevronContainer = DOM.append(headerButton.element, $('span.customization-link-counts')); const chevron = DOM.append(chevronContainer, $('.ai-customization-chevron')); diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css index bc08fc25eb5..3221e599174 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -85,6 +85,11 @@ position: relative; } +/* Customizations header label uses heavier weight */ +.ai-customization-toolbar .ai-customization-header .customization-link-button { + font-weight: 500; +} + /* Counts - floating right inside the button */ .ai-customization-toolbar .customization-link-counts { position: absolute; diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index b9f14273091..87dde988060 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -77,18 +77,4 @@ 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/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 1666be70127..4c96289ccb3 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -93,10 +93,5 @@ .agent-sessions-control-container { flex: 1; overflow: hidden; - - /* Override section header padding to align with dot indicators */ - .agent-session-section { - padding-left: 12px; - } } } diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 52bd4417115..c543f12d05f 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -10,11 +10,15 @@ import { localize, localize2 } from '../../../../nls.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { SessionsTitleBarContribution } from './sessionsTitleBarWidget.js'; import { AgenticSessionsViewPane, SessionsViewId } from './sessionsViewPane.js'; import { SessionsManagementService, ISessionsManagementService } from './sessionsManagementService.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { AgentSessionSection, IAgentSessionSection, isAgentSessionSection } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { NewChatViewPane, SessionsViewId as NewChatViewId } from '../../chat/browser/newChatViewPane.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -45,6 +49,38 @@ const agentSessionsViewDescriptor: IViewDescriptor = { Registry.as(ViewContainerExtensions.ViewsRegistry).registerViews([agentSessionsViewDescriptor], agentSessionsViewContainer); -registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.AfterRestored); - registerSingleton(ISessionsManagementService, SessionsManagementService, InstantiationType.Delayed); + +registerAction2(class NewSessionForRepositoryAction extends Action2 { + + constructor() { + super({ + id: 'agentSessionSection.newSession', + title: localize2('newSessionForRepo', "New Session"), + icon: Codicon.newSession, + menu: [{ + id: MenuId.AgentSessionSectionToolbar, + group: 'navigation', + order: 0, + when: ChatContextKeys.agentSessionSection.isEqualTo(AgentSessionSection.Repository), + }] + }); + } + + async run(accessor: ServicesAccessor, context?: IAgentSessionSection): Promise { + if (!context || !isAgentSessionSection(context) || context.sessions.length === 0) { + return; + } + + const sessionsManagementService = accessor.get(ISessionsManagementService); + const viewsService = accessor.get(IViewsService); + + const repositoryUri = sessionsManagementService.getSessionRepositoryUri(context.sessions[0]); + sessionsManagementService.openNewSessionView(); + + const view = await viewsService.openView(NewChatViewId, true); + if (view instanceof NewChatViewPane && repositoryUri) { + view.setProject(repositoryUri); + } + } +}); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts index 9f009fc0858..c2b35c960f7 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsManagementService.ts @@ -20,13 +20,13 @@ import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../../../. 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 { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { INewSession, CopilotCLISession, RemoteNewSession, AgentHostNewSession } 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 '../common/sessionProject.js'; +import { GITHUB_REMOTE_FILE_SCHEME } from '../common/sessionWorkspace.js'; import { IGitHubSessionContext } from '../../github/common/types.js'; import { ResourceSet } from '../../../../base/common/map.js'; @@ -83,11 +83,16 @@ export interface ISessionsManagementService { */ openNewSessionView(): void; + /** + * Returns the repository URI for the given session, if available. + */ + getSessionRepositoryUri(session: IAgentSession): URI | undefined; + /** * Create a pending session object for the given target type. * Local sessions collect options locally; remote sessions notify the extension. */ - createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise; + createNewSessionForTarget(target: AgentSessionTarget, sessionResource: URI, options?: { defaultRepoUri?: URI; agentHost?: boolean }): Promise; /** * Open a new session, apply options, and send the initial request. @@ -260,14 +265,16 @@ export class SessionsManagementService extends Disposable implements ISessionsMa await this.instantiationService.invokeFunction(openSessionDefault, existingSession, openOptions); } - async createNewSessionForTarget(target: AgentSessionProviders, sessionResource: URI, defaultRepoUri?: URI): Promise { + async createNewSessionForTarget(target: AgentSessionTarget, sessionResource: URI, options?: { defaultRepoUri?: URI; agentHost?: boolean }): Promise { if (!this.isNewChatSessionContext.get()) { this.isNewChatSessionContext.set(true); } let newSession: INewSession; if (target === AgentSessionProviders.Background) { - newSession = this.instantiationService.createInstance(LocalNewSession, sessionResource, defaultRepoUri); + newSession = this.instantiationService.createInstance(CopilotCLISession, sessionResource, options?.defaultRepoUri); + } else if (options?.agentHost) { + newSession = new AgentHostNewSession(sessionResource, target); } else { newSession = this.instantiationService.createInstance(RemoteNewSession, sessionResource, target); } @@ -396,12 +403,9 @@ export class SessionsManagementService extends Disposable implements ISessionsMa if (selectedOptions && selectedOptions.size > 0) { const contributedSession = model.contributedChatSession; if (contributedSession) { - const initialSessionOptions = [...selectedOptions.entries()].map( - ([optionId, value]) => ({ optionId, value }) - ); model.setContributedChatSession({ ...contributedSession, - initialSessionOptions, + initialSessionOptions: selectedOptions, }); } } @@ -460,6 +464,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this.isNewChatSessionContext.set(true); } + getSessionRepositoryUri(session: IAgentSession): URI | undefined { + const [repositoryUri] = this.getRepositoryFromMetadata(session); + return repositoryUri; + } + private setActiveSession(session: IAgentSession | INewSession | undefined): void { let activeSessionItem: IActiveSessionItem | undefined; if (session) { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index b8096dff925..774577eeb23 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -19,7 +19,7 @@ import { IMenuService, MenuId, MenuRegistry, SubmenuItemAction } from '../../../ 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 { IMarshalledAgentSessionContext } 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'; @@ -128,10 +128,8 @@ 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 ?? ''}|${changesSummary?.insertions ?? ''}|${changesSummary?.deletions ?? ''}`; + const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}`; // Skip re-render if state hasn't changed if (this._lastRenderState === renderState) { @@ -176,25 +174,6 @@ 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 @@ -363,24 +342,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { 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) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index c691f5a525c..66f102e9d76 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -8,7 +8,7 @@ import * as DOM from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { autorun } from '../../../../base/common/observable.js'; -import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, IContextKey, 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'; @@ -23,8 +23,8 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IHoverService } from '../../../../platform/hover/browser/hover.js'; 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 { AgentSessionsFilter, AgentSessionsGrouping, AgentSessionsSorting } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; +import { AgentSessionProviders, isAgentHostTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.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'; @@ -41,16 +41,21 @@ import { IHostService } from '../../../../workbench/services/host/browser/host.j const $ = DOM.$; export const SessionsViewId = 'agentic.workbench.view.sessionsView'; const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); -const IsGroupedByRepositoryContext = new RawContextKey('sessionsView.isGroupedByRepository', false); +const SessionsViewFilterOptionsSubMenu = new MenuId('AgentSessionsViewFilterOptionsSubMenu'); +const SessionsViewGroupingContext = new RawContextKey('sessionsView.grouping', AgentSessionsGrouping.Repository); +const SessionsViewSortingContext = new RawContextKey('sessionsView.sorting', AgentSessionsSorting.Created); const GROUPING_STORAGE_KEY = 'agentSessions.grouping'; +const SORTING_STORAGE_KEY = 'agentSessions.sorting'; export class AgenticSessionsViewPane extends ViewPane { private viewPaneContainer: HTMLElement | undefined; private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; - private currentGrouping: AgentSessionsGrouping = AgentSessionsGrouping.Date; - private isGroupedByRepoKey: ReturnType | undefined; + private currentGrouping: AgentSessionsGrouping = AgentSessionsGrouping.Repository; + private currentSorting: AgentSessionsSorting = AgentSessionsSorting.Created; + private groupingContextKey: IContextKey | undefined; + private sortingContextKey: IContextKey | undefined; constructor( options: IViewPaneOptions, @@ -71,10 +76,22 @@ export class AgenticSessionsViewPane extends ViewPane { 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; + const storedGrouping = this.storageService.get(GROUPING_STORAGE_KEY, StorageScope.PROFILE); + if (storedGrouping && Object.values(AgentSessionsGrouping).includes(storedGrouping as AgentSessionsGrouping)) { + this.currentGrouping = storedGrouping as AgentSessionsGrouping; } + + // Restore persisted sorting + const storedSorting = this.storageService.get(SORTING_STORAGE_KEY, StorageScope.PROFILE); + if (storedSorting && Object.values(AgentSessionsSorting).includes(storedSorting as AgentSessionsSorting)) { + this.currentSorting = storedSorting as AgentSessionsSorting; + } + + // Ensure context keys reflect restored state immediately + this.groupingContextKey = SessionsViewGroupingContext.bindTo(contextKeyService); + this.groupingContextKey.set(this.currentGrouping); + this.sortingContextKey = SessionsViewSortingContext.bindTo(contextKeyService); + this.sortingContextKey.set(this.currentSorting); } protected override renderBody(parent: HTMLElement): void { @@ -101,17 +118,15 @@ 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) + // Sessions Filter (actions go to the nested filter submenu) const sessionsFilter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, { - filterMenuId: SessionsViewFilterSubMenu, + filterMenuId: SessionsViewFilterOptionsSubMenu, groupResults: () => this.currentGrouping, - allowedProviders: undefined, // TODO: restore to [AgentSessionProviders.Background, AgentSessionProviders.Cloud] + sortResults: () => this.currentSorting, + allowedProviders: [AgentSessionProviders.Background, AgentSessionProviders.Cloud], + overrideExclude: session => isAgentHostTarget(session.providerType) ? false : undefined, providerLabelOverrides: new Map([ - [AgentSessionProviders.Background, localize('chat.session.providerLabel.local', "Local")], + [AgentSessionProviders.Background, localize('chat.session.providerLabel.background', "Copilot CLI")], ]), })); @@ -229,15 +244,26 @@ export class AgenticSessionsViewPane extends ViewPane { this.sessionsControl?.openFind(); } - toggleGroupByRepository(): void { - if (this.currentGrouping === AgentSessionsGrouping.Repository) { - this.currentGrouping = AgentSessionsGrouping.Date; - } else { - this.currentGrouping = AgentSessionsGrouping.Repository; + setGrouping(grouping: AgentSessionsGrouping): void { + if (this.currentGrouping === grouping) { + return; } + this.currentGrouping = grouping; this.storageService.store(GROUPING_STORAGE_KEY, this.currentGrouping, StorageScope.PROFILE, StorageTarget.USER); - this.isGroupedByRepoKey?.set(this.currentGrouping === AgentSessionsGrouping.Repository); + this.groupingContextKey?.set(this.currentGrouping); + this.sessionsControl?.resetSectionCollapseState(); + this.sessionsControl?.update(); + } + + setSorting(sorting: AgentSessionsSorting): void { + if (this.currentSorting === sorting) { + return; + } + + this.currentSorting = sorting; + this.storageService.store(SORTING_STORAGE_KEY, this.currentSorting, StorageScope.PROFILE, StorageTarget.USER); + this.sortingContextKey?.set(this.currentSorting); this.sessionsControl?.update(); } } @@ -281,22 +307,30 @@ MenuRegistry.appendMenuItem(MenuId.ViewTitle, { title: localize2('filterAgentSessions', "Filter Sessions"), group: 'navigation', order: 3, - icon: Codicon.filter, + icon: Codicon.settings, when: ContextKeyExpr.equals('view', SessionsViewId) } satisfies ISubmenuItem); -registerAction2(class GroupByRepositoryAction extends Action2 { +// Nest the filter toggles (providers, statuses, properties, reset) inside a "Filter" submenu +MenuRegistry.appendMenuItem(SessionsViewFilterSubMenu, { + submenu: SessionsViewFilterOptionsSubMenu, + title: localize2('filter', "Filter"), + group: '1_filter', + order: 0, +} satisfies ISubmenuItem); + +// Sort By: Created Date (radio) +registerAction2(class SortByCreatedAction extends Action2 { constructor() { super({ - id: 'sessionsView.groupByRepository', - title: localize2('groupByRepository', "Group by Repository"), - icon: Codicon.repo, + id: 'sessionsView.sortByCreated', + title: localize2('sortByCreated', "Sort by Created"), category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewSortingContext.key, AgentSessionsSorting.Created), menu: [{ - id: MenuId.ViewTitle, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(ContextKeyExpr.equals('view', SessionsViewId), IsGroupedByRepositoryContext.negate()), + id: SessionsViewFilterSubMenu, + group: '2_sort', + order: 0, }] }); } @@ -304,22 +338,22 @@ registerAction2(class GroupByRepositoryAction extends Action2 { override run(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); const view = viewsService.getViewWithId(SessionsViewId); - view?.toggleGroupByRepository(); + view?.setSorting(AgentSessionsSorting.Created); } }); -registerAction2(class GroupByDateAction extends Action2 { +// Sort By: Updated Date (radio) +registerAction2(class SortByUpdatedAction extends Action2 { constructor() { super({ - id: 'sessionsView.groupByDate', - title: localize2('groupByDate', "Group by Date"), - icon: Codicon.history, + id: 'sessionsView.sortByUpdated', + title: localize2('sortByUpdated', "Sort by Updated"), category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewSortingContext.key, AgentSessionsSorting.Updated), menu: [{ - id: MenuId.ViewTitle, - group: 'navigation', + id: SessionsViewFilterSubMenu, + group: '2_sort', order: 1, - when: ContextKeyExpr.and(ContextKeyExpr.equals('view', SessionsViewId), IsGroupedByRepositoryContext), }] }); } @@ -327,7 +361,53 @@ registerAction2(class GroupByDateAction extends Action2 { override run(accessor: ServicesAccessor) { const viewsService = accessor.get(IViewsService); const view = viewsService.getViewWithId(SessionsViewId); - view?.toggleGroupByRepository(); + view?.setSorting(AgentSessionsSorting.Updated); + } +}); + +// Group By: Project (radio) +registerAction2(class GroupByProjectAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.groupByProject', + title: localize2('groupByProject', "Group by Project"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Repository), + menu: [{ + id: SessionsViewFilterSubMenu, + group: '3_group', + order: 0, + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setGrouping(AgentSessionsGrouping.Repository); + } +}); + +// Group By: Time (radio) +registerAction2(class GroupByTimeAction extends Action2 { + constructor() { + super({ + id: 'sessionsView.groupByTime', + title: localize2('groupByTime', "Group by Time"), + category: SessionsCategories.Sessions, + toggled: ContextKeyExpr.equals(SessionsViewGroupingContext.key, AgentSessionsGrouping.Date), + menu: [{ + id: SessionsViewFilterSubMenu, + group: '3_group', + order: 1, + }] + }); + } + + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.setGrouping(AgentSessionsGrouping.Date); } }); diff --git a/src/vs/sessions/contrib/sessions/common/sessionProject.ts b/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts similarity index 50% rename from src/vs/sessions/contrib/sessions/common/sessionProject.ts rename to src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts index 68127e14c5f..aa5a5ba3a44 100644 --- a/src/vs/sessions/contrib/sessions/common/sessionProject.ts +++ b/src/vs/sessions/contrib/sessions/common/sessionWorkspace.ts @@ -9,10 +9,17 @@ import { IGitRepository } from '../../../../workbench/contrib/git/common/gitServ export const GITHUB_REMOTE_FILE_SCHEME = 'github-remote-file'; /** - * Represents a project (folder or repository) for a session. - * The project type (folder vs repo) is derived from the URI scheme. + * URI scheme for agent host remote filesystems. + * Must match {@link AGENT_HOST_FS_SCHEME} in `agentHostFileSystemProvider.ts` + * (which lives in the `browser` layer and cannot be imported here). */ -export class SessionProject { +export const AGENT_HOST_SCHEME = 'agenthost'; + +/** + * Represents a workspace (folder or repository) for a session. + * The workspace type (folder vs repo vs remote agent host) is derived from the URI scheme. + */ +export class SessionWorkspace { readonly uri: URI; readonly repository: IGitRepository | undefined; @@ -22,18 +29,23 @@ export class SessionProject { this.repository = repository; } - /** Whether this is a local folder project. */ + /** Whether this is a local folder workspace. */ get isFolder(): boolean { - return this.uri.scheme !== GITHUB_REMOTE_FILE_SCHEME; + return this.uri.scheme !== GITHUB_REMOTE_FILE_SCHEME && this.uri.scheme !== AGENT_HOST_SCHEME; } - /** Whether this is a remote repository project. */ + /** Whether this is a remote repository workspace. */ get isRepo(): boolean { return this.uri.scheme === GITHUB_REMOTE_FILE_SCHEME; } - /** Returns a new SessionProject with the repository updated. */ - withRepository(repository: IGitRepository | undefined): SessionProject { - return new SessionProject(this.uri, repository); + /** Whether this is a remote agent host workspace. */ + get isRemoteAgentHost(): boolean { + return this.uri.scheme === AGENT_HOST_SCHEME; + } + + /** Returns a new SessionWorkspace with the repository updated. */ + withRepository(repository: IGitRepository | undefined): SessionWorkspace { + return new SessionWorkspace(this.uri, repository); } } diff --git a/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts b/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts new file mode 100644 index 00000000000..8b9883f1b7b --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/common/sessionWorkspace.test.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { AGENT_HOST_SCHEME, GITHUB_REMOTE_FILE_SCHEME, SessionWorkspace } from '../../common/sessionWorkspace.js'; +import type { IGitRepository } from '../../../../../workbench/contrib/git/common/gitService.js'; + +suite('SessionWorkspace', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('local folder is classified as isFolder', () => { + const ws = new SessionWorkspace(URI.file('/home/user/project')); + assert.strictEqual(ws.isFolder, true); + assert.strictEqual(ws.isRepo, false); + assert.strictEqual(ws.isRemoteAgentHost, false); + }); + + test('GitHub repo is classified as isRepo', () => { + const ws = new SessionWorkspace(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, authority: 'github', path: '/owner/repo/HEAD' })); + assert.strictEqual(ws.isFolder, false); + assert.strictEqual(ws.isRepo, true); + assert.strictEqual(ws.isRemoteAgentHost, false); + }); + + test('agent host URI is classified as isRemoteAgentHost', () => { + const ws = new SessionWorkspace(URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'b64-test', path: '/home/user/project' })); + assert.strictEqual(ws.isFolder, false); + assert.strictEqual(ws.isRepo, false); + assert.strictEqual(ws.isRemoteAgentHost, true); + }); + + test('withRepository preserves URI and updates repository', () => { + const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'b64-test', path: '/proj' }); + const ws = new SessionWorkspace(uri); + const repo = { rootUri: URI.file('/repo') } as IGitRepository; + const ws2 = ws.withRepository(repo); + assert.strictEqual(ws2.uri.toString(), uri.toString()); + assert.strictEqual(ws2.isRemoteAgentHost, true); + assert.strictEqual(ws2.repository, repo); + }); +}); diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index c45ce72df32..44668fe89ec 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.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, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; @@ -20,10 +20,13 @@ import { IPathService } from '../../../../workbench/services/path/common/pathSer 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 { ContextKeyExpr, IContextKeyService, RawContextKey } 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'; +import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; + +const SessionsTerminalViewVisibleContext = new RawContextKey('sessionsTerminalViewVisible', false); /** * Returns the cwd URI for the given session: worktree or repository path for @@ -55,9 +58,21 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, @ILogService private readonly _logService: ILogService, @IPathService private readonly _pathService: IPathService, + @IViewsService viewsService: IViewsService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(); + // Track whether the terminal view is visible so the titlebar toggle + // button shows the correct checked state. + const terminalViewVisible = SessionsTerminalViewVisibleContext.bindTo(contextKeyService); + terminalViewVisible.set(viewsService.isViewVisible(TERMINAL_VIEW_ID)); + this._register(viewsService.onDidChangeViewVisibility(e => { + if (e.id === TERMINAL_VIEW_ID) { + terminalViewVisible.set(e.visible); + } + })); + // React to active session changes — use worktree/repo for background sessions, home dir otherwise this._register(autorun(reader => { const session = this._sessionsManagementService.activeSession.read(reader); @@ -71,8 +86,12 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben 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})`); + const availableInstance = this._getAvailableTerminal(instance, `hide restored terminal for ${cwd}`); + if (!availableInstance) { + return; + } + this._terminalService.moveToBackground(availableInstance); + this._logService.trace(`[SessionsTerminal] Hid restored terminal ${availableInstance.instanceId} (cwd: ${cwd})`); } }); } @@ -100,9 +119,13 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben if (existing.length === 0) { try { - existing = [await this._terminalService.createTerminal({ config: { cwd } })]; - this._terminalService.setActiveInstance(existing[0]); - this._logService.trace(`[SessionsTerminal] Created terminal ${existing[0].instanceId} for ${cwd.fsPath}`); + const createdInstance = this._getAvailableTerminal(await this._terminalService.createTerminal({ config: { cwd } }), `activate created terminal for ${cwd.fsPath}`); + if (!createdInstance) { + return []; + } + existing = [createdInstance]; + this._terminalService.setActiveInstance(createdInstance); + this._logService.trace(`[SessionsTerminal] Created terminal ${createdInstance.instanceId} for ${cwd.fsPath}`); } catch (e) { this._logService.trace(`[SessionsTerminal] Cannot create terminal for ${cwd.fsPath}: ${e}`); return []; @@ -159,6 +182,15 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben return result; } + private _getAvailableTerminal(instance: ITerminalInstance, action: string): ITerminalInstance | undefined { + const currentInstance = this._terminalService.getInstanceFromId(instance.instanceId); + if (!currentInstance || currentInstance.isDisposed) { + this._logService.trace(`[SessionsTerminal] Cannot ${action}; terminal ${instance.instanceId} is no longer available`); + return undefined; + } + return currentInstance; + } + /** * Shows background terminals whose initial cwd matches the active key and * hides foreground terminals whose initial cwd does not match. @@ -174,22 +206,32 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben } catch { continue; } + const currentInstance = this._getAvailableTerminal(instance, `update visibility for ${cwd}`); + if (!currentInstance) { + continue; + } - const isForeground = this._terminalService.foregroundInstances.includes(instance); - const isForceVisible = forceForegroundTerminalIds.includes(instance.instanceId); + const isForeground = this._terminalService.foregroundInstances.includes(currentInstance); + const isForceVisible = forceForegroundTerminalIds.includes(currentInstance.instanceId); const belongsToActiveSession = cwd === activeKey; if ((belongsToActiveSession || isForceVisible) && !isForeground) { - toShow.push(instance); + toShow.push(currentInstance); } else if (!belongsToActiveSession && !isForceVisible && isForeground) { - toHide.push(instance); + toHide.push(currentInstance); } } for (const instance of toShow) { - await this._terminalService.showBackgroundTerminal(instance, true); + const availableInstance = this._getAvailableTerminal(instance, 'show background terminal'); + if (availableInstance) { + await this._terminalService.showBackgroundTerminal(availableInstance, true); + } } for (const instance of toHide) { - this._terminalService.moveToBackground(instance); + const availableInstance = this._getAvailableTerminal(instance, 'move terminal to background'); + if (availableInstance) { + this._terminalService.moveToBackground(availableInstance); + } } // Set the terminal with the most recent command as active @@ -215,8 +257,12 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben try { const cwd = (await instance.getInitialCwd()).toLowerCase(); if (cwd === key) { - this._terminalService.safeDisposeTerminal(instance); - this._logService.trace(`[SessionsTerminal] Closed archived terminal ${instance.instanceId}`); + const availableInstance = this._getAvailableTerminal(instance, `close archived terminal for ${fsPath}`); + if (!availableInstance) { + continue; + } + this._terminalService.safeDisposeTerminal(availableInstance); + this._logService.trace(`[SessionsTerminal] Closed archived terminal ${availableInstance.instanceId}`); } } catch { // ignore @@ -254,6 +300,10 @@ class OpenSessionInTerminalAction extends Action2 { id: 'agentSession.openInTerminal', title: localize2('openInTerminal', "Open Terminal"), icon: Codicon.terminal, + toggled: { + condition: SessionsTerminalViewVisibleContext, + title: localize('hideTerminal', "Hide Terminal"), + }, menu: [{ id: Menus.TitleBarSessionMenu, group: 'navigation', @@ -264,10 +314,21 @@ class OpenSessionInTerminalAction extends Action2 { } override async run(_accessor: ServicesAccessor): Promise { + const layoutService = _accessor.get(IWorkbenchLayoutService); + const viewsService = _accessor.get(IViewsService); + + // Toggle: if panel is visible and the terminal view is active, hide it. + // If the panel is visible but showing another view, open the terminal instead. + if (layoutService.isVisible(Parts.PANEL_PART)) { + if (viewsService.isViewVisible(TERMINAL_VIEW_ID)) { + layoutService.setPartHidden(true, Parts.PANEL_PART); + return; + } + } + 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(); 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 1b11f7ce159..927730c8c9d 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Emitter } 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'; @@ -21,9 +21,26 @@ import { IActiveSessionItem, ISessionsManagementService } from '../../../session import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js'; import { TestPathService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { IPathService } from '../../../../../workbench/services/path/common/pathService.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; const HOME_DIR = URI.file('/home/user'); +class TestLogService extends NullLogService { + readonly traces: string[] = []; + + override trace(message: string, ...args: unknown[]): void { + this.traces.push([message, ...args].join(' ')); + } +} + +type TestTerminalInstance = ITerminalInstance & { + _testCommandHistory: { timestamp: number }[]; + _testSetDisposed(disposed: boolean): void; + _testSetShellLaunchConfig(shellLaunchConfig: ITerminalInstance['shellLaunchConfig']): void; +}; + function makeAgentSession(opts: { repository?: URI; worktree?: URI; @@ -54,8 +71,10 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT } as IActiveSessionItem; } -function makeTerminalInstance(id: number, cwd: string): ITerminalInstance & { _testCommandHistory: { timestamp: number }[] } { +function makeTerminalInstance(id: number, cwd: string): TestTerminalInstance { const commandHistory: { timestamp: number }[] = []; + let isDisposed = false; + let shellLaunchConfig: ITerminalInstance['shellLaunchConfig'] = {} as ITerminalInstance['shellLaunchConfig']; const capabilities = { get(cap: TerminalCapability) { if (cap === TerminalCapability.CommandDetection && commandHistory.length > 0) { @@ -67,15 +86,22 @@ function makeTerminalInstance(id: number, cwd: string): ITerminalInstance & { _t return { instanceId: id, - isDisposed: false, + get isDisposed() { return isDisposed; }, + get shellLaunchConfig() { return shellLaunchConfig; }, getInitialCwd: () => Promise.resolve(cwd), capabilities, _testCommandHistory: commandHistory, - } as unknown as ITerminalInstance & { _testCommandHistory: { timestamp: number }[] }; + _testSetDisposed(disposed: boolean) { + isDisposed = disposed; + }, + _testSetShellLaunchConfig(value: ITerminalInstance['shellLaunchConfig']) { + shellLaunchConfig = value; + }, + } as unknown as TestTerminalInstance; } function addCommandToInstance(instance: ITerminalInstance, timestamp: number): void { - (instance as ITerminalInstance & { _testCommandHistory: { timestamp: number }[] })._testCommandHistory.push({ timestamp }); + (instance as TestTerminalInstance)._testCommandHistory.push({ timestamp }); } suite('SessionsTerminalContribution', () => { @@ -83,6 +109,7 @@ suite('SessionsTerminalContribution', () => { let contribution: SessionsTerminalContribution; let activeSessionObs: ReturnType>; let onDidChangeSessionArchivedState: Emitter; + let onDidCreateInstance: Emitter; let createdTerminals: { cwd: URI }[]; let activeInstanceSet: number[]; @@ -93,6 +120,8 @@ suite('SessionsTerminalContribution', () => { let backgroundedInstances: Set; let moveToBackgroundCalls: number[]; let showBackgroundCalls: number[]; + let disposeOnCreatePaths: Set; + let logService: TestLogService; setup(() => { createdTerminals = []; @@ -104,20 +133,23 @@ suite('SessionsTerminalContribution', () => { backgroundedInstances = new Set(); moveToBackgroundCalls = []; showBackgroundCalls = []; + disposeOnCreatePaths = new Set(); + logService = new TestLogService(); const instantiationService = store.add(new TestInstantiationService()); activeSessionObs = observableValue('activeSession', undefined); onDidChangeSessionArchivedState = store.add(new Emitter()); + onDidCreateInstance = store.add(new Emitter()); - instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ILogService, logService); instantiationService.stub(ISessionsManagementService, new class extends mock() { override activeSession = activeSessionObs; }); instantiationService.stub(ITerminalService, new class extends mock() { - override onDidCreateInstance = Event.None; + override onDidCreateInstance = onDidCreateInstance.event; override get instances(): readonly ITerminalInstance[] { return [...terminalInstances.values()]; } @@ -131,6 +163,10 @@ suite('SessionsTerminalContribution', () => { const instance = makeTerminalInstance(id, cwdStr); createdTerminals.push({ cwd: opts?.config?.cwd }); terminalInstances.set(id, instance); + if (disposeOnCreatePaths.has(cwdStr)) { + instance._testSetDisposed(true); + terminalInstances.delete(id); + } return instance; } override getInstanceFromId(id: number): ITerminalInstance | undefined { @@ -144,6 +180,7 @@ suite('SessionsTerminalContribution', () => { } override async safeDisposeTerminal(instance: ITerminalInstance): Promise { disposedInstances.push(instance); + (instance as TestTerminalInstance)._testSetDisposed(true); terminalInstances.delete(instance.instanceId); backgroundedInstances.delete(instance.instanceId); } @@ -165,6 +202,13 @@ suite('SessionsTerminalContribution', () => { instantiationService.stub(IPathService, new TestPathService(HOME_DIR)); + instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService())); + + instantiationService.stub(IViewsService, new class extends mock() { + override isViewVisible(): boolean { return false; } + override onDidChangeViewVisibility = store.add(new Emitter<{ id: string; visible: boolean }>()).event; + }); + contribution = store.add(instantiationService.createInstance(SessionsTerminalContribution)); }); @@ -317,6 +361,17 @@ suite('SessionsTerminalContribution', () => { assert.strictEqual(createdTerminals.length, 1, 'should match case-insensitively'); }); + test('ensureTerminal does not activate a terminal disposed during creation', async () => { + const cwd = URI.file('/test-cwd'); + disposeOnCreatePaths.add(cwd.fsPath); + + const instances = await contribution.ensureTerminal(cwd, false); + + assert.strictEqual(instances.length, 0); + assert.strictEqual(activeInstanceSet.length, 0); + assert.ok(logService.traces.some(message => message.includes(`Cannot activate created terminal for ${cwd.fsPath}; terminal 1 is no longer available`))); + }); + // --- onDidChangeSessionArchivedState --- test('closes terminals when session is archived', async () => { @@ -453,6 +508,28 @@ suite('SessionsTerminalContribution', () => { assert.ok(showBackgroundCalls.includes(existingInstance.instanceId), 'should show the existing terminal'); }); + test('does not background a restored terminal that is disposed before cwd resolves', async () => { + let resolveInitialCwd: ((cwd: string) => void) | undefined; + const restoredInstance = makeTerminalInstance(nextInstanceId++, '/restored'); + restoredInstance._testSetShellLaunchConfig({ attachPersistentProcess: {} as never } as ITerminalInstance['shellLaunchConfig']); + restoredInstance.getInitialCwd = () => new Promise(resolve => { + resolveInitialCwd = resolve; + }); + terminalInstances.set(restoredInstance.instanceId, restoredInstance); + + activeSessionObs.set(makeAgentSession({ worktree: URI.file('/active'), providerType: AgentSessionProviders.Background }), undefined); + await tick(); + + onDidCreateInstance.fire(restoredInstance); + restoredInstance._testSetDisposed(true); + terminalInstances.delete(restoredInstance.instanceId); + resolveInitialCwd?.('/other'); + await tick(); + + assert.ok(!moveToBackgroundCalls.includes(restoredInstance.instanceId), 'disposed restored terminal should not be backgrounded'); + assert.ok(logService.traces.some(message => message.includes('Cannot hide restored terminal for /other; terminal') && message.includes('is no longer available'))); + }); + 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'); diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts index f22ddd122c5..29634287b09 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -90,7 +90,6 @@ class SessionsWelcomeOverlay extends Disposable { dialogTitle: this.chatEntitlementService.anonymous ? localize('sessions.startUsingSessions', "Start using Sessions") : localize('sessions.signinRequired', "Sign in to use Sessions"), - dialogHideSkip: true }); if (success) { @@ -125,7 +124,7 @@ class SessionsWelcomeOverlay extends Disposable { } } -class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { +export class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsWelcome'; @@ -170,31 +169,42 @@ class SessionsWelcomeContribution extends Disposable implements IWorkbenchContri if (this._needsChatSetup()) { this.showOverlay(); } else { - this.watchForRegressions(); + this.watchEntitlementState(); } } - private watchForRegressions(): void { - let wasComplete = !this._needsChatSetup(); + /** + * Watches entitlement and sentiment observables after setup has already + * completed. If the user's state changes such that setup is needed again + * (e.g. extension uninstalled/disabled), shows the welcome overlay. + * + * {@link ChatEntitlement.Unknown} is intentionally ignored here: it is + * almost always a transient state caused by a stale OAuth token being + * refreshed after an update. A genuine sign-out will be caught on the + * next app launch via the initial {@link showOverlayIfNeeded} check. + */ + private watchEntitlementState(): void { + let setupComplete = !this._needsChatSetup(false); this.watcherRef.value = autorun(reader => { this.chatEntitlementService.sentimentObs.read(reader); this.chatEntitlementService.entitlementObs.read(reader); - const needsSetup = this._needsChatSetup(); - if (wasComplete && needsSetup) { + const needsSetup = this._needsChatSetup(false); + if (setupComplete && needsSetup) { this.showOverlay(); } - wasComplete = !needsSetup; + setupComplete = !needsSetup; }); } - private _needsChatSetup(): boolean { + private _needsChatSetup(includeUnknown: boolean = true): boolean { const { sentiment, entitlement } = this.chatEntitlementService; if ( !sentiment?.installed || // Extension not installed: run setup to install sentiment?.disabled || // Extension disabled: run setup to enable entitlement === ChatEntitlement.Available || // Entitlement available: run setup to sign up ( + includeUnknown && entitlement === ChatEntitlement.Unknown && // Entitlement unknown: run setup to sign in / sign up !this.chatEntitlementService.anonymous // unless anonymous access is enabled ) @@ -232,7 +242,7 @@ class SessionsWelcomeContribution extends Disposable implements IWorkbenchContri this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); overlay.dismiss(); this.overlayRef.clear(); - this.watchForRegressions(); + this.watchEntitlementState(); } })); } diff --git a/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts new file mode 100644 index 00000000000..7105288c548 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ISettableObservable, observableValue, transaction } from '../../../../../base/common/observable.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { ChatEntitlement, IChatEntitlementService, IChatSentiment } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { SessionsWelcomeVisibleContext } from '../../../../common/contextkeys.js'; +import { SessionsWelcomeContribution } from '../../browser/welcome.contribution.js'; + +const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; + +class MockChatEntitlementService implements Partial { + + declare readonly _serviceBrand: undefined; + + readonly onDidChangeEntitlement = Event.None; + readonly onDidChangeSentiment = Event.None; + readonly onDidChangeAnonymous = Event.None; + readonly onDidChangeQuotaExceeded = Event.None; + readonly onDidChangeQuotaRemaining = Event.None; + + readonly entitlementObs: ISettableObservable = observableValue('entitlement', ChatEntitlement.Free); + readonly sentimentObs: ISettableObservable = observableValue('sentiment', { installed: true } as IChatSentiment); + readonly anonymousObs: ISettableObservable = observableValue('anonymous', false); + + readonly organisations = undefined; + readonly isInternal = false; + readonly sku = undefined; + readonly copilotTrackingId = undefined; + readonly quotas = {}; + readonly previewFeaturesDisabled = false; + + get entitlement(): ChatEntitlement { return this.entitlementObs.get(); } + get sentiment(): IChatSentiment { return this.sentimentObs.get(); } + get anonymous(): boolean { return this.anonymousObs.get(); } + + update(): Promise { return Promise.resolve(); } + markAnonymousRateLimited(): void { } +} + +suite('SessionsWelcomeContribution', () => { + + const disposables = new DisposableStore(); + let instantiationService: TestInstantiationService; + let mockEntitlementService: MockChatEntitlementService; + + setup(() => { + instantiationService = workbenchInstantiationService(undefined, disposables); + mockEntitlementService = new MockChatEntitlementService(); + instantiationService.stub(IChatEntitlementService, mockEntitlementService as unknown as IChatEntitlementService); + + // Ensure product has a defaultChatAgent so the contribution activates + const productService = instantiationService.get(IProductService); + instantiationService.stub(IProductService, { + ...productService, + defaultChatAgent: { ...productService.defaultChatAgent, chatExtensionId: 'test.chat' } + } as IProductService); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function markReturningUser(): void { + const storageService = instantiationService.get(IStorageService); + storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + function isOverlayVisible(): boolean { + const contextKeyService = instantiationService.get(IContextKeyService); + return SessionsWelcomeVisibleContext.getValue(contextKeyService) === true; + } + + test('first launch shows overlay', () => { + // First launch with no entitlement — should show overlay + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), true); + }); + + test('returning user with valid entitlement does not show overlay', () => { + markReturningUser(); + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false); + }); + + test('returning user: transient Unknown entitlement does NOT show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false, 'should not show initially'); + + // Simulate transient Unknown (stale token → 401) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should NOT show overlay for transient Unknown'); + + // Simulate recovery (token refreshed → entitlement restored) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should remain hidden after recovery'); + }); + + test('returning user: transient Unresolved entitlement does NOT show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Pro, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Simulate Unresolved (intermediate state during account resolution) + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unresolved, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should NOT show overlay for Unresolved'); + }); + + test('returning user: extension uninstalled DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false, 'should not show initially'); + + // Simulate extension being uninstalled + transaction(tx => { + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay when extension is uninstalled'); + }); + + test('returning user: extension disabled DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Simulate extension being disabled + transaction(tx => { + mockEntitlementService.sentimentObs.set({ installed: true, disabled: true } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay when extension is disabled'); + }); + + test('overlay dismisses when setup completes', () => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, undefined); + mockEntitlementService.sentimentObs.set({ installed: false } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), true, 'should show on first launch'); + + // Simulate completing setup + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, tx); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, tx); + }); + + assert.strictEqual(isOverlayVisible(), false, 'should dismiss after setup completes'); + }); + + test('returning user: entitlement going to Available DOES show overlay', () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, undefined); + mockEntitlementService.sentimentObs.set({ installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + + // Available means user can sign up for free — this is a real state, + // not transient, so the overlay should show + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Available, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay for Available entitlement'); + }); +}); diff --git a/src/vs/sessions/electron-browser/parts/titlebarPart.ts b/src/vs/sessions/electron-browser/parts/titlebarPart.ts index b72a879cb7d..5c2692372ff 100644 --- a/src/vs/sessions/electron-browser/parts/titlebarPart.ts +++ b/src/vs/sessions/electron-browser/parts/titlebarPart.ts @@ -10,6 +10,7 @@ import { IContextKeyService } from '../../../platform/contextkey/common/contextk import { IContextMenuService } from '../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; +import { IProductService } from '../../../platform/product/common/productService.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { useWindowControlsOverlay } from '../../../platform/window/common/window.js'; @@ -20,6 +21,7 @@ import { IAuxiliaryTitlebarPart } from '../../../workbench/browser/parts/titleba import { IEditorGroupsContainer } from '../../../workbench/services/editor/common/editorGroupsService.js'; import { CodeWindow, mainWindow } from '../../../base/browser/window.js'; import { TitlebarPart, TitleService } from '../../browser/parts/titlebarPart.js'; +import { isMacintosh } from '../../../base/common/platform.js'; export class NativeTitlebarPart extends TitlebarPart { @@ -37,6 +39,7 @@ export class NativeTitlebarPart extends TitlebarPart { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService private readonly productService: IProductService, @INativeHostService private readonly nativeHostService: INativeHostService, ) { super(id, targetWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService); @@ -44,6 +47,24 @@ export class NativeTitlebarPart extends TitlebarPart { this.handleWindowsAlwaysOnTop(targetWindow.vscodeWindowId, contextKeyService); } + protected override createContentArea(parent: HTMLElement): HTMLElement { + + // Workaround for macOS/Electron bug where the window does not + // appear in the "Windows" menu if the first `document.title` + // matches the BrowserWindow's initial title. + // See: https://github.com/microsoft/vscode/issues/191288 + if (isMacintosh) { + const window = getWindow(this.element); + const nativeTitle = this.productService.nameLong; + if (!window.document.title || window.document.title === nativeTitle) { + window.document.title = `${nativeTitle} \u200b`; + } + window.document.title = nativeTitle; + } + + return super.createContentArea(parent); + } + private async handleWindowsAlwaysOnTop(targetWindowId: number, contextKeyService: IContextKeyService): Promise { const isWindowAlwaysOnTopContext = IsWindowAlwaysOnTopContext.bindTo(contextKeyService); @@ -107,9 +128,10 @@ class MainNativeTitlebarPart extends NativeTitlebarPart { @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService productService: IProductService, @INativeHostService nativeHostService: INativeHostService, ) { - super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService); + super(Parts.TITLEBAR_PART, mainWindow, contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, productService, nativeHostService); } } @@ -130,10 +152,11 @@ class AuxiliaryNativeTitlebarPart extends NativeTitlebarPart implements IAuxilia @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @IContextKeyService contextKeyService: IContextKeyService, @IHostService hostService: IHostService, + @IProductService productService: IProductService, @INativeHostService nativeHostService: INativeHostService, ) { const id = AuxiliaryNativeTitlebarPart.COUNTER++; - super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, nativeHostService); + super(`workbench.parts.auxiliaryTitle.${id}`, getWindow(container), contextMenuService, configurationService, instantiationService, themeService, storageService, layoutService, contextKeyService, hostService, productService, nativeHostService); } override get preventZoom(): boolean { diff --git a/src/vs/sessions/prompts/create-draft-pr.prompt.md b/src/vs/sessions/prompts/create-draft-pr.prompt.md index 4def295b9fc..c2529a264d4 100644 --- a/src/vs/sessions/prompts/create-draft-pr.prompt.md +++ b/src/vs/sessions/prompts/create-draft-pr.prompt.md @@ -5,7 +5,9 @@ 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 +1. Run the compile and hygiene tasks (fixing any errors) +2. If there are any uncommitted changes, use the `/commit` skill to commit them +3. Review all changes in the current session +4. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") +5. Write a description covering what changed, why, and anything reviewers should know +6. 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 index 02208021e3a..4991f4ff582 100644 --- a/src/vs/sessions/prompts/create-pr.prompt.md +++ b/src/vs/sessions/prompts/create-pr.prompt.md @@ -6,7 +6,8 @@ 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. Run the compile and hygiene tasks (fixing any errors) -2. Review all changes in the current session -3. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") -4. Write a description covering what changed, why, and anything reviewers should know -5. Create the pull request +2. If there are any uncommitted changes, use the `/commit` skill to commit them +3. Review all changes in the current session +4. Write a clear, concise PR title with a short area prefix (e.g. "sessions: …", "editor: …") +5. Write a description covering what changed, why, and anything reviewers should know +6. Create the pull request diff --git a/src/vs/sessions/prompts/merge-changes.prompt.md b/src/vs/sessions/prompts/merge-changes.prompt.md new file mode 100644 index 00000000000..065cb18ad18 --- /dev/null +++ b/src/vs/sessions/prompts/merge-changes.prompt.md @@ -0,0 +1,10 @@ +--- +description: Merge changes from the topic branch to the merge base branch +--- + + +Merge changes from the topic branch to the merge base branch. +The context block appended to the prompt contains the source and target branch information. + +1. If there are any uncommitted changes, use the `/commit` skill to commit them +2. Merge the topic branch into the merge base branch. If there are any merge conflicts, resolve them and commit the merge. When in doubt on how to resolve a merge conflict, ask the user for guidance on how to proceed diff --git a/src/vs/sessions/prompts/update-pr.prompt.md b/src/vs/sessions/prompts/update-pr.prompt.md new file mode 100644 index 00000000000..22ecf6ccf52 --- /dev/null +++ b/src/vs/sessions/prompts/update-pr.prompt.md @@ -0,0 +1,13 @@ +--- +description: Update the pull request for the current session +--- + + +Update the existing pull request for the current session. +The context block appended to the prompt contains the pull request information. + +1. Check whether the pull request has any commits that are not yet present on the current branch (incoming changes). If there are any incoming changes, pull them into the current branch and resolve any merge conflicts +2. Run the compile and hygiene tasks (fixing any errors) +3. If there are any uncommitted changes, use the `/commit` skill to commit them +4. If the outgoing changes introduce significant changes to the pull request, update the pull request title and description to reflect those changes +5. Update the pull request with the new commits and information diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index e4be5c11daf..0202df84fa9 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -212,6 +212,7 @@ import '../workbench/contrib/chat/browser/chat.contribution.js'; import '../workbench/contrib/mcp/browser/mcp.contribution.js'; import '../workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import '../workbench/contrib/chat/browser/contextContrib/chatContext.contribution.js'; +import '../workbench/contrib/imageCarousel/browser/imageCarousel.contribution.js'; // Interactive import '../workbench/contrib/interactive/browser/interactive.contribution.js'; diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index fbcad6fa0ca..d0e992b60a8 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -202,13 +202,13 @@ 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/chatSessionHeader.js'; import './contrib/chat/browser/customizationsDebugLog.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.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/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 @@ -216,10 +216,12 @@ import './contrib/configuration/browser/configuration.contribution.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; import './contrib/logs/browser/logs.contribution.js'; +import './contrib/chatDebug/browser/chatDebug.contribution.js'; import './contrib/workspace/browser/workspace.contribution.js'; import './contrib/welcome/browser/welcome.contribution.js'; // Remote Agent Host +import '../platform/agentHost/electron-browser/agentHostService.js'; import '../platform/agentHost/electron-browser/remoteAgentHostService.js'; import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index f5223d4a29d..68d2ac1ea83 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -143,6 +143,7 @@ import '../workbench/contrib/issue/browser/issue.contribution.js'; import '../workbench/contrib/splash/browser/splash.contribution.js'; import '../workbench/contrib/remote/browser/remoteStartEntry.contribution.js'; import '../workbench/contrib/processExplorer/browser/processExplorer.web.contribution.js'; +import '../workbench/contrib/browserView/browser/browserView.contribution.js'; //#endregion @@ -156,6 +157,7 @@ import './contrib/accountMenu/browser/account.contribution.js'; import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js'; import './contrib/chat/browser/chat.contribution.js'; +import './contrib/chat/browser/chatSessionHeader.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js'; diff --git a/src/vs/sessions/skills/commit/SKILL.md b/src/vs/sessions/skills/commit/SKILL.md new file mode 100644 index 00000000000..2bd73ac44c9 --- /dev/null +++ b/src/vs/sessions/skills/commit/SKILL.md @@ -0,0 +1,80 @@ +--- +name: commit +description: Commit staged or unstaged changes with an AI-generated commit message that matches the repository's existing commit style. Use when the user asks to 'commit', 'commit changes', 'create a commit', 'save my work', or 'check in code'. +--- + + +# Commit Changes + +Help the user commit code changes with a well-crafted commit message derived from the diff, following the conventions already established in the repository. + +## Guidelines + +- **Never amend existing commits** without asking. +- **Never force-push or push** without explicit user approval. +- **Never skip pre-commit hooks** (do not use `--no-verify`). +- **Never skip signing commits** (do not use `--no-gpg-sign`). +- **Never revert, reset, or discard user changes** unless the user explicitly asked for that. +- Check for obvious secrets or generated artifacts that should not be committed. If something looks risky - ask the user. +- When in doubt about staging, convention, or message content — ask the user. + +## Workflow + +### 1. Discover the repository's commit convention + +Run the following to sample recent commits and the user's own commits: + +``` +# Recent repo commits (for overall style) +git log --oneline -20 + +# User's recent commits (for personal style) +git log --oneline --author="$(git config user.name)" -10 +``` + +Analyse the output to determine the commit message convention used in the repository (e.g. Conventional Commits, Gitmoji, ticket-prefixed, free-form). All generated messages **must** follow the detected convention. + +### 2. Check repository status + +``` +git status --short +``` + +- If there are **no changes** (working tree clean, nothing staged), inform the user and stop. +- If there are **staged changes**, proceed with those and do not stage any unstaged changes. +- If there are **only unstaged changes**, stage everything (`git add -A`), and proceed with those. + +### 3. Generate the commit message + +Obtain the full diff of what will be committed: + +```bash +git diff --cached --stat +git diff --cached +``` + +Using the diff and the commit convention detected in step 1, draft a commit message with: + +- A **subject line** (≤ 72 characters) that summarises the change, following the repository's convention. +- An optional **body** that explains *why* the change was made, only when the diff is non-trivial. +- Reference issue/ticket numbers when they appear in branch names or related context. +- Focus on the intent of the change, not a file-by-file inventory. + +### 4. Commit + +Construct the `git commit` command with the generated message. + +Execute the commit: + +``` +git commit -m "" -m "" +``` + +### 5. Confirm + +After the commit: + +- Run `git status --short` to confirm the commit completed. +- Run `git log --oneline -1` to show the new commit. +- If pre-commit hooks changed files or blocked the commit, summarize exactly what happened. +- If hooks rewrote files after the commit attempt, do not amend automatically. Tell the user what changed and ask whether they want you to stage and commit those follow-up edits. diff --git a/src/vs/sessions/test/e2e/scenarios/02-chat-with-changes.scenario.md b/src/vs/sessions/test/e2e/scenarios/02-chat-with-changes.scenario.md index 3e25a5c3399..efe11056b87 100644 --- a/src/vs/sessions/test/e2e/scenarios/02-chat-with-changes.scenario.md +++ b/src/vs/sessions/test/e2e/scenarios/02-chat-with-changes.scenario.md @@ -4,8 +4,7 @@ 1. Type "build the project" in the chat input 2. Press Enter to submit 3. Verify there is a response in the chat -4. Toggle the secondary side bar -5. Verify the changes view shows modified files -6. Click on "index.ts" in the changes list -7. Verify a diff editor opens with the modified content -8. Press Escape to close the diff editor +4. Verify the changes view shows modified files +5. Click on "index.ts" in the changes list +6. Verify a diff editor opens with the modified content +7. Press Escape to close the diff editor diff --git a/src/vs/sessions/test/e2e/scenarios/03-session-in-sidebar.scenario.md b/src/vs/sessions/test/e2e/scenarios/03-session-in-sidebar.scenario.md index aededc6ae0b..a69c0ad39a0 100644 --- a/src/vs/sessions/test/e2e/scenarios/03-session-in-sidebar.scenario.md +++ b/src/vs/sessions/test/e2e/scenarios/03-session-in-sidebar.scenario.md @@ -4,6 +4,5 @@ 1. Type "fix the bug" in the chat input 2. Press Enter to submit 3. Verify the session appears in the sessions list -4. Toggle the secondary side bar -5. Verify the changes view shows the modified file +4. Verify the changes view shows the modified file diff --git a/src/vs/sessions/test/e2e/scenarios/04-local-cloud-toggle.scenario.md b/src/vs/sessions/test/e2e/scenarios/04-local-cloud-toggle.scenario.md deleted file mode 100644 index a45b41ea663..00000000000 --- a/src/vs/sessions/test/e2e/scenarios/04-local-cloud-toggle.scenario.md +++ /dev/null @@ -1,9 +0,0 @@ -# Scenario: Switch between Local and Cloud mode - -## Steps -1. Click button "Cloud" -2. Write "hello world" and hit enter -3. Make sure "Pick Repository" picker opens -4. Press escape key -5. Click on button "Local" -6. Clear chat diff --git a/src/vs/sessions/test/e2e/scenarios/05-navigate-sessions.scenario.md b/src/vs/sessions/test/e2e/scenarios/04-navigate-sessions.scenario.md similarity index 100% rename from src/vs/sessions/test/e2e/scenarios/05-navigate-sessions.scenario.md rename to src/vs/sessions/test/e2e/scenarios/04-navigate-sessions.scenario.md diff --git a/src/vs/sessions/test/e2e/scenarios/05-full-workflow.scenario.md b/src/vs/sessions/test/e2e/scenarios/05-full-workflow.scenario.md new file mode 100644 index 00000000000..5192a847cee --- /dev/null +++ b/src/vs/sessions/test/e2e/scenarios/05-full-workflow.scenario.md @@ -0,0 +1,24 @@ +# Scenario: Full workflow + +## Steps +1. Type "build the project" in the chat input +2. Press Enter to submit +3. Verify there is a response in the chat +4. Verify the changes view shows "CHANGES" +5. Verify "package.json" is visible in the changes list +6. Verify "build.ts" is visible in the changes list +7. Verify "index.ts" is visible in the changes list +8. Click on "index.ts" in the changes list +9. Verify a diff editor opens with modified content +10. Press Escape to close the diff editor +11. Verify "Merge" button is visible in changes view header +12. Verify the "Open Terminal" button is visible +13. Click the "Open Terminal" button +14. Verify the terminal panel becomes visible +15. Verify the terminal tab shows "session-1" in its label +16. Click "New Session" to create a new session +17. Type "fix the bug" in the chat input +18. Press Enter to submit +19. Verify the terminal tab label changes to show "session-2" +20. Click back on the first session in the sessions list +21. Verify the terminal tab label changes back to show "session-1" diff --git a/src/vs/sessions/test/e2e/scenarios/06-full-workflow.scenario.md b/src/vs/sessions/test/e2e/scenarios/06-full-workflow.scenario.md deleted file mode 100644 index 321446d2af2..00000000000 --- a/src/vs/sessions/test/e2e/scenarios/06-full-workflow.scenario.md +++ /dev/null @@ -1,25 +0,0 @@ -# Scenario: Full workflow - -## Steps -1. Type "build the project" in the chat input -2. Press Enter to submit -3. Verify there is a response in the chat -4. Toggle the secondary side bar -5. Verify the changes view shows "CHANGES" with a badge -6. Verify "package.json" is visible in the changes list -7. Verify "build.ts" is visible in the changes list -8. Verify "index.ts" is visible in the changes list -9. Click on "index.ts" in the changes list -10. Verify a diff editor opens with modified content -11. Press Escape to close the diff editor -12. Verify "Merge" button is visible in changes view header -13. Verify the "Open Terminal" button is visible -14. Click the "Open Terminal" button -15. Verify the terminal panel becomes visible -16. Verify the terminal tab shows "session-1" in its label -17. Click "New Session" to create a new session -18. Type "fix the bug" in the chat input -19. Press Enter to submit -20. Verify the terminal tab label changes to show "session-2" -21. Click back on the first session in the sessions list -22. Verify the terminal tab label changes back to show "session-1" diff --git a/src/vs/sessions/test/e2e/scenarios/generated/02-chat-with-changes.commands.json b/src/vs/sessions/test/e2e/scenarios/generated/02-chat-with-changes.commands.json index a28fabe6689..766adbd709d 100644 --- a/src/vs/sessions/test/e2e/scenarios/generated/02-chat-with-changes.commands.json +++ b/src/vs/sessions/test/e2e/scenarios/generated/02-chat-with-changes.commands.json @@ -23,12 +23,6 @@ "# ASSERT_VISIBLE: I'll help you build the project. Here are the changes:" ] }, - { - "description": "Toggle the secondary side bar", - "commands": [ - "click button \"Toggle Secondary Side Bar\"" - ] - }, { "description": "Verify the changes view shows modified files", "commands": [ diff --git a/src/vs/sessions/test/e2e/scenarios/generated/03-session-in-sidebar.commands.json b/src/vs/sessions/test/e2e/scenarios/generated/03-session-in-sidebar.commands.json index 0013dbc8921..994ad5868d7 100644 --- a/src/vs/sessions/test/e2e/scenarios/generated/03-session-in-sidebar.commands.json +++ b/src/vs/sessions/test/e2e/scenarios/generated/03-session-in-sidebar.commands.json @@ -23,12 +23,6 @@ "# ASSERT_VISIBLE: fix the bug" ] }, - { - "description": "Toggle the secondary side bar", - "commands": [ - "click button \"Toggle Secondary Side Bar\"" - ] - }, { "description": "Verify the changes view shows the modified file", "commands": [ diff --git a/src/vs/sessions/test/e2e/scenarios/generated/04-local-cloud-toggle.commands.json b/src/vs/sessions/test/e2e/scenarios/generated/04-local-cloud-toggle.commands.json deleted file mode 100644 index dd3d499af9d..00000000000 --- a/src/vs/sessions/test/e2e/scenarios/generated/04-local-cloud-toggle.commands.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "scenario": "Scenario: Switch between Local and Cloud mode", - "generatedAt": "2026-03-06T04:35:09.018Z", - "steps": [ - { - "description": "Click button \"Cloud\"", - "commands": [ - "click button \"Cloud\"" - ] - }, - { - "description": "Write \"hello world\" and hit enter", - "commands": [ - "click textbox \"Chat input\"", - "type \"hello world\"", - "press Enter" - ] - }, - { - "description": "Make sure \"Pick Repository\" picker opens", - "commands": [ - "# ASSERT_VISIBLE: Pick Repository", - "click listbox \"Repository Picker\"" - ] - }, - { - "description": "Press escape key", - "commands": [ - "press Escape" - ] - }, - { - "description": "Click on button \"Local\"", - "commands": [ - "click button \"Local\"" - ] - }, - { - "description": "Clear chat", - "commands": [ - "click textbox \"Chat input\"", - "press Meta+A", - "press Backspace" - ] - } - ] -} diff --git a/src/vs/sessions/test/e2e/scenarios/generated/05-navigate-sessions.commands.json b/src/vs/sessions/test/e2e/scenarios/generated/04-navigate-sessions.commands.json similarity index 100% rename from src/vs/sessions/test/e2e/scenarios/generated/05-navigate-sessions.commands.json rename to src/vs/sessions/test/e2e/scenarios/generated/04-navigate-sessions.commands.json diff --git a/src/vs/sessions/test/e2e/scenarios/generated/06-full-workflow.commands.json b/src/vs/sessions/test/e2e/scenarios/generated/05-full-workflow.commands.json similarity index 93% rename from src/vs/sessions/test/e2e/scenarios/generated/06-full-workflow.commands.json rename to src/vs/sessions/test/e2e/scenarios/generated/05-full-workflow.commands.json index 5124b5d6d2d..150294bf940 100644 --- a/src/vs/sessions/test/e2e/scenarios/generated/06-full-workflow.commands.json +++ b/src/vs/sessions/test/e2e/scenarios/generated/05-full-workflow.commands.json @@ -21,16 +21,10 @@ "# ASSERT_VISIBLE: I'll help you build the project. Here are the changes:" ] }, - { - "description": "Toggle the secondary side bar", - "commands": [ - "click button \"Toggle Secondary Side Bar Visibility\"" - ] - }, { "description": "Verify the changes view shows \"CHANGES\" with a badge", "commands": [ - "# ASSERT_VISIBLE: Changes - 3 files changed" + "# ASSERT_VISIBLE: Changes" ] }, { diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index 760fd0000a0..d76593eb2d2 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -31,6 +31,11 @@ import { IProcessEnvironment } from '../../base/common/platform.js'; import { Registry } from '../../platform/registry/common/platform.js'; import { InMemoryFileSystemProvider } from '../../platform/files/common/inMemoryFilesystemProvider.js'; import { VSBuffer } from '../../base/common/buffer.js'; +import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; +import { getSingletonServiceDescriptors } from '../../platform/instantiation/common/extensions.js'; +import { ServiceIdentifier } from '../../platform/instantiation/common/instantiation.js'; +import { IWorkbench } from '../../workbench/browser/web.api.js'; +import { isEqual } from '../../base/common/resources.js'; /** * Mock files pre-seeded in the in-memory file system. These match the @@ -279,14 +284,14 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu })); // Add or update session in list - const existing = this._sessionItems.find(s => s.resource.toString() === key); - let addedOrUpdated: IChatSessionItem | undefined = existing; - if (existing) { - existing.timing.lastRequestStarted = now; - existing.timing.lastRequestEnded = now; + const existingIndex = this._sessionItems.findIndex(s => isEqual(s.resource, resource)); + let addedOrUpdated = existingIndex !== -1 ? { ...this._sessionItems[existingIndex] } : undefined; + if (addedOrUpdated) { + addedOrUpdated.timing = { ...addedOrUpdated.timing, lastRequestStarted: now, lastRequestEnded: now }; if (changes) { - existing.changes = changes; + addedOrUpdated.changes = changes; } + this._sessionItems[existingIndex] = addedOrUpdated; } else { addedOrUpdated = { resource, @@ -342,7 +347,7 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu // ChatEditingService computes actual diffs if (response.fileEdits) { emitFileEdits(response.fileEdits, progress); - console.log(`[Sessions Web Test] Emitted ${response.fileEdits.length} file edits`); + console.log(`[Sessions Web Test] Emitted ${response.fileEdits.length} file edits OK`); } self.addSessionItem(request.sessionResource, request.message, response.text, response.fileEdits); @@ -367,7 +372,12 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu this._register(this.chatSessionsService.registerChatSessionContentProvider(scheme, { async provideChatSessionContent(sessionResource, _token) { const key = sessionResource.toString(); - const history = self._sessionHistory.get(key) ?? []; + // Ensure the history array is stored in _sessionHistory so + // addSessionItem pushes into the SAME reference returned here. + if (!self._sessionHistory.has(key)) { + self._sessionHistory.set(key, []); + } + const history = self._sessionHistory.get(key)!; console.log(`[Sessions Web Test] Opening session ${key} (${history.length} history items)`); const disposeEmitter = new Emitter(); const isComplete = observableValue('isComplete', history.length > 0); @@ -424,6 +434,9 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu return { remoteAuthority: undefined, isVirtualProcess: false, + isResponsive: true, + whenReady: Promise.resolve(), + setReady: () => { }, onDidRequestDetach: Event.None, attachToProcess: async () => { throw new Error('Not supported'); }, attachToRevivedProcess: async () => { throw new Error('Not supported'); }, @@ -481,7 +494,14 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu }, getWslPath: async (original: string, _direction: 'unix-to-win' | 'win-to-unix') => original, getEnvironment: async () => ({}), + getLatency: async () => [], getPerformanceMarks: () => [], + updateTitle: async () => { }, + updateIcon: async () => { }, + setNextCommandId: async () => { }, + restartPtyHost: () => { }, + installAutoReply: async () => { }, + uninstallAllAutoReplies: async () => { }, onPtyHostUnresponsive: Event.None, onPtyHostResponsive: Event.None, onPtyHostRestart: Event.None, @@ -516,28 +536,53 @@ class MockGitService implements IGitService { /** * Test variant of SessionsBrowserMain that injects mock services - * for E2E testing. Service overrides for entitlements and auth are set - * in createWorkbench(). The mock chat agent is registered via a - * workbench contribution (MockChatAgentContribution above). + * for E2E testing. Mock singletons are patched into the global + * singleton registry before `super.open()` so they take effect + * during both `BrowserMain.initServices()` and `Workbench.initServices()`. + * Original descriptors are restored when the workbench shuts down. */ export class TestSessionsBrowserMain extends SessionsBrowserMain { - protected override createWorkbench(domElement: HTMLElement, serviceCollection: ServiceCollection, logService: ILogService): IBrowserMainWorkbench { - console.log('[Sessions Web Test] Injecting mock services'); + private _savedDescriptors: [ServiceIdentifier, SyncDescriptor][] = []; - // Register mock-fs:// provider FIRST so all services can resolve workspace files + override async open(): Promise { + // Patch the global singleton registry BEFORE super.open() calls initServices(). + // getSingletonServiceDescriptors() returns the mutable internal array, so + // replacing entries here ensures both BrowserMain and Workbench pick up mocks. + const registry = getSingletonServiceDescriptors(); + const overrides: [ServiceIdentifier, SyncDescriptor][] = [ + [IChatEntitlementService, new SyncDescriptor(MockChatEntitlementService)], + [IDefaultAccountService, new SyncDescriptor(MockDefaultAccountService)], + [IGitService, new SyncDescriptor(MockGitService)], + ]; + for (const [serviceId, mockDescriptor] of overrides) { + const idx = registry.findIndex(([id]) => id === serviceId); + if (idx !== -1) { + this._savedDescriptors.push([serviceId, registry[idx][1]]); + registry[idx] = [serviceId, mockDescriptor]; + } else { + registry.push([serviceId, mockDescriptor]); + } + } + + const workbench = await super.open(); + + // Restore original descriptors now that the workbench has started, + // so subsequent tests in the same process are not affected. + for (const [serviceId, original] of this._savedDescriptors) { + const idx = registry.findIndex(([id]) => id === serviceId); + if (idx !== -1) { + registry[idx] = [serviceId, original]; + } + } + + return workbench; + } + + protected override createWorkbench(domElement: HTMLElement, serviceCollection: ServiceCollection, logService: ILogService): IBrowserMainWorkbench { + // Register mock-fs:// provider so all services can resolve workspace files registerMockFileSystemProvider(serviceCollection); - // Override entitlement service so Sessions thinks user is signed in - serviceCollection.set(IChatEntitlementService, new MockChatEntitlementService()); - - // Override default account service to hide the "Sign In" button - serviceCollection.set(IDefaultAccountService, new MockDefaultAccountService()); - - // Override git service so openRepository resolves instantly (no 10s barrier wait) - serviceCollection.set(IGitService, new MockGitService()); - - console.log('[Sessions Web Test] Creating Sessions workbench with mocks'); return new SessionsWorkbench(domElement, undefined, serviceCollection, logService); } } diff --git a/src/vs/workbench/api/browser/mainThreadBrowsers.ts b/src/vs/workbench/api/browser/mainThreadBrowsers.ts index aad0abf2054..2c0e0b7875b 100644 --- a/src/vs/workbench/api/browser/mainThreadBrowsers.ts +++ b/src/vs/workbench/api/browser/mainThreadBrowsers.ts @@ -41,11 +41,6 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers this._track(e.editor); } })); - this._register(this.editorService.onDidCloseEditor(e => { - if (e.editor instanceof BrowserEditorInput) { - this._knownBrowsers.deleteAndDispose(e.editor.id); - } - })); this._register(this.editorService.onDidActiveEditorChange(() => this._syncActiveBrowserTab())); // Initial sync @@ -82,12 +77,17 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers // #region Browser tab tracking + private _lastActiveBrowserId: string | undefined = undefined; private async _syncActiveBrowserTab(): Promise { const active = this.editorService.activeEditorPane?.input; + let activeId: string | undefined; if (active instanceof BrowserEditorInput) { - this._proxy.$onDidChangeActiveBrowserTab(this._toDto(active)); - } else { - this._proxy.$onDidChangeActiveBrowserTab(undefined); + this._track(active); + activeId = active.id; + } + if (this._lastActiveBrowserId !== activeId) { + this._lastActiveBrowserId = activeId; + this._proxy.$onDidChangeActiveBrowserTab(activeId); } } @@ -99,12 +99,14 @@ export class MainThreadBrowsers extends Disposable implements MainThreadBrowsers // Track property changes. Currently all the tracked properties are covered under the `onDidChangeLabel` event. disposables.add(input.onDidChangeLabel(() => { - this._proxy.$onDidChangeBrowserTabState(input.id, this._toDto(input)); + this._proxy.$onDidChangeBrowserTabState(this._toDto(input)); })); disposables.add(input.onWillDispose(() => { - this._proxy.$onDidCloseBrowserTab(input.id); this._knownBrowsers.deleteAndDispose(input.id); })); + disposables.add(toDisposable(() => { + this._proxy.$onDidCloseBrowserTab(input.id); + })); this._knownBrowsers.set(input.id, { input, dispose: () => disposables.dispose() }); this._proxy.$onDidOpenBrowserTab(this._toDto(input)); diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index f40d952f332..8ac37889384 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -34,7 +34,7 @@ import { ChatRequestAgentPart } from '../../contrib/chat/common/requestParser/ch 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 { ChatSessionOptionsMap, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../contrib/chat/common/constants.js'; import { ILanguageModelToolsService } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; @@ -253,10 +253,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA chatSessionContext = { chatSessionResource, isUntitled, - initialSessionOptions: contributedSession.initialSessionOptions?.map(o => ({ - optionId: o.optionId, - value: typeof o.value === 'string' ? o.value : o.value.id, - })), + initialSessionOptions: ChatSessionOptionsMap.toStrValueArray(contributedSession.initialSessionOptions), }; } return await this._proxy.$invokeAgent(handle, request, { diff --git a/src/vs/workbench/api/browser/mainThreadChatDebug.ts b/src/vs/workbench/api/browser/mainThreadChatDebug.ts index 8daa3270c4a..c28ac56be4d 100644 --- a/src/vs/workbench/api/browser/mainThreadChatDebug.ts +++ b/src/vs/workbench/api/browser/mainThreadChatDebug.ts @@ -6,10 +6,10 @@ import { Disposable, DisposableStore, MutableDisposable } 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 { ChatDebugHookResult, ChatDebugLogLevel, IChatDebugEvent, IChatDebugResolvedEventContent, 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 { ExtHostChatDebugShape, ExtHostContext, IChatDebugEventDto, IChatDebugResolvedEventContentDto, MainContext, MainThreadChatDebugShape } from '../common/extHost.protocol.js'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; @extHostNamedCustomer(MainContext.MainThreadChatDebug) @@ -51,7 +51,8 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb return dtos?.map(dto => this._reviveEvent(dto, sessionResource)); }, resolveChatDebugLogEvent: async (eventId, token) => { - return this._proxy.$resolveChatDebugLogEvent(handle, eventId, token); + const dto = await this._proxy.$resolveChatDebugLogEvent(handle, eventId, token); + return dto ? this._reviveResolvedContent(dto) : undefined; }, provideChatDebugLogExport: async (sessionResource, token) => { // Gather core events and session title to pass to the extension. @@ -185,4 +186,56 @@ export class MainThreadChatDebug extends Disposable implements MainThreadChatDeb }; } } + + private _reviveResolvedContent(dto: IChatDebugResolvedEventContentDto): IChatDebugResolvedEventContent { + switch (dto.kind) { + case 'text': + return { kind: 'text', value: dto.value }; + case 'message': + return { + kind: 'message', + type: dto.type, + message: dto.message, + sections: dto.sections, + }; + case 'toolCall': + return { + kind: 'toolCall', + toolName: dto.toolName, + result: dto.result, + durationInMillis: dto.durationInMillis, + input: dto.input, + output: dto.output, + }; + case 'modelTurn': + return { + kind: 'modelTurn', + requestName: dto.requestName, + model: dto.model, + status: dto.status, + durationInMillis: dto.durationInMillis, + inputTokens: dto.inputTokens, + outputTokens: dto.outputTokens, + cachedTokens: dto.cachedTokens, + totalTokens: dto.totalTokens, + errorMessage: dto.errorMessage, + sections: dto.sections, + }; + case 'hook': + return { + kind: 'hook', + hookType: dto.hookType, + command: dto.command, + result: dto.result === 'success' ? ChatDebugHookResult.Success + : dto.result === 'error' ? ChatDebugHookResult.Error + : dto.result === 'nonBlockingError' ? ChatDebugHookResult.NonBlockingError + : undefined, + durationInMillis: dto.durationInMillis, + input: dto.input, + output: dto.output, + exitCode: dto.exitCode, + errorMessage: dto.errorMessage, + }; + } + } } diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 63729a83881..91214f4dd63 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -25,17 +25,19 @@ import { ChatEditorInput } from '../../contrib/chat/browser/widgetHosts/editor/c import { IChatRequestVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { awaitStatsForSession } from '../../contrib/chat/common/chat.js'; import { IChatContentInlineReference, IChatProgress, IChatService, ResponseModelState } from '../../contrib/chat/common/chatService/chatService.js'; -import { ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionItem, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; +import { ChatSessionOptionsMap, ChatSessionStatus, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsService, ReadonlyChatSessionOptionsMap } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { IChatAgentRequest } from '../../contrib/chat/common/participants/chatAgents.js'; +import { IChatDebugService } from '../../contrib/chat/common/chatDebugService.js'; +import { IChatArtifactsService } from '../../contrib/chat/common/tools/chatArtifactsService.js'; import { IChatTodoListService } from '../../contrib/chat/common/tools/chatTodoListService.js'; import { IEditorGroupsService } from '../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../services/editor/common/editorService.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ChatSessionContentContextDto, ExtHostChatSessionsShape, ExtHostContext, IChatProgressDto, IChatSessionHistoryItemDto, IChatSessionItemsChange, MainContext, MainThreadChatSessionsShape } from '../common/extHost.protocol.js'; +import { ChatSessionContentContextDto, ExtHostChatSessionsShape, ExtHostContext, IChatProgressDto, IChatSessionHistoryItemDto, IChatSessionItemsChange, IChatSessionRequestHistoryItemDto, MainContext, MainThreadChatSessionsShape } from '../common/extHost.protocol.js'; export class ObservableChatSession extends Disposable implements IChatSession { @@ -43,9 +45,9 @@ export class ObservableChatSession extends Disposable implements IChatSession { readonly providerHandle: number; readonly history: Array; title?: string; - private _options?: Record; - public get options(): Record | undefined { - return this._options; + private _options?: ChatSessionOptionsMap; + public get options(): ReadonlyChatSessionOptionsMap | undefined { + return this._options ? new Map(this._options) : undefined; } private readonly _progressObservable = observableValue(this, []); private readonly _isCompleteObservable = observableValue(this, false); @@ -67,6 +69,7 @@ export class ObservableChatSession extends Disposable implements IChatSession { history: any[], token: CancellationToken ) => Promise; + forkSession?: (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => Promise; private readonly _proxy: ExtHostChatSessionsShape; private readonly _providerHandle: number; @@ -113,7 +116,7 @@ export class ObservableChatSession extends Disposable implements IChatSession { token ); - this._options = sessionContent.options; + this._options = sessionContent.options ? ChatSessionOptionsMap.fromRecord(sessionContent.options) : undefined; this.title = sessionContent.title; this.history.length = 0; this.history.push(...sessionContent.history.map((turn: IChatSessionHistoryItemDto) => { @@ -239,6 +242,13 @@ export class ObservableChatSession extends Disposable implements IChatSession { }; } + if (sessionContent.hasForkHandler && !this.forkSession) { + this.forkSession = async (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => { + const result = await this._proxy.$forkChatSession(this._providerHandle, this.sessionResource, request ? this.toRequestDto(request) : undefined, token); + return revive(result) as IChatSessionItem; + }; + } + this._isInitialized = true; // Process any pending progress chunks @@ -307,6 +317,18 @@ export class ObservableChatSession extends Disposable implements IChatSession { } } + private toRequestDto(request: IChatSessionRequestHistoryItem): IChatSessionRequestHistoryItemDto { + return { + type: 'request', + id: request.id, + prompt: request.prompt, + participant: request.participant, + command: request.command, + variableData: undefined, + modelId: request.modelId, + }; + } + override dispose(): void { this._onWillDispose.fire(); this._onWillDispose.dispose(); @@ -338,6 +360,7 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes chatSessionType: string, handle: number, @IChatService chatService: IChatService, + @ILogService private readonly _logService: ILogService, ) { super(); this._proxy = proxy; @@ -361,7 +384,11 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes } async newChatSessionItem(request: IChatNewSessionRequest, token: CancellationToken): Promise { - const dto = await raceCancellationError(this._proxy.$newChatSessionItem(this._handle, request, token), token); + const dto = await raceCancellationError(this._proxy.$newChatSessionItem(this._handle, { + prompt: request.prompt, + command: request.command, + initialSessionOptions: request.initialSessionOptions ? ChatSessionOptionsMap.toStrValueArray(request.initialSessionOptions) : undefined, + }, token), token); if (!dto) { return undefined; } @@ -379,7 +406,7 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes acceptChange(change: { readonly addedOrUpdated: readonly IChatSessionItem[]; readonly removed: readonly URI[] }): void { for (const item of change.addedOrUpdated) { - warnOnUntitledSessionResource(item.resource); + warnOnUntitledSessionResource(item.resource, this._logService); this._items.set(item.resource, item); } for (const uri of change.removed) { @@ -392,7 +419,7 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes } addOrUpdateItem(item: IChatSessionItem): void { - warnOnUntitledSessionResource(item.resource); + warnOnUntitledSessionResource(item.resource, this._logService); this._items.set(item.resource, item); this._onDidChangeChatSessionItems.fire({ addedOrUpdated: [item], @@ -422,6 +449,8 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat @IChatService private readonly _chatService: IChatService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @IChatTodoListService private readonly _chatTodoListService: IChatTodoListService, + @IChatArtifactsService private readonly _chatArtifactsService: IChatArtifactsService, + @IChatDebugService private readonly _chatDebugService: IChatDebugService, @IDialogService private readonly _dialogService: IDialogService, @IEditorService private readonly _editorService: IEditorService, @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @@ -432,12 +461,12 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._proxy = this._extHostContext.getProxy(ExtHostContext.ExtHostChatSessions); - this._register(this._chatSessionsService.onRequestNotifyExtension(({ sessionResource, updates, waitUntil }) => { - warnOnUntitledSessionResource(sessionResource); + this._register(this._chatSessionsService.onDidChangeSessionOptions(({ sessionResource, updates }) => { + warnOnUntitledSessionResource(sessionResource, this._logService); const handle = this._getHandleForSessionType(sessionResource.scheme); - this._logService.trace(`[MainThreadChatSessions] onRequestNotifyExtension received: scheme '${sessionResource.scheme}', handle ${handle}, ${updates.length} update(s)`); + this._logService.trace(`[MainThreadChatSessions] onRequestNotifyExtension received: scheme '${sessionResource.scheme}', handle ${handle}, ${updates.size} update(s)`); if (handle !== undefined) { - waitUntil(this.notifyOptionsChange(handle, sessionResource, updates)); + this.notifyOptionsChange(handle, sessionResource, updates); } else { this._logService.warn(`[MainThreadChatSessions] Cannot notify option change for scheme '${sessionResource.scheme}': no provider registered. Registered schemes: [${Array.from(this._sessionTypeToHandle.keys()).join(', ')}]`); } @@ -446,7 +475,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._register(this._agentSessionsService.model.onDidChangeSessionArchivedState(session => { for (const [handle, { chatSessionType }] of this._itemControllerRegistrations) { if (chatSessionType === session.providerType) { - warnOnUntitledSessionResource(session.resource); + warnOnUntitledSessionResource(session.resource, this._logService); this._proxy.$onDidChangeChatSessionItemState(handle, session.resource, session.isArchived()); } } @@ -486,7 +515,8 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } // We can still get stats if there is no model or if fetching from model failed - if (!item.changes || !model) { + let changes = revive(item.changes); + if (!changes || !model) { const stats = (await this._chatService.getMetadataForSession(uri))?.stats; const diffs: IAgentSession['changes'] = { files: stats?.fileCount || 0, @@ -494,13 +524,13 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat deletions: stats?.removed || 0 }; if (hasValidDiff(diffs)) { - item.changes = diffs; + changes = diffs; } } return { ...item, - changes: revive(item.changes), + changes, resource: uri, iconPath: item.iconPath, tooltip: item.tooltip ? this._reviveTooltip(item.tooltip) : undefined, @@ -523,10 +553,10 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat controller.addOrUpdateItem(resolvedItem); } - $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string }>): void { + $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: Record): void { const sessionResource = URI.revive(sessionResourceComponents); - warnOnUntitledSessionResource(sessionResource); - this._chatSessionsService.notifySessionOptionsChange(sessionResource, updates); + warnOnUntitledSessionResource(sessionResource, this._logService); + this._chatSessionsService.updateSessionOptions(sessionResource, ChatSessionOptionsMap.fromRecord(updates)); } async $onDidCommitChatSessionItem(handle: number, originalComponents: UriComponents, modifiedCompoennts: UriComponents): Promise { @@ -549,6 +579,21 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // Migrate todos from old session to new session this._chatTodoListService.migrateTodos(originalResource, modifiedResource); + // Migrate artifacts from old session to new session + this._chatArtifactsService.migrateArtifacts(originalResource, modifiedResource); + + // Eagerly invoke debug providers for Copilot CLI sessions so the real + // session appears in the debug panel immediately after the untitled → + // real swap. Without this, the untitled session is filtered out (it + // only has a "Load Hooks" event) and the real session has no events + // until someone navigates to it — which can't happen because it's + // not listed. + if (chatSessionType === 'copilotcli') { + // Fire-and-forget: don't block the editor swap. Errors are + // handled internally by invokeProviders via onUnexpectedError. + this._chatDebugService.invokeProviders(modifiedResource).catch(() => { /* handled internally */ }); + } + // Find the group containing the original editor const originalGroup = this.editorGroupService.groups.find(group => group.editors.some(editor => isEqual(editor.resource, originalResource))) @@ -618,18 +663,20 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } private async handleSessionModelOverrides(model: IChatModel, session: Dto): Promise> { - // Override desciription if there's an in-progress count + const outgoingSession = { ...session }; + + // Override description if there's an in-progress count const inProgress = model.getRequests().filter(r => r.response && !r.response.isComplete); if (inProgress.length) { - session.description = this._chatSessionsService.getInProgressSessionDescription(model); + outgoingSession.description = this._chatSessionsService.getInProgressSessionDescription(model); } // Override changes // TODO: @osortega we don't really use statistics anymore, we need to clarify that in the API - if (!(session.changes instanceof Array)) { + if (!(outgoingSession.changes instanceof Array)) { const modelStats = await awaitStatsForSession(model); if (modelStats) { - session.changes = { + outgoingSession.changes = { files: modelStats.fileCount, insertions: modelStats.added, deletions: modelStats.removed @@ -639,14 +686,14 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat // Override status if the models needs input if (model.lastRequest?.response?.state === ResponseModelState.NeedsInput) { - session.status = ChatSessionStatus.NeedsInput; + outgoingSession.status = ChatSessionStatus.NeedsInput; } - return session; + return outgoingSession; } private async _provideChatSessionContent(providerHandle: number, sessionResource: URI, token: CancellationToken): Promise { - warnOnUntitledSessionResource(sessionResource); + warnOnUntitledSessionResource(sessionResource, this._logService); let session = this._activeSessions.get(sessionResource); @@ -670,12 +717,12 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat try { const initialSessionOptions = this._chatSessionsService.getSessionOptions(sessionResource); await session.initialize(token, { - initialSessionOptions: initialSessionOptions ? [...initialSessionOptions].map(([optionId, value]) => ({ optionId, value })) : undefined, + initialSessionOptions: initialSessionOptions ? [...initialSessionOptions].map(([optionId, value]) => ({ optionId, value: typeof value === 'string' ? value : value?.id })) : undefined, }); if (session.options) { for (const [_, handle] of this._sessionTypeToHandle) { if (handle === providerHandle) { - for (const [optionId, value] of Object.entries(session.options)) { + for (const [optionId, value] of session.options) { this._chatSessionsService.setSessionOption(sessionResource, optionId, value); } break; @@ -740,7 +787,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat $handleProgressComplete(handle: number, sessionResource: UriComponents, requestId: string) { const resource = URI.revive(sessionResource); - warnOnUntitledSessionResource(resource); + warnOnUntitledSessionResource(resource, this._logService); const observableSession = this._activeSessions.get(resource); if (!observableSession) { @@ -784,7 +831,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._chatSessionsService.setOptionGroupsForSessionType(chatSessionScheme, handle, groupsWithCallbacks); } if (options?.newSessionOptions) { - this._chatSessionsService.setNewSessionOptionsForSessionType(chatSessionScheme, options.newSessionOptions); + this._chatSessionsService.setNewSessionOptionsForSessionType(chatSessionScheme, ChatSessionOptionsMap.fromRecord(options.newSessionOptions)); } }).catch(err => this._logService.error('Error fetching chat session options', err)); } @@ -824,10 +871,10 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat /** * Notify the extension about option changes for a session */ - async notifyOptionsChange(handle: number, sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem | undefined }>): Promise { + async notifyOptionsChange(handle: number, sessionResource: URI, updates: ReadonlyMap): Promise { this._logService.trace(`[MainThreadChatSessions] notifyOptionsChange: starting proxy call for handle ${handle}, sessionResource ${sessionResource}`); try { - await this._proxy.$provideHandleOptionsChange(handle, sessionResource, updates, CancellationToken.None); + await this._proxy.$provideHandleOptionsChange(handle, sessionResource, Object.fromEntries(updates), CancellationToken.None); this._logService.trace(`[MainThreadChatSessions] notifyOptionsChange: proxy call completed for handle ${handle}, sessionResource ${sessionResource}`); } catch (error) { this._logService.error(`[MainThreadChatSessions] notifyOptionsChange: error for handle ${handle}, sessionResource ${sessionResource}:`, error); @@ -835,8 +882,8 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } } -function warnOnUntitledSessionResource(resource: URI): void { +function warnOnUntitledSessionResource(resource: URI, logService: ILogService): void { if (isUntitledChatSession(resource)) { - console.trace(`[MainThreadChatSessions] untitled-style sessionResource detected ${resource.toString()}`); + logService.warn(`[MainThreadChatSessions] untitled-style sessionResource detected ${resource.toString()}`); } } diff --git a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts index e9579b0a3ec..15774eafe5f 100644 --- a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts +++ b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts @@ -165,6 +165,16 @@ export class MainThreadGitExtensionService extends Disposable implements MainThr return result.map(toGitDiffChange); } + async diffBetweenWithStats2(root: URI, ref: string, path?: string): Promise { + const handle = this._repositoryHandles.get(root); + if (handle === undefined) { + return []; + } + + const result = await this._proxy.$diffBetweenWithStats2(handle, ref, 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/statusBarExtensionPoint.ts b/src/vs/workbench/api/browser/statusBarExtensionPoint.ts index 4da1f68eeb1..c8d1765a7ab 100644 --- a/src/vs/workbench/api/browser/statusBarExtensionPoint.ts +++ b/src/vs/workbench/api/browser/statusBarExtensionPoint.ts @@ -199,7 +199,7 @@ const statusBarItemSchema = { }, text: { type: 'string', - description: localize('text', 'The text to show for the entry. You can embed icons in the text by leveraging the `$()`-syntax, like \'Hello $(globe)!\'') + description: localize('text', 'The text to show for the entry. You can embed icons in the text by leveraging the `$()`-syntax, like \'Hello {0}!\'', '$(globe)') }, tooltip: { type: 'string', diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 2d2d7f8fce7..e42e0f30b2c 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -2127,6 +2127,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatSessionStatus: extHostTypes.ChatSessionStatus, ChatDebugLogLevel: extHostTypes.ChatDebugLogLevel, ChatDebugToolCallResult: extHostTypes.ChatDebugToolCallResult, + ChatDebugHookResult: extHostTypes.ChatDebugHookResult, ChatDebugToolCallEvent: extHostTypes.ChatDebugToolCallEvent, ChatDebugModelTurnEvent: extHostTypes.ChatDebugModelTurnEvent, ChatDebugGenericEvent: extHostTypes.ChatDebugGenericEvent, @@ -2139,6 +2140,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatDebugEventMessageContent: extHostTypes.ChatDebugEventMessageContent, ChatDebugEventToolCallContent: extHostTypes.ChatDebugEventToolCallContent, ChatDebugEventModelTurnContent: extHostTypes.ChatDebugEventModelTurnContent, + ChatDebugEventHookContent: extHostTypes.ChatDebugEventHookContent, ChatRequestEditorData: extHostTypes.ChatRequestEditorData, ChatRequestNotebookData: extHostTypes.ChatRequestNotebookData, ChatReferenceBinaryData: extHostTypes.ChatReferenceBinaryData, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 564e9ba689b..d02ae88a0f1 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -60,10 +60,10 @@ import { ICodeMapperRequest, ICodeMapperResult } from '../../contrib/chat/common import { IChatContextItem } from '../../contrib/chat/common/contextContrib/chatContext.js'; import { IChatProgressHistoryResponseContent, IChatRequestVariableData } from '../../contrib/chat/common/model/chatModel.js'; import { ChatResponseClearToPreviousToolInvocationReason, IChatContentInlineReference, IChatExternalEditsDto, IChatFollowup, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatTask, IChatTaskDto, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService/chatService.js'; -import { IChatNewSessionRequest, IChatSessionItem, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionItem, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { IChatRequestVariableValue } from '../../contrib/chat/common/attachments/chatVariables.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; -import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatSelector } from '../../contrib/chat/common/languageModels.js'; +import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatRequestOptions, ILanguageModelChatSelector } from '../../contrib/chat/common/languageModels.js'; import { IPreparedToolInvocation, IStreamedToolInvocation, IToolInvocation, IToolInvocationPreparationContext, IToolInvocationStreamContext, IToolProgressStep, IToolResult, ToolDataSource } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import { IPromptFileContext, IPromptFileResource } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { DebugConfigurationProviderTriggerKind, IAdapterDescriptor, IConfig, IDebugSessionReplMode, IDebugTestRunReference, IDebugVisualization, IDebugVisualizationContext, IDebugVisualizationTreeItem, MainThreadDebugVisualization } from '../../contrib/debug/common/debug.js'; @@ -1371,8 +1371,8 @@ export interface MainThreadBrowsersShape extends IDisposable { export interface ExtHostBrowsersShape { $onDidOpenBrowserTab(browser: BrowserTabDto): void; $onDidCloseBrowserTab(browserId: string): void; - $onDidChangeActiveBrowserTab(browser: BrowserTabDto | undefined): void; - $onDidChangeBrowserTabState(browserId: string, data: BrowserTabDto): void; + $onDidChangeActiveBrowserTab(browserId: string | undefined): void; + $onDidChangeBrowserTabState(browser: BrowserTabDto): void; $onCDPSessionMessage(sessionId: string, message: CDPResponse | CDPEvent): void; $onCDPSessionClosed(sessionId: string): void; } @@ -1394,7 +1394,7 @@ export interface MainThreadLanguageModelsShape extends IDisposable { export interface ExtHostLanguageModelsShape { $provideLanguageModelChatInfo(vendor: string, options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise; $updateModelAccesslist(data: { from: ExtensionIdentifier; to: ExtensionIdentifier; enabled: boolean }[]): void; - $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier | undefined, messages: SerializableObjectWithBuffers, options: { [name: string]: any }, token: CancellationToken): Promise; + $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier | undefined, messages: SerializableObjectWithBuffers, options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise; $acceptResponsePart(requestId: number, chunk: SerializableObjectWithBuffers): Promise; $acceptResponseDone(requestId: number, error: SerializedError | undefined): Promise; $provideTokenLength(modelId: string, value: string | IChatMessage, token: CancellationToken): Promise; @@ -1523,7 +1523,19 @@ export interface IChatDebugEventModelTurnContentDto { readonly sections?: readonly IChatDebugMessageSectionDto[]; } -export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto | IChatDebugEventMessageContentDto | IChatDebugEventToolCallContentDto | IChatDebugEventModelTurnContentDto; +export interface IChatDebugEventHookContentDto { + readonly kind: 'hook'; + readonly hookType: string; + readonly command?: string; + readonly result?: 'success' | 'error' | 'nonBlockingError'; + readonly durationInMillis?: number; + readonly input?: string; + readonly output?: string; + readonly exitCode?: number; + readonly errorMessage?: string; +} + +export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto | IChatDebugEventMessageContentDto | IChatDebugEventToolCallContentDto | IChatDebugEventModelTurnContentDto | IChatDebugEventHookContentDto; export interface ExtHostChatDebugShape { $provideChatDebugLog(handle: number, sessionResource: UriComponents, token: CancellationToken): Promise; @@ -3578,27 +3590,28 @@ export type IChatSessionHistoryItemDto = { participant: string; }; -export interface ChatSessionOptionUpdateDto { - readonly optionId: string; - readonly value: string | IChatSessionProviderOptionItem | undefined; -} +export type IChatSessionRequestHistoryItemDto = Extract; + -export interface ChatSessionOptionUpdateDto2 { - readonly optionId: string; - readonly value: string | IChatSessionProviderOptionItem; -} export interface ChatSessionContentContextDto { readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }>; } -export interface ChatSessionDto { - id: string; +export interface IChatNewSessionRequestDto { + readonly prompt: string; + readonly command?: string; + + readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }>; +} + +export interface IChatSessionDto { resource: UriComponents; title?: string; history: Array; hasActiveResponseCallback: boolean; hasRequestHandler: boolean; + hasForkHandler: boolean; supportsInterruption: boolean; options?: Record; } @@ -3621,7 +3634,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { $onDidCommitChatSessionItem(controllerHandle: number, original: UriComponents, modified: UriComponents): void; $registerChatSessionContentProvider(handle: number, chatSessionScheme: string): void; $unregisterChatSessionContentProvider(handle: number): void; - $onDidChangeChatSessionOptions(handle: number, sessionResource: UriComponents, updates: ReadonlyArray): void; + $onDidChangeChatSessionOptions(handle: number, sessionResource: UriComponents, updates: Record): void; $onDidChangeChatSessionProviderOptions(handle: number): void; $handleProgressChunk(handle: number, sessionResource: UriComponents, requestId: string, chunks: (IChatProgressDto | [IChatProgressDto, number])[]): Promise; @@ -3632,15 +3645,16 @@ export interface MainThreadChatSessionsShape extends IDisposable { export interface ExtHostChatSessionsShape { $refreshChatSessionItems(providerHandle: number, token: CancellationToken): Promise; $onDidChangeChatSessionItemState(providerHandle: number, sessionResource: UriComponents, archived: boolean): void; - $newChatSessionItem(controllerHandle: number, request: IChatNewSessionRequest, token: CancellationToken): Promise | undefined>; + $newChatSessionItem(controllerHandle: number, request: IChatNewSessionRequestDto, token: CancellationToken): Promise | undefined>; - $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise; + $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise; $interruptChatSessionActiveResponse(providerHandle: number, sessionResource: UriComponents, requestId: string): Promise; $disposeChatSessionContent(providerHandle: number, sessionResource: UriComponents): Promise; $invokeChatSessionRequestHandler(providerHandle: number, sessionResource: UriComponents, request: IChatAgentRequest, history: any[], token: CancellationToken): Promise; $provideChatSessionProviderOptions(providerHandle: number, token: CancellationToken): Promise; $invokeOptionGroupSearch(providerHandle: number, optionGroupId: string, query: string, token: CancellationToken): Promise; - $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: ReadonlyArray, token: CancellationToken): Promise; + $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: Record, token: CancellationToken): Promise; + $forkChatSession(providerHandle: number, sessionResource: UriComponents, request: IChatSessionRequestHistoryItemDto | undefined, token: CancellationToken): Promise>; } export interface GitRefQueryDto { @@ -3710,6 +3724,7 @@ export interface ExtHostGitExtensionShape { $getRefs(handle: number, query: GitRefQueryDto, token?: CancellationToken): Promise; $getRepositoryState(handle: number): Promise; $diffBetweenWithStats(handle: number, ref1: string, ref2: string, path?: string): Promise; + $diffBetweenWithStats2(handle: number, ref: string, path?: string): Promise; } // --- proxy identifiers diff --git a/src/vs/workbench/api/common/extHostBrowsers.ts b/src/vs/workbench/api/common/extHostBrowsers.ts index a66e3113409..bbb360c2705 100644 --- a/src/vs/workbench/api/common/extHostBrowsers.ts +++ b/src/vs/workbench/api/common/extHostBrowsers.ts @@ -237,16 +237,13 @@ export class ExtHostBrowsers extends Disposable implements ExtHostBrowsersShape } } - $onDidChangeActiveBrowserTab(dto: BrowserTabDto | undefined): void { - this._activeBrowserTabId = dto?.id; - if (dto) { - this._getOrCreateTab(dto); - } + $onDidChangeActiveBrowserTab(browserId: string | undefined): void { + this._activeBrowserTabId = browserId; this._onDidChangeActiveBrowserTab.fire(this.activeBrowserTab); } - $onDidChangeBrowserTabState(browserId: string, data: BrowserTabDto): void { - const tab = this._browserTabs.get(browserId); + $onDidChangeBrowserTabState(data: BrowserTabDto): void { + const tab = this._browserTabs.get(data.id); if (tab && tab.update(data)) { this._onDidChangeBrowserTabState.fire(tab.value); } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 860c7277766..12df8ec2edd 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -675,6 +675,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS request, location, model, + request.modelConfiguration, this.getDiagnosticsWhenEnabled(detector.extension), tools, detector.extension, @@ -776,6 +777,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS request, location, model, + request.modelConfiguration, this.getDiagnosticsWhenEnabled(agent.extension), tools, agent.extension, diff --git a/src/vs/workbench/api/common/extHostChatDebug.ts b/src/vs/workbench/api/common/extHostChatDebug.ts index 44cccc60a61..8e84cccaf35 100644 --- a/src/vs/workbench/api/common/extHostChatDebug.ts +++ b/src/vs/workbench/api/common/extHostChatDebug.ts @@ -10,7 +10,7 @@ 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 { ChatDebugGenericEvent, ChatDebugLogLevel, ChatDebugMessageContentType, ChatDebugMessageSection, ChatDebugModelTurnEvent, ChatDebugSubagentInvocationEvent, ChatDebugSubagentStatus, ChatDebugToolCallEvent, ChatDebugToolCallResult, ChatDebugUserMessageEvent, ChatDebugAgentResponseEvent } from './extHostTypes.js'; +import { ChatDebugGenericEvent, ChatDebugHookResult, ChatDebugLogLevel, ChatDebugMessageContentType, ChatDebugMessageSection, ChatDebugModelTurnEvent, ChatDebugSubagentInvocationEvent, ChatDebugSubagentStatus, ChatDebugToolCallEvent, ChatDebugToolCallResult, ChatDebugUserMessageEvent, ChatDebugAgentResponseEvent, ChatDebugEventHookContent } from './extHostTypes.js'; import { IExtHostRpcService } from './extHostRpcService.js'; export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShape { @@ -293,6 +293,23 @@ export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShap sections: mt.sections?.map(s => ({ name: s.name, content: s.content })), }; } + case 'hookContent': { + const hk = result as unknown as ChatDebugEventHookContent; + return { + kind: 'hook', + hookType: hk.hookType, + command: hk.command, + result: hk.result === ChatDebugHookResult.Success ? 'success' + : hk.result === ChatDebugHookResult.Error ? 'error' + : hk.result === ChatDebugHookResult.NonBlockingError ? 'nonBlockingError' + : undefined, + durationInMillis: hk.durationInMillis, + input: hk.input, + output: hk.output, + exitCode: hk.exitCode, + errorMessage: hk.errorMessage, + }; + } default: return undefined; } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 62ff2c6c102..93fb3ecebf5 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -18,11 +18,11 @@ 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, ISymbolVariableEntry, PromptFileVariableKind, toPromptFileVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; -import { IChatNewSessionRequest, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.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'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; -import { ChatSessionContentContextDto, ChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape } from './extHost.protocol.js'; +import { ChatSessionContentContextDto, IChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionRequestHistoryItemDto, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape, IChatNewSessionRequestDto } from './extHost.protocol.js'; import { ChatAgentResponseStream } from './extHostChatAgents2.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; import { ExtHostLanguageModels } from './extHostLanguageModels.js'; @@ -303,8 +303,6 @@ class ExtHostChatSession { } export class ExtHostChatSessions extends Disposable implements ExtHostChatSessionsShape { - private static _sessionHandlePool = 0; - private readonly _proxy: Proxied; private _itemControllerHandlePool = 0; @@ -423,6 +421,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio let isDisposed = false; let newChatSessionItemHandler: vscode.ChatSessionItemController['newChatSessionItemHandler']; + let forkHandler: vscode.ChatSessionItemController['forkHandler']; const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); const collection = new ChatSessionItemCollectionImpl(controllerHandle, this._proxy); @@ -454,6 +453,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, get newChatSessionItemHandler() { return newChatSessionItemHandler; }, set newChatSessionItemHandler(handler: vscode.ChatSessionItemController['newChatSessionItemHandler']) { newChatSessionItemHandler = handler; }, + get forkHandler() { return forkHandler; }, + set forkHandler(handler: vscode.ChatSessionItemController['forkHandler']) { forkHandler = handler; }, dispose: () => { isDisposed = true; disposables.dispose(); @@ -482,7 +483,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio if (provider.onDidChangeChatSessionOptions) { disposables.add(provider.onDidChangeChatSessionOptions(evt => { - this._proxy.$onDidChangeChatSessionOptions(handle, evt.resource, evt.updates); + const updates: Record = Object.create(null); + for (const update of evt.updates) { + updates[update.optionId] = update.value; + } + this._proxy.$onDidChangeChatSessionOptions(handle, evt.resource, updates); })); } @@ -499,7 +504,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); } - async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise { + async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, context: ChatSessionContentContextDto, token: CancellationToken): Promise { const provider = this._chatSessionContentProviders.get(handle); if (!provider) { throw new Error(`No provider for handle ${handle}`); @@ -514,8 +519,9 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio throw new CancellationError(); } + const controllerData = this.getChatSessionItemController(sessionResource.scheme); + const sessionDisposables = new DisposableStore(); - const sessionId = ExtHostChatSessions._sessionHandlePool++; const id = sessionResource.toString(); const chatSession = new ExtHostChatSession(session, provider.extension, { sessionResource, @@ -545,11 +551,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } const { capabilities } = provider; return { - id: sessionId + '', resource: URI.revive(sessionResource), title: session.title, hasActiveResponseCallback: !!session.activeResponseCallback, hasRequestHandler: !!session.requestHandler, + hasForkHandler: !!controllerData?.controller.forkHandler || !!session.forkHandler, supportsInterruption: !!capabilities?.supportsInterruptions, options: session.options, history: session.history.map(turn => { @@ -562,7 +568,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } - async $provideHandleOptionsChange(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem | undefined }>, token: CancellationToken): Promise { + async $provideHandleOptionsChange(handle: number, sessionResourceComponents: UriComponents, updates: Record, token: CancellationToken): Promise { const sessionResource = URI.revive(sessionResourceComponents); const provider = this._chatSessionContentProviders.get(handle); if (!provider) { @@ -576,11 +582,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } try { - const updatesToSend = updates.map(update => ({ - optionId: update.optionId, - value: update.value === undefined ? undefined : (typeof update.value === 'string' ? update.value : update.value.id) + const updatesToSend = Object.entries(updates).map(([optionId, value]) => ({ + optionId, + value: value === undefined ? undefined : (typeof value === 'string' ? value : value.id) })); - await provider.provider.provideHandleOptionsChange(sessionResource, updatesToSend, token); + provider.provider.provideHandleOptionsChange(sessionResource, updatesToSend, token); } catch (error) { this._logService.error(`Error calling provideHandleOptionsChange for handle ${handle}, sessionResource ${sessionResource}:`, error); } @@ -640,7 +646,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return {}; } - const chatRequest = typeConvert.ChatAgentRequest.to(request, undefined, await this.getModelForRequest(request, entry.sessionObj.extension), [], new Map(), entry.sessionObj.extension, this._logService); + const chatRequest = typeConvert.ChatAgentRequest.to(request, undefined, await this.getModelForRequest(request, entry.sessionObj.extension), request.modelConfiguration, [], new Map(), entry.sessionObj.extension, this._logService); const stream = entry.sessionObj.getActiveRequestStream(request); await entry.sessionObj.session.requestHandler(chatRequest, { history, yieldRequested: false }, stream.apiObject, token); @@ -649,6 +655,56 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return {}; } + async $forkChatSession(handle: number, sessionResourceComponents: UriComponents, request: IChatSessionRequestHistoryItemDto | undefined, token: CancellationToken): Promise> { + const sessionResource = URI.revive(sessionResourceComponents); + const entry = this._extHostChatSessions.get(sessionResource); + if (!entry) { + throw new Error(`No chat session found for resource ${sessionResource.toString()}`); + } + + const requestTurn = this.convertRequestDtoToRequestTurn(request); + + const controllerData = this.getChatSessionItemController(sessionResource.scheme); + if (controllerData?.controller.forkHandler) { + const item = await controllerData.controller.forkHandler(sessionResource, requestTurn, token); + return typeConvert.ChatSessionItem.from(item); + } + + if (!entry.sessionObj.session.forkHandler) { + throw new Error(`No fork handler for session ${sessionResource.toString()}`); + } + + const item = await entry.sessionObj.session.forkHandler(sessionResource, requestTurn, token); + return typeConvert.ChatSessionItem.from(item); + } + + private convertRequestDtoToRequestTurn(request: IChatSessionRequestHistoryItemDto | undefined): extHostTypes.ChatRequestTurn | undefined { + if (!request) { + return undefined; + } + + return new extHostTypes.ChatRequestTurn( + request.prompt, + request.command, + [], + request.participant, + [], + undefined, + request.id, + request.modelId, + ); + } + + private getChatSessionItemController(chatSessionType: string) { + for (const controllerData of this._chatSessionItemControllers.values()) { + if (controllerData.chatSessionType === chatSessionType) { + return controllerData; + } + } + + return undefined; + } + private async getModelForRequest(request: IChatAgentRequest, extension: IExtensionDescription): Promise { let model: vscode.LanguageModelChat | undefined; if (request.userSelectedModelId) { @@ -775,7 +831,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio await controllerData.controller.refreshHandler(token); } - async $newChatSessionItem(handle: number, request: IChatNewSessionRequest, token: CancellationToken): Promise | undefined> { + async $newChatSessionItem(handle: number, request: IChatNewSessionRequestDto, token: CancellationToken): Promise | undefined> { const controllerData = this._chatSessionItemControllers.get(handle); if (!controllerData) { this._logService.warn(`No controller found for handle ${handle}`); diff --git a/src/vs/workbench/api/common/extHostGitExtensionService.ts b/src/vs/workbench/api/common/extHostGitExtensionService.ts index 4d1dd2e2acb..a0f7ec43df1 100644 --- a/src/vs/workbench/api/common/extHostGitExtensionService.ts +++ b/src/vs/workbench/api/common/extHostGitExtensionService.ts @@ -95,6 +95,7 @@ interface Repository { getBranchBase(name: string): Promise; getRefs(query: GitRefQuery, token?: vscode.CancellationToken): Promise; diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise; + diffBetweenWithStats2(ref: string, path?: string): Promise; isBranchProtected(branch?: Branch): boolean; } @@ -326,6 +327,24 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi } } + async $diffBetweenWithStats2(handle: number, ref: string, path?: string): Promise { + const repository = this._repositories.get(handle); + if (!repository) { + return []; + } + + try { + const changes = await repository.diffBetweenWithStats2(ref, path); + return changes.map(c => ({ + ...toGitChangeDto(c), + insertions: c.insertions, + deletions: c.deletions, + })); + } catch { + return []; + } + } + private async _ensureGitApi(): Promise { if (this._gitApi) { return this._gitApi; diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index cf52a6f3e30..535fa978f24 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -12,6 +12,7 @@ import { revive } from '../../../base/common/marshalling.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { IPreparedToolInvocation, IStreamedToolInvocation, isToolInvocationContext, IToolInvocation, IToolInvocationContext, IToolInvocationPreparationContext, IToolInvocationStreamContext, IToolResult, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; +import { computeCombinationKey } from '../../contrib/chat/common/tools/languageModelToolsConfirmationService.js'; import { ExtensionEditToolId, InternalEditToolId } from '../../contrib/chat/common/tools/builtinTools/editFileTool.js'; import { InternalFetchWebPageToolId } from '../../contrib/chat/common/tools/builtinTools/tools.js'; import { SearchExtensionsToolId } from '../../contrib/extensions/common/searchExtensionsTool.js'; @@ -304,10 +305,22 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape checkProposedApiEnabled(item.extension, 'chatParticipantPrivate'); } + if (result.confirmationMessages?.approveCombination !== undefined) { + checkProposedApiEnabled(item.extension, 'toolInvocationApproveCombination'); + } + + const approveCombinationLabel = result.confirmationMessages?.approveCombination + ? typeConvert.MarkdownString.fromStrict(result.confirmationMessages.approveCombination) + : undefined; + const approveCombinationKey = approveCombinationLabel + ? await computeCombinationKey(toolId, context.parameters) + : undefined; + return { confirmationMessages: result.confirmationMessages ? { title: typeof result.confirmationMessages.title === 'string' ? result.confirmationMessages.title : typeConvert.MarkdownString.from(result.confirmationMessages.title), message: typeof result.confirmationMessages.message === 'string' ? result.confirmationMessages.message : typeConvert.MarkdownString.from(result.confirmationMessages.message), + approveCombination: approveCombinationLabel && approveCombinationKey ? { label: approveCombinationLabel, key: approveCombinationKey } : undefined, } : undefined, invocationMessage: typeConvert.MarkdownString.fromStrict(result.invocationMessage), pastTenseMessage: typeConvert.MarkdownString.fromStrict(result.pastTenseMessage), diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index cc76961ab15..9d2d8bc68c6 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -11,13 +11,14 @@ import { SerializedError, transformErrorForSerialization, transformErrorFromSeri import { Emitter, Event } from '../../../base/common/event.js'; import { Iterable } from '../../../base/common/iterator.js'; import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { IJSONSchema } from '../../../base/common/jsonSchema.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ExtensionIdentifier, ExtensionIdentifierMap, ExtensionIdentifierSet, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { Progress } from '../../../platform/progress/common/progress.js'; -import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../../contrib/chat/common/languageModels.js'; +import { IChatMessage, IChatResponsePart, ILanguageModelChatInfoOptions, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatRequestOptions } from '../../contrib/chat/common/languageModels.js'; import { DEFAULT_MODEL_PICKER_CATEGORY } from '../../contrib/chat/common/widget/input/modelPickerWidget.js'; import { INTERNAL_AUTH_PROVIDER_PREFIX } from '../../services/authentication/common/authentication.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; @@ -229,6 +230,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { isUserSelectable: m.isUserSelectable, statusIcon: m.statusIcon, targetChatSessionType: m.targetChatSessionType, + configurationSchema: m.configurationSchema as IJSONSchema | undefined, modelPickerCategory: m.category ?? DEFAULT_MODEL_PICKER_CATEGORY, capabilities: m.capabilities ? { vision: m.capabilities.imageInput, @@ -258,7 +260,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { return modelMetadataAndIdentifier; } - async $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier | undefined, messages: SerializableObjectWithBuffers, options: vscode.LanguageModelChatRequestOptions, token: CancellationToken): Promise { + async $startChatRequest(modelId: string, requestId: number, from: ExtensionIdentifier | undefined, messages: SerializableObjectWithBuffers, options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise { const knownModel = this._localModels.get(modelId); if (!knownModel) { throw new Error('Model not found'); @@ -320,7 +322,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { knownModel.info, messages.value.map(typeConvert.LanguageModelChatMessage2.to), // todo@connor4312: move `core` -> `undefined` after 1.111 Insiders is out - { ...options, modelOptions: options.modelOptions ?? {}, requestInitiator: from ? ExtensionIdentifier.toKey(from) : 'core', toolMode: options.toolMode ?? extHostTypes.LanguageModelChatToolMode.Auto }, + { ...options, modelOptions: options.modelOptions ?? {}, modelConfiguration: options.configuration, requestInitiator: from ? ExtensionIdentifier.toKey(from) : 'core', toolMode: options.toolMode ?? extHostTypes.LanguageModelChatToolMode.Auto }, progress, token ); diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index ae28e6be0ee..97ccc59fb08 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -6,6 +6,7 @@ import type * as vscode from 'vscode'; import { asArray, coalesce, isNonEmptyArray } from '../../../base/common/arrays.js'; import { VSBuffer, encodeBase64 } from '../../../base/common/buffer.js'; +import { IStringDictionary } from '../../../base/common/collections.js'; import { IDataTransferFile, IDataTransferItem, UriList } from '../../../base/common/dataTransfer.js'; import { createSingleCallFunction } from '../../../base/common/functional.js'; import * as htmlContent from '../../../base/common/htmlContent.js'; @@ -3403,7 +3404,7 @@ export namespace ChatResponsePart { } export namespace ChatAgentRequest { - export function to(request: IChatAgentRequest, location2: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined, model: vscode.LanguageModelChat, diagnostics: readonly [vscode.Uri, readonly vscode.Diagnostic[]][], tools: Map, extension: IRelaxedExtensionDescription, logService: ILogService): vscode.ChatRequest { + export function to(request: IChatAgentRequest, location2: vscode.ChatRequestEditorData | vscode.ChatRequestNotebookData | undefined, model: vscode.LanguageModelChat, modelConfiguration: IStringDictionary | undefined, diagnostics: readonly [vscode.Uri, readonly vscode.Diagnostic[]][], tools: Map, extension: IRelaxedExtensionDescription, logService: ILogService): vscode.ChatRequest { const toolReferences: IChatRequestVariableEntry[] = []; const variableReferences: IChatRequestVariableEntry[] = []; @@ -3438,6 +3439,7 @@ export namespace ChatAgentRequest { toolInvocationToken: Object.freeze({ sessionResource: request.sessionResource }) as never, tools, model, + modelConfiguration, editedFileEvents: request.editedFileEvents, modeInstructions: request.modeInstructions?.content, modeInstructions2: ChatRequestModeInstructions.to(request.modeInstructions), diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index e5b07617802..25860457deb 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3579,6 +3579,12 @@ export enum ChatDebugToolCallResult { Error = 1 } +export enum ChatDebugHookResult { + Success = 0, + Error = 1, + NonBlockingError = 2 +} + export class ChatDebugToolCallEvent { readonly _kind = 'toolCall'; id?: string; @@ -3756,6 +3762,22 @@ export class ChatDebugEventModelTurnContent { } } +export class ChatDebugEventHookContent { + readonly _kind = 'hookContent'; + hookType: string; + command?: string; + result?: ChatDebugHookResult; + durationInMillis?: number; + input?: string; + output?: string; + exitCode?: number; + errorMessage?: string; + + constructor(hookType: string) { + this.hookType = hookType; + } +} + export class ChatSessionChangedFile { constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { } } diff --git a/src/vs/workbench/api/common/extHostWorkspace.ts b/src/vs/workbench/api/common/extHostWorkspace.ts index 3b01c6c8ac6..24bb32c2327 100644 --- a/src/vs/workbench/api/common/extHostWorkspace.ts +++ b/src/vs/workbench/api/common/extHostWorkspace.ts @@ -524,6 +524,7 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac disregardSearchExcludeSettings: options.useExcludeSettings !== undefined && (options.useExcludeSettings !== ExcludeSettingOptions.SearchAndFilesExclude), maxResults: options.maxResults, excludePattern: excludePatterns.length > 0 ? excludePatterns : undefined, + ignoreGlobCase: options.caseInsensitive, _reason: 'startFileSearch', shouldGlobSearch: query.type === 'include' ? undefined : true, }; @@ -597,6 +598,7 @@ export class ExtHostWorkspace implements ExtHostWorkspaceShape, IExtHostWorkspac disregardSearchExcludeSettings: options.useExcludeSettings !== undefined && (options.useExcludeSettings !== ExcludeSettingOptions.SearchAndFilesExclude), fileEncoding: options.encoding, maxResults: options.maxResults, + ignoreGlobCase: options.caseInsensitive, previewOptions: options.previewOptions ? { matchLines: options.previewOptions?.numMatchLines ?? 100, charsPerLine: options.previewOptions?.charsPerLine ?? 10000, diff --git a/src/vs/workbench/api/node/proxyResolver.ts b/src/vs/workbench/api/node/proxyResolver.ts index 0a2d552624d..acc7d93e4c7 100644 --- a/src/vs/workbench/api/node/proxyResolver.ts +++ b/src/vs/workbench/api/node/proxyResolver.ts @@ -107,11 +107,13 @@ export function connectProxyResolver( promises.push(Promise.resolve(https.globalAgent.testCertificates as string[])); } const result = (await Promise.all(promises)).flat(); + const nodeSystemCertErrors = collectNodeSystemCertErrors(useNodeSystemCerts, extHostLogService); mainThreadTelemetry.$publicLog2('additionalCertificates', { count: result.length, isRemote, loadLocalCertificates, useNodeSystemCerts, + nodeSystemCertErrors, }); return result; }, @@ -283,6 +285,7 @@ type AdditionalCertificatesClassification = { isRemote: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether this is a remote extension host' }; loadLocalCertificates: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether local certificates are loaded' }; useNodeSystemCerts: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether Node.js system certificates are used' }; + nodeSystemCertErrors: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Summary of certificate loading errors from tls.getSystemCACertificatesErrors() or a sentinel string when unavailable/disabled' }; }; type AdditionalCertificatesEvent = { @@ -290,8 +293,84 @@ type AdditionalCertificatesEvent = { isRemote: boolean; loadLocalCertificates: boolean; useNodeSystemCerts: boolean; + nodeSystemCertErrors: string; }; +function collectNodeSystemCertErrors(useNodeSystemCerts: boolean, logService: ILogService): string { + if (!useNodeSystemCerts) { + const result = 'Not using Node.js system certificates'; + logService.debug(`ProxyResolver#collectNodeSystemCertErrors: ${result}`); + return result; + } + // eslint-disable-next-line local/code-no-any-casts + if (typeof (tls as any).getSystemCACertificatesErrors !== 'function') { + const result = 'tls.getSystemCACertificatesErrors is not available'; + logService.debug(`ProxyResolver#collectNodeSystemCertErrors: ${result}`); + return result; + } + try { + // eslint-disable-next-line local/code-no-any-casts + const errors = (tls as any).getSystemCACertificatesErrors(); + if (!errors || typeof errors !== 'object') { + const result = 'tls.getSystemCACertificatesErrors() did not return an object'; + logService.debug(`ProxyResolver#collectNodeSystemCertErrors: ${result}`); + return result; + } + const counts = new Map(); + for (const [category, entries] of Object.entries(errors)) { + if (Array.isArray(entries)) { + for (const entry of entries as { errorMessage?: string; errorCode?: number }[]) { + const code = entry.errorCode ?? 'missing code'; + const error = `${category}: ${sanitizeCertErrorMessage(entry.errorMessage ?? 'missing message')}`; + const key = `${error} (${code})`; + const existing = counts.get(key); + if (existing) { + existing.count++; + } else { + counts.set(key, { error, code, count: 1 }); + } + } + } + } + const result = JSON.stringify([...counts.values()].sort((a, b) => b.count - a.count)); + logService.trace(`ProxyResolver#collectNodeSystemCertErrors: ${result}`); + return result; + } catch (err) { + logService.debug('ProxyResolver#collectNodeSystemCertErrors: Failed to get certificate errors', err); + return `Error: ${err instanceof Error ? err.message : String(err)}`; + } +} + +// Sanitize known error messages to avoid false-positive redaction by the +// telemetry scrubbing regex in telemetryUtils.ts (the Generic Secret pattern +// matches "key", "sig", "signature" followed by a non-alphanumeric character). +// Source strings from Node.js RecordCertError() and OpenSSL's x509_err.c / asn1_err.c. +const certErrorReplacements: [string, string][] = [ + // Node.js RecordCertError: + ['key usage flags', 'k usage flags'], + // x509_err.c: + ['check dh key', 'check dh k'], + ['key type mismatch', 'k type mismatch'], + ['key values mismatch', 'k values mismatch'], + ['public key decode error', 'public k decode error'], + ['public key encode error', 'public k encode error'], + ['unable to get certs public key', 'unable to get certs public k'], + ['unknown key type', 'unknown k type'], + // asn1_err.c: + ['key type not supported', 'k type not supported'], + ['public key type', 'public k type'], + ['sig parse error', 's parse error'], + ['sig invalid mime type', 's invalid mime type'], + ['sig content type', 's content type'], + ['signature algorithm', 's algorithm'], +]; +function sanitizeCertErrorMessage(message: string): string { + for (const [search, replacement] of certErrorReplacements) { + message = message.replaceAll(search, replacement); + } + return message; +} + type ProxyResolveStatsClassification = { owner: 'chrmarti'; comment: 'Performance statistics for proxy resolution'; diff --git a/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts b/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts index e05e040cf70..c27dab06c97 100644 --- a/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts +++ b/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts @@ -74,7 +74,7 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); const dto = createDto({ id: 'b1', url: 'https://active.com' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); assert.strictEqual(extHost.activeBrowserTab?.url, 'https://active.com'); }); @@ -83,23 +83,19 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); const dto = createDto({ id: 'b1' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); assert.ok(extHost.activeBrowserTab); extHost.$onDidChangeActiveBrowserTab(undefined); assert.strictEqual(extHost.activeBrowserTab, undefined); }); - test('$onDidChangeActiveBrowserTab with unknown tab creates it and fires open event', () => { + test('$onDidChangeActiveBrowserTab with unknown tab returns undefined', () => { const extHost = createExtHostBrowsers(); - const opened: vscode.BrowserTab[] = []; - store.add(extHost.onDidOpenBrowserTab(tab => opened.push(tab))); - extHost.$onDidChangeActiveBrowserTab(createDto({ id: 'new-tab', url: 'https://new.com' })); + extHost.$onDidChangeActiveBrowserTab('non-existent'); - assert.strictEqual(extHost.activeBrowserTab?.url, 'https://new.com'); - assert.strictEqual(extHost.browserTabs.length, 1); - assert.strictEqual(opened.length, 1, 'onDidOpenBrowserTab should fire for the new tab'); + assert.strictEqual(extHost.activeBrowserTab, undefined); }); // #endregion @@ -186,7 +182,7 @@ suite('ExtHostBrowsers', () => { store.add(extHost.onDidChangeBrowserTabState(tab => changes.push(tab))); extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com' })); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://new.com' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://new.com' })); assert.strictEqual(changes.length, 1); assert.strictEqual(changes[0].url, 'https://new.com'); @@ -196,7 +192,7 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://example.com', title: 'Old Title' })); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://example.com', title: 'New Title' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://example.com', title: 'New Title' })); assert.strictEqual(extHost.browserTabs[0].url, 'https://example.com'); assert.strictEqual(extHost.browserTabs[0].title, 'New Title'); @@ -213,7 +209,7 @@ suite('ExtHostBrowsers', () => { const dto = createDto({ id: 'b1' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); extHost.$onDidChangeActiveBrowserTab(undefined); assert.deepStrictEqual(activeChanges, ['https://example.com', undefined]); @@ -242,7 +238,7 @@ suite('ExtHostBrowsers', () => { extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: undefined })); assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', favicon: 'https://example.com/new.ico' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', favicon: 'https://example.com/new.ico' })); assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/new.ico'); }); @@ -251,7 +247,7 @@ suite('ExtHostBrowsers', () => { extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: 'https://example.com/icon.ico' })); assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/icon.ico'); - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', favicon: undefined })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', favicon: undefined })); assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe'); }); @@ -379,7 +375,7 @@ suite('ExtHostBrowsers', () => { extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com', title: 'Old' })); const tabBefore = extHost.browserTabs[0]; - extHost.$onDidChangeBrowserTabState('b1', createDto({ id: 'b1', url: 'https://new.com', title: 'New' })); + extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://new.com', title: 'New' })); const tabAfter = extHost.browserTabs[0]; assert.strictEqual(tabBefore, tabAfter); @@ -417,7 +413,7 @@ suite('ExtHostBrowsers', () => { const extHost = createExtHostBrowsers(); const dto = createDto({ id: 'b1' }); extHost.$onDidOpenBrowserTab(dto); - extHost.$onDidChangeActiveBrowserTab(dto); + extHost.$onDidChangeActiveBrowserTab('b1'); assert.ok(extHost.activeBrowserTab); extHost.$onDidCloseBrowserTab('b1'); diff --git a/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts b/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts index 09190276e41..fd22da56829 100644 --- a/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts +++ b/src/vs/workbench/api/test/browser/extHostWorkspace.test.ts @@ -883,6 +883,25 @@ suite('ExtHostWorkspace', function () { }); }); + test('caseInsensitive', () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override $startFileSearch(_includeFolder: UriComponents | null, options: IFileQueryBuilderOptions, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.ignoreGlobCase, true); + return Promise.resolve(null); + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + return ws.findFiles2([''], { caseInsensitive: true }, new ExtensionIdentifier('test')).then(() => { + assert(mainThreadCalled, 'mainThreadCalled'); + }); + }); + // todo: add tests with multiple filePatterns and excludes }); @@ -1096,6 +1115,24 @@ suite('ExtHostWorkspace', function () { assert(mainThreadCalled, 'mainThreadCalled'); }); + test('caseInsensitive', async () => { + const root = '/project/foo'; + const rpcProtocol = new TestRPCProtocol(); + + let mainThreadCalled = false; + rpcProtocol.set(MainContext.MainThreadWorkspace, new class extends mock() { + override async $startTextSearch(query: IPatternInfo, folder: UriComponents | null, options: ITextQueryBuilderOptions, requestId: number, token: CancellationToken): Promise { + mainThreadCalled = true; + assert.strictEqual(options.ignoreGlobCase, true); + return null; + } + }); + + const ws = createExtHostWorkspace(rpcProtocol, { id: 'foo', folders: [aWorkspaceFolderData(URI.file(root), 0)], name: 'Test' }, new NullLogService()); + await (ws.findTextInFiles2({ pattern: 'foo' }, { caseInsensitive: true }, new ExtensionIdentifier('test'))).complete; + assert(mainThreadCalled, 'mainThreadCalled'); + }); + // TODO: test multiple includes/excludess }); }); diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index eeb2d197d82..9d690837308 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -5,9 +5,12 @@ import assert from 'assert'; import * as sinon from 'sinon'; +import type * as vscode from 'vscode'; import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { asSinonMethodStub } from '../../../../base/test/common/sinonUtils.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js'; @@ -15,26 +18,32 @@ import { ContextKeyService } from '../../../../platform/contextkey/browser/conte import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { TestInstantiationService } from '../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; import { ILogService, NullLogService } from '../../../../platform/log/common/log.js'; +import { IAgentSessionsModel } from '../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ChatSessionsService } from '../../../contrib/chat/browser/chatSessions/chatSessions.contribution.js'; -import { IChatAgentRequest } from '../../../contrib/chat/common/participants/chatAgents.js'; import { IChatProgress, IChatProgressMessage, IChatService } from '../../../contrib/chat/common/chatService/chatService.js'; -import { IChatSessionProviderOptionGroup, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; -import { LocalChatSessionUri } from '../../../contrib/chat/common/model/chatUri.js'; +import { IChatSessionProviderOptionGroup, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../../contrib/chat/common/constants.js'; +import { LocalChatSessionUri } from '../../../contrib/chat/common/model/chatUri.js'; +import { IChatAgentRequest, IChatAgentResult } from '../../../contrib/chat/common/participants/chatAgents.js'; +import { MockChatService } from '../../../contrib/chat/test/common/chatService/mockChatService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; import { ExtensionHostKind } from '../../../services/extensions/common/extensionHostKind.js'; -import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IExtensionService, nullExtensionDescription } from '../../../services/extensions/common/extensions.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; import { mock, TestExtensionService } from '../../../test/common/workbenchTestServices.js'; import { MainThreadChatSessions, ObservableChatSession } from '../../browser/mainThreadChatSessions.js'; -import { ExtHostChatSessionsShape, IChatProgressDto, IChatSessionProviderOptions } from '../../common/extHost.protocol.js'; -import { ILabelService } from '../../../../platform/label/common/label.js'; -import { MockChatService } from '../../../contrib/chat/test/common/chatService/mockChatService.js'; -import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { IAgentSessionsModel } from '../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; -import { Event } from '../../../../base/common/event.js'; +import { ExtHostChatSessionsShape, IChatProgressDto, IChatSessionDto, IChatSessionProviderOptions, IChatSessionRequestHistoryItemDto } from '../../common/extHost.protocol.js'; +import { IExtHostAuthentication } from '../../common/extHostAuthentication.js'; +import { ExtHostChatSessions } from '../../common/extHostChatSessions.js'; +import { ExtHostCommands } from '../../common/extHostCommands.js'; +import { ExtHostLanguageModels } from '../../common/extHostLanguageModels.js'; +import { IExtHostTelemetry } from '../../common/extHostTelemetry.js'; +import * as extHostTypes from '../../common/extHostTypes.js'; +import { AnyCallRPCProtocol } from '../common/testRPCProtocol.js'; suite('ObservableChatSession', function () { let disposables: DisposableStore; @@ -63,6 +72,7 @@ suite('ObservableChatSession', function () { $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), $newChatSessionItem: sinon.stub().resolves(undefined), + $forkChatSession: sinon.stub().resolves(undefined), }; }); @@ -79,20 +89,24 @@ suite('ObservableChatSession', function () { history?: any[]; hasActiveResponseCallback?: boolean; hasRequestHandler?: boolean; - } = {}) { + hasForkHandler?: boolean; + } = {}): IChatSessionDto { + const id = options.id || 'test-id'; return { - id: options.id || 'test-id', + resource: LocalChatSessionUri.forSession(id), title: options.title, history: options.history || [], - hasActiveResponseCallback: options.hasActiveResponseCallback || false, - hasRequestHandler: options.hasRequestHandler || false + hasActiveResponseCallback: options.hasActiveResponseCallback ?? false, + hasRequestHandler: options.hasRequestHandler ?? false, + hasForkHandler: options.hasForkHandler ?? false, + supportsInterruption: false, }; } async function createInitializedSession(sessionContent: any, sessionId = 'test-id'): Promise { const resource = LocalChatSessionUri.forSession(sessionId); const session = new ObservableChatSession(resource, 1, proxy, logService, dialogService); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); await session.initialize(CancellationToken.None, { initialSessionOptions: [] }); return session; } @@ -129,7 +143,7 @@ suite('ObservableChatSession', function () { // Initialize the session const sessionContent = createSessionContent(); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); await session.initialize(CancellationToken.None, { initialSessionOptions: [] }); // Now progress should be visible @@ -163,6 +177,49 @@ suite('ObservableChatSession', function () { assert.ok(session.requestHandler); }); + test('initialization sets forkSession and revives forked items', async function () { + const session = disposables.add(await createInitializedSession(createSessionContent({ hasForkHandler: true }))); + assert.ok(session.forkSession); + + const forkedResource = URI.file('/tmp/forked-chat.md'); + const forkedItem = { + resource: forkedResource, + label: 'Forked Session', + timing: { + created: 123, + lastRequestStarted: 234, + lastRequestEnded: 345, + }, + changes: [{ + uri: URI.file('/tmp/changed.ts'), + originalUri: URI.file('/tmp/original.ts'), + insertions: 4, + deletions: 2, + }], + }; + asSinonMethodStub(proxy.$forkChatSession).resolves(forkedItem); + + const request: IChatSessionRequestHistoryItem = { type: 'request', id: 'request-1', prompt: 'Previous question', participant: 'participant' }; + const expectedRequestDto: IChatSessionRequestHistoryItemDto = { + type: 'request', + id: 'request-1', + prompt: 'Previous question', + participant: 'participant', + command: undefined, + variableData: undefined, + modelId: undefined, + }; + const result = await session.forkSession?.(request, CancellationToken.None); + + assert.ok(asSinonMethodStub(proxy.$forkChatSession).calledOnceWithExactly(1, session.sessionResource, expectedRequestDto, CancellationToken.None)); + assert.ok(result); + assert.ok(result.resource instanceof URI); + assert.ok(Array.isArray(result.changes)); + assert.ok(result.changes[0].uri instanceof URI); + assert.ok(result.changes[0].originalUri instanceof URI); + assert.deepStrictEqual(result, forkedItem); + }); + test('initialization sets title from session content', async function () { const sessionContent = createSessionContent({ title: 'My Custom Title', @@ -185,7 +242,7 @@ suite('ObservableChatSession', function () { const session = disposables.add(new ObservableChatSession(resource, 1, proxy, logService, dialogService)); const sessionContent = createSessionContent(); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const promise1 = session.initialize(CancellationToken.None, { initialSessionOptions: [] }); const promise2 = session.initialize(CancellationToken.None, { initialSessionOptions: [] }); @@ -194,7 +251,7 @@ suite('ObservableChatSession', function () { await promise1; // Should only call proxy once even though initialize was called twice - assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce); + assert.ok(asSinonMethodStub(proxy.$provideChatSessionContent).calledOnce); }); test('initialization forwards initial session options context', async function () { @@ -204,11 +261,11 @@ suite('ObservableChatSession', function () { const initialSessionOptions = [{ optionId: 'model', value: 'gpt-4.1' }]; const sessionContent = createSessionContent(); - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); await session.initialize(CancellationToken.None, { initialSessionOptions }); - assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnceWith( + assert.ok(asSinonMethodStub(proxy.$provideChatSessionContent).calledOnceWith( 1, resource, { initialSessionOptions }, @@ -278,7 +335,7 @@ suite('ObservableChatSession', function () { await session.requestHandler!(request, progressCallback, [], CancellationToken.None); - assert.ok((proxy.$invokeChatSessionRequestHandler as sinon.SinonStubbedMember).calledOnceWith(1, session.sessionResource, request, [], CancellationToken.None)); + assert.ok(asSinonMethodStub(proxy.$invokeChatSessionRequestHandler).calledOnceWith(1, session.sessionResource, request, [], CancellationToken.None)); }); test('request handler forwards progress updates to external callback', async function () { @@ -297,12 +354,12 @@ suite('ObservableChatSession', function () { }; const progressCallback = sinon.stub(); - let resolveRequest: () => void; - const requestPromise = new Promise(resolve => { + let resolveRequest: (value: IChatAgentResult) => void; + const requestPromise = new Promise(resolve => { resolveRequest = resolve; }); - (proxy.$invokeChatSessionRequestHandler as sinon.SinonStub).returns(requestPromise); + asSinonMethodStub(proxy.$invokeChatSessionRequestHandler).returns(requestPromise); const requestHandlerPromise = session.requestHandler!(request, progressCallback, [], CancellationToken.None); @@ -320,7 +377,7 @@ suite('ObservableChatSession', function () { assert.deepStrictEqual(progressCallback.secondCall.args[0], [progress2]); // Complete the request - resolveRequest!(); + resolveRequest!({}); await requestHandlerPromise; assert.strictEqual(session.isCompleteObs.get(), true); @@ -339,7 +396,7 @@ suite('ObservableChatSession', function () { session.dispose(); assert.ok(disposeEventFired); - assert.ok((proxy.$disposeChatSessionContent as sinon.SinonStubbedMember).calledOnceWith(1, resource)); + assert.ok(asSinonMethodStub(proxy.$disposeChatSessionContent).calledOnceWith(1, resource)); disposable.dispose(); }); @@ -398,6 +455,7 @@ suite('MainThreadChatSessions', function () { $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), $newChatSessionItem: sinon.stub().resolves(undefined), + $forkChatSession: sinon.stub().resolves(undefined), }; const extHostContext = new class implements IExtHostContext { @@ -457,16 +515,17 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - const resource = URI.parse(`${sessionScheme}:/test-session`); - - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const session1 = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); assert.ok(session1); @@ -474,7 +533,7 @@ suite('MainThreadChatSessions', function () { const session2 = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); assert.strictEqual(session1, session2); - assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce); + assert.ok(asSinonMethodStub(proxy.$provideChatSessionContent).calledOnce); mainThread.$unregisterChatSessionContentProvider(1); }); @@ -482,17 +541,18 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, title: 'My Session Title', history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - const resource = URI.parse(`${sessionScheme}:/test-session`); - - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); assert.strictEqual(session.title, 'My Session Title'); @@ -505,16 +565,18 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession; const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } }; @@ -530,16 +592,18 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession; const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } }; @@ -555,21 +619,22 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'multi-turn-session', + const resource = URI.parse(`${sessionScheme}:/multi-turn-session`); + const sessionContent: IChatSessionDto = { + resource, history: [ - { type: 'request', prompt: 'First question' }, - { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }] }, - { type: 'request', prompt: 'Second question' }, - { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }] } + { type: 'request', prompt: 'First question', participant: 'test-participant' }, + { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }], participant: 'test-participant' }, + { type: 'request', prompt: 'Second question', participant: 'test-participant' }, + { type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }], participant: 'test-participant' } ], hasActiveResponseCallback: false, - hasRequestHandler: false + hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); - - const resource = URI.parse(`${sessionScheme}:/multi-turn-session`); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession; // Verify the session loaded correctly @@ -605,7 +670,7 @@ suite('MainThreadChatSessions', function () { items: [{ id: 'modelB', name: 'Model B' }] }]; - const provideOptionsStub = proxy.$provideChatSessionProviderOptions as sinon.SinonStub; + const provideOptionsStub = asSinonMethodStub(proxy.$provideChatSessionProviderOptions); provideOptionsStub.onFirstCall().resolves({ optionGroups: optionGroups1 } as IChatSessionProviderOptions); provideOptionsStub.onSecondCall().resolves({ optionGroups: optionGroups2 } as IChatSessionProviderOptions); @@ -633,17 +698,18 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, - // No options provided + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // getSessionOption should return undefined for unset options @@ -657,20 +723,22 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, options: { 'models': 'gpt-4', 'region': { id: 'us-east', name: 'US East' } } }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // getSessionOption should return the configured values @@ -689,35 +757,35 @@ suite('MainThreadChatSessions', function () { mainThread.$registerChatSessionContentProvider(handle, sessionScheme); - const sessionContent = { - id: 'test-session', + const sessionContent: IChatSessionDto = { + resource: URI.parse(`${sessionScheme}:/test-session`), history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, options: { 'models': 'gpt-4' } }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // Clear the stub call history - (proxy.$provideHandleOptionsChange as sinon.SinonStub).resetHistory(); + asSinonMethodStub(proxy.$provideHandleOptionsChange).resetHistory(); // Simulate an option change - await chatSessionsService.notifySessionOptionsChange(resource, [ - { optionId: 'models', value: 'gpt-4-turbo' } - ]); + chatSessionsService.setSessionOption(resource, 'models', 'gpt-4-turbo'); // Verify the extension was notified - assert.ok((proxy.$provideHandleOptionsChange as sinon.SinonStub).calledOnce); - const call = (proxy.$provideHandleOptionsChange as sinon.SinonStub).firstCall; + assert.ok(asSinonMethodStub(proxy.$provideHandleOptionsChange).calledOnce); + const call = asSinonMethodStub(proxy.$provideHandleOptionsChange).firstCall; assert.strictEqual(call.args[0], handle); assert.deepStrictEqual(call.args[1], resource); - assert.deepStrictEqual(call.args[2], [{ optionId: 'models', value: 'gpt-4-turbo' }]); + assert.deepStrictEqual(call.args[2], { models: 'gpt-4-turbo' }); mainThread.$unregisterChatSessionContentProvider(handle); }); @@ -730,33 +798,34 @@ suite('MainThreadChatSessions', function () { const resource = URI.parse(`${sessionScheme}:/test-session`); // Clear any previous calls - (proxy.$provideHandleOptionsChange as sinon.SinonStub).resetHistory(); + asSinonMethodStub(proxy.$provideHandleOptionsChange).resetHistory(); // Attempt to notify option change for an unregistered scheme // This should not throw, but also should not call the proxy - await chatSessionsService.notifySessionOptionsChange(resource, [ - { optionId: 'models', value: 'gpt-4-turbo' } - ]); + chatSessionsService.updateSessionOptions(resource, new Map([ + ['models', 'gpt-4-turbo'] + ])); // Verify the extension was NOT notified (no provider registered) - assert.strictEqual((proxy.$provideHandleOptionsChange as sinon.SinonStub).callCount, 0); + assert.strictEqual(asSinonMethodStub(proxy.$provideHandleOptionsChange).callCount, 0); }); test('setSessionOption updates option and getSessionOption reflects change', async function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); - const sessionContent = { - id: 'test-session', + const resource = URI.parse(`${sessionScheme}:/test-session`); + const sessionContent: IChatSessionDto = { + resource, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, - // Start with no options + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent); + asSinonMethodStub(proxy.$provideChatSessionContent).resolves(sessionContent); - const resource = URI.parse(`${sessionScheme}:/test-session`); await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None); // Initially no options set @@ -775,30 +844,34 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; mainThread.$registerChatSessionContentProvider(1, sessionScheme); + const resourceWithOptions = URI.parse(`${sessionScheme}:/session-with-options`); + const resourceWithoutOptions = URI.parse(`${sessionScheme}:/session-without-options`); + // Session with options - const sessionContentWithOptions = { - id: 'session-with-options', + const sessionContentWithOptions: IChatSessionDto = { + resource: resourceWithOptions, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, options: { 'models': 'gpt-4' } }; // Session without options - const sessionContentWithoutOptions = { - id: 'session-without-options', + const sessionContentWithoutOptions: IChatSessionDto = { + resource: resourceWithoutOptions, history: [], hasActiveResponseCallback: false, hasRequestHandler: false, + hasForkHandler: false, + supportsInterruption: false, }; - (proxy.$provideChatSessionContent as sinon.SinonStub) + asSinonMethodStub(proxy.$provideChatSessionContent) .onFirstCall().resolves(sessionContentWithOptions) .onSecondCall().resolves(sessionContentWithoutOptions); - const resourceWithOptions = URI.parse(`${sessionScheme}:/session-with-options`); - const resourceWithoutOptions = URI.parse(`${sessionScheme}:/session-without-options`); - await chatSessionsService.getOrCreateChatSession(resourceWithOptions, CancellationToken.None); await chatSessionsService.getOrCreateChatSession(resourceWithoutOptions, CancellationToken.None); @@ -808,3 +881,135 @@ suite('MainThreadChatSessions', function () { mainThread.$unregisterChatSessionContentProvider(1); }); }); + +suite('ExtHostChatSessions', function () { + let disposables: DisposableStore; + let extHostChatSessions: ExtHostChatSessions; + let mainThreadChatSessionsProxy: { + $registerChatSessionItemController: sinon.SinonStub; + $unregisterChatSessionItemController: sinon.SinonStub; + $updateChatSessionItems: sinon.SinonStub; + $addOrUpdateChatSessionItem: sinon.SinonStub; + $onDidCommitChatSessionItem: sinon.SinonStub; + $registerChatSessionContentProvider: sinon.SinonStub; + $unregisterChatSessionContentProvider: sinon.SinonStub; + $onDidChangeChatSessionOptions: sinon.SinonStub; + $onDidChangeChatSessionProviderOptions: sinon.SinonStub; + }; + + setup(function () { + disposables = new DisposableStore(); + mainThreadChatSessionsProxy = { + $registerChatSessionItemController: sinon.stub(), + $unregisterChatSessionItemController: sinon.stub(), + $updateChatSessionItems: sinon.stub().resolves(), + $addOrUpdateChatSessionItem: sinon.stub().resolves(), + $onDidCommitChatSessionItem: sinon.stub(), + $registerChatSessionContentProvider: sinon.stub(), + $unregisterChatSessionContentProvider: sinon.stub(), + $onDidChangeChatSessionOptions: sinon.stub(), + $onDidChangeChatSessionProviderOptions: sinon.stub(), + }; + + const rpcProtocol = AnyCallRPCProtocol(mainThreadChatSessionsProxy); + const commands = new ExtHostCommands(rpcProtocol, new NullLogService(), new class extends mock() { }); + const languageModels = new ExtHostLanguageModels(rpcProtocol, new NullLogService(), new class extends mock() { }); + + extHostChatSessions = disposables.add(new ExtHostChatSessions(commands, languageModels, rpcProtocol, new NullLogService())); + }); + + teardown(function () { + disposables.dispose(); + sinon.restore(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createContentProvider(session: vscode.ChatSession): vscode.ChatSessionContentProvider { + return { + provideChatSessionContent: async () => session, + }; + } + + test('advertises controller fork support when only the controller registers a fork handler', async function () { + const sessionScheme = 'test-session-type'; + const sessionResource = URI.parse(`${sessionScheme}:/test-session`); + const controller = disposables.add(extHostChatSessions.createChatSessionItemController(nullExtensionDescription, sessionScheme, async () => { })); + controller.forkHandler = async resource => controller.createChatSessionItem(resource.with({ path: '/forked-session' }), 'Forked Session'); + + disposables.add(extHostChatSessions.registerChatSessionContentProvider(nullExtensionDescription, sessionScheme, undefined!, createContentProvider({ + history: [], + requestHandler: undefined, + }))); + + const session = await extHostChatSessions.$provideChatSessionContent(0, sessionResource, { initialSessionOptions: [] }, CancellationToken.None); + + assert.strictEqual(session.hasForkHandler, true); + await extHostChatSessions.$disposeChatSessionContent(0, sessionResource); + }); + + test('prefers controller fork handler over deprecated session fork handler', async function () { + const sessionScheme = 'test-session-type'; + const sessionResource = URI.parse(`${sessionScheme}:/test-session`); + const requestTurn = new extHostTypes.ChatRequestTurn('prompt', undefined, [], 'participant', [], undefined, 'request-1'); + const controller = disposables.add(extHostChatSessions.createChatSessionItemController(nullExtensionDescription, sessionScheme, async () => { })); + const controllerItem = controller.createChatSessionItem(URI.parse(`${sessionScheme}:/forked-by-controller`), 'Forked by Controller'); + const sessionItem = { + resource: URI.parse(`${sessionScheme}:/forked-by-session`), + label: 'Forked by Session' + }; + + const controllerForkHandler = sinon.stub().resolves(controllerItem); + const deprecatedSessionForkHandler = sinon.stub().resolves(sessionItem); + controller.forkHandler = controllerForkHandler; + + disposables.add(extHostChatSessions.registerChatSessionContentProvider(nullExtensionDescription, sessionScheme, undefined!, createContentProvider({ + history: [requestTurn], + requestHandler: undefined, + forkHandler: deprecatedSessionForkHandler, + }))); + + await extHostChatSessions.$provideChatSessionContent(0, sessionResource, { initialSessionOptions: [] }, CancellationToken.None); + const result = await extHostChatSessions.$forkChatSession(0, sessionResource, { + type: 'request', + id: 'request-1', + prompt: 'prompt', + participant: 'participant', + }, CancellationToken.None); + + assert.ok(controllerForkHandler.calledOnceWithExactly(sessionResource, requestTurn, CancellationToken.None)); + assert.strictEqual(deprecatedSessionForkHandler.callCount, 0); + assert.strictEqual(result.resource.toString(), controllerItem.resource.toString()); + assert.strictEqual(result.label, controllerItem.label); + await extHostChatSessions.$disposeChatSessionContent(0, sessionResource); + }); + + test('falls back to deprecated session fork handler when no controller fork handler exists', async function () { + const sessionScheme = 'test-session-type'; + const sessionResource = URI.parse(`${sessionScheme}:/test-session`); + const requestTurn = new extHostTypes.ChatRequestTurn('prompt', undefined, [], 'participant', [], undefined, 'request-1'); + const deprecatedSessionForkHandler = sinon.stub().resolves({ + resource: URI.parse(`${sessionScheme}:/forked-by-session`), + label: 'Forked by Session' + }); + + disposables.add(extHostChatSessions.registerChatSessionContentProvider(nullExtensionDescription, sessionScheme, undefined!, createContentProvider({ + history: [requestTurn], + requestHandler: undefined, + forkHandler: deprecatedSessionForkHandler, + }))); + + await extHostChatSessions.$provideChatSessionContent(0, sessionResource, { initialSessionOptions: [] }, CancellationToken.None); + const result = await extHostChatSessions.$forkChatSession(0, sessionResource, { + type: 'request', + id: 'request-1', + prompt: 'prompt', + participant: 'participant', + }, CancellationToken.None); + + assert.ok(deprecatedSessionForkHandler.calledOnceWithExactly(sessionResource, requestTurn, CancellationToken.None)); + assert.strictEqual(result.resource.toString(), `${sessionScheme}:/forked-by-session`); + assert.strictEqual(result.label, 'Forked by Session'); + await extHostChatSessions.$disposeChatSessionContent(0, sessionResource); + }); +}); diff --git a/src/vs/workbench/browser/actions/quickAccessActions.ts b/src/vs/workbench/browser/actions/quickAccessActions.ts index 35cdd20ccc3..48aca00b6f3 100644 --- a/src/vs/workbench/browser/actions/quickAccessActions.ts +++ b/src/vs/workbench/browser/actions/quickAccessActions.ts @@ -9,13 +9,16 @@ import { KeyMod, KeyCode } from '../../../base/common/keyCodes.js'; import { KeybindingsRegistry, KeybindingWeight, IKeybindingRule } from '../../../platform/keybinding/common/keybindingsRegistry.js'; import { IQuickInputService, ItemActivation, QuickInputHideReason } from '../../../platform/quickinput/common/quickInput.js'; import { IKeybindingService } from '../../../platform/keybinding/common/keybinding.js'; -import { CommandsRegistry } from '../../../platform/commands/common/commands.js'; +import { CommandsRegistry, ICommandService } from '../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { inQuickPickContext, defaultQuickAccessContext, getQuickNavigateHandler } from '../quickaccess.js'; import { ILocalizedString } from '../../../platform/action/common/action.js'; import { AnythingQuickAccessProviderRunOptions } from '../../../platform/quickinput/common/quickAccess.js'; import { Codicon } from '../../../base/common/codicons.js'; +const UNIFIED_AGENTS_BAR_SETTING = 'chat.unifiedAgentsBar.enabled'; + //#region Quick access management commands and keys const globalQuickAccessKeybinding = { @@ -161,16 +164,32 @@ registerAction2(class QuickAccessAction extends Action2 { }); } - run(accessor: ServicesAccessor): void { - const quickInputService = accessor.get(IQuickInputService); - const providerOptions: AnythingQuickAccessProviderRunOptions = { - includeHelp: true, - from: 'commandCenter', + async run(accessor: ServicesAccessor): Promise { + const openClassicQuickAccess = (): void => { + const quickInputService = accessor.get(IQuickInputService); + const providerOptions: AnythingQuickAccessProviderRunOptions = { + includeHelp: true, + from: 'commandCenter', + }; + quickInputService.quickAccess.show(undefined, { + preserveValue: true, + providerOptions + }); }; - quickInputService.quickAccess.show(undefined, { - preserveValue: true, - providerOptions - }); + + const configurationService = accessor.get(IConfigurationService); + const commandService = accessor.get(ICommandService); + const useUnifiedQuickAccess = configurationService.getValue(UNIFIED_AGENTS_BAR_SETTING) === true; + if (useUnifiedQuickAccess) { + try { + await commandService.executeCommand('workbench.action.unifiedQuickAccess'); + } catch { + openClassicQuickAccess(); + } + return; + } + + openClassicQuickAccess(); } }); diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 89f0b6d3b55..c213b29de93 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -100,7 +100,8 @@ enum LayoutClasses { STATUSBAR_HIDDEN = 'nostatusbar', FULLSCREEN = 'fullscreen', MAXIMIZED = 'maximized', - WINDOW_BORDER = 'border' + WINDOW_BORDER = 'border', + NO_SHADOWS = 'no-shadows' } interface IPathToOpen extends IPath { @@ -426,6 +427,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.doUpdateLayoutConfiguration(); } + // Shadows + if (e.affectsConfiguration(LayoutSettings.SHADOWS)) { + this.updateShadows(); + } + // Auxiliary Sidebar if (e.affectsConfiguration(WorkbenchLayoutSettings.AUXILIARYBAR_FORCE_MAXIMIZED)) { const forceMaximized = this.configurationService.getValue(WorkbenchLayoutSettings.AUXILIARYBAR_FORCE_MAXIMIZED); @@ -588,6 +594,18 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.editorGroupService.whenRestored.then(() => this.centerMainEditorLayout(this.stateModel.getRuntimeValue(LayoutStateKeys.MAIN_EDITOR_CENTERED), skipLayout)); } + private isShadowsDisabled(): boolean { + return this.configurationService.getValue(LayoutSettings.SHADOWS) === false; + } + + private updateShadows(): void { + const noShadows = this.isShadowsDisabled(); + + for (const container of Array.from(this.containers)) { + container.classList.toggle(LayoutClasses.NO_SHADOWS, noShadows); + } + } + private setSideBarPosition(position: Position): void { const activityBar = this.getPart(Parts.ACTIVITYBAR_PART); const sideBar = this.getPart(Parts.SIDEBAR_PART); @@ -1857,7 +1875,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi !this.isVisible(Parts.PANEL_PART) ? LayoutClasses.PANEL_HIDDEN : undefined, !this.isVisible(Parts.AUXILIARYBAR_PART) ? LayoutClasses.AUXILIARYBAR_HIDDEN : undefined, !this.isVisible(Parts.STATUSBAR_PART) ? LayoutClasses.STATUSBAR_HIDDEN : undefined, - this.state.runtime.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined + this.state.runtime.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, + this.isShadowsDisabled() ? LayoutClasses.NO_SHADOWS : undefined ]); } diff --git a/src/vs/workbench/browser/media/style.css b/src/vs/workbench/browser/media/style.css index 0d6a2da153b..1f6e583f5e0 100644 --- a/src/vs/workbench/browser/media/style.css +++ b/src/vs/workbench/browser/media/style.css @@ -67,6 +67,27 @@ body { --vscode-shadow-depth-y: 0 5px 10px -4px rgba(0, 0, 0, 0.04); } +/* Disable panel/part shadows when the setting is off (preserves floating modals, notifications, etc.) */ +.monaco-workbench.no-shadows .part.titlebar, +.monaco-workbench.no-shadows .part.activitybar, +.monaco-workbench.no-shadows.nosidebar .part.activitybar, +.monaco-workbench.no-shadows.activitybar-right .part.activitybar, +.monaco-workbench.no-shadows .part.editor .tabs-container > .tab { + box-shadow: none; +} +.monaco-workbench.no-shadows .part.editor .tabs-container > .tab:hover:not(.active), +.monaco-workbench.no-shadows .part.editor .tabs-container > .tab.active { + box-shadow: none; +} +.monaco-workbench.no-shadows { + /* Use zero-offset transparent shadows instead of 'none' because these + * variables are interpolated into multi-value box-shadow declarations. */ + --vscode-shadow-active-tab: 0 0 0 0 transparent; + --vscode-shadow-depth-x: 0 0 0 0 transparent; + --vscode-shadow-depth-y: 0 0 0 0 transparent; + --vscode-shadow-sm: 0 0 0 0 transparent; +} + .monaco-workbench.web { touch-action: none; /* Disable browser handling of all panning and zooming gestures. Removes 300ms touch delay. */ overscroll-behavior: none; /* Prevent bounce effect */ diff --git a/src/vs/workbench/browser/parts/editor/media/editorgroupview.css b/src/vs/workbench/browser/parts/editor/media/editorgroupview.css index 9f63f8390de..4c7da5f5466 100644 --- a/src/vs/workbench/browser/parts/editor/media/editorgroupview.css +++ b/src/vs/workbench/browser/parts/editor/media/editorgroupview.css @@ -51,6 +51,11 @@ inset 0 var(--vscode-shadow-depth-y); } +/* Must appear after positional variants so source-order wins at equal specificity */ +.monaco-workbench.no-shadows.vs .part.editor::after { + box-shadow: none; +} + .monaco-workbench .part.editor > .content .editor-group-container { height: 100%; } diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 26f854253c5..d2ac7485f10 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -170,7 +170,11 @@ export class ModalEditorPart { 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); + const configuredMinWidth = options?.minWidth ?? this.configurationService.getValue('workbench.editor.modalMinWidth'); + const effectiveMinWidth = (typeof configuredMinWidth === 'number' && Number.isFinite(configuredMinWidth) && configuredMinWidth >= MODAL_MIN_WIDTH) + ? configuredMinWidth + : MODAL_MIN_WIDTH; + resizableElement.minSize = new Dimension(effectiveMinWidth, MODAL_MIN_HEIGHT); modalElement.appendChild(resizableElement.domNode); const shadowElement = resizableElement.domNode.appendChild($('.modal-editor-shadow')); diff --git a/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css b/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css index ee7e8a6a64a..a64eda86668 100644 --- a/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css +++ b/src/vs/workbench/browser/parts/notifications/media/notificationsCenter.css @@ -5,7 +5,7 @@ .monaco-workbench > .notifications-center { position: absolute; - z-index: 1000; + z-index: 2545; /* above modal editor backdrop (2540), below quick input (2550) and dialogs (2575) */ right: 7px; /* attempt to position at same location as a toast */ bottom: 29px; /* 22px status bar height + 7px (attempt to position at same location as a toast) */ display: none; diff --git a/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css b/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css index 73764a1003e..d9185ce03f9 100644 --- a/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css +++ b/src/vs/workbench/browser/parts/notifications/media/notificationsToasts.css @@ -5,7 +5,7 @@ .monaco-workbench > .notifications-toasts { position: absolute; - z-index: 1000; + z-index: 2545; /* above modal editor backdrop (2540), below quick input (2550) and dialogs (2575) */ right: 3px; bottom: 25px; /* 22px status bar height + 3px */ display: none; diff --git a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts index 2f28eca7419..cdf065ca432 100644 --- a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts @@ -22,6 +22,11 @@ import { IQuickInputService } from '../../../../platform/quickinput/common/quick import { WindowTitle } from './windowTitle.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; + +const AI_DISABLED_SETTING = 'chat.disableAIFeatures'; +const AI_CUSTOMIZATION_MENU_ENABLED_SETTING = 'chat.customizationsMenu.enabled'; +const AGENT_STATUS_ENABLED_SETTING = 'chat.agentsControl.enabled'; export class CommandCenterControl { @@ -86,6 +91,7 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { @IKeybindingService private _keybindingService: IKeybindingService, @IInstantiationService private _instaService: IInstantiationService, @IEditorGroupsService private _editorGroupService: IEditorGroupsService, + @IConfigurationService private _configurationService: IConfigurationService, ) { super(undefined, _submenu.actions.find(action => action.id === 'workbench.action.quickOpenWithModes') ?? _submenu.actions[0], options); this._hoverDelegate = options.hoverDelegate ?? getDefaultHoverDelegate('mouse'); @@ -143,9 +149,21 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { container.classList.toggle('command-center-quick-pick'); container.role = 'button'; container.setAttribute('aria-description', this.getTooltip()); + + // When agent control mode is 'compact', hide search icon and left-align the label + // Backward compat: the old boolean setting (true) and the new default (undefined) both map to compact + const aiFeaturesDisabled = that._configurationService.getValue(AI_DISABLED_SETTING) === true; + const aiCustomizationsDisabled = that._configurationService.getValue('disableAICustomizations') === true + || that._configurationService.getValue('workbench.disableAICustomizations') === true + || that._configurationService.getValue(AI_CUSTOMIZATION_MENU_ENABLED_SETTING) === false; + const forcedHidden = aiFeaturesDisabled && aiCustomizationsDisabled; + const agentControlValue = that._configurationService.getValue(AGENT_STATUS_ENABLED_SETTING); + const isCompactMode = !forcedHidden && (agentControlValue === true || agentControlValue === undefined || agentControlValue === 'compact'); + container.classList.toggle('compact-mode', isCompactMode); + const action = this.action; - // icon (search) + // icon (search) - hidden in compact mode const searchIcon = document.createElement('span'); searchIcon.ariaHidden = 'true'; searchIcon.className = action.class ?? ''; @@ -156,7 +174,11 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { const labelElement = document.createElement('span'); labelElement.classList.add('search-label'); labelElement.textContent = label; - reset(container, searchIcon, labelElement); + if (isCompactMode) { + reset(container, labelElement); + } else { + reset(container, searchIcon, labelElement); + } const hover = this._store.add(that._hoverService.setupManagedHover(that._hoverDelegate, container, this.getTooltip())); diff --git a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css index 9f8ee2dca9f..7a255b58d0f 100644 --- a/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css +++ b/src/vs/workbench/browser/parts/titlebar/media/titlebarpart.css @@ -203,6 +203,25 @@ text-overflow: ellipsis; } +/* Compact mode: left-aligned label, no icon, full width */ +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center .action-item.command-center-quick-pick.compact-mode { + margin: auto auto auto 0; + padding-left: 8px; + flex: 1; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:has(.compact-mode) > .monaco-toolbar > .monaco-action-bar > .actions-container { + margin: 0; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:has(.compact-mode) > .monaco-toolbar { + flex: 1; +} + +.monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:has(.compact-mode) > .monaco-toolbar > .monaco-action-bar { + width: 100%; +} + .monaco-workbench .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center.multiple { justify-content: flex-start; padding: 0 12px; diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index c19e3074e3f..ae3425dec08 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -365,6 +365,13 @@ const registry = Registry.as(ConfigurationExtensions.Con mode: 'auto' } }, + 'workbench.editor.modalMinWidth': { + 'type': 'number', + 'description': localize('modalMinWidth', "Controls the minimum width of modal editor overlays in pixels."), + 'default': 400, + 'minimum': 0, + 'multipleOf': 1 + }, 'workbench.editor.swipeToNavigate': { 'type': 'boolean', 'description': localize('swipeToNavigate', "Navigate between open files using three-finger swipe horizontally. Note that System Preferences > Trackpad > More Gestures > 'Swipe between pages' must be set to 'Swipe with two or three fingers'."), @@ -784,6 +791,11 @@ const registry = Registry.as(ConfigurationExtensions.Con 'default': true, 'description': localize('tips.enabled', "When enabled, will show the watermark tips when no editor is open.") }, + [LayoutSettings.SHADOWS]: { + 'type': 'boolean', + 'default': true, + 'description': localize('shadows', "Controls whether shadow effects are shown around the side panels and other workbench elements.") + }, } }); diff --git a/src/vs/workbench/common/views.ts b/src/vs/workbench/common/views.ts index b439e870035..6bd3eab98cb 100644 --- a/src/vs/workbench/common/views.ts +++ b/src/vs/workbench/common/views.ts @@ -364,6 +364,13 @@ export interface IViewContainerModel { readonly keybindingId: string | undefined; readonly onDidChangeContainerInfo: Event<{ title?: boolean; icon?: boolean; keybindingId?: boolean; badgeEnablement?: boolean }>; + /** + * Re-reads the container info (title, icon, keybinding) and fires + * `onDidChangeContainerInfo` if anything changed. Call this when + * the container's dynamic title has been updated externally. + */ + refreshContainerInfo(): void; + readonly allViewDescriptors: ReadonlyArray; readonly onDidChangeAllViewDescriptors: Event<{ added: ReadonlyArray; removed: ReadonlyArray }>; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 78bf737d962..c4f7bb4bb27 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -6,28 +6,23 @@ import './media/browser.css'; import { localize } from '../../../../nls.js'; import { $, addDisposableListener, Dimension, EventType, IDomPosition, registerExternalFocusChecker } from '../../../../base/browser/dom.js'; -import { Button, ButtonBar } from '../../../../base/browser/ui/button/button.js'; +import { ButtonBar } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; -import { RawContextKey, IContextKey, IContextKeyService, ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { RawContextKey, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, IConstructorSignature, BrandedService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; -import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { - IBrowserEditorViewState, - IBrowserViewModel -} from '../../browserView/common/browserView.js'; -import { IBrowserZoomService } from '../../browserView/common/browserZoomService.js'; +import { IBrowserEditorViewState, IBrowserViewModel } from '../../browserView/common/browserView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; -import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError, BrowserNewPageLocation, browserZoomFactors, browserZoomLabel, browserZoomAccessibilityLabel } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, IBrowserViewCertificateError, BrowserNewPageLocation } from '../../../../platform/browserView/common/browserView.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -36,53 +31,24 @@ import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js import { BrowserOverlayManager, BrowserOverlayType, IBrowserOverlayInfo } from './overlayManager.js'; import { getZoomFactor, onDidChangeZoomLevel } from '../../../../base/browser/browser.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { disposableTimeout } from '../../../../base/common/async.js'; -import { Lazy } from '../../../../base/common/lazy.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { IBrowserElementsService } from '../../../services/browserElements/browser/browserElementsService.js'; -import { IChatWidgetService } from '../../chat/browser/chat.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; -import { BrowserFindWidget, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE } from './browserFindWidget.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { SiteInfoWidget } from './siteInfoWidget.js'; -import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; -import { IElementAncestor, IElementData, IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../platform/browserElements/common/browserElements.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; import { URI } from '../../../../base/common/uri.js'; -import { ChatConfiguration } from '../../chat/common/constants.js'; -import { Event } from '../../../../base/common/event.js'; +import { Emitter } from '../../../../base/common/event.js'; import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; -import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.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")); export const CONTEXT_BROWSER_FOCUSED = new RawContextKey('browserFocused', true, localize('browser.editorFocused', "Whether the browser editor is focused")); -export const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view")); export const CONTEXT_BROWSER_HAS_URL = new RawContextKey('browserHasUrl', false, localize('browser.hasUrl', "Whether the browser has a URL loaded")); export const CONTEXT_BROWSER_HAS_ERROR = new RawContextKey('browserHasError', false, localize('browser.hasError', "Whether the browser has a load error")); -export const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); -export const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); -export const CONTEXT_BROWSER_CAN_ZOOM_IN = new RawContextKey('browserCanZoomIn', true, localize('browser.canZoomIn', "Whether the browser can zoom in further")); -export const CONTEXT_BROWSER_CAN_ZOOM_OUT = new RawContextKey('browserCanZoomOut', true, localize('browser.canZoomOut', "Whether the browser can zoom out further")); - -// Re-export find widget context keys for use in actions -export { CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE }; - -const canShareBrowserWithAgentContext = ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.has(`config.${ChatConfiguration.AgentEnabled}`), - ContextKeyExpr.has(`config.workbench.browser.enableChatTools`), -)!; -function watchForAgentSharingContextChanges(contextKeyService: IContextKeyService): Event { - const agentSharingKeys = new Set(canShareBrowserWithAgentContext.keys()); - return Event.filter(contextKeyService.onDidChangeContext, e => e.affectsSome(agentSharingKeys)); -} /** * Get the original implementation of HTMLElement focus (without window auto-focusing) @@ -90,55 +56,78 @@ function watchForAgentSharingContextChanges(contextKeyService: IContextKeyServic */ const originalHtmlElementFocus = HTMLElement.prototype.focus; -/** - * Transient zoom-level indicator that briefly appears inside the URL bar on zoom changes. - * All DOM construction, state, and auto-hide logic are self-contained here. - */ -class BrowserZoomPill extends Disposable { - readonly element: HTMLElement; - private readonly _icon: HTMLElement; - private readonly _label: HTMLElement; - private readonly _timeout = this._register(new MutableDisposable()); - constructor() { +/** + * Base class for browser editor services that track the model lifecycle. + * + * Subclasses implement {@link subscribeToModel} which is called whenever a new model is set. + * A {@link DisposableStore} is provided that is automatically cleared when the model + * changes or the editor input is cleared. + */ +export abstract class BrowserEditorContribution extends Disposable { + private readonly _modelStore = this._register(new DisposableStore()); + + constructor(protected readonly editor: BrowserEditor) { super(); - this.element = $('.browser-zoom-pill'); - // Don't announce this transient element; the zoom level is announced via IAccessibilityService.status() in showZoomPill() - this.element.setAttribute('aria-hidden', 'true'); - this._icon = $('span'); - this._label = $('span'); - this.element.appendChild(this._icon); - this.element.appendChild(this._label); + this._register(editor.onDidChangeModel(model => { + this._modelStore.clear(); + if (model) { + this.subscribeToModel(model, this._modelStore); + } else { + this.clear(); + } + })); } /** - * Briefly show the zoom level, then auto-hide after 750 ms. + * Called whenever the editor model changes to update state. */ - show(zoomLabel: string, isAtOrAboveDefault: boolean): void { - this._icon.className = ThemeIcon.asClassName(isAtOrAboveDefault ? Codicon.zoomIn : Codicon.zoomOut); - this._label.textContent = zoomLabel; - this.element.classList.add('visible'); - // Reset auto-hide timer so rapid zoom actions extend the display - this._timeout.value = disposableTimeout(() => { - this.element.classList.remove('visible'); - }, 750); // Chrome shows the zoom level for 1.5 seconds, but we show it for less because ours is non-interactive - } + protected subscribeToModel(_model: IBrowserViewModel, _store: DisposableStore): void { } + + /** + * Called when the model is cleared to reset state. + */ + clear(): void { } + + /** + * Optional widgets to display inside the URL bar (on the right side of the URL input, + * before the actions toolbar). + * Contributions can override this getter to provide widgets. + */ + get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { return []; } + + /** + * Optional toolbar-like elements to insert into the editor root between the navbar and the + * browser container. Contributions can override this getter to provide elements. + */ + get toolbarElements(): readonly HTMLElement[] { return []; } + + /** + * Called when the editor is laid out with a new dimension. + */ + layout(_width: number): void { } +} + +/** + * A widget that can be contributed to the browser editor URL bar. + */ +export interface IBrowserEditorWidgetContribution { + readonly element: HTMLElement; + /** Ordering value — lower numbers appear first (left). */ + readonly order: number; } class BrowserNavigationBar extends Disposable { private readonly _urlInput: HTMLInputElement; private readonly _urlDisplay: HTMLElement; - private readonly _shareButton: Button; - private readonly _shareButtonContainer: HTMLElement; private readonly _siteInfoWidget: SiteInfoWidget; - private readonly _zoomPill: BrowserZoomPill; + private readonly _urlBarWidgetsContainer: HTMLElement; constructor( editor: BrowserEditor, container: HTMLElement, instantiationService: IInstantiationService, - scopedContextKeyService: IContextKeyService, - configurationService: IConfigurationService + scopedContextKeyService: IContextKeyService ) { super(); @@ -194,23 +183,11 @@ class BrowserNavigationBar extends Disposable { urlInputWrapper.appendChild(this._urlDisplay); urlInputWrapper.appendChild(this._urlInput); - // Share toggle button (inside URL bar, right side) - this._shareButtonContainer = $('.browser-share-toggle-container'); - this._shareButton = this._register(new Button(this._shareButtonContainer, { - supportIcons: true, - title: localize('browser.shareWithAgent', "Share with Agent"), - small: true, - hoverDelegate - })); - this._shareButton.element.classList.add('browser-share-toggle'); - this._shareButton.label = '$(agent)'; - - this._zoomPill = this._register(new BrowserZoomPill()); + this._urlBarWidgetsContainer = $('.browser-url-bar-widgets'); urlContainer.appendChild(siteInfoContainer); urlContainer.appendChild(urlInputWrapper); - urlContainer.appendChild(this._zoomPill.element); - urlContainer.appendChild(this._shareButtonContainer); + urlContainer.appendChild(this._urlBarWidgetsContainer); // Create actions toolbar (right side) with scoped context const actionsContainer = $('.browser-actions-toolbar'); @@ -256,33 +233,6 @@ class BrowserNavigationBar extends Disposable { this._register(addDisposableListener(this._urlDisplay, EventType.FOCUS, () => { this._showInput(); })); - - // Share toggle click handler - this._register(this._shareButton.onDidClick(() => { - editor.toggleShareWithAgent(); - })); - - // Show share button only when chat is enabled and browser tools are enabled - const updateShareButtonVisibility = () => { - this._shareButtonContainer.style.display = scopedContextKeyService.contextMatchesRules(canShareBrowserWithAgentContext) ? '' : 'none'; - }; - updateShareButtonVisibility(); - this._register(watchForAgentSharingContextChanges(scopedContextKeyService)(() => { - updateShareButtonVisibility(); - })); - } - - /** - * Update the share toggle visual state - */ - setShared(isShared: boolean): void { - this._shareButton.checked = isShared; - this._shareButton.label = isShared - ? localize('browser.sharingWithAgent', "Sharing with Agent") + ' $(agent)' - : '$(agent)'; - this._shareButton.setTitle(isShared - ? localize('browser.unshareWithAgent', "Stop Sharing with Agent") - : localize('browser.shareWithAgent', "Share with Agent")); } /** @@ -320,10 +270,13 @@ class BrowserNavigationBar extends Disposable { } /** - * Briefly show the zoom level indicator pill, then auto-hide. + * Add widget elements inside the URL bar, sorted by order. */ - showZoomLevel(zoomLabel: string, isAtOrAboveDefault: boolean): void { - this._zoomPill.show(zoomLabel, isAtOrAboveDefault); + addUrlBarWidgets(widgets: readonly IBrowserEditorWidgetContribution[]): void { + const sorted = widgets.slice().sort((a, b) => a.order - b.order); + for (const widget of sorted) { + this._urlBarWidgetsContainer.appendChild(widget.element); + } } /** @@ -371,6 +324,28 @@ class BrowserNavigationBar extends Disposable { } export class BrowserEditor extends EditorPane { + + // -- Contribution registry -------------------------------------------- + + private static readonly _contributions: IConstructorSignature[] = []; + static registerContribution(ctor: { new(editor: BrowserEditor, ...services: Services): BrowserEditorContribution }): void { + BrowserEditor._contributions.push(ctor as IConstructorSignature); + } + + private readonly _contributionInstances = new Map, BrowserEditorContribution>(); + getContribution(ctor: { new(editor: BrowserEditor, ...services: Services): T }): T | undefined { + return this._contributionInstances.get(ctor as IConstructorSignature) as T | undefined; + } + + // -- Model lifecycle ------------------------------------------------ + + private _model: IBrowserViewModel | undefined; + get model(): IBrowserViewModel | undefined { return this._model; } + private readonly _onDidChangeModel = this._register(new Emitter()); + readonly onDidChangeModel = this._onDidChangeModel.event; + + // -- State ---------------------------------------------------------- + private _overlayVisible = false; private _editorVisible = false; private _currentKeyDownEvent: IBrowserViewKeyDownEvent | undefined; @@ -378,29 +353,20 @@ export class BrowserEditor extends EditorPane { private _navigationBar!: BrowserNavigationBar; private _browserContainerWrapper!: HTMLElement; private _browserContainer!: HTMLElement; + get browserContainer(): HTMLElement { return this._browserContainer; } private _placeholderScreenshot!: HTMLElement; private _overlayPauseContainer!: HTMLElement; private _overlayPauseHeading!: HTMLElement; private _overlayPauseDetail!: HTMLElement; private _errorContainer!: HTMLElement; private _welcomeContainer!: HTMLElement; - private _findWidgetContainer!: HTMLElement; - private _findWidget!: Lazy; private _canGoBackContext!: IContextKey; private _canGoForwardContext!: IContextKey; - private _storageScopeContext!: IContextKey; private _hasUrlContext!: IContextKey; private _hasErrorContext!: IContextKey; - private _devToolsOpenContext!: IContextKey; - private _elementSelectionActiveContext!: IContextKey; - private _canZoomInContext!: IContextKey; - private _canZoomOutContext!: IContextKey; - private _model: IBrowserViewModel | undefined; private readonly _inputDisposables = this._register(new DisposableStore()); private overlayManager: BrowserOverlayManager | undefined; - private _elementSelectionCts: CancellationTokenSource | undefined; - private _consoleSessionCts: CancellationTokenSource | undefined; private _screenshotTimeout: ReturnType | undefined; private readonly _certActionButton = this._register(new MutableDisposable()); @@ -414,12 +380,7 @@ export class BrowserEditor extends EditorPane { @IInstantiationService private readonly instantiationService: IInstantiationService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IEditorService private readonly editorService: IEditorService, - @IBrowserElementsService private readonly browserElementsService: IBrowserElementsService, - @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, - @IConfigurationService private readonly configurationService: IConfigurationService, @ILayoutService private readonly layoutService: ILayoutService, - @IBrowserZoomService private readonly browserZoomService: IBrowserZoomService, - @IAccessibilityService private readonly accessibilityService: IAccessibilityService ) { super(BrowserEditorInput.EDITOR_ID, group, telemetryService, themeService, storageService); } @@ -434,48 +395,48 @@ export class BrowserEditor extends EditorPane { // Bind navigation capability context keys this._canGoBackContext = CONTEXT_BROWSER_CAN_GO_BACK.bindTo(contextKeyService); this._canGoForwardContext = CONTEXT_BROWSER_CAN_GO_FORWARD.bindTo(contextKeyService); - this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService); this._hasUrlContext = CONTEXT_BROWSER_HAS_URL.bindTo(contextKeyService); this._hasErrorContext = CONTEXT_BROWSER_HAS_ERROR.bindTo(contextKeyService); - this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); - this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService); - this._canZoomInContext = CONTEXT_BROWSER_CAN_ZOOM_IN.bindTo(contextKeyService); - this._canZoomOutContext = CONTEXT_BROWSER_CAN_ZOOM_OUT.bindTo(contextKeyService); // Currently this is always true since it is scoped to the editor container CONTEXT_BROWSER_FOCUSED.bindTo(contextKeyService); + // Create a scoped instantiation service so contributions get the scoped context key service + const scopedInstantiationService = this._register(this.instantiationService.createChild( + new ServiceCollection([IContextKeyService, contextKeyService]) + )); + + // Instantiate all registered contributions + for (const ctor of BrowserEditor._contributions) { + const instance = this._register(scopedInstantiationService.createInstance(ctor, this)); + this._contributionInstances.set(ctor, instance); + } + // Create root container const root = $('.browser-root'); parent.appendChild(root); - // Create toolbar with navigation buttons and URL input - const toolbar = $('.browser-toolbar'); + // Create navbar with navigation buttons and URL input + const navbar = $('.browser-navbar'); // Create navigation bar widget with scoped context - this._navigationBar = this._register(new BrowserNavigationBar(this, toolbar, this.instantiationService, contextKeyService, this.configurationService)); + this._navigationBar = this._register(new BrowserNavigationBar(this, navbar, this.instantiationService, contextKeyService)); - root.appendChild(toolbar); + // Inject URL bar widgets from contributions + const allWidgets: IBrowserEditorWidgetContribution[] = []; + for (const contribution of this._contributionInstances.values()) { + allWidgets.push(...contribution.urlBarWidgets); + } + this._navigationBar.addUrlBarWidgets(allWidgets); - // Create find widget container (between toolbar and browser container) - this._findWidgetContainer = $('.browser-find-widget-wrapper'); - root.appendChild(this._findWidgetContainer); + root.appendChild(navbar); - // Create find widget (lazy initialization) - this._findWidget = new Lazy(() => { - const findWidget = this.instantiationService.createInstance( - BrowserFindWidget, - this._findWidgetContainer - ); - if (this._model) { - findWidget.setModel(this._model); + // Collect toolbar elements from contributions (e.g. find widget container) + for (const contribution of this._contributionInstances.values()) { + for (const element of contribution.toolbarElements) { + root.appendChild(element); } - findWidget.onDidChangeHeight(() => { - this.layoutBrowserContainer(); - }); - return findWidget; - }); - this._register(toDisposable(() => this._findWidget.rawValue?.dispose())); + } // Create browser container wrapper (flex item that fills remaining space) this._browserContainerWrapper = $('.browser-container-wrapper'); @@ -518,10 +479,16 @@ export class BrowserEditor extends EditorPane { // When the browser container gets focus, make sure the browser view also gets focused. // But only if focus was already in the workbench (and not e.g. clicking back into the workbench from the browser view). if (event.relatedTarget && this._model && this.shouldShowView) { - void this._model.focus(); + this.requestFocus(); } })); + this._register(addDisposableListener(this._browserContainer, EventType.BLUR, () => { + // If the container becomes blurred, cancel any scheduled focus call. + // This can happen when e.g. a menu closes and focus shifts back to the browser, then immediately focuses another element. + this.cancelFocus(); + })); + // Register external focus checker so that cross-window focus logic knows when // this browser view has focus (since it's outside the normal DOM tree). // Include window info so that UI like dialogs appear in the correct window. @@ -531,6 +498,35 @@ export class BrowserEditor extends EditorPane { }))); } + override focus(): void { + if (this._model?.url && !this._model.error) { + this.requestFocus(); + } else { + this.focusUrlInput(); + } + } + + private _focusTimeout: ReturnType | undefined; + private requestFocus(): void { + this.ensureBrowserFocus(); + if (this._focusTimeout) { + return; + } + this._focusTimeout = setTimeout(() => { + this._focusTimeout = undefined; + if (this._model) { + void this._model.focus(); + } + }, 0); + } + + private cancelFocus(): void { + if (this._focusTimeout) { + clearTimeout(this._focusTimeout); + this._focusTimeout = undefined; + } + } + override async setInput(input: BrowserEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { await super.setInput(input, options, context, token); if (token.isCancellationRequested) { @@ -541,35 +537,13 @@ export class BrowserEditor extends EditorPane { // Resolve the browser view model from the input const model = await input.resolve(); - this._model = model; + if (token.isCancellationRequested || this.input !== input) { return; } - this._storageScopeContext.set(this._model.storageScope); - this._devToolsOpenContext.set(this._model.isDevToolsOpen); - this.updateZoomContext(); - this._updateSharingState(true); - - // Update find widget with new model - this._findWidget.rawValue?.setModel(this._model); - - // Clean up on input disposal - this._inputDisposables.add(input.onWillDispose(() => { - this._model = undefined; - })); - - // Listen for sharing state changes on the model - this._inputDisposables.add(this._model.onDidChangeSharedWithAgent(() => { - this._updateSharingState(false); - })); - this._inputDisposables.add(watchForAgentSharingContextChanges(this.contextKeyService)(() => { - this._updateSharingState(false); - })); - - this._inputDisposables.add(this._model.onDidChangeZoom(() => { - this.updateZoomContext(); - })); + this._model = model; + this._onDidChangeModel.fire(model); // Initialize UI state and context keys from model this.updateNavigationState({ @@ -581,18 +555,6 @@ export class BrowserEditor extends EditorPane { }); this.setBackgroundImage(this._model.screenshot); - if (!options?.preserveFocus) { - setTimeout(() => { - if (this._model === model) { - if (this._model.url) { - this._browserContainer.focus(); - } else { - this.focusUrlInput(); - } - } - }, 0); - } - // Start / stop screenshots when the model visibility changes this._inputDisposables.add(this._model.onDidChangeVisibility(() => this.doScreenshot())); @@ -607,13 +569,6 @@ export class BrowserEditor extends EditorPane { // Update navigation bar and context keys from model this.updateNavigationState(navEvent); - - // Ensure a console session is active while a page URL is loaded. - if (navEvent.url) { - this.startConsoleSession(); - } else { - this.stopConsoleSession(); - } })); this._inputDisposables.add(this._model.onDidChangeLoadingState(() => { @@ -629,10 +584,6 @@ export class BrowserEditor extends EditorPane { } })); - this._inputDisposables.add(this._model.onDidChangeDevToolsState(e => { - this._devToolsOpenContext.set(e.isDevToolsOpen); - })); - this._inputDisposables.add(this._model.onDidRequestNewPage(({ resource, url, location, position }) => { logBrowserOpen(this.telemetryService, (() => { switch (location) { @@ -674,11 +625,6 @@ export class BrowserEditor extends EditorPane { this.layout(); this.updateVisibility(); this.doScreenshot(); - - // Start console log capture session if a URL is loaded - if (this._model.url) { - this.startConsoleSession(); - } } protected override setEditorVisible(visible: boolean): void { @@ -689,7 +635,7 @@ export class BrowserEditor extends EditorPane { /** * Make the browser container the active element without moving focus from the browser view. */ - private ensureBrowserFocus(): void { + ensureBrowserFocus(): void { originalHtmlElementFocus.call(this._browserContainer); } @@ -724,7 +670,7 @@ export class BrowserEditor extends EditorPane { this._browserContainer.ownerDocument.activeElement === this._browserContainer ) { // If the editor is focused, ensure the browser view also gets focus - void this._model.focus(); + this.requestFocus(); } } else { this.doScreenshot(); @@ -917,22 +863,6 @@ export class BrowserEditor extends EditorPane { this._model?.untrustCertificate(certError.host, certError.fingerprint); } - private _updateSharingState(isInitialState: boolean): void { - const sharingEnabled = this.contextKeyService.contextMatchesRules(canShareBrowserWithAgentContext); - const isShared = sharingEnabled && !!this._model && this._model.sharedWithAgent; - - this._browserContainer.classList.toggle('animate', !isInitialState); - this._browserContainer.classList.toggle('shared', isShared); - this._navigationBar.setShared(isShared); - } - - toggleShareWithAgent(): void { - if (!this._model) { - return; - } - this._model.setSharedWithAgent(!this._model.sharedWithAgent); - } - async navigateToUrl(url: string): Promise { if (this._model) { this.group.pinEditor(this.input); // pin editor on navigation @@ -974,381 +904,6 @@ export class BrowserEditor extends EditorPane { return this._model?.clearStorage(); } - async zoomIn(): Promise { - await this._model?.zoomIn(); - this.showZoomPill(); - } - - async zoomOut(): Promise { - await this._model?.zoomOut(); - this.showZoomPill(); - } - - async resetZoom(): Promise { - await this._model?.resetZoom(); - this.showZoomPill(); - } - - private showZoomPill(): void { - if (!this._model) { - return; - } - const defaultIndex = this.browserZoomService.getEffectiveZoomIndex(undefined, false); - const defaultFactor = browserZoomFactors[defaultIndex]; - const currentFactor = this._model.zoomFactor; - const label = browserZoomLabel(currentFactor); - this._navigationBar.showZoomLevel(label, currentFactor >= defaultFactor); - // Announce the new zoom level to screen readers (polite, non-interruptive). - this.accessibilityService.status(browserZoomAccessibilityLabel(currentFactor)); - } - - private updateZoomContext(): void { - if (this._model) { - this._canZoomInContext.set(this._model.canZoomIn); - this._canZoomOutContext.set(this._model.canZoomOut); - } - } - - /** - * Show the find widget, optionally pre-populated with selected text from the browser view - */ - async showFind(): Promise { - // Get selected text from the browser view to pre-populate the search box. - const selectedText = (await this._model?.getSelectedText())?.trim(); - - // Only use the selected text if it doesn't contain newlines (single line selection) - const textToReveal = selectedText && !/[\r\n]/.test(selectedText) ? selectedText : undefined; - this._findWidget.value.reveal(textToReveal); - this._findWidget.value.layout(this._findWidgetContainer.clientWidth); - } - - /** - * Hide the find widget - */ - hideFind(): void { - this._findWidget.rawValue?.hide(); - } - - /** - * Find the next match - */ - findNext(): void { - this._findWidget.rawValue?.find(false); - } - - /** - * Find the previous match - */ - findPrevious(): void { - this._findWidget.rawValue?.find(true); - } - - /** - * Start element selection in the browser view, wait for a user selection, and add it to chat. - */ - async addElementToChat(): Promise { - // If selection is already active, cancel it - if (this._elementSelectionCts) { - this._elementSelectionCts.dispose(true); - this._elementSelectionCts = undefined; - this._elementSelectionActiveContext.set(false); - return; - } - - // Start new selection - const cts = new CancellationTokenSource(); - this._elementSelectionCts = cts; - this._elementSelectionActiveContext.set(true); - - type IntegratedBrowserAddElementToChatStartEvent = {}; - - type IntegratedBrowserAddElementToChatStartClassification = { - owner: 'jruales'; - comment: 'The user initiated an Add Element to Chat action in Integrated Browser.'; - }; - - this.telemetryService.publicLog2('integratedBrowser.addElementToChat.start', {}); - - try { - // Get the resource URI for this editor - const resourceUri = this.input?.resource; - if (!resourceUri) { - throw new Error('No resource URI found'); - } - - // Make the browser the focused view - this.ensureBrowserFocus(); - - // Create a locator - for integrated browser, use the URI scheme to identify - // Browser view URIs have a special scheme we can match against - const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(this.input.resource) }; - - // Start debug session for integrated browser - await this.browserElementsService.startDebugSession(cts.token, locator); - - // Get the browser container bounds - const { width, height } = this._browserContainer.getBoundingClientRect(); - - // Get element data from user selection - const elementData = await this.browserElementsService.getElementData({ x: 0, y: 0, width, height }, cts.token, locator); - if (!elementData) { - throw new Error('Element data not found'); - } - - const { attachCss, attachImages } = await this.attachElementDataToChat(elementData); - - type IntegratedBrowserAddElementToChatAddedEvent = { - attachCss: boolean; - attachImages: boolean; - }; - - type IntegratedBrowserAddElementToChatAddedClassification = { - attachCss: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachCSS was enabled.' }; - attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachImages was enabled.' }; - owner: 'jruales'; - comment: 'An element was successfully added to chat from Integrated Browser.'; - }; - - this.telemetryService.publicLog2('integratedBrowser.addElementToChat.added', { - attachCss, - attachImages - }); - - } catch (error) { - if (!cts.token.isCancellationRequested) { - this.logService.error('BrowserEditor.addElementToChat: Failed to select element', error); - } - } finally { - cts.dispose(); - if (this._elementSelectionCts === cts) { - this._elementSelectionCts = undefined; - this._elementSelectionActiveContext.set(false); - } - } - } - - /** - * Grab the current console logs from the active console session and attach them to chat. - */ - async addConsoleLogsToChat(): Promise { - const resourceUri = this.input?.resource; - if (!resourceUri) { - return; - } - - const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(resourceUri) }; - - try { - const logs = await this.browserElementsService.getConsoleLogs(locator); - if (!logs) { - return; - } - - const toAttach: IChatRequestVariableEntry[] = []; - toAttach.push({ - id: 'console-logs-' + Date.now(), - name: localize('consoleLogs', 'Console Logs'), - fullName: localize('consoleLogs', 'Console Logs'), - value: logs, - modelDescription: 'Console logs captured from Integrated Browser.', - kind: 'element', - icon: ThemeIcon.fromId(Codicon.terminal.id), - }); - - const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; - widget?.attachmentModel?.addContext(...toAttach); - } catch (error) { - this.logService.error('BrowserEditor.addConsoleLogsToChat: Failed to get console logs', error); - } - } - - /** - * Start a console session to capture logs from the browser view. - */ - private startConsoleSession(): void { - // Don't restart if already running - if (this._consoleSessionCts) { - return; - } - - const resourceUri = this.input?.resource; - if (!resourceUri || !this._model?.url) { - return; - } - - const cts = new CancellationTokenSource(); - this._consoleSessionCts = cts; - const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(resourceUri) }; - - this.browserElementsService.startConsoleSession(cts.token, locator).catch(error => { - if (!cts.token.isCancellationRequested) { - this.logService.error('BrowserEditor: Failed to start console session', error); - } - }); - } - - /** - * Stop the active console session. - */ - private stopConsoleSession(): void { - if (this._consoleSessionCts) { - this._consoleSessionCts.dispose(true); - this._consoleSessionCts = undefined; - } - } - - private createElementContextValue(elementData: IElementData, displayName: string, attachCss: boolean): string { - const sections: string[] = []; - sections.push('Attached Element Context from Integrated Browser'); - sections.push(`Element: ${displayName}`); - - const htmlPath = this.formatElementPath(elementData.ancestors); - if (htmlPath) { - sections.push(`HTML Path:\n${htmlPath}`); - } - - const attributeTable = this.formatElementMap(elementData.attributes); - if (attributeTable) { - sections.push(`Attributes:\n${attributeTable}`); - } - - if (attachCss) { - const computedStyleTable = this.formatElementMap(elementData.computedStyles); - if (computedStyleTable) { - sections.push(`Computed Styles:\n${computedStyleTable}`); - } - } - - if (elementData.dimensions) { - const { top, left, width, height } = elementData.dimensions; - sections.push( - `Dimensions:\n- top: ${Math.round(top)}px\n- left: ${Math.round(left)}px\n- width: ${Math.round(width)}px\n- height: ${Math.round(height)}px` - ); - } - - const innerText = elementData.innerText?.trim(); - if (innerText) { - sections.push(`Inner Text:\n\`\`\`text\n${innerText}\n\`\`\``); - } - - sections.push(`Outer HTML:\n\`\`\`html\n${elementData.outerHTML}\n\`\`\``); - - if (attachCss) { - sections.push(`Full Computed CSS:\n\`\`\`css\n${elementData.computedStyle}\n\`\`\``); - } - - return sections.join('\n\n'); - } - - private async attachElementDataToChat(elementData: IElementData): Promise<{ attachCss: boolean; attachImages: boolean }> { - const bounds = elementData.bounds; - const toAttach: IChatRequestVariableEntry[] = []; - - const displayName = getDisplayNameFromOuterHTML(elementData.outerHTML); - const attachCss = this.configurationService.getValue('chat.sendElementsToChat.attachCSS'); - const value = this.createElementContextValue(elementData, displayName, attachCss); - - toAttach.push({ - id: 'element-' + Date.now(), - name: displayName, - fullName: displayName, - value: value, - modelDescription: attachCss - ? 'Structured browser element context with HTML path, attributes, and computed styles.' - : 'Structured browser element context with HTML path and attributes.', - kind: 'element', - icon: ThemeIcon.fromId(Codicon.layout.id), - ancestors: elementData.ancestors, - attributes: elementData.attributes, - computedStyles: attachCss ? elementData.computedStyles : undefined, - dimensions: elementData.dimensions, - innerText: elementData.innerText, - }); - - const attachImages = this.configurationService.getValue('chat.sendElementsToChat.attachImages'); - if (attachImages && this._model) { - const screenshotBuffer = await this._model.captureScreenshot({ - quality: 90, - rect: bounds - }); - - toAttach.push({ - id: 'element-screenshot-' + Date.now(), - name: 'Element Screenshot', - fullName: 'Element Screenshot', - kind: 'image', - value: screenshotBuffer.buffer - }); - } - - const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; - widget?.attachmentModel?.addContext(...toAttach); - - return { attachCss, attachImages }; - } - - private formatElementPath(ancestors: readonly IElementAncestor[] | undefined): string | undefined { - if (!ancestors || ancestors.length === 0) { - return undefined; - } - - return ancestors - .map(ancestor => { - const classes = ancestor.classNames?.length ? `.${ancestor.classNames.join('.')}` : ''; - const id = ancestor.id ? `#${ancestor.id}` : ''; - return `${ancestor.tagName}${id}${classes}`; - }) - .join(' > '); - } - - private formatElementMap(entries: Readonly> | undefined): string | undefined { - if (!entries || Object.keys(entries).length === 0) { - return undefined; - } - - const normalizedEntries = new Map(Object.entries(entries)); - const lines: string[] = []; - - const marginShorthand = this.createBoxShorthand(normalizedEntries, 'margin'); - if (marginShorthand) { - lines.push(`- margin: ${marginShorthand}`); - } - - const paddingShorthand = this.createBoxShorthand(normalizedEntries, 'padding'); - if (paddingShorthand) { - lines.push(`- padding: ${paddingShorthand}`); - } - - for (const [name, value] of Array.from(normalizedEntries.entries()).sort(([a], [b]) => a.localeCompare(b))) { - lines.push(`- ${name}: ${value}`); - } - - return lines.join('\n'); - } - - private createBoxShorthand(entries: Map, propertyName: 'margin' | 'padding'): string | undefined { - const topKey = `${propertyName}-top`; - const rightKey = `${propertyName}-right`; - const bottomKey = `${propertyName}-bottom`; - const leftKey = `${propertyName}-left`; - - const top = entries.get(topKey); - const right = entries.get(rightKey); - const bottom = entries.get(bottomKey); - const left = entries.get(leftKey); - - if (top === undefined || right === undefined || bottom === undefined || left === undefined) { - return undefined; - } - - entries.delete(topKey); - entries.delete(rightKey); - entries.delete(bottomKey); - entries.delete(leftKey); - - return `${top} ${right} ${bottom} ${left}`; - } - /** * Update navigation state and context keys */ @@ -1445,34 +1000,6 @@ export class BrowserEditor extends EditorPane { this._currentKeyDownEvent = keyEvent; try { - const isEnterKey = - keyEvent.code === 'Enter' || - keyEvent.code === 'NumpadEnter' || - keyEvent.key === 'Enter' || - keyEvent.key === 'Return'; - if (this._elementSelectionCts && isEnterKey) { - const cts = this._elementSelectionCts; - const resourceUri = this.input?.resource; - if (!resourceUri) { - return; - } - - const locator: IBrowserTargetLocator = { browserViewId: BrowserViewUri.getId(resourceUri) }; - const { width, height } = this._browserContainer.getBoundingClientRect(); - const elementData = await this.browserElementsService.getFocusedElementData({ x: 0, y: 0, width, height }, cts.token, locator); - if (!elementData) { - return; - } - - await this.attachElementDataToChat(elementData); - cts.dispose(); - if (this._elementSelectionCts === cts) { - this._elementSelectionCts = undefined; - this._elementSelectionActiveContext.set(false); - } - return; - } - const syntheticEvent = new KeyboardEvent('keydown', keyEvent); const standardEvent = new StandardKeyboardEvent(syntheticEvent); @@ -1488,9 +1015,10 @@ export class BrowserEditor extends EditorPane { } override layout(dimension?: Dimension, _position?: IDomPosition): void { - // Layout find widget if it exists - if (dimension && this._findWidget.rawValue) { - this._findWidget.rawValue.layout(dimension.width); + if (dimension) { + for (const contribution of this._contributionInstances.values()) { + contribution.layout(dimension.width); + } } const whenContainerStylesLoaded = this.layoutService.whenContainerStylesLoaded(this.window); @@ -1508,7 +1036,7 @@ export class BrowserEditor extends EditorPane { * 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. */ - private layoutBrowserContainer(): void { + layoutBrowserContainer(): void { if (this._model) { this.checkOverlays(); @@ -1530,34 +1058,18 @@ export class BrowserEditor extends EditorPane { override clearInput(): void { this._inputDisposables.clear(); - // Cancel any active element selection - if (this._elementSelectionCts) { - this._elementSelectionCts.dispose(true); - this._elementSelectionCts = undefined; - } - - // Cancel any active console session - this.stopConsoleSession(); - - // Cancel any scheduled screenshots + // Cancel any scheduled timers this.cancelScheduledScreenshot(); - - // Clear find widget model - this._findWidget.rawValue?.setModel(undefined); - this._findWidget.rawValue?.hide(); + this.cancelFocus(); void this._model?.setVisible(false); this._model = undefined; + this._onDidChangeModel.fire(undefined); this._canGoBackContext.reset(); this._canGoForwardContext.reset(); this._hasUrlContext.reset(); this._hasErrorContext.reset(); - this._storageScopeContext.reset(); - this._devToolsOpenContext.reset(); - this._elementSelectionActiveContext.reset(); - this._canZoomInContext.reset(); - this._canZoomOutContext.reset(); this._navigationBar.clear(); this.setBackgroundImage(undefined); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts deleted file mode 100644 index dc4aba47e85..00000000000 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserFindWidget.ts +++ /dev/null @@ -1,189 +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 { SimpleFindWidget } from '../../codeEditor/browser/find/simpleFindWidget.js'; -import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; -import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -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, toDisposable } from '../../../../base/common/lifecycle.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { getWindow } from '../../../../base/browser/dom.js'; - -export const CONTEXT_BROWSER_FIND_WIDGET_VISIBLE = new RawContextKey('browserFindWidgetVisible', false, localize('browser.findWidgetVisible', "Whether the browser find widget is visible")); -export const CONTEXT_BROWSER_FIND_WIDGET_FOCUSED = new RawContextKey('browserFindWidgetFocused', false, localize('browser.findWidgetFocused', "Whether the browser find widget is focused")); - -/** - * Find widget for the integrated browser view. - * Uses the SimpleFindWidget base class and communicates with the browser view model - * to perform find operations in the rendered web page. - */ -export class BrowserFindWidget extends SimpleFindWidget { - private _model: IBrowserViewModel | undefined; - private readonly _modelDisposables = this._register(new DisposableStore()); - private readonly _findWidgetVisible: IContextKey; - private readonly _findWidgetFocused: IContextKey; - private _lastFindResult: { resultIndex: number; resultCount: number } | undefined; - private _hasFoundMatch = false; - - private readonly _onDidChangeHeight = this._register(new Emitter()); - readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; - - constructor( - private readonly container: HTMLElement, - @IContextViewService contextViewService: IContextViewService, - @IContextKeyService contextKeyService: IContextKeyService, - @IHoverService hoverService: IHoverService, - @IKeybindingService keybindingService: IKeybindingService, - @IConfigurationService configurationService: IConfigurationService, - @IAccessibilityService accessibilityService: IAccessibilityService - ) { - super({ - showCommonFindToggles: true, - checkImeCompletionState: true, - showResultCount: true, - enableSash: true, - initialWidth: 350, - previousMatchActionId: BrowserViewCommandId.FindPrevious, - nextMatchActionId: BrowserViewCommandId.FindNext, - closeWidgetActionId: BrowserViewCommandId.HideFind - }, contextViewService, contextKeyService, hoverService, keybindingService, configurationService, accessibilityService); - - this._findWidgetVisible = CONTEXT_BROWSER_FIND_WIDGET_VISIBLE.bindTo(contextKeyService); - this._findWidgetFocused = CONTEXT_BROWSER_FIND_WIDGET_FOCUSED.bindTo(contextKeyService); - - const domNode = this.getDomNode(); - container.appendChild(domNode); - - let lastHeight = domNode.offsetHeight; - const resizeObserver = new (getWindow(container).ResizeObserver)(() => { - const newHeight = domNode.offsetHeight; - if (newHeight !== lastHeight) { - lastHeight = newHeight; - this._onDidChangeHeight.fire(); - } - }); - resizeObserver.observe(domNode); - this._register(toDisposable(() => resizeObserver.disconnect())); - } - - /** - * Set the browser view model to use for find operations. - * This should be called whenever the editor input changes. - */ - setModel(model: IBrowserViewModel | undefined): void { - this._modelDisposables.clear(); - this._model = model; - this._lastFindResult = undefined; - this._hasFoundMatch = false; - - if (model) { - this._modelDisposables.add(model.onDidFindInPage(result => { - this._lastFindResult = { - resultIndex: result.activeMatchOrdinal - 1, // Convert to 0-based index - resultCount: result.matches - }; - this._hasFoundMatch = result.matches > 0; - this.updateButtons(this._hasFoundMatch); - this.updateResultCount(); - })); - - this._modelDisposables.add(model.onWillDispose(() => { - this.setModel(undefined); - })); - } - } - - override reveal(initialInput?: string): void { - const wasVisible = this.isVisible(); - super.reveal(initialInput); - this._findWidgetVisible.set(true); - this.container.classList.toggle('find-visible', true); - - // Focus the find input - this.focusFindBox(); - - // If there's existing input and the widget wasn't already visible, trigger a search - if (this.inputValue && !wasVisible) { - this._onInputChanged(); - } - } - - override hide(): void { - super.hide(false); - this._findWidgetVisible.reset(); - this.container.classList.toggle('find-visible', false); - - // Stop find and clear highlights in the browser view - this._model?.stopFindInPage(true); - this._model?.focus(); - this._lastFindResult = undefined; - this._hasFoundMatch = false; - } - - find(previous: boolean): void { - const value = this.inputValue; - if (value && this._model) { - this._model.findInPage(value, { - forward: !previous, - recompute: false, - matchCase: this._getCaseSensitiveValue() - }); - } - } - - findFirst(): void { - const value = this.inputValue; - if (value && this._model) { - this._model.findInPage(value, { - forward: true, - recompute: true, - matchCase: this._getCaseSensitiveValue() - }); - } - } - - clear(): void { - if (this._model) { - this._model.stopFindInPage(false); - this._lastFindResult = undefined; - this._hasFoundMatch = false; - } - } - - protected _onInputChanged(): boolean { - if (this.inputValue) { - this.findFirst(); - } else if (this._model) { - this.clear(); - } - return false; - } - - protected async _getResultCount(): Promise<{ resultIndex: number; resultCount: number } | undefined> { - return this._lastFindResult; - } - - protected _onFocusTrackerFocus(): void { - this._findWidgetFocused.set(true); - } - - protected _onFocusTrackerBlur(): void { - this._findWidgetFocused.reset(); - } - - protected _onFindInputFocusTrackerFocus(): void { - // No-op - } - - protected _onFindInputFocusTrackerBlur(): void { - // No-op - } -} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index c616b961209..ac62107adcd 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -11,36 +11,23 @@ import { EditorExtensions, IEditorFactoryRegistry } from '../../../common/editor import { BrowserEditor } from './browserEditor.js'; import { BrowserEditorInput, BrowserEditorSerializer } from '../common/browserEditorInput.js'; import { BrowserViewUri } from '../../../../platform/browserView/common/browserViewUri.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; import { IEditorResolverService, RegisteredEditorPriority } from '../../../services/editor/common/editorResolverService.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { Schemas } from '../../../../base/common/network.js'; -import { IBrowserViewWorkbenchService, IBrowserViewCDPService } from '../common/browserView.js'; +import { IBrowserViewCDPService, IBrowserViewWorkbenchService } from '../common/browserView.js'; import { BrowserViewWorkbenchService } from './browserViewWorkbenchService.js'; import { BrowserViewCDPService } from './browserViewCDPService.js'; -import { BrowserZoomService, IBrowserZoomService, MATCH_WINDOW_ZOOM_LABEL } from '../common/browserZoomService.js'; -import { browserZoomFactors, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; -import { IExternalOpener, IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { PolicyCategory } from '../../../../base/common/policy.js'; -import { getZoomLevel, onDidChangeZoomLevel } from '../../../../base/browser/browser.js'; -import { mainWindow } from '../../../../base/browser/window.js'; -import { zoomLevelToZoomFactor } from '../../../../platform/window/common/window.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; -// Register actions and browser tools +// Register actions and browser features import './browserViewActions.js'; -import './tools/browserTools.contribution.js'; +import './features/browserDataStorageFeatures.js'; +import './features/browserDevToolsFeature.js'; +import './features/browserEditorChatFeatures.js'; +import './features/browserEditorZoomFeature.js'; +import './features/browserEditorFindFeature.js'; +import './features/browserTabManagementFeatures.js'; Registry.as(EditorExtensions.EditorPane).registerEditorPane( EditorPaneDescriptor.create( @@ -107,152 +94,5 @@ class BrowserEditorResolverContribution implements IWorkbenchContribution { registerWorkbenchContribution2(BrowserEditorResolverContribution.ID, BrowserEditorResolverContribution, WorkbenchPhase.BlockStartup); -/** - * Opens localhost URLs in the Integrated Browser when the setting is enabled. - */ -class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchContribution, IExternalOpener { - static readonly ID = 'workbench.contrib.localhostLinkOpener'; - - constructor( - @IOpenerService openerService: IOpenerService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IEditorService private readonly editorService: IEditorService, - @ITelemetryService private readonly telemetryService: ITelemetryService - ) { - super(); - - this._register(openerService.registerExternalOpener(this)); - } - - async openExternal(href: string, _ctx: { sourceUri: URI; preferredOpenerId?: string }, _token: CancellationToken): Promise { - if (!this.configurationService.getValue('workbench.browser.openLocalhostLinks')) { - return false; - } - - try { - const parsed = new URL(href); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - return false; - } - if (!isLocalhostAuthority(parsed.host)) { - return false; - } - } catch { - return false; - } - - logBrowserOpen(this.telemetryService, 'localhostLinkOpener'); - - const browserUri = BrowserViewUri.forId(generateUuid()); - await this.editorService.openEditor({ resource: browserUri, options: { pinned: true, viewState: { url: href } } }); - return true; - } -} - -registerWorkbenchContribution2(LocalhostLinkOpenerContribution.ID, LocalhostLinkOpenerContribution, WorkbenchPhase.BlockStartup); - -/** - * Bridges the application's UI zoom level changes into IBrowserZoomService so that - * views using the 'Match Window' default zoom level stay in sync. - */ -class WindowZoomSynchronizer extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.browserView.windowZoomSynchronizer'; - - constructor(@IBrowserZoomService browserZoomService: IBrowserZoomService) { - super(); - browserZoomService.notifyWindowZoomChanged(zoomLevelToZoomFactor(getZoomLevel(mainWindow))); - this._register(onDidChangeZoomLevel(() => { - browserZoomService.notifyWindowZoomChanged(zoomLevelToZoomFactor(getZoomLevel(mainWindow))); - })); - } -} - -registerWorkbenchContribution2(WindowZoomSynchronizer.ID, WindowZoomSynchronizer, WorkbenchPhase.BlockRestore); - registerSingleton(IBrowserViewWorkbenchService, BrowserViewWorkbenchService, InstantiationType.Delayed); registerSingleton(IBrowserViewCDPService, BrowserViewCDPService, InstantiationType.Delayed); -registerSingleton(IBrowserZoomService, BrowserZoomService, InstantiationType.Delayed); - -Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ - ...workbenchConfigurationNodeBase, - properties: { - 'workbench.browser.showInTitleBar': { - type: 'boolean', - default: false, - experiment: { mode: 'startup' }, - description: localize( - { comment: ['This is the description for a setting.'], key: 'browser.showInTitleBar' }, - 'Controls whether the Integrated Browser button is shown in the title bar.' - ) - }, - 'workbench.browser.openLocalhostLinks': { - type: 'boolean', - default: false, - markdownDescription: localize( - { comment: ['This is the description for a setting.'], key: 'browser.openLocalhostLinks' }, - 'When enabled, localhost links from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' - ) - }, - 'workbench.browser.enableChatTools': { - type: 'boolean', - default: false, - experiment: { mode: 'startup' }, - tags: ['experimental'], - markdownDescription: localize( - { comment: ['This is the description for a setting.'], key: 'browser.enableChatTools' }, - 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.' - ), - policy: { - name: 'BrowserChatTools', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.110', - value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, - localization: { - description: { - key: 'browser.enableChatTools', - value: localize('browser.enableChatTools', 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.') - } - }, - } - }, - 'workbench.browser.pageZoom': { - type: 'string', - enum: [MATCH_WINDOW_ZOOM_LABEL, ...browserZoomFactors.map(f => `${Math.round(f * 100)}%`)], - markdownEnumDescriptions: [ - localize( - { comment: ['This is the description for a setting enum value.'], key: 'browser.defaultZoomLevel.matchWindow' }, - 'Matches the application\'s current UI zoom level.' - ), - ...browserZoomFactors.map(() => ''), - ], - default: MATCH_WINDOW_ZOOM_LABEL, - markdownDescription: localize( - { comment: ['This is the description for a setting.'], key: 'browser.pageZoom' }, - 'Default zoom level for all sites in the Integrated Browser.' - ), - // Zoom can change from machine to machine, so we don't need the workspace-level nor syncing that WINDOW has. - scope: ConfigurationScope.MACHINE - }, - 'workbench.browser.dataStorage': { - type: 'string', - enum: [ - BrowserViewStorageScope.Global, - BrowserViewStorageScope.Workspace, - BrowserViewStorageScope.Ephemeral - ], - markdownEnumDescriptions: [ - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.global' }, 'All browser views share a single persistent session across all workspaces.'), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.workspace' }, 'Browser views within the same workspace share a persistent session. If no workspace is opened, `ephemeral` storage is used.'), - localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.') - ], - restricted: true, - default: BrowserViewStorageScope.Global, - markdownDescription: localize( - { comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage' }, - 'Controls how browser data (cookies, cache, storage) is shared between browser views.\n\n**Note**: In untrusted workspaces, this setting is ignored and `ephemeral` storage is always used.' - ), - scope: ConfigurationScope.WINDOW, - order: 100 - } - } -}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index c28b2b634d1..eb04e8d3a73 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -3,108 +3,29 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize, localize2 } from '../../../../nls.js'; +import { localize2 } from '../../../../nls.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { Action2, registerAction2, MenuId } from '../../../../platform/actions/common/actions.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { KeyMod, KeyCode } from '../../../../base/common/keyCodes.js'; -import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_CAN_ZOOM_IN, CONTEXT_BROWSER_CAN_ZOOM_OUT, 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 { generateUuid } from '../../../../base/common/uuid.js'; -import { IBrowserViewWorkbenchService } from '../common/browserView.js'; -import { BrowserViewCommandId, BrowserViewStorageScope } from '../../../../platform/browserView/common/browserView.js'; -import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; +import { BrowserEditor, CONTEXT_BROWSER_CAN_GO_BACK, CONTEXT_BROWSER_CAN_GO_FORWARD, CONTEXT_BROWSER_FOCUSED, CONTEXT_BROWSER_HAS_URL } from './browserEditor.js'; +import { BrowserViewCommandId } from '../../../../platform/browserView/common/browserView.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IPreferencesService } from '../../../services/preferences/common/preferences.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; -import { ToggleTitleBarConfigAction } from '../../../browser/parts/titlebar/titlebarActions.js'; // Context key expression to check if browser editor is active -const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); +export const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); -const BrowserCategory = localize2('browserCategory', "Browser"); -const ActionGroupTabs = '1_tabs'; -const ActionGroupZoom = '2_zoom'; -const ActionGroupPage = '3_page'; -const ActionGroupSettings = '4_settings'; - -interface IOpenBrowserOptions { - url?: string; - openToSide?: boolean; -} - -class OpenIntegratedBrowserAction extends Action2 { - constructor() { - super({ - id: BrowserViewCommandId.Open, - title: localize2('browser.openAction', "Open Integrated Browser"), - category: BrowserCategory, - icon: Codicon.globe, - f1: true, - menu: { - id: MenuId.TitleBar, - group: 'navigation', - order: 10, - when: ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', true) - } - }); - } - - async run(accessor: ServicesAccessor, urlOrOptions?: string | IOpenBrowserOptions): Promise { - const editorService = accessor.get(IEditorService); - const telemetryService = accessor.get(ITelemetryService); - - // Parse arguments - const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : (urlOrOptions ?? {}); - const resource = BrowserViewUri.forId(generateUuid()); - const group = options.openToSide ? SIDE_GROUP : ACTIVE_GROUP; - - logBrowserOpen(telemetryService, options.url ? 'commandWithUrl' : 'commandWithoutUrl'); - - const editorPane = await editorService.openEditor({ resource, options: { viewState: { url: options.url } } }, group); - - // Lock the group when opening to the side - if (options.openToSide && editorPane?.group) { - editorPane.group.lock(true); - } - } -} - -class NewTabAction extends Action2 { - constructor() { - super({ - id: BrowserViewCommandId.NewTab, - title: localize2('browser.newTabAction', "New Tab"), - category: BrowserCategory, - f1: true, - precondition: BROWSER_EDITOR_ACTIVE, - menu: { - id: MenuId.BrowserActionsToolbar, - group: ActionGroupTabs, - order: 1, - }, - // When already in a browser, Ctrl/Cmd + T opens a new tab - keybinding: { - weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over search actions - primary: KeyMod.CtrlCmd | KeyCode.KeyT, - } - }); - } - - async run(accessor: ServicesAccessor, _browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - const editorService = accessor.get(IEditorService); - const telemetryService = accessor.get(ITelemetryService); - const resource = BrowserViewUri.forId(generateUuid()); - - logBrowserOpen(telemetryService, 'newTabCommand'); - - await editorService.openEditor({ resource }); - } +export const BrowserActionCategory = localize2('browserCategory', "Browser"); +export enum BrowserActionGroup { + Tabs = '1_tabs', + Zoom = '2_zoom', + Page = '3_page', + Settings = '4_settings' } class GoBackAction extends Action2 { @@ -114,7 +35,7 @@ class GoBackAction extends Action2 { super({ id: GoBackAction.ID, title: localize2('browser.goBackAction', 'Go Back'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.arrowLeft, f1: true, precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_BACK), @@ -146,7 +67,7 @@ class GoForwardAction extends Action2 { super({ id: GoForwardAction.ID, title: localize2('browser.goForwardAction', 'Go Forward'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.arrowRight, f1: true, precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_FORWARD), @@ -178,7 +99,7 @@ class ReloadAction extends Action2 { super({ id: ReloadAction.ID, title: localize2('browser.reloadAction', 'Reload'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.refresh, f1: true, precondition: BROWSER_EDITOR_ACTIVE, @@ -216,7 +137,7 @@ class HardReloadAction extends Action2 { super({ id: HardReloadAction.ID, title: localize2('browser.hardReloadAction', 'Hard Reload'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.refresh, f1: true, precondition: BROWSER_EDITOR_ACTIVE, @@ -244,7 +165,7 @@ class FocusUrlInputAction extends Action2 { super({ id: FocusUrlInputAction.ID, title: localize2('browser.focusUrlInputAction', 'Focus URL Input'), - category: BrowserCategory, + category: BrowserActionCategory, f1: true, precondition: BROWSER_EDITOR_ACTIVE, keybinding: { @@ -261,102 +182,6 @@ class FocusUrlInputAction extends Action2 { } } -class AddElementToChatAction extends Action2 { - static readonly ID = BrowserViewCommandId.AddElementToChat; - - constructor() { - const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); - super({ - id: AddElementToChatAction.ID, - title: localize2('browser.addElementToChatAction', 'Add Element to Chat'), - category: BrowserCategory, - icon: Codicon.inspect, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), enabled), - toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, - menu: { - id: MenuId.BrowserActionsToolbar, - group: 'actions', - order: 1, - when: enabled - }, - keybinding: [{ - weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over terminal - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC, - }, { - when: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyCode.Escape - }] - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.addElementToChat(); - } - } -} - -class AddConsoleLogsToChatAction extends Action2 { - static readonly ID = BrowserViewCommandId.AddConsoleLogsToChat; - - constructor() { - const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); - super({ - id: AddConsoleLogsToChatAction.ID, - title: localize2('browser.addConsoleLogsToChatAction', 'Add Console Logs to Chat'), - category: BrowserCategory, - icon: Codicon.output, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), enabled), - menu: { - id: MenuId.BrowserActionsToolbar, - group: 'actions', - order: 2, - when: enabled - } - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.addConsoleLogsToChat(); - } - } -} - -class ToggleDevToolsAction extends Action2 { - static readonly ID = BrowserViewCommandId.ToggleDevTools; - - constructor() { - super({ - id: ToggleDevToolsAction.ID, - title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), - category: BrowserCategory, - icon: Codicon.terminal, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), - menu: { - id: MenuId.BrowserActionsToolbar, - group: 'actions', - order: 3, - }, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyCode.F12 - } - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.toggleDevTools(); - } - } -} - class OpenInExternalBrowserAction extends Action2 { static readonly ID = BrowserViewCommandId.OpenExternal; @@ -364,14 +189,14 @@ class OpenInExternalBrowserAction extends Action2 { super({ id: OpenInExternalBrowserAction.ID, title: localize2('browser.openExternalAction', 'Open in External Browser'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.linkExternal, f1: true, // Note: We do allow opening in an external browser even if there is an error page shown precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL), menu: { id: MenuId.BrowserActionsToolbar, - group: ActionGroupPage, + group: BrowserActionGroup.Page, order: 10 } }); @@ -393,83 +218,6 @@ class OpenInExternalBrowserAction extends Action2 { } } -class ClearGlobalBrowserStorageAction extends Action2 { - static readonly ID = BrowserViewCommandId.ClearGlobalStorage; - - constructor() { - super({ - id: ClearGlobalBrowserStorageAction.ID, - title: localize2('browser.clearGlobalStorageAction', 'Clear Storage (Global)'), - category: BrowserCategory, - icon: Codicon.clearAll, - f1: true, - menu: { - id: MenuId.BrowserActionsToolbar, - group: ActionGroupSettings, - order: 1, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Global) - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); - await browserViewWorkbenchService.clearGlobalStorage(); - } -} - -class ClearWorkspaceBrowserStorageAction extends Action2 { - static readonly ID = BrowserViewCommandId.ClearWorkspaceStorage; - - constructor() { - super({ - id: ClearWorkspaceBrowserStorageAction.ID, - title: localize2('browser.clearWorkspaceStorageAction', 'Clear Storage (Workspace)'), - category: BrowserCategory, - icon: Codicon.clearAll, - f1: true, - menu: { - id: MenuId.BrowserActionsToolbar, - group: ActionGroupSettings, - order: 1, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Workspace) - } - }); - } - - async run(accessor: ServicesAccessor): Promise { - const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); - await browserViewWorkbenchService.clearWorkspaceStorage(); - } -} - -class ClearEphemeralBrowserStorageAction extends Action2 { - static readonly ID = BrowserViewCommandId.ClearEphemeralStorage; - - constructor() { - super({ - id: ClearEphemeralBrowserStorageAction.ID, - title: localize2('browser.clearEphemeralStorageAction', 'Clear Storage (Ephemeral)'), - category: BrowserCategory, - icon: Codicon.clearAll, - f1: true, - precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral), - menu: { - id: MenuId.BrowserActionsToolbar, - group: ActionGroupSettings, - order: 1, - when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral) - } - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.clearStorage(); - } - } -} - class OpenBrowserSettingsAction extends Action2 { static readonly ID = BrowserViewCommandId.OpenSettings; @@ -477,12 +225,12 @@ class OpenBrowserSettingsAction extends Action2 { super({ id: OpenBrowserSettingsAction.ID, title: localize2('browser.openSettingsAction', 'Open Browser Settings'), - category: BrowserCategory, + category: BrowserActionCategory, icon: Codicon.settingsGear, f1: false, menu: { id: MenuId.BrowserActionsToolbar, - group: ActionGroupSettings, + group: BrowserActionGroup.Settings, order: 2 } }); @@ -494,257 +242,12 @@ class OpenBrowserSettingsAction extends Action2 { } } -// Zoom actions - -class ZoomInAction extends Action2 { - static readonly ID = 'workbench.action.browser.zoomIn'; - - constructor() { - super({ - id: ZoomInAction.ID, - title: localize2('browser.zoomInAction', 'Zoom In'), - category: BrowserCategory, - icon: Codicon.zoomIn, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - menu: { - id: MenuId.BrowserActionsToolbar, - group: ActionGroupZoom, - order: 1, - when: CONTEXT_BROWSER_CAN_ZOOM_IN, - }, - keybinding: { - when: CONTEXT_BROWSER_FOCUSED, - weight: KeybindingWeight.WorkbenchContrib + 75, - // Same shortcuts as 'workbench.action.zoomIn' - primary: KeyMod.CtrlCmd | KeyCode.Equal, - secondary: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Equal, KeyMod.CtrlCmd | KeyCode.NumpadAdd], - }, - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.zoomIn(); - } - } -} - -class ZoomOutAction extends Action2 { - static readonly ID = 'workbench.action.browser.zoomOut'; - - constructor() { - super({ - id: ZoomOutAction.ID, - title: localize2('browser.zoomOutAction', 'Zoom Out'), - category: BrowserCategory, - icon: Codicon.zoomOut, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - menu: { - id: MenuId.BrowserActionsToolbar, - group: ActionGroupZoom, - order: 2, - when: CONTEXT_BROWSER_CAN_ZOOM_OUT, - }, - keybinding: { - when: CONTEXT_BROWSER_FOCUSED, - weight: KeybindingWeight.WorkbenchContrib + 75, - // Same shortcuts as 'workbench.action.zoomOut' - primary: KeyMod.CtrlCmd | KeyCode.Minus, - secondary: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Minus, KeyMod.CtrlCmd | KeyCode.NumpadSubtract], - linux: { - primary: KeyMod.CtrlCmd | KeyCode.Minus, - secondary: [KeyMod.CtrlCmd | KeyCode.NumpadSubtract] - } - }, - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.zoomOut(); - } - } -} - -class ResetZoomAction extends Action2 { - static readonly ID = 'workbench.action.browser.resetZoom'; - - constructor() { - super({ - id: ResetZoomAction.ID, - title: localize2('browser.resetZoomAction', 'Reset Zoom'), - category: BrowserCategory, - icon: Codicon.screenNormal, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - menu: { - id: MenuId.BrowserActionsToolbar, - group: ActionGroupZoom, - order: 3, - }, - keybinding: { - when: CONTEXT_BROWSER_FOCUSED, - weight: KeybindingWeight.WorkbenchContrib + 75, - // Same shortcuts as 'workbench.action.zoomReset' - // (note: both workbench and here use Numpad0 instead of Digit0 to avoid conflicts with keybinding to focus sidebar.) - primary: KeyMod.CtrlCmd | KeyCode.Numpad0, - }, - }); - } - - async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { - if (browserEditor instanceof BrowserEditor) { - await browserEditor.resetZoom(); - } - } -} - -// Find actions - -class ShowBrowserFindAction extends Action2 { - static readonly ID = BrowserViewCommandId.ShowFind; - - constructor() { - super({ - id: ShowBrowserFindAction.ID, - title: localize2('browser.showFindAction', 'Find in Page'), - category: BrowserCategory, - f1: true, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), - menu: { - id: MenuId.BrowserActionsToolbar, - group: ActionGroupPage, - order: 1, - }, - keybinding: { - weight: KeybindingWeight.EditorContrib, - primary: KeyMod.CtrlCmd | KeyCode.KeyF - } - }); - } - - run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): void { - if (browserEditor instanceof BrowserEditor) { - browserEditor.showFind(); - } - } -} - -class HideBrowserFindAction extends Action2 { - static readonly ID = BrowserViewCommandId.HideFind; - - constructor() { - super({ - id: HideBrowserFindAction.ID, - title: localize2('browser.hideFindAction', 'Close Find Widget'), - category: BrowserCategory, - f1: false, - precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), - keybinding: { - weight: KeybindingWeight.EditorContrib + 5, - primary: KeyCode.Escape - } - }); - } - - run(accessor: ServicesAccessor): void { - const browserEditor = accessor.get(IEditorService).activeEditorPane; - if (browserEditor instanceof BrowserEditor) { - browserEditor.hideFind(); - } - } -} - -class BrowserFindNextAction extends Action2 { - static readonly ID = BrowserViewCommandId.FindNext; - - constructor() { - super({ - id: BrowserFindNextAction.ID, - title: localize2('browser.findNextAction', 'Find Next'), - category: BrowserCategory, - f1: false, - precondition: BROWSER_EDITOR_ACTIVE, - keybinding: [{ - when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, - weight: KeybindingWeight.EditorContrib, - primary: KeyCode.Enter - }, { - when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, - weight: KeybindingWeight.EditorContrib, - primary: KeyCode.F3, - mac: { primary: KeyMod.CtrlCmd | KeyCode.KeyG } - }] - }); - } - - run(accessor: ServicesAccessor): void { - const browserEditor = accessor.get(IEditorService).activeEditorPane; - if (browserEditor instanceof BrowserEditor) { - browserEditor.findNext(); - } - } -} - -class BrowserFindPreviousAction extends Action2 { - static readonly ID = BrowserViewCommandId.FindPrevious; - - constructor() { - super({ - id: BrowserFindPreviousAction.ID, - title: localize2('browser.findPreviousAction', 'Find Previous'), - category: BrowserCategory, - f1: false, - precondition: BROWSER_EDITOR_ACTIVE, - keybinding: [{ - when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, - weight: KeybindingWeight.EditorContrib, - primary: KeyMod.Shift | KeyCode.Enter - }, { - when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, - weight: KeybindingWeight.EditorContrib, - primary: KeyMod.Shift | KeyCode.F3, - mac: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG } - }] - }); - } - - run(accessor: ServicesAccessor): void { - const browserEditor = accessor.get(IEditorService).activeEditorPane; - if (browserEditor instanceof BrowserEditor) { - browserEditor.findPrevious(); - } - } -} - // Register actions -registerAction2(OpenIntegratedBrowserAction); -registerAction2(NewTabAction); registerAction2(GoBackAction); registerAction2(GoForwardAction); registerAction2(ReloadAction); registerAction2(HardReloadAction); -registerAction2(FocusUrlInputAction); -registerAction2(AddElementToChatAction); -registerAction2(AddConsoleLogsToChatAction); -registerAction2(ToggleDevToolsAction); -registerAction2(OpenInExternalBrowserAction); -registerAction2(ClearGlobalBrowserStorageAction); -registerAction2(ClearWorkspaceBrowserStorageAction); -registerAction2(ClearEphemeralBrowserStorageAction); -registerAction2(OpenBrowserSettingsAction); -registerAction2(ZoomInAction); -registerAction2(ZoomOutAction); -registerAction2(ResetZoomAction); -registerAction2(ShowBrowserFindAction); -registerAction2(HideBrowserFindAction); -registerAction2(BrowserFindNextAction); -registerAction2(BrowserFindPreviousAction); -registerAction2(class ToggleBrowserTitleBarButton extends ToggleTitleBarConfigAction { - constructor() { - super('workbench.browser.showInTitleBar', localize('toggle.browser', 'Integrated Browser'), localize('toggle.browserDescription', "Toggle visibility of the Integrated Browser button in title bar"), 8); - } -}); +registerAction2(FocusUrlInputAction); +registerAction2(OpenInExternalBrowserAction); +registerAction2(OpenBrowserSettingsAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts new file mode 100644 index 00000000000..d3ecac788fc --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDataStorageFeatures.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { BrowserEditor, BrowserEditorContribution } from '../browserEditor.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; +import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../common/browserView.js'; +import { BrowserViewCommandId, BrowserViewStorageScope } from '../../../../../platform/browserView/common/browserView.js'; +import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; +import type { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; + +const CONTEXT_BROWSER_STORAGE_SCOPE = new RawContextKey('browserStorageScope', '', localize('browser.storageScope', "The storage scope of the current browser view")); + +class BrowserEditorStorageScopeContribution extends BrowserEditorContribution { + private readonly _storageScopeContext: IContextKey; + + constructor( + editor: BrowserEditor, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(editor); + this._storageScopeContext = CONTEXT_BROWSER_STORAGE_SCOPE.bindTo(contextKeyService); + } + + protected override subscribeToModel(model: IBrowserViewModel, _store: DisposableStore): void { + this._storageScopeContext.set(model.storageScope); + } + + override clear(): void { + this._storageScopeContext.reset(); + } +} + +BrowserEditor.registerContribution(BrowserEditorStorageScopeContribution); + +class ClearGlobalBrowserStorageAction extends Action2 { + static readonly ID = BrowserViewCommandId.ClearGlobalStorage; + + constructor() { + super({ + id: ClearGlobalBrowserStorageAction.ID, + title: localize2('browser.clearGlobalStorageAction', 'Clear Storage (Global)'), + category: BrowserActionCategory, + icon: Codicon.clearAll, + f1: true, + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Settings, + order: 1, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Global) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + await browserViewWorkbenchService.clearGlobalStorage(); + } +} + +class ClearWorkspaceBrowserStorageAction extends Action2 { + static readonly ID = BrowserViewCommandId.ClearWorkspaceStorage; + + constructor() { + super({ + id: ClearWorkspaceBrowserStorageAction.ID, + title: localize2('browser.clearWorkspaceStorageAction', 'Clear Storage (Workspace)'), + category: BrowserActionCategory, + icon: Codicon.clearAll, + f1: true, + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Settings, + order: 1, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Workspace) + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + await browserViewWorkbenchService.clearWorkspaceStorage(); + } +} + +class ClearEphemeralBrowserStorageAction extends Action2 { + static readonly ID = BrowserViewCommandId.ClearEphemeralStorage; + + constructor() { + super({ + id: ClearEphemeralBrowserStorageAction.ID, + title: localize2('browser.clearEphemeralStorageAction', 'Clear Storage (Ephemeral)'), + category: BrowserActionCategory, + icon: Codicon.clearAll, + f1: true, + precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Settings, + order: 1, + when: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral) + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.clearStorage(); + } + } +} + +registerAction2(ClearGlobalBrowserStorageAction); +registerAction2(ClearWorkspaceBrowserStorageAction); +registerAction2(ClearEphemeralBrowserStorageAction); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.dataStorage': { + type: 'string', + enum: [ + BrowserViewStorageScope.Global, + BrowserViewStorageScope.Workspace, + BrowserViewStorageScope.Ephemeral + ], + markdownEnumDescriptions: [ + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.global' }, 'All browser views share a single persistent session across all workspaces.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.workspace' }, 'Browser views within the same workspace share a persistent session. If no workspace is opened, `ephemeral` storage is used.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage.ephemeral' }, 'Each browser view has its own session that is cleaned up when closed.') + ], + restricted: true, + default: BrowserViewStorageScope.Global, + markdownDescription: localize( + { comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.dataStorage' }, + 'Controls how browser data (cookies, cache, storage) is shared between browser views.\n\n**Note**: In untrusted workspaces, this setting is ignored and `ephemeral` storage is always used.' + ), + scope: ConfigurationScope.WINDOW, + order: 100 + } + } +}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts new file mode 100644 index 00000000000..c22809d56ee --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserDevToolsFeature.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { RawContextKey, IContextKey, IContextKeyService, ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory } from '../browserViewActions.js'; + +const CONTEXT_BROWSER_DEVTOOLS_OPEN = new RawContextKey('browserDevToolsOpen', false, localize('browser.devToolsOpen', "Whether developer tools are open for the current browser view")); + +class BrowserEditorDevToolsContribution extends BrowserEditorContribution { + private readonly _devToolsOpenContext: IContextKey; + + constructor( + editor: BrowserEditor, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(editor); + this._devToolsOpenContext = CONTEXT_BROWSER_DEVTOOLS_OPEN.bindTo(contextKeyService); + } + + protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + this._devToolsOpenContext.set(model.isDevToolsOpen); + store.add(model.onDidChangeDevToolsState(e => { + this._devToolsOpenContext.set(e.isDevToolsOpen); + })); + } + + override clear(): void { + this._devToolsOpenContext.reset(); + } +} + +BrowserEditor.registerContribution(BrowserEditorDevToolsContribution); + +class ToggleDevToolsAction extends Action2 { + static readonly ID = BrowserViewCommandId.ToggleDevTools; + + constructor() { + super({ + id: ToggleDevToolsAction.ID, + title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), + category: BrowserActionCategory, + icon: Codicon.terminal, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'actions', + order: 3, + }, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.F12 + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.toggleDevTools(); + } + } +} + +registerAction2(ToggleDevToolsAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts new file mode 100644 index 00000000000..b44e966f2db --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -0,0 +1,511 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { $ } from '../../../../../base/browser/dom.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Event } from '../../../../../base/common/event.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IBrowserElementsService } from '../../../../services/browserElements/browser/browserElementsService.js'; +import { IChatWidgetService } from '../../../chat/browser/chat.js'; +import { IChatRequestVariableEntry } from '../../../chat/common/attachments/chatVariableEntries.js'; +import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../../chat/common/constants.js'; +import { IElementData, IBrowserTargetLocator, getDisplayNameFromOuterHTML, createElementContextValue } from '../../../../../platform/browserElements/common/browserElements.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewModel } from '../../../browserView/common/browserView.js'; +import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { WorkbenchHoverDelegate } from '../../../../../platform/hover/browser/hover.js'; +import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { BrowserEditor, BrowserEditorContribution, IBrowserEditorWidgetContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_FOCUSED } from '../browserEditor.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; +import { PolicyCategory } from '../../../../../base/common/policy.js'; +import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; + +// Register tools +import '../tools/browserTools.contribution.js'; +import { BrowserActionCategory } from '../browserViewActions.js'; + +// Context key expression to check if browser editor is active +const BROWSER_EDITOR_ACTIVE = ContextKeyExpr.equals('activeEditor', BrowserEditorInput.EDITOR_ID); +const BrowserCategory = localize2('browserCategory', "Browser"); + +const CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE = new RawContextKey('browserElementSelectionActive', false, localize('browser.elementSelectionActive', "Whether element selection is currently active")); + +const canShareBrowserWithAgentContext = ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.has(`config.${ChatConfiguration.AgentEnabled}`), + ContextKeyExpr.has(`config.workbench.browser.enableChatTools`), +)!; + + +/** + * Contribution that manages element selection, element attachment to chat, + * console session lifecycle, console log attachment to chat, and agent sharing. + */ +export class BrowserEditorChatIntegration extends BrowserEditorContribution { + private _elementSelectionCts: CancellationTokenSource | undefined; + private readonly _elementSelectionActiveContext: IContextKey; + + // Share with Agent + private readonly _shareButtonContainer: HTMLElement; + private readonly _shareButton: Button; + + constructor( + editor: BrowserEditor, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IInstantiationService instantiationService: IInstantiationService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @ILogService private readonly logService: ILogService, + @IBrowserElementsService private readonly browserElementsService: IBrowserElementsService, + @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(editor); + this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService); + + // Build share toggle button + const hoverDelegate = this._register(instantiationService.createInstance( + WorkbenchHoverDelegate, + 'element', + undefined, + { position: { hoverPosition: HoverPosition.ABOVE } } + )); + + this._shareButtonContainer = $('.browser-share-toggle-container'); + this._shareButton = this._register(new Button(this._shareButtonContainer, { + supportIcons: true, + title: localize('browser.shareWithAgent', "Share with Agent"), + small: true, + hoverDelegate + })); + this._shareButton.element.classList.add('browser-share-toggle'); + this._shareButton.label = '$(agent)'; + + this._register(this._shareButton.onDidClick(() => { + this._toggleShareWithAgent(); + })); + + // Show share button only when chat is enabled and browser tools are enabled + const updateShareButtonVisibility = () => { + this._shareButtonContainer.style.display = contextKeyService.contextMatchesRules(canShareBrowserWithAgentContext) ? '' : 'none'; + }; + updateShareButtonVisibility(); + const agentSharingKeys = new Set(canShareBrowserWithAgentContext.keys()); + this._register(Event.filter(contextKeyService.onDidChangeContext, e => e.affectsSome(agentSharingKeys))(() => { + updateShareButtonVisibility(); + })); + } + + override get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { + return [{ element: this._shareButtonContainer, order: 100 }]; + } + + protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + // Start console session when a page URL is loaded + if (model.url) { + store.add(this._startConsoleSession(model.id)); + } else { + store.add(Event.once(Event.filter(model.onDidNavigate, e => !!e.url))(() => { + store.add(this._startConsoleSession(model.id)); + })); + } + + // Manage sharing state + this._updateSharingState(true); + store.add(model.onDidChangeSharedWithAgent(() => { + this._updateSharingState(false); + })); + store.add(Event.filter(this.contextKeyService.onDidChangeContext, e => e.affectsSome(new Set(canShareBrowserWithAgentContext.keys())))(() => { + this._updateSharingState(false); + })); + } + + override clear(): void { + if (this._elementSelectionCts) { + this._elementSelectionCts.dispose(true); + this._elementSelectionCts = undefined; + } + this._elementSelectionActiveContext.reset(); + } + + // -- Sharing ------------------------------------------------------- + + private _toggleShareWithAgent(): void { + const model = this.editor.model; + if (!model) { + return; + } + model.setSharedWithAgent(!model.sharedWithAgent); + } + + private _updateSharingState(isInitialState: boolean): void { + const model = this.editor.model; + const sharingEnabled = this.contextKeyService.contextMatchesRules(canShareBrowserWithAgentContext); + const isShared = sharingEnabled && !!model && model.sharedWithAgent; + + this.editor.browserContainer.classList.toggle('animate', !isInitialState); + this.editor.browserContainer.classList.toggle('shared', isShared); + + this._shareButton.checked = isShared; + this._shareButton.label = isShared + ? localize('browser.sharingWithAgent', "Sharing with Agent") + ' $(agent)' + : '$(agent)'; + this._shareButton.setTitle(isShared + ? localize('browser.unshareWithAgent', "Stop Sharing with Agent") + : localize('browser.shareWithAgent', "Share with Agent")); + } + + // -- Element Selection ---------------------------------------------- + + /** + * Start element selection in the browser view, wait for a user selection, and add it to chat. + */ + async addElementToChat(): Promise { + // If selection is already active, cancel it + if (this._elementSelectionCts) { + this._elementSelectionCts.dispose(true); + this._elementSelectionCts = undefined; + this._elementSelectionActiveContext.set(false); + return; + } + + // Start new selection + const cts = new CancellationTokenSource(); + this._elementSelectionCts = cts; + this._elementSelectionActiveContext.set(true); + + type IntegratedBrowserAddElementToChatStartEvent = {}; + + type IntegratedBrowserAddElementToChatStartClassification = { + owner: 'jruales'; + comment: 'The user initiated an Add Element to Chat action in Integrated Browser.'; + }; + + this.telemetryService.publicLog2('integratedBrowser.addElementToChat.start', {}); + + try { + const browserViewId = this.editor.model?.id; + if (!browserViewId) { + throw new Error('No browser view ID found'); + } + + // Make the browser the focused view + this.editor.ensureBrowserFocus(); + + const locator: IBrowserTargetLocator = { browserViewId }; + + // Start debug session for integrated browser + await this.browserElementsService.startDebugSession(cts.token, locator); + + // Get the browser container bounds + const { width, height } = this.editor.browserContainer.getBoundingClientRect(); + + // Get element data from user selection + const elementData = await this.browserElementsService.getElementData({ x: 0, y: 0, width, height }, cts.token, locator); + if (!elementData) { + throw new Error('Element data not found'); + } + + const { attachCss, attachImages } = await this._attachElementDataToChat(elementData); + + type IntegratedBrowserAddElementToChatAddedEvent = { + attachCss: boolean; + attachImages: boolean; + }; + + type IntegratedBrowserAddElementToChatAddedClassification = { + attachCss: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachCSS was enabled.' }; + attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachImages was enabled.' }; + owner: 'jruales'; + comment: 'An element was successfully added to chat from Integrated Browser.'; + }; + + this.telemetryService.publicLog2('integratedBrowser.addElementToChat.added', { + attachCss, + attachImages + }); + + } catch (error) { + if (!cts.token.isCancellationRequested) { + this.logService.error('BrowserEditor.addElementToChat: Failed to select element', error); + } + } finally { + cts.dispose(); + if (this._elementSelectionCts === cts) { + this._elementSelectionCts = undefined; + this._elementSelectionActiveContext.set(false); + } + } + } + + /** + * Accept the currently focused element during element selection and attach it to chat. + */ + async addFocusedElementToChat(): Promise { + if (!this._elementSelectionCts) { + return; + } + + const cts = this._elementSelectionCts; + const browserViewId = this.editor.model?.id; + if (!browserViewId) { + return; + } + + const locator: IBrowserTargetLocator = { browserViewId }; + const { width, height } = this.editor.browserContainer.getBoundingClientRect(); + const elementData = await this.browserElementsService.getFocusedElementData({ x: 0, y: 0, width, height }, cts.token, locator); + if (!elementData) { + return; + } + + await this._attachElementDataToChat(elementData); + cts.dispose(); + if (this._elementSelectionCts === cts) { + this._elementSelectionCts = undefined; + this._elementSelectionActiveContext.set(false); + } + } + + private async _attachElementDataToChat(elementData: IElementData): Promise<{ attachCss: boolean; attachImages: boolean }> { + const bounds = elementData.bounds; + const toAttach: IChatRequestVariableEntry[] = []; + + const displayName = getDisplayNameFromOuterHTML(elementData.outerHTML); + const attachCss = this.configurationService.getValue('chat.sendElementsToChat.attachCSS'); + const value = createElementContextValue(elementData, displayName, attachCss); + + toAttach.push({ + id: 'element-' + Date.now(), + name: displayName, + fullName: displayName, + value: value, + modelDescription: attachCss + ? 'Structured browser element context with HTML path, attributes, and computed styles.' + : 'Structured browser element context with HTML path and attributes.', + kind: 'element', + icon: ThemeIcon.fromId(Codicon.layout.id), + ancestors: elementData.ancestors, + attributes: elementData.attributes, + computedStyles: attachCss ? elementData.computedStyles : undefined, + dimensions: elementData.dimensions, + innerText: elementData.innerText, + }); + + const attachImages = this.configurationService.getValue('chat.sendElementsToChat.attachImages'); + const model = this.editor.model; + if (attachImages && model) { + const screenshotBuffer = await model.captureScreenshot({ + quality: 90, + rect: bounds + }); + + toAttach.push({ + id: 'element-screenshot-' + Date.now(), + name: 'Element Screenshot', + fullName: 'Element Screenshot', + kind: 'image', + value: screenshotBuffer.buffer + }); + } + + const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; + widget?.attachmentModel?.addContext(...toAttach); + + return { attachCss, attachImages }; + } + + // -- Console Logs --------------------------------------------------- + + /** + * Grab the current console logs from the active console session and attach them to chat. + */ + async addConsoleLogsToChat(): Promise { + const browserViewId = this.editor.model?.id; + if (!browserViewId) { + return; + } + + const locator: IBrowserTargetLocator = { browserViewId }; + + try { + const logs = await this.browserElementsService.getConsoleLogs(locator); + if (!logs) { + return; + } + + const toAttach: IChatRequestVariableEntry[] = []; + toAttach.push({ + id: 'console-logs-' + Date.now(), + name: localize('consoleLogs', 'Console Logs'), + fullName: localize('consoleLogs', 'Console Logs'), + value: logs, + modelDescription: 'Console logs captured from Integrated Browser.', + kind: 'element', + icon: ThemeIcon.fromId(Codicon.terminal.id), + }); + + const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; + widget?.attachmentModel?.addContext(...toAttach); + } catch (error) { + this.logService.error('BrowserEditor.addConsoleLogsToChat: Failed to get console logs', error); + } + } + + private _startConsoleSession(browserViewId: string): IDisposable { + const cts = new CancellationTokenSource(); + const locator: IBrowserTargetLocator = { browserViewId }; + + this.browserElementsService.startConsoleSession(cts.token, locator).catch(error => { + if (!cts.token.isCancellationRequested) { + this.logService.error('BrowserEditor: Failed to start console session', error); + } + }); + + return toDisposable(() => { + cts.dispose(true); + }); + } +} + +// Register the contribution +BrowserEditor.registerContribution(BrowserEditorChatIntegration); + +// -- Actions ------------------------------------------------------------ + +class AddElementToChatAction extends Action2 { + static readonly ID = BrowserViewCommandId.AddElementToChat; + + constructor() { + const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); + super({ + id: AddElementToChatAction.ID, + title: localize2('browser.addElementToChatAction', 'Add Element to Chat'), + category: BrowserCategory, + icon: Codicon.inspect, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), enabled), + toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'actions', + order: 1, + when: enabled + }, + keybinding: [{ + weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over terminal + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC, + }, { + when: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyCode.Escape + }] + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorChatIntegration)?.addElementToChat(); + } + } +} + +class AddConsoleLogsToChatAction extends Action2 { + static readonly ID = BrowserViewCommandId.AddConsoleLogsToChat; + + constructor() { + const enabled = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('config.chat.sendElementsToChat.enabled', true)); + super({ + id: AddConsoleLogsToChatAction.ID, + title: localize2('browser.addConsoleLogsToChatAction', 'Add Console Logs to Chat'), + category: BrowserActionCategory, + icon: Codicon.output, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate(), enabled), + menu: { + id: MenuId.BrowserActionsToolbar, + group: 'actions', + order: 2, + when: enabled + } + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorChatIntegration)?.addConsoleLogsToChat(); + } + } +} + +class AddFocusedElementToChatAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.browser.addFocusedElementToChat', + title: localize2('browser.addFocusedElementToChat', 'Add Focused Element to Chat'), + category: BrowserActionCategory, + f1: false, + precondition: CONTEXT_BROWSER_FOCUSED, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib + 50, + primary: KeyMod.CtrlCmd | KeyCode.Enter, + when: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE + } + }); + } + + async run(accessor: ServicesAccessor): Promise { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorChatIntegration)?.addFocusedElementToChat(); + } + } +} + +registerAction2(AddElementToChatAction); +registerAction2(AddConsoleLogsToChatAction); +registerAction2(AddFocusedElementToChatAction); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.enableChatTools': { + type: 'boolean', + default: false, + experiment: { mode: 'startup' }, + tags: ['experimental'], + markdownDescription: localize( + { comment: ['This is the description for a setting.'], key: 'browser.enableChatTools' }, + 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.' + ), + policy: { + name: 'BrowserChatTools', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.110', + value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + localization: { + description: { + key: 'browser.enableChatTools', + value: localize('browser.enableChatTools', 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.') + } + }, + } + } + } +}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts new file mode 100644 index 00000000000..241f78d087c --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorFindFeature.ts @@ -0,0 +1,408 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { $, getWindow } from '../../../../../base/browser/dom.js'; +import { IContextKey, IContextKeyService, ContextKeyExpr, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Lazy } from '../../../../../base/common/lazy.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { SimpleFindWidget } from '../../../codeEditor/browser/find/simpleFindWidget.js'; +import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL } from '../browserEditor.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; + +const CONTEXT_BROWSER_FIND_WIDGET_VISIBLE = new RawContextKey('browserFindWidgetVisible', false, localize('browser.findWidgetVisible', "Whether the browser find widget is visible")); +const CONTEXT_BROWSER_FIND_WIDGET_FOCUSED = new RawContextKey('browserFindWidgetFocused', false, localize('browser.findWidgetFocused', "Whether the browser find widget is focused")); + +/** + * Find widget for the integrated browser view. + * Uses the SimpleFindWidget base class and communicates with the browser view model + * to perform find operations in the rendered web page. + */ +class BrowserFindWidget extends SimpleFindWidget { + private _model: IBrowserViewModel | undefined; + private readonly _modelDisposables = this._register(new DisposableStore()); + private readonly _findWidgetVisible: IContextKey; + private readonly _findWidgetFocused: IContextKey; + private _lastFindResult: { resultIndex: number; resultCount: number } | undefined; + private _hasFoundMatch = false; + + private readonly _onDidChangeHeight = this._register(new Emitter()); + readonly onDidChangeHeight: Event = this._onDidChangeHeight.event; + + constructor( + container: HTMLElement, + @IContextViewService contextViewService: IContextViewService, + @IContextKeyService contextKeyService: IContextKeyService, + @IHoverService hoverService: IHoverService, + @IKeybindingService keybindingService: IKeybindingService, + @IConfigurationService configurationService: IConfigurationService, + @IAccessibilityService accessibilityService: IAccessibilityService + ) { + super({ + showCommonFindToggles: true, + checkImeCompletionState: true, + showResultCount: true, + enableSash: true, + initialWidth: 350, + previousMatchActionId: BrowserViewCommandId.FindPrevious, + nextMatchActionId: BrowserViewCommandId.FindNext, + closeWidgetActionId: BrowserViewCommandId.HideFind + }, contextViewService, contextKeyService, hoverService, keybindingService, configurationService, accessibilityService); + + this._findWidgetVisible = CONTEXT_BROWSER_FIND_WIDGET_VISIBLE.bindTo(contextKeyService); + this._findWidgetFocused = CONTEXT_BROWSER_FIND_WIDGET_FOCUSED.bindTo(contextKeyService); + + const domNode = this.getDomNode(); + container.appendChild(domNode); + + let lastHeight = domNode.offsetHeight; + const resizeObserver = new (getWindow(container).ResizeObserver)(() => { + const newHeight = domNode.offsetHeight; + if (newHeight !== lastHeight) { + lastHeight = newHeight; + this._onDidChangeHeight.fire(); + } + }); + resizeObserver.observe(domNode); + this._register(toDisposable(() => resizeObserver.disconnect())); + } + + /** + * Set the browser view model to use for find operations. + * This should be called whenever the editor input changes. + */ + setModel(model: IBrowserViewModel | undefined): void { + this._modelDisposables.clear(); + this._model = model; + this._lastFindResult = undefined; + this._hasFoundMatch = false; + + if (model) { + this._modelDisposables.add(model.onDidFindInPage(result => { + this._lastFindResult = { + resultIndex: result.activeMatchOrdinal - 1, // Convert to 0-based index + resultCount: result.matches + }; + this._hasFoundMatch = result.matches > 0; + this.updateButtons(this._hasFoundMatch); + this.updateResultCount(); + })); + + this._modelDisposables.add(model.onWillDispose(() => { + this.setModel(undefined); + })); + } + } + + override reveal(initialInput?: string): void { + const wasVisible = this.isVisible(); + super.reveal(initialInput); + this._findWidgetVisible.set(true); + + // Focus the find input + this.focusFindBox(); + + // If there's existing input and the widget wasn't already visible, trigger a search + if (this.inputValue && !wasVisible) { + this._onInputChanged(); + } + } + + override hide(): void { + super.hide(false); + this._findWidgetVisible.reset(); + + // Stop find and clear highlights in the browser view + this._model?.stopFindInPage(true); + this._model?.focus(); + this._lastFindResult = undefined; + this._hasFoundMatch = false; + } + + find(previous: boolean): void { + const value = this.inputValue; + if (value && this._model) { + this._model.findInPage(value, { + forward: !previous, + recompute: false, + matchCase: this._getCaseSensitiveValue() + }); + } + } + + findFirst(): void { + const value = this.inputValue; + if (value && this._model) { + this._model.findInPage(value, { + forward: true, + recompute: true, + matchCase: this._getCaseSensitiveValue() + }); + } + } + + clear(): void { + if (this._model) { + this._model.stopFindInPage(false); + this._lastFindResult = undefined; + this._hasFoundMatch = false; + } + } + + protected _onInputChanged(): boolean { + if (this.inputValue) { + this.findFirst(); + } else if (this._model) { + this.clear(); + } + return false; + } + + protected async _getResultCount(): Promise<{ resultIndex: number; resultCount: number } | undefined> { + return this._lastFindResult; + } + + protected _onFocusTrackerFocus(): void { + this._findWidgetFocused.set(true); + } + + protected _onFocusTrackerBlur(): void { + this._findWidgetFocused.reset(); + } + + protected _onFindInputFocusTrackerFocus(): void { + // No-op + } + + protected _onFindInputFocusTrackerBlur(): void { + // No-op + } +} + +/** + * Browser editor contribution that manages the find-in-page widget. + * + * Creates a container just below the toolbar and lazily instantiates the + * {@link BrowserFindWidget}. When the find widget's height changes the + * browser container is re-laid-out so that the web-contents view stays in + * sync. + */ +export class BrowserEditorFindContribution extends BrowserEditorContribution { + private readonly _findWidgetContainer: HTMLElement; + private readonly _findWidget: Lazy; + + constructor( + editor: BrowserEditor, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(editor); + + this._findWidgetContainer = $('.browser-find-widget-wrapper'); + + this._findWidget = new Lazy(() => { + const findWidget = this.instantiationService.createInstance( + BrowserFindWidget, + this._findWidgetContainer + ); + if (editor.model) { + findWidget.setModel(editor.model); + } + findWidget.onDidChangeHeight(() => { + editor.layoutBrowserContainer(); + }); + return findWidget; + }); + this._register(toDisposable(() => this._findWidget.rawValue?.dispose())); + } + + /** + * The container element to insert below the toolbar. + */ + override get toolbarElements(): readonly HTMLElement[] { + return [this._findWidgetContainer]; + } + + protected override subscribeToModel(model: IBrowserViewModel, _store: DisposableStore): void { + this._findWidget.rawValue?.setModel(model); + } + + override clear(): void { + this._findWidget.rawValue?.setModel(undefined); + this._findWidget.rawValue?.hide(); + } + + override layout(width: number): void { + this._findWidget.rawValue?.layout(width); + } + + /** + * Show the find widget, optionally pre-populated with selected text from the browser view + */ + async showFind(): Promise { + const selectedText = (await this.editor.model?.getSelectedText())?.trim(); + const textToReveal = selectedText && !/[\r\n]/.test(selectedText) ? selectedText : undefined; + this._findWidget.value.reveal(textToReveal); + this._findWidget.value.layout(this._findWidgetContainer.clientWidth); + } + + /** + * Hide the find widget + */ + hideFind(): void { + this._findWidget.rawValue?.hide(); + } + + /** + * Find the next match + */ + findNext(): void { + this._findWidget.rawValue?.find(false); + } + + /** + * Find the previous match + */ + findPrevious(): void { + this._findWidget.rawValue?.find(true); + } +} + +BrowserEditor.registerContribution(BrowserEditorFindContribution); + +// -- Actions ---------------------------------------------------------------- + +class ShowBrowserFindAction extends Action2 { + static readonly ID = BrowserViewCommandId.ShowFind; + + constructor() { + super({ + id: ShowBrowserFindAction.ID, + title: localize2('browser.showFindAction', 'Find in Page'), + category: BrowserActionCategory, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Page, + order: 1, + }, + keybinding: { + weight: KeybindingWeight.EditorContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyF + } + }); + } + + run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): void { + if (browserEditor instanceof BrowserEditor) { + void browserEditor.getContribution(BrowserEditorFindContribution)?.showFind(); + } + } +} + +class HideBrowserFindAction extends Action2 { + static readonly ID = BrowserViewCommandId.HideFind; + + constructor() { + super({ + id: HideBrowserFindAction.ID, + title: localize2('browser.hideFindAction', 'Close Find Widget'), + category: BrowserActionCategory, + f1: false, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), + keybinding: { + weight: KeybindingWeight.EditorContrib + 5, + primary: KeyCode.Escape + } + }); + } + + run(accessor: ServicesAccessor): void { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + browserEditor.getContribution(BrowserEditorFindContribution)?.hideFind(); + } + } +} + +class BrowserFindNextAction extends Action2 { + static readonly ID = BrowserViewCommandId.FindNext; + + constructor() { + super({ + id: BrowserFindNextAction.ID, + title: localize2('browser.findNextAction', 'Find Next'), + category: BrowserActionCategory, + f1: false, + precondition: BROWSER_EDITOR_ACTIVE, + keybinding: [{ + when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.Enter + }, { + when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.F3, + mac: { primary: KeyMod.CtrlCmd | KeyCode.KeyG } + }] + }); + } + + run(accessor: ServicesAccessor): void { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + browserEditor.getContribution(BrowserEditorFindContribution)?.findNext(); + } + } +} + +class BrowserFindPreviousAction extends Action2 { + static readonly ID = BrowserViewCommandId.FindPrevious; + + constructor() { + super({ + id: BrowserFindPreviousAction.ID, + title: localize2('browser.findPreviousAction', 'Find Previous'), + category: BrowserActionCategory, + f1: false, + precondition: BROWSER_EDITOR_ACTIVE, + keybinding: [{ + when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, + weight: KeybindingWeight.EditorContrib, + primary: KeyMod.Shift | KeyCode.Enter + }, { + when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, + weight: KeybindingWeight.EditorContrib, + primary: KeyMod.Shift | KeyCode.F3, + mac: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG } + }] + }); + } + + run(accessor: ServicesAccessor): void { + const browserEditor = accessor.get(IEditorService).activeEditorPane; + if (browserEditor instanceof BrowserEditor) { + browserEditor.getContribution(BrowserEditorFindContribution)?.findPrevious(); + } + } +} + +registerAction2(ShowBrowserFindAction); +registerAction2(HideBrowserFindAction); +registerAction2(BrowserFindNextAction); +registerAction2(BrowserFindPreviousAction); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts new file mode 100644 index 00000000000..a527d8ab6c4 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorZoomFeature.ts @@ -0,0 +1,297 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { $ } from '../../../../../base/browser/dom.js'; +import { RawContextKey, IContextKey, IContextKeyService, ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { Action2, registerAction2, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { browserZoomFactors, browserZoomLabel, browserZoomAccessibilityLabel } from '../../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewModel } from '../../../browserView/common/browserView.js'; +import { BrowserZoomService, IBrowserZoomService, MATCH_WINDOW_ZOOM_LABEL } from '../../../browserView/common/browserZoomService.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { BrowserEditor, BrowserEditorContribution, CONTEXT_BROWSER_HAS_ERROR, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_FOCUSED, IBrowserEditorWidgetContribution } from '../browserEditor.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { getZoomLevel, onDidChangeZoomLevel } from '../../../../../base/browser/browser.js'; +import { zoomLevelToZoomFactor } from '../../../../../platform/window/common/window.js'; +import { mainWindow } from '../../../../../base/browser/window.js'; +import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; + +const CONTEXT_BROWSER_CAN_ZOOM_IN = new RawContextKey('browserCanZoomIn', true, localize('browser.canZoomIn', "Whether the browser can zoom in further")); +const CONTEXT_BROWSER_CAN_ZOOM_OUT = new RawContextKey('browserCanZoomOut', true, localize('browser.canZoomOut', "Whether the browser can zoom out further")); + +/** + * Transient zoom-level indicator that briefly appears inside the URL bar on zoom changes. + */ +class BrowserZoomPill extends Disposable { + readonly element: HTMLElement; + private readonly _icon: HTMLElement; + private readonly _label: HTMLElement; + private readonly _timeout = this._register(new MutableDisposable()); + + constructor() { + super(); + this.element = $('.browser-zoom-pill'); + // Don't announce this transient element; the zoom level is announced via IAccessibilityService.status() + this.element.setAttribute('aria-hidden', 'true'); + this._icon = $('span'); + this._label = $('span'); + this.element.appendChild(this._icon); + this.element.appendChild(this._label); + } + + /** + * Briefly show the zoom level, then auto-hide after 750 ms. + */ + show(zoomLabel: string, isAtOrAboveDefault: boolean): void { + this._icon.className = ThemeIcon.asClassName(isAtOrAboveDefault ? Codicon.zoomIn : Codicon.zoomOut); + this._label.textContent = zoomLabel; + this.element.classList.add('visible'); + // Reset auto-hide timer so rapid zoom actions extend the display + this._timeout.value = disposableTimeout(() => { + this.element.classList.remove('visible'); + }, 750); // Chrome shows the zoom level for 1.5 seconds, but we show it for less because ours is non-interactive + } +} + +/** + * Browser editor contribution that manages zoom context keys and the zoom pill indicator. + */ +export class BrowserEditorZoomSupport extends BrowserEditorContribution { + private readonly _zoomPill: BrowserZoomPill; + private readonly _canZoomInContext: IContextKey; + private readonly _canZoomOutContext: IContextKey; + + constructor( + editor: BrowserEditor, + @IContextKeyService contextKeyService: IContextKeyService, + @IBrowserZoomService private readonly browserZoomService: IBrowserZoomService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + ) { + super(editor); + this._canZoomInContext = CONTEXT_BROWSER_CAN_ZOOM_IN.bindTo(contextKeyService); + this._canZoomOutContext = CONTEXT_BROWSER_CAN_ZOOM_OUT.bindTo(contextKeyService); + this._zoomPill = this._register(new BrowserZoomPill()); + } + + override get urlBarWidgets(): readonly IBrowserEditorWidgetContribution[] { + return [{ element: this._zoomPill.element, order: 0 }]; + } + + protected override subscribeToModel(model: IBrowserViewModel, store: DisposableStore): void { + this._updateZoomContext(model); + store.add(model.onDidChangeZoom(() => { + this._updateZoomContext(model); + })); + } + + override clear(): void { + this._canZoomInContext.reset(); + this._canZoomOutContext.reset(); + } + + async zoomIn(): Promise { + await this.editor.model?.zoomIn(); + this._showZoomPill(); + } + + async zoomOut(): Promise { + await this.editor.model?.zoomOut(); + this._showZoomPill(); + } + + async resetZoom(): Promise { + await this.editor.model?.resetZoom(); + this._showZoomPill(); + } + + private _updateZoomContext(model: IBrowserViewModel): void { + this._canZoomInContext.set(model.canZoomIn); + this._canZoomOutContext.set(model.canZoomOut); + } + + private _showZoomPill(): void { + const model = this.editor.model; + if (!model) { + return; + } + const defaultIndex = this.browserZoomService.getEffectiveZoomIndex(undefined, false); + const defaultFactor = browserZoomFactors[defaultIndex]; + const currentFactor = model.zoomFactor; + const label = browserZoomLabel(currentFactor); + this._zoomPill.show(label, currentFactor >= defaultFactor); + // Announce the new zoom level to screen readers (polite, non-interruptive). + this.accessibilityService.status(browserZoomAccessibilityLabel(currentFactor)); + } +} + +// Register the contribution +BrowserEditor.registerContribution(BrowserEditorZoomSupport); + +// -- Actions ------------------------------------------------------------ + +class ZoomInAction extends Action2 { + static readonly ID = 'workbench.action.browser.zoomIn'; + + constructor() { + super({ + id: ZoomInAction.ID, + title: localize2('browser.zoomInAction', 'Zoom In'), + category: BrowserActionCategory, + icon: Codicon.zoomIn, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Zoom, + order: 1, + when: CONTEXT_BROWSER_CAN_ZOOM_IN, + }, + keybinding: { + when: CONTEXT_BROWSER_FOCUSED, + weight: KeybindingWeight.WorkbenchContrib + 75, + // Same shortcuts as 'workbench.action.zoomIn' + primary: KeyMod.CtrlCmd | KeyCode.Equal, + secondary: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Equal, KeyMod.CtrlCmd | KeyCode.NumpadAdd], + }, + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorZoomSupport)?.zoomIn(); + } + } +} + +class ZoomOutAction extends Action2 { + static readonly ID = 'workbench.action.browser.zoomOut'; + + constructor() { + super({ + id: ZoomOutAction.ID, + title: localize2('browser.zoomOutAction', 'Zoom Out'), + category: BrowserActionCategory, + icon: Codicon.zoomOut, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Zoom, + order: 2, + when: CONTEXT_BROWSER_CAN_ZOOM_OUT, + }, + keybinding: { + when: CONTEXT_BROWSER_FOCUSED, + weight: KeybindingWeight.WorkbenchContrib + 75, + // Same shortcuts as 'workbench.action.zoomOut' + primary: KeyMod.CtrlCmd | KeyCode.Minus, + secondary: [KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Minus, KeyMod.CtrlCmd | KeyCode.NumpadSubtract], + linux: { + primary: KeyMod.CtrlCmd | KeyCode.Minus, + secondary: [KeyMod.CtrlCmd | KeyCode.NumpadSubtract] + } + }, + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorZoomSupport)?.zoomOut(); + } + } +} + +class ResetZoomAction extends Action2 { + static readonly ID = 'workbench.action.browser.resetZoom'; + + constructor() { + super({ + id: ResetZoomAction.ID, + title: localize2('browser.resetZoomAction', 'Reset Zoom'), + category: BrowserActionCategory, + icon: Codicon.screenNormal, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, CONTEXT_BROWSER_HAS_ERROR.negate()), + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Zoom, + order: 3, + }, + keybinding: { + when: CONTEXT_BROWSER_FOCUSED, + weight: KeybindingWeight.WorkbenchContrib + 75, + // Same shortcuts as 'workbench.action.zoomReset' + // (note: both workbench and here use Numpad0 instead of Digit0 to avoid conflicts with keybinding to focus sidebar.) + primary: KeyMod.CtrlCmd | KeyCode.Numpad0, + }, + }); + } + + async run(accessor: ServicesAccessor, browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + if (browserEditor instanceof BrowserEditor) { + await browserEditor.getContribution(BrowserEditorZoomSupport)?.resetZoom(); + } + } +} + +registerAction2(ZoomInAction); +registerAction2(ZoomOutAction); +registerAction2(ResetZoomAction); + +/** + * Bridges the application's UI zoom level changes into IBrowserZoomService so that + * views using the 'Match Window' default zoom level stay in sync. + */ +class WindowZoomSynchronizer extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.browserView.windowZoomSynchronizer'; + + constructor(@IBrowserZoomService browserZoomService: IBrowserZoomService) { + super(); + browserZoomService.notifyWindowZoomChanged(zoomLevelToZoomFactor(getZoomLevel(mainWindow))); + this._register(onDidChangeZoomLevel(() => { + browserZoomService.notifyWindowZoomChanged(zoomLevelToZoomFactor(getZoomLevel(mainWindow))); + })); + } +} + +registerWorkbenchContribution2(WindowZoomSynchronizer.ID, WindowZoomSynchronizer, WorkbenchPhase.BlockRestore); + +registerSingleton(IBrowserZoomService, BrowserZoomService, InstantiationType.Delayed); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.pageZoom': { + type: 'string', + enum: [MATCH_WINDOW_ZOOM_LABEL, ...browserZoomFactors.map(f => `${Math.round(f * 100)}%`)], + markdownEnumDescriptions: [ + localize( + { comment: ['This is the description for a setting enum value.'], key: 'browser.defaultZoomLevel.matchWindow' }, + 'Matches the application\'s current UI zoom level.' + ), + ...browserZoomFactors.map(() => ''), + ], + default: MATCH_WINDOW_ZOOM_LABEL, + markdownDescription: localize( + { comment: ['This is the description for a setting.'], key: 'browser.pageZoom' }, + 'Default zoom level for all sites in the Integrated Browser.' + ), + // Zoom can change from machine to machine, so we don't need the workspace-level nor syncing that WINDOW has. + scope: ConfigurationScope.MACHINE + } + } +}); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts new file mode 100644 index 00000000000..dc391f59251 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -0,0 +1,487 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor, IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeyMod, KeyCode } from '../../../../../base/common/keyCodes.js'; +import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../services/editor/common/editorService.js'; +import { IEditorGroupsService, GroupsOrder } from '../../../../services/editor/common/editorGroupsService.js'; +import { EditorsOrder, GroupIdentifier } from '../../../../common/editor.js'; +import { IQuickInputService, IQuickInputButton, IQuickPickItem, IQuickPickSeparator, QuickInputButtonLocation, IQuickPick } from '../../../../../platform/quickinput/common/quickInput.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, BrowserActionGroup } from '../browserViewActions.js'; +import { logBrowserOpen } from '../../../../../platform/browserView/common/browserViewTelemetry.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; +import { IExternalOpener, IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { isLocalhostAuthority } from '../../../../../platform/url/common/trustedDomains.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { ToggleTitleBarConfigAction } from '../../../../browser/parts/titlebar/titlebarActions.js'; +import { Registry } from '../../../../../platform/registry/common/platform.js'; + +const CONTEXT_BROWSER_EDITOR_OPEN = new RawContextKey('browserEditorOpen', false, localize('browser.editorOpen', "Whether any browser editor is currently open")); + +interface IBrowserQuickPickItem extends IQuickPickItem { + groupId: GroupIdentifier; + editor: BrowserEditorInput; +} + +const closeButtonItem: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.close), + tooltip: localize('browser.closeTab', "Close") +}; + +const closeAllButtonItem: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.closeAll), + tooltip: localize('browser.closeAllTabs', "Close All"), + location: QuickInputButtonLocation.Inline +}; + + +/** + * Manages a quick pick that lists all open browser tabs grouped by editor group, + * with close buttons, live updates, and an always-visible "New Integrated Browser Tab" entry. + */ +class BrowserTabQuickPick extends Disposable { + + private readonly _quickPick: IQuickPick; + private readonly _itemListeners = this._register(new DisposableStore()); + + private readonly _openNewTabPick: IBrowserQuickPickItem = { + groupId: -1, + editor: undefined!, + label: localize('browser.openNewTab', "New Integrated Browser Tab"), + iconClass: ThemeIcon.asClassName(Codicon.add), + alwaysShow: true, + }; + + constructor( + @IEditorService private readonly _editorService: IEditorService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IQuickInputService quickInputService: IQuickInputService, + @ITelemetryService telemetryService: ITelemetryService, + ) { + super(); + + this._quickPick = this._register(quickInputService.createQuickPick({ useSeparators: true })); + this._quickPick.placeholder = localize('browser.quickOpenPlaceholder', "Select a browser tab or enter a URL"); + this._quickPick.matchOnDescription = true; + this._quickPick.sortByLabel = false; + this._quickPick.buttons = [closeAllButtonItem]; + + this._register(this._quickPick.onDidTriggerItemButton(async ({ item }) => { + if (!item.editor) { + return; + } + const group = this._editorGroupsService.getGroup(item.groupId); + if (group) { + await group.closeEditor(item.editor, { + preserveFocus: true // Don't shift focus so the quickpick doesn't close + }); + } + })); + + this._register(this._quickPick.onDidTriggerButton(async () => { + for (const group of this._editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE)) { + const browserEditors = group.editors.filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput); + if (browserEditors.length > 0) { + await group.closeEditors(browserEditors, { + preserveFocus: true // Don't shift focus so the quickpick doesn't close + }); + } + } + })); + + this._register(this._quickPick.onDidAccept(async () => { + const [selected] = this._quickPick.selectedItems; + if (!selected) { + return; + } + if (selected === this._openNewTabPick) { + logBrowserOpen(telemetryService, 'quickOpenWithoutUrl'); + await this._editorService.openEditor({ + resource: BrowserViewUri.forId(generateUuid()), + }); + } else { + await this._editorService.openEditor(selected.editor, selected.groupId); + } + })); + + this._register(this._quickPick.onDidHide(() => this.dispose())); + } + + show(): void { + this._buildItems(); + + // Pre-select the currently active browser editor + const activeEditor = this._editorService.activeEditor; + if (activeEditor instanceof BrowserEditorInput) { + const activePick = (this._quickPick.items as readonly (IBrowserQuickPickItem | IQuickPickSeparator)[]) + .find((item): item is IBrowserQuickPickItem => item.type !== 'separator' && item.editor === activeEditor); + if (activePick) { + this._quickPick.activeItems = [activePick]; + } + } + + this._quickPick.show(); + } + + private _buildItems(): void { + this._itemListeners.clear(); + + // Remember which editor was active so we can restore selection + const activeEditor = this._quickPick.activeItems[0]?.editor; + + const picks: (IBrowserQuickPickItem | IQuickPickSeparator)[] = []; + const groups = this._editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE); + + const groupsWithBrowserEditors = groups + .map(group => ({ group, browserEditors: group.editors.filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput) })) + .filter(({ browserEditors }) => browserEditors.length > 0); + const multipleGroups = groupsWithBrowserEditors.length > 1; + + // Build a map of group ID to aria label for screen readers + const mapGroupIdToGroupAriaLabel = new Map(); + for (const { group } of groupsWithBrowserEditors) { + mapGroupIdToGroupAriaLabel.set(group.id, group.ariaLabel); + } + + let newActivePick: IBrowserQuickPickItem | undefined; + + for (const { group, browserEditors } of groupsWithBrowserEditors) { + if (multipleGroups) { + picks.push({ type: 'separator', label: group.label }); + } + for (const editor of browserEditors) { + const icon = editor.getIcon(); + const description = editor.getDescription(); + const nameAndDescription = description ? `${editor.getName()} ${description}` : editor.getName(); + const pick: IBrowserQuickPickItem = { + groupId: group.id, + editor, + label: editor.getName(), + ariaLabel: multipleGroups + ? localize('browserEntryAriaLabelWithGroup', "{0}, {1}", nameAndDescription, mapGroupIdToGroupAriaLabel.get(group.id)) + : nameAndDescription, + description, + buttons: [closeButtonItem], + italic: !group.isPinned(editor), + }; + if (icon instanceof URI) { + pick.iconPath = { dark: icon }; + } else if (icon) { + pick.iconClass = ThemeIcon.asClassName(icon); + } + picks.push(pick); + + if (editor === activeEditor) { + newActivePick = pick; + } + + this._itemListeners.add(editor.onDidChangeLabel(() => this._buildItems())); + } + this._itemListeners.add(group.onDidModelChange(() => this._buildItems())); + } + + picks.push({ type: 'separator' }); + picks.push(this._openNewTabPick); + + this._quickPick.keepScrollPosition = true; + this._quickPick.items = picks; + if (newActivePick) { + this._quickPick.activeItems = [newActivePick]; + } + } +} + +class QuickOpenBrowserAction extends Action2 { + constructor() { + const neverShowInTitleBar = ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', false); + super({ + id: BrowserViewCommandId.QuickOpen, + title: localize2('browser.quickOpenAction', "Quick Open Browser Tab..."), + icon: Codicon.globe, + category: BrowserActionCategory, + f1: true, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + // Note: on Linux this conflicts with the "toggle block comment" keybinding. + // it's not as problem at the moment becase oh the `when`, but worth noting for the future. + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, + when: BROWSER_EDITOR_ACTIVE + }, + menu: { + id: MenuId.TitleBar, + group: 'navigation', + order: 10, + when: ContextKeyExpr.and(CONTEXT_BROWSER_EDITOR_OPEN, neverShowInTitleBar.negate()), + } + }); + } + + run(accessor: ServicesAccessor): void { + const picker = accessor.get(IInstantiationService).createInstance(BrowserTabQuickPick); + picker.show(); + } +} + +interface IOpenBrowserOptions { + url?: string; + openToSide?: boolean; +} + +class OpenIntegratedBrowserAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.Open, + title: localize2('browser.openAction', "Open Integrated Browser"), + category: BrowserActionCategory, + icon: Codicon.globe, + f1: true, + menu: { + id: MenuId.TitleBar, + group: 'navigation', + order: 10, + when: ContextKeyExpr.and( + // This is a hack to work around `true` just testing for truthiness of the key. It works since `1 == true` in JS. + ContextKeyExpr.equals('config.workbench.browser.showInTitleBar', 1), + CONTEXT_BROWSER_EDITOR_OPEN.negate() + ) + } + }); + } + + async run(accessor: ServicesAccessor, urlOrOptions?: string | IOpenBrowserOptions): Promise { + const editorService = accessor.get(IEditorService); + const telemetryService = accessor.get(ITelemetryService); + + // Parse arguments + const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : (urlOrOptions ?? {}); + const resource = BrowserViewUri.forId(generateUuid()); + const group = options.openToSide ? SIDE_GROUP : ACTIVE_GROUP; + + logBrowserOpen(telemetryService, options.url ? 'commandWithUrl' : 'commandWithoutUrl'); + + const editorPane = await editorService.openEditor({ resource, options: { viewState: { url: options.url } } }, group); + + // Lock the group when opening to the side + if (options.openToSide && editorPane?.group) { + editorPane.group.lock(true); + } + } +} + +class NewTabAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.NewTab, + title: localize2('browser.newTabAction', "New Tab"), + category: BrowserActionCategory, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, + menu: { + id: MenuId.BrowserActionsToolbar, + group: BrowserActionGroup.Tabs, + order: 1, + }, + // When already in a browser, Ctrl/Cmd + T opens a new tab + keybinding: { + weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over search actions + primary: KeyMod.CtrlCmd | KeyCode.KeyT, + } + }); + } + + async run(accessor: ServicesAccessor, _browserEditor = accessor.get(IEditorService).activeEditorPane): Promise { + const editorService = accessor.get(IEditorService); + const telemetryService = accessor.get(ITelemetryService); + const resource = BrowserViewUri.forId(generateUuid()); + + logBrowserOpen(telemetryService, 'newTabCommand'); + + await editorService.openEditor({ resource }); + } +} + +class CloseAllBrowserTabsAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.CloseAll, + title: localize2('browser.closeAll', "Close All Browser Tabs"), + category: BrowserActionCategory, + f1: true, + precondition: CONTEXT_BROWSER_EDITOR_OPEN, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + for (const group of editorGroupsService.getGroups(GroupsOrder.GRID_APPEARANCE)) { + const browserEditors = group.getEditors(EditorsOrder.SEQUENTIAL).filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput); + if (browserEditors.length > 0) { + await group.closeEditors(browserEditors); + } + } + } +} + +class CloseAllBrowserTabsInGroupAction extends Action2 { + constructor() { + super({ + id: BrowserViewCommandId.CloseAllInGroup, + title: localize2('browser.closeAllInGroup', "Close All Browser Tabs in Group"), + category: BrowserActionCategory, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + const editorService = accessor.get(IEditorService); + const group = editorGroupsService.getGroup(editorService.activeEditorPane?.group?.id ?? editorGroupsService.activeGroup.id); + if (!group) { + return; + } + const browserEditors = group.getEditors(EditorsOrder.SEQUENTIAL).filter((e): e is BrowserEditorInput => e instanceof BrowserEditorInput); + if (browserEditors.length > 0) { + await group.closeEditors(browserEditors); + } + } +} + +// Register as "Close All Browser Tabs" action in editor title menu to align with the regular "Close All" action +MenuRegistry.appendMenuItem(MenuId.EditorTitleContext, { command: { id: BrowserViewCommandId.CloseAllInGroup, title: localize('browser.closeAllInGroupShort', "Close All Browser Tabs") }, group: '1_close', order: 55, when: BROWSER_EDITOR_ACTIVE }); + +registerAction2(QuickOpenBrowserAction); +registerAction2(OpenIntegratedBrowserAction); +registerAction2(NewTabAction); +registerAction2(CloseAllBrowserTabsAction); +registerAction2(CloseAllBrowserTabsInGroupAction); + +registerAction2(class ToggleBrowserTitleBarButton extends ToggleTitleBarConfigAction { + constructor() { + super('workbench.browser.showInTitleBar', localize('toggle.browser', 'Integrated Browser'), localize('toggle.browserDescription', "Toggle visibility of the Integrated Browser button in title bar"), 8); + } +}); + +/** + * Tracks whether any browser editor is open across all editor groups and + * keeps the `browserEditorOpen` context key in sync. + */ +class BrowserEditorOpenContextKeyContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.browserEditorOpenContextKey'; + + constructor( + @IContextKeyService contextKeyService: IContextKeyService, + @IEditorService editorService: IEditorService, + ) { + super(); + + const contextKey = CONTEXT_BROWSER_EDITOR_OPEN.bindTo(contextKeyService); + const update = () => contextKey.set(editorService.editors.some(e => e instanceof BrowserEditorInput)); + + update(); + + this._register(editorService.onWillOpenEditor(e => { + if (e.editor instanceof BrowserEditorInput) { + contextKey.set(true); + } + })); + this._register(editorService.onDidCloseEditor(e => { + if (e.editor instanceof BrowserEditorInput) { + update(); + } + })); + } +} + +registerWorkbenchContribution2(BrowserEditorOpenContextKeyContribution.ID, BrowserEditorOpenContextKeyContribution, WorkbenchPhase.AfterRestored); + +/** + * Opens localhost URLs in the Integrated Browser when the setting is enabled. + */ +class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchContribution, IExternalOpener { + static readonly ID = 'workbench.contrib.localhostLinkOpener'; + + constructor( + @IOpenerService openerService: IOpenerService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IEditorService private readonly editorService: IEditorService, + @ITelemetryService private readonly telemetryService: ITelemetryService + ) { + super(); + + this._register(openerService.registerExternalOpener(this)); + } + + async openExternal(href: string, _ctx: { sourceUri: URI; preferredOpenerId?: string }, _token: CancellationToken): Promise { + if (!this.configurationService.getValue('workbench.browser.openLocalhostLinks')) { + return false; + } + + try { + const parsed = new URL(href); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return false; + } + if (!isLocalhostAuthority(parsed.host)) { + return false; + } + } catch { + return false; + } + + logBrowserOpen(this.telemetryService, 'localhostLinkOpener'); + + const browserUri = BrowserViewUri.forId(generateUuid()); + await this.editorService.openEditor({ resource: browserUri, options: { pinned: true, viewState: { url: href } } }); + return true; + } +} + +registerWorkbenchContribution2(LocalhostLinkOpenerContribution.ID, LocalhostLinkOpenerContribution, WorkbenchPhase.BlockStartup); + +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + ...workbenchConfigurationNodeBase, + properties: { + 'workbench.browser.showInTitleBar': { + type: ['boolean', 'string'], + enum: [true, false, 'whenOpen'], + enumDescriptions: [ + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.true' }, 'The button is always shown in the title bar.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.false' }, 'The button is never shown in the title bar.'), + localize({ comment: ['This is the description for a setting. Values surrounded by single quotes are not to be translated.'], key: 'browser.showInTitleBar.whenOpen' }, 'The button is shown in the title bar when a browser editor is open.') + ], + default: 'whenOpen', + experiment: { mode: 'startup' }, + description: localize( + { comment: ['This is the description for a setting.'], key: 'browser.showInTitleBar' }, + 'Controls whether the Integrated Browser button is shown in the title bar.' + ) + }, + 'workbench.browser.openLocalhostLinks': { + type: 'boolean', + default: false, + markdownDescription: localize( + { comment: ['This is the description for a setting.'], key: 'browser.openLocalhostLinks' }, + 'When enabled, localhost links from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' + ) + } + } +}); 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 1fec1ab961c..f5856137557 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -30,7 +30,7 @@ width: 100%; height: 100%; - .browser-toolbar { + .browser-navbar { display: flex; align-items: center; padding: 6px 8px; @@ -58,6 +58,7 @@ flex: 1; display: flex; align-items: center; + min-width: 0; background-color: var(--vscode-input-background); border: 1px solid var(--vscode-input-border); border-radius: var(--vscode-cornerRadius-small); @@ -83,6 +84,12 @@ white-space: nowrap; } + .browser-url-bar-widgets { + display: flex; + align-items: center; + flex-shrink: 0; + } + .browser-zoom-pill { display: none; align-items: center; @@ -358,10 +365,6 @@ z-index: 10; overflow: hidden; - &.find-visible { - border-bottom: 1px solid var(--vscode-widget-border); - } - /* Override SimpleFindWidget absolute positioning to flow in layout */ .simple-find-part-wrapper { position: relative; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 19984144472..f33a0d8cba9 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -56,6 +56,7 @@ import { ISCMHistoryItemChangeRangeVariableEntry, ISCMHistoryItemChangeVariableE import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetHistoryService } from '../../common/widget/chatWidgetHistoryService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; +import { AICustomizationManagementCommands } from '../aiCustomization/aiCustomizationManagement.js'; import { ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js'; import { CopilotUsageExtensionFeatureId } from '../../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; @@ -1435,6 +1436,12 @@ export function registerChatActions() { id: MenuId.ChatWelcomeContext, group: '2_settings', order: 1 + }, + { + id: MenuId.ViewTitle, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + order: 15, + group: '3_configure' }] }); } @@ -1445,11 +1452,29 @@ export function registerChatActions() { } }); + // When customizations menu is enabled, show a direct gear action to open the Customizations editor + MenuRegistry.appendMenuItem(MenuId.ViewTitle, { + command: { + id: AICustomizationManagementCommands.OpenEditor, + title: localize2('openChatCustomizations', "Open Customizations"), + category: CHAT_CATEGORY, + icon: Codicon.gear + }, + group: 'navigation', + when: ContextKeyExpr.and( + ChatContextKeys.enabled, + ContextKeyExpr.equals('view', ChatViewId), + ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`) + ), + order: 6 + }); + + // When customizations menu is disabled, show the legacy gear submenu MenuRegistry.appendMenuItem(MenuId.ViewTitle, { submenu: CHAT_CONFIG_MENU_ID, title: localize2('config.label', "Configure Chat"), group: 'navigation', - when: ContextKeyExpr.equals('view', ChatViewId), + when: ContextKeyExpr.and(ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`).negate()), icon: Codicon.gear, order: 6 }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts index e4aeffebcaf..d3cf66b0f03 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts @@ -21,19 +21,16 @@ 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 { ILanguageModelToolsService, isToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; -import { IChatWidget, IChatWidgetService } from '../chat.js'; +import { isToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; +import { IChatWidget } 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'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; /** * Command ID that extensions can call to enable debug tools for the current @@ -49,37 +46,9 @@ 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 hasDebugToolsKey = ChatContextKeys.chatSessionHasDebugTools.bindTo(contextKeyService); - this._store.add(chatWidgetService.onDidChangeFocusedSession(() => { - const sessionResource = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; - hasDebugToolsKey.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()) { - hasDebugToolsKey.set(true); - languageModelToolsService.flushToolUpdates(); - } - })); - - // Register a command that extensions can call to enable debug tools - // for the current session. This sets the context key AND flushes the - // tools service synchronously so the change is visible immediately. - this._store.add(CommandsRegistry.registerCommand(EnableChatDebugToolsCommandId, () => { - hasDebugToolsKey.set(true); - languageModelToolsService.flushToolUpdates(); - })); - // ############################################################################################### // // Default context picks/values which are "native" to chat. This is NOT the complete list diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts index fd008ea10c9..026866c4e4e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatForkActions.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { revive } from '../../../../../base/common/marshalling.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -11,10 +12,12 @@ import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IProgressService, ProgressLocation } from '../../../../../platform/progress/common/progress.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; import type { ISerializableChatData } from '../../common/model/chatModel.js'; import { isChatTreeItem, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; +import { IChatSessionRequestHistoryItem, IChatSessionsService } from '../../common/chatSessionsService.js'; import { CHAT_CATEGORY } from './chatActions.js'; import { ChatTreeItem, ChatViewPaneTarget, IChatWidgetService } from '../chat.js'; @@ -34,7 +37,13 @@ export function registerChatForkActions() { id: MenuId.ChatMessageCheckpoint, group: 'navigation', order: 3, - when: ContextKeyExpr.and(ChatContextKeys.isRequest, ChatContextKeys.lockedToCodingAgent.negate()) + when: ContextKeyExpr.and( + ChatContextKeys.isRequest, + ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent.negate(), + ChatContextKeys.chatSessionSupportsFork + ) + ) } ] }); @@ -43,12 +52,22 @@ export function registerChatForkActions() { async run(accessor: ServicesAccessor, ...args: unknown[]) { const chatWidgetService = accessor.get(IChatWidgetService); const chatService = accessor.get(IChatService); + const chatSessionsService = accessor.get(IChatSessionsService); + const progressService = accessor.get(IProgressService); const forkedTitlePrefix = localize('chat.forked.titlePrefix', "Forked: "); // When invoked via /fork slash command, args[0] is a URI (sessionResource). // Fork at the last request in that session. if (URI.isUri(args[0])) { const sourceSessionResource = args[0]; + + // Check if this is a contributed session that supports forking + const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); + if (contentProviderSchemes.includes(sourceSessionResource.scheme)) { + await forkContributedChatSession(sourceSessionResource, undefined, false, chatSessionsService, chatWidgetService, progressService); + return; + } + const chatModel = chatService.getSession(sourceSessionResource); if (!chatModel) { return; @@ -117,17 +136,43 @@ export function registerChatForkActions() { return; } - const chatModel = chatService.getSession(sessionResource); - if (!chatModel) { - return; - } - // Get all requests and find the target request index const targetRequestId = isRequestVM(item) ? item.id : isResponseVM(item) ? item.requestId : undefined; if (!targetRequestId) { return; } + // Check if this is a contributed session that supports forking + const contentProviderSchemes = chatSessionsService.getContentProviderSchemes(); + if (contentProviderSchemes.includes(sessionResource.scheme)) { + const contributedSession = await chatSessionsService.getOrCreateChatSession(sessionResource, CancellationToken.None); + let request = contributedSession.history.find((entry): entry is IChatSessionRequestHistoryItem => entry.type === 'request' && entry.id === targetRequestId); + if (!request) { + const chatModel = chatService.getSession(sessionResource); + const serializedData = chatModel?.toJSON(); + for (const [, entry] of serializedData?.requests.entries() ?? []) { + if (entry.requestId === targetRequestId) { + request = { + id: entry.requestId, + type: 'request', + prompt: typeof entry.message === 'string' ? entry.message : entry.message.text, + participant: entry.agent?.id ?? '', + variableData: entry.variableData, + modelId: entry.modelId, + }; + break; + } + } + } + await forkContributedChatSession(sessionResource, request, true, chatSessionsService, chatWidgetService, progressService); + return; + } + + const chatModel = chatService.getSession(sessionResource); + if (!chatModel) { + return; + } + // Export the full session data and truncate to include only requests up to and including the target const serializedData = chatModel.toJSON(); const isRequestItem = isRequestVM(item); @@ -187,3 +232,26 @@ export function registerChatForkActions() { } }); } + +async function forkContributedChatSession(sourceSessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, openForkedSessionImmediately: boolean, chatSessionsService: IChatSessionsService, chatWidgetService: IChatWidgetService, progressService: IProgressService) { + const forkedItem = await progressService.withProgress( + { location: ProgressLocation.Notification, title: localize('forking', "Forking session...") }, + async () => { + const cts = new CancellationTokenSource(); + try { + return await chatSessionsService.forkChatSession(sourceSessionResource, request, cts.token); + } finally { + cts.dispose(); + } + } + ); + if (forkedItem) { + if (openForkedSessionImmediately) { + await chatWidgetService.openSession(forkedItem.resource, ChatViewPaneTarget); + } else { + setTimeout(async () => { + await chatWidgetService.openSession(forkedItem.resource, ChatViewPaneTarget); + }, 0); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index 263c8839baf..d6522e25c2a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -14,6 +14,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService, 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 { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -22,9 +23,11 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatDebugService } from '../../common/chatDebugService.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; +import { ChatConfiguration } from '../../common/constants.js'; import { ChatDebugEditorInput } from '../chatDebug/chatDebugEditorInput.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { IChatDebugEditorOptions } from '../chatDebug/chatDebugTypes.js'; +import { LocalChatSessionUri } from '../../common/model/chatUri.js'; /** * Registers the Open Agent Debug Logs and Show Agent Debug Logs actions. @@ -71,6 +74,11 @@ export function registerChatOpenAgentDebugPanelAction() { group: '2_settings', order: 0, when: ChatContextKeys.inChatEditor.negate() + }, { + id: MenuId.ViewTitle, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + order: 0, + group: '4_logs' }] }); } @@ -127,6 +135,7 @@ export function registerChatOpenAgentDebugPanelAction() { const fileDialogService = accessor.get(IFileDialogService); const fileService = accessor.get(IFileService); const notificationService = accessor.get(INotificationService); + const openerService = accessor.get(IOpenerService); const telemetryService = accessor.get(ITelemetryService); const sessionResource = chatDebugService.activeSessionResource; @@ -135,7 +144,11 @@ export function registerChatOpenAgentDebugPanelAction() { return; } - const defaultUri = joinPath(await fileDialogService.defaultFilePath(), defaultDebugLogFileName); + const localSessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource); + const rawIdentifier = localSessionId ?? (sessionResource.path.replace(/^\//, '') || sessionResource.authority); + const sessionIdentifier = rawIdentifier?.replace(/[/\\:*?"<>|.]+/g, '_').replace(/^_+|_+$/g, ''); + const exportFileName = sessionIdentifier ? `agent-debug-log-${sessionIdentifier}.json` : defaultDebugLogFileName; + const defaultUri = joinPath(await fileDialogService.defaultFilePath(), exportFileName); const outputPath = await fileDialogService.showSaveDialog({ defaultUri, filters: debugLogFilters }); if (!outputPath) { return; @@ -152,6 +165,15 @@ export function registerChatOpenAgentDebugPanelAction() { telemetryService.publicLog2('chatDebugLogExported', { fileSizeBytes: data.byteLength, }); + + notificationService.prompt( + Severity.Info, + localize('chatDebugLog.exportSuccess', "Agent debug log exported successfully."), + [{ + label: localize('chatDebugLog.openExportedFile', "Open File"), + run: () => openerService.open(outputPath) + }] + ); } }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index 14c73c7b688..89475130ce5 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -45,12 +45,12 @@ export function registerChatTitleActions() { id: MenuId.ChatMessageFooter, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig)) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate()) }, { id: MENU_INLINE_CHAT_WIDGET_SECONDARY, group: 'navigation', order: 1, - when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig)) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate()) }] }); } @@ -92,12 +92,12 @@ export function registerChatTitleActions() { id: MenuId.ChatMessageFooter, group: 'navigation', order: 3, - when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ContextKeyExpr.has(enableFeedbackConfig)) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate()) }, { id: MENU_INLINE_CHAT_WIDGET_SECONDARY, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig)) + when: ContextKeyExpr.and(ChatContextKeys.extensionParticipantRegistered, ChatContextKeys.isResponse, ChatContextKeys.responseHasError.negate(), ContextKeyExpr.has(enableFeedbackConfig), ChatContextKeys.lockedToCodingAgent.negate()) }] }); } @@ -188,12 +188,13 @@ export function registerChatTitleActions() { group: 'navigation', when: ContextKeyExpr.and( ChatContextKeys.isResponse, - ContextKeyExpr.in(ChatContextKeys.itemId.key, ChatContextKeys.lastItemId.key)) + ContextKeyExpr.in(ChatContextKeys.itemId.key, ChatContextKeys.lastItemId.key), + ChatContextKeys.lockedToCodingAgent.negate()) }, { id: MenuId.ChatEditingWidgetToolbar, group: 'navigation', - when: applyingChatEditsFailedContextKey, + when: ContextKeyExpr.and(applyingChatEditsFailedContextKey, ChatContextKeys.lockedToCodingAgent.negate()), order: 0 } ] @@ -283,7 +284,7 @@ export function registerChatTitleActions() { id: MenuId.ChatMessageFooter, group: 'navigation', isHiddenByDefault: true, - when: ContextKeyExpr.and(NOTEBOOK_IS_ACTIVE_EDITOR, ChatContextKeys.isResponse, ChatContextKeys.responseIsFiltered.negate()) + when: ContextKeyExpr.and(NOTEBOOK_IS_ACTIVE_EDITOR, ChatContextKeys.isResponse, ChatContextKeys.responseIsFiltered.negate(), ChatContextKeys.lockedToCodingAgent.negate()) } }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index 92d569bc9be..155b5b23178 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -286,13 +286,27 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } } - async cleanupPluginSource(plugin: IMarketplacePlugin): Promise { + async cleanupPluginSource(plugin: IMarketplacePlugin, otherInstalledDescriptors?: readonly IPluginSourceDescriptor[]): Promise { const repo = this.getPluginSource(plugin.sourceDescriptor.kind); const cleanupDir = repo.getCleanupTarget(this._cacheRoot, plugin.sourceDescriptor); if (!cleanupDir) { return; } + // Skip deletion when another installed plugin shares the same + // cleanup target (e.g. same cloned repository with different sub-paths). + if (otherInstalledDescriptors) { + const shared = otherInstalledDescriptors.some(other => { + const otherRepo = this.getPluginSource(other.kind); + const otherTarget = otherRepo.getCleanupTarget(this._cacheRoot, other); + return otherTarget && isEqual(otherTarget, cleanupDir); + }); + if (shared) { + this._logService.info(`[${plugin.sourceDescriptor.kind}] Skipping cleanup of shared cache: ${cleanupDir.toString()}`); + return; + } + } + try { const exists = await this._fileService.exists(cleanupDir); if (exists) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 28130358707..c87e45bead2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -66,7 +66,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr this._setupIpcLogging(); // Shared client state for protocol reconciliation - this._clientState = this._register(new SessionClientState(this._agentHostService.clientId)); + this._clientState = this._register(new SessionClientState(this._agentHostService.clientId, this._logService)); // Forward action envelopes from the host to client state this._register(this._agentHostService.onDidAction(envelope => { @@ -219,6 +219,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr fullName: agent.displayName, description: agent.description, connection: this._agentHostService, + resolveAuthentication: () => this._resolveAuthenticationInteractively(), })); store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -233,27 +234,97 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr store.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); // Push auth token and refresh models from server - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }); store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); store.add(this._authenticationService.onDidChangeSessions(() => - this._pushAuthToken().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); + this._authenticateWithServer().then(() => this._agentHostService.refreshModels()).catch(() => { /* best-effort */ }))); } - private async _pushAuthToken(): Promise { + /** + * Discover auth requirements from the server's resource metadata + * and authenticate using matching tokens resolved via the standard + * VS Code authentication service (same flow as MCP auth). + */ + private async _authenticateWithServer(): Promise { try { - const account = await this._defaultAccountService.getDefaultAccount(); - if (!account) { - return; + const metadata = await this._agentHostService.getResourceMetadata(); + this._logService.trace(`[AgentHost] Resource metadata: ${metadata.resources.length} resource(s)`); + for (const resource of metadata.resources) { + const resourceUri = URI.parse(resource.resource); + const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + this._logService.info(`[AgentHost] Authenticating for resource: ${resource.resource}`); + await this._agentHostService.authenticate({ resource: resource.resource, token }); + } else { + this._logService.info(`[AgentHost] No token resolved for resource: ${resource.resource}`); + } } - - const sessions = await this._authenticationService.getSessions(account.authenticationProvider.id); - const session = sessions.find(s => s.id === account.sessionId); - if (session) { - await this._agentHostService.setAuthToken(session.accessToken); - } - } catch { - // best-effort + } catch (err) { + this._logService.error('[AgentHost] Failed to authenticate with server', err); } } + + /** + * Resolve a bearer token for a set of authorization servers using the + * standard VS Code authentication service provider resolution. + */ + private async _resolveTokenForResource(resourceServer: URI, authorizationServers: readonly string[], scopes: readonly string[]): Promise { + for (const server of authorizationServers) { + const serverUri = URI.parse(server); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceServer); + if (!providerId) { + this._logService.trace(`[AgentHost] No auth provider found for server: ${server}`); + continue; + } + this._logService.trace(`[AgentHost] Resolved auth provider '${providerId}' for server: ${server}`); + + const sessions = await this._authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true); + if (sessions.length > 0) { + return sessions[0].accessToken; + } + + this._logService.trace(`[AgentHost] No sessions found for provider '${providerId}'`); + } + return undefined; + } + + /** + * Interactively prompt the user to authenticate when the server requires it. + * Fetches resource metadata, resolves the auth provider, creates a session + * (which triggers the login UI), and pushes the token to the server. + * Returns true if authentication succeeded. + */ + private async _resolveAuthenticationInteractively(): Promise { + try { + const metadata = await this._agentHostService.getResourceMetadata(); + for (const resource of metadata.resources) { + for (const server of resource.authorization_servers ?? []) { + const serverUri = URI.parse(server); + const resourceUri = URI.parse(resource.resource); + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri); + if (!providerId) { + continue; + } + + // createSession will show the login UI if no session exists + const scopes = [...(resource.scopes_supported ?? [])]; + const session = await this._authenticationService.createSession(providerId, scopes, { + activateImmediate: true, + authorizationServer: serverUri, + }); + + await this._agentHostService.authenticate({ + resource: resource.resource, + token: session.accessToken, + }); + this._logService.info(`[AgentHost] Interactive authentication succeeded for ${resource.resource}`); + return true; + } + } + } catch (err) { + this._logService.error('[AgentHost] Interactive authentication failed', err); + } + return false; + } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index a1ddeacc81b..c12d76c2766 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -7,6 +7,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Emitter } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../../nls.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -16,10 +17,11 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IAgentAttachment, AgentProvider, AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ActionType, isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js'; import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; -import { TurnState, type IMessageAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AttachmentType, ToolCallStatus, TurnState, type IMessageAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { IChatProgress, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; @@ -96,6 +98,12 @@ export interface IAgentHostSessionHandlerConfig { * If not provided, falls back to the first workspace folder. */ readonly resolveWorkingDirectory?: (resourceKey: string) => string | undefined; + /** + * Optional callback invoked when the server rejects an operation because + * authentication is required. Should trigger interactive authentication + * and return true if the user authenticated successfully. + */ + readonly resolveAuthentication?: () => Promise; } export class AgentHostSessionHandler extends Disposable implements IChatSessionContentProvider { @@ -120,7 +128,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._config = config; // Create shared client state manager for this handler instance - this._clientState = this._register(new SessionClientState(config.connection.clientId)); + this._clientState = this._register(new SessionClientState(config.connection.clientId, this._logService)); // Forward action envelopes from IPC to client state this._register(config.connection.onDidAction(envelope => { @@ -265,7 +273,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const currentModel = this._clientState.getSessionState(session.toString())?.summary.model; if (currentModel !== rawModelId) { const modelAction = { - type: 'session/modelChanged' as const, + type: ActionType.SessionModelChanged as const, session: session.toString(), model: rawModelId, }; @@ -277,7 +285,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Dispatch session/turnStarted — the server will call sendMessage on // the provider as a side effect. const turnAction = { - type: 'session/turnStarted' as const, + type: ActionType.SessionTurnStarted as const, session: session.toString(), turnId, userMessage: { @@ -355,15 +363,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC for (const [toolCallId, tc] of Object.entries(activeTurn.toolCalls)) { const existing = activeToolInvocations.get(toolCallId); if (!existing) { - if (tc.status === 'running' || tc.status === 'streaming' || tc.status === 'pending-confirmation') { + if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Streaming || tc.status === ToolCallStatus.PendingConfirmation) { const invocation = toolCallStateToInvocation(tc); activeToolInvocations.set(toolCallId, invocation); progress([invocation]); } - } else if (tc.status === 'completed' || tc.status === 'cancelled') { + } else if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { activeToolInvocations.delete(toolCallId); finalizeToolInvocation(existing, tc); - } else if (tc.status === 'running' || tc.status === 'pending-confirmation') { + } else if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.PendingConfirmation) { // Tool transitioned from streaming to ready — update the invocation // with the now-available invocationMessage and toolSpecificData. existing.invocationMessage = typeof tc.invocationMessage === 'string' @@ -392,7 +400,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const approved = reason.type !== ToolConfirmKind.Denied && reason.type !== ToolConfirmKind.Skipped; this._logService.info(`[AgentHost] Permission response: requestId=${requestId}, approved=${approved}`); const resolveAction = { - type: 'session/permissionResolved' as const, + type: ActionType.SessionPermissionResolved as const, session: session.toString(), turnId, requestId, @@ -414,7 +422,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC turnDisposables.add(cancellationToken.onCancellationRequested(() => { this._logService.info(`[AgentHost] Cancellation requested for ${session.toString()}, dispatching turnCancelled`); const cancelAction = { - type: 'session/turnCancelled' as const, + type: ActionType.SessionTurnCancelled as const, session: session.toString(), turnId, }; @@ -442,11 +450,33 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ?? this._workspaceContextService.getWorkspace().folders[0]?.uri.fsPath; this._logService.trace(`[AgentHost] Creating new session, model=${rawModelId ?? '(default)'}, provider=${this._config.provider}`); - const session = await this._config.connection.createSession({ - model: rawModelId, - provider: this._config.provider, - workingDirectory, - }); + + let session: URI; + try { + session = await this._config.connection.createSession({ + model: rawModelId, + provider: this._config.provider, + workingDirectory, + }); + } catch (err) { + // If authentication is required, try to resolve it and retry once + if (this._isAuthRequiredError(err) && this._config.resolveAuthentication) { + this._logService.info('[AgentHost] Authentication required, prompting user...'); + const authenticated = await this._config.resolveAuthentication(); + if (authenticated) { + session = await this._config.connection.createSession({ + model: rawModelId, + provider: this._config.provider, + workingDirectory, + }); + } else { + throw new Error(localize('agentHost.authRequired', "Authentication is required to start a session. Please sign in and try again.")); + } + } else { + throw err; + } + } + this._logService.trace(`[AgentHost] Created session: ${session.toString()}`); // Subscribe to the new session's state @@ -460,6 +490,22 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return session; } + /** + * Check if an error is an "authentication required" error. + * Checks for the AHP_AUTH_REQUIRED error code when available, + * with a message-based fallback for transports that don't preserve + * structured error codes (e.g. ProxyChannel). + */ + private _isAuthRequiredError(err: unknown): boolean { + if (err instanceof ProtocolError && err.code === AHP_AUTH_REQUIRED) { + return true; + } + if (err instanceof Error && err.message.includes('Authentication required')) { + return true; + } + return false; + } + /** * Extracts the raw model id from a language-model service identifier. * E.g. "agent-host-copilot:claude-sonnet-4-20250514" → "claude-sonnet-4-20250514". @@ -481,17 +527,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (v.kind === 'file') { const uri = v.value instanceof URI ? v.value : undefined; if (uri?.scheme === 'file') { - attachments.push({ type: 'file', path: uri.fsPath, displayName: v.name }); + attachments.push({ type: AttachmentType.File, path: uri.fsPath, displayName: v.name }); } } else if (v.kind === 'directory') { const uri = v.value instanceof URI ? v.value : undefined; if (uri?.scheme === 'file') { - attachments.push({ type: 'directory', path: uri.fsPath, displayName: v.name }); + attachments.push({ type: AttachmentType.Directory, path: uri.fsPath, displayName: v.name }); } } else if (v.kind === 'implicit' && v.isSelection) { const uri = v.uri; if (uri?.scheme === 'file') { - attachments.push({ type: 'selection', path: uri.fsPath, displayName: v.name }); + attachments.push({ type: AttachmentType.Selection, path: uri.fsPath, displayName: v.name }); } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index cf43a98126c..095117618df 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -41,12 +41,14 @@ export class AgentHostSessionListController extends Disposable implements IChatS this._register(this._connection.onDidNotification(n => { if (n.type === 'notify/sessionAdded' && n.summary.provider === this._provider) { const rawId = AgentSession.id(n.summary.resource); + const workingDir = typeof n.summary.workingDirectory === 'string' ? n.summary.workingDirectory : undefined; const item: IChatSessionItem = { resource: URI.from({ scheme: this._sessionType, path: `/${rawId}` }), label: n.summary.title ?? `Session ${rawId.substring(0, 8)}`, description: this._description, iconPath: getAgentHostIcon(this._productService), status: ChatSessionStatus.Completed, + metadata: this._buildMetadata(workingDir), timing: { created: n.summary.createdAt, lastRequestStarted: n.summary.modifiedAt, @@ -89,6 +91,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS description: this._description, iconPath: getAgentHostIcon(this._productService), status: ChatSessionStatus.Completed, + metadata: this._buildMetadata(s.workingDirectory), timing: { created: s.startTime, lastRequestStarted: s.modifiedTime, @@ -100,4 +103,15 @@ export class AgentHostSessionListController extends Disposable implements IChatS } this._onDidChangeChatSessionItems.fire({ addedOrUpdated: this._items }); } + + private _buildMetadata(workingDirectory?: string): { readonly [key: string]: unknown } | undefined { + if (!this._description) { + return undefined; + } + const result: { [key: string]: unknown } = { remoteAgentHost: this._description }; + if (workingDirectory) { + result.workingDirectoryPath = workingDirectory; + } + return result; + } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 7b5cf77c3dd..bad97cd2521 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { TurnState, getToolOutputText, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { PermissionKind, ToolCallStatus, TurnState, getToolOutputText, type ICompletedToolCall, type IPermissionRequest, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; @@ -49,12 +49,12 @@ export function turnsToHistory(turns: readonly ITurn[], participantId: string): */ function completedToolCallToSerialized(tc: ICompletedToolCall): IChatToolInvocationSerialized { const isTerminal = getToolKind(tc) === 'terminal'; - const isSuccess = tc.status === 'completed' && tc.success; + const isSuccess = tc.status === ToolCallStatus.Completed && tc.success; const invocationMsg = stringOrMarkdownToString(tc.invocationMessage) ?? ''; let toolSpecificData: IChatTerminalToolInvocationData | undefined; if (isTerminal && tc.toolInput) { - const toolOutput = tc.status === 'completed' ? getToolOutputText(tc) : undefined; + const toolOutput = tc.status === ToolCallStatus.Completed ? getToolOutputText(tc) : undefined; toolSpecificData = { kind: 'terminal', commandLine: { original: tc.toolInput }, @@ -117,7 +117,7 @@ export function toolCallStateToInvocation(tc: IToolCallState): ChatToolInvocatio if (getToolKind(tc) === 'terminal') { invocation.toolSpecificData = { kind: 'terminal', - commandLine: { original: tc.status !== 'streaming' ? (tc.toolInput ?? '') : '' }, + commandLine: { original: tc.status !== ToolCallStatus.Streaming ? (tc.toolInput ?? '') : '' }, language: getToolLanguage(tc) ?? 'shellscript', } satisfies IChatTerminalToolInvocationData; } @@ -135,7 +135,7 @@ export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvo let toolSpecificData: IChatTerminalToolInvocationData | IChatToolInputInvocationData | undefined; switch (perm.permissionKind) { - case 'shell': { + case PermissionKind.Shell: { title = perm.intention ?? 'Run command'; toolSpecificData = perm.fullCommandText ? { kind: 'terminal', @@ -144,14 +144,14 @@ export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvo } : undefined; break; } - case 'write': { + case PermissionKind.Write: { title = perm.path ? `Edit ${perm.path}` : 'Edit file'; let rawInput: unknown; try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : { path: perm.path }; } catch { rawInput = { path: perm.path }; } toolSpecificData = { kind: 'input', rawInput }; break; } - case 'mcp': { + case PermissionKind.Mcp: { const toolTitle = perm.toolName ?? 'MCP Tool'; title = perm.serverName ? `${perm.serverName}: ${toolTitle}` : toolTitle; let rawInput: unknown; @@ -159,7 +159,7 @@ export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvo toolSpecificData = { kind: 'input', rawInput }; break; } - case 'read': { + case PermissionKind.Read: { title = perm.intention ?? 'Read file'; let rawInput: unknown; try { rawInput = perm.rawRequest ? JSON.parse(perm.rawRequest) : { path: perm.path, intention: perm.intention }; } catch { rawInput = { path: perm.path, intention: perm.intention }; } @@ -202,8 +202,8 @@ export function permissionToConfirmation(perm: IPermissionRequest): ChatToolInvo * protocol's tool-call state, transitioning it to the completed state. */ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: IToolCallState): void { - const isCompleted = tc.status === 'completed'; - const isCancelled = tc.status === 'cancelled'; + const isCompleted = tc.status === ToolCallStatus.Completed; + const isCancelled = tc.status === ToolCallStatus.Cancelled; const isTerminal = invocation.toolSpecificData?.kind === 'terminal' || getToolKind(tc) === 'terminal'; if (isTerminal && (isCompleted || isCancelled)) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 4000cefb73b..61ef7f44865 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -16,7 +16,7 @@ import { IAgentSessionsService, AgentSessionsService } from './agentSessionsServ import { LocalAgentsSessionsController } from './localAgentSessionsController.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, MarkAllAgentSessionsReadAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction, MarkAgentSessionSectionReadAction, ToggleShowAgentSessionsAction, UnarchiveAgentSessionSectionAction, PinAgentSessionAction, UnpinAgentSessionAction } from './agentSessionsActions.js'; +import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, MarkAllAgentSessionsReadAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction, MarkAgentSessionSectionReadAction, ToggleShowAgentSessionsAction, UnarchiveAgentSessionSectionAction, PinAgentSessionAction, UnpinAgentSessionAction, CollapseAllAgentSessionSectionsAction } from './agentSessionsActions.js'; import { AgentSessionsQuickAccessProvider, AGENT_SESSIONS_QUICK_ACCESS_PREFIX } from './agentSessionsQuickAccess.js'; //#region Actions and Menus @@ -28,6 +28,7 @@ registerAction2(MarkAllAgentSessionsReadAction); registerAction2(ArchiveAgentSessionSectionAction); registerAction2(UnarchiveAgentSessionSectionAction); registerAction2(MarkAgentSessionSectionReadAction); +registerAction2(CollapseAllAgentSessionSectionsAction); registerAction2(ArchiveAgentSessionAction); registerAction2(UnarchiveAgentSessionAction); registerAction2(PinAgentSessionAction); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index f01ebe74835..b58ce96c3c3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -21,6 +21,14 @@ export enum AgentSessionProviders { AgentHostCopilot = 'agent-host-copilot', } +/** + * A session target is either a well-known {@link AgentSessionProviders} enum + * value or a dynamic string for dynamically-registered providers (e.g. remote + * agent hosts like `remote-{authority}-copilot`). + * TODO@roblourens HACK + */ +export type AgentSessionTarget = AgentSessionProviders | (string & {}); + export function isBuiltInAgentSessionProvider(provider: string): boolean { return provider === AgentSessionProviders.Local || provider === AgentSessionProviders.Background || @@ -171,6 +179,9 @@ export interface IAgentSessionsControl { clearFocus(): void; hasFocusOrSelection(): boolean; + + resetSectionCollapseState(): void; + collapseAllSections(): void; } export const agentSessionReadIndicatorForeground = registerColor( diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 2250b149a33..b501f0a66ba 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -365,6 +365,25 @@ export class MarkAgentSessionSectionReadAction extends Action2 { } } +export class CollapseAllAgentSessionSectionsAction extends Action2 { + + constructor() { + super({ + id: 'agentSessionSection.collapseAll', + title: localize2('collapseAll', "Collapse All"), + menu: [{ + id: MenuId.AgentSessionSectionContext, + group: '2_collapse', + order: 1, + }] + }); + } + + async run(accessor: ServicesAccessor, _section: unknown, control?: IAgentSessionsControl): Promise { + control?.collapseAllSections(); + } +} + //#endregion //#region Session Actions @@ -552,7 +571,6 @@ export class PinAgentSessionAction extends BaseAgentSessionAction { group: 'navigation', order: 0, when: ContextKeyExpr.and( - IsSessionsWindowContext, ChatContextKeys.isPinnedAgentSession.negate(), ChatContextKeys.isArchivedAgentSession.negate() ), @@ -561,7 +579,6 @@ export class PinAgentSessionAction extends BaseAgentSessionAction { group: '0_pin', order: 1, when: ContextKeyExpr.and( - IsSessionsWindowContext, ChatContextKeys.isPinnedAgentSession.negate(), ChatContextKeys.isArchivedAgentSession.negate() ), @@ -588,7 +605,6 @@ export class UnpinAgentSessionAction extends BaseAgentSessionAction { group: 'navigation', order: 0, when: ContextKeyExpr.and( - IsSessionsWindowContext, ChatContextKeys.isPinnedAgentSession, ChatContextKeys.isArchivedAgentSession.negate() ), @@ -597,7 +613,6 @@ export class UnpinAgentSessionAction extends BaseAgentSessionAction { group: '0_pin', order: 1, when: ContextKeyExpr.and( - IsSessionsWindowContext, ChatContextKeys.isPinnedAgentSession, ChatContextKeys.isArchivedAgentSession.negate() ), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 1130ad7925e..1f073a38862 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -13,8 +13,8 @@ import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent 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 } from './agentSessionsViewer.js'; -import { AgentSessionsGrouping } from './agentSessionsFilter.js'; +import { AgentSessionListItem, AgentSessionRenderer, AgentSessionsAccessibilityProvider, AgentSessionsCompressionDelegate, AgentSessionsDataSource, AgentSessionsDragAndDrop, AgentSessionsIdentityProvider, AgentSessionsKeyboardNavigationLabelProvider, AgentSessionsListDelegate, AgentSessionSectionRenderer, AgentSessionSectionLabels, AgentSessionsSorter, getRepositoryName, IAgentSessionsFilter } from './agentSessionsViewer.js'; +import { AgentSessionsGrouping, AgentSessionsSorting } from './agentSessionsFilter.js'; import { AgentSessionApprovalModel } from './agentSessionApprovalModel.js'; import { FuzzyScore } from '../../../../../base/common/filters.js'; import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; @@ -42,7 +42,6 @@ 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'; -import { ILogService } from '../../../../../platform/log/common/log.js'; export interface IAgentSessionsControlOptions { readonly overrideStyles: IStyleOverride; @@ -80,8 +79,11 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo private emptyFilterMessage: HTMLElement | undefined; private sessionsList: WorkbenchCompressibleAsyncDataTree | undefined; + private static readonly RECENT_SESSIONS_FOR_EXPAND = 5; + private sessionsListFindIsOpen = false; private _isProgrammaticCollapseChange = false; + private readonly _recentRepositoryLabels = new Set(); private readonly updateSessionsListThrottler = this._register(new Throttler()); @@ -109,7 +111,6 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo @ITelemetryService private readonly telemetryService: ITelemetryService, @IEditorService private readonly editorService: IEditorService, @IStorageService private readonly storageService: IStorageService, - @ILogService private readonly logService: ILogService, ) { super(); @@ -215,6 +216,10 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.storageService.store(AgentSessionsControl.SECTION_COLLAPSE_STATE_KEY, JSON.stringify(state), StorageScope.PROFILE, StorageTarget.USER); } + resetSectionCollapseState(): void { + this.storageService.remove(AgentSessionsControl.SECTION_COLLAPSE_STATE_KEY, StorageScope.PROFILE); + } + private createList(container: HTMLElement): void { const collapseByDefault = (element: unknown) => { if (isAgentSessionSection(element)) { @@ -238,20 +243,24 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo if (element.section === AgentSessionSection.Yesterday && this.hasTodaySessions()) { return true; // Also collapse Yesterday when there are sessions from Today } + if (element.section === AgentSessionSection.Repository && !this._recentRepositoryLabels.has(element.label)) { + return true; // Collapse repository sections that don't contain recent sessions + } } } return false; }; - const sorter = new AgentSessionsSorter(); + const sorter = new AgentSessionsSorter(() => this.options.filter.sortResults?.() ?? AgentSessionsSorting.Created); 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, isGroupedByRepository: () => this.options.filter.groupResults?.() === AgentSessionsGrouping.Repository, + isSortedByUpdated: () => this.options.filter.sortResults?.() === AgentSessionsSorting.Updated, }, approvalModel, activeSessionResource)); - const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter, this.logService)); + const sessionFilter = this._register(new AgentSessionsDataSource(this.options.filter, sorter)); const list = this.sessionsList = this._register(this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, 'AgentSessionsView', container, @@ -305,6 +314,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } })); + this.computeRecentRepositoryLabels(); list.setInput(model); this._register(list.onDidOpen(e => this.openAgentSession(e))); @@ -376,6 +386,20 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo ); } + private computeRecentRepositoryLabels(): void { + this._recentRepositoryLabels.clear(); + + const sessions = this.agentSessionsService.model.sessions + .filter(s => !s.isArchived() && !s.isPinned()) + .sort((a, b) => b.timing.created - a.timing.created) + .slice(0, AgentSessionsControl.RECENT_SESSIONS_FOR_EXPAND); + + for (const session of sessions) { + const name = getRepositoryName(session); + this._recentRepositoryLabels.add(name ?? AgentSessionSectionLabels[AgentSessionSection.Repository]); + } + } + private async openAgentSession(e: IOpenEvent): Promise { const element = e.element; if (!element || isAgentSessionSection(element)) { @@ -421,7 +445,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo this.contextMenuService.showContextMenu({ getActions: () => Separator.join(...menu.getActions({ arg: section, shouldForwardArgs: true }).map(([, actions]) => actions)), getAnchor: () => anchor, - getActionsContext: () => section, + getActionsContext: () => this, }); menu.dispose(); @@ -509,8 +533,22 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo return this.agentSessionsService.model.resolve(undefined); } + collapseAllSections(): void { + if (!this.sessionsList) { + return; + } + + const model = this.agentSessionsService.model; + for (const child of this.sessionsList.getNode(model).children) { + if (isAgentSessionSection(child.element) && !child.collapsed) { + this.sessionsList.collapse(child.element); + } + } + } + async update(): Promise { return this.updateSessionsListThrottler.queue(async () => { + this.computeRecentRepositoryLabels(); await this.sessionsList?.updateChildren(); this._onDidUpdate.fire(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 5cb399a6c87..eba6abdd656 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -21,6 +21,11 @@ export enum AgentSessionsGrouping { Repository = 'repository' } +export enum AgentSessionsSorting { + Created = 'created', + Updated = 'updated' +} + export interface IAgentSessionsFilterOptions extends Partial { readonly filterMenuId?: MenuId; @@ -41,6 +46,7 @@ export interface IAgentSessionsFilterOptions extends Partial AgentSessionsGrouping | undefined; + readonly sortResults?: () => AgentSessionsSorting | undefined; overrideExclude?(session: IAgentSession): boolean | undefined; } @@ -61,6 +67,7 @@ export class AgentSessionsFilter extends Disposable implements Required this.options.limitResults?.(); readonly groupResults = () => this.options.groupResults?.(); + readonly sortResults = () => this.options.sortResults?.(); private excludes = DEFAULT_EXCLUDES; private isStoringExcludes = false; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 2249911c278..369ad0e89b1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -42,15 +42,13 @@ import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer. import { MarkdownString, IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { AgentSessionHoverWidget } from './agentSessionHoverWidget.js'; import { AgentSessionProviders } from './agentSessions.js'; -import { AgentSessionsGrouping } from './agentSessionsFilter.js'; +import { AgentSessionsGrouping, AgentSessionsSorting } 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'; -import { ILogService } from '../../../../../platform/log/common/log.js'; - export type AgentSessionListItem = IAgentSession | IAgentSessionSection; @@ -90,7 +88,9 @@ interface IAgentSessionItemTemplate { export interface IAgentSessionRendererOptions { readonly disableHover?: boolean; getHoverPosition(): HoverPosition; + isGroupedByRepository?(): boolean; + isSortedByUpdated?(): boolean; } export class AgentSessionRenderer extends Disposable implements ICompressibleTreeRenderer { @@ -368,7 +368,11 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre return Codicon.circleFilled; } - return Codicon.circleSmallFilled; + if (session.providerType === AgentSessionProviders.Local) { + return Codicon.circleSmallFilled; + } + + return session.icon; } private renderDescription(session: ITreeNode, template: IAgentSessionItemTemplate): boolean { @@ -412,7 +416,9 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre } if (!timeLabel) { - const date = session.timing.created; + const date = this.options.isSortedByUpdated?.() + ? session.timing.lastRequestEnded ?? session.timing.created + : session.timing.created; const seconds = Math.round((new Date().getTime() - date) / 1000); if (seconds < 60) { timeLabel = localize('secondsDuration', "now"); @@ -567,6 +573,7 @@ export function toStatusLabel(status: AgentSessionStatus): string { interface IAgentSessionSectionTemplate { readonly container: HTMLElement; readonly label: HTMLSpanElement; + readonly count: HTMLSpanElement; readonly toolbar: MenuWorkbenchToolBar; readonly contextKeyService: IContextKeyService; readonly disposables: IDisposable; @@ -590,6 +597,7 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer AgentSessionsGrouping | undefined; + /** + * The field to sort sessions by. + * Defaults to created date when undefined. + */ + readonly sortResults?: () => AgentSessionsSorting | undefined; + /** * A callback to notify the filter about the number of * results after filtering. @@ -756,7 +774,6 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou constructor( private readonly filter: IAgentSessionsFilter | undefined, private readonly sorter: ITreeSorter, - private readonly logService?: ILogService, ) { super(); } @@ -848,14 +865,22 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou const firstArchivedIndex = sortedSessions.findIndex(session => session.isArchived()); const nonArchivedCount = firstArchivedIndex === -1 ? sortedSessions.length : firstArchivedIndex; + const nonArchivedSessions = sortedSessions.slice(0, nonArchivedCount); + const archivedSessions = sortedSessions.slice(nonArchivedCount); - const topSessions = sortedSessions.slice(0, Math.min(AgentSessionsDataSource.CAPPED_SESSIONS_LIMIT, nonArchivedCount)); - const othersSessions = sortedSessions.slice(topSessions.length); + // All pinned sessions are always visible + const pinnedSessions = nonArchivedSessions.filter(session => session.isPinned()); + const unpinnedSessions = nonArchivedSessions.filter(session => !session.isPinned()); - // Add top sessions directly (no section header) - result.push(...topSessions); + // Take up to N non-pinned sessions from the sorted order (preserves NeedsInput prioritization) + const topUnpinned = unpinnedSessions.slice(0, AgentSessionsDataSource.CAPPED_SESSIONS_LIMIT); + const remainingUnpinned = unpinnedSessions.slice(AgentSessionsDataSource.CAPPED_SESSIONS_LIMIT); - // Add "More" section for the rest + // Add pinned first, then top N non-pinned + result.push(...pinnedSessions, ...topUnpinned); + + // Add "More" section for the rest (remaining unpinned + archived) + const othersSessions = [...remainingUnpinned, ...archivedSessions]; if (othersSessions.length > 0) { result.push({ section: AgentSessionSection.More, @@ -869,7 +894,8 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou private groupSessionsByDate(sortedSessions: IAgentSession[]): AgentSessionListItem[] { const result: AgentSessionListItem[] = []; - const groupedSessions = groupAgentSessionsByDate(sortedSessions); + const sortBy = this.filter?.sortResults?.(); + const groupedSessions = groupAgentSessionsByDate(sortedSessions, sortBy); for (const { sessions, section, label } of groupedSessions.values()) { if (sessions.length === 0) { @@ -886,8 +912,7 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou const repoMap = new Map(); const pinnedSessions: IAgentSession[] = []; const archivedSessions: IAgentSession[] = []; - const unknownKey = '\x00unknown'; - const unknownLabel = localize('agentSessions.noRepository', "Other"); + const otherSessions: IAgentSession[] = []; for (const session of sortedSessions) { if (session.isArchived()) { @@ -900,19 +925,17 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou continue; } - const repoName = this.getRepositoryName(session); - if (!repoName) { - this.logService?.warn('[AgentSessions] Could not determine repository name for session, categorizing as "Other"', JSON.stringify(session)); + const repoName = getRepositoryName(session); + if (repoName) { + let group = repoMap.get(repoName); + if (!group) { + group = { label: repoName, sessions: [] }; + repoMap.set(repoName, group); + } + group.sessions.push(session); + } else { + otherSessions.push(session); } - const repoId = repoName || unknownKey; - const repoLabel = repoName || unknownLabel; - - let group = repoMap.get(repoId); - if (!group) { - group = { label: repoLabel, sessions: [] }; - repoMap.set(repoId, group); - } - group.sessions.push(session); } const result: AgentSessionListItem[] = []; @@ -933,6 +956,14 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou }); } + if (otherSessions.length > 0) { + result.push({ + section: AgentSessionSection.Repository, + label: AgentSessionSectionLabels[AgentSessionSection.Repository], + sessions: otherSessions, + }); + } + if (archivedSessions.length > 0) { result.push({ section: AgentSessionSection.Archived, @@ -943,10 +974,6 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou return result; } - - private getRepositoryName(session: IAgentSession): string | undefined { - return getRepositoryName(session); - } } /** @@ -957,6 +984,19 @@ export class AgentSessionsDataSource extends Disposable implements IAsyncDataSou export function getRepositoryName(session: IAgentSession): string | undefined { const metadata = session.metadata; if (metadata) { + // Remote agent host sessions: group by folder + remote name (e.g. "myproject [dev-box]") + const remoteAgentHost = metadata.remoteAgentHost as string | undefined; + if (remoteAgentHost) { + const workingDir = metadata.workingDirectoryPath as string | undefined; + if (workingDir) { + const folderName = extractRepoNameFromPath(workingDir); + if (folderName) { + return `${folderName} [${remoteAgentHost}]`; + } + } + return remoteAgentHost; + } + // Cloud sessions: metadata.owner + metadata.name const owner = metadata.owner as string | undefined; const name = metadata.name as string | undefined; @@ -1100,12 +1140,13 @@ export const AgentSessionSectionLabels = { [AgentSessionSection.Older]: localize('agentSessions.olderSection', "Older"), [AgentSessionSection.Archived]: localize('agentSessions.archivedSection', "Archived"), [AgentSessionSection.More]: localize('agentSessions.moreSection', "More"), + [AgentSessionSection.Repository]: localize('agentSessions.noRepository', "Other"), }; const DAY_THRESHOLD = 24 * 60 * 60 * 1000; const WEEK_THRESHOLD = 7 * DAY_THRESHOLD; -export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map { +export function groupAgentSessionsByDate(sessions: IAgentSession[], sortBy?: AgentSessionsSorting): Map { const now = Date.now(); const startOfToday = new Date(now).setHours(0, 0, 0, 0); const startOfYesterday = startOfToday - DAY_THRESHOLD; @@ -1124,7 +1165,9 @@ export function groupAgentSessionsByDate(sessions: IAgentSession[]): Map= startOfToday) { todaySessions.push(session); } else if (sessionTime >= startOfYesterday) { @@ -1205,6 +1248,12 @@ export class AgentSessionsCompressionDelegate implements ITreeCompressionDelegat export class AgentSessionsSorter implements ITreeSorter { + private readonly getSortBy: () => AgentSessionsSorting; + + constructor(getSortBy?: () => AgentSessionsSorting) { + this.getSortBy = getSortBy ?? (() => AgentSessionsSorting.Created); + } + compare(sessionA: IAgentSession, sessionB: IAgentSession, prioritizeActiveSessions = false): number { // Special sorting if enabled @@ -1243,8 +1292,17 @@ export class AgentSessionsSorter implements ITreeSorter { } // Sort by time - const timeA = prioritizeActiveSessions ? sessionA.timing.lastRequestStarted ?? sessionA.timing.created : sessionA.timing.created; - const timeB = prioritizeActiveSessions ? sessionB.timing.lastRequestStarted ?? sessionB.timing.created : sessionB.timing.created; + const sortBy = this.getSortBy(); + const timeA = prioritizeActiveSessions + ? sessionA.timing.lastRequestStarted ?? sessionA.timing.created + : sortBy === AgentSessionsSorting.Updated + ? sessionA.timing.lastRequestEnded ?? sessionA.timing.created + : sessionA.timing.created; + const timeB = prioritizeActiveSessions + ? sessionB.timing.lastRequestStarted ?? sessionB.timing.created + : sortBy === AgentSessionsSorting.Updated + ? sessionB.timing.lastRequestEnded ?? sessionB.timing.created + : sessionB.timing.created; return timeB - timeA; } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts index a5e80a2aa79..e3855988f0a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionProjectionActions.ts @@ -90,26 +90,6 @@ export class ExitAgentSessionProjectionAction extends Action2 { //#endregion -//#region Toggle Agent Status - -export class ToggleAgentStatusAction extends ToggleTitleBarConfigAction { - constructor() { - super( - ChatConfiguration.AgentStatusEnabled, - localize('toggle.agentStatus', 'Agent Status'), - localize('toggle.agentStatusDescription', "Toggle visibility of the Agent Status in title bar"), 6, - ContextKeyExpr.and( - ChatContextKeys.enabled, - IsCompactTitleBarContext.negate(), - ChatContextKeys.supported, - ContextKeyExpr.has('config.window.commandCenter') - ) - ); - } -} - -//#endregion - //#region Toggle Agent Quick Input export class ToggleUnifiedAgentsBarAction extends ToggleTitleBarConfigAction { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts index 07fec9b6d17..4e1d91d4927 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentSessionsExperiments.contribution.ts @@ -6,7 +6,7 @@ import { registerSingleton, InstantiationType } from '../../../../../../platform/instantiation/common/extensions.js'; import { MenuId, MenuRegistry, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { IAgentSessionProjectionService, AgentSessionProjectionService, AGENT_SESSION_PROJECTION_ENABLED_PROVIDERS } from './agentSessionProjectionService.js'; -import { EnterAgentSessionProjectionAction, ExitAgentSessionProjectionAction, ToggleAgentStatusAction, ToggleUnifiedAgentsBarAction } from './agentSessionProjectionActions.js'; +import { EnterAgentSessionProjectionAction, ExitAgentSessionProjectionAction, ToggleUnifiedAgentsBarAction } from './agentSessionProjectionActions.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; import { AgentTitleBarStatusRendering } from './agentTitleBarStatusWidget.js'; import { AgentTitleBarStatusService, IAgentTitleBarStatusService } from './agentTitleBarStatusService.js'; @@ -235,7 +235,6 @@ class AgentSessionReadyContribution extends Disposable implements IWorkbenchCont registerAction2(EnterAgentSessionProjectionAction); registerAction2(ExitAgentSessionProjectionAction); -registerAction2(ToggleAgentStatusAction); registerAction2(ToggleUnifiedAgentsBarAction); registerSingleton(IAgentSessionProjectionService, AgentSessionProjectionService, InstantiationType.Delayed); @@ -251,10 +250,8 @@ MenuRegistry.appendMenuItem(MenuId.CommandCenter, { icon: Codicon.chatSparkle, when: ContextKeyExpr.and( ChatContextKeys.enabled, - ContextKeyExpr.or( - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), - ContextKeyExpr.has(`config.${ChatConfiguration.UnifiedAgentsBar}`) - ) + ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, 'hidden'), + ContextKeyExpr.notEquals(`config.${ChatConfiguration.AgentStatusEnabled}`, false) ), order: 10002 // to the right of the chat button }); @@ -271,7 +268,6 @@ MenuRegistry.appendMenuItem(MenuId.TitleBar, { ChatContextKeys.Setup.hidden.negate(), ChatContextKeys.Setup.disabled.negate() ), - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), ContextKeyExpr.has('config.window.commandCenter').negate(), ), order: 1 @@ -283,13 +279,7 @@ MenuRegistry.appendMenuItem(MenuId.AgentsTitleBarControlMenu, { id: 'workbench.action.chat.toggle', title: localize('openChat', "Open Chat"), }, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.or( - ContextKeyExpr.has(`config.${ChatConfiguration.AgentStatusEnabled}`), - ContextKeyExpr.has(`config.${ChatConfiguration.UnifiedAgentsBar}`) - ) - ), + when: ChatContextKeys.enabled, group: 'a_open', order: 1 }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 86ec453b950..0ec54a9d165 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -21,15 +21,10 @@ import { IAgentSessionsService } from '../agentSessionsService.js'; import { AgentSessionStatus, IAgentSession, isSessionInProgressStatus } from '../agentSessionsModel.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction, Separator, SubmenuAction, toAction } from '../../../../../../base/common/actions.js'; -import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; -import { IBrowserWorkbenchEnvironmentService } from '../../../../../services/environment/browser/environmentService.js'; import { IEditorGroupsService } from '../../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; -import { Verbosity } from '../../../../../common/editor.js'; -import { Schemas } from '../../../../../../base/common/network.js'; import { renderAsPlaintext } from '../../../../../../base/browser/markdownRenderer.js'; -import { openSession } from '../agentSessionsOpener.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMenuService, MenuId, MenuItemAction, SubmenuItemAction } from '../../../../../../platform/actions/common/actions.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; @@ -43,6 +38,7 @@ import { IActionViewItemService } from '../../../../../../platform/actions/brows import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { mainWindow } from '../../../../../../base/browser/window.js'; import { LayoutSettings } from '../../../../../services/layout/browser/layoutService.js'; +import { WindowTitle } from '../../../../../browser/parts/titlebar/windowTitle.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatWidgetService } from '../../chat.js'; @@ -62,7 +58,7 @@ type AgentStatusClickAction = | 'exitProjection'; type AgentStatusClickEvent = { - source: 'pill' | 'sparkle' | 'unread' | 'inProgress'; + source: 'pill' | 'sparkle' | 'unread' | 'inProgress' | 'needsInput'; action: AgentStatusClickAction; }; @@ -84,8 +80,39 @@ const FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.agentsessionsviewerfilt // Storage key for saving user's filter state before we override it const PREVIOUS_FILTER_STORAGE_KEY = 'agentSessions.filterExcludes.previousUserFilter'; -const NLS_EXTENSION_HOST = localize('devExtensionWindowTitlePrefix', "[Extension Development Host]"); -const TITLE_DIRTY = '\u25cf '; +type AgentStatusSettingMode = 'hidden' | 'badge' | 'compact'; + +function shouldForceHiddenAgentStatus(configurationService: IConfigurationService): boolean { + const aiFeaturesDisabled = configurationService.getValue(ChatConfiguration.AIDisabled) === true; + const aiCustomizationsDisabled = configurationService.getValue('disableAICustomizations') === true + || configurationService.getValue('workbench.disableAICustomizations') === true + || configurationService.getValue(ChatConfiguration.ChatCustomizationMenuEnabled) === false; + + return aiFeaturesDisabled && aiCustomizationsDisabled; +} + +function getAgentStatusSettingMode(configurationService: IConfigurationService): AgentStatusSettingMode { + if (shouldForceHiddenAgentStatus(configurationService)) { + return 'hidden'; + } + + const value = configurationService.getValue(ChatConfiguration.AgentStatusEnabled); + + if (value === false || value === 'hidden') { + return 'hidden'; + } + + if (value === 'badge') { + return 'badge'; + } + + // Backward compatibility: previous experiments stored this as a boolean. + if (value === true || value === undefined || value === 'compact') { + return 'compact'; + } + + return 'compact'; +} /** * Agent Status Widget - renders agent status in the command center. @@ -102,7 +129,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { private readonly _dynamicDisposables = this._register(new DisposableStore()); /** The currently displayed in-progress session (if any) - clicking pill opens this */ - private _displayedSession: IAgentSession | undefined; /** Cached render state to avoid unnecessary DOM rebuilds */ private _lastRenderState: string | undefined; @@ -110,12 +136,13 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** Guard to prevent re-entrant rendering */ private _isRendering = false; - /** First focusable element for keyboard navigation */ - private _firstFocusableElement: HTMLElement | undefined; + /** Roving tabindex elements for keyboard navigation */ + private _rovingElements: HTMLElement[] = []; + private _rovingIndex: number = 0; /** Tracks if this window applied a badge filter (unread/inProgress), so we only auto-clear our own filters */ // TODO: This is imperfect. Targetted fix for vscode#290863. We should revisit storing filter state per-window to avoid this - private _badgeFilterAppliedByThisWindow: 'unread' | 'inProgress' | null = null; + private _badgeFilterAppliedByThisWindow: 'unread' | 'inProgress' | 'needsInput' | null = null; /** Reusable menu for CommandCenterCenter items (e.g., debug toolbar) */ private readonly _commandCenterMenu; @@ -123,6 +150,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** Menu for ChatTitleBarMenu items (same as chat controls dropdown) */ private readonly _chatTitleBarMenu; + /** WindowTitle instance for honoring the user's window.title setting */ + private readonly _windowTitle: WindowTitle; + constructor( action: IAction, options: IBaseActionViewItemOptions | undefined, @@ -132,9 +162,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { @ICommandService private readonly commandService: ICommandService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, - @ILabelService private readonly labelService: ILabelService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IBrowserWorkbenchEnvironmentService private readonly environmentService: IBrowserWorkbenchEnvironmentService, @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IEditorService private readonly editorService: IEditorService, @IMenuService private readonly menuService: IMenuService, @@ -153,6 +181,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Create menu for ChatTitleBarMenu to show in sparkle section dropdown this._chatTitleBarMenu = this._register(this.menuService.createMenu(MenuId.ChatTitleBarMenu, this.contextKeyService)); + // Create WindowTitle to honor the user's window.title setting + this._windowTitle = this._register(this.instantiationService.createInstance(WindowTitle, mainWindow)); + // Re-render when control mode or session info changes this._register(this.agentTitleBarStatusService.onDidChangeMode(() => { this._render(); @@ -167,6 +198,11 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { this._render(); })); + // Re-render when window title changes (honors user's window.title setting) + this._register(this._windowTitle.onDidChange(() => { + this._render(); + })); + // Re-render when active editor changes (for file name display when tabs are hidden) this._register(this.editorService.onDidActiveEditorChange(() => { this._render(); @@ -192,7 +228,15 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Re-render when settings change this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) || e.affectsConfiguration(ChatConfiguration.ChatViewSessionsEnabled)) { + if ( + e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) + || e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) + || e.affectsConfiguration(ChatConfiguration.ChatViewSessionsEnabled) + || e.affectsConfiguration(ChatConfiguration.AIDisabled) + || e.affectsConfiguration(ChatConfiguration.ChatCustomizationMenuEnabled) + || e.affectsConfiguration('disableAICustomizations') + || e.affectsConfiguration('workbench.disableAICustomizations') + ) { this._lastRenderState = undefined; // Force re-render this._render(); } @@ -223,6 +267,8 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { super.render(container); this._container = container; container.classList.add('agent-status-container'); + container.setAttribute('role', 'toolbar'); + container.setAttribute('aria-label', localize('agentStatusToolbarLabel', "Agent Status")); // Container should not be focusable - inner elements handle focus container.tabIndex = -1; @@ -237,8 +283,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } override focus(): void { - // Focus the first focusable child instead - this._firstFocusableElement?.focus(); + this._rovingElements[this._rovingIndex]?.focus(); } override blur(): void { @@ -285,11 +330,10 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const label = this._getLabel(); // Get current filter state for state key - const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); + const { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput } = this._getCurrentFilterState(); - // Check which settings are enabled (these are independent settings) + const statusMode = getAgentStatusSettingMode(this.configurationService); const unifiedAgentsBarEnabled = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; - const agentStatusEnabled = this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true; const viewSessionsEnabled = this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled) !== false; // Build state key for comparison @@ -303,8 +347,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { label, isFilteredToUnread, isFilteredToInProgress, + isFilteredToNeedsInput, + statusMode, unifiedAgentsBarEnabled, - agentStatusEnabled, viewSessionsEnabled, }); @@ -317,9 +362,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Clear existing content reset(this._container); - // Clear previous disposables and focusable element for dynamic content + // Clear previous disposables and roving elements for dynamic content this._dynamicDisposables.clear(); - this._firstFocusableElement = undefined; + this._rovingElements = []; if (this.agentTitleBarStatusService.mode === AgentStatusMode.Session) { // Agent Session Projection mode - show session title + close button @@ -327,19 +372,77 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } else if (this.agentTitleBarStatusService.mode === AgentStatusMode.SessionReady) { // Session ready mode - show session title + enter projection button this._renderSessionReadyMode(this._dynamicDisposables); - } else if (unifiedAgentsBarEnabled) { - // Unified Agents Bar - show full pill with label + status badge + } else if (statusMode === 'compact') { + // Compact mode - replace command center search with integrated control this._renderChatInputMode(this._dynamicDisposables); - } else if (agentStatusEnabled) { - // Agent Status - show only the status badge (sparkle + unread/active counts) - this._renderBadgeOnlyMode(this._dynamicDisposables); + } else if (statusMode === 'badge') { + // Badge mode - render status badge next to command center search + this._renderStatusBadge(this._dynamicDisposables, activeSessions, unreadSessions, attentionNeededSessions); } - // If neither setting is enabled, nothing is rendered (container is already cleared) + // Hidden mode intentionally renders nothing. + + // Setup roving tabindex for keyboard navigation + this._setupRovingTabIndex(this._dynamicDisposables); } finally { this._isRendering = false; } } + /** + * Setup roving tabindex for arrow key navigation between interactive elements. + * Uses the elements registered in `this._rovingElements` in their existing order. + */ + private _setupRovingTabIndex(disposables: DisposableStore): void { + if (!this._container || this._rovingElements.length === 0) { + return; + } + + if (this._rovingIndex >= this._rovingElements.length) { + this._rovingIndex = 0; + } + for (let i = 0; i < this._rovingElements.length; i++) { + this._rovingElements[i].tabIndex = i === this._rovingIndex ? 0 : -1; + } + + disposables.add(addDisposableListener(this._container, EventType.KEY_DOWN, (e) => { + const index = this._rovingElements.findIndex(el => el === e.target || el.contains(e.target as Node)); + if (index === -1) { + return; + } + + const nextIndex = this._getNextRovingIndex(index, e.key); + if (nextIndex !== undefined && nextIndex !== index) { + e.preventDefault(); + e.stopPropagation(); + this._moveRovingFocus(index, nextIndex); + } + })); + } + + /** + * Moves roving focus from `currentIndex` to `nextIndex`, updating tabIndex and focusing the element. + */ + private _moveRovingFocus(currentIndex: number, nextIndex: number): void { + this._rovingElements[currentIndex].tabIndex = -1; + this._rovingElements[nextIndex].tabIndex = 0; + this._rovingElements[nextIndex].focus(); + this._rovingIndex = nextIndex; + } + + /** + * Returns the next roving index for the given key, or `undefined` if no navigation should occur. + */ + private _getNextRovingIndex(currentIndex: number, key: string): number | undefined { + const len = this._rovingElements.length; + switch (key) { + case 'ArrowRight': return (currentIndex + 1) % len; + case 'ArrowLeft': return (currentIndex - 1 + len) % len; + case 'Home': return 0; + case 'End': return len - 1; + default: return undefined; + } + } + // #region Session Statistics /** @@ -392,20 +495,20 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const { activeSessions, unreadSessions, attentionNeededSessions, hasAttentionNeeded } = this._getSessionStats(); - // Render command center items (like debug toolbar) FIRST - to the left - this._renderCommandCenterToolbar(disposables); - // Create pill const pill = $('div.agent-status-pill.chat-input-mode'); if (hasAttentionNeeded) { pill.classList.add('needs-attention'); } - pill.setAttribute('role', 'button'); - pill.setAttribute('aria-label', localize('openQuickAccess', "Open Quick Access")); - pill.tabIndex = 0; - this._firstFocusableElement = pill; this._container.appendChild(pill); + // Render command center items (like debug toolbar) inside the pill + this._renderCommandCenterToolbar(disposables, pill); + + // Compact mode is always true when rendering chat input mode (caller already checked for compact) + const isCompactMode = true; + pill.classList.toggle('compact-mode', isCompactMode); + // Left icon container (sparkle by default, report+count when attention needed, search on hover) const leftIcon = $('span.agent-status-left-icon'); if (hasAttentionNeeded) { @@ -418,82 +521,105 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } else { reset(leftIcon, renderIcon(Codicon.searchSparkle)); } - pill.appendChild(leftIcon); + if (!isCompactMode) { + pill.appendChild(leftIcon); + } - // Label (workspace name by default, placeholder on hover) - // Show attention progress or default label + // Input area wrapper - hover only activates here, not on badge sections + const inputArea = $('div.agent-status-input-area'); + inputArea.setAttribute('role', 'button'); + inputArea.setAttribute('aria-label', localize('openQuickAccess', "Open Quick Access")); + inputArea.tabIndex = 0; + this._rovingElements.push(inputArea); + pill.appendChild(inputArea); + + // Label - always shows workspace name in compact mode const label = $('span.agent-status-label'); - const { session: attentionSession, progress: progressText } = this._getSessionNeedingAttention(attentionNeededSessions); - this._displayedSession = attentionSession; + const { progress: progressText } = this._getSessionNeedingAttention(attentionNeededSessions); + const defaultLabel = isCompactMode ? this._getLabel() : (progressText ?? this._getLabel()); - const defaultLabel = progressText ?? this._getLabel(); - - if (progressText) { + if (!isCompactMode && progressText) { label.classList.add('has-progress'); } const hoverLabel = localize('askAnythingPlaceholder', "Ask anything or describe what to build"); label.textContent = defaultLabel; - pill.appendChild(label); + inputArea.appendChild(label); - // Send icon (hidden by default, shown on hover - only when not showing attention message) - const sendIcon = $('span.agent-status-send'); - reset(sendIcon, renderIcon(Codicon.send)); - sendIcon.classList.add('hidden'); - pill.appendChild(sendIcon); - - // Hover behavior - swap icon and label (only when showing default state). - // When progressText is defined (e.g. sessions need attention), keep the attention/progress - // message visible and do not replace it with the generic placeholder on hover. - if (!progressText) { - disposables.add(addDisposableListener(pill, EventType.MOUSE_ENTER, () => { + if (isCompactMode) { + // Compact mode: hover resets icon state but keeps workspace name + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_ENTER, () => { reset(leftIcon, renderIcon(Codicon.searchSparkle)); leftIcon.classList.remove('has-attention'); - label.textContent = hoverLabel; label.classList.remove('has-progress'); - sendIcon.classList.remove('hidden'); })); - disposables.add(addDisposableListener(pill, EventType.MOUSE_LEAVE, () => { + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_LEAVE, () => { reset(leftIcon, renderIcon(Codicon.searchSparkle)); - label.textContent = defaultLabel; - sendIcon.classList.add('hidden'); })); + } else { + // Send icon (hidden by default, shown on hover - only when not showing attention message) + const sendIcon = $('span.agent-status-send'); + reset(sendIcon, renderIcon(Codicon.send)); + sendIcon.classList.add('hidden'); + inputArea.appendChild(sendIcon); + + // Hover behavior - swap icon and label (only when showing default state). + if (!progressText) { + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_ENTER, () => { + reset(leftIcon, renderIcon(Codicon.searchSparkle)); + leftIcon.classList.remove('has-attention'); + label.textContent = hoverLabel; + label.classList.remove('has-progress'); + sendIcon.classList.remove('hidden'); + })); + + disposables.add(addDisposableListener(inputArea, EventType.MOUSE_LEAVE, () => { + reset(leftIcon, renderIcon(Codicon.searchSparkle)); + label.textContent = defaultLabel; + sendIcon.classList.add('hidden'); + })); + } } - // Setup hover tooltip + // Setup hover tooltip on input area const hoverDelegate = getDefaultHoverDelegate('mouse'); - disposables.add(this.hoverService.setupManagedHover(hoverDelegate, pill, () => { - if (this._displayedSession) { - return localize('openSessionTooltip', "Open session: {0}", this._displayedSession.label); - } + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, inputArea, () => { const kbForTooltip = this.keybindingService.lookupKeybinding(UNIFIED_QUICK_ACCESS_ACTION_ID)?.getLabel(); return kbForTooltip ? localize('askTooltip', "Open Quick Access ({0})", kbForTooltip) : localize('askTooltip2', "Open Quick Access"); })); - // Click handler - open displayed session if showing progress, otherwise open unified quick access - disposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { + // Click handler - always open quick access in compact mode (attention sessions are handled by the badge) + disposables.add(addDisposableListener(inputArea, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); - this._handlePillClick(); + this.telemetryService.publicLog2('agentStatusWidget.click', { + source: 'pill', + action: 'quickAccess', + }); + const useUnifiedQuickAccess = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; + this.commandService.executeCommand(useUnifiedQuickAccess ? UNIFIED_QUICK_ACCESS_ACTION_ID : QUICK_OPEN_ACTION_ID); })); // Keyboard handler - disposables.add(addDisposableListener(pill, EventType.KEY_DOWN, (e) => { + disposables.add(addDisposableListener(inputArea, EventType.KEY_DOWN, (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); - this._handlePillClick(); + this.telemetryService.publicLog2('agentStatusWidget.click', { + source: 'pill', + action: 'quickAccess', + }); + const useUnifiedQuickAccess = this.configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true; + this.commandService.executeCommand(useUnifiedQuickAccess ? UNIFIED_QUICK_ACCESS_ACTION_ID : QUICK_OPEN_ACTION_ID); } })); - // Status badge (separate rectangle on right) - only when Agent Status is enabled - if (this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true) { - this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); - } + // In compact mode, render status badge inline within the pill + this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions, pill); } private _renderSessionMode(disposables: DisposableStore): void { @@ -537,10 +663,8 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(addDisposableListener(pill, EventType.CLICK, exitHandler)); disposables.add(addDisposableListener(pill, EventType.MOUSE_DOWN, exitHandler)); - // Status badge (separate rectangle on right) - only when Agent Status is enabled - if (this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true) { - this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); - } + // Status badge (separate rectangle on right) + this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); } /** @@ -588,24 +712,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(addDisposableListener(pill, EventType.CLICK, enterHandler)); disposables.add(addDisposableListener(pill, EventType.MOUSE_DOWN, enterHandler)); - // Status badge (separate rectangle on right) - only when Agent Status is enabled - if (this.configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true) { - this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); - } - } - - /** - * Render badge-only mode - just the status badge without the full pill. - * Used when Agent Status is enabled but Enhanced Agent Status is not. - */ - private _renderBadgeOnlyMode(disposables: DisposableStore): void { - if (!this._container) { - return; - } - - const { activeSessions, unreadSessions, attentionNeededSessions } = this._getSessionStats(); - - // Status badge only - no pill, no command center toolbar + // Status badge (separate rectangle on right) this._renderStatusBadge(disposables, activeSessions, unreadSessions, attentionNeededSessions); } @@ -618,8 +725,9 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * Filters out the quick open action since we provide our own search UI. * Adds a dot separator after the toolbar if content was rendered. */ - private _renderCommandCenterToolbar(disposables: DisposableStore): void { - if (!this._container) { + private _renderCommandCenterToolbar(disposables: DisposableStore, parent?: HTMLElement): void { + const container = parent ?? this._container; + if (!container) { return; } @@ -647,7 +755,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const hoverDelegate = getDefaultHoverDelegate('mouse'); const toolbarContainer = $('div.agent-status-command-center-toolbar'); - this._container.appendChild(toolbarContainer); + container.appendChild(toolbarContainer); const toolbar = this.instantiationService.createInstance(WorkbenchToolBar, toolbarContainer, { hiddenItemStrategy: HiddenItemStrategy.NoHide, @@ -660,10 +768,17 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { toolbar.setActions(allActions); - // Add dot separator after the toolbar (matching command center style) - const separator = renderIcon(Codicon.circleSmallFilled); - separator.classList.add('agent-status-separator'); - this._container.appendChild(separator); + // Add separator after the toolbar + if (parent) { + // Inside pill (compact mode): use a vertical line separator + const separator = $('span.agent-status-line-separator'); + container.appendChild(separator); + } else { + // Outside pill: use dot separator (matching command center style) + const separator = renderIcon(Codicon.circleSmallFilled); + separator.classList.add('agent-status-separator'); + container.appendChild(separator); + } } /** @@ -680,9 +795,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { searchButton.setAttribute('role', 'button'); searchButton.setAttribute('aria-label', localize('openQuickOpen', "Open Quick Open")); searchButton.tabIndex = 0; - if (!this._firstFocusableElement) { - this._firstFocusableElement = searchButton; - } + this._rovingElements.push(searchButton); container.appendChild(searchButton); // Setup hover @@ -715,7 +828,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * Shows split UI with sparkle icon on left, then unread, needs-input, and active indicators. * Always renders the sparkle icon section. */ - private _renderStatusBadge(disposables: DisposableStore, activeSessions: IAgentSession[], unreadSessions: IAgentSession[], attentionNeededSessions: IAgentSession[]): void { + private _renderStatusBadge(disposables: DisposableStore, activeSessions: IAgentSession[], unreadSessions: IAgentSession[], attentionNeededSessions: IAgentSession[], inlineContainer?: HTMLElement): void { if (!this._container) { return; } @@ -725,18 +838,21 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const hasAttentionNeeded = attentionNeededSessions.length > 0; // Auto-clear filter if the filtered category becomes empty if this window applied it - this._clearFilterIfCategoryEmpty(hasUnreadSessions, hasActiveSessions); + this._clearFilterIfCategoryEmpty(hasUnreadSessions, hasActiveSessions, hasAttentionNeeded); - const badge = $('div.agent-status-badge'); - this._container.appendChild(badge); + // When inlineContainer is provided, render sections directly into it (compact mode) + // Otherwise, create a separate badge container + let badge: HTMLElement; + if (inlineContainer) { + badge = inlineContainer; + } else { + badge = $('div.agent-status-badge'); + this._container.appendChild(badge); + } // Sparkle dropdown button section (always visible on left) - proper button with dropdown menu const sparkleContainer = $('span.agent-status-badge-section.sparkle'); sparkleContainer.tabIndex = 0; - if (!this._firstFocusableElement) { - this._firstFocusableElement = sparkleContainer; - } - badge.appendChild(sparkleContainer); // Get menu actions for dropdown with proper group separators const menuActions: IAction[] = Separator.join(...this._chatTitleBarMenu.getActions({ shouldForwardArgs: true }).map(([, actions]) => actions)); @@ -792,6 +908,23 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { sparkleDropdown.render(sparkleContainer); disposables.add(sparkleDropdown); + // Capture-phase listener for ArrowLeft/ArrowRight/Home/End to prevent DropdownWithPrimaryActionViewItem + // from consuming these keys internally. This ensures the outer roving tabindex handles navigation. + disposables.add(addDisposableListener(sparkleContainer, EventType.KEY_DOWN, (e) => { + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') { + const idx = this._rovingElements.indexOf(sparkleContainer); + if (idx === -1) { + return; + } + const nextIndex = this._getNextRovingIndex(idx, e.key); + if (nextIndex !== undefined && nextIndex !== idx) { + e.preventDefault(); + e.stopImmediatePropagation(); + this._moveRovingFocus(idx, nextIndex); + } + } + }, true /* useCapture */)); + // Add keyboard handler for Enter/Space on the sparkle container disposables.add(addDisposableListener(sparkleContainer, EventType.KEY_DOWN, (e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -812,10 +945,25 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Only show status indicators if chat.viewSessions.enabled is true const viewSessionsEnabled = this.configurationService.getValue(ChatConfiguration.ChatViewSessionsEnabled) !== false; + // When compact mode is active, show status indicators before the sparkle button: + // [needs-input, active, unread, sparkle] (populating inward) + // Otherwise, keep original order: [sparkle, unread, active, needs-input] + const reverseOrder = !!inlineContainer; + + if (!reverseOrder) { + // Original order: sparkle first + badge.appendChild(sparkleContainer); + } + + // Build status sections but don't append yet - we need to control order + let unreadSection: HTMLElement | undefined; + let activeSection: HTMLElement | undefined; + let needsInputSection: HTMLElement | undefined; + // Unread section (blue dot + count) if (viewSessionsEnabled && hasUnreadSessions && this.workspaceContextService.getWorkbenchState() !== WorkbenchState.EMPTY) { const { isFilteredToUnread } = this._getCurrentFilterState(); - const unreadSection = $('span.agent-status-badge-section.unread'); + unreadSection = $('span.agent-status-badge-section.unread'); if (isFilteredToUnread) { unreadSection.classList.add('filtered'); } @@ -827,7 +975,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { const unreadCount = $('span.agent-status-text'); unreadCount.textContent = String(unreadSessions.length); unreadSection.appendChild(unreadCount); - badge.appendChild(unreadSection); // Click handler - filter to unread sessions disposables.add(addDisposableListener(unreadSection, EventType.CLICK, (e) => { @@ -850,30 +997,58 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { disposables.add(this.hoverService.setupManagedHover(hoverDelegate, unreadSection, unreadTooltip)); } - // In-progress/Needs-input section - shows "needs input" state when any session needs attention, - // otherwise shows "in progress" state. This is a single section that transforms based on state. - if (viewSessionsEnabled && hasActiveSessions) { - const { isFilteredToInProgress } = this._getCurrentFilterState(); - const activeSection = $('span.agent-status-badge-section.active'); - if (hasAttentionNeeded) { - activeSection.classList.add('needs-input'); + // Needs-input section - shows sessions requiring user attention (approval/confirmation/input) + if (viewSessionsEnabled && hasAttentionNeeded) { + const { isFilteredToNeedsInput } = this._getCurrentFilterState(); + needsInputSection = $('span.agent-status-badge-section.active.needs-input'); + if (isFilteredToNeedsInput) { + needsInputSection.classList.add('filtered'); } + needsInputSection.setAttribute('role', 'button'); + needsInputSection.tabIndex = 0; + const needsInputIcon = $('span.agent-status-icon'); + reset(needsInputIcon, renderIcon(Codicon.report)); + needsInputSection.appendChild(needsInputIcon); + const needsInputCount = $('span.agent-status-text'); + needsInputCount.textContent = String(attentionNeededSessions.length); + needsInputSection.appendChild(needsInputCount); + + disposables.add(addDisposableListener(needsInputSection, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._openSessionsWithFilter('needsInput'); + })); + disposables.add(addDisposableListener(needsInputSection, EventType.KEY_DOWN, (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this._openSessionsWithFilter('needsInput'); + } + })); + + const needsInputTooltip = attentionNeededSessions.length === 1 + ? localize('needsInputSessionsTooltip1', "{0} session needs input", attentionNeededSessions.length) + : localize('needsInputSessionsTooltip', "{0} sessions need input", attentionNeededSessions.length); + disposables.add(this.hoverService.setupManagedHover(hoverDelegate, needsInputSection, needsInputTooltip)); + } + + // In-progress section - shows sessions that are actively running (excludes needs-input) + const inProgressOnly = activeSessions.filter(s => s.status !== AgentSessionStatus.NeedsInput); + if (viewSessionsEnabled && inProgressOnly.length > 0) { + const { isFilteredToInProgress } = this._getCurrentFilterState(); + activeSection = $('span.agent-status-badge-section.active'); if (isFilteredToInProgress) { activeSection.classList.add('filtered'); } activeSection.setAttribute('role', 'button'); activeSection.tabIndex = 0; const statusIcon = $('span.agent-status-icon'); - // Show report icon when needs input, otherwise session-in-progress icon - reset(statusIcon, renderIcon(hasAttentionNeeded ? Codicon.report : Codicon.sessionInProgress)); + reset(statusIcon, renderIcon(Codicon.sessionInProgress)); activeSection.appendChild(statusIcon); const statusCount = $('span.agent-status-text'); - // Show needs-input count when attention needed, otherwise total active count - statusCount.textContent = String(hasAttentionNeeded ? attentionNeededSessions.length : activeSessions.length); + statusCount.textContent = String(inProgressOnly.length); activeSection.appendChild(statusCount); - badge.appendChild(activeSection); - // Click handler - filter to in-progress sessions disposables.add(addDisposableListener(activeSection, EventType.CLICK, (e) => { e.preventDefault(); e.stopPropagation(); @@ -887,17 +1062,28 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { } })); - // Hover tooltip - different message based on state - const activeTooltip = hasAttentionNeeded - ? (attentionNeededSessions.length === 1 - ? localize('needsInputSessionsTooltip1', "{0} session needs input", attentionNeededSessions.length) - : localize('needsInputSessionsTooltip', "{0} sessions need input", attentionNeededSessions.length)) - : (activeSessions.length === 1 - ? localize('activeSessionsTooltip1', "{0} session in progress", activeSessions.length) - : localize('activeSessionsTooltip', "{0} sessions in progress", activeSessions.length)); + const activeTooltip = inProgressOnly.length === 1 + ? localize('activeSessionsTooltip1', "{0} session in progress", inProgressOnly.length) + : localize('activeSessionsTooltip', "{0} sessions in progress", inProgressOnly.length); disposables.add(this.hoverService.setupManagedHover(hoverDelegate, activeSection, activeTooltip)); } + // Append status sections in the correct order and register for roving tabindex + if (reverseOrder) { + // [needs-input, active, unread, sparkle] — populates inward + if (needsInputSection) { badge.appendChild(needsInputSection); this._rovingElements.push(needsInputSection); } + if (activeSection) { badge.appendChild(activeSection); this._rovingElements.push(activeSection); } + if (unreadSection) { badge.appendChild(unreadSection); this._rovingElements.push(unreadSection); } + badge.appendChild(sparkleContainer); + this._rovingElements.push(sparkleContainer); + } else { + // Original: [sparkle (already appended), unread, active, needs-input] + this._rovingElements.push(sparkleContainer); + if (unreadSection) { badge.appendChild(unreadSection); this._rovingElements.push(unreadSection); } + if (activeSection) { badge.appendChild(activeSection); this._rovingElements.push(activeSection); } + if (needsInputSection) { badge.appendChild(needsInputSection); this._rovingElements.push(needsInputSection); } + } + } /** @@ -905,31 +1091,35 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * For example, if filtered to "unread" but no unread sessions exist, restore user's previous filter. * Only auto-clears if THIS window applied the badge filter to avoid cross-window interference. */ - private _clearFilterIfCategoryEmpty(hasUnreadSessions: boolean, hasActiveSessions: boolean): void { + private _clearFilterIfCategoryEmpty(hasUnreadSessions: boolean, hasActiveSessions: boolean, hasAttentionNeeded: boolean): void { // Only auto-clear if this window applied the badge filter // This prevents Window B from clearing filters that Window A set if (this._badgeFilterAppliedByThisWindow === 'unread' && !hasUnreadSessions) { this._restoreUserFilter(); } else if (this._badgeFilterAppliedByThisWindow === 'inProgress' && !hasActiveSessions) { this._restoreUserFilter(); + } else if (this._badgeFilterAppliedByThisWindow === 'needsInput' && !hasAttentionNeeded) { + this._restoreUserFilter(); } } /** * Get the current filter state from storage. */ - private _getCurrentFilterState(): { isFilteredToUnread: boolean; isFilteredToInProgress: boolean } { + private _getCurrentFilterState(): { isFilteredToUnread: boolean; isFilteredToInProgress: boolean; isFilteredToNeedsInput: boolean } { const filter = this._getStoredFilter(); if (!filter) { - return { isFilteredToUnread: false, isFilteredToInProgress: false }; + return { isFilteredToUnread: false, isFilteredToInProgress: false, isFilteredToNeedsInput: false }; } // Detect if filtered to unread (read=true excludes read sessions, leaving only unread) const isFilteredToUnread = filter.read === true && filter.states.length === 0; - // Detect if filtered to in-progress (2 excluded states = Completed + Failed) - const isFilteredToInProgress = filter.states?.length === 2 && filter.read === false; + // Detect if filtered to in-progress only (3 excluded states including NeedsInput) + const isFilteredToInProgress = filter.states?.length === 3 && filter.states.includes(AgentSessionStatus.NeedsInput) && filter.read === false; + // Detect if filtered to needs-input only (3 excluded states including InProgress) + const isFilteredToNeedsInput = filter.states?.length === 3 && filter.states.includes(AgentSessionStatus.InProgress) && filter.read === false; - return { isFilteredToUnread, isFilteredToInProgress }; + return { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput }; } /** @@ -972,11 +1162,11 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { * This preserves the original user filter when switching between badge filters. */ private _saveUserFilter(): void { - const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); + const { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput } = this._getCurrentFilterState(); // Don't overwrite the saved filter if we're already in a badge-filtered state // The previous user filter should already be saved - if (isFilteredToUnread || isFilteredToInProgress) { + if (isFilteredToUnread || isFilteredToInProgress || isFilteredToNeedsInput) { return; } @@ -1012,56 +1202,54 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { /** * Opens the agent sessions view with a specific filter applied, or restores previous filter if already applied. * Preserves session type (provider) filters while toggling only status filters. - * @param filterType 'unread' to show only unread sessions, 'inProgress' to show only in-progress sessions */ - private _openSessionsWithFilter(filterType: 'unread' | 'inProgress'): void { - const { isFilteredToUnread, isFilteredToInProgress } = this._getCurrentFilterState(); + private _openSessionsWithFilter(filterType: 'unread' | 'inProgress' | 'needsInput'): void { + const { isFilteredToUnread, isFilteredToInProgress, isFilteredToNeedsInput } = this._getCurrentFilterState(); const currentFilter = this._getStoredFilter(); // Preserve existing provider filters (session type filters like Local, Background, etc.) const preservedProviders = currentFilter?.providers ?? []; // Log telemetry for filter button clicks - const isToggleOff = (filterType === 'unread' && isFilteredToUnread) || (filterType === 'inProgress' && isFilteredToInProgress); + const isToggleOff = (filterType === 'unread' && isFilteredToUnread) + || (filterType === 'inProgress' && isFilteredToInProgress) + || (filterType === 'needsInput' && isFilteredToNeedsInput); this.telemetryService.publicLog2('agentStatusWidget.click', { source: filterType, action: isToggleOff ? 'clearFilter' : 'applyFilter', }); - // Toggle filter based on current state - if (filterType === 'unread') { - if (isFilteredToUnread) { - // Already filtered to unread - restore user's previous filter - this._restoreUserFilter(); - } else { - // Save current filter before applying our own - this._saveUserFilter(); - // Exclude read sessions to show only unread, preserving provider filters + // Check if already filtered to this type — toggle off + if (isToggleOff) { + this._restoreUserFilter(); + } else { + // Save current filter before applying our own + this._saveUserFilter(); + + if (filterType === 'unread') { this._storeFilter({ providers: preservedProviders, states: [], archived: true, read: true }); - // Track that this window applied the badge filter - this._badgeFilterAppliedByThisWindow = 'unread'; - } - } else { - if (isFilteredToInProgress) { - // Already filtered to in-progress - restore user's previous filter - this._restoreUserFilter(); - } else { - // Save current filter before applying our own - this._saveUserFilter(); - // Exclude Completed and Failed to show InProgress and NeedsInput, preserving provider filters + } else if (filterType === 'inProgress') { + // Exclude Completed, Failed, and NeedsInput — show only InProgress this._storeFilter({ providers: preservedProviders, - states: [AgentSessionStatus.Completed, AgentSessionStatus.Failed], + states: [AgentSessionStatus.Completed, AgentSessionStatus.Failed, AgentSessionStatus.NeedsInput], + archived: true, + read: false + }); + } else { + // Exclude Completed, Failed, and InProgress — show only NeedsInput + this._storeFilter({ + providers: preservedProviders, + states: [AgentSessionStatus.Completed, AgentSessionStatus.Failed, AgentSessionStatus.InProgress], archived: true, read: false }); - // Track that this window applied the badge filter - this._badgeFilterAppliedByThisWindow = 'inProgress'; } + this._badgeFilterAppliedByThisWindow = filterType; } // Open the sessions view @@ -1077,6 +1265,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { escButton.setAttribute('role', 'button'); escButton.setAttribute('aria-label', localize('exitAgentSessionProjection', "Exit Agent Session Projection")); escButton.tabIndex = 0; + this._rovingElements.push(escButton); parent.appendChild(escButton); // Setup hover @@ -1117,9 +1306,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { enterButton.setAttribute('role', 'button'); enterButton.setAttribute('aria-label', localize('enterAgentSessionProjection', "Enter Agent Session Projection")); enterButton.tabIndex = 0; - if (!this._firstFocusableElement) { - this._firstFocusableElement = enterButton; - } + this._rovingElements.push(enterButton); parent.appendChild(enterButton); // Setup hover @@ -1156,29 +1343,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // #endregion - // #region Click Handlers - - /** - * Handle pill click - opens the displayed session if showing progress, otherwise opens unified quick access - */ - private _handlePillClick(): void { - if (this._displayedSession) { - this.telemetryService.publicLog2('agentStatusWidget.click', { - source: 'pill', - action: 'openSession', - }); - this.instantiationService.invokeFunction(openSession, this._displayedSession); - } else { - this.telemetryService.publicLog2('agentStatusWidget.click', { - source: 'pill', - action: 'quickAccess', - }); - this.commandService.executeCommand(UNIFIED_QUICK_ACCESS_ACTION_ID); - } - } - - // #endregion - // #region Session Helpers /** @@ -1215,24 +1379,23 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // #region Label Helpers /** - * Compute the label to display, matching the command center behavior. - * Includes prefix and suffix decorations (remote host, extension dev host, etc.) + * Compute the label to display in the command center. + * Uses the workspace name (folder name) with prefix/suffix decorations. + * Falls back to file name when tabs are hidden, or "Search" when empty. */ private _getLabel(): string { - const { prefix, suffix } = this._getTitleDecorations(); + const { prefix, suffix } = this._windowTitle.getTitleDecorations(); - // Base label: workspace name or file name (when tabs are hidden) - let label = this.labelService.getWorkspaceLabel(this.workspaceContextService.getWorkspace()); - if (this.editorGroupsService.partOptions.showTabs === 'none') { - const activeEditor = this.editorService.activeEditor; - if (activeEditor) { - const dirty = activeEditor.isDirty() && !activeEditor.isSaving() ? TITLE_DIRTY : ''; - label = `${dirty}${activeEditor.getTitle(Verbosity.SHORT)}`; - } + // Base label: custom title, workspace name, or file name when tabs are hidden + let label = this._windowTitle.workspaceName; + if (this._windowTitle.isCustomTitleFormat()) { + label = this._windowTitle.getWindowTitle(); + } else if (!label && this.editorGroupsService.partOptions.showTabs === 'none') { + label = this._windowTitle.fileName ?? ''; } if (!label) { - label = localize('agentStatusWidget.askAnything', "Ask anything..."); + label = localize('agentStatusWidget.search', "Search"); } // Apply prefix and suffix decorations @@ -1246,28 +1409,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { return label.replaceAll(/\r\n|\r|\n/g, '\u23CE'); } - /** - * Get prefix and suffix decorations for the title (matching WindowTitle behavior) - */ - private _getTitleDecorations(): { prefix: string | undefined; suffix: string | undefined } { - let prefix: string | undefined; - const suffix: string | undefined = undefined; - - // Add remote host label if connected to a remote - if (this.environmentService.remoteAuthority) { - prefix = this.labelService.getHostLabel(Schemas.vscodeRemote, this.environmentService.remoteAuthority); - } - - // Add extension development host prefix - if (this.environmentService.isExtensionDevelopment) { - prefix = !prefix - ? NLS_EXTENSION_HOST - : `${NLS_EXTENSION_HOST} - ${prefix}`; - } - - return { prefix, suffix }; - } - // #endregion } @@ -1284,7 +1425,8 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben constructor( @IActionViewItemService actionViewItemService: IActionViewItemService, @IInstantiationService instantiationService: IInstantiationService, - @IConfigurationService configurationService: IConfigurationService + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService ) { super(); @@ -1295,19 +1437,38 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben return instantiationService.createInstance(AgentTitleBarStatusWidget, action, options); }, undefined)); - // Add/remove CSS classes on workbench based on settings - // Force enable command center and disable chat controls when agent status or unified agents bar is enabled + // Add/remove CSS classes on workbench based on settings. + // Only hide the default command center search box (via unified-agents-bar) + // when chat is enabled, so the search box remains visible during remote + // connection startup before the agent status widget is ready to render. + const chatEnabledKey = contextKeyService.getContextKeyValue('chatIsEnabled'); + let chatEnabled = !!chatEnabledKey; + const updateClass = () => { const commandCenterEnabled = configurationService.getValue(LayoutSettings.COMMAND_CENTER) === true; - const enabled = configurationService.getValue(ChatConfiguration.AgentStatusEnabled) === true && commandCenterEnabled; - const enhanced = configurationService.getValue(ChatConfiguration.UnifiedAgentsBar) === true && commandCenterEnabled; + const statusMode = getAgentStatusSettingMode(configurationService); + const enabled = commandCenterEnabled && chatEnabled && statusMode !== 'hidden'; + const enhanced = enabled && statusMode === 'compact'; mainWindow.document.body.classList.toggle('agent-status-enabled', enabled); mainWindow.document.body.classList.toggle('unified-agents-bar', enhanced); }; updateClass(); this._register(configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) || e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(LayoutSettings.COMMAND_CENTER)) { + if ( + e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) + || e.affectsConfiguration(LayoutSettings.COMMAND_CENTER) + || e.affectsConfiguration(ChatConfiguration.AIDisabled) + || e.affectsConfiguration(ChatConfiguration.ChatCustomizationMenuEnabled) + || e.affectsConfiguration('disableAICustomizations') + || e.affectsConfiguration('workbench.disableAICustomizations') + ) { + updateClass(); + } + })); + this._register(contextKeyService.onDidChangeContext(e => { + if (e.affectsSome(new Set(['chatIsEnabled']))) { + chatEnabled = !!contextKeyService.getContextKeyValue('chatIsEnabled'); updateClass(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css index 2da5d3bb06a..620950ec921 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css @@ -65,13 +65,24 @@ border-color: var(--vscode-commandCenter-activeBorder, transparent); } -.agent-status-pill.chat-input-mode { +/* Compact mode: pill hover is handled by individual sections, not the whole pill */ +.agent-status-pill.compact-mode { + padding: 0; + gap: 0; + background-color: transparent; +} + +.agent-status-pill.compact-mode:hover { + background-color: transparent; + border-color: var(--vscode-commandCenter-border, transparent); +} + +.agent-status-pill.chat-input-mode:not(.compact-mode) { cursor: pointer; } -.agent-status-pill.chat-input-mode:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; +.agent-status-pill.chat-input-mode .agent-status-input-area { + cursor: pointer; } .agent-status-pill.session-mode, @@ -95,13 +106,53 @@ color: var(--vscode-commandCenter-activeForeground); } -/* Label */ +/* Label - styled as placeholder text */ .agent-status-label { flex: 1; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + opacity: 0.6; +} + +/* Compact mode: left-aligned label, no icon */ +.agent-status-pill.compact-mode .agent-status-label { + text-align: left; +} + +/* Compact mode: inline status sections inside the pill */ +.agent-status-pill.compact-mode .agent-status-badge-section { + flex-shrink: 0; +} + +/* Input hover target - only this area triggers hover, not badge sections */ +.agent-status-pill .agent-status-input-area { + display: flex; + align-items: center; + flex: 1; + min-width: 0; + overflow: hidden; + cursor: pointer; + gap: 6px; + border-radius: 5px 0 0 5px; + height: 100%; + padding: 0 10px; + background-color: var(--vscode-agentStatusIndicator-background); +} + +/* When preceded by a toolbar/separator, remove left border-radius */ +.agent-status-line-separator + .agent-status-input-area { + border-radius: 0; +} + +.agent-status-pill .agent-status-input-area:hover { + background-color: var(--vscode-commandCenter-activeBackground); +} + +.agent-status-pill .agent-status-input-area:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; } .agent-status-label.has-progress { @@ -203,6 +254,9 @@ display: flex; align-items: center; -webkit-app-region: no-drag; + height: 100%; + background-color: var(--vscode-agentStatusIndicator-background); + border-radius: 5px 0 0 5px; } .agent-status-separator { @@ -212,6 +266,16 @@ align-items: center; } +/* Vertical line separator used inside the pill in compact+debug mode */ +.agent-status-line-separator { + width: 1px; + align-self: stretch; + margin: 4px 0; + background-color: var(--vscode-commandCenter-border, rgba(128, 128, 128, 0.35)); + flex-shrink: 0; + pointer-events: none; +} + /* Status Badge */ .agent-status-badge { display: flex; @@ -235,6 +299,7 @@ height: 100%; position: relative; cursor: pointer; + background-color: var(--vscode-agentStatusIndicator-background); } .agent-status-badge-section:first-child { border-radius: 5px 0 0 5px; } @@ -281,7 +346,8 @@ } /* Separator between sections */ -.agent-status-badge-section + .agent-status-badge-section::before { +.agent-status-badge-section + .agent-status-badge-section::before, +.agent-status-input-area + .agent-status-badge-section::before { content: ''; position: absolute; left: 0; @@ -317,10 +383,16 @@ } .agent-status-badge-section.sparkle .action-container { - padding: 0 4px; + padding: 0 5px; border-radius: 5px 0 0 5px; } +/* In compact mode, no left radius on sparkle - it sits flush next to other sections */ +.agent-status-pill.compact-mode .agent-status-badge-section.sparkle .action-container { + border-radius: 0; + padding: 0 5px 0 6px; +} + .agent-status-badge-section.sparkle .dropdown-action-container { width: 18px; padding: 0; 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 bca4bae6e8a..3e28c11d54f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -64,7 +64,7 @@ /* On hover or keyboard focus: show toolbar */ .monaco-list-row:hover, - .monaco-list-row.focused { + .monaco-list-row.focused:not(.selected) { .agent-session-title-toolbar { display: block; @@ -310,6 +310,7 @@ font-size: 11px; font-weight: 500; color: var(--vscode-descriptionForeground); + text-transform: uppercase; /* align with session item padding */ padding: 0 6px; @@ -317,6 +318,11 @@ flex: 1; } + .agent-session-section-count { + opacity: 0.7; + margin-right: 4px; + } + .agent-session-section-toolbar { /* for the absolute positioning of the toolbar below */ position: relative; @@ -331,8 +337,13 @@ } } + .monaco-list-row:hover .agent-session-section .agent-session-section-count, + .monaco-list-row.focused:not(.selected) .agent-session-section .agent-session-section-count { + display: none; + } + .monaco-list-row:hover .agent-session-section .agent-session-section-toolbar, - .monaco-list-row.focused .agent-session-section .agent-session-section-toolbar { + .monaco-list-row.focused:not(.selected) .agent-session-section .agent-session-section-toolbar { width: 22px; .monaco-toolbar { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index c3a7eb81ba0..014615bd6ed 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -10,19 +10,20 @@ import { Disposable, DisposableStore } from '../../../../../base/common/lifecycl import { Emitter, Event } from '../../../../../base/common/event.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; +import { ResourceSet } from '../../../../../base/common/map.js'; import { Codicon } from '../../../../../base/common/codicons.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 { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { AGENT_MD_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon, builtinIcon } from './aiCustomizationIcons.js'; -import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementSection, BUILTIN_STORAGE } from './aiCustomizationManagement.js'; +import { AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, AICustomizationManagementItemMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, AI_CUSTOMIZATION_ITEM_DISABLED_KEY } from './aiCustomizationManagement.js'; +import { IAgentPluginService } from '../../common/plugins/agentPluginService.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'; @@ -40,6 +41,7 @@ import { IAICustomizationWorkspaceService, applyStorageSourceFilter } from '../. import { Action, Separator } from '../../../../../base/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js'; @@ -51,7 +53,12 @@ import { parse as parseJSONC } from '../../../../../base/common/json.js'; import { Schemas } from '../../../../../base/common/network.js'; import { OS } from '../../../../../base/common/platform.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, matchesWorkspaceSubpath, matchesInstructionFileFilter } from '../../common/customizationHarnessService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { getCleanPromptName, isInClaudeRulesFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { evaluateApplyToPattern } from '../../common/promptSyntax/computeAutomaticInstructions.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; export { truncateToFirstSentence } from './aiCustomizationListWidgetUtils.js'; @@ -88,8 +95,19 @@ export interface IAICustomizationListItem { readonly description?: string; readonly storage: PromptsStorage; readonly promptType: PromptsType; + readonly disabled: boolean; /** When set, overrides `storage` for display grouping purposes. */ readonly groupKey?: string; + /** URI of the parent plugin, when this item comes from an installed plugin. */ + readonly pluginUri?: URI; + /** When set, overrides the formatted name for display. */ + readonly displayName?: string; + /** When set, shows a small inline badge next to the item name. */ + readonly badge?: string; + /** Tooltip shown when hovering the badge. */ + readonly badgeTooltip?: string; + /** When set, overrides the default prompt-type icon. */ + readonly typeIcon?: ThemeIcon; nameMatches?: IMatch[]; descriptionMatches?: IMatch[]; } @@ -141,6 +159,7 @@ interface IAICustomizationItemTemplateData { readonly actionBar: ActionBar; readonly typeIcon: HTMLElement; readonly nameLabel: HighlightedLabel; + readonly badge: HTMLElement; readonly description: HighlightedLabel; readonly disposables: DisposableStore; readonly elementDisposables: DisposableStore; @@ -233,6 +252,19 @@ function promptTypeToIcon(type: PromptsType): ThemeIcon { } } +/** + * Returns the icon for a given storage type. + */ +function storageToIcon(storage: PromptsStorage): ThemeIcon { + switch (storage) { + case PromptsStorage.local: return workspaceIcon; + case PromptsStorage.user: return userIcon; + case PromptsStorage.extension: return extensionIcon; + case PromptsStorage.plugin: return pluginIcon; + default: return instructionsIcon; + } +} + /** * Formats a name for display: strips a trailing .md extension, converts dashes/underscores * to spaces and applies title case. @@ -258,6 +290,7 @@ class AICustomizationItemRenderer implements IListRenderer { const uriLabel = this.labelService.getUriLabel(element.uri, { relative: false }); + let content = `${element.name}\n${uriLabel}`; + if (element.badgeTooltip) { + content += `\n\n${element.badgeTooltip}`; + } + const plugin = element.pluginUri && this.agentPluginService.plugins.get().find(p => isEqual(p.uri, element.pluginUri)); + if (plugin) { + content += `\n${localize('fromPlugin', "Plugin: {0}", plugin.label)}`; + } return { - content: `${element.name}\n${uriLabel}`, + content, appearance: { compact: true, skipFadeInAnimation: true, @@ -310,10 +354,29 @@ class AICustomizationItemRenderer implements IListRenderer()); readonly onDidRequestCreate: Event = this._onDidRequestCreate.event; - private readonly _onDidRequestCreateManual = this._register(new Emitter<{ type: PromptsType; target: 'workspace' | 'user' | 'workspace-root' }>()); - readonly onDidRequestCreateManual: Event<{ type: PromptsType; target: 'workspace' | 'user' | 'workspace-root' }> = this._onDidRequestCreateManual.event; + private readonly _onDidRequestCreateManual = this._register(new Emitter<{ type: PromptsType; target: 'workspace' | 'user' | 'workspace-root'; rootFileName?: string }>()); + readonly onDidRequestCreateManual: Event<{ type: PromptsType; target: 'workspace' | 'user' | 'workspace-root'; rootFileName?: string }> = this._onDidRequestCreateManual.event; constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -463,6 +542,9 @@ export class AICustomizationListWidget extends Disposable { @IPathService private readonly pathService: IPathService, @ITelemetryService private readonly telemetryService: ITelemetryService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, + @IAgentPluginService private readonly agentPluginService: IAgentPluginService, + @ICommandService private readonly commandService: ICommandService, + @IProductService private readonly productService: IProductService, ) { super(); this.element = $('.ai-customization-list-widget'); @@ -536,8 +618,9 @@ export class AICustomizationListWidget extends Disposable { // Empty state container this.emptyStateContainer = DOM.append(this.element, $('.list-empty-state')); - this.emptyStateIcon = DOM.append(this.emptyStateContainer, $('.empty-state-icon')); - this.emptyStateText = DOM.append(this.emptyStateContainer, $('.empty-state-text')); + const emptyStateHeader = DOM.append(this.emptyStateContainer, $('.empty-state-header')); + this.emptyStateIcon = DOM.append(emptyStateHeader, $('.empty-state-icon')); + this.emptyStateText = DOM.append(emptyStateHeader, $('.empty-state-text')); this.emptyStateSubtext = DOM.append(this.emptyStateContainer, $('.empty-state-subtext')); this.emptyStateContainer.style.display = 'none'; @@ -560,9 +643,12 @@ export class AICustomizationListWidget extends Disposable { if (entry.type === 'group-header') { return localize('groupAriaLabel', "{0}, {1} items, {2}", entry.label, entry.count, entry.collapsed ? localize('collapsed', "collapsed") : localize('expanded', "expanded")); } - return entry.item.description + const nameAndDesc = entry.item.description ? localize('itemAriaLabel', "{0}, {1}", entry.item.name, entry.item.description) : entry.item.name; + return entry.item.disabled + ? localize('itemAriaLabelDisabled', "{0}, disabled", nameAndDesc) + : nameAndDesc; }, getWidgetAriaLabel: () => localize('listAriaLabel', "Chat Customizations"), }, @@ -591,6 +677,7 @@ export class AICustomizationListWidget extends Disposable { // Subscribe to prompt service changes this._register(this.promptsService.onDidChangeCustomAgents(() => this.refresh())); this._register(this.promptsService.onDidChangeSlashCommands(() => this.refresh())); + this._register(this.promptsService.onDidChangeSkills(() => this.refresh())); // Refresh on file deletions so the list updates after inline delete actions this._register(this.fileService.onDidFilesChange(e => { @@ -629,14 +716,20 @@ export class AICustomizationListWidget extends Disposable { name: item.name, promptType: item.promptType, storage: item.storage, + pluginUri: item.pluginUri?.toString(), }; // Create scoped context key service with item-specific keys for when-clause filtering - const overlay = this.contextKeyService.createOverlay([ + const overlayPairs: [string, string | boolean][] = [ [AI_CUSTOMIZATION_ITEM_TYPE_KEY, item.promptType], [AI_CUSTOMIZATION_ITEM_STORAGE_KEY, item.storage], [AI_CUSTOMIZATION_ITEM_URI_KEY, item.uri.toString()], - ]); + [AI_CUSTOMIZATION_ITEM_DISABLED_KEY, item.disabled], + ]; + if (item.pluginUri) { + overlayPairs.push([AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY, item.pluginUri.toString()]); + } + const overlay = this.contextKeyService.createOverlay(overlayPairs); // Get menu actions, excluding inline actions to avoid duplicates const actions = this.menuService.getMenuActions(AICustomizationManagementItemMenuId, overlay, { @@ -722,122 +815,173 @@ export class AICustomizationListWidget extends Disposable { } /** - * Updates the add button label based on the current section. + * Updates the add button by building a unified action list. + * The first action becomes the primary button; the rest go in the dropdown. */ private updateAddButton(): void { - const typeLabel = this.getTypeLabel(); - const dropdownActions = this.getDropdownActions(); - const hasDropdown = dropdownActions.length > 0; + const actions = this.buildCreateActions(); + const [primary, ...dropdown] = actions; + const hasDropdown = dropdown.length > 0; // Toggle which button is visible this.addButton.element.style.display = hasDropdown ? '' : 'none'; this.addButtonSimple.element.style.display = hasDropdown ? 'none' : ''; - if (this.workspaceService.isSessionsWindow) { - // Sessions: primary is workspace creation - const hasWorkspace = this.hasActiveWorkspace(); - const label = `$(${Codicon.add.id}) New ${typeLabel} (Workspace)`; + if (!primary) { + this.addButtonSimple.element.style.display = 'none'; + this.addButton.element.style.display = 'none'; + return; + } - if (hasDropdown) { - this.addButton.label = label; - this.addButton.enabled = hasWorkspace; - this.addButton.primaryButton.setTitle(''); - this.addButton.dropdownButton.setTitle(''); - if (!hasWorkspace) { - const disabledTitle = localize('createDisabled', "Open a workspace folder to create customizations."); - this.addButton.primaryButton.setTitle(disabledTitle); - this.addButton.dropdownButton.setTitle(disabledTitle); - } - } else { - this.addButtonSimple.label = label; - this.addButtonSimple.enabled = hasWorkspace; - if (!hasWorkspace) { - this.addButtonSimple.setTitle(localize('createDisabled', "Open a workspace folder to create customizations.")); - } else { - this.addButtonSimple.setTitle(''); - } - } + if (hasDropdown) { + this.addButton.label = primary.label; + this.addButton.enabled = primary.enabled; + this.addButton.primaryButton.setTitle(primary.tooltip ?? ''); + this.addButton.dropdownButton.setTitle(''); } else { - // Core: primary is AI generation - const label = `$(${Codicon.sparkle.id}) Generate ${typeLabel}`; - if (hasDropdown) { - this.addButton.label = label; - this.addButton.enabled = true; - this.addButton.primaryButton.setTitle(''); - this.addButton.dropdownButton.setTitle(''); - } else { - this.addButtonSimple.label = label; - this.addButtonSimple.enabled = true; - this.addButtonSimple.setTitle(''); - } + this.addButtonSimple.label = primary.label; + this.addButtonSimple.enabled = primary.enabled; + this.addButtonSimple.setTitle(primary.tooltip ?? ''); } } /** - * Gets the dropdown actions for the add button. - * Respects the active harness filter — user-scoped creation is only - * offered when the harness shows all user roots (i.e. "Local"). + * Builds an ordered list of create actions for the current section. + * The first entry is the primary button; remaining entries are dropdown items. */ - private getDropdownActions(): Action[] { - this.dropdownActionDisposables.clear(); + private buildCreateActions(): ICreateAction[] { const typeLabel = this.getTypeLabel(); - const actions: Action[] = []; const promptType = sectionToPromptType(this.currentSection); + const descriptor = this.harnessService.getActiveDescriptor(); + const override = descriptor.sectionOverrides?.get(this.currentSection); + const hasWorkspace = this.hasActiveWorkspace(); - // Hooks: no user-scoped creation + // Full command override (e.g. Claude hooks) — single action, no dropdown + if (override?.commandId) { + return [{ + label: `$(${Codicon.add.id}) ${override.label}`, + enabled: true, + run: () => { this.commandService.executeCommand(override.commandId!); }, + }]; + } + + const createTypeLabel = override?.typeLabel ?? typeLabel; + const actions: ICreateAction[] = []; + const addedTargets = new Set(); + + // Root-file primary button (e.g. "Add CLAUDE.md") — only when workspace is open. + // Without a workspace, user creation becomes primary and rootFile goes to dropdown. + if (override?.rootFile && hasWorkspace) { + actions.push({ + label: `$(${Codicon.add.id}) ${override.label}`, + enabled: true, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace-root' }); }, + }); + addedTargets.add('workspace-root'); + } + + // Hooks have a simplified action set if (promptType === PromptsType.hook) { - if (this.workspaceService.isSessionsWindow) { - // Sessions: no dropdown for hooks - } else { - // Core: primary is generate, dropdown has configure quick pick - if (this.hasActiveWorkspace()) { - actions.push(this.dropdownActionDisposables.add(new Action('configureHooks', `$(${Codicon.add.id}) Configure Hooks`, undefined, true, () => { - this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); - }))); + if (!this.workspaceService.isSessionsWindow && !descriptor.hideGenerateButton) { + // Core Local: Generate is primary, configure hooks in dropdown + actions.push({ + label: `$(${Codicon.sparkle.id}) Generate ${typeLabel}`, + enabled: true, + run: () => { this._onDidRequestCreate.fire(promptType); }, + }); + if (hasWorkspace) { + actions.push({ + label: `$(${Codicon.add.id}) Configure Hooks`, + enabled: true, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, + }); } + } else if (!override?.commandId) { + // Sessions / non-local: workspace creation only + actions.push({ + label: `$(${Codicon.add.id}) New ${typeLabel} (Workspace)`, + enabled: hasWorkspace, + tooltip: hasWorkspace ? undefined : localize('createDisabled', "Open a workspace folder to create customizations."), + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, + }); } return actions; } - // User-scoped creation: in core VS Code, only when the harness shows - // all user roots (no includedUserFileRoots restriction). Restricted - // harnesses like CLI/Claude filter user roots, so creating in the - // VS Code profile directory wouldn't be visible in the current view. - // In sessions, user creation always targets CLI-accessible paths - // (AgenticPromptsService routes to ~/.copilot/...) so it's always valid. - const filter = this.harnessService.getStorageSourceFilter(promptType); - const showUserCreate = this.workspaceService.isSessionsWindow || !filter.includedUserFileRoots; + // Non-hook sections: build the full action list - if (this.workspaceService.isSessionsWindow) { - // Sessions: primary is workspace, dropdown has user - if (showUserCreate) { - actions.push(this.dropdownActionDisposables.add(new Action('createUser', `$(${Codicon.account.id}) New ${typeLabel} (User)`, undefined, true, () => { - this._onDidRequestCreateManual.fire({ type: promptType, target: 'user' }); - }))); + if (!override?.rootFile) { + // Determine the primary action (first in list) + if (!this.workspaceService.isSessionsWindow && !descriptor.hideGenerateButton) { + // Core Local: Generate is primary + actions.push({ + label: `$(${Codicon.sparkle.id}) Generate ${typeLabel}`, + enabled: true, + run: () => { this._onDidRequestCreate.fire(promptType); }, + }); + } else if (hasWorkspace) { + // Sessions or non-local harness with workspace: workspace is primary + actions.push({ + label: `$(${Codicon.add.id}) New ${createTypeLabel} (Workspace)`, + enabled: true, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, + }); + addedTargets.add('workspace'); + } else { + // No workspace: user is primary + actions.push({ + label: `$(${Codicon.add.id}) New ${createTypeLabel} (User)`, + enabled: true, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'user' }); }, + }); + addedTargets.add('user'); } - // For instructions: offer AGENTS.md at workspace root - if (promptType === PromptsType.instructions && this.hasActiveWorkspace()) { - actions.push(this.dropdownActionDisposables.add(new Action('createAgentsMd', `$(${Codicon.file.id}) New ${AGENT_MD_FILENAME}`, undefined, true, () => { - this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace-root' }); - }))); - } - } else { - // Core: primary is generate, dropdown has workspace + user - if (this.hasActiveWorkspace()) { - actions.push(this.dropdownActionDisposables.add(new Action('createWorkspace', `$(${Codicon.folder.id}) New ${typeLabel} (Workspace)`, undefined, true, () => { - this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); - }))); - } - if (showUserCreate) { - actions.push(this.dropdownActionDisposables.add(new Action('createUser', `$(${Codicon.account.id}) New ${typeLabel} (User)`, undefined, true, () => { - this._onDidRequestCreateManual.fire({ type: promptType, target: 'user' }); - }))); + } + + // Secondary actions (dropdown) — only add if not already present + if (hasWorkspace && !addedTargets.has('workspace')) { + actions.push({ + label: `$(${Codicon.folder.id}) New ${createTypeLabel} (Workspace)`, + enabled: true, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, + }); + } + + if (!addedTargets.has('user')) { + actions.push({ + label: `$(${Codicon.account.id}) New ${createTypeLabel} (User)`, + enabled: true, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'user' }); }, + }); + } + + // Root-file shortcuts from the descriptor (e.g. "New AGENTS.md") + if (hasWorkspace && override?.rootFileShortcuts && !addedTargets.has('workspace-root')) { + for (const fileName of override.rootFileShortcuts) { + actions.push({ + label: `$(${Codicon.file.id}) New ${fileName}`, + enabled: true, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace-root', rootFileName: fileName }); }, + }); } } return actions; } + /** + * Gets the dropdown actions for the add button (consumed by ButtonWithDropdown). + * Returns all actions except the primary (first) from buildCreateActions. + */ + private getDropdownActions(): Action[] { + this.dropdownActionDisposables.clear(); + const allActions = this.buildCreateActions(); + // Skip the first (primary) action + return allActions.slice(1).map((a, i) => + this.dropdownActionDisposables.add(new Action(`create_${i}`, a.label, undefined, a.enabled, () => a.run())) + ); + } + /** * Checks if there's an active project root (workspace folder or session repository). */ @@ -849,16 +993,9 @@ export class AICustomizationListWidget extends Disposable { * Executes the primary create action based on context. */ private executePrimaryCreateAction(): void { - const promptType = sectionToPromptType(this.currentSection); - if (this.workspaceService.isSessionsWindow) { - // Sessions: primary creates in workspace - if (!this.hasActiveWorkspace()) { - return; - } - this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); - } else { - // Core: primary is generate - this._onDidRequestCreate.fire(promptType); + const actions = this.buildCreateActions(); + if (actions.length > 0 && actions[0].enabled) { + actions[0].run(); } } @@ -914,6 +1051,61 @@ export class AICustomizationListWidget extends Disposable { return items.length; } + /** + * Returns true if the given extension identifier matches the default + * chat extension (e.g. GitHub Copilot Chat). Used to group items from + * the chat extension under "Built-in" instead of "Extensions", similar + * to how MCP categorizes built-in servers. + */ + private isChatExtensionItem(extensionId: ExtensionIdentifier): boolean { + const chatExtensionId = this.productService.defaultChatAgent?.chatExtensionId; + return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); + } + + /** + * Resolves the display group key for an extension-storage item. + * Items from the default chat extension are re-grouped under "Built-in"; + * all other extension items keep their original storage as group key. + * + * Returns `undefined` when no override is needed (the item will fall back + * to its `storage` value for grouping). + * + * This is the single point where extension → group mapping is decided, + * making it easy to add dynamic filter layers in the future. + */ + private resolveExtensionGroupKey(extensionId: ExtensionIdentifier | undefined): string | undefined { + if (extensionId && this.isChatExtensionItem(extensionId)) { + return BUILTIN_STORAGE; + } + return undefined; + } + + /** + * Post-processes items to assign groupKey overrides for extension-sourced + * items. Applies the built-in grouping consistently across all item types. + * + * Items that already have an explicit groupKey (e.g. instruction categories, + * agent hooks) are left untouched — groupKey overrides are only applied to + * items whose current groupKey is `undefined`. + */ + private applyBuiltinGroupKeys(items: IAICustomizationListItem[], extensionIdByUri: ReadonlyMap): void { + for (const item of items) { + if (item.groupKey !== undefined) { + continue; // respect explicit groupKey from upstream (e.g. instruction categories) + } + if (item.storage !== PromptsStorage.extension) { + continue; + } + const extId = extensionIdByUri.get(item.uri.toString()); + const override = this.resolveExtensionGroupKey(extId); + if (override) { + // IAICustomizationListItem.groupKey is readonly for consumers but + // we own the items array here, so the mutation is safe. + (item as { groupKey?: string }).groupKey = override; + } + } + } + /** * Fetches and filters items for a given section. * Shared between `loadItems` (active section) and `computeItemCountForSection` (any section). @@ -921,6 +1113,8 @@ export class AICustomizationListWidget extends Disposable { private async fetchItemsForSection(section: AICustomizationManagementSection): Promise { const promptType = sectionToPromptType(section); const items: IAICustomizationListItem[] = []; + const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); + const extensionIdByUri = new Map(); if (promptType === PromptsType.agent) { @@ -936,14 +1130,29 @@ export class AICustomizationListWidget extends Disposable { description: agent.description, storage: agent.source.storage, promptType, + pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, + disabled: disabledUris.has(agent.uri), }); + // Track extension ID for built-in grouping + if (agent.source.storage === PromptsStorage.extension) { + extensionIdByUri.set(agent.uri.toString(), agent.source.extensionId); + } } } else if (promptType === PromptsType.skill) { - // Use findAgentSkills which has parsed name/description from frontmatter + // Use findAgentSkills for enabled skills (has parsed name/description from frontmatter) const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + // Build extension ID lookup from raw file list (like MCP builds collectionSources) + const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None); + for (const file of allSkillFiles) { + if (file.extension) { + extensionIdByUri.set(file.uri.toString(), file.extension.identifier); + } + } + const seenUris = new ResourceSet(); for (const skill of skills || []) { const filename = basename(skill.uri); const skillName = skill.name || basename(dirname(skill.uri)) || filename; + seenUris.add(skill.uri); items.push({ id: skill.uri.toString(), uri: skill.uri, @@ -952,8 +1161,28 @@ export class AICustomizationListWidget extends Disposable { description: skill.description, storage: skill.storage, promptType, + pluginUri: skill.storage === PromptsStorage.plugin ? this.findPluginUri(skill.uri) : undefined, + disabled: false, }); } + // Also include disabled skills from the raw file list + if (disabledUris.size > 0) { + for (const file of allSkillFiles) { + if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) { + const filename = basename(file.uri); + items.push({ + id: file.uri.toString(), + uri: file.uri, + name: file.name || basename(dirname(file.uri)) || filename, + filename, + description: file.description, + storage: file.storage, + promptType, + disabled: true, + }); + } + } + } } else if (promptType === PromptsType.prompt) { // Use getPromptSlashCommands which has parsed name/description from frontmatter // Filter out skills since they have their own section @@ -971,7 +1200,12 @@ export class AICustomizationListWidget extends Disposable { description: command.description, storage: command.promptPath.storage, promptType, + pluginUri: command.promptPath.storage === PromptsStorage.plugin ? command.promptPath.pluginUri : undefined, + disabled: disabledUris.has(command.promptPath.uri), }); + if (command.promptPath.extension) { + extensionIdByUri.set(command.promptPath.uri.toString(), command.promptPath.extension.identifier); + } } } else if (promptType === PromptsType.hook) { // Try to parse individual hooks from each file; fall back to showing the file itself @@ -981,6 +1215,23 @@ export class AICustomizationListWidget extends Disposable { const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; for (const hookFile of hookFiles) { + // Plugins parse their own hooks and emit them individually because they can + // be embedded with interpolations in the plugin manifests; don't re-parse them + if (hookFile.storage === PromptsStorage.plugin) { + const filename = basename(hookFile.uri); + items.push({ + id: hookFile.uri.toString() + ':' + hookFile.name, + uri: hookFile.uri, + name: hookFile.name || this.getFriendlyName(filename), + filename, + storage: hookFile.storage, + promptType, + pluginUri: hookFile.pluginUri, + disabled: disabledUris.has(hookFile.uri), + }); + continue; + } + let parsedHooks = false; try { const content = await this.fileService.readFile(hookFile.uri); @@ -1003,6 +1254,7 @@ export class AICustomizationListWidget extends Disposable { description: truncatedCmd || localize('hookUnset', "(unset)"), storage: hookFile.storage, promptType, + disabled: disabledUris.has(hookFile.uri), }); } } @@ -1016,10 +1268,11 @@ export class AICustomizationListWidget extends Disposable { items.push({ id: hookFile.uri.toString(), uri: hookFile.uri, - name: this.getFriendlyName(filename), + name: hookFile.name || this.getFriendlyName(filename), filename, storage: hookFile.storage, promptType, + disabled: disabledUris.has(hookFile.uri), }); } } @@ -1050,68 +1303,157 @@ export class AICustomizationListWidget extends Disposable { storage: agent.source.storage, groupKey: 'agents', promptType, + pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, + disabled: disabledUris.has(agent.uri), }); } } } } else { - // For instructions, fetch prompt files and group by storage + // For instructions, group by category: agent instructions, context instructions, on-demand instructions const promptFiles = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); - const allItems: IPromptPath[] = [...promptFiles]; - - // Also include agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md) - if (promptType === PromptsType.instructions) { - const agentInstructions = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); - const workspaceFolderUris = this.workspaceContextService.getWorkspace().folders.map(f => f.uri); - const activeRoot = this.workspaceService.getActiveProjectRoot(); - if (activeRoot) { - workspaceFolderUris.push(activeRoot); + for (const file of promptFiles) { + if (file.extension) { + extensionIdByUri.set(file.uri.toString(), file.extension.identifier); } - for (const file of agentInstructions) { - const isWorkspaceFile = workspaceFolderUris.some(root => isEqualOrParent(file.uri, root)); - allItems.push({ - uri: file.uri, - storage: isWorkspaceFile ? PromptsStorage.local : PromptsStorage.user, - type: PromptsType.instructions, - name: basename(file.uri), + } + const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); + const agentInstructionUris = new ResourceSet(agentInstructionFiles.map(f => f.uri)); + + // Add agent instruction items + const workspaceFolderUris = this.workspaceContextService.getWorkspace().folders.map(f => f.uri); + const activeRoot = this.workspaceService.getActiveProjectRoot(); + if (activeRoot) { + workspaceFolderUris.push(activeRoot); + } + for (const file of agentInstructionFiles) { + const storage = PromptsStorage.local; + const filename = basename(file.uri); + items.push({ + id: file.uri.toString(), + uri: file.uri, + name: filename, + filename: this.labelService.getUriLabel(file.uri, { relative: true }), + displayName: filename, + storage, + promptType, + typeIcon: storageToIcon(storage), + groupKey: 'agent-instructions', + disabled: disabledUris.has(file.uri), + }); + } + + // Parse prompt files to separate into context vs on-demand + const promptFilesToParse = promptFiles.filter(item => !agentInstructionUris.has(item.uri)); + const parseResults = await Promise.all(promptFilesToParse.map(async item => { + try { + const parsed = await this.promptsService.parseNew(item.uri, CancellationToken.None); + return { item, parsed }; + } catch { + // Parse failed — treat as on-demand + return { item, parsed: undefined }; + } + })); + + for (const { item, parsed } of parseResults) { + const applyTo = evaluateApplyToPattern(parsed?.header, isInClaudeRulesFolder(item.uri)); + const name = parsed?.header?.name; + let description = parsed?.header?.description; + const friendlyName = this.getFriendlyName(name || item.name || getCleanPromptName(item.uri)); + description = description || item.description; + + if (applyTo !== undefined) { + // Context instruction + const badge = applyTo === '**' + ? localize('alwaysAdded', "always added") + : applyTo; + const badgeTooltip = applyTo === '**' + ? localize('alwaysAddedTooltip', "This instruction is automatically included in every interaction.") + : localize('onContextTooltip', "This instruction is automatically included when files matching '{0}' are in context.", applyTo); + items.push({ + id: item.uri.toString(), + uri: item.uri, + name: friendlyName, + filename: this.labelService.getUriLabel(item.uri, { relative: true }), + displayName: friendlyName, + badge, + badgeTooltip, + description: description, + storage: item.storage, + promptType, + typeIcon: storageToIcon(item.storage), + groupKey: 'context-instructions', + pluginUri: item.storage === PromptsStorage.plugin ? item.pluginUri : undefined, + disabled: disabledUris.has(item.uri), + }); + } else { + // On-demand instruction + items.push({ + id: item.uri.toString(), + uri: item.uri, + name: friendlyName, + filename: basename(item.uri), + displayName: friendlyName, + description: description, + storage: item.storage, + promptType, + typeIcon: storageToIcon(item.storage), + groupKey: 'on-demand-instructions', + pluginUri: item.storage === PromptsStorage.plugin ? item.pluginUri : undefined, + disabled: disabledUris.has(item.uri), }); } } - - const workspaceItems = allItems.filter(item => item.storage === PromptsStorage.local); - 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); - // For instructions, derive a friendly name from filename - const friendlyName = item.name || this.getFriendlyName(filename); - return { - id: item.uri.toString(), - uri: item.uri, - name: friendlyName, - filename, - description: item.description, - storage: item.storage, - promptType, - }; - }; - - items.push(...workspaceItems.map(mapToListItem)); - items.push(...userItems.map(mapToListItem)); - items.push(...extensionItems.map(mapToListItem)); - items.push(...pluginItems.map(mapToListItem)); - items.push(...builtinItems.map(mapToListItem)); } + // Assign built-in groupKeys — items from the default chat extension + // are re-grouped under "Built-in" instead of "Extensions". + // This is a single-pass transformation applied after all items are + // collected, keeping the item-building code free of grouping logic. + this.applyBuiltinGroupKeys(items, extensionIdByUri); + // Apply storage source filter (removes items not in visible sources or excluded user roots) const filter = this.workspaceService.getStorageSourceFilter(promptType); const filteredItems = applyStorageSourceFilter(items, filter); items.length = 0; items.push(...filteredItems); + // Apply workspace subpath filter — when the active harness specifies + // workspaceSubpaths, hide workspace-local items that aren't under one + // of the recognized sub-paths (e.g. Claude only shows .claude/ items). + // Exception: instruction files matched by the harness's instructionFileFilter + // are exempt (e.g. CLAUDE.md at workspace root is a Claude-native file + // even though it's not under .claude/). + const descriptor = this.harnessService.getActiveDescriptor(); + const subpaths = descriptor.workspaceSubpaths; + const instrFilter = descriptor.instructionFileFilter; + if (subpaths) { + const projectRoot = this.workspaceService.getActiveProjectRoot(); + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item.storage === PromptsStorage.local && projectRoot && isEqualOrParent(item.uri, projectRoot)) { + if (!matchesWorkspaceSubpath(item.uri.path, subpaths)) { + // Keep instruction files that match the harness's native patterns + if (instrFilter && promptType === PromptsType.instructions && matchesInstructionFileFilter(item.uri.path, instrFilter)) { + continue; + } + items.splice(i, 1); + } + } + } + } + + // Apply instruction file filter — when the active harness specifies + // instructionFileFilter, hide instruction files that don't match the + // recognized patterns (e.g. Claude doesn't support *.instructions.md). + if (instrFilter && promptType === PromptsType.instructions) { + for (let i = items.length - 1; i >= 0; i--) { + if (!matchesInstructionFileFilter(items[i].uri.path, instrFilter)) { + items.splice(i, 1); + } + } + } + // Sort items by name items.sort((a, b) => a.name.localeCompare(b.name)); @@ -1152,12 +1494,13 @@ export class AICustomizationListWidget extends Disposable { for (const item of this.allItems) { // 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 displayName = item.displayName ?? formatDisplayName(item.name); const nameMatches = matchesContiguousSubString(query, displayName); const descriptionMatches = item.description ? matchesContiguousSubString(query, item.description) : null; const filenameMatches = matchesContiguousSubString(query, item.filename); + const badgeMatches = item.badge ? matchesContiguousSubString(query, item.badge) : null; - if (nameMatches || descriptionMatches || filenameMatches) { + if (nameMatches || descriptionMatches || filenameMatches || badgeMatches) { matchedItems.push({ ...item, nameMatches: nameMatches || undefined, @@ -1167,17 +1510,24 @@ export class AICustomizationListWidget extends Disposable { } } - // Group items by storage + // Group items — instructions use category-based grouping; other sections use storage-based const promptType = sectionToPromptType(this.currentSection); const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources); - 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'); + const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = + this.currentSection === AICustomizationManagementSection.Instructions + ? [ + { groupKey: 'agent-instructions', label: localize('agentInstructionsGroup', "Agent Instructions"), icon: instructionsIcon, description: localize('agentInstructionsGroupDescription', "Instruction files automatically loaded for all agent interactions (e.g. AGENTS.md, CLAUDE.md, copilot-instructions.md)."), items: [] }, + { groupKey: 'context-instructions', label: localize('contextInstructionsGroup', "Included Based on Context"), icon: instructionsIcon, description: localize('contextInstructionsGroupDescription', "Instructions automatically loaded when matching files are part of the context."), items: [] }, + { groupKey: 'on-demand-instructions', label: localize('onDemandInstructionsGroup', "Loaded on Demand"), icon: instructionsIcon, description: localize('onDemandInstructionsGroupDescription', "Instructions loaded only when explicitly referenced."), items: [] }, + ] + : [ + { 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.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, + { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), 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 key = item.groupKey ?? item.storage; @@ -1282,6 +1632,18 @@ export class AICustomizationListWidget extends Disposable { } } + /** + * Finds the plugin URI for an item URI by checking the known plugins. + */ + private findPluginUri(itemUri: URI): URI | undefined { + for (const plugin of this.agentPluginService.plugins.get()) { + if (isEqualOrParent(itemUri, plugin.uri)) { + return plugin.uri; + } + } + return undefined; + } + private getEmptyStateInfo(): { title: string; description: string } { switch (this.currentSection) { case AICustomizationManagementSection.Agents: 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 28e6a71a85c..ac7d63aba97 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -3,48 +3,52 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Codicon } from '../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { isMacintosh, isWindows } from '../../../../../base/common/platform.js'; +import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { FileSystemProviderCapabilities, IFileService } from '../../../../../platform/files/common/files.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'; -import { IEditorPaneRegistry, EditorPaneDescriptor } from '../../../../browser/editor.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../browser/editor.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../../common/editor.js'; import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; -import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; -import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; +import { AgentPluginItemKind } from '../agentPluginEditor/agentPluginItems.js'; import { - AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, - AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID, + AI_CUSTOMIZATION_ITEM_DISABLED_KEY, AI_CUSTOMIZATION_ITEM_STORAGE_KEY, AI_CUSTOMIZATION_ITEM_TYPE_KEY, AI_CUSTOMIZATION_ITEM_URI_KEY, + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, + AI_CUSTOMIZATION_MANAGEMENT_EDITOR_INPUT_ID, AICustomizationManagementCommands, AICustomizationManagementItemMenuId, AICustomizationManagementSection, BUILTIN_STORAGE, } from './aiCustomizationManagement.js'; -import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; -import { Codicon } from '../../../../../base/common/codicons.js'; -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, FileSystemProviderCapabilities } from '../../../../../platform/files/common/files.js'; -import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { isWindows, isMacintosh } from '../../../../../base/common/platform.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; -import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { AICustomizationManagementEditor } from './aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; //#region Telemetry @@ -152,6 +156,20 @@ function extractPromptType(context: AICustomizationContext): PromptsType | undef return context.promptType; } +/** + * Extracts the parent plugin URI from context, if present. + */ +function extractPluginUri(context: AICustomizationContext): URI | undefined { + if (URI.isUri(context) || typeof context === 'string') { + return undefined; + } + const raw = context.pluginUri; + if (!raw) { + return undefined; + } + return URI.isUri(raw) ? raw : typeof raw === 'string' ? URI.parse(raw) : undefined; +} + // Open file action const OPEN_AI_CUSTOMIZATION_MGMT_FILE_ID = 'aiCustomizationManagement.openFile'; registerAction2(class extends Action2 { @@ -164,9 +182,19 @@ registerAction2(class extends Action2 { } async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { const editorService = accessor.get(IEditorService); - await editorService.openEditor({ + const storage = extractStorage(context); + + const editorPane = await editorService.openEditor({ resource: extractURI(context) }); + + const codeEditor = getCodeEditor(editorPane?.getControl()); + if (codeEditor && (storage === PromptsStorage.extension || storage === PromptsStorage.plugin)) { + codeEditor.updateOptions({ + readOnly: true, + readOnlyMessage: new MarkdownString(localize('readonlyPluginFile', "This file is provided by a plugin or extension and cannot be edited.")), + }); + } } }); @@ -424,6 +452,148 @@ MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { when: WHEN_ITEM_IS_PLUGIN, }); +// Show Plugin action - navigates to the parent plugin detail page +const SHOW_PLUGIN_AI_CUSTOMIZATION_ID = 'aiCustomizationManagement.showPlugin'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: SHOW_PLUGIN_AI_CUSTOMIZATION_ID, + title: localize2('showPlugin', "Show Plugin"), + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const agentPluginService = accessor.get(IAgentPluginService); + const editorService = accessor.get(IEditorService); + + const pluginUri = extractPluginUri(context); + if (!pluginUri) { + return; + } + const plugin = agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUri.toString()); + if (!plugin) { + return; + } + + const item = { + kind: AgentPluginItemKind.Installed as const, + name: plugin.label, + description: plugin.fromMarketplace?.description ?? '', + marketplace: plugin.fromMarketplace?.marketplace, + plugin, + }; + + // Try to show within the active AI Customization editor (with back navigation) + const input = AICustomizationManagementEditorInput.getOrCreate(); + const pane = await editorService.openEditor(input, { pinned: true }); + if (pane instanceof AICustomizationManagementEditor) { + await pane.showPluginDetail(item); + } + } +}); + +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: SHOW_PLUGIN_AI_CUSTOMIZATION_ID, title: localize('showPlugin', "Show Plugin") }, + group: '1_open', + order: 2, + when: WHEN_ITEM_IS_PLUGIN, +}); + +// Disable item action +const DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID = 'aiCustomizationManagement.disableItem'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, + title: localize2('disable', "Disable"), + icon: Codicon.eyeClosed, + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const promptsService = accessor.get(IPromptsService); + const uri = extractURI(context); + const promptType = extractPromptType(context); + if (!promptType) { + return; + } + + const disabled = promptsService.getDisabledPromptFiles(promptType); + disabled.add(uri); + promptsService.setDisabledPromptFiles(promptType, disabled); + } +}); + +// Enable item action +const ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID = 'aiCustomizationManagement.enableItem'; +registerAction2(class extends Action2 { + constructor() { + super({ + id: ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, + title: localize2('enable', "Enable"), + icon: Codicon.eye, + }); + } + async run(accessor: ServicesAccessor, context: AICustomizationContext): Promise { + const promptsService = accessor.get(IPromptsService); + const uri = extractURI(context); + const promptType = extractPromptType(context); + if (!promptType) { + return; + } + + const disabled = promptsService.getDisabledPromptFiles(promptType); + disabled.delete(uri); + promptsService.setDisabledPromptFiles(promptType, disabled); + } +}); + +// Context menu: Disable (shown when builtin item is enabled) +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disable', "Disable") }, + group: '5_toggle', + order: 1, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + ), +}); + +// Context menu: Enable (shown when builtin item is disabled) +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('enable', "Enable") }, + group: '5_toggle', + order: 1, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + ), +}); + +// Inline hover: Disable (shown when builtin item is enabled) +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: DISABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('disable', "Disable"), icon: Codicon.eyeClosed }, + group: 'inline', + order: 5, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, false), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + ), +}); + +// Inline hover: Enable (shown when builtin item is disabled) +MenuRegistry.appendMenuItem(AICustomizationManagementItemMenuId, { + command: { id: ENABLE_AI_CUSTOMIZATION_MGMT_ITEM_ID, title: localize('enable', "Enable"), icon: Codicon.eye }, + group: 'inline', + order: 5, + when: ContextKeyExpr.and( + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_DISABLED_KEY, true), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_STORAGE_KEY, BUILTIN_STORAGE), + ContextKeyExpr.equals(AI_CUSTOMIZATION_ITEM_TYPE_KEY, PromptsType.skill), + ), +}); + //#endregion //#region Actions diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index 254aef807e8..d6439837a8e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -5,23 +5,13 @@ 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'; +export type { AICustomizationPromptsStorage } from '../../common/aiCustomizationWorkspaceService.js'; +export { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; /** * Editor pane ID for the AI Customizations Management Editor. @@ -87,6 +77,16 @@ export const AI_CUSTOMIZATION_ITEM_STORAGE_KEY = 'aiCustomizationManagementItemS */ export const AI_CUSTOMIZATION_ITEM_URI_KEY = 'aiCustomizationManagementItemUri'; +/** + * Context key for the parent plugin URI, set when the item is provided by a plugin. + */ +export const AI_CUSTOMIZATION_ITEM_PLUGIN_URI_KEY = 'aiCustomizationManagementItemPluginUri'; + +/** + * Context key indicating whether the item is disabled. + */ +export const AI_CUSTOMIZATION_ITEM_DISABLED_KEY = 'aiCustomizationManagementItemDisabled'; + /** * Storage key for persisting the selected section. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 0ee3bc0f260..2896cbbef08 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -74,6 +74,8 @@ import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { Action } from '../../../../../base/common/actions.js'; import { McpServerEditorInput } from '../../../mcp/browser/mcpServerEditorInput.js'; import { McpServerEditor } from '../../../mcp/browser/mcpServerEditor.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; @@ -81,7 +83,8 @@ 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'; -import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, CustomizationHarness, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { ChatConfiguration } from '../../common/constants.js'; const $ = DOM.$; @@ -286,6 +289,8 @@ export class AICustomizationManagementEditor extends EditorPane { private pluginDetailContainer: HTMLElement | undefined; private embeddedPluginEditor: AgentPluginEditor | undefined; private readonly pluginDetailDisposables = this._register(new DisposableStore()); + /** Section to restore when navigating back from plugin detail (when opened from a non-plugin section). */ + private pluginDetailReturnSection: AICustomizationManagementSection | undefined; private dimension: DOM.Dimension | undefined; private readonly sections: ISectionItem[] = []; @@ -302,6 +307,7 @@ export class AICustomizationManagementEditor extends EditorPane { private folderPickerClearButton: HTMLElement | undefined; // Harness dropdown + private harnessDropdownContainer: HTMLElement | undefined; private harnessDropdownButton: HTMLElement | undefined; private harnessDropdownIcon: HTMLElement | undefined; private harnessDropdownLabel: HTMLElement | undefined; @@ -327,6 +333,7 @@ export class AICustomizationManagementEditor extends EditorPane { @IHoverService private readonly hoverService: IHoverService, @IModelService private readonly modelService: IModelService, @IQuickInputService private readonly quickInputService: IQuickInputService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, @IFileService private readonly fileService: IFileService, @INotificationService private readonly notificationService: INotificationService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @@ -462,15 +469,28 @@ export class AICustomizationManagementEditor extends EditorPane { })); } + /** + * Whether the harness selector UI is enabled. + * When disabled, the editor behaves as if "Local" is always selected. + */ + private get isHarnessSelectorEnabled(): boolean { + return this.configurationService.getValue(ChatConfiguration.ChatCustomizationHarnessSelectorEnabled) !== false; + } + /** * Rebuilds the visible sections list based on the active harness's * `hiddenSections`. If the current selection falls into a hidden * section, the first visible section is selected instead. */ private rebuildVisibleSections(): void { - const activeId = this.harnessService.activeHarness.get(); - const descriptor = this.harnessService.availableHarnesses.get().find(h => h.id === activeId); - const hidden = new Set(descriptor?.hiddenSections ?? []); + let hidden: Set; + if (this.isHarnessSelectorEnabled) { + const activeId = this.harnessService.activeHarness.get(); + const descriptor = this.harnessService.availableHarnesses.get().find(h => h.id === activeId); + hidden = new Set(descriptor?.hiddenSections ?? []); + } else { + hidden = new Set(); // Local harness has no hidden sections + } this.sections.length = 0; for (const s of this.allSections) { @@ -533,11 +553,36 @@ export class AICustomizationManagementEditor extends EditorPane { this.selectSection(e.elements[0].id); })); - // React to harness changes — rebuild visible sections + // React to harness changes — rebuild visible sections and refresh counts. + // Also track availableHarnesses to handle agent registration/unregistration. this.editorDisposables.add(autorun(reader => { - this.harnessService.activeHarness.read(reader); + const available = this.harnessService.availableHarnesses.read(reader); + const activeId = this.harnessService.activeHarness.read(reader); + + // If the active harness is no longer available, fall back to the default + if (!available.some(h => h.id === activeId) && available.length > 0) { + this.harnessService.setActiveHarness(available[0].id); + return; // setActiveHarness will trigger another autorun cycle + } + this.rebuildVisibleSections(); this.updateHarnessDropdown(); + this.refreshAllPromptsSectionCounts(); + })); + + // When the harness selector setting is off, lock to Local harness. + // In Sessions (single CLI harness) the dropdown is already hidden and + // setActiveHarness(VSCode) is a safe no-op since the CLI harness + // remains active — filtering stays correct for that window. + if (!this.isHarnessSelectorEnabled) { + this.harnessService.setActiveHarness(CustomizationHarness.VSCode); + } + this.editorDisposables.add(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.ChatCustomizationHarnessSelectorEnabled)) { + if (!this.isHarnessSelectorEnabled) { + this.harnessService.setActiveHarness(CustomizationHarness.VSCode); + } + } })); // Folder picker (sessions window only) @@ -547,12 +592,11 @@ export class AICustomizationManagementEditor extends EditorPane { } private createHarnessDropdown(sidebarContent: HTMLElement): void { - const harnesses = this.harnessService.availableHarnesses.get(); - if (harnesses.length <= 1) { + if (!this.isHarnessSelectorEnabled) { return; } - const container = DOM.append(sidebarContent, $('.sidebar-harness-dropdown')); + const container = this.harnessDropdownContainer = DOM.append(sidebarContent, $('.sidebar-harness-dropdown')); this.harnessDropdownButton = DOM.append(container, $('button.harness-dropdown-button')); this.harnessDropdownButton.setAttribute('aria-label', localize('selectHarness', "Select customization target")); @@ -565,16 +609,20 @@ export class AICustomizationManagementEditor extends EditorPane { this.updateHarnessDropdown(); this.editorDisposables.add(DOM.addDisposableListener(this.harnessDropdownButton, 'click', () => { - this.showHarnessPicker(); + this.showHarnessMenu(); })); } private updateHarnessDropdown(): void { - if (!this.harnessDropdownIcon || !this.harnessDropdownLabel) { + if (!this.harnessDropdownContainer || !this.harnessDropdownIcon || !this.harnessDropdownLabel) { return; } + const harnesses = this.harnessService.availableHarnesses.get(); + // Hide dropdown when only one harness is available + this.harnessDropdownContainer.style.display = harnesses.length <= 1 ? 'none' : ''; + const activeId = this.harnessService.activeHarness.get(); - const descriptor = this.harnessService.availableHarnesses.get().find(h => h.id === activeId); + const descriptor = harnesses.find(h => h.id === activeId); if (descriptor) { this.harnessDropdownIcon.className = 'harness-dropdown-icon'; this.harnessDropdownIcon.classList.add(...ThemeIcon.asClassNameArray(descriptor.icon)); @@ -582,31 +630,26 @@ export class AICustomizationManagementEditor extends EditorPane { } } - private showHarnessPicker(): void { + private showHarnessMenu(): void { + if (!this.harnessDropdownButton) { + return; + } const harnesses = this.harnessService.availableHarnesses.get(); const activeId = this.harnessService.activeHarness.get(); - const items = harnesses.map(h => ({ - label: h.label, - iconClass: ThemeIcon.asClassName(h.icon), - id: h.id, - picked: h.id === activeId, - })); - - const picker = this.quickInputService.createQuickPick(); - picker.items = items; - picker.placeholder = localize('selectTarget', "Select customization target"); - picker.canSelectMany = false; - picker.activeItems = items.filter(i => i.picked); - picker.onDidAccept(() => { - const selected = picker.activeItems[0] as typeof items[0] | undefined; - if (selected) { - this.harnessService.setActiveHarness(selected.id); - } - picker.dispose(); + const actions = harnesses.map(h => { + const action = new Action(h.id, h.label, ThemeIcon.asClassName(h.icon), true, () => { + this.harnessService.setActiveHarness(h.id); + }); + action.checked = h.id === activeId; + return action; + }); + + this.contextMenuService.showContextMenu({ + getAnchor: () => this.harnessDropdownButton!, + getActions: () => actions, + getCheckedActionsRepresentation: () => 'radio', }); - picker.onDidHide(() => picker.dispose()); - picker.show(); } private createFolderPicker(sidebarContent: HTMLElement): void { @@ -696,8 +739,8 @@ export class AICustomizationManagementEditor extends EditorPane { })); // Handle manual create actions - open editor directly - this.editorDisposables.add(this.listWidget.onDidRequestCreateManual(({ type, target }) => { - this.createNewItemManual(type, target); + this.editorDisposables.add(this.listWidget.onDidRequestCreateManual(({ type, target, rootFileName }) => { + this.createNewItemManual(type, target, rootFileName); })); // Container for Models content (only in sessions) @@ -732,6 +775,10 @@ export class AICustomizationManagementEditor extends EditorPane { this.editorDisposables.add(this.mcpListWidget.onDidSelectServer(server => { this.showEmbeddedMcpDetail(server); })); + + this.editorDisposables.add(this.mcpListWidget.onDidRequestShowPlugin(item => { + this.showPluginDetail(item); + })); } // Container for Plugins content @@ -745,6 +792,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.createEmbeddedPluginDetail(); this.editorDisposables.add(this.pluginListWidget.onDidSelectPlugin(item => { + this.pluginDetailReturnSection = undefined; this.showEmbeddedPluginDetail(item); })); } @@ -914,7 +962,9 @@ export class AICustomizationManagementEditor extends EditorPane { const isMcpSection = this.selectedSection === AICustomizationManagementSection.McpServers; const isPluginsSection = this.selectedSection === AICustomizationManagementSection.Plugins; - this.promptsContentContainer.style.display = !isEditorMode && !isDetailMode && isPromptsSection ? '' : 'none'; + if (this.promptsContentContainer) { + this.promptsContentContainer.style.display = !isEditorMode && !isDetailMode && isPromptsSection ? '' : 'none'; + } if (this.modelsContentContainer) { this.modelsContentContainer.style.display = !isEditorMode && !isDetailMode && isModelsSection ? '' : 'none'; } @@ -962,7 +1012,7 @@ export class AICustomizationManagementEditor extends EditorPane { /** * Creates a new prompt file and opens it in the embedded editor. */ - private async createNewItemManual(type: PromptsType, target: 'workspace' | 'user' | 'workspace-root'): Promise { + private async createNewItemManual(type: PromptsType, target: 'workspace' | 'user' | 'workspace-root', rootFileName?: string): Promise { this.telemetryService.publicLog2('chatCustomizationEditor.createItem', { section: this.selectedSection, promptType: type, @@ -970,19 +1020,23 @@ export class AICustomizationManagementEditor extends EditorPane { target: target === 'workspace-root' ? 'workspace' : target, }); - // Handle workspace-root files (e.g. AGENTS.md at project root) + // Handle workspace-root files (e.g. AGENTS.md or CLAUDE.md at project root). + // rootFileName is passed from rootFileShortcuts; falls back to + // the section override's rootFile, then AGENTS.md as the default. if (target === 'workspace-root') { const projectRoot = this.workspaceService.getActiveProjectRoot(); if (!projectRoot) { return; } - const fileUri = URI.joinPath(projectRoot, AGENT_MD_FILENAME); + const override = this.harnessService.getActiveDescriptor().sectionOverrides?.get(this.selectedSection); + const fileName = rootFileName ?? override?.rootFile ?? AGENT_MD_FILENAME; + const fileUri = URI.joinPath(projectRoot, fileName); if (await this.fileService.exists(fileUri)) { // File already exists — just open it - await this.showEmbeddedEditor(fileUri, AGENT_MD_FILENAME, PromptsType.instructions, PromptsStorage.local, true); + await this.showEmbeddedEditor(fileUri, fileName, PromptsType.instructions, PromptsStorage.local, true); } else { await this.fileService.createFile(fileUri); - await this.showEmbeddedEditor(fileUri, AGENT_MD_FILENAME, PromptsType.instructions, PromptsStorage.local, true); + await this.showEmbeddedEditor(fileUri, fileName, PromptsType.instructions, PromptsStorage.local, true); } void this.listWidget.refresh(); return; @@ -1019,9 +1073,15 @@ export class AICustomizationManagementEditor extends EditorPane { // Pass it through — the command handles undefined by showing its own // folder picker via askForPromptSourceFolder. + // When the active harness overrides the file extension (e.g. Claude + // rules use .md instead of .instructions.md), pass it through so the + // name picker and file creation use the correct extension. + const override = this.harnessService.getActiveDescriptor().sectionOverrides?.get(this.selectedSection); + const options: INewPromptOptions = { targetFolder: targetDir, targetStorage: target === 'user' ? PromptsStorage.user : PromptsStorage.local, + fileExtension: override?.fileExtension, openFile: async (uri) => { const isWorkspace = target === 'workspace'; await this.showEmbeddedEditor(uri, basename(uri), type, target === 'user' ? PromptsStorage.user : PromptsStorage.local, isWorkspace); @@ -1053,6 +1113,8 @@ export class AICustomizationManagementEditor extends EditorPane { private async resolveTargetDirectoryWithPicker(type: PromptsType, target: 'workspace' | 'user'): Promise { const allFolders = await this.promptsService.getSourceFolders(type); const projectRoot = this.workspaceService.getActiveProjectRoot(); + const descriptor = this.harnessService.getActiveDescriptor(); + const subpaths = descriptor.workspaceSubpaths; // Partition folders by whether they're under the active project root. // The storage tags from getSourceFolders() are unreliable (tilde-expanded @@ -1061,12 +1123,33 @@ export class AICustomizationManagementEditor extends EditorPane { let matchingFolders; if (target === 'workspace') { matchingFolders = projectRoot - ? allFolders.filter(f => isEqualOrParent(f.uri, projectRoot)) + ? allFolders.filter(f => { + if (!isEqualOrParent(f.uri, projectRoot)) { + return false; + } + // When the active harness specifies workspaceSubpaths, only offer + // directories whose path includes one of those sub-paths. + if (subpaths) { + return matchesWorkspaceSubpath(f.uri.path, subpaths); + } + return true; + }) : []; } else { matchingFolders = projectRoot ? allFolders.filter(f => !isEqualOrParent(f.uri, projectRoot)) : allFolders; + + // When the active harness restricts user roots, only offer + // directories under the harness-accessible user roots + // (e.g. Claude → ~/.claude only, not ~/.copilot or profile paths). + const filter = this.harnessService.getStorageSourceFilter(type); + if (filter.includedUserFileRoots) { + const roots = filter.includedUserFileRoots; + matchingFolders = matchingFolders.filter(f => + roots.some(root => isEqualOrParent(f.uri, root)) + ); + } } // Deduplicate by URI (getSourceFolders may return the same path @@ -1716,16 +1799,40 @@ export class AICustomizationManagementEditor extends EditorPane { } } + /** + * Public method to show a plugin detail from any section (e.g. from "Show Plugin" context menu). + * Saves the current section so the back button returns the user to it. + */ + public async showPluginDetail(item: IAgentPluginItem): Promise { + if (this.selectedSection !== AICustomizationManagementSection.Plugins) { + this.pluginDetailReturnSection = this.selectedSection; + } + await this.showEmbeddedPluginDetail(item); + } + private goBackFromPluginDetail(): void { this.pluginDetailDisposables.clear(); this.embeddedPluginEditor?.clearInput(); - this.viewMode = 'list'; - this.updateContentVisibility(); + + const returnSection = this.pluginDetailReturnSection; + this.pluginDetailReturnSection = undefined; + + if (returnSection) { + // Return to the section the user was on before opening the plugin detail. + // selectSection may early-return when the section hasn't changed, so always + // ensure viewMode and content visibility are updated. + this.viewMode = 'list'; + this.updateContentVisibility(); + this.selectSection(returnSection); + } else { + this.viewMode = 'list'; + this.updateContentVisibility(); + this.pluginListWidget?.focusSearch(); + } if (this.dimension) { this.layout(this.dimension); } - this.pluginListWidget?.focusSearch(); } //#endregion diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts index 1d22914d9c2..cec5c423ff0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts @@ -15,7 +15,7 @@ import { isEqualOrParent } from '../../../../../base/common/resources.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { localize } from '../../../../../nls.js'; -import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; /** * Service that opens an AI-guided chat session to help the user create @@ -131,8 +131,7 @@ export class CustomizationCreatorService { } seen.add(key); if (subpaths) { - const path = f.uri.path; - return subpaths.some(sp => path.includes(sp)); + return matchesWorkspaceSubpath(f.uri.path, subpaths); } return true; }) diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts index d85c205b892..3f03253741d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationHarnessService.ts @@ -3,11 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { derived, observableFromEvent } from '../../../../../base/common/observable.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { CustomizationHarness, CustomizationHarnessServiceBase, ICustomizationHarnessService, + IHarnessDescriptor, createCliHarnessDescriptor, createClaudeHarnessDescriptor, createVSCodeHarnessDescriptor, @@ -15,25 +17,52 @@ import { getClaudeUserRoots, } from '../../common/customizationHarnessService.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { BUILTIN_STORAGE } from '../../common/aiCustomizationWorkspaceService.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; +import { IChatAgentService } from '../../common/participants/chatAgents.js'; /** * Core implementation of the customization harness service. * Exposes VS Code, CLI, and Claude harnesses for filtering customizations. + * CLI and Claude harnesses are only shown when their respective agents are registered. */ class CustomizationHarnessService extends CustomizationHarnessServiceBase { constructor( @IPathService pathService: IPathService, + @IChatAgentService chatAgentService: IChatAgentService, ) { const userHome = pathService.userHome({ preferLocal: true }); - const extras = [PromptsStorage.extension]; + // The Local harness includes extension-contributed and built-in customizations. + // Built-in items come from the default chat extension (productService.defaultChatAgent). + // CLI and Claude harnesses don't consume extension contributions. + const localExtras = [PromptsStorage.extension, BUILTIN_STORAGE]; + const restrictedExtras: readonly string[] = []; + const allHarnesses: readonly IHarnessDescriptor[] = [ + createVSCodeHarnessDescriptor(localExtras), + createCliHarnessDescriptor(getCliUserRoots(userHome), restrictedExtras), + createClaudeHarnessDescriptor(getClaudeUserRoots(userHome), restrictedExtras), + ]; + + // Track agent registration changes as an observable. + // Return the agent count so the value changes on each event + // (observableFromEvent uses strictEquals to decide whether to notify). + const agentCount = observableFromEvent(chatAgentService.onDidChangeAgents, () => chatAgentService.getAgents().length); + + // Derive available harnesses from agent registration state + const available = derived(reader => { + agentCount.read(reader); + return allHarnesses.filter(h => { + if (!h.requiredAgentId) { + return true; + } + return !!chatAgentService.getAgent(h.requiredAgentId); + }); + }); + super( - [ - createVSCodeHarnessDescriptor(extras), - createCliHarnessDescriptor(getCliUserRoots(userHome), extras), - createClaudeHarnessDescriptor(getClaudeUserRoots(userHome), extras), - ], + allHarnesses, CustomizationHarness.VSCode, + available, ); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 2806ac06f6d..f809954da8a 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -16,7 +16,9 @@ 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 { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, McpServerInstallState, IMcpService } from '../../../../contrib/mcp/common/mcpTypes.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, McpServerInstallState, IMcpService, IMcpServer } from '../../../../contrib/mcp/common/mcpTypes.js'; +import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { isContributionDisabled } from '../../common/enablement.js'; import { McpCommandIds } from '../../../../contrib/mcp/common/mcpCommandIds.js'; import { autorun } from '../../../../../base/common/observable.js'; @@ -31,22 +33,36 @@ import { getContextMenuActions } from '../../../../contrib/mcp/browser/mcpServer import { LocalMcpServerScope } from '../../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { workspaceIcon, userIcon, mcpServerIcon, builtinIcon } from './aiCustomizationIcons.js'; +import { workspaceIcon, userIcon, mcpServerIcon, builtinIcon, pluginIcon, extensionIcon } from './aiCustomizationIcons.js'; import { formatDisplayName, truncateToFirstSentence } from './aiCustomizationListWidget.js'; import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { ICustomizationHarnessService, CustomizationHarness } from '../../common/customizationHarnessService.js'; import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; +import { AgentPluginItemKind, IAgentPluginItem } from '../agentPluginEditor/agentPluginItems.js'; const $ = DOM.$; const MCP_ITEM_HEIGHT = 36; +const PLUGIN_COLLECTION_PREFIX = 'plugin.'; + +const COPILOT_EXTENSION_IDS = ['github.copilot', 'github.copilot-chat']; + +function isCopilotExtension(id: ExtensionIdentifier): boolean { + return COPILOT_EXTENSION_IDS.some(copilotId => ExtensionIdentifier.equals(id, copilotId)); +} + +function getPluginUriFromCollectionId(collectionId: string | undefined): string | undefined { + return collectionId?.startsWith(PLUGIN_COLLECTION_PREFIX) ? collectionId.slice(PLUGIN_COLLECTION_PREFIX.length) : undefined; +} + /** * Represents a collapsible group header in the MCP server list. */ interface IMcpGroupHeaderEntry extends ICustomizationGroupHeaderEntry { - readonly scope: LocalMcpServerScope | 'builtin'; + readonly scope: LocalMcpServerScope | 'builtin' | 'plugin' | 'extension'; } /** @@ -102,6 +118,7 @@ interface IMcpServerItemTemplateData { readonly name: HTMLElement; readonly description: HTMLElement; readonly status: HTMLElement; + readonly bridgedBadge: HTMLElement; readonly disposables: DisposableStore; } @@ -114,6 +131,9 @@ class McpServerItemRenderer implements IListRenderer { + const activeId = this.harnessService.activeHarness.read(reader); + templateData.bridgedBadge.style.display = activeId !== CustomizationHarness.VSCode ? '' : 'none'; + })); + templateData.disposables.add(this.hoverService.setupManagedHover( + getDefaultHoverDelegate('mouse'), + templateData.bridgedBadge, + localize('bridgedHover', "This server is managed by VS Code and forwarded to all compatible agent sessions."), + )); + if (element.type === 'builtin-item') { templateData.container.classList.add('builtin'); templateData.name.textContent = formatDisplayName(element.label); @@ -144,6 +180,21 @@ class McpServerItemRenderer implements IListRenderer { + const plugin = this.agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUriStr); + if (plugin) { + return { + content: `${element.label}\n${localize('fromPlugin', "Plugin: {0}", plugin.label)}`, + appearance: { compact: true, skipFadeInAnimation: true }, + }; + } + return { content: element.label, appearance: { compact: true, skipFadeInAnimation: true } }; + })); + } return; } @@ -308,6 +359,9 @@ export class McpListWidget extends Disposable { private readonly _onDidChangeItemCount = this._register(new Emitter()); readonly onDidChangeItemCount = this._onDidChangeItemCount.event; + private readonly _onDidRequestShowPlugin = this._register(new Emitter()); + readonly onDidRequestShowPlugin = this._onDidRequestShowPlugin.event; + private sectionHeader!: HTMLElement; private sectionDescription!: HTMLElement; private sectionLink!: HTMLAnchorElement; @@ -337,6 +391,7 @@ export class McpListWidget extends Disposable { @IInstantiationService private readonly instantiationService: IInstantiationService, @IMcpWorkbenchService private readonly mcpWorkbenchService: IMcpWorkbenchService, @IMcpService private readonly mcpService: IMcpService, + @IMcpRegistry private readonly mcpRegistry: IMcpRegistry, @ICommandService private readonly commandService: ICommandService, @IOpenerService private readonly openerService: IOpenerService, @IContextViewService private readonly contextViewService: IContextViewService, @@ -423,9 +478,10 @@ export class McpListWidget extends Disposable { // Empty state this.emptyContainer = DOM.append(this.element, $('.mcp-empty-state')); - const emptyIcon = DOM.append(this.emptyContainer, $('.empty-icon')); + const emptyHeader = DOM.append(this.emptyContainer, $('.empty-state-header')); + const emptyIcon = DOM.append(emptyHeader, $('.empty-icon')); emptyIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.server)); - this.emptyText = DOM.append(this.emptyContainer, $('.empty-text')); + this.emptyText = DOM.append(emptyHeader, $('.empty-text')); this.emptySubtext = DOM.append(this.emptyContainer, $('.empty-subtext')); // List container @@ -687,22 +743,39 @@ export class McpListWidget extends Disposable { isFirst = false; } - // Add built-in / extension-provided servers - if (builtinServers.length > 0) { - const collapsed = this.collapsedGroups.has('builtin'); + // Add plugin-provided, extension-provided, and built-in servers. + // Servers from the Copilot extension (github.copilot / github.copilot-chat) + // are treated as built-in; servers from other extensions go under "Extensions". + const collectionSources = new Map(this.mcpRegistry.collections.get().map(c => [c.id, c.source])); + const pluginServers: IMcpServer[] = []; + const extensionServers: IMcpServer[] = []; + const otherBuiltinServers: IMcpServer[] = []; + for (const server of builtinServers) { + const source = collectionSources.get(server.collection.id); + if (server.collection.id.startsWith(PLUGIN_COLLECTION_PREFIX)) { + pluginServers.push(server); + } else if (source instanceof ExtensionIdentifier && !isCopilotExtension(source)) { + extensionServers.push(server); + } else { + otherBuiltinServers.push(server); + } + } + + if (pluginServers.length > 0) { + const collapsed = this.collapsedGroups.has('plugin'); entries.push({ type: 'group-header', - id: 'mcp-group-builtin', - scope: 'builtin', - label: localize('builtInGroup', "Built-in"), - icon: builtinIcon, - count: builtinServers.length, + id: 'mcp-group-plugin', + scope: 'plugin', + label: localize('pluginGroup', "Plugins"), + icon: pluginIcon, + count: pluginServers.length, isFirst, - description: localize('builtInGroupDescription', "MCP servers built into VS Code. These are available automatically."), + description: localize('pluginGroupDescription', "MCP servers provided by installed plugins."), collapsed, }); if (!collapsed) { - for (const server of builtinServers) { + for (const server of pluginServers) { entries.push({ type: 'builtin-item', id: `builtin-${server.definition.id}`, @@ -712,6 +785,61 @@ export class McpListWidget extends Disposable { }); } } + isFirst = false; + } + + if (extensionServers.length > 0) { + const collapsed = this.collapsedGroups.has('extension'); + entries.push({ + type: 'group-header', + id: 'mcp-group-extension', + scope: 'extension', + label: localize('extensionGroup', "Extensions"), + icon: extensionIcon, + count: extensionServers.length, + isFirst, + description: localize('extensionGroupDescription', "MCP servers contributed by installed VS Code extensions."), + collapsed, + }); + if (!collapsed) { + for (const server of extensionServers) { + entries.push({ + type: 'builtin-item', + id: `builtin-${server.definition.id}`, + label: server.definition.label, + description: '', + collectionId: server.collection.id, + }); + } + } + isFirst = false; + } + + if (otherBuiltinServers.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: otherBuiltinServers.length, + isFirst, + description: localize('builtInGroupDescription', "MCP servers built into VS Code. These are available automatically."), + collapsed, + }); + if (!collapsed) { + for (const server of otherBuiltinServers) { + entries.push({ + type: 'builtin-item', + id: `builtin-${server.definition.id}`, + label: server.definition.label, + description: '', + collectionId: server.collection.id, + }); + } + } + isFirst = false; } this.displayEntries = entries; @@ -807,16 +935,32 @@ export class McpListWidget extends Disposable { // Plugin-provided builtin items get an "Uninstall Plugin" context menu if (e.element.type === 'builtin-item') { const collectionId = e.element.collectionId; - if (!collectionId?.startsWith('plugin.')) { + const pluginUriStr = getPluginUriFromCollectionId(collectionId); + if (!pluginUriStr) { return; } - const pluginUriStr = collectionId.slice('plugin.'.length); const plugin = this.agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUriStr); if (!plugin) { return; } const disposables = new DisposableStore(); + const showPluginAction = disposables.add(new Action( + 'mcpServer.showPlugin', + localize('showPlugin', "Show Plugin"), + undefined, + true, + async () => { + const item = { + kind: AgentPluginItemKind.Installed as const, + name: plugin.label, + description: plugin.fromMarketplace?.description ?? '', + marketplace: plugin.fromMarketplace?.marketplace, + plugin, + }; + this._onDidRequestShowPlugin.fire(item); + } + )); const uninstallAction = disposables.add(new Action( 'mcpServer.uninstallPlugin', localize('uninstallPlugin', "Uninstall Plugin"), @@ -837,7 +981,7 @@ export class McpListWidget extends Disposable { this.contextMenuService.showContextMenu({ getAnchor: () => e.anchor, - getActions: () => [uninstallAction], + getActions: () => [showPluginAction, uninstallAction], onHide: () => disposables.dispose(), }); return; 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 7ff9bf38bae..b4e17ae435e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -53,7 +53,7 @@ background: transparent; color: var(--vscode-descriptionForeground); cursor: pointer; - font-size: 11px; + font-size: 13px; } .ai-customization-management-editor .folder-picker-button:hover { @@ -143,9 +143,7 @@ flex-shrink: 0; font-size: 10px; font-weight: 500; - background-color: var(--vscode-list-inactiveSelectionBackground); - color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); - padding: 0 5px; + color: var(--vscode-descriptionForeground); border-radius: 8px; min-width: 14px; text-align: center; @@ -276,7 +274,7 @@ align-items: center; justify-content: center; padding: 48px 24px; - gap: 12px; + gap: 8px; text-align: center; flex: 1; } @@ -296,7 +294,7 @@ .ai-customization-list-widget .list-empty-state .empty-state-subtext { font-size: 13px; color: var(--vscode-descriptionForeground); - max-width: 300px; + max-width: 250px; line-height: 1.4; } @@ -309,16 +307,16 @@ display: flex; align-items: center; gap: 6px; - padding: 8px 8px 4px 8px; + padding: 4px 8px; cursor: pointer; user-select: none; border-radius: 4px; } /* Spacing above non-first group headers */ -.ai-customization-group-header.has-previous-group { - padding-top: 12px; -} +/* .ai-customization-group-header.has-previous-group { + margin-top: 8px; +} */ .ai-customization-group-header:hover { background-color: var(--vscode-list-hoverBackground); @@ -336,13 +334,7 @@ } .ai-customization-group-header .group-icon { - flex-shrink: 0; - width: 16px; - height: 16px; - display: flex; - align-items: center; - justify-content: center; - opacity: 0.8; + display: none; } .ai-customization-group-header .group-label-group { @@ -355,9 +347,8 @@ } .ai-customization-group-header .group-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; + font-weight: 500; + text-transform: capitalize; letter-spacing: 0.04em; color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-foreground)); overflow: hidden; @@ -369,9 +360,7 @@ flex-shrink: 0; font-size: 10px; font-weight: 500; - background-color: var(--vscode-list-inactiveSelectionBackground); - color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); - padding: 0 5px; + color: var(--vscode-descriptionForeground); border-radius: 8px; min-width: 14px; text-align: center; @@ -417,6 +406,10 @@ background-color: var(--vscode-list-hoverBackground); } +.ai-customization-list-item.disabled { + opacity: 0.5; +} + .ai-customization-list-item .item-left { display: flex; align-items: center; @@ -459,6 +452,25 @@ opacity: 0.6; } +/* Shared inline badge style — used by MCP "Bridged" badge and item badges */ +.inline-badge { + flex-shrink: 0; + font-size: 10px; + padding: 0 4px; + border-radius: 3px; + color: var(--vscode-descriptionForeground); + background: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31)); + white-space: nowrap; + line-height: 16px; +} + +/* MCP bridged badge — shown inline next to the server name */ +.mcp-server-item .mcp-server-name-row { + display: flex; + align-items: center; + gap: 6px; +} + .ai-customization-list-item .item-type-icon { flex-shrink: 0; width: 16px; @@ -478,6 +490,13 @@ min-width: 0; } +.ai-customization-list-item .item-name-row { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; +} + .ai-customization-list-item .item-name { font-size: 13px; overflow: hidden; @@ -743,7 +762,7 @@ align-items: center; justify-content: center; padding: 48px 24px; - gap: 12px; + gap: 8px; text-align: center; flex: 1; } @@ -763,7 +782,7 @@ .mcp-list-widget .mcp-empty-state .empty-subtext { font-size: 13px; color: var(--vscode-descriptionForeground); - max-width: 300px; + max-width: 250px; line-height: 1.4; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index ca9efd9b6d5..98223af757b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -387,9 +387,10 @@ export class PluginListWidget extends Disposable { // Empty state this.emptyContainer = DOM.append(this.element, $('.mcp-empty-state')); - const emptyIcon = DOM.append(this.emptyContainer, $('.empty-icon')); + const emptyHeader = DOM.append(this.emptyContainer, $('.empty-state-header')); + const emptyIcon = DOM.append(emptyHeader, $('.empty-icon')); emptyIcon.classList.add(...ThemeIcon.asClassNameArray(pluginIcon)); - this.emptyText = DOM.append(this.emptyContainer, $('.empty-text')); + this.emptyText = DOM.append(emptyHeader, $('.empty-text')); this.emptySubtext = DOM.append(this.emptyContainer, $('.empty-subtext')); // List container diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index ab219b9bd1e..849d78a520a 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -454,8 +454,8 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { this.element.ariaLabel = this.appendDeletionHint(ariaLabel); // Wire up click + keyboard (Enter/Space) open handlers - const canOpenCarousel = attachment.value instanceof Uint8Array && configurationService.getValue(ChatConfiguration.ImageCarouselEnabled); - if ((imageData && canOpenCarousel) || resource) { + const canOpenCarousel = !!imageData && configurationService.getValue(ChatConfiguration.ImageCarouselEnabled); + if (canOpenCarousel || resource) { this.element.style.cursor = 'pointer'; this._register(registerOpenEditorListeners(this.element, async () => { await clickHandler(); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 5f019512289..6ab781a848d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -9,7 +9,6 @@ import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { PolicyCategory } from '../../../../base/common/policy.js'; import { AgentHostEnabledSettingId } from '../../../../platform/agentHost/common/agentService.js'; -import { RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; @@ -45,6 +44,7 @@ import { IChatService } from '../common/chatService/chatService.js'; import { ChatService } from '../common/chatService/chatServiceImpl.js'; import { IChatSessionsService } from '../common/chatSessionsService.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; +import { ChatArtifactsService, IChatArtifactsService } from '../common/tools/chatArtifactsService.js'; import { ChatTodoListService, IChatTodoListService } from '../common/tools/chatTodoListService.js'; import { ChatTransferService, IChatTransferService } from '../common/model/chatTransferService.js'; import { IChatVariablesService } from '../common/attachments/chatVariables.js'; @@ -237,9 +237,15 @@ configurationRegistry.registerConfiguration({ default: 0 }, [ChatConfiguration.AgentStatusEnabled]: { - type: 'boolean', - markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls whether the 'Agent Status' indicator is shown in the title bar command center. Enabling this setting will automatically enable {0}. The unread/in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), - default: true, + type: 'string', + enum: ['hidden', 'badge', 'compact'], + enumDescriptions: [ + nls.localize('chat.agentsControl.hidden', "The agent status indicator is hidden from the title bar."), + nls.localize('chat.agentsControl.badge', "Shows the agent status as a badge next to the command center."), + nls.localize('chat.agentsControl.compact', "Replaces the command center search box with a compact agent status indicator and unified chat widget."), + ], + markdownDescription: nls.localize('chat.agentsControl.enabled', "Controls how the 'Agent Status' indicator appears in the title bar command center. When set to `hidden`, the indicator is not shown. Other values show the indicator and automatically enable {0}. The unread and in-progress session indicators require {1} to be enabled.", '`#window.commandCenter#`', '`#chat.viewSessions.enabled#`'), + default: 'compact', tags: ['experimental'] }, [ChatConfiguration.UnifiedAgentsBar]: { @@ -308,6 +314,11 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.RevealNextChangeOnResolve]: { + type: 'boolean', + markdownDescription: nls.localize('chat.editing.revealNextChangeOnResolve', "Controls whether the editor automatically reveals the next change after keeping or undoing a chat edit."), + default: true, + }, 'chat.tips.enabled': { type: 'boolean', scope: ConfigurationScope.APPLICATION, @@ -487,11 +498,11 @@ configurationRegistry.registerConfiguration({ type: 'boolean', tags: ['experimental'] }, - [ChatConfiguration.ImageCarouselEnabled]: { + [ChatConfiguration.ArtifactsEnabled]: { default: false, - description: nls.localize('chat.imageCarousel.enabled', "Controls whether clicking an image attachment in chat opens the image carousel viewer."), + description: nls.localize('chat.artifacts.enabled', "Controls whether the artifacts view is available in chat."), type: 'boolean', - tags: ['preview'] + tags: ['experimental'] }, 'chat.undoRequests.restoreInput': { default: true, @@ -716,23 +727,7 @@ configurationRegistry.registerConfiguration({ type: 'boolean', description: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process."), default: false, - tags: ['experimental'], - included: product.quality !== 'stable', - }, - [RemoteAgentHostsSettingId]: { - type: 'array', - items: { - type: 'object', - properties: { - address: { type: 'string', description: nls.localize('chat.remoteAgentHosts.address', "The address of the remote agent host (e.g. \"localhost:3000\").") }, - name: { type: 'string', description: nls.localize('chat.remoteAgentHosts.name', "A display name for this remote agent host.") }, - connectionToken: { type: 'string', description: nls.localize('chat.remoteAgentHosts.connectionToken', "An optional connection token for authenticating with the remote agent host.") }, - }, - required: ['address', 'name'], - }, - description: nls.localize('chat.remoteAgentHosts', "A list of remote agent host addresses to connect to (e.g. \"localhost:3000\")."), - default: [], - tags: ['experimental'], + tags: ['experimental', 'advanced'], included: product.quality !== 'stable', }, [ChatConfiguration.PlanAgentDefaultModel]: { @@ -1312,10 +1307,26 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + [ChatConfiguration.SubagentsMaxDepth]: { + type: 'number', + description: nls.localize('chat.subagents.maxDepth', "Maximum nesting depth for subagents. Set to 0 to disable nested subagents. A subagent at this depth will not be able to launch further subagents."), + default: 0, + minimum: 0, + maximum: 20, + experiment: { + mode: 'auto' + } + }, [ChatConfiguration.ChatCustomizationMenuEnabled]: { type: 'boolean', tags: ['preview'], - description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customizations editor is available in the Command Palette. When disabled, the Chat Customizations editor and related commands are hidden."), + description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customizations editor is enabled. When enabled, the gear icon in the Chat view opens the Customizations editor directly and additional actions are moved to the overflow menu. When disabled, the gear icon shows the legacy configuration dropdown."), + default: true, + }, + [ChatConfiguration.ChatCustomizationHarnessSelectorEnabled]: { + type: 'boolean', + tags: ['preview'], + description: nls.localize('chat.customizations.harnessSelector.enabled', "Controls whether the harness selector (Local, Copilot CLI, Claude) is shown in the Chat Customizations editor sidebar. When disabled, the editor always shows all customizations without filtering."), default: true, }, } @@ -1888,6 +1899,7 @@ registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); registerSingleton(IChatAttachmentResolveService, ChatAttachmentResolveService, InstantiationType.Delayed); registerSingleton(IChatAttachmentWidgetRegistry, ChatAttachmentWidgetRegistry, InstantiationType.Delayed); registerSingleton(IChatTodoListService, ChatTodoListService, InstantiationType.Delayed); +registerSingleton(IChatArtifactsService, ChatArtifactsService, InstantiationType.Delayed); registerSingleton(IChatOutputRendererService, ChatOutputRendererService, InstantiationType.Delayed); registerSingleton(IChatLayoutService, ChatLayoutService, InstantiationType.Delayed); registerSingleton(IChatTipService, ChatTipService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 1ef5314da04..60e2bff6558 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -227,6 +227,7 @@ export interface IChatListItemRendererOptions { readonly noFooter?: boolean; readonly renderDetectedCommandsWithRequest?: boolean; readonly restorable?: boolean; + readonly supportsFork?: boolean; readonly editable?: boolean; readonly renderTextEditsAsSummary?: (uri: URI) => boolean; readonly referencesExpandedWhenEmptyResponse?: boolean | ((mode: ChatModeKind) => boolean); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts index 2548e901e7f..cfdcc601178 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts @@ -27,6 +27,7 @@ import { renderCustomizationDiscoveryContent, fileListToPlainText } from './chat import { renderUserMessageContent, renderAgentResponseContent, messageEventToPlainText, renderResolvedMessageContent, resolvedMessageToPlainText } from './chatDebugMessageContentRenderer.js'; import { renderToolCallContent, toolCallContentToPlainText } from './chatDebugToolCallContentRenderer.js'; import { renderModelTurnContent, modelTurnContentToPlainText } from './chatDebugModelTurnContentRenderer.js'; +import { renderHookContent, hookContentToPlainText } from './chatDebugHookContentRenderer.js'; const $ = DOM.$; @@ -205,6 +206,16 @@ export class ChatDebugDetailPanel extends Disposable { } this.detailDisposables.add(contentDisposables); this.contentContainer.appendChild(contentEl); + } else if (resolved && resolved.kind === 'hook') { + this.currentDetailText = hookContentToPlainText(resolved); + const { element: contentEl, disposables: contentDisposables } = await renderHookContent(resolved, this.languageService, this.clipboardService, this.scrollable); + if (this.currentDetailEventId !== event.id) { + // Another event was selected while we were rendering + contentDisposables.dispose(); + return; + } + this.detailDisposables.add(contentDisposables); + this.contentContainer.appendChild(contentEl); } else if (event.kind === 'userMessage') { this.currentDetailText = messageEventToPlainText(event); const { element: contentEl, disposables: contentDisposables } = await renderUserMessageContent(event, this.languageService, this.clipboardService, this.scrollable); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts index 8874ddebeaa..1dc8b8b231e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHomeView.ts @@ -17,7 +17,7 @@ import { defaultButtonStyles } from '../../../../../platform/theme/browser/defau import { IChatDebugService } from '../../common/chatDebugService.js'; import { IChatService } from '../../common/chatService/chatService.js'; import { AGENT_DEBUG_LOG_ENABLED_SETTING } from '../../common/promptSyntax/promptTypes.js'; -import { LocalChatSessionUri } from '../../common/model/chatUri.js'; +import { getChatSessionType, isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js'; import { IChatWidgetService } from '../chat.js'; import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; @@ -88,7 +88,12 @@ export class ChatDebugHomeView extends Disposable { // List sessions that have debug event data. // Use the debug service as the source of truth — it includes sessions // whose chat models may have been archived (e.g. when a new chat was started). - const sessionResources = [...this.chatDebugService.getSessionResources()].reverse(); + const cliSessionTypes = new Set(['copilotcli', 'claude-code']); + const sessionResources = [...this.chatDebugService.getSessionResources()].reverse() + // Hide untitled bootstrap sessions for CLI session types (e.g. copilotcli, claude-code). + // These are transient sessions created during async session setup that only contain + // a single "Load Hooks" event and would confuse users. + .filter(r => !cliSessionTypes.has(getChatSessionType(r)) || !isUntitledChatSession(r)); // Sort: active session first if (activeSessionResource) { @@ -122,10 +127,14 @@ export class ChatDebugHomeView extends Disposable { sessionTitle = localize('chatDebug.newSession', "New Chat"); } else if (importedTitle) { sessionTitle = localize('chatDebug.importedSession', "Imported: {0}", importedTitle); - } else if (sessionResource.scheme === 'copilotcli') { + } else if (getChatSessionType(sessionResource) === 'copilotcli') { const pathId = sessionResource.path.replace(/^\//, '').split('-')[0]; const shortId = pathId || sessionResource.authority || sessionResource.toString(); sessionTitle = localize('chatDebug.copilotCliSessionWithId', "Copilot CLI: {0}", shortId); + } else if (getChatSessionType(sessionResource) === 'claude-code') { + const pathId = sessionResource.path.replace(/^\//, '').split('-')[0]; + const shortId = pathId || sessionResource.authority || sessionResource.toString(); + sessionTitle = localize('chatDebug.claudeCodeSessionWithId', "Claude Code: {0}", shortId); } else { sessionTitle = localize('chatDebug.newSession', "New Chat"); } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHookContentRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHookContentRenderer.ts new file mode 100644 index 00000000000..1bdc7b22b6d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugHookContentRenderer.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { localize } from '../../../../../nls.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { ChatDebugHookResult, IChatDebugEventHookContent } from '../../common/chatDebugService.js'; +import { renderSection, tokenizeContent } from './chatDebugToolCallContentRenderer.js'; + +const $ = DOM.$; + +/** + * Render a resolved hook execution content with structured sections for + * hook type, command, result, duration, input, and output. + * When JSON is detected in input/output, renders it with syntax highlighting. + */ +export async function renderHookContent(content: IChatDebugEventHookContent, languageService: ILanguageService, clipboardService?: IClipboardService, scrollable?: { scanDomNode(): void }): Promise<{ element: HTMLElement; disposables: DisposableStore }> { + const disposables = new DisposableStore(); + const container = $('div.chat-debug-message-content'); + container.tabIndex = 0; + + // Header: hook type + DOM.append(container, $('div.chat-debug-message-content-title', undefined, content.hookType)); + + // Status summary line + const statusParts: string[] = []; + if (content.result !== undefined) { + statusParts.push(formatHookResult(content.result)); + } + if (content.exitCode !== undefined) { + statusParts.push(localize('chatDebug.hook.exitCode', "Exit Code: {0}", content.exitCode)); + } + if (content.durationInMillis !== undefined) { + statusParts.push(localize('chatDebug.hook.duration', "{0}ms", content.durationInMillis)); + } + if (statusParts.length > 0) { + DOM.append(container, $('div.chat-debug-message-content-summary', undefined, statusParts.join(' \u00b7 '))); + } + + // Build collapsible sections for command, input, output, and error + const sectionsContainer = DOM.append(container, $('div.chat-debug-message-sections')); + + if (content.command) { + const { plainText, tokenizedHtml } = await tokenizeContent(content.command, languageService); + renderSection(sectionsContainer, localize('chatDebug.hook.command', "Command"), plainText, tokenizedHtml, disposables, false, clipboardService, scrollable); + } + + if (content.input) { + const { plainText, tokenizedHtml } = await tokenizeContent(content.input, languageService); + renderSection(sectionsContainer, localize('chatDebug.hook.input', "Input"), plainText, tokenizedHtml, disposables, false, clipboardService, scrollable); + } + + if (content.output) { + const { plainText, tokenizedHtml } = await tokenizeContent(content.output, languageService); + renderSection(sectionsContainer, localize('chatDebug.hook.output', "Output"), plainText, tokenizedHtml, disposables, false, clipboardService, scrollable); + } + + if (content.errorMessage) { + const { plainText, tokenizedHtml } = await tokenizeContent(content.errorMessage, languageService); + renderSection(sectionsContainer, localize('chatDebug.hook.error', "Error"), plainText, tokenizedHtml, disposables, false, clipboardService, scrollable); + } + + return { element: container, disposables }; +} + +function formatHookResult(result: ChatDebugHookResult): string { + switch (result) { + case ChatDebugHookResult.Success: + return localize('chatDebug.hook.result.success', "Success"); + case ChatDebugHookResult.Error: + return localize('chatDebug.hook.result.error', "Error"); + case ChatDebugHookResult.NonBlockingError: + return localize('chatDebug.hook.result.nonBlockingError', "Non-blocking Error"); + default: + return String(result); + } +} + +/** + * Convert a resolved hook content to plain text for clipboard / editor output. + */ +export function hookContentToPlainText(content: IChatDebugEventHookContent): string { + const lines: string[] = []; + lines.push(localize('chatDebug.hook.typeLabel', "Hook Type: {0}", content.hookType)); + + if (content.result !== undefined) { + lines.push(localize('chatDebug.hook.resultLabel', "Result: {0}", formatHookResult(content.result))); + } + if (content.exitCode !== undefined) { + lines.push(localize('chatDebug.hook.exitCodeLabel', "Exit Code: {0}", content.exitCode)); + } + if (content.durationInMillis !== undefined) { + lines.push(localize('chatDebug.hook.durationLabel', "Duration: {0}ms", content.durationInMillis)); + } + + if (content.command) { + lines.push(''); + lines.push(`[${localize('chatDebug.hook.command', "Command")}]`); + lines.push(content.command); + } + + if (content.input) { + lines.push(''); + lines.push(`[${localize('chatDebug.hook.input', "Input")}]`); + try { + const parsed = JSON.parse(content.input); + lines.push(JSON.stringify(parsed, null, 2)); + } catch { + lines.push(content.input); + } + } + + if (content.output) { + lines.push(''); + lines.push(`[${localize('chatDebug.hook.output', "Output")}]`); + try { + const parsed = JSON.parse(content.output); + lines.push(JSON.stringify(parsed, null, 2)); + } catch { + lines.push(content.output); + } + } + + if (content.errorMessage) { + lines.push(''); + lines.push(`[${localize('chatDebug.hook.error', "Error")}]`); + lines.push(content.errorMessage); + } + + return lines.join('\n'); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts index 269ce660fb2..d2f6573afe6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugLogsView.ts @@ -7,6 +7,7 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { Dimension } from '../../../../../base/browser/dom.js'; import { BreadcrumbsWidget } from '../../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { ProgressBar } from '../../../../../base/browser/ui/progressbar/progressbar.js'; import { IObjectTreeElement } from '../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; @@ -20,7 +21,7 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; import { WorkbenchList, WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; -import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { defaultBreadcrumbsWidgetStyles, defaultButtonStyles, defaultProgressBarStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { FilterWidget } from '../../../../browser/parts/views/viewFilter.js'; import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugService.js'; import { filterDebugEventsByText } from '../../common/chatDebugEvents.js'; @@ -70,7 +71,7 @@ export class ChatDebugLogsView extends Disposable { private readonly eventListener = this._register(new MutableDisposable()); private readonly sessionStateDisposable = this._register(new MutableDisposable()); private readonly refreshScheduler: RunOnceScheduler; - private shimmerRow!: HTMLElement; + private readonly progressBar: ProgressBar; constructor( parent: HTMLElement, @@ -171,6 +172,12 @@ export class ChatDebugLogsView extends Disposable { DOM.append(this.tableHeader, $('span.chat-debug-col-name', undefined, localize('chatDebug.col.name', "Name"))); DOM.append(this.tableHeader, $('span.chat-debug-col-details', undefined, localize('chatDebug.col.details', "Details"))); + // Progress bar (shown when session is in progress) + this.progressBar = this._register(new ProgressBar(mainColumn, { + ...defaultProgressBarStyles, + ariaLabel: localize('chatDebug.progressAriaLabel', "Chat debug logs loading progress") + })); + // Body container this.bodyContainer = DOM.append(mainColumn, $('.chat-debug-logs-body')); @@ -228,13 +235,6 @@ export class ChatDebugLogsView extends Disposable { { identityProvider, accessibilityProvider } )); - // Shimmer row (positioned right below last row to indicate session is running) - this.shimmerRow = DOM.append(this.bodyContainer, $('.chat-debug-logs-shimmer-row')); - this.shimmerRow.setAttribute('aria-label', localize('chatDebug.loadingMore', "Loading more events…")); - this.shimmerRow.setAttribute('aria-busy', 'true'); - DOM.append(this.shimmerRow, $('span.chat-debug-logs-shimmer-bar')); - DOM.hide(this.shimmerRow); - // Detail panel (sibling of main column so it aligns with table header) this.detailPanel = this._register(this.instantiationService.createInstance(ChatDebugDetailPanel, contentContainer)); this._register(this.detailPanel.onDidChangeWidth(() => { @@ -366,11 +366,6 @@ export class ChatDebugLogsView extends Disposable { } else { this.refreshTree(filtered); } - this.updateShimmerPosition(filtered.length); - } - - private updateShimmerPosition(itemCount: number): void { - this.shimmerRow.style.top = `${itemCount * 28}px`; } addEvent(event: IChatDebugEvent): void { @@ -426,14 +421,14 @@ export class ChatDebugLogsView extends Disposable { private trackSessionState(): void { if (!this.currentSessionResource) { - DOM.hide(this.shimmerRow); + this.progressBar.stop(); this.sessionStateDisposable.clear(); return; } const model = this.chatService.getSession(this.currentSessionResource); if (!model) { - DOM.hide(this.shimmerRow); + this.progressBar.stop(); this.sessionStateDisposable.clear(); return; } @@ -441,9 +436,9 @@ export class ChatDebugLogsView extends Disposable { this.sessionStateDisposable.value = autorun(reader => { const inProgress = model.requestInProgress.read(reader); if (inProgress) { - DOM.show(this.shimmerRow); + this.progressBar.infinite(); } else { - DOM.hide(this.shimmerRow); + this.progressBar.stop(); } }); } 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 eade72c676f..5709e69513e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -331,6 +331,10 @@ .chat-debug-table-header .chat-debug-col-details { flex: 1; } +.chat-debug-logs-main > .monaco-progress-container { + height: 2px; + flex-shrink: 0; +} .chat-debug-logs-content { display: flex; flex-direction: row; @@ -402,31 +406,6 @@ .chat-debug-log-row.chat-debug-log-trace { opacity: 0.7; } -.chat-debug-logs-shimmer-row { - position: absolute; - left: 0; - right: 0; - display: flex; - align-items: center; - padding: 0 16px; - height: 28px; - gap: 40px; - pointer-events: none; -} -.chat-debug-logs-shimmer-bar { - flex: 1; - height: 10px; - border-radius: 3px; - background: linear-gradient( - 90deg, - var(--vscode-descriptionForeground) 25%, - var(--vscode-chat-thinkingShimmer, rgba(255, 255, 255, 0.3)) 50%, - var(--vscode-descriptionForeground) 75% - ); - background-size: 200% 100%; - animation: chat-debug-shimmer 2s linear infinite; - opacity: 0.15; -} .chat-debug-detail-panel { flex-shrink: 0; display: flex; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts index 442e73d33f4..e539ca72eef 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingEditorActions.ts @@ -17,6 +17,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IListService } from '../../../../../platform/list/browser/listService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { resolveCommandsContext } from '../../../../browser/parts/editor/editorCommandsContext.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { EditorResourceAccessor, SideBySideEditor, TEXT_DIFF_EDITOR_ID } from '../../../../common/editor.js'; @@ -211,6 +212,7 @@ abstract class KeepOrUndoAction extends ChatEditingEditorAction { override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditingSession, entry: IModifiedFileEntry, _integration: IModifiedFileEntryEditorIntegration): Promise { const instaService = accessor.get(IInstantiationService); + const configService = accessor.get(IConfigurationService); if (this._keep) { session.accept(entry.modifiedURI); @@ -218,7 +220,9 @@ abstract class KeepOrUndoAction extends ChatEditingEditorAction { session.reject(entry.modifiedURI); } - await instaService.invokeFunction(openNextOrPreviousChange, session, entry, true); + if (configService.getValue(ChatConfiguration.RevealNextChangeOnResolve)) { + await instaService.invokeFunction(openNextOrPreviousChange, session, entry, true); + } } } @@ -270,6 +274,7 @@ abstract class AcceptRejectHunkAction extends ChatEditingEditorAction { override async runChatEditingCommand(accessor: ServicesAccessor, session: IChatEditingSession, entry: IModifiedFileEntry, ctrl: IModifiedFileEntryEditorIntegration, ...args: unknown[]): Promise { const instaService = accessor.get(IInstantiationService); + const configService = accessor.get(IConfigurationService); if (this._accept) { await ctrl.acceptNearestChange(args[0] as IModifiedFileEntryChangeHunk | undefined); @@ -277,7 +282,7 @@ abstract class AcceptRejectHunkAction extends ChatEditingEditorAction { await ctrl.rejectNearestChange(args[0] as IModifiedFileEntryChangeHunk | undefined); } - if (entry.changesCount.get() === 0) { + if (configService.getValue(ChatConfiguration.RevealNextChangeOnResolve) && entry.changesCount.get() === 0) { // no more changes, move to next file await instaService.invokeFunction(openNextOrPreviousChange, session, entry, true); } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts index 48073f03acf..6dda2da6585 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingModifiedNotebookEntry.ts @@ -911,17 +911,28 @@ export class ChatEditingModifiedNotebookEntry extends AbstractChatEditingModifie } } + private _safeCreateSnapshot(model: NotebookTextModel): string { + try { + return createSnapshot(model, this.transientOptions, this.configurationService); + } catch (e) { + this.loggingService.error('Notebook Chat', `Error creating snapshot: ${e instanceof Error ? e.message : e}`); + return this.initialContent; + } + } + public getCurrentSnapshot() { - return createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService); + return this._safeCreateSnapshot(this.modifiedModel); } override createSnapshot(chatSessionResource: URI, requestId: string | undefined, undoStop: string | undefined): ISnapshotEntry { + const original = this._safeCreateSnapshot(this.originalModel); + const current = this.getCurrentSnapshot(); return { resource: this.modifiedURI, languageId: SnapshotLanguageId, snapshotUri: getNotebookSnapshotFileURI(chatSessionResource, requestId, undoStop, this.modifiedURI.path, this.modifiedModel.viewType), - original: createSnapshot(this.originalModel, this.transientOptions, this.configurationService), - current: createSnapshot(this.modifiedModel, this.transientOptions, this.configurationService), + original, + current, state: this.state.get(), telemetryInfo: this.telemetryInfo, }; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts index 8439b5518b7..04343f023ce 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts @@ -30,6 +30,7 @@ import { EditorActivation } from '../../../../../platform/editor/common/editor.j 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 { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; @@ -47,7 +48,7 @@ import { ChatEditingDeletedFileEntry } from './chatEditingDeletedFileEntry.js'; import { ChatEditingModifiedDocumentEntry } from './chatEditingModifiedDocumentEntry.js'; import { AbstractChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js'; import { ChatEditingModifiedNotebookEntry } from './chatEditingModifiedNotebookEntry.js'; -import { FileOperation, FileOperationType } from './chatEditingOperations.js'; +import { FileOperation, FileOperationType, getKeyForChatSessionResource } from './chatEditingOperations.js'; import { IChatEditingExplanationModelManager, IExplanationDiffInfo, IExplanationGenerationHandle } from './chatEditingExplanationModelManager.js'; import { ChatEditingSessionStorage, IChatEditingSessionStop, StoredSessionState } from './chatEditingSessionStorage.js'; import { ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js'; @@ -59,6 +60,25 @@ const enum NotExistBehavior { Abort, } +type ChatEditingSessionInfoEvent = { + editSessionId: string; + entryCount: number; + modifiedCount: number; + acceptedCount: number; + rejectedCount: number; +}; + +type ChatEditingSessionInfoClassification = { + owner: 'jrieken'; + comment: 'Tracks the number and state of chat editing entries when a session is stored.'; + editSessionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed identifier of the chat session for correlation.' }; + entryCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of entries stored with the session.' }; + modifiedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Modified state when storing.' }; + acceptedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Accepted state when storing.' }; + rejectedCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of entries in Rejected state when storing.' }; +}; + + class ThrottledSequencer extends Sequencer { private _size = 0; @@ -199,6 +219,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio @IConfigurationService private readonly configurationService: IConfigurationService, @IFileService private readonly _fileService: IFileService, @IChatEditingExplanationModelManager private readonly _explanationModelManager: IChatEditingExplanationModelManager, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); this._timeline = this._instantiationService.createInstance( @@ -308,7 +329,12 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio public storeState(): Promise { const storage = this._instantiationService.createInstance(ChatEditingSessionStorage, this.chatSessionResource); - return storage.storeState(this._getStoredState()); + const storedState = this._getStoredState(); + this._telemetryService.publicLog2('chatEditing/sessionStore', { + editSessionId: getKeyForChatSessionResource(this.chatSessionResource), + ...this._countEntryStates(this._entriesObs.get()), + }); + return storage.storeState(storedState); } private _getStoredState(sessionResource = this.chatSessionResource): StoredSessionState { @@ -945,6 +971,10 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio } this._entriesObs.set(entriesArr, undefined); + this._telemetryService.publicLog2('chatEditing/sessionRestore', { + editSessionId: getKeyForChatSessionResource(this.chatSessionResource), + ...this._countEntryStates(entriesArr), + }); } private async _acceptEdits(resource: URI, textEdits: (TextEdit | ICellEditOperation)[], isLastEdits: boolean, responseModel: IChatResponseModel): Promise { @@ -981,6 +1011,28 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio }; } + private _countEntryStates(entries: readonly AbstractChatEditingModifiedFileEntry[]): { entryCount: number; modifiedCount: number; acceptedCount: number; rejectedCount: number } { + let entryCount = 0; + let modifiedCount = 0; + let acceptedCount = 0; + let rejectedCount = 0; + for (const entry of entries) { + entryCount += 1; + switch (entry.state.get()) { + case ModifiedFileEntryState.Modified: + modifiedCount += 1; + break; + case ModifiedFileEntryState.Accepted: + acceptedCount += 1; + break; + case ModifiedFileEntryState.Rejected: + rejectedCount += 1; + break; + } + } + return { entryCount, modifiedCount, acceptedCount, rejectedCount }; + } + private async _resolve(requestId: string, undoStop: string | undefined, resource: URI): Promise { const hasOtherTasks = Iterable.some(this._streamingEditLocks.keys(), k => k !== resource.toString()); if (!hasOtherTasks) { diff --git a/src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts b/src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts index 456cd6a48bb..9ea827bccff 100644 --- a/src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts @@ -37,6 +37,7 @@ export interface ICarouselImage { readonly name: string; readonly mimeType: string; readonly data: Uint8Array; + readonly caption?: string; } export interface ICarouselSection { @@ -99,7 +100,7 @@ export async function collectCarouselSections( if (dedupedImages.length > 0) { sections.push({ title: request?.messageText ?? extractedTitle, - images: dedupedImages.map(({ id, name, mimeType, data }) => ({ id, name, mimeType, data: data.buffer })) + images: dedupedImages.map(({ uri, name, mimeType, data, caption }) => ({ id: uri.toString(), name, mimeType, data: data.buffer, caption })) }); } } @@ -117,7 +118,7 @@ export async function collectCarouselSections( if (dedupedImages.length > 0) { sections.push({ title: item.messageText, - images: dedupedImages.map(({ id, name, mimeType, data }) => ({ id, name, mimeType, data: data.buffer })) + images: dedupedImages.map(({ uri, name, mimeType, data, caption }) => ({ id: uri.toString(), name, mimeType, data: data.buffer, caption })) }); } } @@ -150,7 +151,20 @@ export function findClickedImageIndex( let globalOffset = 0; for (const section of sections) { - const localIndex = findImageInList(section.images, resource, data); + const localIndex = findImageInListByUri(section.images, resource); + if (localIndex >= 0) { + return globalOffset + localIndex; + } + globalOffset += section.images.length; + } + + if (!data) { + return -1; + } + + globalOffset = 0; + for (const section of sections) { + const localIndex = findImageInListByData(section.images, data); if (localIndex >= 0) { return globalOffset + localIndex; } @@ -160,10 +174,9 @@ export function findClickedImageIndex( return -1; } -function findImageInList( +function findImageInListByUri( images: ICarouselImage[], resource: URI, - data?: Uint8Array, ): number { // Try matching by URI string (for inline references and tool images with URIs) const uriStr = resource.toString(); @@ -184,15 +197,14 @@ function findImageInList( return byParsedUri; } - // Fall back to matching by data buffer equality - if (data) { - const wrapped = VSBuffer.wrap(data); - return images.findIndex(img => VSBuffer.wrap(img.data).equals(wrapped)); - } - return -1; } +function findImageInListByData(images: ICarouselImage[], data: Uint8Array): number { + const wrapped = VSBuffer.wrap(data); + return images.findIndex(img => VSBuffer.wrap(img.data).equals(wrapped)); +} + /** * Builds the collection arguments for the carousel command. */ diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index 3e5e9bf244c..f362516fa9a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -792,6 +792,21 @@ class ActionsColumnRenderer extends ModelsTableColumnRenderer this.languageModelsService.configureModel(entry.model.identifier) + })); + + templateData.actionBar.setActions([], secondaryActions); } } @@ -1181,6 +1196,15 @@ export class ChatModelsWidget extends Disposable { run: () => this.viewModel.setModelsVisibility(selectedModelEntries, true) })); + // Show per-model configuration actions for a single model + if (selectedModelEntries.length === 1) { + const configActions = this.languageModelsService.getModelConfigurationActions(selectedModelEntries[0].model.identifier); + if (configActions.length) { + actions.push(new Separator()); + actions.push(...configActions); + } + } + // Show configure action if all models are from the same group configureGroup = selectedModelEntries[0].model.provider.group.name; configureVendor = selectedModelEntries[0].model.provider.vendor; 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 bbf758db76b..e74df0c7bcf 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -7,7 +7,7 @@ import { sep } from '../../../../../base/common/path.js'; import { AsyncIterableProducer, 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 { Emitter, Event } from '../../../../../base/common/event.js'; import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -31,7 +31,7 @@ import { ExtensionsRegistry } from '../../../../services/extensions/common/exten import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; +import { ChatSessionOptionsMap, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus, ReadonlyChatSessionOptionsMap, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; @@ -47,7 +47,7 @@ import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.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 { getChatSessionType, isUntitledChatSession, 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/promptTypes.js'; @@ -83,7 +83,7 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint; + private readonly _optionsCache: ChatSessionOptionsMap; public getOption(optionId: string): string | IChatSessionProviderOptionItem | undefined { return this._optionsCache.get(optionId); } @@ -250,17 +250,12 @@ class ContributedChatSessionData extends Disposable { readonly session: IChatSession, readonly chatSessionType: string, readonly resource: URI, - readonly options: Record | undefined, + readonly options: ReadonlyChatSessionOptionsMap | undefined, private readonly onWillDispose: (resource: URI) => void ) { super(); - this._optionsCache = new Map(); - if (options) { - for (const [key, value] of Object.entries(options)) { - this._optionsCache.set(key, value); - } - } + this._optionsCache = new Map(options); this._register(this.session.onWillDispose(() => { this.onWillDispose(this.resource); @@ -295,16 +290,14 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private readonly _onDidChangeContentProviderSchemes = this._register(new Emitter<{ readonly added: string[]; readonly removed: string[] }>()); public get onDidChangeContentProviderSchemes() { return this._onDidChangeContentProviderSchemes.event; } - private readonly _onDidChangeSessionOptions = this._register(new Emitter()); + private readonly _onDidChangeSessionOptions = this._register(new Emitter()); public get onDidChangeSessionOptions() { return this._onDidChangeSessionOptions.event; } private readonly _onDidChangeOptionGroups = this._register(new Emitter()); public get onDidChangeOptionGroups() { return this._onDidChangeOptionGroups.event; } - private readonly _onRequestNotifyExtension = this._register(new AsyncEmitter()); - public get onRequestNotifyExtension() { return this._onRequestNotifyExtension.event; } - private readonly inProgressMap: Map = new Map(); - private readonly _sessionTypeOptions: Map = new Map(); - private readonly _sessionTypeNewSessionOptions: Map> = new Map(); + private readonly inProgressMap = new Map(); + private readonly _sessionTypeOptions = new Map(); + private readonly _sessionTypeNewSessionOptions = new Map(); private readonly _sessions = new ResourceMap(); private readonly _resourceAliases = new ResourceMap(); // real resource -> untitled resource @@ -358,23 +351,6 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } })); - this._register(this.onDidChangeSessionItems((delta) => { - const changedChatSessionTypes = new Set(); - for (const session of delta.addedOrUpdated ?? []) { - changedChatSessionTypes.add(getChatSessionType(session.resource)); - } - - for (const resource of delta.removed ?? []) { - changedChatSessionTypes.add(getChatSessionType(resource)); - } - - for (const chatSessionType of changedChatSessionTypes) { - this.updateInProgressStatus(chatSessionType).catch(error => { - this._logService.warn(`Failed to update progress status for '${chatSessionType}':`, error); - }); - } - })); - this._register(this._labelService.registerFormatter({ scheme: Schemas.copilotPr, formatting: { @@ -385,27 +361,17 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ })); } - public reportInProgress(chatSessionType: string, count: number): void { - let displayName: string | undefined; - - if (chatSessionType === AgentSessionProviders.Local) { - displayName = localize('chat.session.inProgress.local', "Local Agent"); - } else if (chatSessionType === AgentSessionProviders.Background) { - displayName = localize('chat.session.inProgress.background', "Background Agent"); - } else if (chatSessionType === AgentSessionProviders.Cloud) { - displayName = localize('chat.session.inProgress.cloud', "Cloud Agent"); - } else { - displayName = this._contributions.get(chatSessionType)?.contribution.displayName; + private reportInProgress(chatSessionType: string, count: number): void { + if (!this._itemControllers.has(chatSessionType)) { + this._logService.warn(`Attempted to report in-progress status for unknown chat session type '${chatSessionType}'`); } - if (displayName) { - this.inProgressMap.set(displayName, count); - } + this.inProgressMap.set(chatSessionType, count); this._onDidChangeInProgress.fire(); } - public getInProgress(): { displayName: string; count: number }[] { - return Array.from(this.inProgressMap.entries()).map(([displayName, count]) => ({ displayName, count })); + public getInProgress(): { chatSessionType: string; count: number }[] { + return Array.from(this.inProgressMap.entries()).map(([chatSessionType, count]) => ({ chatSessionType, count })); } private async updateInProgressStatus(chatSessionType: string): Promise { @@ -422,9 +388,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}'`); + this._logService.trace(`[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`); + this._logService.trace(`[ChatSessionsService] registerContribution: type='${contribution.type}' already registered, skipping`); return Disposable.None; } @@ -657,7 +623,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}`); + this._logService.trace(`[ChatSessionsService] _enableContribution: type='${contribution.type}', canDelegate=${contribution.canDelegate}`); const disposableStore = new DisposableStore(); this._contributionDisposables.set(contribution.type, disposableStore); if (contribution.canDelegate) { @@ -926,12 +892,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ disposables.add(controller.onDidChangeChatSessionItems(e => { this._onDidChangeSessionItems.fire(e); + this.updateInProgressStatus(chatSessionType); })); - this.updateInProgressStatus(chatSessionType).catch(error => { - this._logService.warn(`Failed to update initial progress status for '${chatSessionType}':`, error); - }); - return { dispose: () => { initialRefreshCts.cancel(); @@ -942,6 +905,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._itemControllers.delete(chatSessionType); this._onDidChangeItemsProviders.fire({ chatSessionType }); } + + // Remove any in-progress tracking for this provider since it's no longer available + this.updateInProgressStatus(chatSessionType); } }; } @@ -1075,8 +1041,10 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ session = await raceCancellationError(provider.provideChatSessionContent(sessionResource, token), token); } - for (const [optionId, value] of Object.entries(session.options ?? {})) { - this.setSessionOption(sessionResource, optionId, value); + if (session.options) { + for (const [optionId, value] of session.options) { + this.setSessionOption(sessionResource, optionId, value); + } } // Make sure another session wasn't created while we were awaiting the provider @@ -1096,14 +1064,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._sessions.set(sessionResource, sessionData); // Make sure any listeners are aware of the new session and its options - this._onDidChangeSessionOptions.fire(sessionResource); + if (session.options) { + this._onDidChangeSessionOptions.fire({ sessionResource, updates: session.options }); + } return session; } public hasAnySessionOptions(sessionResource: URI): boolean { const session = this._sessions.get(this._resolveResource(sessionResource)); - return !!session && !!session.options && Object.keys(session.options).length > 0; + return !!session && !!session.options && session.options.size > 0; } public getSessionOptions(sessionResource: URI): Map | undefined { @@ -1124,8 +1094,28 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } public setSessionOption(sessionResource: URI, optionId: string, value: string | IChatSessionProviderOptionItem): boolean { + return this.updateSessionOptions(sessionResource, new Map([[optionId, value]])); + } + + public updateSessionOptions(sessionResource: URI, updates: ReadonlyChatSessionOptionsMap): boolean { const session = this._sessions.get(this._resolveResource(sessionResource)); - return !!session?.setOption(optionId, value); + if (!session) { + return false; + } + + let didChange = false; + for (const [optionId, value] of updates) { + const existingValue = session.getOption(optionId); + if (existingValue !== value) { + session.setOption(optionId, value); + didChange = true; + } + } + + if (didChange) { + this._onDidChangeSessionOptions.fire({ sessionResource, updates: updates }); + } + return didChange; } /** @@ -1160,31 +1150,16 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this._sessionTypeOptions.get(chatSessionType); } - public getNewSessionOptionsForSessionType(chatSessionType: string): Record | undefined { - return this._sessionTypeNewSessionOptions.get(chatSessionType); + public getNewSessionOptionsForSessionType(chatSessionType: string): ReadonlyChatSessionOptionsMap | undefined { + const options = this._sessionTypeNewSessionOptions.get(chatSessionType); + if (!options || options.size === 0) { + return undefined; + } + return new Map(options); } - public setNewSessionOptionsForSessionType(chatSessionType: string, options: Record): void { - this._sessionTypeNewSessionOptions.set(chatSessionType, options); - } - - /** - * Notify extension about option changes for a session - */ - public async notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise { - if (!updates.length) { - return; - } - this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: starting for ${sessionResource}, ${updates.length} update(s): [${updates.map(u => u.optionId).join(', ')}]`); - // Fire event to notify MainThreadChatSessions (which forwards to extension host) - // Uses fireAsync to properly await async listener work via waitUntil pattern - await this._onRequestNotifyExtension.fireAsync({ sessionResource, updates }, CancellationToken.None); - this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: fireAsync completed for ${sessionResource}`); - for (const u of updates) { - this.setSessionOption(sessionResource, u.optionId, u.value); - } - this._onDidChangeSessionOptions.fire(this._resolveResource(sessionResource)); - this._logService.trace(`[ChatSessionsService] notifySessionOptionsChange: finished for ${sessionResource}`); + public setNewSessionOptionsForSessionType(chatSessionType: string, options: ReadonlyChatSessionOptionsMap): void { + this._sessionTypeNewSessionOptions.set(chatSessionType, new Map(options)); } /** @@ -1214,6 +1189,21 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return contribution?.supportsDelegation !== false; } + public sessionSupportsFork(sessionResource: URI): boolean { + const session = this._sessions.get(sessionResource) + // Try to resolve in case an alias was used + ?? this._sessions.get(this._resolveResource(sessionResource)); + return !!session?.session.forkSession; + } + + public async forkChatSession(sessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken): Promise { + const session = this._sessions.get(this._resolveResource(sessionResource)); + if (!session?.session.forkSession) { + throw new Error(`Session ${sessionResource.toString()} does not support forking`); + } + return session.session.forkSession(request, token); + } + public getContentProviderSchemes(): string[] { return Array.from(this._contentProviders.keys()); } @@ -1277,7 +1267,7 @@ export enum ChatSessionPosition { type NewChatSessionSendOptions = { readonly prompt: string; readonly attachedContext?: IChatRequestVariableEntry[]; - readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | { id: string; name: string } }>; + readonly initialSessionOptions?: ReadonlyChatSessionOptionsMap; }; export type NewChatSessionOpenOptions = { diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 4a7157945cc..349ffc80a83 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -232,7 +232,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr }); } - override async run(accessor: ServicesAccessor, mode?: ChatModeKind | string, options?: { forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; inputValue?: string; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + override async run(accessor: ServicesAccessor, mode?: ChatModeKind | string, options?: { forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; inputValue?: string; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { const widgetService = accessor.get(IChatWidgetService); const instantiationService = accessor.get(IInstantiationService); const dialogService = accessor.get(IDialogService); @@ -281,7 +281,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr }); } - override async run(accessor: ServicesAccessor, options?: { dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + override async run(accessor: ServicesAccessor, options?: { dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { const commandService = accessor.get(ICommandService); const telemetryService = accessor.get(ITelemetryService); const chatEntitlementService = accessor.get(IChatEntitlementService); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index 19f1606d516..193681944b8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -6,7 +6,6 @@ import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../../base/common/actions.js'; import { raceTimeout, timeout } from '../../../../../base/common/async.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { mark } from '../../../../../base/common/performance.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; @@ -243,7 +242,6 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { } async invoke(request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void): Promise { - mark('code/chat/setup/willInvoke'); return this.instantiationService.invokeFunction(async accessor /* using accessor for lazy loading */ => { const chatService = accessor.get(IChatService); const languageModelsService = accessor.get(ILanguageModelsService); @@ -274,7 +272,6 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { } private async doInvokeWithoutSetup(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService): Promise { - mark('code/chat/setup/invokeWithoutSetup'); const requestModel = chatWidgetService.getWidgetBySessionResource(request.sessionResource)?.viewModel?.model.getRequests().at(-1); if (!requestModel) { this.logService.error('[chat setup] Request model not found, cannot redispatch request.'); @@ -321,7 +318,6 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { } private async doForwardRequestToChatWhenReady(requestModel: IChatRequestModel, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatAgentService: IChatAgentService, chatWidgetService: IChatWidgetService, languageModelToolsService: ILanguageModelToolsService): Promise { - mark('code/chat/setup/willForwardRequestToChatWhenReady'); // Ensure auth extension is enabled before waiting for chat readiness. // This must run before the readiness event listeners are set up because @@ -343,8 +339,6 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { let languageModelReady = false; let toolsModelReady = false; - mark('code/chat/setup/willWaitForReadiness'); - const whenAgentActivated = this.whenAgentActivated(chatService).then(() => agentActivated = true); const whenAgentReady = this.whenAgentReady(chatAgentService, modeInfo?.kind)?.then(() => agentReady = true); if (!whenAgentReady) { @@ -540,7 +534,6 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { } } - mark('code/chat/setup/didWaitForReadiness'); await chatService.resendRequest(requestModel, { ...widget?.getModeRequestOptions(), modeInfo, @@ -651,7 +644,6 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { } private async doInvokeWithSetup(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService, defaultAccountService: IDefaultAccountService): Promise { - mark('code/chat/setup/invokeWithSetup'); this.telemetryService.publicLog2('workbenchActionExecuted', { id: CHAT_SETUP_ACTION_ID, from: 'chat' }); const widget = chatWidgetService.getWidgetBySessionResource(request.sessionResource); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index 7e77a64f55e..dbf3a6895b7 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -32,7 +32,6 @@ import { ChatSetupController } from './chatSetupController.js'; import { IChatSetupResult, ChatSetupAnonymous, InstallChatEvent, InstallChatClassification, ChatSetupStrategy, ChatSetupResultValue } from './chatSetup.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IHostService } from '../../../../services/host/browser/host.js'; -import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; const defaultChat = { publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '', @@ -51,7 +50,7 @@ export class ChatSetup { let instance = ChatSetup.instance; if (!instance) { instance = ChatSetup.instance = instantiationService.invokeFunction(accessor => { - return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService), accessor.get(IWorkbenchAssignmentService)); + return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService)); }); } @@ -75,14 +74,13 @@ export class ChatSetup { @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, @IHostService private readonly hostService: IHostService, - @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, ) { } skipDialog(): void { this.skipDialogOnce = true; } - async run(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + async run(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { if (this.pendingRun) { return this.pendingRun; } @@ -96,7 +94,7 @@ export class ChatSetup { } } - private async doRun(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + private async doRun(options?: { disableChatViewReveal?: boolean; forceSignInDialog?: boolean; additionalScopes?: readonly string[]; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { this.context.update({ later: false }); const dialogSkipped = this.skipDialogOnce; @@ -162,11 +160,10 @@ export class ChatSetup { return { success, dialogSkipped }; } - private async showDialog(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string; dialogHideSkip?: boolean }): Promise { + private async showDialog(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous; dialogIcon?: ThemeIcon; dialogTitle?: string }): Promise { const disposables = new DisposableStore(); - const useCloseButton = options?.dialogHideSkip || await this.experimentService.getTreatment('chatSetupDialogCloseButton'); - const buttons = this.getButtons(options, useCloseButton); + const buttons = this.getButtons(options); const dialog = disposables.add(new Dialog( this.layoutService.activeContainer, @@ -178,8 +175,8 @@ export class ChatSetup { detail: ' ', // workaround allowing us to render the message in large icon: options?.dialogIcon ?? Codicon.copilotLarge, alignment: DialogContentsAlignment.Vertical, - cancelId: useCloseButton ? buttons.length : buttons.length - 1, - disableCloseButton: !useCloseButton, + cancelId: buttons.length, + disableCloseButton: false, renderFooter: footer => footer.appendChild(this.createDialogFooter(disposables, options)), buttonOptions: buttons.map(button => button[2]) }, this.keybindingService, this.layoutService, this.hostService) @@ -191,7 +188,7 @@ export class ChatSetup { return buttons[button]?.[1] ?? ChatSetupStrategy.Canceled; } - private getButtons(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous }, useCloseButton?: boolean): Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]> { + private getButtons(options?: { forceSignInDialog?: boolean; forceAnonymous?: ChatSetupAnonymous }): Array<[string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]> { type ContinueWithButton = [string, ChatSetupStrategy, { styleButton?: (button: IButton) => void } | undefined]; const styleButton = (...classes: string[]) => ({ styleButton: (button: IButton) => button.element.classList.add(...classes) }); @@ -225,10 +222,6 @@ export class ChatSetup { buttons = [[localize('setupAIButton', "Use AI Features"), ChatSetupStrategy.DefaultSetup, undefined]]; } - if (!useCloseButton) { - buttons.push([localize('skipForNow', "Skip for now"), ChatSetupStrategy.Canceled, styleButton('link-button', 'skip-button')]); - } - return buttons; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 646aa86dc5a..f75404ddde0 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -12,6 +12,7 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IChatAgentService } from '../common/participants/chatAgents.js'; +import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; import { IChatService } from '../common/chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../common/constants.js'; @@ -28,6 +29,7 @@ import { IChatWidgetService } from './chat.js'; import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; import { Target } from '../common/promptSyntax/promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; export class ChatSlashCommandsContribution extends Disposable { @@ -63,7 +65,7 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, silent: true, locations: [ChatAgentLocation.Chat], - target: Target.VSCode + targets: [Target.VSCode] }, async () => { await instantiationService.invokeFunction(showConfigureHooksQuickPick); })); @@ -74,7 +76,6 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, silent: true, locations: [ChatAgentLocation.Chat], - target: Target.VSCode }, async () => { await commandService.executeCommand(OpenModelPickerAction.ID); })); @@ -85,7 +86,7 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, silent: true, locations: [ChatAgentLocation.Chat], - target: Target.VSCode + targets: [Target.VSCode] }, async () => { await commandService.executeCommand(ConfigureToolsAction.ID); })); @@ -96,7 +97,7 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, silent: true, locations: [ChatAgentLocation.Chat], - target: Target.VSCode + targets: [Target.VSCode] }, async () => { await commandService.executeCommand(ManagePluginsAction.ID); })); @@ -119,7 +120,7 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, silent: true, locations: [ChatAgentLocation.Chat], - target: Target.VSCode + targets: [Target.VSCode] }, async () => { await commandService.executeCommand(OpenModePickerAction.ID); })); @@ -130,7 +131,7 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, silent: true, locations: [ChatAgentLocation.Chat], - target: Target.VSCode + targets: [Target.VSCode] }, async () => { await commandService.executeCommand(CONFIGURE_SKILLS_ACTION_ID); })); @@ -141,7 +142,7 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, silent: true, locations: [ChatAgentLocation.Chat], - target: Target.VSCode + targets: [Target.VSCode] }, async () => { await commandService.executeCommand(CONFIGURE_INSTRUCTIONS_ACTION_ID); })); @@ -152,7 +153,7 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, silent: true, locations: [ChatAgentLocation.Chat], - target: Target.VSCode + targets: [Target.VSCode] }, async () => { await commandService.executeCommand(CONFIGURE_PROMPTS_ACTION_ID); })); @@ -163,7 +164,10 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, silent: true, locations: [ChatAgentLocation.Chat], - target: Target.VSCode + when: ContextKeyExpr.or( + ChatContextKeys.lockedToCodingAgent.negate(), + ChatContextKeys.chatSessionSupportsFork + ), }, async (_prompt, _progress, _history, _location, sessionResource) => { await commandService.executeCommand('workbench.action.chat.forkConversation', sessionResource); })); @@ -174,7 +178,7 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: false, silent: true, locations: [ChatAgentLocation.Chat], - target: Target.VSCode + targets: [Target.VSCode] }, async (prompt, _progress, _history, _location, sessionResource) => { const title = prompt.trim(); if (title) { @@ -195,7 +199,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z1_autoApprove', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + targets: [Target.VSCode, Target.GitHubCopilot] }, async (_prompt, _progress, _history, _location, sessionResource) => { setPermissionLevelForSession(sessionResource, ChatPermissionLevel.AutoApprove); })); @@ -205,7 +210,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z1_disableAutoApprove', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + targets: [Target.VSCode, Target.GitHubCopilot] }, async (_prompt, _progress, _history, _location, sessionResource) => { setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default); })); @@ -215,7 +221,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z1_yolo', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + targets: [Target.VSCode, Target.GitHubCopilot] }, async (_prompt, _progress, _history, _location, sessionResource) => { setPermissionLevelForSession(sessionResource, ChatPermissionLevel.AutoApprove); })); @@ -225,7 +232,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z1_disableYolo', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + targets: [Target.VSCode, Target.GitHubCopilot] }, async (_prompt, _progress, _history, _location, sessionResource) => { setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default); })); @@ -236,7 +244,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z1_autopilot', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + targets: [Target.VSCode, Target.GitHubCopilot] }, async (_prompt, _progress, _history, _location, sessionResource) => { setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Autopilot); })); @@ -246,7 +255,8 @@ export class ChatSlashCommandsContribution extends Disposable { sortText: 'z1_exitAutopilot', executeImmediately: true, silent: true, - locations: [ChatAgentLocation.Chat] + locations: [ChatAgentLocation.Chat], + targets: [Target.VSCode, Target.GitHubCopilot] }, async (_prompt, _progress, _history, _location, sessionResource) => { setPermissionLevelForSession(sessionResource, ChatPermissionLevel.Default); })); @@ -259,7 +269,7 @@ export class ChatSlashCommandsContribution extends Disposable { executeImmediately: true, locations: [ChatAgentLocation.Chat], modes: [ChatModeKind.Ask], - target: Target.VSCode + targets: [Target.VSCode] }, async (prompt, progress, _history, _location, sessionResource) => { const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Chat); const agents = chatAgentService.getAgents(); diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index ad2cb70765e..5b28e7c8a89 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -48,6 +48,7 @@ import { Color } from '../../../../../base/common/color.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../chat.js'; import { isCompletionsEnabled } from '../../../../../editor/common/services/completionsEnablement.js'; +import { AgentSessionProviders } from '../agentSessions/agentSessions.js'; const defaultChat = product.defaultChatAgent; @@ -235,12 +236,15 @@ export class ChatStatusDashboard extends DomWidget { } })); - for (const { displayName, count } of inProgress) { + for (const { chatSessionType, count } of inProgress) { if (count > 0) { - const text = localize('inProgressChatSession', "$(loading~spin) {0} in progress", displayName); - const chatSessionsElement = this.element.appendChild($('div.description')); - const parts = renderLabelWithIcons(text); - chatSessionsElement.append(...parts); + const displayName = this.getDisplayNameForChatSessionType(chatSessionType); + if (displayName) { + const text = '$(loading~spin) ' + localize('inProgressChatSession', "{0} in progress", displayName); + const chatSessionsElement = this.element.appendChild($('div.description')); + const parts = renderLabelWithIcons(text); + chatSessionsElement.append(...parts); + } } } } @@ -402,6 +406,18 @@ export class ChatStatusDashboard extends DomWidget { } } + private getDisplayNameForChatSessionType(chatSessionType: string): string | undefined { + if (chatSessionType === AgentSessionProviders.Local) { + return localize('chat.session.inProgress.local', "Local Agent"); + } else if (chatSessionType === AgentSessionProviders.Background) { + return localize('chat.session.inProgress.background', "Background Agent"); + } else if (chatSessionType === AgentSessionProviders.Cloud) { + return localize('chat.session.inProgress.cloud', "Cloud Agent"); + } else { + return this.chatSessionsService.getChatSessionContribution(chatSessionType)?.displayName; + } + } + private canUseChat(): boolean { if (!this.chatEntitlementService.sentiment.installed || this.chatEntitlementService.sentiment.disabled || this.chatEntitlementService.sentiment.untrusted) { return false; // chat not installed or not enabled diff --git a/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts b/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts index 8109c362c3b..b33c3fe5278 100644 --- a/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/contextContrib/chatContext.contribution.ts @@ -67,7 +67,7 @@ export class ChatContextContribution extends Disposable implements IWorkbenchCon for (const contribution of ext.value) { const icon = contribution.icon ? ThemeIcon.fromString(contribution.icon) : undefined; if (!icon && contribution.icon) { - ext.collector.error(localize('chatContextExtPoint.invalidIcon', "Invalid icon format for chat context contribution '{0}'. Icon must be in the format '$(iconId)' or '$(iconId~spin)', e.g. '$(copilot)'.", contribution.id)); + ext.collector.error(localize('chatContextExtPoint.invalidIcon', "Invalid icon format for chat context contribution '{0}'. Icon must be in the format '{1}' or '{2}', e.g. '{3}'.", contribution.id, '$(iconId)', '$(iconId~spin)', '$(copilot)')); continue; } if (!icon) { diff --git a/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts index f89cca85b67..e5ad21a9bb5 100644 --- a/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts +++ b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts @@ -8,6 +8,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { Mutable } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; @@ -315,6 +316,32 @@ export class ChatLanguageModelsDataContribution extends Disposable implements IW private updateSchema(registry: IJSONContributionRegistry): void { const vendors = this.languageModelsService.getVendors(); + // Build per-model configuration schemas + const modelSchemas: IJSONSchema[] = []; + const modelIds = this.languageModelsService.getLanguageModelIds(); + for (const modelId of modelIds) { + const metadata = this.languageModelsService.lookupLanguageModel(modelId); + if (metadata?.configurationSchema) { + modelSchemas.push({ + if: { + properties: { + vendor: { const: metadata.vendor } + } + }, + then: { + properties: { + settings: { + type: 'object', + properties: { + [metadata.id]: metadata.configurationSchema + } + } + } + } + }); + } + } + const schema: IJSONSchema = { type: 'array', items: { @@ -323,16 +350,23 @@ export class ChatLanguageModelsDataContribution extends Disposable implements IW type: 'string', enum: vendors.map(v => v.vendor) }, - name: { type: 'string' } + name: { type: 'string' }, + settings: { + type: 'object', + description: localize('settings.perModelConfig', "Per-model settings"), + } }, - allOf: vendors.map(vendor => ({ - if: { - properties: { - vendor: { const: vendor.vendor } - } - }, - then: vendor.configuration - })), + allOf: [ + ...vendors.map(vendor => ({ + if: { + properties: { + vendor: { const: vendor.vendor } + } + }, + then: vendor.configuration + })), + ...modelSchemas + ], required: ['vendor', 'name'] } }; diff --git a/src/vs/workbench/contrib/chat/browser/pluginSources.ts b/src/vs/workbench/contrib/chat/browser/pluginSources.ts index 8ea0f30210d..383e3112e63 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginSources.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginSources.ts @@ -8,7 +8,7 @@ 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 { dirname, 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'; @@ -79,18 +79,27 @@ abstract class AbstractGitPluginSource implements IPluginSource { protected abstract _displayLabel(descriptor: IPluginSourceDescriptor): string; getCleanupTarget(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI | undefined { + return this._getRepoDir(cacheRoot, descriptor); + } + + /** + * Returns the on-disk directory of the cloned repository. Subclasses that + * support a sub-path within a repository should override this to return the + * repository root, while {@link getInstallUri} returns root + sub-path. + */ + protected _getRepoDir(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { 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 repoDir = this._getRepoDir(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; + return this.getInstallUri(cacheRoot, descriptor); } const progressTitle = options?.progressTitle ?? localize('cloningPluginSource', "Cloning plugin source '{0}'...", label); @@ -99,12 +108,12 @@ abstract class AbstractGitPluginSource implements IPluginSource { await this._cloneRepository(repoDir, this._cloneUrl(descriptor), progressTitle, failureLabel, ref); await this._checkoutRevision(repoDir, descriptor, failureLabel); - return repoDir; + return this.getInstallUri(cacheRoot, descriptor); } async update(cacheRoot: URI, plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise { const descriptor = plugin.sourceDescriptor; - const repoDir = this.getInstallUri(cacheRoot, descriptor); + const repoDir = this._getRepoDir(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`); @@ -242,14 +251,32 @@ export class RelativePathPluginSource implements IPluginSource { export class GitHubPluginSource extends AbstractGitPluginSource { readonly kind = PluginSourceKind.GitHub; + /** Returns the URI where the plugin content lives (repo root + optional sub-path). */ getInstallUri(cacheRoot: URI, descriptor: IPluginSourceDescriptor): URI { + const repoDir = this._getRepoDir(cacheRoot, descriptor); + const gh = descriptor as IGitHubPluginSource; + if (gh.path) { + const normalizedPath = gh.path.trim().replace(/^\.?\/+|\/+$/g, ''); + if (normalizedPath) { + const target = joinPath(repoDir, normalizedPath); + if (isEqualOrParent(target, repoDir)) { + return target; + } + } + } + return repoDir; + } + + /** Returns the cloned repository root (without sub-path). */ + protected override _getRepoDir(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; + const gh = descriptor as IGitHubPluginSource; + return gh.path ? `${gh.repo}/${gh.path}` : gh.repo; } protected _cloneUrl(descriptor: IPluginSourceDescriptor): string { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index a5c04f02c1e..27cc2591d9c 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -25,7 +25,7 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; 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 { getCleanPromptName, SKILL_FILENAME, VALID_SKILL_NAME_REGEX } from '../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { getTarget } from '../../common/promptSyntax/languageProviders/promptFileAttributes.js'; @@ -38,6 +38,12 @@ export interface INewPromptOptions { readonly targetFolder?: URI; readonly targetStorage?: PromptsStorage; readonly openFile?: (uri: URI) => Promise; + /** + * Override the file extension (e.g. `.md` for Claude rules instead of + * `.instructions.md`). When set, the name picker uses this extension + * instead of the default for the prompt type. + */ + readonly fileExtension?: string; } class AbstractNewPromptFileAction extends Action2 { @@ -83,7 +89,7 @@ class AbstractNewPromptFileAction extends Action2 { storage = selectedFolder.storage; } - const fileName = await instaService.invokeFunction(askForPromptFileName, this.type, folderUri); + const fileName = await instaService.invokeFunction(askForPromptFileName, this.type, folderUri, undefined, options?.fileExtension); if (!fileName) { return; } @@ -321,7 +327,7 @@ class NewSkillFileAction extends Action2 { return localize('commands.new.skill.name.tooLong', "Skill name must be 64 characters or less"); } // Per spec: lowercase alphanumeric and hyphens only - if (!/^[a-z0-9-]+$/.test(name)) { + if (!VALID_SKILL_NAME_REGEX.test(name)) { return localize('commands.new.skill.name.invalidChars', "Skill name may only contain lowercase letters, numbers, and hyphens"); } if (name.startsWith('-') || name.endsWith('-')) { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptName.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptName.ts index cce0b716c08..c7421626c78 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptName.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptName.ts @@ -20,7 +20,8 @@ export async function askForPromptFileName( accessor: ServicesAccessor, type: PromptsType, selectedFolder: URI, - existingFileName?: string + existingFileName?: string, + fileExtensionOverride?: string, ): Promise { const quickInputService = accessor.get(IQuickInputService); const fileService = accessor.get(IFileService); @@ -31,7 +32,7 @@ export async function askForPromptFileName( return undefined; } - const fileExtension = getPromptFileExtension(type); + const fileExtension = fileExtensionOverride ?? getPromptFileExtension(type); return (trimmedName.endsWith(fileExtension)) ? trimmedName : `${trimmedName}${fileExtension}`; 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 a2cfc90f7cd..b28c2c70f36 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -14,7 +14,7 @@ import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; -import { getCleanPromptName } from '../../../common/promptSyntax/config/promptFileLocations.js'; +import { getCleanPromptName, getSkillFolderName } from '../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; import { NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../newPromptFileActions.js'; import { GENERATE_AGENT_INSTRUCTIONS_COMMAND_ID, GENERATE_ON_DEMAND_INSTRUCTIONS_COMMAND_ID, GENERATE_PROMPT_COMMAND_ID, GENERATE_SKILL_COMMAND_ID, GENERATE_AGENT_COMMAND_ID } from '../../actions/chatActions.js'; @@ -569,7 +569,7 @@ export class PromptFilePickers { private async _createPromptPickItem(promptFile: IPromptPath, buttons: IQuickInputButton[] | undefined, visibility: boolean | undefined, token: CancellationToken): Promise { const parsedPromptFile = await this._promptsService.parseNew(promptFile.uri, token).catch(() => undefined); - let promptName = parsedPromptFile?.header?.name ?? promptFile.name ?? getCleanPromptName(promptFile.uri); + let promptName = (parsedPromptFile?.header?.name ?? promptFile.name) || (promptFile.type === PromptsType.skill ? getSkillFolderName(promptFile.uri) : getCleanPromptName(promptFile.uri)); const promptDescription = parsedPromptFile?.header?.description ?? promptFile.description; let tooltip: string | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts index 3661f5a27c3..ed9457893d3 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsConfirmationService.ts @@ -19,11 +19,22 @@ import { IToolData, ToolDataSource } from '../../common/tools/languageModelTools const RUN_WITHOUT_APPROVAL = localize('runWithoutApproval', "without approval"); const CONTINUE_WITHOUT_REVIEWING_RESULTS = localize('continueWithoutReviewingResults', "without reviewing result"); +/** + * Represents an auto-confirmation entry in the confirm store. + * When `confirmed` is true, the tool/combination is auto-confirmed. + * When `label` is set, it provides a human-readable description + * for display in the management UI. + */ +interface IAutoConfirmEntry { + readonly confirmed: true; + readonly label?: string; +} + class GenericConfirmStore extends Disposable { private _workspaceStore: Lazy; private _profileStore: Lazy; - private _memoryStore = new Set(); + private _memoryStore = new Map(); constructor( private readonly _storageKey: string, @@ -34,19 +45,20 @@ class GenericConfirmStore extends Disposable { this._profileStore = new Lazy(() => this._register(this._instantiationService.createInstance(ToolConfirmStore, StorageScope.PROFILE, this._storageKey))); } - public setAutoConfirmation(id: string, scope: 'workspace' | 'profile' | 'session' | 'never'): void { + public setAutoConfirmation(id: string, scope: 'workspace' | 'profile' | 'session' | 'never', label?: string): void { // Clear from all scopes first - this._workspaceStore.value.setAutoConfirm(id, false); - this._profileStore.value.setAutoConfirm(id, false); + this._workspaceStore.value.setAutoConfirm(id, undefined); + this._profileStore.value.setAutoConfirm(id, undefined); this._memoryStore.delete(id); + const entry: IAutoConfirmEntry = { confirmed: true, label }; // Set in the appropriate scope if (scope === 'workspace') { - this._workspaceStore.value.setAutoConfirm(id, true); + this._workspaceStore.value.setAutoConfirm(id, entry); } else if (scope === 'profile') { - this._profileStore.value.setAutoConfirm(id, true); + this._profileStore.value.setAutoConfirm(id, entry); } else if (scope === 'session') { - this._memoryStore.add(id); + this._memoryStore.set(id, entry); } } @@ -65,14 +77,20 @@ class GenericConfirmStore extends Disposable { public getAutoConfirmationIn(id: string, scope: 'workspace' | 'profile' | 'session'): boolean { if (scope === 'workspace') { - return this._workspaceStore.value.getAutoConfirm(id); + return !!this._workspaceStore.value.getAutoConfirm(id); } else if (scope === 'profile') { - return this._profileStore.value.getAutoConfirm(id); + return !!this._profileStore.value.getAutoConfirm(id); } else { return this._memoryStore.has(id); } } + public getLabel(id: string): string | undefined { + return this._workspaceStore.value.getAutoConfirm(id)?.label + ?? this._profileStore.value.getAutoConfirm(id)?.label + ?? this._memoryStore.get(id)?.label; + } + public reset(): void { this._workspaceStore.value.reset(); this._profileStore.value.reset(); @@ -100,7 +118,7 @@ class GenericConfirmStore extends Disposable { for (const key of this._profileStore.value.getAll()) { all.add(key); } - for (const key of this._memoryStore) { + for (const key of this._memoryStore.keys()) { all.add(key); } return all; @@ -108,7 +126,7 @@ class GenericConfirmStore extends Disposable { } class ToolConfirmStore extends Disposable { - private _autoConfirmTools: LRUCache = new LRUCache(100); + private _autoConfirmTools: LRUCache = new LRUCache(100); private _didChange = false; constructor( @@ -118,16 +136,34 @@ class ToolConfirmStore extends Disposable { ) { super(); - const stored = storageService.getObject(this._storageKey, this._scope); - if (stored) { - for (const key of stored) { - this._autoConfirmTools.set(key, true); + // Read stored data — supports both legacy string[] and new Record formats + const raw = storageService.get(this._storageKey, this._scope); + if (raw) { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + // Legacy format: string[] + for (const key of parsed) { + this._autoConfirmTools.set(key, { confirmed: true }); + } + } else if (typeof parsed === 'object' && parsed !== null) { + // New format: Record + for (const [key, value] of Object.entries(parsed)) { + this._autoConfirmTools.set(key, { confirmed: true, label: typeof value === 'string' ? value : undefined }); + } + } + } catch { + // Ignore malformed data } } this._register(storageService.onWillSaveState(() => { if (this._didChange) { - this.storageService.store(this._storageKey, [...this._autoConfirmTools.keys()], this._scope, StorageTarget.MACHINE); + const data: Record = {}; + for (const [key, entry] of this._autoConfirmTools) { + data[key] = entry.label ?? true; + } + this.storageService.store(this._storageKey, JSON.stringify(data), this._scope, StorageTarget.MACHINE); this._didChange = false; } })); @@ -138,20 +174,20 @@ class ToolConfirmStore extends Disposable { this._didChange = true; } - public getAutoConfirm(id: string): boolean { - if (this._autoConfirmTools.get(id)) { + public getAutoConfirm(id: string): IAutoConfirmEntry | undefined { + const entry = this._autoConfirmTools.get(id); + if (entry) { this._didChange = true; - return true; + return entry; } - - return false; + return undefined; } - public setAutoConfirm(id: string, autoConfirm: boolean): void { - if (autoConfirm) { - this._autoConfirmTools.set(id, true); - } else { + public setAutoConfirm(id: string, entry: IAutoConfirmEntry | undefined): void { + if (!entry) { this._autoConfirmTools.delete(id); + } else { + this._autoConfirmTools.set(id, entry); } this._didChange = true; } @@ -168,6 +204,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements private _postExecutionToolConfirmStore: GenericConfirmStore; private _preExecutionServerConfirmStore: GenericConfirmStore; private _postExecutionServerConfirmStore: GenericConfirmStore; + private _combinationConfirmStore: GenericConfirmStore; private _contributions = new Map(); @@ -181,6 +218,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements this._postExecutionToolConfirmStore = this._register(new GenericConfirmStore('chat/autoconfirm-post', this._instantiationService)); this._preExecutionServerConfirmStore = this._register(new GenericConfirmStore('chat/servers/autoconfirm', this._instantiationService)); this._postExecutionServerConfirmStore = this._register(new GenericConfirmStore('chat/servers/autoconfirm-post', this._instantiationService)); + this._combinationConfirmStore = this._register(new GenericConfirmStore('chat/autoconfirm-combination', this._instantiationService)); } getPreConfirmAction(ref: ILanguageModelToolConfirmationRef): ConfirmedReason | undefined { @@ -198,6 +236,14 @@ export class LanguageModelToolsConfirmationService extends Disposable implements return undefined; } + // Check combination-level confirmation + if (ref.combination) { + const combinationResult = this._combinationConfirmStore.checkAutoConfirmation(ref.combination.key); + if (combinationResult) { + return combinationResult; + } + } + // Check tool-level confirmation const toolResult = this._preExecutionToolConfirmStore.checkAutoConfirmation(ref.toolId); if (toolResult) { @@ -261,6 +307,41 @@ export class LanguageModelToolsConfirmationService extends Disposable implements return actions; } + // Add combination-level actions when approveCombination is provided + if (ref.combination) { + const { label: combinationLabel, key: combinationKey } = ref.combination; + actions.push( + { + label: localize('allowCombinationSession', '{0} in this Session', combinationLabel), + detail: localize('allowCombinationSessionTooltip', 'Allow this particular combination of tool and arguments in this session without confirmation.'), + divider: !!actions.length, + scope: 'session', + select: async () => { + this._combinationConfirmStore.setAutoConfirmation(combinationKey, 'session', combinationLabel); + return true; + } + }, + { + label: localize('allowCombinationWorkspace', '{0} in this Workspace', combinationLabel), + detail: localize('allowCombinationWorkspaceTooltip', 'Allow this particular combination of tool and arguments in this workspace without confirmation.'), + scope: 'workspace', + select: async () => { + this._combinationConfirmStore.setAutoConfirmation(combinationKey, 'workspace', combinationLabel); + return true; + } + }, + { + label: localize('allowCombinationGlobally', 'Always {0}', combinationLabel), + detail: localize('allowCombinationGloballyTooltip', 'Always allow this particular combination of tool and arguments without confirmation.'), + scope: 'profile', + select: async () => { + this._combinationConfirmStore.setAutoConfirmation(combinationKey, 'profile', combinationLabel); + return true; + } + }, + ); + } + // Add default tool-level actions actions.push( { @@ -429,15 +510,39 @@ export class LanguageModelToolsConfirmationService extends Disposable implements || !!tool.canRequestPostApproval || this._contributions.has(tool.id) || !!this._preExecutionToolConfirmStore.checkAutoConfirmation(tool.id) - || !!this._postExecutionToolConfirmStore.checkAutoConfirmation(tool.id); + || !!this._postExecutionToolConfirmStore.checkAutoConfirmation(tool.id) + || this._hasCombinationApprovalsForTool(tool.id); + } + + private _hasCombinationApprovalsForTool(toolId: string): boolean { + const prefix = toolId + ':combination:'; + for (const key of this._combinationConfirmStore.getAllConfirmed()) { + if (key.startsWith(prefix)) { + return true; + } + } + return false; + } + + private _getCombinationApprovalsForTool(toolId: string, scope: 'workspace' | 'profile' | 'session'): { key: string; label: string }[] { + const prefix = toolId + ':combination:'; + const results: { key: string; label: string }[] = []; + for (const key of this._combinationConfirmStore.getAllConfirmed()) { + if (key.startsWith(prefix) && this._combinationConfirmStore.getAutoConfirmationIn(key, scope)) { + const label = this._combinationConfirmStore.getLabel(key) ?? key; + results.push({ key, label }); + } + } + return results; } 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'; + type: 'tool' | 'server' | 'tool-pre' | 'tool-post' | 'server-pre' | 'server-post' | 'manage' | 'combination'; toolId?: string; serverId?: string; scope?: 'workspace' | 'profile'; + combinationKey?: string; } // Helper to track tools under servers @@ -491,6 +596,14 @@ export class LanguageModelToolsConfirmationService extends Disposable implements } } + // Add tools that have combination approvals + for (const tool of tools) { + if (!relevantTools.has(tool.id) && this._hasCombinationApprovalsForTool(tool.id)) { + relevantTools.add(tool.id); + addServerToolFromSource(tool.source, tool.id, serversWithTools); + } + } + if (relevantTools.size === 0) { return; // Nothing to show } @@ -546,6 +659,18 @@ export class LanguageModelToolsConfirmationService extends Disposable implements }); } + // Add combination approval children + const combinationApprovals = this._getCombinationApprovalsForTool(tool.id, currentScope); + for (const { key, label } of combinationApprovals) { + toolChildren.push({ + type: 'combination', + toolId: tool.id, + combinationKey: key, + label, + checked: true, + }); + } + // Tool item always has a checkbox const preApproval = this._preExecutionToolConfirmStore.getAutoConfirmationIn(tool.id, currentScope); const postApproval = this._postExecutionToolConfirmStore.getAutoConfirmationIn(tool.id, currentScope); @@ -561,6 +686,9 @@ export class LanguageModelToolsConfirmationService extends Disposable implements } else if (hasPost) { checked = postApproval; description = CONTINUE_WITHOUT_REVIEWING_RESULTS; + } else if (toolChildren.length > 0) { + // Tool has combination approvals only + checked = false; } else { continue; } @@ -672,6 +800,18 @@ export class LanguageModelToolsConfirmationService extends Disposable implements }); } + // Add combination approval children + const combinationApprovals = this._getCombinationApprovalsForTool(tool.id, currentScope); + for (const { key, label } of combinationApprovals) { + toolChildren.push({ + type: 'combination', + toolId: tool.id, + combinationKey: key, + label, + checked: true, + }); + } + // Tool item always has a checkbox const preApproval = this._preExecutionToolConfirmStore.getAutoConfirmationIn(tool.id, currentScope); const postApproval = this._postExecutionToolConfirmStore.getAutoConfirmationIn(tool.id, currentScope); @@ -760,6 +900,15 @@ export class LanguageModelToolsConfirmationService extends Disposable implements if (tool?.canRequestPreApproval || newState === 'never') { this._preExecutionToolConfirmStore.setAutoConfirmation(item.toolId, newState); } + // Also clear combination approvals when unchecking the tool + if (newState === 'never') { + for (const key of this._combinationConfirmStore.getAllConfirmed()) { + if (key.startsWith(item.toolId + ':combination:')) { + this._combinationConfirmStore.setAutoConfirmation(key, 'never'); + } + } + } + quickTree.setItemTree(buildTreeItems()); } else if (item.type === 'tool-pre' && item.toolId) { this._preExecutionToolConfirmStore.setAutoConfirmation(item.toolId, newState); } else if (item.type === 'tool-post' && item.toolId) { @@ -772,6 +921,9 @@ export class LanguageModelToolsConfirmationService extends Disposable implements quickTree.setItemTree(buildTreeItems()); } else if (item.type === 'manage') { (item as ILanguageModelToolConfirmationContributionQuickTreeItem).onDidChangeChecked?.(!!item.checked); + } else if (item.type === 'combination' && item.combinationKey) { + this._combinationConfirmStore.setAutoConfirmation(item.combinationKey, newState); + quickTree.setItemTree(buildTreeItems()); } })); @@ -787,6 +939,8 @@ export class LanguageModelToolsConfirmationService extends Disposable implements quickTree.hide(); await (manageItem as ILanguageModelToolConfirmationContributionQuickTreeItem).onDidOpen?.(); this.manageConfirmationPreferences(tools, options); + } else { + quickTree.hide(); } })); @@ -819,6 +973,7 @@ export class LanguageModelToolsConfirmationService extends Disposable implements this._postExecutionToolConfirmStore.reset(); this._preExecutionServerConfirmStore.reset(); this._postExecutionServerConfirmStore.reset(); + this._combinationConfirmStore.reset(); // Reset all contributions for (const contribution of this._contributions.values()) { diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 3b85fb792dd..0692eb9db67 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -18,7 +18,6 @@ import { Iterable } from '../../../../../base/common/iterator.js'; import { combinedDisposable, Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { getMediaMime } from '../../../../../base/common/mime.js'; import { derived, derivedOpts, IObservable, IReader, observableFromEventOpts, ObservableSet, observableSignal, transaction } from '../../../../../base/common/observable.js'; -import { mark } from '../../../../../base/common/performance.js'; import Severity from '../../../../../base/common/severity.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -583,7 +582,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo toolInvocation.transitionFromStreaming(preparedInvocation, dto.parameters, autoConfirmed); } else { // Create a new tool invocation (no streaming phase) - toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.callId, dto.subAgentInvocationId, dto.parameters); + toolInvocation = new ChatToolInvocation(preparedInvocation, tool.data, dto.chatStreamToolCallId ?? dto.callId, dto.subAgentInvocationId, dto.parameters); if (autoConfirmed) { IChatToolInvocation.confirmWith(toolInvocation, autoConfirmed); } @@ -639,15 +638,12 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo throw new CancellationError(); } - mark('code/chat/willInvokeTool'); invocationTimeWatch = StopWatch.create(true); toolResult = await tool.impl.invoke(dto, countTokens, { report: step => { toolInvocation?.acceptProgress(step); } }, token); - - mark('code/chat/didInvokeTool'); invocationTimeWatch.stop(); this.ensureToolDetails(dto, toolResult, tool.data, toolInvocation); @@ -802,7 +798,15 @@ 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, dto.chatRequestId); + const approveCombination = preparedInvocation?.confirmationMessages?.approveCombination; + let combination: { label: string; key: string } | undefined; + if (approveCombination) { + combination = { + label: typeof approveCombination.label === 'string' ? approveCombination.label : approveCombination.label.value, + key: approveCombination.key, + }; + } + const autoConfirmed = await this.shouldAutoConfirm(tool.data.id, tool.data.runsInWorkspace, tool.data.source, dto.parameters, sessionResource, dto.chatRequestId, combination); return { autoConfirmed, preparedInvocation }; } @@ -1115,7 +1119,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return true; } - private async shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined, chatRequestId: string | undefined): Promise { + private async shouldAutoConfirm(toolId: string, runsInWorkspace: boolean | undefined, source: ToolDataSource, parameters: unknown, chatSessionResource: URI | undefined, chatRequestId: string | undefined, combination?: { label: string; key: string }): Promise { const tool = this._tools.get(toolId); if (!tool) { return undefined; @@ -1141,7 +1145,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - const reason = this._confirmationService.getPreConfirmAction({ toolId, source, parameters, chatSessionResource }); + const reason = this._confirmationService.getPreConfirmAction({ toolId, source, parameters, chatSessionResource, combination }); if (reason) { return reason; } diff --git a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts index fef06150ff0..0ddb27fa1ff 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/renameTool.ts @@ -15,6 +15,7 @@ import { TextEdit } from '../../../../../editor/common/languages.js'; import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { rename } from '../../../../../editor/contrib/rename/browser/rename.js'; import { localize } from '../../../../../nls.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -53,6 +54,7 @@ export class RenameTool extends Disposable implements IToolImpl { constructor( @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ILanguageService private readonly _languageService: ILanguageService, @ITextModelService private readonly _textModelService: ITextModelService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IChatService private readonly _chatService: IChatService, @@ -67,26 +69,31 @@ export class RenameTool extends Disposable implements IToolImpl { )((() => this._onDidUpdateToolData.fire()))); } - getToolData(): IToolData { + getToolData(): IToolData | undefined { const languageIds = this._languageFeaturesService.renameProvider.registeredLanguageIds; - let modelDescription = BaseModelDescription; - if (languageIds.has('*')) { - modelDescription += '\n\nSupported for all languages.'; - } else if (languageIds.size > 0) { - const sorted = [...languageIds].sort(); - modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; - } else { - modelDescription += '\n\nNo languages currently have rename providers registered.'; + if (languageIds.size === 0) { + return undefined; } + let modelDescription = BaseModelDescription; + let userDescription: string; + if (languageIds.has('*')) { + modelDescription += '\n\nSupported for all languages.'; + userDescription = localize('tool.rename.userDescription', 'Rename a symbol across the workspace'); + } else { + const sorted = [...languageIds].sort(); + modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; + const niceNames = sorted.map(id => this._languageService.getLanguageName(id) ?? id); + userDescription = localize('tool.rename.userDescriptionWithLanguages', 'Rename a symbol across the workspace ({0})', niceNames.join(', ')); + } return { id: RenameToolId, toolReferenceName: 'rename', canBeReferencedInPrompt: false, icon: ThemeIcon.fromId(Codicon.rename.id), displayName: localize('tool.rename.displayName', 'Rename Symbol'), - userDescription: localize('tool.rename.userDescription', 'Rename a symbol across the workspace'), + userDescription, modelDescription, source: ToolDataSource.Internal, when: ContextKeyExpr.has('config.chat.tools.renameTool.enabled'), @@ -251,9 +258,12 @@ export class RenameToolContribution extends Disposable implements IWorkbenchCont let registration: IDisposable | undefined; const registerRenameTool = () => { registration?.dispose(); + registration = undefined; toolsService.flushToolUpdates(); const toolData = renameTool.getToolData(); - registration = toolsService.registerTool(toolData, renameTool); + if (toolData) { + registration = toolsService.registerTool(toolData, renameTool); + } }; registerRenameTool(); this._store.add(renameTool.onDidUpdateToolData(registerRenameTool)); diff --git a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts index 1360fbcba5c..96618b9afc4 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts @@ -14,7 +14,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { assertType, isObject } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../nls.js'; -import { Action2 } from '../../../../../platform/actions/common/actions.js'; +import { Action2, MenuId } from '../../../../../platform/actions/common/actions.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -37,6 +37,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ChatViewId } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ChatConfiguration } from '../../common/constants.js'; const toolEnumValues: string[] = []; @@ -323,12 +324,18 @@ export class ConfigureToolSets extends Action2 { category: CHAT_CATEGORY, f1: true, precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ChatContextKeys.Tools.toolsCount.greater(0)), - menu: { + menu: [{ id: CHAT_CONFIG_MENU_ID, when: ContextKeyExpr.equals('view', ChatViewId), order: 11, group: '2_level' }, + { + id: MenuId.ViewTitle, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + order: 11, + group: '2_level' + }], }); } diff --git a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts index 153763ce7b0..6f8f708ee01 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts @@ -18,6 +18,7 @@ import { Location, LocationLink } from '../../../../../editor/common/languages.j import { IModelService } from '../../../../../editor/common/services/model.js'; import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; import { getDefinitionsAtPosition, getImplementationsAtPosition, getReferencesAtPosition } from '../../../../../editor/contrib/gotoSymbol/browser/goToSymbol.js'; import { localize } from '../../../../../nls.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -50,6 +51,7 @@ export class UsagesTool extends Disposable implements IToolImpl { constructor( @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @ILanguageService private readonly _languageService: ILanguageService, @IModelService private readonly _modelService: IModelService, @ISearchService private readonly _searchService: ISearchService, @ITextModelService private readonly _textModelService: ITextModelService, @@ -64,17 +66,23 @@ export class UsagesTool extends Disposable implements IToolImpl { )((() => this._onDidUpdateToolData.fire()))); } - getToolData(): IToolData { + getToolData(): IToolData | undefined { const languageIds = this._languageFeaturesService.referenceProvider.registeredLanguageIds; + if (languageIds.size === 0) { + return undefined; + } + let modelDescription = BaseModelDescription; + let userDescription: string; if (languageIds.has('*')) { modelDescription += '\n\nSupported for all languages.'; - } else if (languageIds.size > 0) { + userDescription = localize('tool.usages.userDescription', 'Find references, definitions, and implementations of a symbol'); + } else { const sorted = [...languageIds].sort(); modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; - } else { - modelDescription += '\n\nNo languages currently have reference providers registered.'; + const niceNames = sorted.map(id => this._languageService.getLanguageName(id) ?? id); + userDescription = localize('tool.usages.userDescriptionWithLanguages', 'Find references, definitions, and implementations of a symbol ({0})', niceNames.join(', ')); } return { @@ -83,7 +91,7 @@ export class UsagesTool extends Disposable implements IToolImpl { canBeReferencedInPrompt: false, icon: ThemeIcon.fromId(Codicon.references.id), displayName: localize('tool.usages.displayName', 'List Code Usages'), - userDescription: localize('tool.usages.userDescription', 'Find references, definitions, and implementations of a symbol'), + userDescription, modelDescription, source: ToolDataSource.Internal, when: ContextKeyExpr.has('config.chat.tools.usagesTool.enabled'), @@ -320,9 +328,12 @@ export class UsagesToolContribution extends Disposable implements IWorkbenchCont let registration: IDisposable | undefined; const registerUsagesTool = () => { registration?.dispose(); + registration = undefined; toolsService.flushToolUpdates(); const toolData = usagesTool.getToolData(); - registration = toolsService.registerTool(toolData, usagesTool); + if (toolData) { + registration = toolsService.registerTool(toolData, usagesTool); + } }; registerUsagesTool(); this._store.add(usagesTool.onDidUpdateToolData(registerUsagesTool)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.ts new file mode 100644 index 00000000000..709ee187d2e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatArtifactsWidget.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 { Button } from '../../../../../base/browser/ui/button/button.js'; +import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { getMediaMime } from '../../../../../base/common/mime.js'; +import { autorun, IObservable, IReader } 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 { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../../platform/files/common/files.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 { ChatConfiguration } from '../../common/constants.js'; +import { IChatArtifact, IChatArtifactsService } from '../../common/tools/chatArtifactsService.js'; + +const ARTIFACT_TYPE_ICONS: Record = { + devServer: Codicon.globe, + screenshot: Codicon.file, + plan: Codicon.book, +}; + +export class ChatArtifactsWidget extends Disposable { + readonly domNode: HTMLElement; + + private readonly _autorunDisposable = this._register(new MutableDisposable()); + private _currentObs: IObservable | undefined; + private _isCollapsed = true; + private _list: WorkbenchList | undefined; + private readonly _listStore = this._register(new DisposableStore()); + private _expandIcon!: HTMLElement; + private _titleElement!: HTMLElement; + + public static readonly ELEMENT_HEIGHT = 22; + private static readonly MAX_ITEMS_SHOWN = 6; + + private _sessionResource: URI | undefined; + + constructor( + @IChatArtifactsService private readonly _chatArtifactsService: IChatArtifactsService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IOpenerService private readonly _openerService: IOpenerService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ICommandService private readonly _commandService: ICommandService, + @IFileService private readonly _fileService: IFileService, + ) { + super(); + this.domNode = dom.$('.chat-artifacts-widget'); + this.domNode.style.display = 'none'; + } + + render(sessionResource: URI): void { + this._sessionResource = sessionResource; + this._currentObs = this._chatArtifactsService.artifacts(sessionResource); + + dom.clearNode(this.domNode); + this._listStore.clear(); + + const expandoContainer = dom.$('.chat-artifacts-expand'); + const headerButton = this._listStore.add(new Button(expandoContainer, { supportIcons: true })); + headerButton.element.setAttribute('aria-expanded', String(!this._isCollapsed)); + + const titleSection = dom.$('.chat-artifacts-title-section'); + this._expandIcon = dom.$('.expand-icon.codicon'); + this._expandIcon.classList.add(this._isCollapsed ? 'codicon-chevron-right' : 'codicon-chevron-down'); + this._expandIcon.setAttribute('aria-hidden', 'true'); + this._titleElement = dom.$('.chat-artifacts-title'); + + titleSection.appendChild(this._expandIcon); + titleSection.appendChild(this._titleElement); + headerButton.element.appendChild(titleSection); + + this.domNode.appendChild(expandoContainer); + + const listContainer = dom.$('.chat-artifacts-list'); + listContainer.style.display = this._isCollapsed ? 'none' : 'block'; + this.domNode.appendChild(listContainer); + + this._list = this._listStore.add(this._instantiationService.createInstance( + WorkbenchList, + 'ChatArtifactsList', + listContainer, + new ChatArtifactsListDelegate(), + [new ChatArtifactsListRenderer()], + { alwaysConsumeMouseWheel: false }, + )); + + this._listStore.add(this._list.onDidOpen(e => { + if (e.element) { + if (e.element.type === 'screenshot' && this._configurationService.getValue(ChatConfiguration.ImageCarouselEnabled)) { + this._openScreenshotInCarousel(e.element); + } else { + this._openerService.open(URI.parse(e.element.uri)); + } + } + })); + + this._listStore.add(headerButton.onDidClick(() => { + this._isCollapsed = !this._isCollapsed; + this._expandIcon.classList.toggle('codicon-chevron-down', !this._isCollapsed); + this._expandIcon.classList.toggle('codicon-chevron-right', this._isCollapsed); + headerButton.element.setAttribute('aria-expanded', String(!this._isCollapsed)); + listContainer.style.display = this._isCollapsed ? 'none' : 'block'; + })); + + this._autorunDisposable.value = autorun((reader: IReader) => { + const artifacts: readonly IChatArtifact[] = this._currentObs!.read(reader); + if (artifacts.length === 0) { + this.domNode.style.display = 'none'; + return; + } + this.domNode.style.display = ''; + + this._titleElement.textContent = artifacts.length === 1 + ? localize('chat.artifacts.one', "1 Artifact") + : localize('chat.artifacts.count', "{0} Artifacts", artifacts.length); + + const itemsShown = Math.min(artifacts.length, ChatArtifactsWidget.MAX_ITEMS_SHOWN); + const listHeight = itemsShown * ChatArtifactsWidget.ELEMENT_HEIGHT; + this._list!.layout(listHeight); + this._list!.getHTMLElement().style.height = `${listHeight}px`; + this._list!.splice(0, this._list!.length, [...artifacts]); + }); + } + + private async _openScreenshotInCarousel(clicked: IChatArtifact): Promise { + const allArtifacts = this._currentObs?.get() ?? []; + const screenshots = allArtifacts.filter(a => a.type === 'screenshot'); + const startIndex = screenshots.indexOf(clicked); + + const images = await Promise.all(screenshots.map(async a => { + const uri = URI.parse(a.uri); + const content = await this._fileService.readFile(uri); + const name = uri.path.split('/').pop() ?? 'image'; + return { + id: a.uri, + name, + mimeType: getMediaMime(name) ?? 'image/png', + data: content.value.buffer, + }; + })); + + await this._commandService.executeCommand('workbench.action.chat.openImageInCarousel', { + collection: { + id: this._sessionResource!.toString() + '_artifacts_carousel', + title: localize('chat.artifacts.carousel', "Artifacts"), + sections: [{ title: '', images }], + }, + startIndex: Math.max(0, startIndex), + }); + } + + hide(): void { + this._autorunDisposable.clear(); + this.domNode.style.display = 'none'; + } +} + +class ChatArtifactsListDelegate implements IListVirtualDelegate { + getHeight(): number { + return ChatArtifactsWidget.ELEMENT_HEIGHT; + } + getTemplateId(): string { + return ChatArtifactsListRenderer.TEMPLATE_ID; + } +} + +interface IChatArtifactsListTemplate { + readonly container: HTMLElement; + readonly iconElement: HTMLElement; + readonly labelElement: HTMLElement; +} + +class ChatArtifactsListRenderer implements IListRenderer { + static readonly TEMPLATE_ID = 'chatArtifactsListRenderer'; + readonly templateId = ChatArtifactsListRenderer.TEMPLATE_ID; + + renderTemplate(container: HTMLElement): IChatArtifactsListTemplate { + const row = dom.append(container, dom.$('.chat-artifacts-list-row')); + const iconElement = dom.append(row, dom.$('.chat-artifacts-list-icon')); + const labelElement = dom.append(row, dom.$('.chat-artifacts-list-label')); + return { container: row, iconElement, labelElement }; + } + + renderElement(artifact: IChatArtifact, _index: number, templateData: IChatArtifactsListTemplate): void { + const icon = (artifact.type && ARTIFACT_TYPE_ICONS[artifact.type]) || Codicon.archive; + templateData.iconElement.className = 'chat-artifacts-list-icon ' + ThemeIcon.asClassName(icon); + templateData.labelElement.textContent = artifact.label; + templateData.container.title = artifact.uri; + } + + disposeTemplate(): void { } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index e1cb498c7cd..0ca080497f5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -15,7 +15,7 @@ import { coalesce } from '../../../../../../base/common/arrays.js'; import { findLast } from '../../../../../../base/common/arraysFind.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { autorun, autorunSelfDisposable, derived } from '../../../../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; @@ -396,6 +396,13 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } } + override dispose(): void { + super.dispose(); + + dispose(this.allRefs); + this.allRefs.length = 0; + } + private renderCodeBlockPill(sessionResource: URI, requestId: string, inUndoStop: string | undefined, codemapperUri: URI | undefined): IDisposableReference { const codeBlock = this.instantiationService.createInstance(CollapsedCodeBlock, sessionResource, requestId, inUndoStop); if (codemapperUri) { @@ -526,15 +533,6 @@ export class CollapsedCodeBlock extends Disposable { this.element.appendChild(this.statusIndicatorContainer); this.element.appendChild(this.pillElement); - // Toggle show-checkmarks class for the accessibility setting - const updateCheckmarks = () => this.element.classList.toggle('show-checkmarks', !!this.configurationService.getValue(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)); - updateCheckmarks(); - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)) { - updateCheckmarks(); - } - })); - this.registerListeners(); } 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 704052557be..2105d647822 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -11,6 +11,7 @@ import { IMarkdownString, MarkdownString, isMarkdownString } from '../../../../. import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { isMacintosh } from '../../../../../../base/common/platform.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; @@ -49,13 +50,16 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent private _currentIndex = 0; private readonly _answers = new Map(); + private _isCollapsed = false; private _questionContainer: HTMLElement | undefined; + private _headerActionsContainer: HTMLElement | undefined; private _closeButtonContainer: HTMLElement | undefined; private _footerRow: HTMLElement | undefined; private _stepIndicator: HTMLElement | undefined; private _submitHint: HTMLElement | undefined; private _submitButton: Button | undefined; + private _collapseButton: Button | undefined; private _prevButton: Button | undefined; private _nextButton: Button | undefined; private _skipAllButton: Button | undefined; @@ -92,6 +96,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent super(); this.domNode = dom.$('.chat-question-carousel-container'); + this.domNode.id = generateUuid(); this._inChatQuestionCarouselContextKey = ChatContextKeys.inChatQuestionCarousel.bindTo(this._contextKeyService); const focusTracker = this._register(dom.trackFocus(this.domNode)); this._register(focusTracker.onDidFocus(() => this._inChatQuestionCarouselContextKey.set(true))); @@ -110,6 +115,10 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._currentIndex = Math.max(0, Math.min(carousel.draftCurrentIndex, carousel.questions.length - 1)); } + if (typeof carousel.draftCollapsed === 'boolean') { + this._isCollapsed = carousel.draftCollapsed; + } + if (carousel.draftAnswers) { for (const [key, value] of Object.entries(carousel.draftAnswers)) { this._answers.set(key, value); @@ -141,6 +150,13 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Question container this._questionContainer = dom.$('.chat-question-carousel-content'); this.domNode.append(this._questionContainer); + this._headerActionsContainer = dom.$('.chat-question-header-actions'); + + const collapseToggleTitle = localize('chat.questionCarousel.collapseTitle', 'Collapse Questions'); + const collapseButton = interactiveStore.add(new Button(this._headerActionsContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true })); + collapseButton.element.classList.add('chat-question-collapse-toggle'); + collapseButton.element.setAttribute('aria-label', collapseToggleTitle); + this._collapseButton = collapseButton; // Close/skip button (X) - placed in header row, only shown when allowSkip is true if (carousel.allowSkip) { @@ -155,6 +171,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } // Register event listeners + interactiveStore.add(collapseButton.onDidClick(() => this.toggleCollapsed())); + if (this._skipAllButton) { interactiveStore.add(this._skipAllButton.onDidClick(() => this.ignore())); } @@ -224,6 +242,31 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this.carousel.draftAnswers = Object.fromEntries(this._answers.entries()); this.carousel.draftCurrentIndex = this._currentIndex; + this.carousel.draftCollapsed = this._isCollapsed; + } + + private toggleCollapsed(): void { + this._isCollapsed = !this._isCollapsed; + this.persistDraftState(); + this.updateCollapsedPresentation(); + this._onDidChangeHeight.fire(); + } + + private updateCollapsedPresentation(): void { + this.domNode.classList.toggle('chat-question-carousel-collapsed', this._isCollapsed); + + if (this._collapseButton) { + const collapsed = this._isCollapsed; + const buttonTitle = collapsed + ? localize('chat.questionCarousel.expandTitle', 'Expand Questions') + : localize('chat.questionCarousel.collapseTitle', 'Collapse Questions'); + const contentId = this.domNode.id; + this._collapseButton.label = collapsed ? `$(${Codicon.chevronUp.id})` : `$(${Codicon.chevronDown.id})`; + this._collapseButton.element.setAttribute('aria-label', buttonTitle); + this._collapseButton.element.setAttribute('aria-expanded', String(!collapsed)); + this._collapseButton.element.setAttribute('aria-controls', contentId); + this._collapseButton.setTitle(buttonTitle); + } } /** @@ -335,7 +378,9 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent this._submitButton = undefined; this._skipAllButton = undefined; this._questionContainer = undefined; + this._headerActionsContainer = undefined; this._closeButtonContainer = undefined; + this._collapseButton = undefined; this._footerRow = undefined; this._stepIndicator = undefined; this._submitHint = undefined; @@ -609,9 +654,15 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent headerRow.appendChild(titleRow); - // Always keep the close button in the title row so it does not overlap content. - if (this._closeButtonContainer) { - titleRow.appendChild(this._closeButtonContainer); + if (this._headerActionsContainer) { + dom.clearNode(this._headerActionsContainer); + if (this._closeButtonContainer) { + this._headerActionsContainer.appendChild(this._closeButtonContainer); + } + if (this._collapseButton) { + this._headerActionsContainer.appendChild(this._collapseButton.element); + } + titleRow.appendChild(this._headerActionsContainer); } this._questionContainer.appendChild(headerRow); @@ -680,6 +731,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent // Update aria-label to reflect the current question this._updateAriaLabel(); + this.updateCollapsedPresentation(); // In screen reader mode, focus the container and announce the question // This must happen after all render calls to avoid focus being stolen diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts index dcd85778a0e..cfa95551f1a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatResourceGroupWidget.ts @@ -7,6 +7,7 @@ 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 { Emitter } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { basename, joinPath } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -47,6 +48,8 @@ const IMAGE_DECODE_DELAY_MS = 100; */ export class ChatResourceGroupWidget extends Disposable { public readonly domNode: HTMLElement; + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; constructor( parts: IChatCollapsibleIODataPart[], @@ -124,6 +127,7 @@ export class ChatResourceGroupWidget extends Disposable { }; itemsContainer.appendChild(attachments.domNode!); + this._onDidChangeHeight.fire(); const toolbar = this._register(this._instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, MenuId.ChatToolOutputResourceToolbar, { menuOptions: { @@ -146,6 +150,7 @@ export class ChatResourceGroupWidget extends Disposable { // Update attachments in place attachments.updateVariables(entries); + this._onDidChangeHeight.fire(); }, IMAGE_DECODE_DELAY_MS)); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index b39a3230afa..04a51e71ef6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -803,7 +803,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Wrap with icon like thinking parts do const itemWrapper = $('.chat-thinking-tool-wrapper'); - const icon = getToolInvocationIcon(toolInvocation.toolId); + const icon = getToolInvocationIcon(toolInvocation.toolId, toolInvocation.icon); const iconElement = createThinkingIcon(icon); itemWrapper.appendChild(content); 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 83164c7a850..c4d66671c21 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -27,13 +27,16 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { Emitter } from '../../../../../../base/common/event.js'; -import { DisposableMap, DisposableStore, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { autorun } from '../../../../../../base/common/observable.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; import { ChatMessageRole, ILanguageModelsService } from '../../../common/languageModels.js'; import './media/chatThinkingContent.css'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { extractImagesFromToolInvocationOutputDetails } from '../../../common/chatImageExtraction.js'; +import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; +import { ChatThinkingExternalResourceWidget } from './chatThinkingExternalResourcesWidget.js'; function extractTextFromPart(content: IChatThinkingPart): string { @@ -41,7 +44,36 @@ function extractTextFromPart(content: IChatThinkingPart): string { return raw.trim(); } -export function getToolInvocationIcon(toolId: string): ThemeIcon { +function isEditToolId(toolId: string): boolean { + const lowerToolId = toolId.toLowerCase(); + return lowerToolId.includes('edit') || + lowerToolId.includes('create') || + lowerToolId.includes('replace') || + lowerToolId.includes('patch'); +} + +/** + * Returns true for edit tools whose generic display name should be replaced + * with "Editing files" while streaming (e.g. replace, multi-replace, patch, insertEdit). + * Excludes create and notebook tools which already have good labels. + */ +function isGenericEditToolId(toolId: string): boolean { + const lowerToolId = toolId.toLowerCase(); + if (lowerToolId.includes('create') || lowerToolId.includes('notebook')) { + return false; + } + return lowerToolId.includes('replace') || + lowerToolId.includes('patch') || + lowerToolId.includes('insertedit') || + lowerToolId.includes('insert_edit') || + lowerToolId.includes('editfile'); +} + +export function getToolInvocationIcon(toolId: string, registeredIcon?: ThemeIcon): ThemeIcon { + if (registeredIcon) { + return registeredIcon; + } + const lowerToolId = toolId.toLowerCase(); if ( @@ -64,11 +96,7 @@ export function getToolInvocationIcon(toolId: string): ThemeIcon { return Codicon.book; } - if ( - lowerToolId.includes('edit') || - lowerToolId.includes('create') || - lowerToolId.includes('replace') - ) { + if (isEditToolId(toolId)) { return Codicon.pencil; } @@ -200,14 +228,19 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen private workingSpinnerLabel: HTMLElement | undefined; private availableMessagesByCategory = new Map(); private readonly toolWrappersByCallId = new Map(); + private readonly toolLabelsByCallId = new Map(); private readonly toolDisposables = this._register(new DisposableMap()); + private readonly ownedToolParts = new Map(); private pendingRemovals: { toolCallId: string; toolLabel: string }[] = []; private pendingRemovalFlushDisposable: IDisposable | undefined; private pendingScrollDisposable: IDisposable | undefined; private mutationObserverDisposable: IDisposable | undefined; private isUpdatingDimensions: boolean = false; + private lastKnownContentHeight: number = 0; + private lastKnownScrollTop: number = 0; private titleShimmerSpan: HTMLElement | undefined; private titleDetailContainer: HTMLElement | undefined; + private readonly _externalResourceWidget: ChatThinkingExternalResourceWidget; private readonly _titleDetailRendered = this._register(new MutableDisposable()); private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { @@ -277,10 +310,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen if (configuredMode === ThinkingDisplayMode.Collapsed) { this.setExpanded(false); } else if (configuredMode === ThinkingDisplayMode.CollapsedPreview) { - // Start expanded if still in progress - // Use streamingCompleted to support look-ahead completion: when we know - // this thinking part is done (based on subsequent non-pinnable parts) - // even though the overall response is not complete + // Start expanded if still in progress. + // streamingCompleted is true when look-ahead finds subsequent non-pinnable + // parts, meaning this thinking part won't receive more content. this.setExpanded(!this.streamingCompleted && !this.element.isComplete); } else { this.setExpanded(false); @@ -289,6 +321,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const node = this.domNode; node.classList.add('chat-thinking-box'); + this._externalResourceWidget = this._register(this.instantiationService.createInstance(ChatThinkingExternalResourceWidget)); + this._register(this._externalResourceWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + node.appendChild(this._externalResourceWidget.domNode); + if (!this.streamingCompleted && !this.element.isComplete) { if (!this.fixedScrollingMode) { node.classList.add('chat-thinking-active'); @@ -308,6 +344,13 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.currentTitle = this.defaultTitle; } + this._register(toDisposable(() => { + for (const d of this.ownedToolParts.values()) { + d.dispose(); + } + this.ownedToolParts.clear(); + })); + // override for codicon chevron in the collapsible part this._register(autorun(r => { const isExpanded = this.expanded.read(r); @@ -336,12 +379,15 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } } - // If expanded but content matches title and there's nothing else to show, revert immediately - if (isExpanded && !this.shouldAllowExpansion()) { + // If expanded but content matches title and there's nothing else to show, revert immediately. + // Skip this check while still streaming — more content will arrive. + if (isExpanded && !this.shouldAllowExpansion() && (this.streamingCompleted || this.element.isComplete)) { this.setExpanded(false); return; } + this._externalResourceWidget.setCollapsed(!isExpanded); + // Fire when expanded/collapsed this._onDidChangeHeight.fire(); })); @@ -419,7 +465,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen // check for content changes to update scroll dimensions const mutationObserver = new MutationObserver(() => { - if (!this.streamingCompleted) { + if (!this.streamingCompleted && this.domNode.classList.contains('chat-used-context-collapsed')) { this.syncDimensionsAndScheduleScroll(); } }); @@ -450,15 +496,28 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen return; } - const scrollDimensions = this.scrollableElement.getScrollDimensions(); - const maxScrollTop = scrollDimensions.scrollHeight - scrollDimensions.height; - const isAtBottom = maxScrollTop <= 0 || scrollTop >= maxScrollTop - 10; + this.lastKnownScrollTop = scrollTop; + const contentHeight = this.lastKnownContentHeight; + const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); + const maxScrollTop = contentHeight - viewportHeight; + this.autoScrollEnabled = maxScrollTop <= 0 || scrollTop >= maxScrollTop - 10; - if (isAtBottom) { - this.autoScrollEnabled = true; - } else { - this.autoScrollEnabled = false; + this.updateFadeClasses(scrollTop, contentHeight, viewportHeight); + } + + private updateFadeClasses(scrollTop?: number, contentHeight?: number, viewportHeight?: number): void { + if (!this.fixedScrollingMode || this.streamingCompleted) { + this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); + return; } + + const currentScrollTop = scrollTop ?? this.lastKnownScrollTop; + const currentContentHeight = contentHeight ?? this.lastKnownContentHeight; + const currentViewportHeight = viewportHeight ?? Math.min(currentContentHeight, THINKING_SCROLL_MAX_HEIGHT); + const maxScrollTop = currentContentHeight - currentViewportHeight; + + this.domNode.classList.toggle('chat-thinking-fade-top', currentScrollTop > 5); + this.domNode.classList.toggle('chat-thinking-fade-bottom', maxScrollTop > 0 && currentScrollTop < maxScrollTop - 5); } // Schedule a batched scroll dimension update for the next animation frame. @@ -483,6 +542,8 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } finally { this.isUpdatingDimensions = false; } + // Use the cached values from updateScrollDimensions to avoid extra layout reads + this.updateFadeClasses(this.lastKnownScrollTop, this.lastKnownContentHeight); }); } @@ -497,6 +558,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } const contentHeight = this.wrapper.scrollHeight; + this.lastKnownContentHeight = contentHeight; const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); this.scrollableElement.setScrollDimensions({ @@ -506,6 +568,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen scrollHeight: contentHeight }); + // Cache the scroll position after dimension update + this.lastKnownScrollTop = this.scrollableElement.getScrollPosition().scrollTop; + // 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); @@ -521,7 +586,9 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); if (contentHeight > viewportHeight) { - this.scrollableElement.setScrollPosition({ scrollTop: contentHeight - viewportHeight }); + const newScrollTop = contentHeight - viewportHeight; + this.lastKnownScrollTop = newScrollTop; + this.scrollableElement.setScrollPosition({ scrollTop: newScrollTop }); } } @@ -661,7 +728,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen } } - if (!allowExpansion && this.isExpanded()) { + if (!allowExpansion && this.isExpanded() && (this.streamingCompleted || this.element.isComplete)) { this.setExpanded(false); } this.setDropdownClickable(allowExpansion); @@ -759,6 +826,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen public markAsInactive(): void { this.isActive = false; this.domNode.classList.remove('chat-thinking-active'); + this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); this.processPendingRemovals(); if (this.workingSpinnerElement) { this.workingSpinnerElement.remove(); @@ -780,6 +848,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen this.wrapper.classList.remove('chat-thinking-streaming'); } this.domNode.classList.remove('chat-thinking-active'); + this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); this.streamingCompleted = true; if (this.mutationObserverDisposable) { @@ -834,7 +903,12 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen toolInvocation }; if (result.disposable) { - this._register(result.disposable); + const toolCallId = toolInvocation?.toolCallId; + if (toolCallId) { + this.ownedToolParts.set(toolCallId, result.disposable); + } else { + this._register(result.disposable); + } } } } @@ -1122,7 +1196,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): if (result.disposable) { const toolCallId = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.toolCallId : undefined; if (toolCallId) { - this.toolDisposables.get(toolCallId)?.add(result.disposable); + this.ownedToolParts.set(toolCallId, result.disposable); } else { this._register(result.disposable); } @@ -1145,6 +1219,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): public removeMaterializedItem(toolCallId: string): void { this.toolDisposables.deleteAndDispose(toolCallId); + this.ownedToolParts.delete(toolCallId); const wrapper = this.toolWrappersByCallId.get(toolCallId); if (wrapper) { @@ -1158,19 +1233,20 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolCallId === toolCallId ); if (toolInvocationsIndex !== -1) { - const invocation = this.toolInvocations[toolInvocationsIndex]; - if (invocation.kind === 'toolInvocation' || invocation.kind === 'toolInvocationSerialized') { - const msg = invocation.invocationMessage; - const label = msg ? (typeof msg === 'string' ? msg : msg.value) : undefined; - if (label) { - const titleIndex = this.extractedTitles.indexOf(label); - if (titleIndex !== -1) { - this.extractedTitles.splice(titleIndex, 1); - } + // Use the tracked displayed label (which may differ from invocationMessage + // for streaming edit tools that show "Editing files") + const label = this.toolLabelsByCallId.get(toolCallId); + if (label) { + const titleIndex = this.extractedTitles.indexOf(label); + if (titleIndex !== -1) { + this.extractedTitles.splice(titleIndex, 1); } } this.toolInvocations.splice(toolInvocationsIndex, 1); } + this.toolLabelsByCallId.delete(toolCallId); + + this._externalResourceWidget.removeToolInvocation(toolCallId); this.updateDropdownClickability(); this._onDidChangeHeight.fire(); @@ -1199,15 +1275,19 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): if (removedItem.kind === 'tool' && removedItem.toolInvocationOrMarkdown && (removedItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || removedItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized')) { removedItem.toolInvocationOrMarkdown.isAttachedToThinking = false; - // Keep extractedTitles in sync when a lazy tool leaves the thinking container - const msg = removedItem.toolInvocationOrMarkdown.invocationMessage; - const label = msg ? (typeof msg === 'string' ? msg : msg.value) : undefined; + // Keep extractedTitles in sync when a lazy tool leaves the thinking container. + // Use the tracked displayed label (which may differ from invocationMessage + // for streaming edit tools that show "Editing files") + const toolCallId = removedItem.toolInvocationOrMarkdown.toolCallId; + this._externalResourceWidget.removeToolInvocation(toolCallId); + const label = this.toolLabelsByCallId.get(toolCallId); if (label) { const titleIndex = this.extractedTitles.indexOf(label); if (titleIndex !== -1) { this.extractedTitles.splice(titleIndex, 1); } } + this.toolLabelsByCallId.delete(toolCallId); } const toolInvocationsIndex = this.toolInvocations.findIndex(t => @@ -1255,6 +1335,8 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): // removes the tool entry that was previously streaming and now is not. removes item from dom and internal tracking. private removeStreamingToolEntry(toolCallId: string, toolLabel: string): void { this.toolDisposables.deleteAndDispose(toolCallId); + this.ownedToolParts.get(toolCallId)?.dispose(); + this.ownedToolParts.delete(toolCallId); const wrapper = this.toolWrappersByCallId.get(toolCallId); if (wrapper) { @@ -1290,6 +1372,8 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): if (titleIndex !== -1) { this.extractedTitles.splice(titleIndex, 1); } + this.toolLabelsByCallId.delete(toolCallId); + this._externalResourceWidget.removeToolInvocation(toolCallId); this.updateDropdownClickability(); this._onDidChangeHeight.fire(); } @@ -1320,10 +1404,27 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); if (isToolInvocation && toolInvocationOrMarkdown.invocationMessage) { const message = typeof toolInvocationOrMarkdown.invocationMessage === 'string' ? toolInvocationOrMarkdown.invocationMessage : toolInvocationOrMarkdown.invocationMessage.value; - toolCallLabel = message; + + // For edit-type tools that are still streaming, use a friendlier label + // instead of the generic tool display name (e.g. "Replace String in File") + const isStreamingEditTool = toolInvocationOrMarkdown.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(toolInvocationOrMarkdown) && isGenericEditToolId(toolInvocationOrMarkdown.toolId); + if (isStreamingEditTool) { + toolCallLabel = localize('chat.thinking.editingFiles', 'Editing files'); + } else { + toolCallLabel = message; + } this.toolInvocations.push(toolInvocationOrMarkdown); + // Track the displayed label for consistent cleanup + const toolCallId = toolInvocationOrMarkdown.toolCallId; + this.toolLabelsByCallId.set(toolCallId, toolCallLabel); + + // Render external image pills for serialized (already-completed) tool invocations + if (toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') { + this.updateExternalResourceParts(toolInvocationOrMarkdown); + } + // track state for live/still streaming tools, excluding serialized tools if (toolInvocationOrMarkdown.kind === 'toolInvocation') { let currentToolLabel = toolCallLabel; @@ -1349,6 +1450,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this.extractedTitles.push(updatedMessage); } currentToolLabel = updatedMessage; + this.toolLabelsByCallId.set(toolCallId, updatedMessage); this.lastExtractedTitle = updatedMessage; // make sure not to set title if expanded @@ -1383,6 +1485,12 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: currentToolLabel }); this.schedulePendingRemovalsFlush(); } + + // Render image pills outside the collapsible area for completed tools + if (currentState.type === IChatToolInvocation.StateKind.Completed) { + this.updateExternalResourceParts(toolInvocationOrMarkdown); + } + isComplete = true; return; } @@ -1447,6 +1555,22 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): } } + private updateExternalResourceParts(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + const extractedImages = extractImagesFromToolInvocationOutputDetails(toolInvocation, this.element.sessionResource); + if (extractedImages.length === 0) { + return; + } + + const parts: IChatCollapsibleIODataPart[] = extractedImages.map(image => ({ + kind: 'data', + value: image.data.buffer, + mimeType: image.mimeType, + uri: image.uri, + })); + + this._externalResourceWidget.setToolInvocationParts(toolInvocation.toolCallId, parts); + } + private appendItemToDOM( content: HTMLElement, toolInvocationId?: string, @@ -1473,20 +1597,28 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): const itemWrapper = $('.chat-thinking-tool-wrapper'); const isMarkdownEdit = toolInvocationOrMarkdown?.kind === 'markdownContent'; const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; + const toolInvocationIcon = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.icon : undefined; let icon: ThemeIcon; if (isMarkdownEdit) { icon = Codicon.pencil; } else if (isTerminalTool) { - const terminalData = (toolInvocationOrMarkdown as IChatToolInvocation | IChatToolInvocationSerialized).toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode?: number } }; + const terminalData = (toolInvocationOrMarkdown as IChatToolInvocation | IChatToolInvocationSerialized).toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode?: number }; commandLine?: { isSandboxWrapped?: boolean } }; const exitCode = terminalData?.terminalCommandState?.exitCode; - icon = exitCode !== undefined && exitCode !== 0 ? Codicon.error : Codicon.terminal; + const isSandboxWrapped = terminalData?.commandLine?.isSandboxWrapped; + if (exitCode !== undefined && exitCode !== 0) { + icon = Codicon.error; + } else if (isSandboxWrapped) { + icon = Codicon.terminalSecure; + } else { + icon = toolInvocationIcon ?? Codicon.terminal; + } } else if (content.classList.contains('chat-hook-outcome-blocked')) { icon = Codicon.error; } else if (content.classList.contains('chat-hook-outcome-warning')) { icon = Codicon.warning; } else { - icon = toolInvocationId ? getToolInvocationIcon(toolInvocationId) : Codicon.tools; + icon = toolInvocationId ? getToolInvocationIcon(toolInvocationId, toolInvocationIcon) : Codicon.tools; } const iconElement = createThinkingIcon(icon); @@ -1534,7 +1666,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): if (result.disposable) { const toolCallId = item.toolInvocationOrMarkdown && (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? item.toolInvocationOrMarkdown.toolCallId : undefined; if (toolCallId) { - this.toolDisposables.get(toolCallId)?.add(result.disposable); + this.ownedToolParts.set(toolCallId, result.disposable); } else { this._register(result.disposable); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingExternalResourcesWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingExternalResourcesWidget.ts new file mode 100644 index 00000000000..732ff270de5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingExternalResourcesWidget.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, clearNode, hide, show } from '../../../../../../base/browser/dom.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ChatResourceGroupWidget } from './chatResourceGroupWidget.js'; +import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; + +export class ChatThinkingExternalResourceWidget extends Disposable { + + public readonly domNode: HTMLElement; + private readonly _onDidChangeHeight = this._register(new Emitter()); + public readonly onDidChangeHeight = this._onDidChangeHeight.event; + + private readonly resourcePartsByToolCallId = new Map(); + private readonly resourceGroupWidget = this._register(new MutableDisposable()); + private readonly resourceGroupWidgetHeightListener = this._register(new MutableDisposable()); + private isCollapsed = true; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + this.domNode = $('.chat-thinking-external-resources'); + hide(this.domNode); + } + + public setToolInvocationParts(toolCallId: string, parts: IChatCollapsibleIODataPart[]): void { + if (parts.length === 0) { + return; + } + + this.resourcePartsByToolCallId.set(toolCallId, parts); + + this.rebuild(); + } + + public removeToolInvocation(toolCallId: string): void { + if (!this.resourcePartsByToolCallId.delete(toolCallId)) { + return; + } + + this.rebuild(); + } + + public setCollapsed(collapsed: boolean): void { + this.isCollapsed = collapsed; + + if (!this.resourceGroupWidget.value) { + hide(this.domNode); + return; + } + + if (this.isCollapsed) { + show(this.domNode); + } else { + hide(this.domNode); + } + } + + private rebuild(): void { + const allParts: IChatCollapsibleIODataPart[] = []; + for (const parts of this.resourcePartsByToolCallId.values()) { + allParts.push(...parts); + } + + this.resourceGroupWidgetHeightListener.clear(); + this.resourceGroupWidget.clear(); + clearNode(this.domNode); + + if (allParts.length === 0) { + hide(this.domNode); + this._onDidChangeHeight.fire(); + return; + } + + const widget = this.instantiationService.createInstance(ChatResourceGroupWidget, allParts); + this.resourceGroupWidgetHeightListener.value = widget.onDidChangeHeight(() => this._onDidChangeHeight.fire()); + this.resourceGroupWidget.value = widget; + this.domNode.appendChild(widget.domNode); + this.setCollapsed(this.isCollapsed); + this._onDidChangeHeight.fire(); + } +} 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 45e5b303c2e..656af1f8bf1 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 @@ -99,6 +99,13 @@ } } + .chat-question-header-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + } + .chat-question-close-container { flex-shrink: 0; @@ -117,6 +124,35 @@ background: var(--vscode-toolbar-hoverBackground) !important; } } + + .monaco-button.chat-question-collapse-toggle { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none !important; + box-shadow: none !important; + background: transparent !important; + color: var(--vscode-icon-foreground) !important; + } + + .monaco-button.chat-question-collapse-toggle:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } + } +} + +.interactive-session .chat-question-carousel-container.chat-question-carousel-collapsed { + .chat-question-carousel-content { + .chat-question-description, + .chat-question-input-scrollable, + .chat-question-validation-message { + display: none; + } + } + + .chat-question-footer-row { + display: none; } } @@ -211,31 +247,52 @@ background-color: var(--vscode-list-hoverBackground); } - /* Single-select: highlight entire row when selected */ + /* Single-select: highlight entire row when selected (list not focused) */ .chat-question-list-item.selected { - background-color: var(--vscode-list-hoverBackground); - color: var(--vscode-list-activeSelectionForeground); + background-color: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); - .chat-question-label { - color: var(--vscode-list-activeSelectionForeground); + .chat-question-list-label, + .chat-question-list-label-title { + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } .chat-question-list-label-desc { - color: var(--vscode-list-activeSelectionForeground); - opacity: 0.8; + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } .chat-question-list-indicator.codicon-check { - color: var(--vscode-list-activeSelectionForeground); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } .chat-question-list-number { - color: var(--vscode-list-activeSelectionForeground); + color: var(--vscode-list-inactiveSelectionForeground, var(--vscode-foreground)); } } + /* When the question list has focus, use active selection styling */ + .chat-question-list:focus .chat-question-list-item.selected { + background-color: var(--vscode-list-activeSelectionBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + + .chat-question-label { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + .chat-question-list-label-desc { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + .chat-question-list-indicator.codicon-check { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + .chat-question-list-number { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + } .chat-question-list-item.selected:hover { - background-color: var(--vscode-list-hoverBackground); + background-color: var(--vscode-list-inactiveSelectionBackground, var(--vscode-list-hoverBackground)); } /* Checkbox for multi-select */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css index df4551eaa35..19684ae349f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatThinkingContent.css @@ -13,6 +13,11 @@ position: relative; color: var(--vscode-descriptionForeground); + .chat-thinking-external-resources { + margin-top: 4px; + margin-left: 5px; + } + .chat-used-context { margin: 0px; } @@ -56,6 +61,9 @@ .codicon.codicon-circle-filled { display: none; } + } + + &.chat-thinking-active.chat-used-context-collapsed > .chat-used-context-label .monaco-button.monaco-icon-button { .chat-thinking-title-shimmer { background: linear-gradient(90deg, @@ -92,17 +100,32 @@ max-height: none; } + /* Curved connector from the header to the first thinking item */ + &:not(.chat-used-context-collapsed)::after { + content: ''; + position: absolute; + left: 3px; + top: 22px; + height: 16px; + width: 5px; + border-left: 1px solid var(--vscode-chat-requestBorder); + border-bottom: 1px solid var(--vscode-chat-requestBorder); + border-bottom-left-radius: 5px; + } + .monaco-button.hidden, .chat-pinned-preview.hidden { display: none; } .chat-used-context-list.chat-thinking-collapsible { - border: 1px solid var(--vscode-chat-requestBorder); - border-radius: var(--vscode-cornerRadius-medium); + border: none; + border-radius: 0; margin-bottom: 0; position: relative; overflow: hidden; + padding-top: 0; + margin-left: 5px; .chat-tool-invocation-part { padding: 4px 12px 4px 18px; @@ -161,15 +184,16 @@ .chat-used-context.chat-hook-outcome-blocked, .chat-used-context.chat-hook-outcome-warning { - padding: 4px 12px 4px 20px; - margin-bottom: 0; - } + padding: 4px 12px 4px 20px; + margin-bottom: 0; + } } .chat-thinking-item.markdown-content { padding: 6px 12px 6px 24px; position: relative; font-size: var(--vscode-chat-font-size-body-s); + .progress-container { margin-bottom: 0px; padding-top: 0px; @@ -255,7 +279,6 @@ > .chat-thinking-icon .codicon.codicon-book { top: 10px; } - } .chat-thinking-spinner-item { @@ -326,10 +349,31 @@ .interactive-session .interactive-response .value .chat-thinking-fixed-mode { outline: none; + /* Hide the header/collapse button while streaming — it reappears when chat-thinking-streaming is removed */ + &:has(.chat-thinking-streaming) > .chat-used-context-label { + display: none; + } + > .monaco-scrollable-element > .shadow { display: none; } + /* Directional fade indicators via mask-image on the scrollable viewport */ + &.chat-thinking-fade-top > .monaco-scrollable-element { + mask-image: linear-gradient(to bottom, transparent 0px, black 20px); + -webkit-mask-image: linear-gradient(to bottom, transparent 0px, black 20px); + } + + &.chat-thinking-fade-bottom > .monaco-scrollable-element { + mask-image: linear-gradient(to top, transparent 0px, black 20px); + -webkit-mask-image: linear-gradient(to top, transparent 0px, black 20px); + } + + &.chat-thinking-fade-top.chat-thinking-fade-bottom > .monaco-scrollable-element { + mask-image: linear-gradient(to bottom, transparent 0px, black 20px, black calc(100% - 20px), transparent 100%); + -webkit-mask-image: linear-gradient(to bottom, transparent 0px, black 20px, black calc(100% - 20px), transparent 100%); + } + &.chat-used-context-collapsed > .monaco-scrollable-element:has(.chat-used-context-list.chat-thinking-collapsible:not(.chat-thinking-streaming)) { display: none; } @@ -338,6 +382,42 @@ max-height: 200px; overflow: hidden; display: block; + margin-left: 0px; + padding-left: 0px; + + .chat-tool-invocation-part { + padding-left: 13px; + } + + .chat-thinking-spinner-item { + padding-left: 19px; + } + + .chat-used-context.chat-hook-outcome-blocked, + .chat-used-context.chat-hook-outcome-warning { + padding-left: 15px; + } + + .chat-thinking-item.markdown-content { + padding-left: 19px; + } + + .chat-thinking-tool-wrapper .chat-markdown-part.rendered-markdown { + padding-left: 19px; + } + + /* chain of thought lines */ + .chat-thinking-tool-wrapper, + .chat-thinking-item.markdown-content, + .chat-thinking-spinner-item { + &::before { + left: 5.5px; + } + + > .chat-thinking-icon { + left: 0px; + } + } } &:not(.chat-used-context-collapsed) .chat-used-context-list.chat-thinking-collapsible.chat-thinking-streaming { @@ -349,10 +429,6 @@ &:not(.chat-used-context-collapsed) > .monaco-scrollable-element { overflow: visible; height: auto; - - > .scrollbar { - display: none; - } } .chat-used-context-list.chat-thinking-collapsible:not(.chat-thinking-streaming) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts index e736777479d..3a9ac0a040f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts @@ -81,7 +81,7 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca const additionalActions = this.additionalPrimaryActions(); // find session scoped action - const sessionAction = additionalActions.find( + const sessionAction = this.useAllowOnceAsPrimary() ? undefined : additionalActions.find( (action): action is IAbstractToolPrimaryAction => 'scope' in action && action.scope === 'session' ); @@ -157,6 +157,16 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca return []; } + /** + * When true, "Allow Once" stays the primary button even when a + * session-scoped action is available. Subclasses override this + * to keep the simple allow-once default (e.g. when combination + * approval options are present). + */ + protected useAllowOnceAsPrimary(): boolean { + return false; + } + protected abstract createContentElement(): HTMLElement | string; protected abstract getTitle(): string; } 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 index 8bf86a6b9d1..710087171ca 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts @@ -92,11 +92,6 @@ export class ChatModifiedFilesConfirmationSubPart extends AbstractToolConfirmati 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, } ]; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 8f70589d4d4..da42cf8d77b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -45,7 +45,7 @@ import { URI } from '../../../../../../../base/common/uri.js'; import { stripIcons } from '../../../../../../../base/common/iconLabels.js'; import { IAccessibleViewService } from '../../../../../../../platform/accessibility/browser/accessibleView.js'; import { IContextKey, IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; -import { AccessibilityVerbositySettingId, AccessibilityWorkbenchSettingId } from '../../../../../accessibility/browser/accessibilityConfiguration.js'; +import { AccessibilityVerbositySettingId } from '../../../../../accessibility/browser/accessibilityConfiguration.js'; import { ChatContextKeys } from '../../../../common/actions/chatContextKeys.js'; import { EditorPool } from '../chatContentCodePools.js'; import { IKeybindingService } from '../../../../../../../platform/keybinding/common/keybinding.js'; @@ -422,14 +422,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this.domNode = this._createCollapsibleWrapper(progressPart.domNode, displayCommand, toolInvocation, context); } else { this.domNode = progressPart.domNode; - // Toggle show-checkmarks class on the progress container for accessibility setting - const updateCheckmarks = () => this.domNode.classList.toggle('show-checkmarks', !!this._configurationService.getValue(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)); - updateCheckmarks(); - this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)) { - updateCheckmarks(); - } - })); } this._renderImagePills(toolInvocation, context, elements.container); @@ -507,6 +499,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const wrapper = this._register(this._instantiationService.createInstance( ChatTerminalThinkingCollapsibleWrapper, truncatedCommand, + this._terminalData.commandLine.isSandboxWrapped === true, contentElement, context, initialExpanded, @@ -1631,13 +1624,15 @@ export class ContinueInBackgroundAction extends Action implements IAction { } } -class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart { +export class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart { private readonly _terminalContentElement: HTMLElement; private readonly _commandText: string; + private readonly _isSandboxWrapped: boolean; private _isComplete: boolean; constructor( commandText: string, + isSandboxWrapped: boolean, contentElement: HTMLElement, context: IChatContentPartRenderContext, initialExpanded: boolean, @@ -1645,11 +1640,14 @@ class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart @IHoverService hoverService: IHoverService, @IConfigurationService configurationService: IConfigurationService, ) { - const title = isComplete ? `Ran \`${commandText}\`` : `Running \`${commandText}\``; + const title = isComplete + ? localize('chat.terminal.ran.plain', "Ran {0}", commandText) + : localize('chat.terminal.running.plain', "Running {0}", commandText); super(title, context, undefined, hoverService, configurationService); this._terminalContentElement = contentElement; this._commandText = commandText; + this._isSandboxWrapped = isSandboxWrapped; this._isComplete = isComplete; this.domNode.classList.add('chat-terminal-thinking-collapsible'); @@ -1670,6 +1668,19 @@ class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart const labelElement = this._collapseButton.labelElement; labelElement.textContent = ''; + if (this._isSandboxWrapped) { + const prefixText = this._isComplete + ? localize('chat.terminal.ranInSandbox.prefix', "Ran ") + : localize('chat.terminal.runningInSandbox.prefix', "Running "); + const suffixText = localize('chat.terminal.sandbox.suffix', " in sandbox"); + labelElement.appendChild(document.createTextNode(prefixText)); + const codeElement = document.createElement('code'); + codeElement.textContent = this._commandText; + labelElement.appendChild(codeElement); + labelElement.appendChild(document.createTextNode(suffixText)); + return; + } + const prefixText = this._isComplete ? localize('chat.terminal.ran.prefix', "Ran ") : localize('chat.terminal.running.prefix', "Running "); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts index 93eb27ff036..fcd46cea03e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolConfirmationSubPart.ts @@ -87,12 +87,22 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { } if (state.confirmationMessages?.allowAutoConfirm !== false) { + // Get combination label and precomputed key if present + const approveCombination = state.confirmationMessages?.approveCombination; + const combination = approveCombination + ? { + label: typeof approveCombination.label === 'string' ? approveCombination.label : approveCombination.label.value, + key: approveCombination.key, + } + : undefined; + // Get actions from confirmation service const confirmActions = this.confirmationService.getPreConfirmActions({ toolId: this.toolInvocation.toolId, source: this.toolInvocation.source, parameters: state.parameters, - chatSessionResource: this.context.element.sessionResource + chatSessionResource: this.context.element.sessionResource, + combination, }); for (const action of confirmActions) { @@ -128,6 +138,14 @@ export class ToolConfirmationSubPart extends AbstractToolConfirmationSubPart { return actions; } + protected override useAllowOnceAsPrimary(): boolean { + const state = this.toolInvocation.state.get(); + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { + return !!state.confirmationMessages?.approveCombination; + } + return false; + } + protected createContentElement(): HTMLElement | string { const state = this.toolInvocation.state.get(); if (state.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { 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 03c147cf351..9ba9846de1a 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 @@ -17,7 +17,7 @@ export function isMcpToolInvocation(toolInvocation: IChatToolInvocation | IChatT */ export function shouldShimmerForTool(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { if (isMcpToolInvocation(toolInvocation)) { - return true; + return !IChatToolInvocation.isComplete(toolInvocation); } if (toolInvocation.toolId === 'copilot_askQuestions' || toolInvocation.toolId === 'vscode_askQuestions') { return false; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts index 61e94fed177..b19c0ce3e3c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatToolProgressPart.ts @@ -36,15 +36,6 @@ export class ChatToolProgressSubPart extends BaseChatToolInvocationSubPart { super(toolInvocation); this.domNode = this.createProgressPart(); - - // Toggle show-checkmarks class for the accessibility setting - const updateCheckmarks = () => this.domNode.classList.toggle('show-checkmarks', !!this.configurationService.getValue(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)); - updateCheckmarks(); - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)) { - updateCheckmarks(); - } - })); } private createProgressPart(): HTMLElement { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index be0d5847e3c..ea350214eac 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -745,6 +745,17 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer templateData.rowContainer.classList.toggle('show-checkmarks', !!this.configService.getValue(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)); + updateContainerCheckmarks(); + templateData.elementDisposables.add(this.configService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)) { + updateContainerCheckmarks(); + } + })); + if (!this.rendererOptions.noHeader) { this.renderAvatar(element, templateData); } @@ -760,8 +771,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(ChatConfiguration.CheckpointsEnabled) - && (this.rendererOptions.restorable ?? true); + && supportsForkOrRestoration; const isPendingRequest = isRequestVM(element) && !!element.pendingKind; templateData.checkpointContainer.classList.toggle('hidden', isResponseVM(element) || isPendingRequest || !(checkpointEnabled)); @@ -929,14 +941,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer templateData.detail.classList.toggle('show-checkmarks', !!this.configService.getValue(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)); - updateCheckmarks(); - templateData.elementDisposables.add(this.configService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(AccessibilityWorkbenchSettingId.ShowChatCheckmarks)) { - updateCheckmarks(); - } - })); } } @@ -1021,6 +1025,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part) && isMcpToolInvocation(part))) { + return false; + } + // Show if no content, only "used references", ends with a complete tool call, or ends with complete text edits and there is no incomplete tool call (edits are still being applied some time after they are all generated) const lastPart = findLast(partsToRender, part => part.kind !== 'markdownContent' || part.content.value.trim().length > 0); @@ -1244,7 +1253,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer, contentForThisTurn: ReadonlyArray, element: IChatResponseViewModel, elementIndex: number, templateData: IChatListItemTemplate): void { const renderedParts = templateData.renderedParts ?? []; templateData.renderedParts = renderedParts; - const lastMarkdownIndex = partsToRender.findLastIndex(part => part?.kind === 'markdownContent'); let codeBlockStartIndex = 0; let treeStartIndex = 0; partsToRender.forEach((partToRender, contentIndex) => { @@ -1260,7 +1268,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer, contentToRender: ReadonlyArray, element: ChatTreeItem): ReadonlyArray { const diff: (IChatRendererContent | null)[] = []; - const elementIsComplete = isResponseVM(element) && element.isComplete; - const lastMarkdownContentIndex = contentToRender.findLastIndex(part => part.kind === 'markdownContent'); for (let i = 0; i < contentToRender.length; i++) { const content = contentToRender[i]; const renderedPart = renderedParts[i]; - const isFinalAnswerPart = content.kind === 'markdownContent' && i === lastMarkdownContentIndex && elementIsComplete; - - if (isFinalAnswerPart && this.isRenderedPartInsideThinking(renderedPart)) { - diff.push(content); - continue; - } if (!renderedPart || !renderedPart.hasSameContent(content, contentToRender.slice(i + 1), element)) { diff.push(content); @@ -1497,13 +1496,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { + const createToolPart = (): { domNode: HTMLElement; disposable: ChatToolInvocationPart; part: ChatToolInvocationPart } => { lazilyCreatedPart = this.instantiationService.createInstance(ChatToolInvocationPart, toolInvocation, context, this.chatContentMarkdownRenderer, this._contentReferencesListPool, this._toolEditorPool, () => this._currentLayoutWidth.get(), this._toolInvocationCodeBlockCollection, this._announcedToolProgressKeys, codeBlockStartIndex); this.handleRenderedCodeblocks(context.element, lazilyCreatedPart, codeBlockStartIndex); - return { domNode: lazilyCreatedPart.domNode, part: lazilyCreatedPart }; + return { domNode: lazilyCreatedPart.domNode, disposable: lazilyCreatedPart, part: lazilyCreatedPart }; }; // handling for when we want to put tool invocations inside a thinking part @@ -2028,7 +2020,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer ChatToolInvocationPart | undefined, - createToolPart: () => { domNode: HTMLElement; part: ChatToolInvocationPart }, + createToolPart: () => { domNode: HTMLElement; disposable: ChatToolInvocationPart; part: ChatToolInvocationPart }, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate ): void { @@ -2360,9 +2352,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer c.kind === 'thinking' || c.kind === 'toolInvocation' || c.kind === 'toolInvocationSerialized') : -1; - const isFinalAnswerPart = isFinalRenderPass && context.contentIndex > lastPinnedPartIndex; const isBlankMarkdown = !markdown.content.value.trim(); // Don't finalize thinking if the markdown has an incomplete codeblock with a // vscode_codeblock_uri tag — the isEdit annotation may not have arrived yet. @@ -2370,7 +2359,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.agent.thinking.collapsedTools'); - if (isResponseVM(context.element) && collapsedToolsMode !== CollapsedToolsDisplayMode.Off && !isFinalAnswerPart) { + if (isResponseVM(context.element) && collapsedToolsMode !== CollapsedToolsDisplayMode.Off) { // append to thinking part when the codeblock is complete const isComplete = this.isCodeblockComplete(markdown, context.element); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 56c25f2ceba..ee6806961e6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -17,7 +17,6 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { hash } from '../../../../../base/common/hash.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; -import { mark } from '../../../../../base/common/performance.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, thenIfNotDisposed } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -61,11 +60,12 @@ import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } fro import { ChatRequestQueueKind, ChatSendResult, IChatLocationData, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { IChatSlashCommandService } from '../../common/participants/chatSlashCommands.js'; +import { IChatArtifactsService } from '../../common/tools/chatArtifactsService.js'; 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, ChatPermissionLevel } from '../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, ThinkingDisplayMode } 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'; @@ -173,6 +173,20 @@ type ChatPromptRunClassification = { promptNameHash?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Hashed name of local or user prompt for privacy.' }; }; +type ChatThinkingStyleUsageEvent = { + thinkingStyle: ThinkingDisplayMode; + location: ChatAgentLocation; + requestKind: 'submit' | 'rerun'; +}; + +type ChatThinkingStyleUsageClassification = { + owner: 'justschen'; + comment: 'Event fired when a chat request uses the configured thinking style rendering mode.'; + thinkingStyle: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The configured rendering mode for thinking content.' }; + location: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The location where the request was made.' }; + requestKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the request was a new submit or a rerun.' }; +}; + const supportsAllAttachments: Required = { supportsFileAttachments: true, supportsToolAttachments: true, @@ -288,6 +302,7 @@ export class ChatWidget extends Disposable implements IChatWidget { }; private readonly _lockedToCodingAgentContextKey: IContextKey; private readonly _lockedCodingAgentIdContextKey: IContextKey; + private readonly _chatSessionSupportsForkContextKey: IContextKey; private readonly _agentSupportsAttachmentsContextKey: IContextKey; private readonly _sessionIsEmptyContextKey: IContextKey; private readonly _hasPendingRequestsContextKey: IContextKey; @@ -394,6 +409,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatTodoListService private readonly chatTodoListService: IChatTodoListService, + @IChatArtifactsService private readonly chatArtifactsService: IChatArtifactsService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService, @IChatTipService private readonly chatTipService: IChatTipService, @@ -403,6 +419,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this._lockedToCodingAgentContextKey = ChatContextKeys.lockedToCodingAgent.bindTo(this.contextKeyService); this._lockedCodingAgentIdContextKey = ChatContextKeys.lockedCodingAgentId.bindTo(this.contextKeyService); + this._chatSessionSupportsForkContextKey = ChatContextKeys.chatSessionSupportsFork.bindTo(this.contextKeyService); this._agentSupportsAttachmentsContextKey = ChatContextKeys.agentSupportsAttachments.bindTo(this.contextKeyService); this._sessionIsEmptyContextKey = ChatContextKeys.chatSessionIsEmpty.bindTo(this.contextKeyService); this._hasPendingRequestsContextKey = ChatContextKeys.hasPendingRequests.bindTo(this.contextKeyService); @@ -590,6 +607,12 @@ export class ChatWidget extends Disposable implements IChatWidget { this.inputPart.renderChatTodoListWidget(sessionResource); } })); + + this._register(this.chatArtifactsService.onDidUpdateArtifacts((sessionResource) => { + if (isEqual(this.viewModel?.sessionResource, sessionResource)) { + this.inputPart.renderArtifactsWidget(sessionResource); + } + })); } private _lastSelectedAgent: IChatAgentData | undefined; @@ -2025,6 +2048,9 @@ export class ChatWidget extends Disposable implements IChatWidget { this.onDidChangeItems(); })); this._sessionIsEmptyContextKey.set(model.getRequests().length === 0); + const supportsFork = this.chatSessionsService.sessionSupportsFork(model.sessionResource); + this._chatSessionSupportsForkContextKey.set(supportsFork); + this.listWidget?.updateRendererOptions({ supportsFork }); this._sessionHasDebugDataContextKey.set(this.chatDebugService.getEvents(model.sessionResource).length > 0); let lastSteeringCount = 0; const updatePendingRequestKeys = (announceSteering: boolean) => { @@ -2084,6 +2110,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.renderChatSuggestNextWidget(); this.updateChatInputContext(); this.input.renderChatTodoListWidget(this.viewModel.sessionResource); + this.input.renderArtifactsWidget(this.viewModel.sessionResource); } getFocus(): ChatTreeItem | undefined { @@ -2148,6 +2175,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this._lockedAgent = undefined; this._lockedToCodingAgentContextKey.set(false); this._lockedCodingAgentIdContextKey.set(''); + this._chatSessionSupportsForkContextKey.set(false); this._updateAgentCapabilitiesContextKeys(undefined); // Explicitly update the DOM to reflect unlocked state @@ -2177,10 +2205,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } async acceptInput(query?: string, options?: IChatAcceptInputOptions): Promise { - mark('code/chat/willAcceptInput'); - const result = await this._acceptInput(query ? { query } : undefined, options); - mark('code/chat/didAcceptInput'); - return result; + return this._acceptInput(query ? { query } : undefined, options); } async rerunLastRequest(): Promise { @@ -2200,7 +2225,29 @@ export class ChatWidget extends Disposable implements IChatWidget { userSelectedModelId: this.input.currentLanguageModel, modeInfo: this.input.currentModeInfo, }; - return await this.chatService.resendRequest(lastRequest, options); + const result = await this.chatService.resendRequest(lastRequest, options); + this.logThinkingStyleUsage('rerun'); + return result; + } + + private getConfiguredThinkingStyle(): ThinkingDisplayMode { + const thinkingStyle = this.configurationService.getValue(ChatConfiguration.ThinkingStyle); + switch (thinkingStyle) { + case ThinkingDisplayMode.Collapsed: + case ThinkingDisplayMode.CollapsedPreview: + case ThinkingDisplayMode.FixedScrolling: + return thinkingStyle; + default: + return ThinkingDisplayMode.FixedScrolling; + } + } + + private logThinkingStyleUsage(requestKind: ChatThinkingStyleUsageEvent['requestKind']): void { + this.telemetryService.publicLog2('chat.thinkingStyleUsage', { + thinkingStyle: this.getConfiguredThinkingStyle(), + location: this.location, + requestKind, + }); } private async _applyPromptFileIfSet(requestInput: IChatRequestInputOptions): Promise { @@ -2385,6 +2432,8 @@ export class ChatWidget extends Disposable implements IChatWidget { return; } + this.logThinkingStyleUsage('submit'); + // visibility sync before firing events to hide the welcome view this.updateChatViewVisibility(); this.input.acceptInput(options?.storeToHistory ?? isUserQuery); @@ -2727,11 +2776,18 @@ export class ChatWidget extends Disposable implements IChatWidget { */ private async _autoAttachInstructions({ attachedContext }: IChatRequestInputOptions): Promise { const contribution = this._lockedAgent ? this.chatSessionsService.getChatSessionContribution(this._lockedAgent.id) : undefined; - if (!contribution?.autoAttachReferences) { + + // For contributed session types, default to false for autoAttachReferences. + const isContributedSession = !!contribution; + const autoAttachEnabled = isContributedSession ? + contribution.autoAttachReferences === true : true; + + if (!autoAttachEnabled) { this.logService.debug(`ChatWidget#_autoAttachInstructions: skipped, autoAttachReferences is disabled`); return; } - this.logService.debug(`ChatWidget#_autoAttachInstructions: prompt files are always enabled`); + + this.logService.debug(`ChatWidget#_autoAttachInstructions: prompt files are enabled`); const enabledTools = this.input.currentModeKind === ChatModeKind.Agent ? this.input.selectedToolsModel.userSelectedTools.get() : undefined; const enabledSubAgents = this.input.currentModeKind === ChatModeKind.Agent ? this.input.currentModeObs.get().agents?.get() : undefined; const sessionResource = this._viewModel?.model.sessionResource; 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 7cbffcfeacd..55a40c0a6a2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -114,6 +114,7 @@ import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../chatC import { IChatContentPartRenderContext } from '../chatContentParts/chatContentParts.js'; import { CollapsibleListPool, IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; import { ChatTodoListWidget } from '../chatContentParts/chatTodoListWidget.js'; +import { ChatArtifactsWidget } from '../chatArtifactsWidget.js'; import { ChatDragAndDrop } from '../chatDragAndDrop.js'; import { ChatFollowups } from './chatFollowups.js'; import { ChatInputPartWidgetController } from './chatInputPartWidgets.js'; @@ -218,6 +219,7 @@ 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 _chatArtifactsWidget = this._register(new MutableDisposable()); private readonly _chatQuestionCarouselWidgets = this._register(new DisposableMap()); private readonly _questionCarouselResponseIds = new Map(); private readonly _questionCarouselSessionResources = new Map(); @@ -308,6 +310,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatEditingSessionWidgetContainer!: HTMLElement; private chatInputTodoListWidgetContainer!: HTMLElement; + private chatArtifactsWidgetContainer!: HTMLElement; private chatGettingStartedTipContainer!: HTMLElement; private chatQuestionCarouselContainer!: HTMLElement; private chatInputWidgetsContainer!: HTMLElement; @@ -560,7 +563,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // React to chat session option changes for the active session this._register(this.chatSessionsService.onDidChangeSessionOptions(e => { const sessionResource = this._widget?.viewModel?.model.sessionResource; - if (sessionResource && isEqual(sessionResource, e)) { + if (sessionResource && isEqual(sessionResource, e.sessionResource)) { // Options changed for our current session - refresh pickers this.refreshChatSessionPickers(); } @@ -701,10 +704,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge : mode.label.read(undefined) !== agentId; // Extensions use Label (name) as identifier for custom agents. } if (needsUpdate) { - this.chatSessionsService.notifySessionOptionsChange( + this.chatSessionsService.updateSessionOptions( ctx.chatSessionResource, - [{ optionId: agentOptionId, value: mode.isBuiltin ? '' : modeName }] - ).catch(err => this.logService.error('Failed to notify extension of agent change:', err)); + new Map([[agentOptionId, mode.isBuiltin ? '' : modeName]]) + ); } } } @@ -879,10 +882,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionResource = this._widget?.viewModel?.model.sessionResource; const currentCtx = sessionResource ? this.chatService.getChatSessionFromInternalUri(sessionResource) : undefined; if (currentCtx) { - this.chatSessionsService.notifySessionOptionsChange( - currentCtx.chatSessionResource, - [{ optionId: optionGroup.id, value: option }] - ).catch(err => this.logService.error(`Failed to notify extension of ${optionGroup.id} change:`, err)); + this.chatSessionsService.setSessionOption(currentCtx.chatSessionResource, optionGroup.id, option); } // Refresh pickers to re-evaluate visibility of other option groups @@ -1973,6 +1973,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.chat-question-carousel-widget-container@chatQuestionCarouselContainer'), dom.h('.chat-input-widgets-container@chatInputWidgetsContainer'), dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'), + dom.h('.chat-artifacts-widget-container@chatArtifactsWidgetContainer'), dom.h('.chat-editing-session@chatEditingSessionWidgetContainer'), dom.h('.chat-getting-started-tip-container@chatGettingStartedTipContainer'), dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [ @@ -1996,6 +1997,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge dom.h('.interactive-input-followups@followupsContainer'), dom.h('.chat-input-widgets-container@chatInputWidgetsContainer'), dom.h('.chat-todo-list-widget-container@chatInputTodoListWidgetContainer'), + dom.h('.chat-artifacts-widget-container@chatArtifactsWidgetContainer'), dom.h('.chat-editing-session@chatEditingSessionWidgetContainer'), dom.h('.chat-getting-started-tip-container@chatGettingStartedTipContainer'), dom.h('.interactive-input-and-side-toolbar@inputAndSideToolbar', [ @@ -2036,6 +2038,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } this.chatEditingSessionWidgetContainer = elements.chatEditingSessionWidgetContainer; this.chatInputTodoListWidgetContainer = elements.chatInputTodoListWidgetContainer; + this.chatArtifactsWidgetContainer = elements.chatArtifactsWidgetContainer; this.chatGettingStartedTipContainer = elements.chatGettingStartedTipContainer; this.chatGettingStartedTipContainer.style.display = 'none'; this.chatQuestionCarouselContainer = elements.chatQuestionCarouselContainer; @@ -2686,6 +2689,24 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._chatInputTodoListWidget.value?.clear(sessionResource, force); } + renderArtifactsWidget(chatSessionResource: URI): void { + if (!this.configurationService.getValue(ChatConfiguration.ArtifactsEnabled)) { + return; + } + + if (!this._chatArtifactsWidget.value) { + const widget = this._register(this.instantiationService.createInstance(ChatArtifactsWidget)); + this._chatArtifactsWidget.value = widget; + dom.clearNode(this.chatArtifactsWidgetContainer); + dom.append(this.chatArtifactsWidgetContainer, widget.domNode); + } + this._chatArtifactsWidget.value.render(chatSessionResource); + } + + clearArtifactsWidget(): void { + this._chatArtifactsWidget.value?.hide(); + } + renderQuestionCarousel(carousel: IChatQuestionCarousel, context: IChatContentPartRenderContext, options: IChatQuestionCarouselOptions): ChatQuestionCarouselPart { const carouselKey = carousel.resolveId ?? `${isResponseVM(context.element) ? context.element.requestId : ''}_${context.contentIndex}`; 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 f7bacb6aa00..dd43c63d365 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -14,12 +14,14 @@ import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable } 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 { 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'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; @@ -28,6 +30,7 @@ import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageM import { ChatEntitlement, IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js'; import * as semver from '../../../../../../base/common/semver/semver.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; +import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IUpdateService, StateType } from '../../../../../../platform/update/common/update.js'; function isVersionAtLeast(current: string, required: string): boolean { @@ -74,10 +77,23 @@ type ChatModelChangeEvent = { toModel: string | TelemetryTrustedValue; }; +type ChatModelPickerInteraction = 'disabledModelContactAdminClicked' | 'premiumModelUpgradePlanClicked' | 'otherModelsExpanded' | 'otherModelsCollapsed'; + +type ChatModelPickerInteractionClassification = { + owner: 'sandy081'; + comment: 'Reporting interactions in the chat model picker'; + interaction: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The model picker interaction that occurred' }; +}; + +type ChatModelPickerInteractionEvent = { + interaction: ChatModelPickerInteraction; +}; + function createModelItem( action: IActionWidgetDropdownAction & { section?: string }, model?: ILanguageModelChatMetadataAndIdentifier, hoverPosition?: IHoverPositionOptions, + languageModelsService?: ILanguageModelsService, ): IActionListItem { return { item: action, @@ -87,26 +103,65 @@ 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), position: hoverPosition } : undefined, + hover: model ? { content: getModelHoverContent(model, languageModelsService), position: hoverPosition } : undefined, + submenuActions: action.toolbarActions, }; } +/** + * Returns a short description summarizing the model's current configuration values + * for properties marked with group 'navigation' (e.g., "High", "Medium"). + */ +function getModelConfigurationDescription(model: ILanguageModelChatMetadataAndIdentifier, languageModelsService: ILanguageModelsService): string | undefined { + const schema = model.metadata.configurationSchema; + if (!schema?.properties) { + return undefined; + } + + const currentConfig = languageModelsService.getModelConfiguration(model.identifier) ?? {}; + const parts: string[] = []; + + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (propSchema.group !== 'navigation') { + continue; + } + const value = currentConfig[key] ?? propSchema.default; + if (value === undefined) { + continue; + } + const enumItemLabels = propSchema.enumItemLabels; + const enumIndex = propSchema.enum?.indexOf(value) ?? -1; + const label = enumItemLabels?.[enumIndex] ?? String(value); + parts.push(label); + } + + return parts.length > 0 ? parts.join(', ') : undefined; +} + function createModelAction( model: ILanguageModelChatMetadataAndIdentifier, selectedModelId: string | undefined, onSelect: (model: ILanguageModelChatMetadataAndIdentifier) => void, + languageModelsService: ILanguageModelsService, section?: string, ): IActionWidgetDropdownAction & { section?: string } { + const toolbarActions = languageModelsService.getModelConfigurationActions(model.identifier); + const configDescription = getModelConfigurationDescription(model, languageModelsService); + const baseDescription = model.metadata.multiplier ?? model.metadata.detail; + const description = configDescription && baseDescription + ? `${configDescription} · ${baseDescription}` + : configDescription ?? baseDescription; return { id: model.identifier, enabled: true, icon: model.metadata.statusIcon, checked: model.identifier === selectedModelId, class: undefined, - description: model.metadata.multiplier ?? model.metadata.detail, + description, tooltip: model.metadata.name, label: model.metadata.name, section, + toolbarActions: toolbarActions && toolbarActions.length > 0 ? toolbarActions : undefined, run: () => onSelect(model), }; } @@ -158,6 +213,7 @@ export function buildModelPickerItems( showUnavailableFeatured: boolean, showFeatured: boolean, hoverPosition?: IHoverPositionOptions, + languageModelsService?: ILanguageModelsService, ): IActionListItem[] { const items: IActionListItem[] = []; if (models.length === 0) { @@ -209,7 +265,7 @@ export function buildModelPickerItems( 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)); + items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect, languageModelsService!), autoModel, hoverPosition, languageModelsService)); } // --- 2. Promoted section (selected + recently used + featured) --- @@ -297,7 +353,7 @@ export function buildModelPickerItems( for (const item of promotedItems) { if (item.kind === 'available') { - items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect), item.model, hoverPosition)); + items.push(createModelItem(createModelAction(item.model, selectedModelId, onSelect, languageModelsService!), item.model, hoverPosition, languageModelsService)); } else { items.push(createUnavailableModelItem(item.id, item.entry, item.reason, manageSettingsUrl, updateStateType, undefined, hoverPosition)); } @@ -350,7 +406,7 @@ export function buildModelPickerItems( 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)); + items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, languageModelsService!, ModelPickerSection.Other), model, hoverPosition, languageModelsService)); } } } @@ -372,7 +428,7 @@ export function buildModelPickerItems( // 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, hoverPosition)); + items.push(createModelItem(createModelAction(autoModel, selectedModelId, onSelect, languageModelsService!), autoModel, hoverPosition, languageModelsService)); } const sortedModels = models .filter(m => m !== autoModel) @@ -381,7 +437,7 @@ 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, hoverPosition)); + items.push(createModelItem(createModelAction(model, selectedModelId, onSelect, languageModelsService!), model, hoverPosition, languageModelsService)); } } @@ -496,13 +552,19 @@ export class ModelPickerWidget extends Disposable { private readonly _hoverPosition: IHoverPositionOptions | undefined, @IActionWidgetService private readonly _actionWidgetService: IActionWidgetService, @ICommandService private readonly _commandService: ICommandService, + @IOpenerService private readonly _openerService: IOpenerService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IProductService private readonly _productService: IProductService, @IChatEntitlementService private readonly _entitlementService: IChatEntitlementService, @IUpdateService private readonly _updateService: IUpdateService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, ) { super(); + + this._register(this._languageModelsService.onDidChangeLanguageModels(() => { + this._renderLabel(); + })); } setHideChevrons(hideChevrons: IObservable): void { @@ -587,6 +649,10 @@ export class ModelPickerWidget extends Disposable { const controlModelsForTier = isPro ? manifest.paid : manifest.free; const canShowManageModelsAction = this._delegate.showManageModelsAction() && shouldShowManageModelsAction(this._entitlementService); const manageModelsAction = canShowManageModelsAction ? createManageModelsAction(this._commandService) : undefined; + const logModelPickerInteraction = (interaction: ChatModelPickerInteraction) => { + this._telemetryService.publicLog2('chat.modelPickerInteraction', { interaction }); + }; + const manageSettingsUrl = this._productService.defaultChatAgent?.manageSettingsUrl; const items = buildModelPickerItems( models, this._selectedModel?.identifier, @@ -595,13 +661,14 @@ export class ModelPickerWidget extends Disposable { this._productService.version, this._updateService.state.type, onSelect, - this._productService.defaultChatAgent?.manageSettingsUrl, + manageSettingsUrl, this._delegate.useGroupedModelPicker(), !showFilter ? manageModelsAction : undefined, this._entitlementService, this._delegate.showUnavailableFeatured(), this._delegate.showFeatured(), this._hoverPosition, + this._languageModelsService, ); const listOptions = { @@ -610,6 +677,19 @@ export class ModelPickerWidget extends Disposable { filterActions: showFilter && manageModelsAction ? [manageModelsAction] : undefined, focusFilterOnOpen: true, collapsedByDefault: new Set([ModelPickerSection.Other]), + onDidToggleSection: (section: string, collapsed: boolean) => { + if (section === ModelPickerSection.Other) { + logModelPickerInteraction(collapsed ? 'otherModelsCollapsed' : 'otherModelsExpanded'); + } + }, + linkHandler: (uri: URI) => { + if (uri.scheme === 'command' && uri.path === 'workbench.action.chat.upgradePlan') { + logModelPickerInteraction('premiumModelUpgradePlanClicked'); + } else if (manageSettingsUrl && this._uriIdentityService.extUri.isEqual(uri, URI.parse(manageSettingsUrl))) { + logModelPickerInteraction('disabledModelContactAdminClicked'); + } + void this._openerService.open(uri, { allowCommands: true }); + }, minWidth: 200, }; const previouslyFocusedElement = dom.getActiveElement(); @@ -674,7 +754,14 @@ export class ModelPickerWidget extends Disposable { domChildren.push(iconElement); } - domChildren.push(dom.$('span.chat-input-picker-label', undefined, name ?? localize('chat.modelPicker.auto', "Auto"))); + const modelLabel = name ?? localize('chat.modelPicker.auto', "Auto"); + const configDescription = this._selectedModel + ? getModelConfigurationDescription(this._selectedModel, this._languageModelsService) + : undefined; + const fullLabel = configDescription + ? `${modelLabel} · ${configDescription}` + : modelLabel; + domChildren.push(dom.$('span.chat-input-picker-label', undefined, fullLabel)); // Badge icon between label and chevron if (this._badgeIcon) { @@ -686,13 +773,12 @@ export class ModelPickerWidget extends Disposable { dom.reset(this._domNode, ...domChildren); // Aria - const modelName = this._selectedModel?.metadata.name ?? localize('chat.modelPicker.auto', "Auto"); - this._domNode.ariaLabel = localize('chat.modelPicker.ariaLabel', "Pick Model, {0}", modelName); + this._domNode.ariaLabel = localize('chat.modelPicker.ariaLabel', "Pick Model, {0}", fullLabel); } } -function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): MarkdownString { +function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier, languageModelsService?: ILanguageModelsService): MarkdownString { const isAuto = model.metadata.id === 'auto' && model.metadata.vendor === 'copilot'; const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); markdown.appendMarkdown(`**${model.metadata.name}**`); @@ -718,6 +804,25 @@ function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier): M markdown.appendText(`\n`); } + if (languageModelsService) { + const schema = model.metadata.configurationSchema; + if (schema?.properties) { + const currentConfig = languageModelsService.getModelConfiguration(model.identifier) ?? {}; + for (const [key, propSchema] of Object.entries(schema.properties)) { + const value = currentConfig[key] ?? propSchema.default; + if (value === undefined) { + continue; + } + const enumItemLabels = propSchema.enumItemLabels; + const enumIndex = propSchema.enum?.indexOf(value) ?? -1; + const displayValue = enumItemLabels?.[enumIndex] ?? String(value); + const label = propSchema.title ?? key; + markdown.appendText(`${label}: ${displayValue}`); + markdown.appendText(`\n`); + } + } + } + return markdown; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index f7d921a4245..27a805aa710 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -5,6 +5,7 @@ import { IAction } from '../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Iterable } from '../../../../../../base/common/iterator.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; @@ -22,6 +23,7 @@ import { AgentSessionProviders, getAgentCanContinueIn, getAgentSessionProvider, import { ISessionTypePickerDelegate } from '../../chat.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ISessionTypeItem, SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; +import { IGitService } from '../../../../git/common/gitService.js'; /** * Action view item for delegating to a remote session (Background or Cloud). @@ -43,6 +45,7 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt @ICommandService commandService: ICommandService, @IOpenerService openerService: IOpenerService, @ITelemetryService telemetryService: ITelemetryService, + @IGitService private readonly gitService: IGitService, ) { super(action, chatSessionPosition, delegate, pickerOptions, actionWidgetService, keybindingService, contextKeyService, chatSessionsService, commandService, openerService, telemetryService); this._isSessionsWindow = IsSessionsWindowContext.getValue(contextKeyService) === true; @@ -79,6 +82,11 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt return false; } + // In the sessions window, cloud delegation requires a git repository + if (this._isSessionsWindow && type === AgentSessionProviders.Cloud && !this._hasGitRepository()) { + return false; + } + if (contribution && !contribution.canDelegate && activeProvider !== type /* Allow switching back to active type */) { return false; } @@ -86,6 +94,10 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt return this._getSelectedSessionType() !== type; // Always allow switching back to active session } + private _hasGitRepository(): boolean { + return !Iterable.isEmpty(this.gitService.repositories); + } + protected override _isVisible(type: AgentSessionProviders): boolean { // In the sessions window, only show Background and Cloud targets if (this._isSessionsWindow && type === AgentSessionProviders.Local) { @@ -106,6 +118,13 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt return { label: localize('continueInThirdParty', "Continue In (Third Party)"), order: 2, showHeader: false }; } + protected override _getSessionDescription(sessionTypeItem: ISessionTypeItem): string | undefined { + if (this._isSessionsWindow && sessionTypeItem.type === AgentSessionProviders.Cloud && !this._hasGitRepository()) { + return localize('chat.cloudRequiresGit', "Requires a Git repository"); + } + return undefined; + } + protected override _getLearnMore(): IAction { const learnMoreUrl = 'https://aka.ms/vscode-continue-chat-in'; return { 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 7d959ef24f7..921c0f4b396 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 @@ -104,9 +104,6 @@ class SlashCommandCompletions extends Disposable { let customAgentTarget: Target | undefined = undefined; if (widget.lockedAgentId) { - if (!widget.attachmentCapabilities.supportsPromptAttachments) { - return null; - } const sessionResource = widget.viewModel.model.sessionResource; const ctx = sessionResource && chatService.getChatSessionFromInternalUri(sessionResource); customAgentTarget = (ctx ? chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource)) : undefined) ?? Target.Undefined; @@ -137,13 +134,22 @@ class SlashCommandCompletions extends Disposable { return { suggestions: slashCommands .filter(c => { + // silent commands are client-side only... so they're not "attaching anything" + // so this check can be scoped to when the command _does_ attach something before + // checking if the widget supports attachments at all + if (!c.silent && !widget.attachmentCapabilities.supportsPromptAttachments) { + return false; + } + if (c.when && !widget.scopedContextKeyService.contextMatchesRules(c.when)) { + return false; + } if (!widget.lockedAgentId) { return true; } if (c.modes && c.modes.length && !c.modes.includes(ChatModeKind.Agent)) { return false; } - if (c.target && customAgentTarget && c.target !== customAgentTarget) { + if (c.targets && customAgentTarget && !c.targets.includes(customAgentTarget)) { return false; } return true; @@ -192,19 +198,21 @@ class SlashCommandCompletions extends Disposable { } return { - suggestions: slashCommands.map((c, i): CompletionItem => { - const withSlash = `${chatSubcommandLeader}${c.command}`; - return { - label: { label: withSlash, description: c.detail }, - insertText: c.executeImmediately ? '' : `${withSlash} `, - documentation: c.detail, - range, - filterText: `${chatAgentLeader}${c.command}`, - sortText: c.sortText ?? 'z'.repeat(i + 1), - kind: CompletionItemKind.Text, // The icons are disabled here anyway, - command: c.executeImmediately ? { id: ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` } satisfies IChatExecuteActionContext] } : undefined, - }; - }) + suggestions: slashCommands + .filter(c => !c.when || widget.scopedContextKeyService.contextMatchesRules(c.when)) + .map((c, i): CompletionItem => { + const withSlash = `${chatSubcommandLeader}${c.command}`; + return { + label: { label: withSlash, description: c.detail }, + insertText: c.executeImmediately ? '' : `${withSlash} `, + documentation: c.detail, + range, + filterText: `${chatAgentLeader}${c.command}`, + sortText: c.sortText ?? 'z'.repeat(i + 1), + kind: CompletionItemKind.Text, // The icons are disabled here anyway, + command: c.executeImmediately ? { id: ChatSubmitAction.ID, title: withSlash, arguments: [{ widget, inputValue: `${withSlash} ` } satisfies IChatExecuteActionContext] } : 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 0bf73505e4e..d22c7463cf9 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 @@ -8,7 +8,9 @@ import { Disposable, MutableDisposable } from '../../../../../../../base/common/ import { autorun } from '../../../../../../../base/common/observable.js'; import { themeColorFromId } from '../../../../../../../base/common/themables.js'; import { URI } from '../../../../../../../base/common/uri.js'; +import { MouseTargetType } from '../../../../../../../editor/browser/editorBrowser.js'; import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'; +import { Position } from '../../../../../../../editor/common/core/position.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../../../../editor/common/editorCommon.js'; import { TrackedRangeStickiness } from '../../../../../../../editor/common/model.js'; @@ -17,6 +19,7 @@ import { ILabelService } from '../../../../../../../platform/label/common/label. import { inputPlaceholderForeground } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentService } from '../../../../common/participants/chatAgents.js'; +import { localize } from '../../../../../../../nls.js'; 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'; @@ -29,10 +32,12 @@ import { NativeEditContextRegistry } from '../../../../../../../editor/browser/c import { TextAreaEditContextRegistry } from '../../../../../../../editor/browser/controller/editContext/textArea/textAreaEditContextRegistry.js'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; import { ThrottledDelayer } from '../../../../../../../base/common/async.js'; +import { IEditorService } from '../../../../../../services/editor/common/editorService.js'; const decorationDescription = 'chat'; const placeholderDecorationType = 'chat-session-detail'; const slashCommandTextDecorationType = 'chat-session-text'; +const clickableSlashPromptTextDecorationType = 'chat-session-clickable-text'; const variableTextDecorationType = 'chat-variable-text'; function agentAndCommandToKey(agent: IChatAgentData, subcommand: string | undefined): string { @@ -69,6 +74,8 @@ class InputEditorDecorations extends Disposable { public readonly id = 'inputEditorDecorations'; private readonly previouslyUsedAgents = new Set(); + private clickablePromptSlashCommand: { range: Range; uri: URI } | undefined; + private mouseDownPromptSlashCommand: { position: Position; uri: URI; range: Range } | undefined; private readonly viewModelDisposables = this._register(new MutableDisposable()); @@ -82,6 +89,7 @@ class InputEditorDecorations extends Disposable { @IChatAgentService private readonly chatAgentService: IChatAgentService, @ILabelService private readonly labelService: ILabelService, @IPromptsService private readonly promptsService: IPromptsService, + @IEditorService private readonly editorService: IEditorService, ) { super(); @@ -97,6 +105,38 @@ class InputEditorDecorations extends Disposable { this._register(this.widget.onDidSubmitAgent((e) => { this.previouslyUsedAgents.add(agentAndCommandToKey(e.agent, e.slashCommand?.name)); })); + this._register(this.widget.inputEditor.onMouseDown(e => { + this.mouseDownPromptSlashCommand = undefined; + + if (!e.event.leftButton || e.target.type !== MouseTargetType.CONTENT_TEXT || !e.target.position) { + return; + } + + const clickablePromptSlashCommand = this.clickablePromptSlashCommand; + if (!clickablePromptSlashCommand || !clickablePromptSlashCommand.range.containsPosition(e.target.position)) { + return; + } + + this.mouseDownPromptSlashCommand = { + position: Position.lift(e.target.position), + uri: clickablePromptSlashCommand.uri, + range: clickablePromptSlashCommand.range, + }; + })); + this._register(this.widget.inputEditor.onMouseUp(e => { + const mouseDownPromptSlashCommand = this.mouseDownPromptSlashCommand; + this.mouseDownPromptSlashCommand = undefined; + + if (!mouseDownPromptSlashCommand || e.target.type !== MouseTargetType.CONTENT_TEXT || !e.target.position) { + return; + } + + if (!mouseDownPromptSlashCommand.range.containsPosition(e.target.position) || !Position.equals(mouseDownPromptSlashCommand.position, e.target.position)) { + return; + } + + void this.editorService.openEditor({ resource: mouseDownPromptSlashCommand.uri }); + })); this._register(this.chatAgentService.onDidChangeAgents(() => this.triggerInputEditorDecorationsUpdate())); this._register(this.promptsService.onDidChangeSlashCommands(() => this.triggerInputEditorDecorationsUpdate())); this._register(autorun(reader => { @@ -128,6 +168,12 @@ class InputEditorDecorations extends Disposable { backgroundColor: themeColorFromId(chatSlashCommandBackground), borderRadius: '3px' })); + this._register(this.codeEditorService.registerDecorationType(decorationDescription, clickableSlashPromptTextDecorationType, { + color: themeColorFromId(chatSlashCommandForeground), + backgroundColor: themeColorFromId(chatSlashCommandBackground), + borderRadius: '3px', + cursor: 'pointer' + })); this._register(this.codeEditorService.registerDecorationType(decorationDescription, variableTextDecorationType, { color: themeColorFromId(chatSlashCommandForeground), backgroundColor: themeColorFromId(chatSlashCommandBackground), @@ -253,6 +299,8 @@ class InputEditorDecorations extends Disposable { } private async updateAsyncInputEditorDecorations(token: CancellationToken): Promise { + this.clickablePromptSlashCommand = undefined; + this.widget.inputEditor.setDecorationsByType(decorationDescription, clickableSlashPromptTextDecorationType, []); const parsedRequest = this.widget.parsedInput.parts; @@ -299,7 +347,21 @@ class InputEditorDecorations extends Disposable { } if (slashPromptPart && promptSlashCommand) { - textDecorations.push({ range: slashPromptPart.editorRange }); + this.clickablePromptSlashCommand = { + range: Range.lift(slashPromptPart.editorRange), + uri: promptSlashCommand.promptPath.uri, + }; + const promptHoverMessage = new MarkdownString(); + promptHoverMessage.appendText(localize( + 'chatInput.promptSlashCommand.open', + "Click to open {0}", + this.labelService.getUriLabel(promptSlashCommand.promptPath.uri, { relative: true }) + )); + const promptDecoration = { + range: slashPromptPart.editorRange, + hoverMessage: promptHoverMessage, + }; + this.widget.inputEditor.setDecorationsByType(decorationDescription, clickableSlashPromptTextDecorationType, [promptDecoration]); } this.widget.inputEditor.setDecorationsByType(decorationDescription, slashCommandTextDecorationType, textDecorations); 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 4206cfe9f1a..8ec205d7f37 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -850,6 +850,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container, .interactive-input-part:has(.chat-todo-list-widget-container > .chat-todo-list-widget.has-todos) .chat-input-container, +.interactive-input-part:has(.chat-artifacts-widget-container > .chat-artifacts-widget:not([style*="display: none"])) .chat-input-container, .interactive-input-part:has(.chat-input-widgets-container > .chat-status-widget:not([style*="display: none"])) .chat-input-container, .interactive-input-part:has(.chat-getting-started-tip-container > .chat-tip-widget) .chat-input-container { /* Remove top border radius when editing session, todo list, or status widget is present */ @@ -878,11 +879,16 @@ have to be updated for changes to the rules above, or to support more deeply nes overflow: hidden; } -.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-editing-session .chat-editing-session-container { +/* Remove top radius from widgets that follow another visible widget */ +.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-artifacts-widget-container .chat-artifacts-widget, +.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-editing-session .chat-editing-session-container, +.interactive-session .interactive-input-part > .chat-todo-list-widget-container:has(.chat-todo-list-widget.has-todos) + .chat-artifacts-widget-container + .chat-editing-session .chat-editing-session-container, +.interactive-session .interactive-input-part > .chat-artifacts-widget-container:has(.chat-artifacts-widget:not([style*="display: none"])) + .chat-editing-session .chat-editing-session-container { border-top-left-radius: 0; border-top-right-radius: 0; } + .interactive-session .chat-editing-session .monaco-list-row .chat-collapsible-list-action-bar { padding-left: 5px; display: none; @@ -1106,6 +1112,14 @@ have to be updated for changes to the rules above, or to support more deeply nes position: relative; } + +.interactive-session .interactive-input-part > .chat-artifacts-widget-container { + margin-bottom: -4px; + width: 100%; + position: relative; +} + + /* Chat Todo List Widget Container - mirrors chat-editing-session styling */ .interactive-session .interactive-input-part > .chat-todo-list-widget-container { margin-bottom: -4px; @@ -2146,6 +2160,97 @@ have to be updated for changes to the rules above, or to support more deeply nes border: none; } +/* Chat artifacts widget — collapsible list of session artifacts */ +.chat-artifacts-widget { + padding: 4px 3px 4px 3px; + box-sizing: border-box; + border: 1px solid var(--vscode-input-border, transparent); + background-color: var(--vscode-editor-background); + border-bottom: none; + border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0; + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} + +.chat-artifacts-widget .chat-artifacts-expand { + width: 100%; +} + +.chat-artifacts-widget .chat-artifacts-expand .monaco-button { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + justify-content: space-between; + width: 100%; + background-color: transparent; + border-color: transparent; + color: var(--vscode-foreground); + padding: 0; + min-width: unset; +} + +.chat-artifacts-widget .chat-artifacts-expand .monaco-button:focus:not(:focus-visible) { + outline: none; +} + +.chat-artifacts-widget .chat-artifacts-expand .chat-artifacts-title-section { + padding-left: 3px; + display: flex; + align-items: center; + flex: 1; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 22px; +} + +.chat-artifacts-widget .chat-artifacts-expand .chat-artifacts-title-section .codicon { + font-size: 16px; + line-height: 22px; + flex-shrink: 0; + margin-right: 3px; +} + +.chat-artifacts-widget .chat-artifacts-list { + width: 100%; + padding: 0; + box-sizing: border-box; +} + +.chat-artifacts-widget .chat-artifacts-list .monaco-list .monaco-list-row { + border-radius: var(--vscode-cornerRadius-small); +} + +.chat-artifacts-widget .chat-artifacts-list .monaco-list .monaco-list-row:hover { + background-color: var(--vscode-list-hoverBackground) !important; +} + +.chat-artifacts-widget .chat-artifacts-list-row { + display: flex; + align-items: center; + gap: 6px; + padding: 0 6px; + height: 100%; + cursor: pointer; +} + +.chat-artifacts-widget .chat-artifacts-list-icon { + font-size: 14px; + display: flex; + align-items: center; +} + +.chat-artifacts-widget .chat-artifacts-list-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; +} + .interactive-session .checkpoint-file-changes-summary { display: flex; flex-direction: column; @@ -2900,7 +3005,7 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } - .interactive-item-container .header.partially-disabled .detail.show-checkmarks { + .interactive-item-container.show-checkmarks .header.partially-disabled .detail { margin-left: 4px; .codicon-check { 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 db74594fd35..bdc1c632cb0 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -8,11 +8,12 @@ import { $, addDisposableListener, append, EventHelper, EventType, getWindow, se import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; import { Button } from '../../../../../../base/browser/ui/button/button.js'; import { Orientation, Sash } from '../../../../../../base/browser/ui/sash/sash.js'; -import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; import { MutableDisposable, toDisposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { MarshalledId } from '../../../../../../base/common/marshallingIds.js'; import { autorun, IReader } from '../../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; @@ -97,6 +98,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private welcomeController: ChatViewWelcomeController | undefined; private restoringSession: Promise | undefined; + private readonly loadSessionCts = this._register(new MutableDisposable()); private readonly modelRef = this._register(new MutableDisposable()); private readonly activityBadge = this._register(new MutableDisposable()); @@ -262,7 +264,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { try { this._widget.setVisible(false); - await this.showModel(modelRef); + await this.showModel(CancellationToken.None, modelRef); } finally { this._widget.setVisible(wasVisible); } @@ -627,6 +629,16 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } })); + // When the currently displayed session is archived, start a new session + this._register(this.agentSessionsService.model.onDidChangeSessionArchivedState(e => { + if (e.isArchived()) { + const currentSessionResource = chatWidget.viewModel?.sessionResource; + if (currentSessionResource && isEqual(currentSessionResource, e.resource)) { + this.clear(); + } + } + })); + // When showing sessions stacked, adjust the height of the sessions list to make room for chat input this._register(autorun(reader => { chatWidget.inputPart.height.read(reader); @@ -687,29 +699,39 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private async _applyModel(): Promise { const sessionResource = this.getTransferredOrPersistedSessionInfo(); const modelRef = sessionResource ? await this.chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None) : undefined; - await this.showModel(modelRef); + await this.showModel(CancellationToken.None, modelRef); } - private async showModel(modelRef?: IChatModelReference | undefined, startNewSession = true): Promise { + private async showModel(token: CancellationToken, modelRef?: IChatModelReference | undefined, startNewSession = true): Promise { const oldModelResource = this.modelRef.value?.object.sessionResource; this.modelRef.value = undefined; let ref: IChatModelReference | undefined; if (startNewSession) { ref = modelRef ?? (this.chatService.transferredSessionResource - ? await this.chatService.acquireOrLoadSession(this.chatService.transferredSessionResource, ChatAgentLocation.Chat, CancellationToken.None) + ? await this.chatService.acquireOrLoadSession(this.chatService.transferredSessionResource, ChatAgentLocation.Chat, token) : this.chatService.startNewLocalSession(ChatAgentLocation.Chat)); if (!ref) { throw new Error('Could not start chat session'); } } + if (token.isCancellationRequested) { + ref?.dispose(); + return undefined; + } + this.modelRef.value = ref; const model = ref?.object; if (model) { await this.updateWidgetLockState(getChatSessionType(model.sessionResource)); // Update widget lock state based on session type + if (token.isCancellationRequested) { + this.modelRef.value = undefined; + return undefined; + } + // remember as model to restore in view state this.viewState.sessionResource = model.sessionResource; } @@ -760,46 +782,75 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } private async clear(): Promise { + // Cancel any in-flight loadSession call to prevent it from + // overwriting the fresh session we are about to create. + this.loadSessionCts.value?.cancel(); // Grab the widget's latest view state because it will be loaded back into the widget this.updateViewState(); - await this.showModel(undefined); + await this.showModel(CancellationToken.None); // Update the toolbar context with new sessionId this.updateActions(); } async loadSession(sessionResource: URI): Promise { + // Cancel any in-flight loadSession call so the last one always wins + this.loadSessionCts.value?.cancel(); + const cts = this.loadSessionCts.value = new CancellationTokenSource(); + const token = cts.token; + // Wait for any in-progress session restore (e.g. from onDidChangeAgents) // to finish first, so our showModel call is guaranteed to be the last one. if (this.restoringSession) { await this.restoringSession; } + if (token.isCancellationRequested) { + return undefined; + } + return this.progressService.withProgress({ location: ChatViewId, delay: 200 }, async () => { let queue: Promise = Promise.resolve(); // A delay here to avoid blinking because only Cloud sessions are slow, most others are fast const clearWidget = disposableTimeout(() => { + // Only clear the current model if this loadSession call is still the active one + // and has not been cancelled. This preserves the "last call wins" behavior. + if (token.isCancellationRequested || this.loadSessionCts.value !== cts) { + return; + } // clear current model without starting a new one - queue = this.showModel(undefined, false).then(() => { }); + queue = this.showModel(token, undefined, false).then(() => { }); }, 100); + const clearWidgetCancellationListener = token.onCancellationRequested(() => clearWidget.dispose()); try { - const newModelRef = await this.chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + const newModelRef = await this.chatService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, token); clearWidget.dispose(); await queue; - return this.showModel(newModelRef); + if (token.isCancellationRequested) { + newModelRef?.dispose(); + return undefined; + } + + return this.showModel(token, newModelRef); } catch (err) { clearWidget.dispose(); await queue; + if (token.isCancellationRequested) { + return undefined; + } + // Recover by starting a fresh empty session so the widget // is not left in a broken state without title or back button. this.logService.error(`Failed to load chat session '${sessionResource.toString()}'`, err); this.notificationService.error(localize('chat.loadSessionFailed', "Failed to open chat session: {0}", toErrorMessage(err))); - return this.showModel(undefined); + return this.showModel(token, undefined); + } finally { + clearWidgetCancellationListener.dispose(); } }); } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 58df5cc8610..64b70535c91 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -132,6 +132,7 @@ export namespace ChatContextKeys { export const agentSessionsViewerVisible = new RawContextKey('agentSessionsViewerVisible', undefined, { type: 'boolean', description: localize('agentSessionsViewerVisible', "Visibility of the agent sessions view in the chat view.") }); export const agentSessionType = new RawContextKey('chatSessionType', '', { type: 'string', description: localize('agentSessionType', "The type of the current agent session item.") }); export const chatSessionSupportsDelegation = new RawContextKey('chatSessionSupportsDelegation', true, { type: 'boolean', description: localize('chatSessionSupportsDelegation', "True when the current session type supports delegation.") }); + export const chatSessionSupportsFork = new RawContextKey('chatSessionSupportsFork', false, { type: 'boolean', description: localize('chatSessionSupportsFork', "True when the current chat session provider supports forking conversations.") }); export const agentSessionSection = new RawContextKey('agentSessionSection', '', { type: 'string', description: localize('agentSessionSection', "The section of the current agent session section item.") }); export const isArchivedAgentSession = new RawContextKey('agentSessionIsArchived', false, { type: 'boolean', description: localize('agentSessionIsArchived', "True when the agent session item is archived.") }); export const isPinnedAgentSession = new RawContextKey('agentSessionIsPinned', false, { type: 'boolean', description: localize('agentSessionIsPinned', "True when the agent session item is pinned.") }); diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 2b6c01066fe..9b2aadb806a 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -13,6 +13,17 @@ import { IChatPromptSlashCommand, PromptsStorage } from './promptSyntax/service/ export const IAICustomizationWorkspaceService = createDecorator('aiCustomizationWorkspaceService'); +/** + * 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 customizations shipped with the application. + */ +export const BUILTIN_STORAGE: AICustomizationPromptsStorage = 'builtin'; + /** * Possible section IDs for the AI Customization Management Editor sidebar. */ diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index 0c7415c0533..21ea0917b9f 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -307,6 +307,8 @@ export interface IAgentFeedbackVariableEntry extends IBaseChatRequestVariableEnt readonly range: IRange; readonly codeSelection?: string; readonly diffHunks?: string; + /** When this item was converted from a PR review comment, the original thread ID. */ + readonly sourcePRReviewCommentId?: string; }>; } diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index d97213d3f3e..e54c6abc168 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -19,6 +19,18 @@ export enum ChatDebugLogLevel { Error = 3 } +/** + * The result of a hook execution. + */ +export enum ChatDebugHookResult { + /** The hook executed successfully (exit code 0). */ + Success = 0, + /** The hook returned a blocking error (exit code 2). */ + Error = 1, + /** The hook returned a non-blocking warning (other non-zero exit codes). */ + NonBlockingError = 2 +} + /** * Common properties shared by all chat debug event types. */ @@ -331,10 +343,26 @@ export interface IChatDebugEventModelTurnContent { readonly sections?: readonly IChatDebugMessageSection[]; } +/** + * Structured hook execution content for a resolved debug event. + * Contains the hook type, command, input, output, and result for rich rendering. + */ +export interface IChatDebugEventHookContent { + readonly kind: 'hook'; + readonly hookType: string; + readonly command?: string; + readonly result?: ChatDebugHookResult; + readonly durationInMillis?: number; + readonly input?: string; + readonly output?: string; + readonly exitCode?: number; + readonly errorMessage?: string; +} + /** * Union of all resolved event content types. */ -export type IChatDebugResolvedEventContent = IChatDebugEventTextContent | IChatDebugEventFileListContent | IChatDebugEventMessageContent | IChatDebugEventToolCallContent | IChatDebugEventModelTurnContent; +export type IChatDebugResolvedEventContent = IChatDebugEventTextContent | IChatDebugEventFileListContent | IChatDebugEventMessageContent | IChatDebugEventToolCallContent | IChatDebugEventModelTurnContent | IChatDebugEventHookContent; /** * Provider interface for debug events. diff --git a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts index 00ff71eda6a..60d45fe1f8d 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugServiceImpl.ts @@ -121,6 +121,7 @@ export class ChatDebugServiceImpl extends Disposable implements IChatDebugServic private static readonly _debugEligibleSchemes = new Set([ LocalChatSessionUri.scheme, // vscode-chat-session (local sessions) 'copilotcli', // Copilot CLI background sessions + 'claude-code', // Claude Code CLI sessions ]); private _isDebugEligibleSession(sessionResource: URI): boolean { diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 61bd25515b6..de3247a59fe 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -31,6 +31,7 @@ import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js import { IChatRequestVariableValue } from '../attachments/chatVariables.js'; import { ChatAgentLocation } from '../constants.js'; import { IPreparedToolInvocation, IToolConfirmationMessages, IToolResult, IToolResultInputOutputDetails, ToolDataSource } from '../tools/languageModelToolsService.js'; +import { ReadonlyChatSessionOptionsMap } from '../chatSessionsService.js'; export interface IChatRequest { message: string; @@ -488,6 +489,8 @@ export interface IChatTerminalToolInvocationData { toolEdited?: string; // command to show in the chat UI (potentially different from what is actually run in the terminal) forDisplay?: string; + // isSandboxWrapped boolean to run in the terminal (potentially different from original command) + isSandboxWrapped?: boolean; }; /** The working directory URI for the terminal */ cwd?: UriComponents; @@ -523,6 +526,10 @@ export interface IChatTerminalToolInvocationData { terminalCommandId?: string; /** Whether the terminal command was started as a background execution */ isBackground?: boolean; + /** Whether the command was explicitly approved to run outside the sandbox */ + requestUnsandboxedExecution?: boolean; + /** The model-provided reason for requesting sandbox bypass */ + requestUnsandboxedExecutionReason?: string; /** Serialized URI for the command that was executed in the terminal */ terminalCommandUri?: UriComponents; /** Serialized output of the executed command */ @@ -603,6 +610,7 @@ export interface IChatToolInvocation { readonly toolId: string; readonly toolCallId: string; readonly subAgentInvocationId?: string; + readonly icon?: ThemeIcon; readonly state: IObservable; generatedTitle?: string; isAttachedToThinking: boolean; @@ -866,6 +874,7 @@ export interface IChatToolInvocationSerialized { isComplete: boolean; toolCallId: string; toolId: string; + readonly icon?: undefined; source: ToolDataSource | undefined; // undefined on pre-1.104 versions readonly subAgentInvocationId?: string; generatedTitle?: string; @@ -1197,35 +1206,35 @@ export interface IChatCompleteResponse { } export interface IChatSessionStats { - fileCount: number; - added: number; - removed: number; + readonly fileCount: number; + readonly added: number; + readonly removed: number; } export type IChatSessionTiming = { /** * Timestamp when the session was created in milliseconds elapsed since January 1, 1970 00:00:00 UTC. */ - created: number; + readonly created: number; /** * Timestamp when the most recent request started in milliseconds elapsed since January 1, 1970 00:00:00 UTC. * * Should be undefined if no requests have been made yet. */ - lastRequestStarted: number | undefined; + readonly lastRequestStarted: number | undefined; /** * Timestamp when the most recent request completed in milliseconds elapsed since January 1, 1970 00:00:00 UTC. * * Should be undefined if the most recent request is still in progress or if no requests have been made yet. */ - lastRequestEnded: number | undefined; + readonly lastRequestEnded: number | undefined; }; interface ILegacyChatSessionTiming { - startTime: number; - endTime?: number; + readonly startTime: number; + readonly endTime?: number; } export function convertLegacyChatSessionTiming(timing: IChatSessionTiming | ILegacyChatSessionTiming): IChatSessionTiming { @@ -1535,7 +1544,7 @@ export interface IChatService { export interface IChatSessionContext { readonly chatSessionResource: URI; - readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | { id: string; name: string } }>; + readonly initialSessionOptions?: ReadonlyChatSessionOptionsMap; } export const KEYWORD_ACTIVIATION_SETTING_ID = 'accessibility.voice.keywordActivation'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 1ee23e9326f..6aea62fe4eb 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -5,7 +5,6 @@ import { DeferredPromise, raceTimeout } from '../../../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { mark } from '../../../../../base/common/performance.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; import { BugIndicatingError, ErrorNoTelemetry } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; @@ -585,9 +584,8 @@ export class ChatService extends Disposable implements IChatService { } private async loadRemoteSession(sessionResource: URI, location: ChatAgentLocation, token: CancellationToken): Promise { - await this.chatSessionService.canResolveChatSession(sessionResource.scheme); - - // Check if session already exists + // Check if session already exists before resolving the provider, + // so we can return a cached model even if the provider was unregistered. { const existingRef = this.acquireExistingSession(sessionResource); if (existingRef) { @@ -595,6 +593,10 @@ export class ChatService extends Disposable implements IChatService { } } + if (!await this.chatSessionService.canResolveChatSession(sessionResource.scheme)) { + return undefined; + } + const providedSession = await this.chatSessionService.getOrCreateChatSession(sessionResource, token); // Make sure we haven't created this in the meantime @@ -746,7 +748,6 @@ export class ChatService extends Disposable implements IChatService { } async resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions): Promise { - mark('code/chat/willResendRequest'); const model = this._sessionModels.get(request.session.sessionResource); if (!model && model !== request.session) { throw new Error(`Unknown session: ${request.session.sessionResource}`); @@ -802,7 +803,6 @@ export class ChatService extends Disposable implements IChatService { } async sendRequest(sessionResource: URI, request: string, options?: IChatSendRequestOptions): Promise { - mark('code/chat/willSendRequest'); this.trace('sendRequest', `sessionResource: ${sessionResource.toString()}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`); @@ -830,8 +830,7 @@ export class ChatService extends Disposable implements IChatService { // Capture session options before loading the remote session, // since the alias registration below may change the lookup. - const sessionOptions = this.chatSessionService.getSessionOptions(sessionResource); - const initialSessionOptions = sessionOptions ? [...sessionOptions].map(([optionId, value]) => ({ optionId, value })) : undefined; + const initialSessionOptions = this.chatSessionService.getSessionOptions(sessionResource); const newItem = await this.chatSessionService.createNewChatSessionItem(getChatSessionType(sessionResource), { prompt: requestText, command: commandPart?.text, initialSessionOptions }, CancellationToken.None); if (newItem) { @@ -847,7 +846,7 @@ export class ChatService extends Disposable implements IChatService { // so that the agent receives them when invoked. model.setContributedChatSession({ chatSessionResource: newItem.resource, - initialSessionOptions: sessionOptions ? [...sessionOptions].map(([optionId, value]) => ({ optionId, value })) : undefined, + initialSessionOptions: initialSessionOptions, }); sessionResource = newItem.resource; @@ -890,7 +889,7 @@ export class ChatService extends Disposable implements IChatService { const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); // This method is only returning whether the request was accepted - don't block on the actual request - const result = { + return { kind: 'sent', newSessionResource, data: { @@ -898,10 +897,7 @@ export class ChatService extends Disposable implements IChatService { agent, slashCommand: agentSlashCommandPart?.command, }, - } as const; - - mark('code/chat/didSendRequest'); - return result; + }; } private parseChatRequest(sessionResource: URI, request: string, location: ChatAgentLocation, options: IChatSendRequestOptions | undefined): IParsedChatRequest { @@ -936,7 +932,6 @@ export class ChatService extends Disposable implements IChatService { } private _sendRequestAsync(model: ChatModel, sessionResource: URI, parsedRequest: IParsedChatRequest, attempt: number, enableCommandDetection: boolean, defaultAgent: IChatAgentData, location: ChatAgentLocation, options?: IChatSendRequestOptions): IChatSendRequestResponseState { - mark('code/chat/willSendRequestAsync'); const followupsCancelToken = this.refreshFollowupsCancellationToken(sessionResource); let request: ChatRequestModel | undefined; const agentPart = parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart); @@ -1129,6 +1124,7 @@ export class ChatService extends Disposable implements IChatService { acceptedConfirmationData: options?.acceptedConfirmationData, rejectedConfirmationData: options?.rejectedConfirmationData, userSelectedModelId: options?.userSelectedModelId, + modelConfiguration: options?.userSelectedModelId ? this.languageModelsService.getModelConfiguration(options.userSelectedModelId) : undefined, userSelectedTools: options?.userSelectedTools?.get(), modeInstructions: options?.modeInfo?.modeInstructions, permissionLevel: options?.modeInfo?.permissionLevel, @@ -1327,7 +1323,6 @@ export class ChatService extends Disposable implements IChatService { this._pendingRequests.set(model.sessionResource, cancellableRequest); this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'sendRequest', chatSessionId: chatSessionResourceToId(model.sessionResource) }); rawResponsePromise.finally(() => { - mark('code/chat/didCompleteRequest'); if (this._pendingRequests.get(model.sessionResource) === cancellableRequest) { this._pendingRequests.deleteAndDispose(model.sessionResource); this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'remove', source: 'sendRequestComplete', requestId: cancellableRequest.requestId, chatSessionId: chatSessionResourceToId(model.sessionResource) }); @@ -1631,36 +1626,17 @@ export class ChatService extends Disposable implements IChatService { return; } - // Detect an in-flight request that was dequeued and started on the old session - const lastRequest = model.lastRequest; - const inFlightRequest = lastRequest?.response && !lastRequest.response.isComplete ? lastRequest : undefined; - // Capture send options before cancelling, since cancellation disposes the tracking - const inFlightSendOptions = inFlightRequest ? this._pendingRequests.get(originalResource)?.sendOptions : undefined; - const pendingRequests = [...model.getPendingRequests()]; - if (!inFlightRequest && pendingRequests.length === 0) { + if (pendingRequests.length === 0) { return; } - // Cancel the in-flight request on the old session - if (inFlightRequest) { - void this.cancelCurrentRequestForSession(originalResource); - } - // Remove each remaining pending request from the original session for (const pending of pendingRequests) { this.removePendingRequest(originalResource, pending.request.id); } - // Re-send the cancelled in-flight request first (it was ahead of the queued ones) - if (inFlightRequest) { - void this.sendRequest(targetResource, inFlightRequest.message.text, { - ...inFlightSendOptions, - queue: ChatRequestQueueKind.Queued, - }); - } - // Re-send remaining queued requests for (const pending of pendingRequests) { void this.sendRequest(targetResource, pending.request.message.text, { diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index bb0fb16361a..bc68d0d77c7 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { Event, IWaitUntil } from '../../../../base/common/event.js'; +import { Event } from '../../../../base/common/event.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable } from '../../../../base/common/observable.js'; @@ -25,48 +25,48 @@ export const enum ChatSessionStatus { } export interface IChatSessionCommandContribution { - name: string; - description: string; - when?: string; + readonly name: string; + readonly description: string; + readonly when?: string; } export interface IChatSessionProviderOptionItem { - id: string; - name: string; - description?: string; - locked?: boolean; - icon?: ThemeIcon; - default?: boolean; + readonly id: string; + readonly name: string; + readonly description?: string; + readonly locked?: boolean; + readonly icon?: ThemeIcon; + readonly default?: boolean; // [key: string]: any; } export interface IChatSessionProviderOptionGroupCommand { - command: string; - title: string; - tooltip?: string; - arguments?: unknown[]; + readonly command: string; + readonly title: string; + readonly tooltip?: string; + readonly arguments?: readonly unknown[]; } export interface IChatSessionProviderOptionGroup { - id: string; - name: string; - description?: string; - items: IChatSessionProviderOptionItem[]; - searchable?: boolean; - onSearch?: (query: string, token: CancellationToken) => Thenable; + readonly id: string; + readonly name: string; + readonly description?: string; + readonly items: readonly IChatSessionProviderOptionItem[]; + readonly searchable?: boolean; + readonly onSearch?: (query: string, token: CancellationToken) => Thenable; /** * A context key expression that controls visibility of this option group picker. * When specified, the picker is only visible when the expression evaluates to true. * The expression can reference other option group values via `chatSessionOption.`. * Example: `"chatSessionOption.models == 'gpt-4'"` */ - when?: string; - icon?: ThemeIcon; + readonly when?: string; + readonly icon?: ThemeIcon; /** * Custom commands to show in the option group's picker UI. * These will be shown in a separate section at the end of the picker. */ - commands?: IChatSessionProviderOptionGroupCommand[]; + readonly commands?: readonly IChatSessionProviderOptionGroupCommand[]; } export interface IChatSessionsExtensionPoint { @@ -107,28 +107,28 @@ export interface IChatSessionsExtensionPoint { } export interface IChatSessionItem { - resource: URI; - label: string; - iconPath?: ThemeIcon; - badge?: string | IMarkdownString; - description?: string | IMarkdownString; - status?: ChatSessionStatus; - tooltip?: string | IMarkdownString; - timing: IChatSessionTiming; - changes?: { - files: number; - insertions: number; - deletions: number; + readonly resource: URI; + readonly label: string; + readonly iconPath?: ThemeIcon; + readonly badge?: string | IMarkdownString; + readonly description?: string | IMarkdownString; + readonly status?: ChatSessionStatus; + readonly tooltip?: string | IMarkdownString; + readonly timing: IChatSessionTiming; + readonly changes?: { + readonly files: number; + readonly insertions: number; + readonly deletions: number; } | readonly IChatSessionFileChange[] | readonly IChatSessionFileChange2[]; - archived?: boolean; - metadata?: { readonly [key: string]: unknown }; + readonly archived?: boolean; + readonly metadata?: { readonly [key: string]: unknown }; } export interface IChatSessionFileChange { - modifiedUri: URI; - originalUri?: URI; - insertions: number; - deletions: number; + readonly modifiedUri: URI; + readonly originalUri?: URI; + readonly insertions: number; + readonly deletions: number; } export interface IChatSessionFileChange2 { @@ -153,6 +153,8 @@ export type IChatSessionHistoryItem = { participant: string; }; +export type IChatSessionRequestHistoryItem = Extract; + /** * The session type used for local agent chat sessions. */ @@ -172,11 +174,8 @@ export interface IChatSession extends IDisposable { readonly history: readonly IChatSessionHistoryItem[]; - /** - * Session options as key-value pairs. Keys correspond to option group IDs (e.g., 'models', 'subagents') - * and values are either the selected option item IDs (string) or full option items (for locked state). - */ - readonly options?: Record; + + readonly options?: ReadonlyChatSessionOptionsMap; readonly progressObs?: IObservable; readonly isCompleteObs?: IObservable; @@ -186,8 +185,8 @@ export interface IChatSession extends IDisposable { * Editing session transferred from a previously-untitled chat session in `onDidCommitChatSessionItem`. */ transferredState?: { - editingSession: IChatEditingSession | undefined; - inputState: ISerializableChatModelInputState | undefined; + readonly editingSession: IChatEditingSession | undefined; + readonly inputState: ISerializableChatModelInputState | undefined; }; requestHandler?: ( @@ -197,6 +196,14 @@ export interface IChatSession extends IDisposable { history: any[], // TODO: Nail down types token: CancellationToken ) => Promise; + + /** + * Forks the session from the given request point. + * @param request The request history item to fork from, or undefined to fork from the end. + * @param token Cancellation token. + * @returns The forked session item. The promise is rejected if forking fails. + */ + forkSession?: (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => Promise; } export interface IChatSessionContentProvider { @@ -207,7 +214,7 @@ export interface IChatNewSessionRequest { readonly prompt: string; readonly command?: string; - readonly initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>; + readonly initialSessionOptions?: ReadonlyChatSessionOptionsMap; } export interface IChatSessionItemsDelta { @@ -226,19 +233,49 @@ export interface IChatSessionItemController { newChatSessionItem?(request: IChatNewSessionRequest, token: CancellationToken): Promise; } -/** - * Event fired when session options need to be sent to the extension. - * Extends IWaitUntil to allow listeners to register async work that will be awaited. - */ -export interface IChatSessionOptionsWillNotifyExtensionEvent extends IWaitUntil { +export interface IChatSessionOptionsChangeEvent { readonly sessionResource: URI; - readonly updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>; + readonly updates: ReadonlyMap; } export type ResolvedChatSessionsExtensionPoint = Omit & { readonly icon: ThemeIcon | URI | undefined; }; +/** + * Session options as key-value pairs. + * + * Keys correspond to option group IDs (e.g., 'models', 'subagents') and values are either the selected option item IDs (string) or full option items (for locked state). + */ +export type ChatSessionOptionsMap = Map; + +export namespace ChatSessionOptionsMap { + export function fromRecord(obj: { [key: string]: string | IChatSessionProviderOptionItem }): ChatSessionOptionsMap { + return new Map(Object.entries(obj)); + } + + export function toRecord(map: ReadonlyChatSessionOptionsMap): Record { + const record: Record = Object.create(null); + for (const [key, value] of map) { + record[key] = value; + } + return record; + } + + export function toStrValueArray(map: ReadonlyChatSessionOptionsMap | undefined): Array<{ optionId: string; value: string }> | undefined { + if (!map) { + return undefined; + } + return Array.from(map, ([optionId, value]) => ({ optionId, value: typeof value === 'string' ? value : value.id })); + } +} + +/** + * Readonly version of {@link ChatSessionOptionsMap} + */ +export type ReadonlyChatSessionOptionsMap = ReadonlyMap; + + export const IChatSessionsService = createDecorator('chatSessionsService'); export interface IChatSessionsService { @@ -278,8 +315,8 @@ export interface IChatSessionsService { */ refreshChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise; - reportInProgress(chatSessionType: string, count: number): void; - getInProgress(): { displayName: string; count: number }[]; + /** @deprecated Use `getChatSessionItems` */ + getInProgress(): { chatSessionType: string; count: number }[]; // #endregion @@ -293,14 +330,15 @@ export interface IChatSessionsService { getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise; hasAnySessionOptions(sessionResource: URI): boolean; - getSessionOptions(sessionResource: URI): Map | undefined; + getSessionOptions(sessionResource: URI): ReadonlyChatSessionOptionsMap | undefined; getSessionOption(sessionResource: URI, optionId: string): string | IChatSessionProviderOptionItem | undefined; setSessionOption(sessionResource: URI, optionId: string, value: string | IChatSessionProviderOptionItem): boolean; + updateSessionOptions(sessionResource: URI, updates: ReadonlyChatSessionOptionsMap): boolean; /** * Fired when options for a chat session change. */ - readonly onDidChangeSessionOptions: Event; + readonly onDidChangeSessionOptions: Event; /** * Get the capabilities for a specific session type @@ -324,20 +362,27 @@ export interface IChatSessionsService { */ supportsDelegationForSessionType(chatSessionType: string): boolean; + /** + * Returns whether the loaded session supports forking conversations. + */ + sessionSupportsFork(sessionResource: URI): boolean; + + /** + * Forks a contributed chat session from the given request point. + * @param sessionResource The session resource to fork. + * @param request The request history item to fork from, or undefined to fork from the end. + * @param token Cancellation token. + * @returns The forked session item, or undefined if forking failed. + */ + forkChatSession(sessionResource: URI, request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken): Promise; + readonly onDidChangeOptionGroups: Event; getOptionGroupsForSessionType(chatSessionType: string): IChatSessionProviderOptionGroup[] | undefined; setOptionGroupsForSessionType(chatSessionType: string, handle: number, optionGroups?: IChatSessionProviderOptionGroup[]): void; - getNewSessionOptionsForSessionType(chatSessionType: string): Record | undefined; - setNewSessionOptionsForSessionType(chatSessionType: string, options: Record): void; - /** - * Event fired when session options change and need to be sent to the extension. - * MainThreadChatSessions subscribes to this to forward changes to the extension host. - * Uses IWaitUntil pattern to allow listeners to register async work. - */ - readonly onRequestNotifyExtension: Event; - notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise; + getNewSessionOptionsForSessionType(chatSessionType: string): ReadonlyChatSessionOptionsMap | undefined; + setNewSessionOptionsForSessionType(chatSessionType: string, options: ReadonlyChatSessionOptionsMap): void; 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 5d51053f843..75d30b88a44 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -48,14 +48,18 @@ export enum ChatConfiguration { ChatViewProgressBadgeEnabled = 'chat.viewProgressBadge.enabled', ChatContextUsageEnabled = 'chat.contextUsage.enabled', SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled', + SubagentsMaxDepth = 'chat.subagents.maxDepth', ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress', RestoreLastPanelSession = 'chat.restoreLastPanelSession', ExitAfterDelegation = 'chat.exitAfterDelegation', ExplainChangesEnabled = 'chat.editing.explainChanges.enabled', + RevealNextChangeOnResolve = 'chat.editing.revealNextChangeOnResolve', GrowthNotificationEnabled = 'chat.growthNotification.enabled', ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', + ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', AutopilotEnabled = 'chat.autopilot.enabled', - ImageCarouselEnabled = 'chat.imageCarousel.enabled', + ImageCarouselEnabled = 'imageCarousel.chat.enabled', + ArtifactsEnabled = 'chat.artifacts.enabled', } /** diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 3317bffd548..0c368744fe1 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -13,9 +13,44 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { AICustomizationManagementSection, IStorageSourceFilter } from './aiCustomizationWorkspaceService.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; import { PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { AGENT_MD_FILENAME } from './promptSyntax/config/promptFileLocations.js'; export const ICustomizationHarnessService = createDecorator('customizationHarnessService'); +/** + * Override for a management section's create-button behavior. + */ +export interface ISectionOverride { + /** + * Label for the primary button. Required when `commandId` or `rootFile` + * is set. Ignored otherwise (the widget uses its default label). + */ + readonly label?: string; + /** When set, the primary button invokes this command (e.g. hooks quick pick). */ + readonly commandId?: string; + /** When set, the primary button creates this file at the workspace root. */ + readonly rootFile?: string; + /** + * Custom type label for the dropdown workspace/user create actions + * (e.g. "Rule" instead of "Instruction"). When undefined, the + * section's default type label is used. + */ + readonly typeLabel?: string; + /** + * Root-level file shortcuts added to the dropdown (e.g. `['AGENTS.md']`). + * Each entry creates a "New {filename}" action that creates the file at + * the workspace root. Harnesses that don't support a file simply omit it. + */ + readonly rootFileShortcuts?: readonly string[]; + /** + * File extension override for new files created under this section. + * When set, files are created with this extension (e.g. `.md` for + * Claude rules) instead of the default for the prompt type + * (e.g. `.instructions.md`). + */ + readonly fileExtension?: string; +} + /** * Identifies the AI harness (execution environment) that customizations * are filtered for. Storage answers "where did this come from?"; harness @@ -36,7 +71,7 @@ export interface IHarnessDescriptor { readonly icon: ThemeIcon; /** * Management sections that should be hidden when this harness is active. - * For example, Claude does not support custom agents so the Agents + * For example, Claude does not support prompt files so the Prompts * section is hidden. */ readonly hiddenSections?: readonly string[]; @@ -47,6 +82,37 @@ export interface IHarnessDescriptor { * When `undefined`, all workspace directories are shown (Local harness). */ readonly workspaceSubpaths?: readonly string[]; + /** + * When `true`, the "Generate with AI" sparkle button is hidden and replaced + * with a plain "New X" manual-creation button (like sessions). + */ + readonly hideGenerateButton?: boolean; + /** + * Per-section overrides for the create button behavior. + * + * A `commandId` entry replaces the button entirely with a command + * invocation (e.g. Claude hooks → `copilot.claude.hooks`). + * + * A `rootFile` entry makes the primary button create a specific file + * at the workspace root (e.g. Claude instructions → `CLAUDE.md`). + * When combined with `typeLabel`, the dropdown create actions use + * that label instead of the section's default (e.g. "Rule" instead + * of "Instruction"). + */ + readonly sectionOverrides?: ReadonlyMap; + /** + * The chat agent ID that must be registered for this harness to appear. + * When `undefined`, the harness is always available (e.g. Local). + */ + readonly requiredAgentId?: string; + /** + * Instruction file patterns that this harness recognizes. + * Each entry is either an exact filename (e.g. `'CLAUDE.md'`) or a + * path prefix ending with `/` (e.g. `'.claude/rules/'`). + * When set, instruction items that don't match any pattern are filtered out. + * When `undefined`, all instruction files are shown. + */ + readonly instructionFileFilter?: readonly string[]; /** * Returns the storage source filter that should be applied to customization * items of the given type when this harness is active. @@ -150,6 +216,11 @@ export function createVSCodeHarnessDescriptor(extras: readonly string[]): IHarne id: CustomizationHarness.VSCode, label: localize('harness.local', "Local"), icon: ThemeIcon.fromId(Codicon.vm.id), + sectionOverrides: new Map([ + [AICustomizationManagementSection.Instructions, { + rootFileShortcuts: [AGENT_MD_FILENAME], + }], + ]), getStorageSourceFilter: () => filter, }; } @@ -159,14 +230,22 @@ export function createVSCodeHarnessDescriptor(extras: readonly string[]): IHarne * types (agents, skills, instructions) while leaving hooks and prompts * unrestricted. Used for CLI and Claude harnesses. */ +interface IRestrictedHarnessOptions { + readonly hiddenSections?: readonly string[]; + readonly workspaceSubpaths?: readonly string[]; + readonly hideGenerateButton?: boolean; + readonly sectionOverrides?: ReadonlyMap; + readonly requiredAgentId?: string; + readonly instructionFileFilter?: readonly string[]; +} + function createRestrictedHarnessDescriptor( id: CustomizationHarness, label: string, icon: ThemeIcon, restrictedUserRoots: readonly URI[], extras: readonly string[], - hiddenSections?: readonly string[], - workspaceSubpaths?: readonly string[], + options?: IRestrictedHarnessOptions, ): IHarnessDescriptor { const allSources = buildAllSources(extras); const allRootsFilter: IStorageSourceFilter = { sources: allSources }; @@ -175,8 +254,12 @@ function createRestrictedHarnessDescriptor( id, label, icon, - hiddenSections, - workspaceSubpaths, + hiddenSections: options?.hiddenSections, + workspaceSubpaths: options?.workspaceSubpaths, + hideGenerateButton: options?.hideGenerateButton, + sectionOverrides: options?.sectionOverrides, + requiredAgentId: options?.requiredAgentId, + instructionFileFilter: options?.instructionFileFilter, getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { if (type === PromptsType.hook) { return HOOKS_FILTER; @@ -199,14 +282,24 @@ export function createCliHarnessDescriptor(cliUserRoots: readonly URI[], extras: ThemeIcon.fromId(Codicon.worktree.id), cliUserRoots, extras, - undefined, // no hidden sections - ['.github', '.copilot', '.agents', '.claude'], + { + hideGenerateButton: true, + requiredAgentId: 'copilotcli', + workspaceSubpaths: ['.github', '.copilot', '.agents', '.claude'], + sectionOverrides: new Map([ + [AICustomizationManagementSection.Instructions, { + rootFileShortcuts: [AGENT_MD_FILENAME], + }], + ]), + }, ); } /** * Creates a "Claude" harness descriptor. - * Claude does not support custom agents or hooks. + * Claude does not support prompt files (.prompt.md), AGENTS.md, or extension-contributed plugins. + * It supports agents (.claude/agents/), instructions (CLAUDE.md, .claude/rules/), + * skills (.claude/skills/), and hooks (.claude/settings.json). */ export function createClaudeHarnessDescriptor(claudeRoots: readonly URI[], extras: readonly string[]): IHarnessDescriptor { return createRestrictedHarnessDescriptor( @@ -215,13 +308,60 @@ export function createClaudeHarnessDescriptor(claudeRoots: readonly URI[], extra ThemeIcon.fromId(Codicon.claude.id), claudeRoots, extras, - [AICustomizationManagementSection.Agents, AICustomizationManagementSection.Hooks], - ['.claude'], + { + hiddenSections: [AICustomizationManagementSection.Prompts, AICustomizationManagementSection.Plugins], + workspaceSubpaths: ['.claude'], + hideGenerateButton: true, + requiredAgentId: 'claude-code', + sectionOverrides: new Map([ + [AICustomizationManagementSection.Hooks, { + label: localize('claudeHooks', "Configure Claude Hooks"), + commandId: 'copilot.claude.hooks', + }], + [AICustomizationManagementSection.Instructions, { + label: localize('addClaudeMd', "Add CLAUDE.md"), + rootFile: 'CLAUDE.md', + typeLabel: localize('rule', "Rule"), + fileExtension: '.md', + }], + ]), + instructionFileFilter: ['CLAUDE.md', 'CLAUDE.local.md', '.claude/rules/', 'copilot-instructions.md'], + }, ); } // #endregion +// #region Helpers + +/** + * Tests whether a file path belongs to one of the given workspace sub-paths. + * Matches on path segment boundaries to avoid false positives + * (e.g. `.claude` must appear as `/.claude/` in the path, not as part of + * a longer segment like `not.claude`). + */ +export function matchesWorkspaceSubpath(filePath: string, subpaths: readonly string[]): boolean { + return subpaths.some(sp => filePath.includes(`/${sp}/`) || filePath.endsWith(`/${sp}`)); +} + +/** + * Tests whether an instruction file matches one of the harness's recognized + * instruction file patterns. Patterns can be exact filenames (e.g. `CLAUDE.md`) + * or path prefixes ending with `/` (e.g. `.claude/rules/`). + */ +export function matchesInstructionFileFilter(filePath: string, filters: readonly string[]): boolean { + const name = filePath.substring(filePath.lastIndexOf('/') + 1); + return filters.some(f => { + if (f.endsWith('/')) { + // Path prefix: check if the file is under this directory + return filePath.includes(`/${f}`) || filePath.startsWith(f); + } + return name === f; + }); +} + +// #endregion + // #region Base implementation /** @@ -236,30 +376,35 @@ export class CustomizationHarnessServiceBase implements ICustomizationHarnessSer readonly activeHarness: IObservable; readonly availableHarnesses: IObservable; + private readonly _allHarnesses: readonly IHarnessDescriptor[]; + constructor( - private readonly _harnesses: readonly IHarnessDescriptor[], + harnesses: readonly IHarnessDescriptor[], defaultHarness: CustomizationHarness, + availableHarnesses?: IObservable, ) { + this._allHarnesses = harnesses; this._activeHarness = observableValue(this, defaultHarness); this.activeHarness = this._activeHarness; - this.availableHarnesses = constObservable(this._harnesses); + this.availableHarnesses = availableHarnesses ?? constObservable(harnesses); } setActiveHarness(id: CustomizationHarness): void { - if (this._harnesses.some(h => h.id === id)) { + const available = this.availableHarnesses.get(); + if (available.some(h => h.id === id)) { this._activeHarness.set(id, undefined); } } getStorageSourceFilter(type: PromptsType): IStorageSourceFilter { - const activeId = this._activeHarness.get(); - const descriptor = this._harnesses.find(h => h.id === activeId); - return descriptor?.getStorageSourceFilter(type) ?? this._harnesses[0].getStorageSourceFilter(type); + const descriptor = this.getActiveDescriptor(); + return descriptor.getStorageSourceFilter(type); } getActiveDescriptor(): IHarnessDescriptor { const activeId = this._activeHarness.get(); - return this._harnesses.find(h => h.id === activeId) ?? this._harnesses[0]; + const available = this.availableHarnesses.get(); + return available.find(h => h.id === activeId) ?? available[0] ?? this._allHarnesses[0]; } } diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index d88d5a917f2..a0dd5106900 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -18,6 +18,7 @@ import { equals } from '../../../../base/common/objects.js'; import Severity from '../../../../base/common/severity.js'; import { format, isFalsyOrWhitespace } from '../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { IAction, SubmenuAction } from '../../../../base/common/actions.js'; import { isObject, isString } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -169,6 +170,17 @@ export type IChatResponsePart = IChatResponseTextPart | IChatResponseToolUsePart export type IExtendedChatResponsePart = IChatResponsePullRequestPart; +export interface ILanguageModelConfigurationSchema extends IJSONSchema { + properties?: { + [key: string]: IJSONSchema & { + /** When set to `'navigation'`, the property is shown as a primary action in the model picker. */ + group?: string; + /** Labels for enum values. If provided, these are shown instead of the raw enum values. */ + enumItemLabels?: string[]; + }; + }; +} + export interface ILanguageModelChatMetadata { readonly extension: ExtensionIdentifier; @@ -204,6 +216,11 @@ export interface ILanguageModelChatMetadata { * when the user is in a session matching this type. */ readonly targetChatSessionType?: string; + /** + * An optional JSON schema describing the per-model configuration options. + * Used to validate user-provided per-model configuration in `chatLanguageModels.json`. + */ + readonly configurationSchema?: ILanguageModelConfigurationSchema; } export namespace ILanguageModelChatMetadata { @@ -263,13 +280,13 @@ export async function getTextResponseFromStream(response: ILanguageModelChatResp export interface ILanguageModelChatProvider { readonly onDidChange: Event; provideLanguageModelChatInfo(options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise; - sendChatRequest(modelId: string, messages: IChatMessage[], from: ExtensionIdentifier | undefined, options: { [name: string]: unknown }, token: CancellationToken): Promise; + sendChatRequest(modelId: string, messages: IChatMessage[], from: ExtensionIdentifier | undefined, options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise; provideTokenCount(modelId: string, message: string | IChatMessage, token: CancellationToken): Promise; } export interface ILanguageModelChat { metadata: ILanguageModelChatMetadata; - sendChatRequest(messages: IChatMessage[], from: ExtensionIdentifier | undefined, options: { [name: string]: unknown }, token: CancellationToken): Promise; + sendChatRequest(messages: IChatMessage[], from: ExtensionIdentifier | undefined, options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise; provideTokenCount(message: string | IChatMessage, token: CancellationToken): Promise; } @@ -313,6 +330,13 @@ export interface ILanguageModelChatInfoOptions { readonly configuration?: IStringDictionary; } +export interface ILanguageModelChatRequestOptions { + readonly modelOptions?: IStringDictionary; + readonly configuration?: IStringDictionary; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly [name: string]: any; +} + export interface ILanguageModelsGroup { readonly group?: ILanguageModelsProviderGroup; readonly modelIdentifiers: string[]; @@ -354,17 +378,42 @@ export interface ILanguageModelsService { deltaLanguageModelChatProviderDescriptors(added: IUserFriendlyLanguageModel[], removed: IUserFriendlyLanguageModel[]): void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendChatRequest(modelId: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise; + sendChatRequest(modelId: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise; computeTokenLength(modelId: string, message: string | IChatMessage, token: CancellationToken): Promise; + /** + * Returns the resolved per-model configuration for the given model identifier. + * Includes schema defaults with user overrides applied on top. + * Returns undefined if the model has no configuration schema and no user config. + */ + getModelConfiguration(modelId: string): IStringDictionary | undefined; + + /** + * Updates the per-model configuration for the given model. + * Merges the provided values into the existing configuration. + */ + setModelConfiguration(modelId: string, values: IStringDictionary): Promise; + + /** + * Returns actions for configuring the given model based on its configuration schema. + * For enum properties, returns submenu actions with checkable values. + * Returns an empty array if the model has no configuration schema. + */ + getModelConfigurationActions(modelId: string): IAction[]; + addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise; removeLanguageModelsProviderGroup(vendorId: string, providerGroupName: string): Promise; configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise; + /** + * Opens the language models configuration file and navigates to + * or creates the per-model configuration for the given model. + */ + configureModel(modelId: string): Promise; + migrateLanguageModelsProviderGroup(languageModelsProviderGroup: ILanguageModelsProviderGroup): Promise; /** @@ -531,6 +580,7 @@ export class LanguageModelsService implements ILanguageModelsService { private readonly _modelCache = new Map(); private readonly _resolveLMSequencer = new SequencerByKey(); private _modelPickerUserPreferences: IStringDictionary = {}; + private readonly _modelConfigurations = new Map>(); private readonly _hasUserSelectableModels: IContextKey; private readonly _onLanguageModelChange = this._store.add(new Emitter()); @@ -821,11 +871,29 @@ export class LanguageModelsService implements ILanguageModelsService { } const groups = this._languageModelsConfigurationService.getLanguageModelsProviderGroups(); + const perModelConfigurations = new Map>(); for (const group of groups) { if (group.vendor !== vendorId) { continue; } + // For the default vendor, groups that only have per-model config + // should not trigger a separate model resolution call. + // Instead, apply the per-model config to the already-resolved models. + if (vendor.isDefault && !vendor.configuration) { + if (group.settings) { + for (const model of allModels) { + const modelConfig = group.settings[model.metadata.id]; + if (modelConfig) { + // Store raw config (without resolving secrets) to avoid leaking secrets on persist + perModelConfigurations.set(model.identifier, { ...modelConfig }); + } + } + } + languageModelsGroups.push({ group, modelIdentifiers: [] }); + continue; + } + const configuration = await this._resolveConfiguration(group, vendor.configuration); try { @@ -834,6 +902,17 @@ export class LanguageModelsService implements ILanguageModelsService { allModels.push(...models); languageModelsGroups.push({ group, modelIdentifiers: models.map(m => m.identifier) }); } + + // Collect per-model configurations from the group + if (group.settings) { + for (const model of models) { + const modelConfig = group.settings[model.metadata.id]; + if (modelConfig) { + // Store raw config (without resolving secrets) to avoid leaking secrets on persist + perModelConfigurations.set(model.identifier, { ...modelConfig }); + } + } + } } catch (error) { languageModelsGroups.push({ group, @@ -861,6 +940,14 @@ export class LanguageModelsService implements ILanguageModelsService { this._logService.trace(`[LM] Resolved language models for vendor ${vendorId}`, allModels); hasChanges = hasChanges || oldModels.size > 0; + // Update per-model configurations for this vendor + this._clearModelConfigurations(vendorId); + for (const [identifier, config] of perModelConfigurations) { + if (this._modelCache.has(identifier)) { + this._modelConfigurations.set(identifier, config); + } + } + if (hasChanges) { this._onLanguageModelChange.fire(vendorId); } else { @@ -926,13 +1013,41 @@ export class LanguageModelsService implements ILanguageModelsService { }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async sendChatRequest(modelId: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise { - const provider = this._providers.get(this._modelCache.get(modelId)?.vendor || ''); + async sendChatRequest(modelId: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise { + const metadata = this._modelCache.get(modelId); + const provider = this._providers.get(metadata?.vendor || ''); if (!provider) { throw new Error(`Chat provider for model ${modelId} is not registered.`); } - return provider.sendChatRequest(modelId, messages, from, options, token); + const configuration = this.getModelConfiguration(modelId); + const mergedOptions = configuration ? { ...options, configuration: { ...configuration, ...options.configuration } } : options; + return provider.sendChatRequest(modelId, messages, from, mergedOptions, token); + } + + private _resolveModelConfigurationWithDefaults(modelId: string, metadata: ILanguageModelChatMetadata | undefined): IStringDictionary | undefined { + const userConfig = this._modelConfigurations.get(modelId); + const schema = metadata?.configurationSchema; + + if (!schema?.properties && !userConfig) { + return undefined; + } + + // Start with schema defaults + const defaults: IStringDictionary = {}; + if (schema?.properties) { + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (propSchema.default !== undefined) { + defaults[key] = propSchema.default; + } + } + } + + if (!userConfig && Object.keys(defaults).length === 0) { + return undefined; + } + + // User config overrides defaults + return { ...defaults, ...userConfig }; } computeTokenLength(modelId: string, message: string | IChatMessage, token: CancellationToken): Promise { @@ -947,6 +1062,127 @@ export class LanguageModelsService implements ILanguageModelsService { return provider.provideTokenCount(modelId, message, token); } + getModelConfiguration(modelId: string): IStringDictionary | undefined { + const metadata = this._modelCache.get(modelId); + return this._resolveModelConfigurationWithDefaults(modelId, metadata); + } + + async setModelConfiguration(modelId: string, values: IStringDictionary): Promise { + const metadata = this._modelCache.get(modelId); + if (!metadata) { + return; + } + + // Find the group from the configuration service (source of truth) + const allGroups = this._languageModelsConfigurationService.getLanguageModelsProviderGroups(); + let group: ILanguageModelsProviderGroup | undefined; + + // First try to find a group that already has config for this model + group = allGroups.find(g => g.vendor === metadata.vendor && g.settings?.[metadata.id] !== undefined); + + // If not found, find any group for this vendor + if (!group) { + group = allGroups.find(g => g.vendor === metadata.vendor); + } + + // Merge new values into existing config, removing properties set to their schema default + const existingConfig = this._modelConfigurations.get(modelId) ?? {}; + const updatedConfig = { ...existingConfig, ...values }; + const schema = metadata.configurationSchema; + if (schema?.properties) { + for (const [key, value] of Object.entries(updatedConfig)) { + const propSchema = schema.properties[key]; + if (propSchema?.default !== undefined && propSchema.default === value) { + delete updatedConfig[key]; + } + } + } + + if (group) { + const existingSettings = (group.settings as IStringDictionary> | undefined) ?? {}; + let updatedSettings: IStringDictionary>; + if (Object.keys(updatedConfig).length === 0) { + updatedSettings = { ...existingSettings }; + delete updatedSettings[metadata.id]; + } else { + updatedSettings = { ...existingSettings, [metadata.id]: updatedConfig }; + } + const updatedGroup: ILanguageModelsProviderGroup = { + ...group, + settings: Object.keys(updatedSettings).length > 0 ? updatedSettings : undefined + }; + if (!updatedGroup.settings && Object.keys(updatedGroup).filter(k => k !== 'name' && k !== 'vendor' && k !== 'range' && k !== 'settings').length === 0) { + // Remove the group entirely if it only had model config + await this._languageModelsConfigurationService.removeLanguageModelsProviderGroup(group); + } else { + await this._languageModelsConfigurationService.updateLanguageModelsProviderGroup(group, updatedGroup); + } + } else if (Object.keys(updatedConfig).length > 0) { + // Only create a new group if there's non-default config + const vendor = this.getVendors().find(v => v.vendor === metadata.vendor); + if (!vendor) { + return; + } + const newGroup: ILanguageModelsProviderGroup = { + name: vendor.displayName, + vendor: metadata.vendor, + settings: { [metadata.id]: updatedConfig } + }; + await this._languageModelsConfigurationService.addLanguageModelsProviderGroup(newGroup); + } + + // Update the in-memory cache + if (Object.keys(updatedConfig).length > 0) { + this._modelConfigurations.set(modelId, updatedConfig); + } else { + this._modelConfigurations.delete(modelId); + } + + // Notify listeners so UI (e.g., model picker label) updates + this._onLanguageModelChange.fire(metadata.vendor); + } + + getModelConfigurationActions(modelId: string): IAction[] { + const metadata = this._modelCache.get(modelId); + const schema = metadata?.configurationSchema; + if (!schema?.properties) { + return []; + } + + const actions: IAction[] = []; + const currentConfig = this._modelConfigurations.get(modelId) ?? {}; + + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (!propSchema.enum || !Array.isArray(propSchema.enum)) { + continue; + } + const currentValue = currentConfig[key] ?? propSchema.default; + const label = (typeof propSchema.title === 'string' ? propSchema.title : undefined) + ?? key.replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/^./, s => s.toUpperCase()); + const defaultValue = propSchema.default; + const enumItemLabels = propSchema.enumItemLabels; + const enumDescriptions = propSchema.enumDescriptions; + const enumActions: IAction[] = propSchema.enum.map((value: unknown, index: number) => { + const itemLabel = enumItemLabels?.[index] ?? String(value); + const displayLabel = value === defaultValue ? localize('models.enumDefault', "{0} (default)", itemLabel) : itemLabel; + const tooltip = enumDescriptions?.[index] ?? ''; + return { + id: `configureModel.${key}.${value}`, + label: displayLabel, + class: undefined, + enabled: true, + tooltip, + checked: currentValue === value, + run: () => this.setModelConfiguration(modelId, { [key]: value }) + }; + }); + actions.push(new SubmenuAction(`configureModel.${key}`, label, enumActions)); + } + + return actions; + } + async configureLanguageModelsProviderGroup(vendorId: string, providerGroupName?: string): Promise { const vendor = this.getVendors().find(({ vendor }) => vendor === vendorId); @@ -992,6 +1228,63 @@ export class LanguageModelsService implements ILanguageModelsService { } } + async configureModel(modelId: string): Promise { + const metadata = this._modelCache.get(modelId); + if (!metadata || !metadata.configurationSchema) { + return; + } + + // Find the group that contains this model + const vendorGroups = this._modelsGroups.get(metadata.vendor); + let group: ILanguageModelsProviderGroup | undefined; + if (vendorGroups) { + for (const vg of vendorGroups) { + if (vg.modelIdentifiers.includes(modelId) && vg.group) { + group = vg.group; + break; + } + } + } + + // If the model doesn't belong to any configured group, create one + if (!group) { + const vendor = this.getVendors().find(v => v.vendor === metadata.vendor); + if (!vendor) { + return; + } + const groupName = vendor.displayName; + const newGroup: ILanguageModelsProviderGroup = { name: groupName, vendor: metadata.vendor, settings: { [metadata.id]: {} } }; + group = await this._languageModelsConfigurationService.addLanguageModelsProviderGroup(newGroup); + await this._resolveAllLanguageModels(metadata.vendor, true); + } + + // Generate a snippet for the model's configuration schema + const snippet = this._getModelConfigurationSnippet(metadata.id, metadata.configurationSchema); + await this._languageModelsConfigurationService.configureLanguageModels({ group, snippet }); + } + + private _getModelConfigurationSnippet(modelId: string, schema: ILanguageModelConfigurationSchema): string { + const properties: string[] = []; + if (schema.properties) { + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (propSchema.defaultSnippets?.[0]) { + const snippet = propSchema.defaultSnippets[0]; + let bodyText = snippet.bodyText ?? JSON.stringify(snippet.body, null, '\t\t\t'); + bodyText = bodyText.replace(/"(\^[^"]*)"/g, (_, value) => value.substring(1)); + properties.push(`\t\t\t"${key}": ${bodyText}`); + } else if (propSchema.default !== undefined) { + properties.push(`\t\t\t"${key}": ${JSON.stringify(propSchema.default)}`); + } else { + properties.push(`\t\t\t"${key}": $\{${key}\}`); + } + } + } + const modelContent = properties.length > 0 + ? `{\n${properties.join(',\n')}\n\t\t}` + : '{\n\t\t\t$0\n\t\t}'; + return `"settings": {\n\t\t"${modelId}": ${modelContent}\n\t}`; + } + async addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise { const vendor = this.getVendors().find(({ vendor }) => vendor === vendorId); if (!vendor) { @@ -1292,6 +1585,14 @@ export class LanguageModelsService implements ILanguageModelsService { return removed; } + private _clearModelConfigurations(vendor: string): void { + for (const [id] of this._modelConfigurations) { + if (this._modelCache.get(id)?.vendor === vendor || id.startsWith(`${vendor}/`)) { + this._modelConfigurations.delete(id); + } + } + } + private async _resolveConfiguration(group: ILanguageModelsProviderGroup, schema: IJSONSchema | undefined): Promise> { if (!schema) { return {}; @@ -1299,7 +1600,7 @@ export class LanguageModelsService implements ILanguageModelsService { const result: IStringDictionary = {}; for (const key in group) { - if (key === 'vendor' || key === 'name' || key === 'range') { + if (key === 'vendor' || key === 'name' || key === 'range' || key === 'settings') { continue; } let value = group[key]; @@ -1439,7 +1740,7 @@ export class LanguageModelsService implements ILanguageModelsService { if (!entry || !isObject(entry)) { continue; } - free[entry.id] = { label: entry.label, featured: entry.featured, exists: this._modelExistsInCache(entry.id) }; + free[entry.id] = { label: entry.label, featured: entry.featured, exists: this._modelCache.has(`copilot/${entry.id}`) }; } } @@ -1449,7 +1750,7 @@ export class LanguageModelsService implements ILanguageModelsService { if (!entry || !isObject(entry)) { continue; } - paid[entry.id] = { label: entry.label, featured: entry.featured, minVSCodeVersion: entry.minVSCodeVersion, exists: this._modelExistsInCache(entry.id) }; + paid[entry.id] = { label: entry.label, featured: entry.featured, minVSCodeVersion: entry.minVSCodeVersion, exists: this._modelCache.has(`copilot/${entry.id}`) }; } } @@ -1457,17 +1758,7 @@ export class LanguageModelsService implements ILanguageModelsService { this._onDidChangeModelsControlManifest.fire(this._modelsControlManifest); } - private _modelExistsInCache(metadataId: string): boolean { - for (const model of this._modelCache.values()) { - if (model.id === metadataId) { - return true; - } - } - return false; - } - //#region Chat control data - private _initChatControlData(): void { this._chatControlUrl = this._productService.chatParticipantRegistry; if (!this._chatControlUrl) { diff --git a/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts index 4f0dfa41691..86c0d307537 100644 --- a/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts +++ b/src/vs/workbench/contrib/chat/common/languageModelsConfiguration.ts @@ -38,4 +38,5 @@ export interface ILanguageModelsProviderGroup extends IStringDictionary readonly name: string; readonly vendor: string; readonly range?: IRange; + readonly settings?: IStringDictionary>; } diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 6bff6a86544..e5ce38c5fea 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -862,6 +862,9 @@ export class Response extends AbstractResponse implements IDisposable { ); if (existingInvocation) { + if (progress.toolSpecificData !== undefined) { + existingInvocation.toolSpecificData = progress.toolSpecificData; + } if (progress.isComplete) { existingInvocation.didExecuteTool({ content: [], @@ -870,9 +873,6 @@ export class Response extends AbstractResponse implements IDisposable { toolResultDetails: progress.resultDetails }); } - if (progress.toolSpecificData !== undefined) { - existingInvocation.toolSpecificData = progress.toolSpecificData; - } return; } @@ -900,15 +900,15 @@ export class Response extends AbstractResponse implements IDisposable { if (progress.isComplete) { // Already completed on first push + if (progress.toolSpecificData !== undefined) { + invocation.toolSpecificData = progress.toolSpecificData; + } invocation.didExecuteTool({ content: [], toolResultMessage: progress.pastTenseMessage, toolResultError: progress.errorMessage, toolResultDetails: progress.resultDetails }); - if (progress.toolSpecificData !== undefined) { - invocation.toolSpecificData = progress.toolSpecificData; - } } this._responseParts.push(invocation); @@ -2809,7 +2809,7 @@ export namespace ChatResponseResource { } const parts = uri.path.split('/'); - if (parts.length < 5) { + if (parts.length < 4) { return undefined; } 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 5feb507b755..7cc5a5425ff 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatQuestionCarouselData.ts @@ -18,6 +18,7 @@ export class ChatQuestionCarouselData implements IChatQuestionCarousel { public readonly completion = new DeferredPromise<{ answers: IChatQuestionAnswers | undefined }>(); public draftAnswers: IChatQuestionAnswers | undefined; public draftCurrentIndex: number | undefined; + public draftCollapsed: boolean | undefined; constructor( public questions: IChatQuestion[], 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 f128efd7e89..0879932c884 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatProgressTypes/chatToolInvocation.ts @@ -6,6 +6,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 { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.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'; @@ -27,6 +28,7 @@ export class ChatToolInvocation implements IChatToolInvocation { public confirmationMessages: IToolConfirmationMessages | undefined; public presentation: IPreparedToolInvocation['presentation']; public readonly toolId: string; + public readonly icon?: ThemeIcon; public source: ToolDataSource; public readonly subAgentInvocationId: string | undefined; public parameters: unknown; @@ -86,6 +88,7 @@ export class ChatToolInvocation implements IChatToolInvocation { this.presentation = preparedInvocation?.presentation; this.toolSpecificData = preparedInvocation?.toolSpecificData; this.toolId = toolData.id; + this.icon = preparedInvocation?.icon ?? (toolData.icon && ThemeIcon.isThemeIcon(toolData.icon) ? toolData.icon : undefined); this.source = toolData.source; this.subAgentInvocationId = subAgentInvocationId; this.parameters = parameters; diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 96c673ab22c..e6cdfd9e005 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -5,6 +5,7 @@ import { findLast } from '../../../../../base/common/arraysFind.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { IStringDictionary } from '../../../../../base/common/collections.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; @@ -26,7 +27,6 @@ import { IRawChatCommandContribution } from './chatParticipantContribTypes.js'; import { IChatFollowup, IChatLocationData, IChatProgress, IChatResponseErrorDetails, IChatTaskDto } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ILanguageModelsService } from '../languageModels.js'; -import { mark } from '../../../../../base/common/performance.js'; //#region agent service, commands etc @@ -147,6 +147,7 @@ export interface IChatAgentRequest { acceptedConfirmationData?: unknown[]; rejectedConfirmationData?: unknown[]; userSelectedModelId?: string; + modelConfiguration?: IStringDictionary; userSelectedTools?: UserSelectedTools; modeInstructions?: IChatRequestModeInstructions; editedFileEvents?: IChatAgentEditedFileEvent[]; @@ -506,15 +507,12 @@ export class ChatAgentService extends Disposable implements IChatAgentService { } async invokeAgent(id: string, request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void, history: IChatAgentHistoryEntry[], token: CancellationToken): Promise { - mark('code/chat/willInvokeAgent'); const data = this._agents.get(id); if (!data?.impl) { throw new Error(`No activated agent with id "${id}"`); } - const result = await data.impl.invoke(request, progress, history, token); - mark('code/chat/didInvokeAgent'); - return result; + return await data.impl.invoke(request, progress, history, token); } setRequestTools(id: string, requestId: string, tools: UserSelectedTools): void { diff --git a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts index 35c609e3c7d..d517710e084 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatSlashCommands.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { IChatMessage } from '../languageModels.js'; @@ -38,7 +39,13 @@ export interface IChatSlashData { locations: ChatAgentLocation[]; modes?: ChatModeKind[]; - target?: Target; + targets?: Target[]; + + /** + * Optional context key expression that controls visibility of this command. + * When set, the command is only shown if the expression evaluates to true. + */ + when?: ContextKeyExpression; } export interface IChatSlashFragment { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts index ea90fb6f832..7ad90d2fd26 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts @@ -102,9 +102,13 @@ export interface IAgentPluginRepositoryService { * the marketplace repository cache). For direct sources (github, url, npm, * pip) the cache directory is deleted. * + * When {@link otherInstalledDescriptors} is provided, deletion is skipped + * if any of those descriptors share the same cleanup target directory + * (e.g. multiple plugins installed from the same cloned repository). + * * This is best-effort: failures are logged but do not throw. */ - cleanupPluginSource(plugin: IMarketplacePlugin): Promise; + cleanupPluginSource(plugin: IMarketplacePlugin, otherInstalledDescriptors?: readonly IPluginSourceDescriptor[]): Promise; /** * Silently fetches remote refs for a cloned marketplace repository and diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index 392d86f6139..3f531617166 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -20,6 +20,8 @@ export const IAgentPluginService = createDecorator('agentPl export interface IAgentPluginHook { readonly type: HookType; readonly hooks: readonly IHookCommand[]; + /** URI where this hook is defined -- not unique, multiple hooks may be in a manifest */ + readonly uri: URI; readonly originalId: string; } @@ -46,6 +48,7 @@ export interface IAgentPluginInstruction { export interface IAgentPluginMcpServerDefinition { readonly name: string; readonly configuration: IMcpServerConfiguration; + readonly uri: URI; } export interface IAgentPlugin { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index c044c2d0cd5..934888a9cce 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -57,11 +57,11 @@ interface IAgentPluginFormatAdapter { readonly hookConfigPath: string; readonly pluginRootToken: string | undefined; readonly pluginRootEnvVar: string | undefined; - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[]; + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[]; } -function mapParsedHooks(parsed: Map): IAgentPluginHook[] { - return [...parsed.entries()].map(([type, { hooks, originalId }]) => ({ type, hooks, originalId })); +function mapParsedHooks(uri: URI, parsed: Map): IAgentPluginHook[] { + return [...parsed.entries()].map(([type, { hooks, originalId }]) => ({ type, uri, hooks, originalId })); } /** @@ -85,9 +85,9 @@ class CopilotPluginFormatAdapter implements IAgentPluginFormatAdapter { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, ) { } - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { const workspaceRoot = resolveWorkspaceRoot(pluginUri, this._workspaceContextService); - return mapParsedHooks(parseCopilotHooks(json, workspaceRoot, userHome)); + return mapParsedHooks(hookURI, parseCopilotHooks(json, workspaceRoot, userHome)); } } @@ -201,7 +201,7 @@ function interpolateMcpPluginRoot( interpolated = remote; } - return { name: def.name, configuration: interpolated }; + return { name: def.name, configuration: interpolated, uri: def.uri }; } /** @@ -211,6 +211,7 @@ function interpolateMcpPluginRoot( * delegates to {@link parseClaudeHooks} for the actual hook resolution. */ function parsePluginRootHooks( + hookURI: URI, json: unknown, pluginUri: URI, userHome: string, @@ -265,7 +266,7 @@ function parsePluginRootHooks( return []; } - return mapParsedHooks(hooks); + return mapParsedHooks(hookURI, hooks); } class ClaudePluginFormatAdapter implements IAgentPluginFormatAdapter { @@ -279,8 +280,8 @@ class ClaudePluginFormatAdapter implements IAgentPluginFormatAdapter { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, ) { } - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { - return parsePluginRootHooks(json, pluginUri, userHome, this._workspaceContextService, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT'); + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + return parsePluginRootHooks(hookURI, json, pluginUri, userHome, this._workspaceContextService, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT'); } } @@ -295,8 +296,8 @@ class OpenPluginFormatAdapter implements IAgentPluginFormatAdapter { @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, ) { } - parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { - return parsePluginRootHooks(json, pluginUri, userHome, this._workspaceContextService, '${PLUGIN_ROOT}', 'PLUGIN_ROOT'); + parseHooks(hookURI: URI, json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + return parsePluginRootHooks(hookURI, json, pluginUri, userHome, this._workspaceContextService, '${PLUGIN_ROOT}', 'PLUGIN_ROOT'); } } @@ -584,6 +585,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements return result.recomputeInitiallyAndOnChange(store); }; + const manifestUri = joinPath(uri, adapter.manifestPath); const commands = observeComponent('commands', d => this._readMarkdownComponents(d)); const skills = observeComponent('skills', d => this._readSkills(uri, d)); const agents = observeComponent('agents', d => this._readMarkdownComponents(d)); @@ -593,7 +595,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements paths => this._readHooksFromPaths(uri, paths, adapter), async section => { const userHome = (await this._pathService.userHome()).fsPath; - return adapter.parseHooks(section, uri, userHome); + return adapter.parseHooks(manifestUri, section, uri, userHome); }, adapter.hookConfigPath, ); @@ -601,7 +603,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const mcpServerDefinitions = observeComponent( 'mcpServers', paths => this._readMcpDefinitionsFromPaths(paths, uri.fsPath, adapter), - async section => this._parseMcpServerDefinitionMap({ mcpServers: section }, uri.fsPath, adapter), + async section => this._parseMcpServerDefinitionMap(manifestUri, { mcpServers: section }, uri.fsPath, adapter), '.mcp.json', ); @@ -611,7 +613,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements }; const manifestWatcher = this._fileService.createWatcher( - joinPath(uri, adapter.manifestPath), + manifestUri, { recursive: false, excludes: [] }, ); store.add(manifestWatcher); @@ -657,7 +659,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const json = await this._readJsonFile(hookPath); if (json) { try { - return adapter.parseHooks(json, pluginUri, userHome); + return adapter.parseHooks(hookPath, json, pluginUri, userHome); } catch (e) { this._logService.info(`[AgentPluginDiscovery] Failed to parse hooks from ${hookPath.toString()}:`, e); } @@ -672,21 +674,19 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements * server name wins. */ private async _readMcpDefinitionsFromPaths(paths: readonly URI[], pluginFsPath: string, adapter: IAgentPluginFormatAdapter): Promise { - const merged = new Map(); + const merged = new Map(); for (const mcpPath of paths) { const json = await this._readJsonFile(mcpPath); - for (const def of this._parseMcpServerDefinitionMap(json, pluginFsPath, adapter)) { + for (const def of this._parseMcpServerDefinitionMap(mcpPath, json, pluginFsPath, adapter)) { if (!merged.has(def.name)) { - merged.set(def.name, def.configuration); + merged.set(def.name, def); } } } - return [...merged.entries()] - .map(([name, configuration]) => ({ name, configuration } satisfies IAgentPluginMcpServerDefinition)) - .sort((a, b) => a.name.localeCompare(b.name)); + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); } - private _parseMcpServerDefinitionMap(raw: unknown, pluginFsPath: string, adapter: IAgentPluginFormatAdapter): IAgentPluginMcpServerDefinition[] { + private _parseMcpServerDefinitionMap(definitionURI: URI, raw: unknown, pluginFsPath: string, adapter: IAgentPluginFormatAdapter): IAgentPluginMcpServerDefinition[] { const mcpServers = resolveMcpServersMap(raw); if (!mcpServers) { return []; @@ -699,7 +699,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements continue; } - let def: IAgentPluginMcpServerDefinition = { name, configuration }; + let def: IAgentPluginMcpServerDefinition = { name, configuration, uri: definitionURI }; if (adapter.pluginRootToken && adapter.pluginRootEnvVar) { def = interpolateMcpPluginRoot(def, pluginFsPath, adapter.pluginRootToken, adapter.pluginRootEnvVar); } @@ -1120,7 +1120,14 @@ export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscover remove: () => { this._enablementModel.remove(stat.resource.toString()); this._pluginMarketplaceService.removeInstalledPlugin(entry.pluginUri); - this._pluginRepositoryService.cleanupPluginSource(entry.plugin).catch(error => { + + // Pass remaining installed descriptors so the repository service + // can skip deletion when other plugins share the same cache dir. + const remaining = this._pluginMarketplaceService.installedPlugins.get(); + this._pluginRepositoryService.cleanupPluginSource( + entry.plugin, + remaining.map(e => e.plugin.sourceDescriptor), + ).catch(error => { this._logService.error('[MarketplaceAgentPluginDiscovery] Failed to clean up plugin source', error); }); }, diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 49a873828a9..b67baada8a6 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -57,6 +57,7 @@ export interface IGitHubPluginSource { readonly repo: string; readonly ref?: string; readonly sha?: string; + readonly path?: string; } export interface IGitUrlPluginSource { @@ -113,6 +114,7 @@ interface IJsonPluginSource { readonly package?: string; readonly ref?: string; readonly sha?: string; + readonly path?: string; readonly version?: string; readonly registry?: string; } @@ -840,11 +842,16 @@ export function parsePluginSource( logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'sha' must be a full 40-character commit hash when provided`); return undefined; } + if (!isOptionalString(rawSource.path)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'path' must be a string when provided`); + return undefined; + } return { kind: PluginSourceKind.GitHub, repo: rawSource.repo, ref: rawSource.ref, sha: rawSource.sha, + path: rawSource.path, }; } case 'url': { @@ -930,7 +937,7 @@ export function getPluginSourceLabel(descriptor: IPluginSourceDescriptor): strin case PluginSourceKind.RelativePath: return descriptor.path || '.'; case PluginSourceKind.GitHub: - return descriptor.repo; + return descriptor.path ? `${descriptor.repo}/${descriptor.path}` : descriptor.repo; case PluginSourceKind.GitUrl: return descriptor.url; case PluginSourceKind.Npm: @@ -952,7 +959,8 @@ export function hasSourceChanged(installed: IPluginSourceDescriptor, marketplace switch (installed.kind) { case PluginSourceKind.GitHub: return installed.ref !== (marketplace as typeof installed).ref - || installed.sha !== (marketplace as typeof installed).sha; + || installed.sha !== (marketplace as typeof installed).sha + || installed.path !== (marketplace as typeof installed).path; case PluginSourceKind.GitUrl: return installed.ref !== (marketplace as typeof installed).ref || installed.sha !== (marketplace as typeof installed).sha; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 0af684c506b..dafa487c65b 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -23,13 +23,12 @@ import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariable import { ILanguageModelToolsService, IToolData, VSCodeToolReference } from '../tools/languageModelToolsService.js'; import { PromptsConfig } from './config/config.js'; import { isInClaudeAgentsFolder, isInClaudeRulesFolder, isPromptOrInstructionsFile } from './config/promptFileLocations.js'; -import { ParsedPromptFile } from './promptFileParser.js'; +import { ParsedPromptFile, PromptHeader } from './promptFileParser.js'; import { AgentFileType, ICustomAgent, IPromptPath, IPromptsService } from './service/promptsService.js'; import { AGENT_DEBUG_LOG_ENABLED_SETTING, AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_SKILL_PATH } from './promptTypes.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { ChatConfiguration, ChatModeKind } from '../constants.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; -import { mark } from '../../../../../base/common/performance.js'; export type InstructionsCollectionEvent = { applyingInstructionsCount: number; @@ -96,7 +95,6 @@ export class ComputeAutomaticInstructions { } public async collect(variables: ChatRequestVariableSet, token: CancellationToken): Promise { - mark('code/chat/willCollectInstructions'); const instructionFiles = await this._promptsService.getInstructionFiles(token, this._sessionResource); @@ -121,7 +119,6 @@ export class ComputeAutomaticInstructions { } this.sendTelemetry(telemetryEvent); - mark('code/chat/didCollectInstructions'); } private sendTelemetry(telemetryEvent: InstructionsCollectionEvent): void { @@ -236,21 +233,6 @@ export class ComputeAutomaticInstructions { } } - /** - * Combines the `applyTo` and `paths` attributes into a single comma-separated - * pattern string that can be matched by {@link _matches}. - * Used for the instructions list XML output where both should be shown. - */ - private _getApplyToPattern(applyTo: string | undefined, paths: readonly string[] | undefined): string | undefined { - if (applyTo) { - return applyTo; - } - if (paths && paths.length > 0) { - return paths.join(', '); - } - return undefined; - } - private _matches(files: ResourceSet, applyToPattern: string): { pattern: string; file?: URI } | undefined { const patterns = splitGlobAware(applyToPattern, ','); const patterMatches = (pattern: string): { pattern: string; file?: URI } | undefined => { @@ -325,12 +307,12 @@ export class ComputeAutomaticInstructions { if (parsedFile) { entries.push(''); if (parsedFile.header) { - const { description, applyTo, paths } = parsedFile.header; + const { description } = parsedFile.header; if (description) { entries.push(`${description}`); } entries.push(`${filePath(uri)}`); - const applyToPattern = this._getApplyToPattern(applyTo, paths); + const applyToPattern = evaluateApplyToPattern(parsedFile.header, isInClaudeRulesFolder(uri)); if (applyToPattern) { entries.push(`${applyToPattern}`); } @@ -536,3 +518,14 @@ export function getFilePath(uri: URI, remoteOS: OperatingSystem | undefined): st } return uri.toString(); } + +/** + * Returns `applyTo` or `paths` attributes based on whether the instruction file is a Claude rules file or a regular instruction file + */ +export function evaluateApplyToPattern(header: PromptHeader | undefined, isClaudeRules: boolean): string | undefined { + if (isClaudeRules) { + // For Claude rules files, `paths` is the primary attribute (defaulting to '**' when omitted) + return header?.paths?.join(', ') ?? '**'; + } + return header?.applyTo ?? undefined; // For regular instruction files, only show `applyTo` patterns, and skip if it's omitted +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 6e360f9d8b3..bc63b772fe5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -4,12 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../../base/common/uri.js'; -import { posix } from '../../../../../../base/common/path.js'; +import { basename, dirname } from '../../../../../../base/common/resources.js'; import { PromptsType } from '../promptTypes.js'; import { PromptsStorage } from '../service/promptsService.js'; -const { basename, dirname } = posix; - /** * File extension for the reusable prompt files. */ @@ -35,6 +33,11 @@ export const AGENT_FILE_EXTENSION = '.agent.md'; */ export const SKILL_FILENAME = 'SKILL.md'; +/** + * Regex for valid skill names: lowercase alphanumeric and hyphens only. + */ +export const VALID_SKILL_NAME_REGEX = /^[a-z0-9-]+$/; + /** * AGENT file name */ @@ -217,7 +220,7 @@ export const DEFAULT_HOOK_FILE_PATHS: readonly IPromptSourceFolder[] = [ * Helper function to check if a file is directly in the .github/agents/ folder (not in subfolders). */ function isInAgentsFolder(fileUri: URI): boolean { - const dir = dirname(fileUri.path); + const dir = dirname(fileUri).path; return dir.endsWith('/' + AGENTS_SOURCE_FOLDER) || dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER) || isInCopilotAgentsFolder(fileUri); } @@ -225,7 +228,7 @@ function isInAgentsFolder(fileUri: URI): boolean { * Helper function to check if a file is directly in the .claude/agents/ folder. */ export function isInClaudeAgentsFolder(fileUri: URI): boolean { - const dir = dirname(fileUri.path); + const dir = dirname(fileUri).path; return dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER); } @@ -233,7 +236,7 @@ export function isInClaudeAgentsFolder(fileUri: URI): boolean { * Helper function to check if a file is directly in the ~/.copilot/agents/ folder. */ export function isInCopilotAgentsFolder(fileUri: URI): boolean { - const dir = dirname(fileUri.path); + const dir = dirname(fileUri).path; return dir.endsWith(COPILOT_USER_AGENTS_SOURCE_FOLDER.substring(1)); } @@ -255,7 +258,7 @@ export function isInClaudeRulesFolder(fileUri: URI): boolean { * PromptsType.hook regardless of its location. */ export function getPromptFileType(fileUri: URI): PromptsType | undefined { - const filename = basename(fileUri.path); + const filename = basename(fileUri); if (filename.endsWith(PROMPT_FILE_EXTENSION)) { return PromptsType.prompt; @@ -335,12 +338,15 @@ export function getPromptFileDefaultLocations(type: PromptsType): readonly IProm } } +export function getSkillFolderName(fileUri: URI): string { + return basename(dirname(fileUri)); +} /** * Gets clean prompt name without file extension. */ export function getCleanPromptName(fileUri: URI): string { - const fileName = basename(fileUri.path); + const fileName = basename(fileUri); const extensions = [ PROMPT_FILE_EXTENSION, @@ -351,33 +357,33 @@ export function getCleanPromptName(fileUri: URI): string { for (const ext of extensions) { if (fileName.endsWith(ext)) { - return basename(fileUri.path, ext); + return basename(fileUri, ext); } } if (fileName === COPILOT_CUSTOM_INSTRUCTIONS_FILENAME) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // For SKILL.md files (case insensitive), return 'SKILL' if (fileName.toLowerCase() === SKILL_FILENAME.toLowerCase()) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // For .md files in .github/agents/ folder, treat them as agent files // Exclude README.md to allow documentation files if (fileName.endsWith('.md') && fileName !== 'README.md' && isInAgentsFolder(fileUri)) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // For .md files in .claude/rules/ folder, treat them as instruction files if (fileName.endsWith('.md') && fileName !== 'README.md' && isInClaudeRulesFolder(fileUri)) { - return basename(fileUri.path, '.md'); + return basename(fileUri, '.md'); } // because we now rely on the `prompt` language ID that can be explicitly // set for any document in the editor, any file can be a "prompt" file, so // to account for that, we return the full file name including the file // extension for all other cases - return basename(fileUri.path); + return basename(fileUri); } 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 664dab89291..30cad1ba8fe 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -24,7 +24,7 @@ import { IFileService } from '../../../../../../platform/files/common/files.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, CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION, VALID_SKILL_NAME_REGEX } 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'; @@ -52,7 +52,7 @@ export class PromptValidator { await this.validateHeader(promptAST, promptType, target, report); await this.validateBody(promptAST, target, report); await this.validateFileName(promptAST, promptType, report); - await this.validateSkillFolderName(promptAST, promptType, report); + await this.validateSkillAttributes(promptAST, promptType, report); } private async validateFileName(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { @@ -66,32 +66,55 @@ export class PromptValidator { } } - private async validateSkillFolderName(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { + private async validateSkillAttributes(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { if (promptType !== PromptsType.skill) { return; } const nameAttribute = promptAST.header?.attributes.find(attr => attr.key === PromptHeaderAttributes.name); - if (!nameAttribute || nameAttribute.value.type !== 'scalar') { + if (!nameAttribute) { + report(toMarker( + localize('promptValidator.skillNameMissing', "Skill must provide a name."), + new Range(1, 1, 1, 4), + MarkerSeverity.Error + )); return; } - const skillName = nameAttribute.value.value.trim(); - if (!skillName) { + const descriptionAttribute = promptAST.header?.attributes.find(attr => attr.key === PromptHeaderAttributes.description); + if (!descriptionAttribute) { + report(toMarker( + localize('promptValidator.skillDescriptionMissing', "Skill must provide a description."), + new Range(1, 1, 1, 4), + MarkerSeverity.Error + )); return; } - // Extract folder name from path (e.g., .github/skills/my-skill/SKILL.md -> my-skill) - const pathParts = promptAST.uri.path.split('/'); - const skillIndex = pathParts.findIndex(part => part === 'SKILL.md'); - if (skillIndex > 0) { - const folderName = pathParts[skillIndex - 1]; - if (folderName && skillName !== folderName) { - report(toMarker( - localize('promptValidator.skillNameFolderMismatch', "The skill name '{0}' should match the folder name '{1}'.", skillName, folderName), - nameAttribute.value.range, - MarkerSeverity.Warning - )); + if (nameAttribute.value.type === 'scalar') { + const skillName = nameAttribute.value.value.trim(); + if (skillName.length > 0) { + if (!VALID_SKILL_NAME_REGEX.test(skillName)) { + report(toMarker( + localize('promptValidator.skillNameInvalidChars', "Skill name may only contain lowercase letters, numbers, and hyphens."), + nameAttribute.value.range, + MarkerSeverity.Error + )); + } + + // Extract folder name from path (e.g., .github/skills/my-skill/SKILL.md -> my-skill) + const pathParts = promptAST.uri.path.split('/'); + const skillIndex = pathParts.findIndex(part => part === 'SKILL.md'); + if (skillIndex > 0) { + const folderName = pathParts[skillIndex - 1]; + if (folderName && skillName !== folderName) { + report(toMarker( + localize('promptValidator.skillNameFolderMismatch', "The skill name '{0}' should match the folder name '{1}'.", skillName, folderName), + nameAttribute.value.range, + MarkerSeverity.Warning + )); + } + } } } } 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 930c9390c2b..8bae5ad6026 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -29,7 +29,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; 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, COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, getCleanPromptName, GITHUB_CONFIG_FOLDER, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; +import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, getCleanPromptName, getSkillFolderName, GITHUB_CONFIG_FOLDER, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; import { PROMPT_LANGUAGE_ID, PromptsType, Target, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IWorkspaceInstructionFile, @@ -267,11 +267,23 @@ export class PromptsService extends Disposable implements IPromptsService { this._register(autorun(reader => { const plugins = this.agentPluginService.plugins.read(reader); + const hookFiles: IPluginPromptPath[] = []; for (const plugin of plugins) { if (isContributionEnabled(plugin.enablement.read(reader))) { - plugin.hooks.read(reader); + for (const hook of plugin.hooks.read(reader)) { + hookFiles.push({ + uri: hook.uri, + storage: PromptsStorage.plugin, + type: PromptsType.hook, + name: getCanonicalPluginCommandId(plugin, hook.originalId), + pluginUri: plugin.uri, + }); + } } } + + this._pluginPromptFilesByType.set(PromptsType.hook, hookFiles); + this.cachedFileLocations[PromptsType.hook] = undefined; this._onDidPluginHooksChange.fire(); })); } @@ -603,7 +615,11 @@ export class PromptsService extends Disposable implements IPromptsService { const promptFiles = await this.listPromptFiles(PromptsType.prompt, token); const useAgentSkills = this.configurationService.getValue(PromptsConfig.USE_AGENT_SKILLS); const skills = useAgentSkills ? await this.listPromptFiles(PromptsType.skill, token) : []; - const slashCommandFiles = [...promptFiles, ...skills]; + const disabledSkills = this.getDisabledPromptFiles(PromptsType.skill); + const slashCommandFiles = [ + ...promptFiles, + ...skills.filter(s => !disabledSkills.has(s.uri)), + ]; const details = await Promise.all(slashCommandFiles.map(async promptPath => { try { const parsedPromptFile = await this.parseNew(promptPath.uri, token); @@ -1007,6 +1023,9 @@ export class PromptsService extends Disposable implements IPromptsService { this.storageService.store(this.disabledPromptsStorageKeyPrefix + type, JSON.stringify(disabled), StorageScope.PROFILE, StorageTarget.USER); if (type === PromptsType.agent) { this.cachedCustomAgents.refresh(); + } else if (type === PromptsType.skill) { + this.cachedSkills.refresh(); + this.cachedSlashCommands.refresh(); } } @@ -1040,8 +1059,7 @@ export class PromptsService extends Disposable implements IPromptsService { const sanitizedName = this.truncateAgentSkillName(name, uri); // Validate that the sanitized name matches the parent folder name (per agentskills.io specification) - const skillFolderUri = dirname(uri); - const folderName = basename(skillFolderUri); + const folderName = getSkillFolderName(uri); if (sanitizedName !== folderName) { this.logger.error(`[validateAndSanitizeSkillFile] Agent skill name "${sanitizedName}" does not match folder name "${folderName}": ${uri}`); throw new SkillNameMismatchError(uri, sanitizedName, folderName); @@ -1055,10 +1073,10 @@ export class PromptsService extends Disposable implements IPromptsService { const MAX_NAME_LENGTH = 64; const sanitized = this.sanitizeAgentSkillText(name); if (sanitized !== name) { - this.logger.warn(`[findAgentSkills] Agent skill name contains XML tags, removed: ${uri}`); + this.logger.debug(`[findAgentSkills] Agent skill name contains XML tags, removed: ${uri}`); } if (sanitized.length > MAX_NAME_LENGTH) { - this.logger.warn(`[findAgentSkills] Agent skill name exceeds ${MAX_NAME_LENGTH} characters, truncated: ${uri}`); + this.logger.debug(`[findAgentSkills] Agent skill name exceeds ${MAX_NAME_LENGTH} characters, truncated: ${uri}`); return sanitized.substring(0, MAX_NAME_LENGTH); } return sanitized; @@ -1071,10 +1089,10 @@ export class PromptsService extends Disposable implements IPromptsService { const MAX_DESCRIPTION_LENGTH = 1024; const sanitized = this.sanitizeAgentSkillText(description); if (sanitized !== description) { - this.logger.warn(`[findAgentSkills] Agent skill description contains XML tags, removed: ${uri}`); + this.logger.debug(`[findAgentSkills] Agent skill description contains XML tags, removed: ${uri}`); } if (sanitized.length > MAX_DESCRIPTION_LENGTH) { - this.logger.warn(`[findAgentSkills] Agent skill description exceeds ${MAX_DESCRIPTION_LENGTH} characters, truncated: ${uri}`); + this.logger.debug(`[findAgentSkills] Agent skill description exceeds ${MAX_DESCRIPTION_LENGTH} characters, truncated: ${uri}`); return sanitized.substring(0, MAX_DESCRIPTION_LENGTH); } return sanitized; @@ -1374,7 +1392,8 @@ export class PromptsService extends Disposable implements IPromptsService { } const { files } = await this.computeSkillDiscoveryInfo(token); - return { type: PromptsType.skill, files }; + const sourceFolders = await this._collectSourceFolderDiagnostics(PromptsType.skill); + return { type: PromptsType.skill, files, sourceFolders }; } /** @@ -1445,34 +1464,26 @@ export class PromptsService extends Disposable implements IPromptsService { try { const parsedFile = await this.parseNew(uri, token); - const name = parsedFile.header?.name; - if (!name) { - this.logger.error(`[computeSkillDiscoveryInfo] Agent skill file missing name attribute: ${uri}`); - files.push({ uri, storage, status: 'skipped', skipReason: 'missing-name', extensionId, source }); - continue; - } + const folderName = getSkillFolderName(uri); + let name = parsedFile.header?.name; + + if (!name) { + this.logger.debug(`[computeSkillDiscoveryInfo] Agent skill file missing name attribute, using folder name "${folderName}": ${uri}`); + name = folderName; + } const sanitizedName = this.truncateAgentSkillName(name, uri); - const skillFolderUri = dirname(uri); - const folderName = basename(skillFolderUri); if (sanitizedName !== folderName) { - this.logger.error(`[computeSkillDiscoveryInfo] Agent skill name "${sanitizedName}" does not match folder name "${folderName}": ${uri}`); - files.push({ uri, storage, status: 'skipped', skipReason: 'name-mismatch', name: sanitizedName, extensionId, source }); - continue; + this.logger.debug(`[computeSkillDiscoveryInfo] Agent skill name "${sanitizedName}" does not match folder name "${folderName}", using folder name: ${uri}`); } if (seenNames.has(sanitizedName)) { - this.logger.warn(`[computeSkillDiscoveryInfo] Skipping duplicate agent skill name: ${sanitizedName} at ${uri}`); + this.logger.debug(`[computeSkillDiscoveryInfo] Skipping duplicate agent skill name: ${sanitizedName} at ${uri}`); files.push({ uri, storage, status: 'skipped', skipReason: 'duplicate-name', name: sanitizedName, duplicateOf: nameToUri.get(sanitizedName), extensionId, source }); continue; } const description = parsedFile.header?.description; - if (!description) { - this.logger.error(`[computeSkillDiscoveryInfo] Agent skill file missing description attribute: ${uri}`); - files.push({ uri, storage, status: 'skipped', skipReason: 'missing-description', name: sanitizedName, extensionId, source }); - continue; - } seenNames.add(sanitizedName); nameToUri.set(sanitizedName, uri); diff --git a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts index bf1214267a5..f46a4164005 100644 --- a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts @@ -223,10 +223,14 @@ export class ChatRequestParser { } } - const capabilities = context?.attachmentCapabilities ?? usedAgent?.capabilities ?? context?.attachmentCapabilities; - if (!usedAgent || capabilities?.supportsPromptAttachments) { - const slashCommands = this.slashCommandService.getCommands(location, context?.mode ?? ChatModeKind.Ask); - const slashCommand = slashCommands.find(c => c.command === command); + const capabilities = context?.attachmentCapabilities ?? usedAgent?.capabilities; + const slashCommands = this.slashCommandService.getCommands(location, context?.mode ?? ChatModeKind.Ask); + const slashCommand = slashCommands.find(c => c.command === command); + // If there is no agent, we allow any slash command. + // If there is an agent, we let + // * silent ones go through since they are only UI-facing and don't influence chat history + // * slash commands that support prompt attachments, since those are meant to be used in conjunction with an agent and we can assume the agent can handle them. + if (!usedAgent || slashCommand?.silent || capabilities?.supportsPromptAttachments) { if (slashCommand) { // Valid standalone slash command return new ChatRequestSlashCommandPart(slashRange, slashEditorRange, slashCommand); diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/listDebugEventsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/listDebugEventsTool.ts deleted file mode 100644 index 37baf2839d4..00000000000 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/listDebugEventsTool.ts +++ /dev/null @@ -1,98 +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 { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { localize } from '../../../../../../nls.js'; -import { ChatContextKeys } from '../../actions/chatContextKeys.js'; -import { IChatDebugEvent, IChatDebugService } from '../../chatDebugService.js'; -import { formatDebugEventsForContext, debugEventKindDescriptions, filterDebugEvents, debugEventFilterDescription } from '../../chatDebugEvents.js'; -import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress } from '../languageModelToolsService.js'; - -export const ListDebugEventsToolId = 'vscode_listDebugEvents_internal'; - -export const ListDebugEventsToolData: IToolData = { - id: ListDebugEventsToolId, - toolReferenceName: 'listDebugEvents', - displayName: localize('listDebugEvents.displayName', "List Debug Events"), - when: ChatContextKeys.chatSessionHasDebugTools, - canBeReferencedInPrompt: false, - modelDescription: 'Lists debug event summaries for the current chat session. Returns a compact log of events including timestamps, event IDs, and brief descriptions. Use this tool FIRST to get an overview of what happened, then call resolveDebugEventDetails on specific event IDs to get full details.\n\n' - + 'Event types:\n' - + Object.values(debugEventKindDescriptions).join('\n'), - source: ToolDataSource.Internal, - inputSchema: { - type: 'object', - properties: { - kind: { - type: 'string', - description: 'Filter by event kind: ' + Object.keys(debugEventKindDescriptions).join(', ') + '.', - }, - filter: { - type: 'string', - description: debugEventFilterDescription, - }, - limit: { - type: 'number', - description: 'Return only the N most recent matching events.', - }, - }, - }, -}; - -export class ListDebugEventsTool implements IToolImpl { - constructor( - @IChatDebugService private readonly chatDebugService: IChatDebugService, - ) { } - - async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { - return { - invocationMessage: localize('listDebugEvents.invocationMessage', 'Listing debug events'), - pastTenseMessage: localize('listDebugEvents.pastTenseMessage', 'Listed debug events'), - }; - } - - async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, _token: CancellationToken): Promise { - const sessionResource = invocation.context?.sessionResource; - if (!sessionResource) { - return { - content: [{ kind: 'text', value: 'Error: no chat session context available.' }], - }; - } - - // Ensure providers have been invoked so we have all events - if (!this.chatDebugService.hasInvokedProviders(sessionResource)) { - await this.chatDebugService.invokeProviders(sessionResource); - } - - let events: readonly IChatDebugEvent[] = this.chatDebugService.getEvents(sessionResource); - if (events.length === 0) { - return { - content: [{ kind: 'text', value: 'No debug events found for this conversation.' }], - }; - } - - events = filterDebugEvents(events, { - kind: typeof invocation.parameters['kind'] === 'string' ? invocation.parameters['kind'] : undefined, - filter: typeof invocation.parameters['filter'] === 'string' ? invocation.parameters['filter'].toLowerCase() : undefined, - limit: typeof invocation.parameters['limit'] === 'number' ? invocation.parameters['limit'] : undefined, - }); - - if (events.length === 0) { - return { - content: [{ kind: 'text', value: 'No debug events matched the filter criteria.' }], - }; - } - - const summary = formatDebugEventsForContext(events); - return { - content: [{ - kind: 'text', - value: 'Debug event log for this conversation. Each line is a summary — call resolveDebugEventDetails with the event ID (shown as [id=...]) to get full details.\n\n' - + 'IMPORTANT: Do NOT mention event IDs or tool resolution steps in your response to the user.\n\n' - + summary, - }], - }; - } -} diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts index 24bfed2d557..6fda3019827 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/resolveDebugEventDetailsTool.ts @@ -6,7 +6,7 @@ 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 { ChatDebugHookResult, 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'; @@ -36,20 +36,27 @@ function formatResolvedContent(content: IChatDebugResolvedEventContent): string case 'text': return content.value; case 'fileList': { - const lines: string[] = [`File list (${content.discoveryType}):`]; + const lines: string[] = [localize('formatResolvedContent.fileList', "File list ({0}):", content.discoveryType)]; if (content.sourceFolders) { for (const folder of content.sourceFolders) { - lines.push(` Source folder: ${folder.uri.toString()} (${folder.storage})`); + lines.push(localize('formatResolvedContent.sourceFolder', " Source folder: {0} ({1})", folder.uri.toString(), folder.storage)); } } for (const file of content.files) { - const status = file.status === 'loaded' ? 'loaded' : `skipped${file.skipReason ? `: ${file.skipReason}` : ''}`; + const status = file.status === 'loaded' + ? localize('formatResolvedContent.loaded', "loaded") + : file.skipReason + ? localize('formatResolvedContent.skippedWithReason', "skipped: {0}", file.skipReason) + : localize('formatResolvedContent.skipped', "skipped"); lines.push(` ${file.uri.toString()} [${status}]`); } return lines.join('\n'); } case 'message': { - const lines: string[] = [`${content.type === 'user' ? 'User' : 'Agent'} message: ${content.message}`]; + const messageType = content.type === 'user' + ? localize('formatResolvedContent.userMessage', "User message: {0}", content.message) + : localize('formatResolvedContent.agentMessage', "Agent message: {0}", content.message); + const lines: string[] = [messageType]; for (const section of content.sections) { lines.push(`--- ${section.name} ---`); lines.push(section.content); @@ -57,37 +64,37 @@ function formatResolvedContent(content: IChatDebugResolvedEventContent): string return lines.join('\n'); } case 'toolCall': { - const lines: string[] = [`Tool call: ${content.toolName}`]; + const lines: string[] = [localize('formatResolvedContent.toolCall', "Tool call: {0}", content.toolName)]; if (content.result) { - lines.push(`Result: ${content.result}`); + lines.push(localize('formatResolvedContent.result', "Result: {0}", content.result)); } if (content.durationInMillis !== undefined) { - lines.push(`Duration: ${content.durationInMillis}ms`); + lines.push(localize('formatResolvedContent.duration', "Duration: {0}ms", content.durationInMillis)); } if (content.input) { - lines.push(`Input:\n${content.input}`); + lines.push(localize('formatResolvedContent.input', "Input:") + '\n' + content.input); } if (content.output) { - lines.push(`Output:\n${content.output}`); + lines.push(localize('formatResolvedContent.output', "Output:") + '\n' + content.output); } return lines.join('\n'); } case 'modelTurn': { - const lines: string[] = [`Model turn: ${content.requestName}`]; + const lines: string[] = [localize('formatResolvedContent.modelTurn', "Model turn: {0}", content.requestName)]; if (content.model) { - lines.push(`Model: ${content.model}`); + lines.push(localize('formatResolvedContent.model', "Model: {0}", content.model)); } if (content.status) { - lines.push(`Status: ${content.status}`); + lines.push(localize('formatResolvedContent.status', "Status: {0}", content.status)); } if (content.durationInMillis !== undefined) { - lines.push(`Duration: ${content.durationInMillis}ms`); + lines.push(localize('formatResolvedContent.duration', "Duration: {0}ms", content.durationInMillis)); } if (content.inputTokens !== undefined || content.outputTokens !== undefined) { - lines.push(`Tokens: input=${content.inputTokens ?? '?'}, output=${content.outputTokens ?? '?'}, cached=${content.cachedTokens ?? '?'}, total=${content.totalTokens ?? '?'}`); + lines.push(localize('formatResolvedContent.tokens', "Tokens: input={0}, output={1}, cached={2}, total={3}", content.inputTokens ?? '?', content.outputTokens ?? '?', content.cachedTokens ?? '?', content.totalTokens ?? '?')); } if (content.errorMessage) { - lines.push(`Error: ${content.errorMessage}`); + lines.push(localize('formatResolvedContent.error', "Error: {0}", content.errorMessage)); } if (content.sections) { for (const section of content.sections) { @@ -97,6 +104,36 @@ function formatResolvedContent(content: IChatDebugResolvedEventContent): string } return lines.join('\n'); } + case 'hook': { + const lines: string[] = [localize('formatResolvedContent.hook', "Hook: {0}", content.hookType)]; + if (content.command) { + lines.push(localize('formatResolvedContent.command', "Command: {0}", content.command)); + } + if (content.result !== undefined) { + const resultText = content.result === ChatDebugHookResult.Success + ? localize('formatResolvedContent.hookResult.success', "Success") + : content.result === ChatDebugHookResult.Error + ? localize('formatResolvedContent.hookResult.error', "Error") + : localize('formatResolvedContent.hookResult.nonBlockingError', "Non-blocking Error"); + lines.push(localize('formatResolvedContent.result', "Result: {0}", resultText)); + } + if (content.exitCode !== undefined) { + lines.push(localize('formatResolvedContent.exitCode', "Exit Code: {0}", content.exitCode)); + } + if (content.durationInMillis !== undefined) { + lines.push(localize('formatResolvedContent.duration', "Duration: {0}ms", content.durationInMillis)); + } + if (content.input) { + lines.push(localize('formatResolvedContent.input', "Input:") + '\n' + content.input); + } + if (content.output) { + lines.push(localize('formatResolvedContent.output', "Output:") + '\n' + content.output); + } + if (content.errorMessage) { + lines.push(localize('formatResolvedContent.error', "Error: {0}", content.errorMessage)); + } + return lines.join('\n'); + } default: { const _: never = content; return JSON.stringify(_); @@ -156,28 +193,28 @@ export class ResolveDebugEventDetailsTool implements IToolImpl { const eventId = invocation.parameters['eventId']; if (typeof eventId !== 'string' || !eventId) { return { - content: [{ kind: 'text', value: 'Error: eventId parameter is required.' }], + content: [{ kind: 'text', value: localize('resolveDebugEventDetails.errorEventIdRequired', "Error: eventId parameter is required.") }], }; } const sessionResource = invocation.context?.sessionResource; if (!sessionResource) { return { - content: [{ kind: 'text', value: 'Error: no chat session context available.' }], + content: [{ kind: 'text', value: localize('resolveDebugEventDetails.errorNoSession', "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.` }], + content: [{ kind: 'text', value: localize('resolveDebugEventDetails.errorEventNotFound', "No event with ID \"{0}\" found in the current session.", eventId) }], }; } const resolved = await this.chatDebugService.resolveEvent(eventId); if (!resolved) { return { - content: [{ kind: 'text', value: `No details found for event ID: ${eventId}` }], + content: [{ kind: 'text', value: localize('resolveDebugEventDetails.errorNoDetails', "No details found for event ID: {0}", eventId) }], }; } 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 2710319a7de..12daaa059da 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -9,7 +9,6 @@ import { Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { mark } from '../../../../../../base/common/performance.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { localize } from '../../../../../../nls.js'; @@ -22,7 +21,7 @@ import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js'; import { ILanguageModelsService } from '../../languageModels.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; -import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; +import { IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../participants/chatAgents.js'; import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; import { ChatRequestHooks, mergeHooks } from '../../promptSyntax/hookSchema.js'; import { HookType } from '../../promptSyntax/hookTypes.js'; @@ -68,6 +67,9 @@ export class RunSubagentTool extends Disposable implements IToolImpl { /** Hack to port data between prepare/invoke */ private readonly _resolvedModels = new Map(); + /** Tracks the current subagent nesting depth per session to detect and limit recursion. */ + private readonly _sessionDepth = new Map(); + constructor( @IChatAgentService private readonly chatAgentService: IChatAgentService, @IChatService private readonly chatService: IChatService, @@ -81,7 +83,9 @@ export class RunSubagentTool extends Disposable implements IToolImpl { @IProductService private readonly productService: IProductService, ) { super(); - this.onDidUpdateToolData = Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(ChatConfiguration.SubagentToolCustomAgents)); + this.onDidUpdateToolData = Event.filter(this.configurationService.onDidChangeConfiguration, e => + e.affectsConfiguration(ChatConfiguration.SubagentToolCustomAgents) + ); } getToolData(): IToolData { @@ -122,8 +126,6 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { - mark('code/chat/subagent/willInvoke'); - const args = invocation.parameters as IRunSubagentToolInputParams; this.logService.debug(`RunSubagentTool: Invoking with prompt: ${args.prompt.substring(0, 100)}...`); @@ -213,8 +215,10 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // Track whether we should collect markdown (after the last tool invocation) const markdownParts: string[] = []; - // Generate a stable subAgentInvocationId for routing edits to this subagent's content part - const subAgentInvocationId = invocation.callId ?? `subagent-${generateUuid()}`; + // Generate a stable subAgentInvocationId for routing edits to this subagent's content part. + // Use chatStreamToolCallId when available because that is what ChatToolInvocation.toolCallId + // uses in the renderer (see PR #302863), and the subagent grouping matches on toolCallId. + const subAgentInvocationId = invocation.chatStreamToolCallId ?? invocation.callId ?? `subagent-${generateUuid()}`; let inEdit = false; const progressCallback = (parts: IChatProgress[]) => { @@ -245,14 +249,32 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } }; - if (modeTools) { - modeTools[RunSubagentTool.Id] = false; - modeTools[ManageTodoListToolToolId] = false; - modeTools['copilot_askQuestions'] = false; + // Determine whether the subagent should be allowed to spawn its own subagents. + const maxDepth = this.configurationService.getValue(ChatConfiguration.SubagentsMaxDepth) ?? 0; + const sessionKey = invocation.context.sessionResource.toString(); + const currentDepth = this._sessionDepth.get(sessionKey) ?? 0; + const depthAllowed = currentDepth + 1 <= maxDepth; + + if (!modeTools) { + // Initialize modeTools so that we can still enforce the max depth restriction + modeTools = {}; + } + + // Only further-restrict RunSubagentTool: do not re-enable it if it was explicitly disabled. + const existingRunSubagentEnablement = modeTools[RunSubagentTool.Id]; + if (existingRunSubagentEnablement !== false) { + modeTools[RunSubagentTool.Id] = depthAllowed; // only enable the Run Subagent tool if we are under the max depth limit + } + + modeTools[ManageTodoListToolToolId] = false; + modeTools['copilot_askQuestions'] = false; + + if (maxDepth > 0) { + this.logService.debug(`RunSubagentTool: Nested subagents enabling ${modeTools[RunSubagentTool.Id]}: session ${sessionKey}, currentDepth: ${currentDepth}, maxDepth: ${maxDepth}`); } const variableSet = new ChatRequestVariableSet(); - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined, invocation.context.sessionResource); // agents can not call subagents + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined, invocation.context.sessionResource); await computer.collect(variableSet, token); // Collect hooks from hook .json files @@ -286,9 +308,10 @@ export class RunSubagentTool extends Disposable implements IToolImpl { message: args.prompt, variables: { variables: variableSet.asArray() }, location: ChatAgentLocation.Chat, - subAgentInvocationId: invocation.callId, + subAgentInvocationId: subAgentInvocationId, subAgentName: subAgentName, userSelectedModelId: modeModelId, + modelConfiguration: modeModelId ? this.languageModelsService.getModelConfiguration(modeModelId) : undefined, userSelectedTools: modeTools, modeInstructions, parentRequestId: invocation.chatRequestId, @@ -303,17 +326,28 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } })); - // Invoke the agent - const result = await this.chatAgentService.invokeAgent( - defaultAgent.id, - agentRequest, - progressCallback, - [], - token - ); + // Invoke the agent, tracking nesting depth for recursion detection + this._sessionDepth.set(sessionKey, currentDepth + 1); + let result: IChatAgentResult | undefined; + try { + result = await this.chatAgentService.invokeAgent( + defaultAgent.id, + agentRequest, + progressCallback, + [], + token + ); + } finally { + const newDepth = (this._sessionDepth.get(sessionKey) ?? 1) - 1; + if (newDepth <= 0) { + this._sessionDepth.delete(sessionKey); + } else { + this._sessionDepth.set(sessionKey, newDepth); + } + } // Check for errors - if (result.errorDetails) { + if (result?.errorDetails) { return createToolSimpleTextResult(`Agent error: ${result.errorDetails.message}`); } diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/setArtifactsTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/setArtifactsTool.ts new file mode 100644 index 00000000000..26d62156ac4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/setArtifactsTool.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { + IToolData, + IToolImpl, + IToolInvocation, + IToolResult, + ToolDataSource, + IToolInvocationPreparationContext, + IPreparedToolInvocation, + ToolInvocationPresentation +} from '../languageModelToolsService.js'; +import { IChatArtifact, IChatArtifactsService } from '../chatArtifactsService.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; + +export const SetArtifactsToolId = 'setArtifacts'; + +const inputSchema: IJSONSchema & { properties: IJSONSchemaMap } = { + type: 'object', + properties: { + artifacts: { + type: 'array', + description: 'The complete list of artifacts for this session. Overwrites any existing artifacts.', + items: { + type: 'object', + properties: { + label: { + type: 'string', + description: 'Display label for the artifact.' + }, + uri: { + type: 'string', + description: 'Fully qualified URI of the artifact (e.g. https://localhost:3000 or file:///path/to/file). Must include the scheme.' + }, + type: { + type: 'string', + enum: ['devServer', 'screenshot', 'plan'], + description: 'The type of artifact.' + } + }, + required: ['label'] + } + } + }, + required: ['artifacts'] +}; + +export const SetArtifactsToolData: IToolData = { + id: SetArtifactsToolId, + toolReferenceName: 'artifacts', + legacyToolReferenceFullNames: ['Set Session Artifacts'], + displayName: localize('tool.setArtifacts.displayName', 'Set Session Artifacts'), + modelDescription: 'Set the list of artifacts for the current session. Each artifact has a label and either a uri or a toolCallId+dataPartIndex reference, plus an optional type (devServer, screenshot, plan). This overwrites the entire artifact list. Use this to surface important links, screenshots, plans, drafts, or temporary markdown documents to the user. URIs must be fully qualified with a scheme (e.g. https://localhost:3000, file:///tmp/plan.md). To reference a screenshot or image from a previous tool result, use toolCallId and dataPartIndex instead of uri.', + canBeReferencedInPrompt: true, + source: ToolDataSource.Internal, + inputSchema +}; + +interface ISetArtifactsToolInput { + artifacts: IChatArtifact[]; +} + +export class SetArtifactsTool implements IToolImpl { + + constructor( + @IChatArtifactsService private readonly _chatArtifactsService: IChatArtifactsService, + @IFileService private readonly _fileService: IFileService, + ) { } + + async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + return { + pastTenseMessage: new MarkdownString(localize('tool.setArtifacts.pastTense', "Updated session artifacts")), + presentation: ToolInvocationPresentation.Hidden, + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: never, _progress: never, _token: CancellationToken): Promise { + const args = invocation.parameters as ISetArtifactsToolInput; + const chatSessionResource = invocation.context?.sessionResource; + if (!chatSessionResource) { + return { + content: [{ kind: 'text', value: 'Error: No session resource available' }] + }; + } + + const artifacts: IChatArtifact[] = []; + for (const a of args.artifacts ?? []) { + let uri = a.uri; + if (!uri) { + uri = ''; + } + + if (uri) { + const parsed = URI.parse(uri); + if (parsed.scheme !== 'http' && parsed.scheme !== 'https') { + if (!await this._fileService.exists(parsed)) { + throw new Error(localize('tool.setArtifacts.uriNotFound', "Artifact URI does not exist: {0}", uri)); + } + } + } + + artifacts.push({ label: a.label, uri, type: a.type }); + } + + this._chatArtifactsService.setArtifacts(chatSessionResource, artifacts); + + return { + content: [{ kind: 'text', value: localize('tool.setArtifacts.success', "Set {0} artifact(s)", artifacts.length) }] + }; + } +} 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 fe6be20ef6d..ce3c0a9b8df 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/tools.ts @@ -3,17 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../../../common/contributions.js'; +import { ChatConfiguration } from '../../constants.js'; import { ILanguageModelToolsService } from '../languageModelToolsService.js'; import { AskQuestionsTool, AskQuestionsToolData } from './askQuestionsTool.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 { ListDebugEventsTool, ListDebugEventsToolData } from './listDebugEventsTool.js'; import { RunSubagentTool } from './runSubagentTool.js'; +import { SetArtifactsTool, SetArtifactsToolData } from './setArtifactsTool.js'; import { TaskCompleteTool, TaskCompleteToolData } from './taskCompleteTool.js'; export class BuiltinToolsContribution extends Disposable implements IWorkbenchContribution { @@ -23,6 +24,7 @@ export class BuiltinToolsContribution extends Disposable implements IWorkbenchCo constructor( @ILanguageModelToolsService toolsService: ILanguageModelToolsService, @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); @@ -48,14 +50,23 @@ export class BuiltinToolsContribution extends Disposable implements IWorkbenchCo 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 listDebugEventsTool = instantiationService.createInstance(ListDebugEventsTool); - this._register(toolsService.registerTool(ListDebugEventsToolData, listDebugEventsTool)); - this._register(toolsService.readToolSet.addTool(ListDebugEventsToolData)); - + const setArtifactsTool = instantiationService.createInstance(SetArtifactsTool); + const setArtifactsRegistration = this._register(new MutableDisposable()); + const updateArtifactsRegistration = () => { + if (configurationService.getValue(ChatConfiguration.ArtifactsEnabled)) { + if (!setArtifactsRegistration.value) { + setArtifactsRegistration.value = toolsService.registerTool(SetArtifactsToolData, setArtifactsTool); + } + } else { + setArtifactsRegistration.clear(); + } + }; + updateArtifactsRegistration(); + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.ArtifactsEnabled)) { + updateArtifactsRegistration(); + } + })); const runSubagentTool = this._register(instantiationService.createInstance(RunSubagentTool)); diff --git a/src/vs/workbench/contrib/chat/common/tools/chatArtifactsService.ts b/src/vs/workbench/contrib/chat/common/tools/chatArtifactsService.ts new file mode 100644 index 00000000000..082226d5823 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/tools/chatArtifactsService.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IObservable, observableValueOpts } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { Memento } from '../../../../common/memento.js'; +import { chatSessionResourceToId } from '../model/chatUri.js'; + +export interface IChatArtifact { + readonly label: string; + readonly uri: string; + readonly toolCallId?: string; + readonly dataPartIndex?: number; + readonly type: 'devServer' | 'screenshot' | 'plan' | undefined; +} + +export const IChatArtifactsService = createDecorator('chatArtifactsService'); + +export interface IChatArtifactsService { + readonly _serviceBrand: undefined; + readonly onDidUpdateArtifacts: Event; + getArtifacts(sessionResource: URI): readonly IChatArtifact[]; + setArtifacts(sessionResource: URI, artifacts: IChatArtifact[]): void; + migrateArtifacts(oldSessionResource: URI, newSessionResource: URI): void; + artifacts(sessionResource: URI): IObservable; +} + +class ChatArtifactsStorage { + private readonly _memento: Memento>; + + constructor(@IStorageService storageService: IStorageService) { + this._memento = new Memento('chat-artifacts', storageService); + } + + getArtifacts(sessionResource: URI): IChatArtifact[] { + const storage = this._memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE); + return storage[this._toKey(sessionResource)] || []; + } + + setArtifacts(sessionResource: URI, artifacts: IChatArtifact[]): void { + const storage = this._memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE); + storage[this._toKey(sessionResource)] = artifacts; + this._memento.saveMemento(); + } + + migrateArtifacts(oldSessionResource: URI, newSessionResource: URI): void { + const artifacts = this.getArtifacts(oldSessionResource); + if (artifacts.length > 0) { + this.setArtifacts(newSessionResource, artifacts); + const storage = this._memento.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE); + delete storage[this._toKey(oldSessionResource)]; + this._memento.saveMemento(); + } + } + + private _toKey(sessionResource: URI): string { + return chatSessionResourceToId(sessionResource); + } +} + +export class ChatArtifactsService extends Disposable implements IChatArtifactsService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidUpdateArtifacts = this._register(new Emitter()); + readonly onDidUpdateArtifacts = this._onDidUpdateArtifacts.event; + + private readonly _storage: ChatArtifactsStorage; + private readonly _observables = new Map>>(); + + constructor(@IStorageService storageService: IStorageService) { + super(); + this._storage = new ChatArtifactsStorage(storageService); + } + + getArtifacts(sessionResource: URI): readonly IChatArtifact[] { + return this._storage.getArtifacts(sessionResource); + } + + setArtifacts(sessionResource: URI, artifacts: IChatArtifact[]): void { + this._storage.setArtifacts(sessionResource, artifacts); + const key = chatSessionResourceToId(sessionResource); + this._observables.get(key)?.set(artifacts, undefined); + this._onDidUpdateArtifacts.fire(sessionResource); + } + + migrateArtifacts(oldSessionResource: URI, newSessionResource: URI): void { + this._storage.migrateArtifacts(oldSessionResource, newSessionResource); + this._onDidUpdateArtifacts.fire(newSessionResource); + } + + artifacts(sessionResource: URI): IObservable { + const key = chatSessionResourceToId(sessionResource); + let obs = this._observables.get(key); + if (!obs) { + obs = observableValueOpts({ owner: this, equalsFn: () => false }, this._storage.getArtifacts(sessionResource)); + this._observables.set(key, obs); + } + return obs; + } +} diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts index 2e3c66261b8..fb2e048ded0 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsConfirmationService.ts @@ -10,6 +10,19 @@ import { IQuickInputButton, IQuickTreeItem } from '../../../../../platform/quick import { ConfirmedReason } from '../chatService/chatService.js'; import { IToolData, ToolDataSource } from './languageModelToolsService.js'; +/** + * Computes a stable, bounded key for a tool+parameters combination + * using SHA-256 via SubtleCrypto. The resulting hex digest ensures + * raw parameter values are never leaked into storage. + */ +export async function computeCombinationKey(toolId: string, parameters: unknown): Promise { + const input = toolId + ':' + JSON.stringify(parameters); + const encoded = new TextEncoder().encode(input); + const buffer = await crypto.subtle.digest('SHA-256', encoded); + const hashHex = Array.from(new Uint8Array(buffer)).map(b => b.toString(16).padStart(2, '0')).join(''); + return toolId + ':combination:' + hashHex; +} + export interface ILanguageModelToolConfirmationActions { /** Label for the action */ label: string; @@ -28,6 +41,13 @@ export interface ILanguageModelToolConfirmationRef { source: ToolDataSource; parameters: unknown; chatSessionResource?: URI; + /** When set, the confirmation service will offer combination-level approval actions */ + combination?: { + /** Human-readable label for the approval option */ + label: string; + /** Precomputed SHA-256 key for the combination */ + key: string; + }; } export interface ILanguageModelToolConfirmationActionProducer { diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts index a17eb174f38..0728a528512 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts @@ -176,7 +176,7 @@ const languageModelToolSetsExtensionPoint = extensionsRegistry.ExtensionsRegistr type: 'string' }, icon: { - markdownDescription: localize('toolSetIcon', "An icon that represents this tool set, like `$(zap)`"), + markdownDescription: localize('toolSetIcon', "An icon that represents this tool set, like {0}", '`$(zap)`'), type: 'string' }, tools: { diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 93be052e04b..318347b6be2 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -330,6 +330,13 @@ export interface IToolConfirmationMessages { confirmationNotNeededReason?: string | IMarkdownString; /** Custom button labels to display instead of the default Allow/Skip buttons. */ customButtons?: string[]; + /** When set, shows an additional approval option to approve this particular combination of tool and arguments */ + approveCombination?: { + /** Human-readable label for the approval option */ + label: string | IMarkdownString; + /** Precomputed SHA-256 key for the combination (set during tool preparation) */ + key: string; + }; } export interface IToolConfirmationAction { @@ -365,6 +372,7 @@ export interface IPreparedToolInvocation { originMessage?: string | IMarkdownString; confirmationMessages?: IToolConfirmationMessages; presentation?: ToolInvocationPresentation; + icon?: ThemeIcon; toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatModifiedFilesConfirmationData; } diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentHost.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/agentHost.contribution.ts deleted file mode 100644 index a877a5b975c..00000000000 --- a/src/vs/workbench/contrib/chat/electron-browser/agentHost.contribution.ts +++ /dev/null @@ -1,45 +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 { generateUuid } from '../../../../base/common/uuid.js'; -import { URI } from '../../../../base/common/uri.js'; -import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; -import { WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; -import { IViewsService } from '../../../services/views/common/viewsService.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { ChatViewId } from '../browser/chat.js'; -import { ChatEditorInput } from '../browser/widgetHosts/editor/chatEditorInput.js'; -import { ChatViewPane } from '../browser/widgetHosts/viewPane/chatViewPane.js'; -import { AgentSessionProviders } from '../browser/agentSessions/agentSessions.js'; -import { AgentHostContribution } from '../browser/agentSessions/agentHost/agentHostChatContribution.js'; - -registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); - -// Register command for opening a new Agent Host session from the session type picker -CommandsRegistry.registerCommand( - `workbench.action.chat.openNewChatSessionInPlace.${AgentSessionProviders.AgentHostCopilot}`, - async (accessor, chatSessionPosition: string) => { - const viewsService = accessor.get(IViewsService); - const resource = URI.from({ - scheme: AgentSessionProviders.AgentHostCopilot, - path: `/untitled-${generateUuid()}`, - }); - - if (chatSessionPosition === 'editor') { - const editorService = accessor.get(IEditorService); - await editorService.openEditor({ - resource, - options: { - override: ChatEditorInput.EditorID, - pinned: true, - }, - }); - } else { - const view = await viewsService.openView(ChatViewId) as ChatViewPane; - await view.loadSession(resource); - view.focus(); - } - } -); diff --git a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts index 16559339c6c..e581719063a 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/agentSessions/agentSessionsActions.ts @@ -11,6 +11,12 @@ import { ChatEntitlementContextKeys } from '../../../../services/chat/common/cha import { CHAT_CATEGORY } from '../../browser/actions/chatActions.js'; import { ProductQualityContext } from '../../../../../platform/contextkey/common/contextkeys.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { isMacintosh, isWindows } from '../../../../../base/common/platform.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; +import { Schemas } from '../../../../../base/common/network.js'; export class OpenSessionsWindowAction extends Action2 { constructor() { @@ -24,7 +30,21 @@ export class OpenSessionsWindowAction extends Action2 { } async run(accessor: ServicesAccessor) { - const nativeHostService = accessor.get(INativeHostService); - await nativeHostService.openSessionsWindow(); + const openerService = accessor.get(IOpenerService); + const productService = accessor.get(IProductService); + const environmentService = accessor.get(IWorkbenchEnvironmentService); + + if (environmentService.isBuilt && (isMacintosh || isWindows)) { + const scheme = productService.quality === 'stable' + ? 'vscode-sessions' + : productService.quality === 'exploration' + ? 'vscode-sessions-exploration' + : 'vscode-sessions-insiders'; + + await openerService.open(URI.from({ scheme, authority: Schemas.file }), { openExternal: true }); + } else { + const nativeHostService = accessor.get(INativeHostService); + await nativeHostService.openSessionsWindow(); + } } } diff --git a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts index d59f63292d7..e1b59c7a695 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts @@ -13,6 +13,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IWebContentExtractorService, WebContentExtractResult } from '../../../../../platform/webContentExtractor/common/webContentExtractor.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { detectEncodingFromBuffer } from '../../../../services/textfile/common/encoding.js'; import { ITrustedDomainService } from '../../../url/browser/trustedDomainService.js'; import { IChatService } from '../../common/chatService/chatService.js'; @@ -56,6 +57,7 @@ export class FetchWebPageTool implements IToolImpl { @IFileService private readonly _fileService: IFileService, @ITrustedDomainService private readonly _trustedDomainService: ITrustedDomainService, @IChatService private readonly _chatService: IChatService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, ) { } async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { @@ -170,7 +172,15 @@ export class FetchWebPageTool implements IToolImpl { } const invalid = [...Array.from(invalidUris), ...additionalInvalidUrls]; - const urlsNeedingConfirmation = new ResourceSet([...webUris.values(), ...validFileUris]); + // All valid URIs (web + file) for display in messages + const allFetchedUris = new ResourceSet([...webUris.values(), ...validFileUris]); + // File URIs that are inside the workspace don't need confirmation — they're already accessible + // and don't carry the web content risks (prompt injection, malicious redirects). + // File URIs outside the workspace are treated like web URIs and require confirmation. + const fileUrisOutsideWorkspace = validFileUris.filter( + uri => !this._workspaceContextService.getWorkspaceFolder(uri) + ); + const urlsNeedingConfirmation = new ResourceSet([...webUris.values(), ...fileUrisOutsideWorkspace]); const pastTenseMessage = invalid.length ? invalid.length > 1 @@ -178,7 +188,7 @@ export class FetchWebPageTool implements IToolImpl { ? new MarkdownString( localize( 'fetchWebPage.pastTenseMessage.plural', - 'Fetched {0} resources, but the following were invalid URLs:\n\n{1}\n\n', urlsNeedingConfirmation.size, invalid.map(url => `- ${url}`).join('\n') + 'Fetched {0} resources, but the following were invalid URLs:\n\n{1}\n\n', allFetchedUris.size, invalid.map(url => `- ${url}`).join('\n') )) // If there is only one invalid URL, show it : new MarkdownString( @@ -190,11 +200,11 @@ export class FetchWebPageTool implements IToolImpl { : new MarkdownString(); const invocationMessage = new MarkdownString(); - if (urlsNeedingConfirmation.size > 1) { - pastTenseMessage.appendMarkdown(localize('fetchWebPage.pastTenseMessageResult.plural', 'Fetched {0} resources', urlsNeedingConfirmation.size)); - invocationMessage.appendMarkdown(localize('fetchWebPage.invocationMessage.plural', 'Fetching {0} resources', urlsNeedingConfirmation.size)); - } else if (urlsNeedingConfirmation.size === 1) { - const url = Iterable.first(urlsNeedingConfirmation)!.toString(true); + if (allFetchedUris.size > 1) { + pastTenseMessage.appendMarkdown(localize('fetchWebPage.pastTenseMessageResult.plural', 'Fetched {0} resources', allFetchedUris.size)); + invocationMessage.appendMarkdown(localize('fetchWebPage.invocationMessage.plural', 'Fetching {0} resources', allFetchedUris.size)); + } else if (allFetchedUris.size === 1) { + const url = Iterable.first(allFetchedUris)!.toString(true); // If the URL is too long or it's a file url, show it as a link... otherwise, show it as plain text if (url.length > 400 || validFileUris.length === 1) { pastTenseMessage.appendMarkdown(localize({ diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 36256611e93..765158017c4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -16,7 +16,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IActionEnvelope, INotification, IPermissionResolvedAction, ISessionAction, ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { SessionLifecycle, SessionStatus, TurnState, createSessionState, ROOT_STATE_URI, type ISessionState, type ISessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionLifecycle, SessionStatus, TurnState, createSessionState, ROOT_STATE_URI, PolicyState, type ISessionState, type ISessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -49,8 +49,6 @@ class MockAgentHostService extends mock() { public createSessionCalls: IAgentCreateSessionConfig[] = []; public agents = [{ provider: 'copilot' as const, displayName: 'Agent Host - Copilot', description: 'test', requiresAuth: true }]; - override async setAuthToken(_token: string): Promise { } - override async listSessions(): Promise { return [...this._sessions.values()]; } @@ -1181,8 +1179,8 @@ suite('AgentHostChatContribution', () => { test('filters out disabled models', async () => { const provider = disposables.add(new AgentHostLanguageModelProvider('agent-host-copilot', 'agent-host-copilot')); provider.updateModels([ - { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, supportsVision: false, policyState: 'enabled' }, - { provider: 'copilot', id: 'gpt-3.5', name: 'GPT-3.5', maxContextWindow: 16000, supportsVision: false, policyState: 'disabled' }, + { provider: 'copilot', id: 'gpt-4o', name: 'GPT-4o', maxContextWindow: 128000, supportsVision: false, policyState: PolicyState.Enabled }, + { provider: 'copilot', id: 'gpt-3.5', name: 'GPT-3.5', maxContextWindow: 16000, supportsVision: false, policyState: PolicyState.Disabled }, ]); const models = await provider.provideLanguageModelChatInfo({}, CancellationToken.None); 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 44fca63d1d5..56f217f3c3a 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 @@ -6,13 +6,13 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { AgentSessionsDataSource, AgentSessionListItem, IAgentSessionsFilter, sessionDateFromNow, getRepositoryName, AgentSessionsSorter } from '../../../browser/agentSessions/agentSessionsViewer.js'; +import { AgentSessionsDataSource, AgentSessionListItem, IAgentSessionsFilter, sessionDateFromNow, getRepositoryName, AgentSessionsSorter, groupAgentSessionsByDate } from '../../../browser/agentSessions/agentSessionsViewer.js'; import { AgentSessionSection, IAgentSession, IAgentSessionSection, IAgentSessionsModel, isAgentSessionSection } from '../../../browser/agentSessions/agentSessionsModel.js'; import { ChatSessionStatus } from '../../../common/chatSessionsService.js'; import { ITreeSorter } from '../../../../../../base/browser/ui/tree/tree.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; -import { AgentSessionsGrouping } from '../../../browser/agentSessions/agentSessionsFilter.js'; +import { AgentSessionsGrouping, AgentSessionsSorting } from '../../../browser/agentSessions/agentSessionsFilter.js'; suite('sessionDateFromNow', () => { @@ -536,16 +536,18 @@ suite('AgentSessionsDataSource', () => { assert.strictEqual(archivedSection.sessions[0].label, 'Session archived-pinned'); }); - test('pinned sessions are not capped into More section with capped grouping', () => { + test('pinned sessions are always shown above the cap with capped grouping', () => { const now = Date.now(); const sessions = [ - // Two pinned sessions — sorted to top by time so they appear in the flat portion - createMockSession({ id: 'pinned1', isPinned: true, startTime: now }), - createMockSession({ id: 'pinned2', isPinned: true, startTime: now - ONE_DAY }), - // Additional unpinned sessions to exceed the cap and populate the More section + // Recent unpinned sessions fill the top 3 by time createMockSession({ id: 's1', startTime: now }), createMockSession({ id: 's2', startTime: now - ONE_DAY }), createMockSession({ id: 's3', startTime: now - 2 * ONE_DAY }), + // Unpinned overflow + createMockSession({ id: 's4', startTime: now - 3 * ONE_DAY }), + // Two pinned sessions with old timestamps — would fall outside top 3 by time alone + createMockSession({ id: 'pinned1', isPinned: true, startTime: now - 4 * ONE_DAY }), + createMockSession({ id: 'pinned2', isPinned: true, startTime: now - 5 * ONE_DAY }), ]; const filter = createMockFilter({ @@ -558,20 +560,97 @@ suite('AgentSessionsDataSource', () => { const mockModel = createMockModel(sessions); const result = Array.from(dataSource.getChildren(mockModel)); const sections = getSectionsFromResult(result); + const topSessions = result.filter((r): r is IAgentSession => !isAgentSessionSection(r)); - // Capped grouping does not create a Pinned section — all sessions are - // sorted by time and the top N appear as flat items, the rest in More. - assert.strictEqual(sections.filter(s => s.section === AgentSessionSection.Pinned).length, 0); + // Pinned sessions first, then up to 3 non-pinned sessions + assert.deepStrictEqual(topSessions.map(s => s.label), [ + 'Session pinned1', + 'Session pinned2', + 'Session s1', + 'Session s2', + 'Session s3', + ]); + // Only unpinned overflow goes to More const moreSection = sections.find(s => s.section === AgentSessionSection.More); assert.ok(moreSection); - // Pinned sessions have recent timestamps so they land in the flat top portion, - // not in the More section - const moreLabels = moreSection.sessions.map(s => s.label); - for (const label of moreLabels) { - assert.notStrictEqual(label, 'Session pinned1'); - assert.notStrictEqual(label, 'Session pinned2'); - } + assert.deepStrictEqual(moreSection.sessions.map(s => s.label), [ + 'Session s4', + ]); + }); + + test('more pinned sessions than cap limit are all shown', () => { + const now = Date.now(); + const sessions = [ + createMockSession({ id: 'pinned1', isPinned: true, startTime: now }), + createMockSession({ id: 'pinned2', isPinned: true, startTime: now - ONE_DAY }), + createMockSession({ id: 'pinned3', isPinned: true, startTime: now - 2 * ONE_DAY }), + createMockSession({ id: 'pinned4', isPinned: true, startTime: now - 3 * ONE_DAY }), + // Unpinned session — still fits within the cap of 3 non-pinned + createMockSession({ id: 'unpinned1', startTime: now - 4 * ONE_DAY }), + ]; + + const filter = createMockFilter({ + groupBy: AgentSessionsGrouping.Capped, + excludeRead: false + }); + const sorter = createMockSorter(); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); + + const mockModel = createMockModel(sessions); + const result = Array.from(dataSource.getChildren(mockModel)); + const sections = getSectionsFromResult(result); + const topSessions = result.filter((r): r is IAgentSession => !isAgentSessionSection(r)); + + // All 4 pinned + 1 unpinned (fits within cap of 3 non-pinned) + assert.deepStrictEqual(topSessions.map(s => s.label), [ + 'Session pinned1', + 'Session pinned2', + 'Session pinned3', + 'Session pinned4', + 'Session unpinned1', + ]); + + // No More section needed since unpinned count (1) is within cap (3) + const moreSection = sections.find(s => s.section === AgentSessionSection.More); + assert.strictEqual(moreSection, undefined); + }); + + test('unpinned NeedsInput session appears in the non-pinned section below pinned', () => { + const now = Date.now(); + const sessions = [ + createMockSession({ id: 'needs-input', status: ChatSessionStatus.NeedsInput, startTime: now }), + createMockSession({ id: 'pinned1', isPinned: true, startTime: now }), + createMockSession({ id: 'pinned2', isPinned: true, startTime: now - ONE_DAY }), + createMockSession({ id: 'pinned3', isPinned: true, startTime: now - 2 * ONE_DAY }), + createMockSession({ id: 's1', startTime: now }), + ]; + + const filter = createMockFilter({ + groupBy: AgentSessionsGrouping.Capped, + excludeRead: false + }); + // Use real sorter to exercise NeedsInput prioritization in capped mode + const sorter = new AgentSessionsSorter(); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, sorter)); + + const mockModel = createMockModel(sessions); + const result = Array.from(dataSource.getChildren(mockModel)); + const sections = getSectionsFromResult(result); + const topSessions = result.filter((r): r is IAgentSession => !isAgentSessionSection(r)); + + // Pinned sessions come first, then up to 3 non-pinned (NeedsInput + s1 both fit in cap) + assert.deepStrictEqual(topSessions.map(s => s.label), [ + 'Session pinned1', + 'Session pinned2', + 'Session pinned3', + 'Session needs-input', + 'Session s1', + ]); + + // All non-pinned fit within cap of 3, so no More section + const moreSection = sections.find(s => s.section === AgentSessionSection.More); + assert.strictEqual(moreSection, undefined); }); }); @@ -872,6 +951,37 @@ suite('AgentSessionsDataSource', () => { assert.deepStrictEqual(result.map(s => s.label), ['vscode']); }); + + test('Other group appears after named repos and before Archived', () => { + const now = Date.now(); + const sessions = [ + createMockSession({ id: 'no-repo', startTime: now }), + createMockSession({ id: 'repo-a', startTime: now - 1, metadata: { repositoryPath: '/path/alpha' } }), + createMockSession({ id: 'archived', startTime: now - 2, isArchived: true }), + createMockSession({ id: 'repo-b', startTime: now - 3, metadata: { repositoryPath: '/path/beta' } }), + createMockSession({ id: 'no-repo-2', startTime: now - 4 }), + ]; + + const filter = createMockFilter({ groupBy: AgentSessionsGrouping.Repository }); + const dataSource = disposables.add(new AgentSessionsDataSource(filter, createMockSorter())); + const result = getSectionsFromResult(dataSource.getChildren(createMockModel(sessions))); + + const labels = result.map(s => s.label); + const otherIndex = labels.indexOf('Other'); + const archivedIndex = labels.indexOf('Archived'); + + // Other must exist and contain the 2 sessions without repo info + assert.ok(otherIndex !== -1, 'Other section should be present'); + assert.strictEqual(result[otherIndex].sessions.length, 2); + + // Other must come after all named repo groups + for (let i = 0; i < otherIndex; i++) { + assert.strictEqual(result[i].section, AgentSessionSection.Repository, `section at index ${i} should be a named repository group`); + } + + // Archived must come after Other + assert.ok(archivedIndex > otherIndex, 'Archived section should come after Other'); + }); }); suite('getRepositoryName', () => { @@ -960,6 +1070,7 @@ suite('AgentSessionsSorter', () => { isPinned: boolean; created: number; lastRequestStarted: number; + lastRequestEnded: number; }>): IAgentSession { const now = Date.now(); return { @@ -971,7 +1082,7 @@ suite('AgentSessionsSorter', () => { icon: Codicon.terminal, timing: { created: overrides.created ?? now, - lastRequestEnded: undefined, + lastRequestEnded: overrides.lastRequestEnded, lastRequestStarted: overrides.lastRequestStarted, }, changes: undefined, @@ -1057,4 +1168,126 @@ suite('AgentSessionsSorter', () => { const sorted = [archivedPinned, regular].sort((a, b) => sorter.compare(a, b)); assert.deepStrictEqual(sorted.map(s => s.label), ['Session regular', 'Session archived-pinned']); }); + + test('sortBy Created: sorts by creation time regardless of lastRequestEnded', () => { + const sorter = new AgentSessionsSorter(() => AgentSessionsSorting.Created); + const olderCreated = createSession({ id: 'older', created: 1000, lastRequestEnded: 5000 }); + const newerCreated = createSession({ id: 'newer', created: 3000, lastRequestEnded: 2000 }); + + const sorted = [olderCreated, newerCreated].sort((a, b) => sorter.compare(a, b)); + assert.deepStrictEqual(sorted.map(s => s.label), ['Session newer', 'Session older']); + }); + + test('sortBy Updated: sorts by lastRequestEnded', () => { + const sorter = new AgentSessionsSorter(() => AgentSessionsSorting.Updated); + const recentlyUpdated = createSession({ id: 'updated', created: 1000, lastRequestEnded: 5000 }); + const recentlyCreated = createSession({ id: 'created', created: 3000, lastRequestEnded: 2000 }); + + const sorted = [recentlyCreated, recentlyUpdated].sort((a, b) => sorter.compare(a, b)); + assert.deepStrictEqual(sorted.map(s => s.label), ['Session updated', 'Session created']); + }); + + test('sortBy Updated: falls back to created when lastRequestEnded is undefined', () => { + const sorter = new AgentSessionsSorter(() => AgentSessionsSorting.Updated); + const withRequest = createSession({ id: 'with-request', created: 1000, lastRequestEnded: 3000 }); + const withoutRequest = createSession({ id: 'no-request', created: 4000 }); + + const sorted = [withRequest, withoutRequest].sort((a, b) => sorter.compare(a, b)); + assert.deepStrictEqual(sorted.map(s => s.label), ['Session no-request', 'Session with-request']); + }); +}); + +suite('groupAgentSessionsByDate with sortBy', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createSession(overrides: Partial<{ + id: string; + isArchived: boolean; + isPinned: boolean; + created: number; + lastRequestEnded: number; + }>): IAgentSession { + return { + providerType: 'test', + providerLabel: 'Test', + resource: URI.parse(`test://session/${overrides.id ?? 'default'}`), + status: ChatSessionStatus.Completed, + label: `Session ${overrides.id ?? 'default'}`, + icon: Codicon.terminal, + timing: { + created: overrides.created ?? Date.now(), + lastRequestEnded: overrides.lastRequestEnded, + lastRequestStarted: undefined, + }, + changes: undefined, + metadata: undefined, + isArchived: () => overrides.isArchived ?? false, + setArchived: () => { }, + isPinned: () => overrides.isPinned ?? false, + setPinned: () => { }, + isRead: () => true, + isMarkedUnread: () => false, + setRead: () => { }, + }; + } + + test('default (Created): buckets by created time', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const oldSession = createSession({ id: 'old', created: tenDaysAgo, lastRequestEnded: now }); + + const grouped = groupAgentSessionsByDate([oldSession]); + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + const olderSessions = grouped.get(AgentSessionSection.Older)!.sessions; + + assert.deepStrictEqual(todaySessions.length, 0); + assert.deepStrictEqual(olderSessions.length, 1); + }); + + test('Updated: session created long ago but recently updated goes into Today', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const oldButUpdated = createSession({ id: 'old-updated', created: tenDaysAgo, lastRequestEnded: now }); + + const grouped = groupAgentSessionsByDate([oldButUpdated], AgentSessionsSorting.Updated); + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + const olderSessions = grouped.get(AgentSessionSection.Older)!.sessions; + + assert.deepStrictEqual(todaySessions.length, 1); + assert.deepStrictEqual(olderSessions.length, 0); + }); + + test('Updated: falls back to created when lastRequestEnded is undefined', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const oldNoUpdate = createSession({ id: 'old-no-update', created: tenDaysAgo }); + + const grouped = groupAgentSessionsByDate([oldNoUpdate], AgentSessionsSorting.Updated); + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + const olderSessions = grouped.get(AgentSessionSection.Older)!.sessions; + + assert.deepStrictEqual(todaySessions.length, 0); + assert.deepStrictEqual(olderSessions.length, 1); + }); + + test('Updated: pinned and archived sessions are not affected by sortBy', () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + + const pinnedOld = createSession({ id: 'pinned', created: tenDaysAgo, lastRequestEnded: now, isPinned: true }); + const archivedOld = createSession({ id: 'archived', created: tenDaysAgo, lastRequestEnded: now, isArchived: true }); + + const grouped = groupAgentSessionsByDate([pinnedOld, archivedOld], AgentSessionsSorting.Updated); + const pinnedSessions = grouped.get(AgentSessionSection.Pinned)!.sessions; + const archivedSessions = grouped.get(AgentSessionSection.Archived)!.sessions; + const todaySessions = grouped.get(AgentSessionSection.Today)!.sessions; + + assert.deepStrictEqual(pinnedSessions.length, 1); + assert.deepStrictEqual(archivedSessions.length, 1); + assert.deepStrictEqual(todaySessions.length, 0); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 19b43fdf7eb..aa4337eaa49 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallRunningState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ToolCallStatus, ToolCallConfirmationReason, PermissionKind, ToolResultContentType, TurnState, type ICompletedToolCall, type IPermissionRequest, type IToolCallRunningState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocationSerialized, type IChatMarkdownContent } from '../../../common/chatService/chatService.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { turnsToHistory, toolCallStateToInvocation, permissionToConfirmation, finalizeToolInvocation } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; @@ -18,21 +18,21 @@ function createToolCallState(overrides?: Partial): IToolC toolName: 'test_tool', displayName: 'Test Tool', invocationMessage: 'Running test tool...', - status: 'running', - confirmed: 'not-needed', + status: ToolCallStatus.Running, + confirmed: ToolCallConfirmationReason.NotNeeded, ...overrides, }; } function createCompletedToolCall(overrides?: Partial): ICompletedToolCall { return { - status: 'completed', + status: ToolCallStatus.Completed, toolCallId: 'tc-1', toolName: 'test_tool', displayName: 'Test Tool', invocationMessage: 'Running test tool...', success: true, - confirmed: 'not-needed', + confirmed: ToolCallConfirmationReason.NotNeeded, pastTenseMessage: 'Ran test tool', ...overrides, } as ICompletedToolCall; @@ -54,7 +54,7 @@ function createTurn(overrides?: Partial): ITurn { function createPermission(overrides?: Partial): IPermissionRequest { return { requestId: 'perm-1', - permissionKind: 'shell', + permissionKind: PermissionKind.Shell, ...overrides, }; } @@ -103,7 +103,7 @@ suite('stateToProgressAdapter', () => { toolCalls: [createCompletedToolCall({ _meta: { toolKind: 'terminal', language: 'shellscript' }, toolInput: 'echo hello', - content: [{ type: 'text', text: 'hello' }], + content: [{ type: ToolResultContentType.Text, text: 'hello' }], success: true, })], }); @@ -157,7 +157,7 @@ suite('stateToProgressAdapter', () => { toolCalls: [createCompletedToolCall({ _meta: { toolKind: 'terminal' }, toolInput: 'bad-command', - content: [{ type: 'text', text: 'error' }], + content: [{ type: ToolResultContentType.Text, text: 'error' }], success: false, })], }); @@ -183,7 +183,7 @@ suite('stateToProgressAdapter', () => { toolName: 'my_tool', displayName: 'My Tool', invocationMessage: 'Doing stuff', - status: 'running', + status: ToolCallStatus.Running, }); const invocation = toolCallStateToInvocation(tc); @@ -217,7 +217,7 @@ suite('stateToProgressAdapter', () => { test('shell permission has terminal data', () => { const perm = createPermission({ - permissionKind: 'shell', + permissionKind: PermissionKind.Shell, fullCommandText: 'rm -rf /', intention: 'Delete everything', }); @@ -231,7 +231,7 @@ suite('stateToProgressAdapter', () => { test('mcp permission uses server + tool name as title', () => { const perm = createPermission({ - permissionKind: 'mcp', + permissionKind: PermissionKind.Mcp, serverName: 'My Server', toolName: 'my_tool', }); @@ -243,7 +243,7 @@ suite('stateToProgressAdapter', () => { test('write permission has input data', () => { const perm = createPermission({ - permissionKind: 'write', + permissionKind: PermissionKind.Write, path: '/test.ts', rawRequest: '{"path":"/test.ts","content":"hello"}', }); @@ -260,22 +260,22 @@ suite('stateToProgressAdapter', () => { const tc = createToolCallState({ _meta: { toolKind: 'terminal' }, toolInput: 'echo hi', - status: 'running', + status: ToolCallStatus.Running, }); const invocation = toolCallStateToInvocation(tc); finalizeToolInvocation(invocation, { - status: 'completed', + status: ToolCallStatus.Completed, toolCallId: 'tc-1', toolName: 'test_tool', displayName: 'Test Tool', invocationMessage: 'Running test tool...', _meta: { toolKind: 'terminal' }, toolInput: 'echo hi', - confirmed: 'not-needed', + confirmed: ToolCallConfirmationReason.NotNeeded, success: true, pastTenseMessage: 'Ran echo hi', - content: [{ type: 'text', text: 'output text' }], + content: [{ type: ToolResultContentType.Text, text: 'output text' }], }); assert.ok(invocation.toolSpecificData); @@ -287,17 +287,17 @@ suite('stateToProgressAdapter', () => { test('finalizes failed tool with error message', () => { const tc = createToolCallState({ - status: 'running', + status: ToolCallStatus.Running, }); const invocation = toolCallStateToInvocation(tc); finalizeToolInvocation(invocation, { - status: 'completed', + status: ToolCallStatus.Completed, toolCallId: 'tc-1', toolName: 'test_tool', displayName: 'Test Tool', invocationMessage: 'Running test tool...', - confirmed: 'not-needed', + confirmed: ToolCallConfirmationReason.NotNeeded, success: false, pastTenseMessage: 'Failed', error: { message: 'timeout' }, diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts index 95988b49868..55f7cb956f7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts @@ -7,9 +7,9 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; -import { applyStorageSourceFilter, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; +import { applyStorageSourceFilter, BUILTIN_STORAGE, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; -function item(path: string, storage: PromptsStorage): { uri: URI; storage: PromptsStorage } { +function item(path: string, storage: PromptsStorage | string): { uri: URI; storage: string } { return { uri: URI.file(path), storage }; } @@ -218,6 +218,36 @@ suite('applyStorageSourceFilter', () => { }; assert.strictEqual(applyStorageSourceFilter(items, filter).length, 4); }); + + test('core-like filter with builtin: extension items pass when both extension and builtin are in sources', () => { + // Items from the chat extension have storage=extension but groupKey=builtin. + // The filter operates on storage, so extension items pass through regardless of groupKey. + const items = [ + item('/w/a.md', PromptsStorage.local), + item('/e/builtin-agent.md', PromptsStorage.extension), + item('/e/third-party.md', PromptsStorage.extension), + item('/b/sessions-builtin.md', BUILTIN_STORAGE), + ]; + const filter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.extension, BUILTIN_STORAGE], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 4); + }); + + test('builtin source is respected independently', () => { + const items = [ + item('/e/from-extension.md', PromptsStorage.extension), + item('/b/from-sessions.md', BUILTIN_STORAGE), + ]; + // Only builtin in sources — extension items excluded + const filter: IStorageSourceFilter = { + sources: [BUILTIN_STORAGE], + }; + const result = applyStorageSourceFilter(items, filter); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].storage, BUILTIN_STORAGE); + }); }); suite('type safety', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/chatImageCarouselService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatImageCarouselService.test.ts index 4facfe453df..4f11f611c29 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatImageCarouselService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatImageCarouselService.test.ts @@ -8,8 +8,11 @@ import { VSBuffer } from '../../../../../base/common/buffer.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { URI } from '../../../../../base/common/uri.js'; import { buildCollectionArgs, buildSingleImageArgs, collectCarouselSections, findClickedImageIndex, ICarouselSection } from '../../browser/chatImageCarouselService.js'; +import { IChatToolInvocationSerialized } from '../../common/chatService/chatService.js'; +import { ChatResponseResource } from '../../common/model/chatModel.js'; import { IImageVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IChatRequestViewModel, IChatResponseViewModel } from '../../common/model/chatViewModel.js'; +import { ToolDataSource } from '../../common/tools/languageModelToolsService.js'; suite('ChatImageCarouselService helpers', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -35,12 +38,12 @@ suite('ChatImageCarouselService helpers', () => { } as unknown as IChatRequestViewModel; } - function makeResponse(requestId: string, id: string = 'resp-1'): IChatResponseViewModel { + function makeResponse(requestId: string, id: string = 'resp-1', responseValue: IChatResponseViewModel['response']['value'] = []): IChatResponseViewModel { return { id, requestId, sessionResource: URI.parse('chat-session://test/session'), - response: { value: [] }, + response: { value: responseValue }, session: { getItems: () => [] }, setVote: () => { }, } as unknown as IChatResponseViewModel; @@ -104,6 +107,28 @@ suite('ChatImageCarouselService helpers', () => { assert.strictEqual(findClickedImageIndex(sections, unknownUri, new Uint8Array([30, 40])), 1); }); + test('prefers a later exact URI match over an earlier image with identical data', () => { + const firstUri = URI.parse('vscode-chat-response-resource://session/tool-call-1/0/file.png'); + const secondUri = URI.parse('vscode-chat-response-resource://session/tool-call-2/0/file.png'); + const identicalData = new Uint8Array([10, 20, 30]); + const sections: ICarouselSection[] = [ + { + title: 'Earlier', + images: [ + { id: firstUri.toString(), name: 'first.png', mimeType: 'image/png', data: identicalData }, + ], + }, + { + title: 'Later', + images: [ + { id: secondUri.toString(), name: 'second.png', mimeType: 'image/png', data: identicalData }, + ], + }, + ]; + + assert.strictEqual(findClickedImageIndex(sections, secondUri, identicalData), 1); + }); + test('returns -1 for empty sections', () => { assert.strictEqual(findClickedImageIndex([], URI.file('/x.png')), -1); }); @@ -282,6 +307,58 @@ suite('ChatImageCarouselService helpers', () => { assert.strictEqual(result.length, 1); assert.strictEqual(result[0].images.length, 3); }); + + test('uses tool image URIs as carousel image ids', async () => { + const request = makeRequest('req-1', [], 'Request with tool output image'); + const toolCallId = 'tool-call-1'; + const sessionResource = URI.parse('chat-session://test/session'); + const expectedUri = ChatResponseResource.createUri(sessionResource, toolCallId, 0, 'file.png').toString(); + const response = makeResponse('req-1', 'resp-1', [ + { + kind: 'toolInvocationSerialized', + toolId: 'test_tool', + toolCallId, + invocationMessage: 'Took screenshot', + originMessage: undefined, + pastTenseMessage: undefined, + presentation: undefined, + resultDetails: { + output: { + type: 'data', + mimeType: 'image/png', + base64Data: 'AQID' + } + }, + isConfirmed: { type: 0 }, + isComplete: true, + source: ToolDataSource.Internal, + generatedTitle: undefined, + isAttachedToThinking: false, + } as unknown as IChatToolInvocationSerialized, + ]); + + const result = await collectCarouselSections([request, response], async () => new Uint8Array()); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].images.length, 1); + assert.strictEqual(result[0].images[0].id, expectedUri); + }); + + test('image data is a plain Uint8Array usable by Blob constructor', async () => { + const request = makeRequest('req-1', [ + makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }), + ], 'Screenshot request'); + const response = makeResponse('req-1'); + + const result = await collectCarouselSections([request, response], async () => new Uint8Array()); + + assert.strictEqual(result.length, 1); + const data = result[0].images[0].data; + // data must be a Uint8Array (not VSBuffer or ArrayBuffer) so that + // new Blob([data]) in the carousel editor works correctly. + assert.ok(data instanceof Uint8Array, 'image data should be Uint8Array'); + assert.deepStrictEqual([...data], [1, 2, 3]); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 46e8316eef2..307e6955d7a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { IAction } from '../../../../../../base/common/actions.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../base/common/observable.js'; @@ -123,9 +124,23 @@ class MockLanguageModelsService implements ILanguageModelsService { throw new Error('Method not implemented.'); } + getModelConfiguration(_modelId: string): IStringDictionary | undefined { + return undefined; + } + + async setModelConfiguration(_modelId: string, _values: IStringDictionary): Promise { + } + + getModelConfigurationActions(_modelId: string): IAction[] { + return []; + } + async configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise { } + async configureModel(_modelId: string): Promise { + } + async addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise { } 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 91dbe03786d..3e94219d5ae 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 @@ -445,5 +445,72 @@ suite('AgentPluginRepositoryService', () => { assert.strictEqual(deleted.length, 1); assert.ok(deleted[0].includes('github.com/owner/repo')); }); + + test('skips deletion when another installed plugin shares the same cleanup target', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource( + { + name: 'plugin-a', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/a' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }, + // Another plugin from the same repo still installed + [{ kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/b' }], + ); + + assert.strictEqual(deleted.length, 0); + }); + + test('proceeds with deletion when no other plugin shares the cleanup target', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource( + { + name: 'plugin-a', + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/a' }, + marketplace: 'owner/marketplace', + marketplaceReference: parseMarketplaceReference('owner/marketplace')!, + marketplaceType: MarketplaceType.Copilot, + }, + // Only unrelated plugins remain + [{ kind: PluginSourceKind.GitHub, repo: 'other-owner/other-repo' }], + ); + + assert.ok(deleted.length >= 1); + assert.ok(deleted[0].includes('github.com/owner/repo')); + }); + + test('proceeds with deletion when otherInstalledDescriptors is empty', async () => { + const deleted: string[] = []; + const service = createServiceWithDel(r => deleted.push(r.path)); + + await service.cleanupPluginSource( + { + name: 'plugin-a', + 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')); + }); }); }); 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 74617590be8..b16251caf33 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 @@ -2165,7 +2165,7 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].message, `The skill name 'different-name' should match the folder name 'my-skill'.`); }); - test('skill without name attribute does not error', async () => { + test('skill without name attribute should error', async () => { const content = [ '---', 'description: Test Skill', @@ -2173,10 +2173,12 @@ suite('PromptValidator', () => { 'This is a skill without a name.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); - assert.deepStrictEqual(markers, [], 'Expected no validation issues when name is missing'); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `Skill must provide a name.`); }); - test('skill with empty name does not validate folder match', async () => { + test('skill with empty name should error', async () => { const content = [ '---', 'name: ""', @@ -2185,9 +2187,49 @@ suite('PromptValidator', () => { 'This is a skill.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); - // Should get error for empty name, but no folder mismatch warning since name is empty - assert.ok(markers.some(m => m.message.includes('must not be empty')), 'Expected error for empty name'); - assert.ok(!markers.some(m => m.message.includes('should match the folder name')), 'Should not warn about folder mismatch for empty name'); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'name' attribute must not be empty.`); + }); + + test('skill without description attribute should error', async () => { + const content = [ + '---', + 'name: my-skill', + '---', + 'This is a skill without a description.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `Skill must provide a description.`); + }); + + test('skill with empty description should error', async () => { + const content = [ + '---', + 'name: my-skill', + 'description: ""', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'description' attribute should not be empty.`); + }); + + + test('skill name with invalid characters should error', async () => { + const content = [ + '---', + 'name: My Skill', + 'description: Test Skill', + '---', + 'This is a skill.' + ].join('\n'); + const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); + assert.ok(markers.some(m => m.severity === MarkerSeverity.Error && m.message === 'Skill name may only contain lowercase letters, numbers, and hyphens.')); }); test('skill name with whitespace trimmed matches folder name', async () => { @@ -2240,7 +2282,7 @@ suite('PromptValidator', () => { 'This is a skill.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my_special-skill.v2/SKILL.md')); - assert.deepStrictEqual(markers, [], 'Expected no issues when name with special chars matches folder'); + assert.ok(markers.some(m => m.severity === MarkerSeverity.Error && m.message === 'Skill name may only contain lowercase letters, numbers, and hyphens.'), 'Expected error for invalid characters in skill name'); }); test('skill with non-string name type does not validate folder match', async () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsConfirmationService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsConfirmationService.test.ts index 653cc47feb7..94e98ecbadc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsConfirmationService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsConfirmationService.test.ts @@ -6,10 +6,10 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { IStorageService, InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { LanguageModelToolsConfirmationService } from '../../../browser/tools/languageModelToolsConfirmationService.js'; import { ToolConfirmKind } from '../../../common/chatService/chatService.js'; -import { ILanguageModelToolConfirmationActions, ILanguageModelToolConfirmationContribution, ILanguageModelToolConfirmationRef } from '../../../common/tools/languageModelToolsConfirmationService.js'; +import { computeCombinationKey, ILanguageModelToolConfirmationActions, ILanguageModelToolConfirmationContribution, ILanguageModelToolConfirmationRef } from '../../../common/tools/languageModelToolsConfirmationService.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; suite('LanguageModelToolsConfirmationService', () => { @@ -44,6 +44,16 @@ suite('LanguageModelToolsConfirmationService', () => { }; } + async function createCombinationRef(toolId: string, parameters: unknown, combinationLabel: string): Promise { + return { + ...createToolRef(toolId, ToolDataSource.Internal, parameters), + combination: { + label: combinationLabel, + key: await computeCombinationKey(toolId, parameters), + }, + }; + } + test('getPreConfirmAction returns undefined by default', () => { const ref = createToolRef('testTool'); const result = service.getPreConfirmAction(ref); @@ -525,4 +535,134 @@ suite('LanguageModelToolsConfirmationService', () => { const newResult = newService.getPreConfirmAction(ref); assert.strictEqual(newResult, undefined); }); + + test('combination actions are only offered when combinationLabel is set', async () => { + const refWithout = createToolRef('testTool', ToolDataSource.Internal, { file: 'foo.txt' }); + const actionsWithout = service.getPreConfirmActions(refWithout); + assert.ok(!actionsWithout.some(a => a.label.includes('foo.txt'))); + + const refWith = await createCombinationRef('testTool', { file: 'foo.txt' }, 'Allow reading "foo.txt"'); + const actionsWith = service.getPreConfirmActions(refWith); + assert.ok(actionsWith.some(a => a.label.includes('Allow reading "foo.txt"'))); + }); + + test('combination actions include session, workspace, and profile scopes', async () => { + const ref = await createCombinationRef('testTool', { file: 'foo.txt' }, 'Allow reading "foo.txt"'); + const actions = service.getPreConfirmActions(ref); + const combinationActions = actions.filter(a => a.label.includes('Allow reading "foo.txt"')); + assert.strictEqual(combinationActions.length, 3); + assert.ok(combinationActions.some(a => a.scope === 'session')); + assert.ok(combinationActions.some(a => a.scope === 'workspace')); + assert.ok(combinationActions.some(a => a.scope === 'profile')); + }); + + test('selecting a combination session action auto-confirms the same parameters', async () => { + const ref = await createCombinationRef('testTool', { file: 'foo.txt' }, 'Allow reading "foo.txt"'); + + assert.strictEqual(service.getPreConfirmAction(ref), undefined); + + const actions = service.getPreConfirmActions(ref); + const combinationAction = actions.find(a => a.label.includes('Allow reading "foo.txt"') && a.scope === 'session'); + assert.ok(combinationAction); + await combinationAction.select(); + + const result = service.getPreConfirmAction(ref); + assert.deepStrictEqual(result, { type: ToolConfirmKind.LmServicePerTool, scope: 'session' }); + }); + + test('selecting a combination workspace action stores at workspace scope', async () => { + const ref = await createCombinationRef('testTool', { file: 'foo.txt' }, 'Allow reading "foo.txt"'); + + const actions = service.getPreConfirmActions(ref); + const combinationAction = actions.find(a => a.label.includes('Allow reading "foo.txt"') && a.scope === 'workspace'); + assert.ok(combinationAction); + await combinationAction.select(); + + assert.deepStrictEqual(service.getPreConfirmAction(ref), { type: ToolConfirmKind.LmServicePerTool, scope: 'workspace' }); + }); + + test('combination approval does not apply to different parameters', async () => { + const refFoo = await createCombinationRef('testTool', { file: 'foo.txt' }, 'Allow reading "foo.txt"'); + const refBar = await createCombinationRef('testTool', { file: 'bar.txt' }, 'Allow reading "bar.txt"'); + + const actions = service.getPreConfirmActions(refFoo); + const combinationAction = actions.find(a => a.label.includes('Allow reading "foo.txt"') && a.scope === 'session'); + assert.ok(combinationAction); + await combinationAction.select(); + + assert.ok(service.getPreConfirmAction(refFoo)); + assert.strictEqual(service.getPreConfirmAction(refBar), undefined); + }); + + test('tool-level approval takes precedence over combination approval', async () => { + const ref = await createCombinationRef('testTool', { file: 'foo.txt' }, 'Allow reading "foo.txt"'); + + const actions = service.getPreConfirmActions(ref); + const toolSessionAction = actions.find(a => a.label.includes('Session') + && !a.label.includes('foo.txt') && !a.label.includes('Server')); + assert.ok(toolSessionAction); + await toolSessionAction.select(); + + const result = service.getPreConfirmAction(ref); + assert.deepStrictEqual(result, { type: ToolConfirmKind.LmServicePerTool, scope: 'session' }); + }); + + test('combination approvals are cleared on reset', async () => { + const ref = await createCombinationRef('testTool', { file: 'foo.txt' }, 'Allow reading "foo.txt"'); + + const actions = service.getPreConfirmActions(ref); + const combinationAction = actions.find(a => a.label.includes('Allow reading "foo.txt"') && a.scope === 'session'); + assert.ok(combinationAction); + await combinationAction.select(); + assert.ok(service.getPreConfirmAction(ref)); + + service.resetToolAutoConfirmation(); + assert.strictEqual(service.getPreConfirmAction(ref), undefined); + }); + + test('combination session approvals do not persist across service instances', async () => { + const ref = await createCombinationRef('testTool', { file: 'foo.txt' }, 'Allow reading "foo.txt"'); + + const actions = service.getPreConfirmActions(ref); + const combinationAction = actions.find(a => a.label.includes('Allow reading "foo.txt"') && a.scope === 'session'); + assert.ok(combinationAction); + await combinationAction.select(); + assert.ok(service.getPreConfirmAction(ref)); + + const newService = store.add(instantiationService.createInstance(LanguageModelToolsConfirmationService)); + assert.strictEqual(newService.getPreConfirmAction(ref), undefined); + }); + + test('legacy string[] storage format is read correctly', () => { + // Pre-seed storage with the legacy string[] format + const storageService = instantiationService.get(IStorageService); + storageService.store('chat/autoconfirm', JSON.stringify(['tool1', 'tool2']), StorageScope.WORKSPACE, StorageTarget.MACHINE); + + // Create a new service instance that reads the legacy data + const newService = store.add(instantiationService.createInstance(LanguageModelToolsConfirmationService)); + + const ref1 = createToolRef('tool1'); + const ref2 = createToolRef('tool2'); + const ref3 = createToolRef('tool3'); + + assert.deepStrictEqual(newService.getPreConfirmAction(ref1), { type: ToolConfirmKind.LmServicePerTool, scope: 'workspace' }); + assert.deepStrictEqual(newService.getPreConfirmAction(ref2), { type: ToolConfirmKind.LmServicePerTool, scope: 'workspace' }); + assert.strictEqual(newService.getPreConfirmAction(ref3), undefined); + }); + + test('new Record storage format preserves labels', () => { + // Pre-seed storage with the new Record format + const storageService = instantiationService.get(IStorageService); + const data: Record = { + 'tool1:combination:12345': 'Allow reading foo.txt', + 'tool2': true, + }; + storageService.store('chat/autoconfirm', JSON.stringify(data), StorageScope.WORKSPACE, StorageTarget.MACHINE); + + const newService = store.add(instantiationService.createInstance(LanguageModelToolsConfirmationService)); + + // tool2 should be auto-confirmed (boolean true, no label) + const ref2 = createToolRef('tool2'); + assert.deepStrictEqual(newService.getPreConfirmAction(ref2), { type: ToolConfirmKind.LmServicePerTool, scope: 'workspace' }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts index 42226127e20..fabf66ac855 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/renameTool.test.ts @@ -12,10 +12,11 @@ import { RenameProvider, WorkspaceEdit, Rejection } from '../../../../../../edit import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js'; import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IBulkEditService, IBulkEditResult } from '../../../../../../editor/browser/services/bulkEditService.js'; -import { RenameTool, RenameToolId } from '../../../browser/tools/renameTool.js'; +import { RenameTool } from '../../../browser/tools/renameTool.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -100,9 +101,14 @@ suite('RenameTool', () => { const noopCountTokens = async () => 0; const noopProgress: ToolProgress = { report() { } }; + function createMockLanguageService(): ILanguageService { + return { getLanguageName: (id: string) => id } as unknown as ILanguageService; + } + function createTool(textModelService: ITextModelService, options?: { bulkEditService?: IBulkEditService }): RenameTool { return new RenameTool( langFeatures, + createMockLanguageService(), textModelService, createMockWorkspaceService(), createMockChatService(), @@ -124,9 +130,7 @@ suite('RenameTool', () => { test('reports no providers when none registered', () => { const tool = disposables.add(createTool(createMockTextModelService(null!))); - const data = tool.getToolData(); - assert.strictEqual(data.id, RenameToolId); - assert.ok(data.modelDescription.includes('No languages currently have rename providers')); + assert.strictEqual(tool.getToolData(), undefined); }); test('lists registered language ids', () => { @@ -136,7 +140,7 @@ suite('RenameTool', () => { provideRenameEdits: () => ({ edits: [] }), })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('typescript')); + assert.ok(data?.modelDescription.includes('typescript')); }); test('reports all languages for wildcard', () => { @@ -145,7 +149,7 @@ suite('RenameTool', () => { provideRenameEdits: () => ({ edits: [] }), })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('all languages')); + assert.ok(data?.modelDescription.includes('all languages')); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts index e0e20ec03f6..28c14f3b1ad 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts @@ -13,10 +13,11 @@ import { ITextModel } from '../../../../../../editor/common/model.js'; import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js'; import { IModelService } from '../../../../../../editor/common/services/model.js'; import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; +import { ILanguageService } from '../../../../../../editor/common/languages/language.js'; import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { FileMatch, ISearchComplete, ISearchService, ITextQuery, OneLineRange, TextSearchMatch } from '../../../../../services/search/common/search.js'; -import { UsagesTool, UsagesToolId } from '../../../browser/tools/usagesTool.js'; +import { UsagesTool } from '../../../browser/tools/usagesTool.js'; import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; @@ -91,8 +92,12 @@ suite('UsagesTool', () => { const noopCountTokens = async () => 0; const noopProgress: ToolProgress = { report() { } }; + function createMockLanguageService(): ILanguageService { + return { getLanguageName: (id: string) => id } as unknown as ILanguageService; + } + function createTool(textModelService: ITextModelService, workspaceService: IWorkspaceContextService, options?: { modelService?: IModelService; searchService?: ISearchService }): UsagesTool { - return new UsagesTool(langFeatures, options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService); + return new UsagesTool(langFeatures, createMockLanguageService(), options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService); } setup(() => { @@ -109,9 +114,7 @@ suite('UsagesTool', () => { test('reports no providers when none registered', () => { const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); - const data = tool.getToolData(); - assert.strictEqual(data.id, UsagesToolId); - assert.ok(data.modelDescription.includes('No languages currently have reference providers')); + assert.strictEqual(tool.getToolData(), undefined); }); test('lists registered language ids', () => { @@ -119,14 +122,14 @@ suite('UsagesTool', () => { const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('typescript')); + assert.ok(data?.modelDescription.includes('typescript')); }); test('reports all languages for wildcard', () => { const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); disposables.add(langFeatures.referenceProvider.register('*', { provideReferences: () => [] })); const data = tool.getToolData(); - assert.ok(data.modelDescription.includes('all languages')); + assert.ok(data?.modelDescription.includes('all languages')); }); }); 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 7bc6a6c0412..6362d8764f2 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 @@ -149,6 +149,82 @@ suite('ChatQuestionCarouselPart', () => { 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'); }); + + test('renders collapse button in title row even when skip is disabled', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Question 1' } + ], false); + createWidget(carousel); + + const titleRow = widget.domNode.querySelector('.chat-question-title-row'); + assert.ok(titleRow, 'title row should exist'); + + const collapseButton = titleRow?.querySelector('.chat-question-collapse-toggle'); + assert.ok(collapseButton, 'collapse button should be rendered even when skip is disabled'); + }); + + test('renders collapse button to the right of close button', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + createWidget(carousel); + + const actionsContainer = widget.domNode.querySelector('.chat-question-header-actions'); + assert.ok(actionsContainer, 'actions container should exist'); + if (!actionsContainer) { + return; + } + + const actionButtons = Array.from(actionsContainer.querySelectorAll('.monaco-button')); + const closeIndex = actionButtons.findIndex(button => button.classList.contains('chat-question-close')); + const collapseIndex = actionButtons.findIndex(button => button.classList.contains('chat-question-collapse-toggle')); + + assert.ok(closeIndex >= 0, 'close button should exist'); + assert.ok(collapseIndex >= 0, 'collapse button should exist'); + assert.ok(collapseIndex > closeIndex, 'collapse button should be positioned to the right of close button'); + }); + + test('toggles collapsed state and updates aria-expanded', () => { + const carousel = createMockCarousel([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + createWidget(carousel); + + const collapseButton = widget.domNode.querySelector('.chat-question-collapse-toggle') as HTMLElement; + assert.ok(collapseButton, 'collapse button should exist'); + assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'true'); + + collapseButton.click(); + assert.ok(widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should enter collapsed state'); + assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'false'); + const collapsedSummary = widget.domNode.querySelector('.chat-question-collapsed-summary'); + assert.strictEqual(collapsedSummary, null, 'collapsed mode should not render an additional summary section'); + + const titleRow = widget.domNode.querySelector('.chat-question-title-row'); + assert.ok(titleRow, 'header should remain visible when collapsed'); + + const inputScrollable = widget.domNode.querySelector('.chat-question-input-scrollable'); + assert.ok(inputScrollable, 'input section exists in DOM but is hidden while collapsed'); + + collapseButton.click(); + assert.ok(!widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should exit collapsed state'); + assert.strictEqual(collapseButton.getAttribute('aria-expanded'), 'true'); + }); + + test('restores draft collapsed state from carousel data', () => { + const carousel = new ChatQuestionCarouselData([ + { id: 'q1', type: 'text', title: 'Question 1' }, + { id: 'q2', type: 'text', title: 'Question 2' } + ], true); + carousel.draftCollapsed = true; + createWidget(carousel); + + assert.ok(widget.domNode.classList.contains('chat-question-carousel-collapsed'), 'widget should restore collapsed draft state'); + const collapseButton = widget.domNode.querySelector('.chat-question-collapse-toggle'); + assert.strictEqual(collapseButton?.getAttribute('aria-expanded'), 'false'); + }); }); suite('Question Types', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index f11478cebc5..4d58c6c4339 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -14,7 +14,7 @@ import { workbenchInstantiationService } from '../../../../../../test/browser/wo import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ChatThinkingContentPart } from '../../../../browser/widget/chatContentParts/chatThinkingContentPart.js'; -import { IChatMarkdownContent, IChatThinkingPart } from '../../../../common/chatService/chatService.js'; +import { IChatMarkdownContent, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; import { IChatContentPartRenderContext, InlineTextModelCollection } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { IChatRendererContent, IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; import { IChatMarkdownAnchorService } from '../../../../browser/widget/chatContentParts/chatMarkdownAnchorService.js'; @@ -26,6 +26,7 @@ import { CodeBlockModelCollection } from '../../../../common/widget/codeBlockMod import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { ILanguageModelsService } from '../../../../common/languageModels.js'; +import { ToolDataSource } from '../../../../common/tools/languageModelToolsService.js'; import { URI } from '../../../../../../../base/common/uri.js'; suite('ChatThinkingContentPart', () => { @@ -1193,5 +1194,238 @@ suite('ChatThinkingContentPart', () => { const circleIcon = part.domNode.querySelector('.codicon-circle-filled'); assert.ok(circleIcon, 'Should have circle-filled icon while streaming'); }); + + function createMockStreamingToolInvocation(toolId: string, invocationMessage: string, toolCallId: string): IChatToolInvocation { + return { + kind: 'toolInvocation', + toolId, + toolCallId, + invocationMessage, + originMessage: undefined, + pastTenseMessage: undefined, + presentation: undefined, + source: ToolDataSource.Internal, + isAttachedToThinking: false, + generatedTitle: undefined, + state: observableValue('state', { + type: IChatToolInvocation.StateKind.Streaming, + partialInput: observableValue('partialInput', undefined), + streamingMessage: observableValue('streamingMessage', undefined), + }), + toJSON: () => ({} as IChatToolInvocationSerialized), + } as IChatToolInvocation; + } + + function createMockExecutingToolInvocation(toolId: string, invocationMessage: string, toolCallId: string): IChatToolInvocation { + return { + kind: 'toolInvocation', + toolId, + toolCallId, + invocationMessage, + originMessage: undefined, + pastTenseMessage: undefined, + presentation: undefined, + source: ToolDataSource.Internal, + isAttachedToThinking: false, + generatedTitle: undefined, + state: observableValue('state', { + type: IChatToolInvocation.StateKind.Executing, + confirmed: { type: 0 }, + progress: observableValue('progress', { progress: 0 }), + parameters: {}, + confirmationMessages: undefined, + }), + toJSON: () => ({} as IChatToolInvocationSerialized), + } as IChatToolInvocation; + } + + function createMockSerializedImageToolInvocation(toolId: string, invocationMessage: string, toolCallId: string): IChatToolInvocationSerialized { + return { + kind: 'toolInvocationSerialized', + toolId, + toolCallId, + invocationMessage, + originMessage: undefined, + pastTenseMessage: undefined, + presentation: undefined, + resultDetails: { + output: { + type: 'data', + mimeType: 'image/png', + base64Data: 'AQID' + } + }, + isConfirmed: { type: 0 }, + isComplete: true, + source: ToolDataSource.Internal, + generatedTitle: undefined, + isAttachedToThinking: false, + }; + } + + test('should show "Editing files" for streaming edit tools instead of generic display name', () => { + const content = createThinkingPart('**Working**'); + const context = createMockRenderContext(false); + + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + const streamingReplaceTool = createMockStreamingToolInvocation( + 'copilot_replaceString', 'Replace String in File', 'call-1' + ); + + part.appendItem(() => { + const div = $('div.test-item'); + div.textContent = 'Replace tool'; + return { domNode: div }; + }, streamingReplaceTool.toolId, streamingReplaceTool); + + // The title should show "Editing files" instead of "Replace String in File" + const button = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.ok(button, 'Should have collapse button'); + const labelText = button.querySelector('.icon-label')?.textContent ?? button.textContent ?? ''; + assert.ok(labelText.includes('Editing files'), `Title should contain "Editing files" but got "${labelText}"`); + }); + + test('should show original message for non-edit streaming tools', () => { + const content = createThinkingPart('**Working**'); + const context = createMockRenderContext(false); + + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + const streamingReadTool = createMockStreamingToolInvocation( + 'copilot_readFile', 'Reading file.ts', 'call-2' + ); + + part.appendItem(() => { + const div = $('div.test-item'); + div.textContent = 'Read tool'; + return { domNode: div }; + }, streamingReadTool.toolId, streamingReadTool); + + const button = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.ok(button, 'Should have collapse button'); + const labelText = button.querySelector('.icon-label')?.textContent ?? button.textContent ?? ''; + assert.ok(labelText.includes('Reading file.ts'), `Title should contain "Reading file.ts" but got "${labelText}"`); + }); + + test('should show original message for non-streaming edit tools', () => { + const content = createThinkingPart('**Working**'); + const context = createMockRenderContext(false); + + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + // Non-streaming (executing) edit tool should show its invocation message + const executingReplaceTool = createMockExecutingToolInvocation( + 'copilot_replaceString', 'Replacing 5 lines in file.ts', 'call-3' + ); + + part.appendItem(() => { + const div = $('div.test-item'); + div.textContent = 'Replace tool'; + return { domNode: div }; + }, executingReplaceTool.toolId, executingReplaceTool); + + const button = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.ok(button, 'Should have collapse button'); + const labelText = button.querySelector('.icon-label')?.textContent ?? button.textContent ?? ''; + assert.ok(labelText.includes('Replacing 5 lines in file.ts'), `Title should contain "Replacing 5 lines in file.ts" but got "${labelText}"`); + }); + + test('should keep original message for create_file tool even when streaming', () => { + const content = createThinkingPart('**Working**'); + const context = createMockRenderContext(false); + + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + const streamingCreateTool = createMockStreamingToolInvocation( + 'copilot_createFile', 'Creating newFile.ts', 'call-4' + ); + + part.appendItem(() => { + const div = $('div.test-item'); + div.textContent = 'Create tool'; + return { domNode: div }; + }, streamingCreateTool.toolId, streamingCreateTool); + + const button = part.domNode.querySelector('.chat-used-context-label .monaco-button'); + assert.ok(button, 'Should have collapse button'); + const labelText = button.querySelector('.icon-label')?.textContent ?? button.textContent ?? ''; + assert.ok(labelText.includes('Creating newFile.ts'), `Title should contain "Creating newFile.ts" but got "${labelText}"`); + }); + + test('should show external resources for serialized image tools when initially collapsed and hide them when expanded', () => { + const content = createThinkingPart('**Working**'); + const context = createMockRenderContext(false); + + const part = store.add(instantiationService.createInstance( + ChatThinkingContentPart, + content, + context, + mockMarkdownRenderer, + false + )); + + mainWindow.document.body.appendChild(part.domNode); + disposables.add(toDisposable(() => part.domNode.remove())); + + const serializedImageTool = createMockSerializedImageToolInvocation( + 'chat_screenshot', 'Captured screenshot', 'image-call-1' + ); + + part.appendItem(() => { + const div = $('div.test-item'); + div.textContent = 'Image tool'; + return { domNode: div }; + }, serializedImageTool.toolId, serializedImageTool); + + const externalResources = part.domNode.querySelector('.chat-thinking-external-resources') as HTMLElement; + assert.ok(externalResources, 'Should render external resources container'); + assert.notStrictEqual(externalResources.style.display, 'none', 'Should show external resources while initially collapsed'); + + const button = part.domNode.querySelector('.monaco-button') as HTMLElement; + assert.ok(button, 'Should have expand button'); + button.click(); + + assert.strictEqual(externalResources.style.display, 'none', 'Should hide external resources when expanded'); + + button.click(); + assert.notStrictEqual(externalResources.style.display, 'none', 'Should show external resources again after collapsing'); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts index 3125d372e86..535b44dcd18 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatToolProgressPart.test.ts @@ -182,7 +182,7 @@ suite('ChatToolProgressSubPart', () => { }); test('adds shimmer styling for active MCP tool progress', () => { - const mcpTool = createSerializedToolInvocation({ + const mcpTool = createToolInvocation({ source: { type: 'mcp', label: 'Weather MCP', @@ -221,4 +221,28 @@ suite('ChatToolProgressSubPart', () => { assert.strictEqual(part.domNode.querySelector('.shimmer-progress'), null); }); + + test('does not add shimmer styling for completed MCP tool progress', () => { + const mcpTool = createSerializedToolInvocation({ + source: { + type: 'mcp', + label: 'Weather MCP', + serverLabel: 'Weather', + instructions: undefined, + collectionId: 'collection', + definitionId: 'definition' + }, + toolId: 'weather_lookup' + }); + + const part = disposables.add(instantiationService.createInstance( + ChatToolProgressSubPart, + mcpTool, + createRenderContext(false), + mockMarkdownRenderer, + new Set() + )); + + assert.strictEqual(part.domNode.querySelector('.shimmer-progress'), null); + }); }); 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 a4673e5d7a2..d98c5e41fe5 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 @@ -12,7 +12,7 @@ import { ActionListItemKind, IActionListItem } from '../../../../../../../platfo import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.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'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelControlEntry } from '../../../../common/languageModels.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; function createStubEntitlementService(opts?: { entitlement?: ChatEntitlement; isInternal?: boolean; anonymous?: boolean }): IChatEntitlementService { @@ -69,6 +69,8 @@ const stubManageModelsAction: IActionWidgetDropdownAction = { run: () => { } }; +const stubLanguageModelsService = { getModelConfigurationActions: () => [], getModelConfiguration: () => undefined } as unknown as ILanguageModelsService; + function callBuild( models: ILanguageModelChatMetadataAndIdentifier[], opts: { @@ -103,6 +105,8 @@ function callBuild( entitlementService, opts.showUnavailableFeatured ?? true, opts.showFeatured ?? true, + undefined, + stubLanguageModelsService, ); } @@ -480,6 +484,8 @@ suite('buildModelPickerItems', () => { stubChatEntitlementService, true, true, + undefined, + stubLanguageModelsService, ); const gptItem = getActionItems(items).find(a => a.label === 'GPT-4o'); assert.ok(gptItem?.item); @@ -565,6 +571,8 @@ suite('buildModelPickerItems', () => { businessEntitlementService, true, true, + undefined, + stubLanguageModelsService, ); const adminItem = getActionItems(items).find(a => a.label === 'Missing Model'); @@ -650,6 +658,8 @@ suite('buildModelPickerItems', () => { anonymousEntitlementService, true, true, + undefined, + stubLanguageModelsService, ); const gptItem = getActionItems(items).find(a => a.label === 'GPT-4o'); assert.ok(gptItem?.item); 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 386c7a916cf..62e41c7fcb8 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatDebugServiceImpl.test.ts @@ -23,6 +23,7 @@ suite('ChatDebugServiceImpl', () => { const sessionGeneric = URI.parse('vscode-chat-session://local/session'); const nonLocalSession = URI.parse('some-other-scheme://authority/session-1'); const copilotCliSession = URI.parse('copilotcli:/test-session-id'); + const claudeCodeSession = URI.parse('claude-code:/test-session-id'); setup(() => { service = disposables.add(new ChatDebugServiceImpl()); @@ -168,6 +169,16 @@ suite('ChatDebugServiceImpl', () => { assert.strictEqual(firedEvents.length, 1); assert.strictEqual(service.getEvents(copilotCliSession).length, 1); }); + + test('should log events for claude-code sessions', () => { + const firedEvents: IChatDebugEvent[] = []; + disposables.add(service.onDidAddEvent(e => firedEvents.push(e))); + + service.log(claudeCodeSession, 'claude-event', 'details'); + + assert.strictEqual(firedEvents.length, 1); + assert.strictEqual(service.getEvents(claudeCodeSession).length, 1); + }); }); suite('getSessionResources', () => { @@ -492,6 +503,29 @@ suite('ChatDebugServiceImpl', () => { assert.ok(service.getEvents(copilotCliSession).length > 0); }); + test('should invoke providers for claude-code sessions', async () => { + let providerCalled = false; + + const provider: IChatDebugLogProvider = { + provideChatDebugLog: async () => { + providerCalled = true; + return [{ + kind: 'generic', + sessionResource: claudeCodeSession, + created: new Date(), + name: 'claude-provider-event', + level: ChatDebugLogLevel.Info, + }]; + }, + }; + + disposables.add(service.registerProvider(provider)); + await service.invokeProviders(claudeCodeSession); + + assert.strictEqual(providerCalled, true); + assert.ok(service.getEvents(claudeCodeSession).length > 0); + }); + test('newly registered provider should be invoked for active sessions', async () => { // Start an invocation before the provider is registered const firstProvider: IChatDebugLogProvider = { 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 70fa3572eb8..c4dacb71de5 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 @@ -55,7 +55,7 @@ import { MockChatVariablesService } from '../mockChatVariables.js'; import { MockPromptsService } from '../promptSyntax/service/mockPromptsService.js'; import { MockLanguageModelToolsService } from '../tools/mockLanguageModelToolsService.js'; import { MockChatService } from './mockChatService.js'; -import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { ChatSessionOptionsMap, IChatSessionsService } from '../../../common/chatSessionsService.js'; import { MockChatSessionsService } from '../mockChatSessionsService.js'; const chatAgentWithUsedContextId = 'ChatProviderWithUsedContext'; @@ -865,6 +865,19 @@ suite('ChatService', () => { assert.ok(lastThree[2].includes('queued-3')); }); + test('acquireOrLoadSession returns undefined when remote provider is not registered (fix for #301203)', async () => { + const unregisteredScheme = 'unregistered-provider'; + const sessionResource = URI.from({ scheme: unregisteredScheme, path: '/orphaned-session' }); + + // Use a mock sessions service with NO content provider registered for the scheme + const mockSessionsService = new MockChatSessionsService(); + instantiationService.stub(IChatSessionsService, mockSessionsService); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.strictEqual(ref, undefined, 'Should return undefined when no provider is registered'); + }); + test('sendRequest on untitled remote session propagates initialSessionOptions to new model', async () => { const remoteScheme = 'remoteProvider'; const untitledResource = URI.from({ scheme: remoteScheme, path: '/untitled-test-session' }); @@ -924,7 +937,7 @@ suite('ChatService', () => { assert.ok(newModel, 'New model should exist at the real resource'); assert.ok(newModel.contributedChatSession, 'New model should have contributedChatSession'); assert.deepStrictEqual( - newModel.contributedChatSession?.initialSessionOptions?.map(o => ({ optionId: o.optionId, value: o.value })), + ChatSessionOptionsMap.toStrValueArray(newModel.contributedChatSession?.initialSessionOptions), [ { optionId: 'model', value: 'claude-3.5-sonnet' }, { optionId: 'repo', value: 'my-repo' }, diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts index 5790fab95dd..6908d48f9d4 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.test.ts @@ -993,3 +993,153 @@ suite('LanguageModels - Vendor Change Events', function () { assert.strictEqual(eventFired, false, 'Should not fire event when vendor list is unchanged'); }); }); + +suite('LanguageModels - Per-Model Configuration', function () { + + let languageModelsService: LanguageModelsService; + const disposables = new DisposableStore(); + let receivedOptions: { [name: string]: unknown } | undefined; + + setup(async function () { + receivedOptions = undefined; + + languageModelsService = new LanguageModelsService( + new class extends mock() { + override activateByEvent() { + return Promise.resolve(); + } + }, + new NullLogService(), + new TestStorageService(), + new MockContextKeyService(), + new class extends mock() { + override onDidChangeLanguageModelGroups = Event.None; + override getLanguageModelsProviderGroups() { + return [{ + vendor: 'config-vendor', + name: 'default', + settings: { + 'model-a': { temperature: 0.7, reasoningEffort: 'high' }, + 'model-b': { temperature: 0.2 } + } + }]; + } + }, + new class extends mock() { }, + new TestSecretStorageService(), + new class extends mock() { override readonly version = '1.100.0'; }, + new class extends mock() { }, + ); + + languageModelsService.deltaLanguageModelChatProviderDescriptors([ + { vendor: 'config-vendor', displayName: 'Config Vendor', configuration: undefined, managementCommand: undefined, when: undefined } + ], []); + + disposables.add(languageModelsService.registerLanguageModelProvider('config-vendor', { + onDidChange: Event.None, + provideLanguageModelChatInfo: async (options) => { + if (options.group) { + return [{ + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model A', + vendor: 'config-vendor', + family: 'family-a', + version: '1.0', + id: 'model-a', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {}, + configurationSchema: { + type: 'object', + properties: { + temperature: { type: 'number', default: 0.5 }, + reasoningEffort: { type: 'string', default: 'medium' }, + maxTokens: { type: 'number', default: 4096 } + } + } + } satisfies ILanguageModelChatMetadata, + identifier: 'config-vendor/default/model-a' + }, { + metadata: { + extension: nullExtensionDescription.identifier, + name: 'Model B', + vendor: 'config-vendor', + family: 'family-b', + version: '1.0', + id: 'model-b', + maxInputTokens: 100, + maxOutputTokens: 100, + modelPickerCategory: DEFAULT_MODEL_PICKER_CATEGORY, + isDefaultForLocation: {} + } satisfies ILanguageModelChatMetadata, + identifier: 'config-vendor/default/model-b' + }]; + } + return []; + }, + sendChatRequest: async (_modelId, _messages, _from, options) => { + receivedOptions = options; + const defer = new DeferredPromise(); + const stream = new AsyncIterableSource(); + stream.resolve(); + defer.complete(undefined); + return { stream: stream.asyncIterable, result: defer.p }; + }, + provideTokenCount: async () => { throw new Error(); } + })); + + await languageModelsService.selectLanguageModels({}); + }); + + teardown(function () { + languageModelsService.dispose(); + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('getModelConfiguration returns per-model config from group', function () { + const configA = languageModelsService.getModelConfiguration('config-vendor/default/model-a'); + assert.deepStrictEqual(configA, { temperature: 0.7, reasoningEffort: 'high', maxTokens: 4096 }); + + const configB = languageModelsService.getModelConfiguration('config-vendor/default/model-b'); + assert.deepStrictEqual(configB, { temperature: 0.2 }); + }); + + test('getModelConfiguration returns undefined for unknown model', function () { + const config = languageModelsService.getModelConfiguration('config-vendor/default/model-c'); + assert.strictEqual(config, undefined); + }); + + test('sendChatRequest merges schema defaults with user config', async function () { + const cts = disposables.add(new CancellationTokenSource()); + const request = await languageModelsService.sendChatRequest( + 'config-vendor/default/model-a', + nullExtensionDescription.identifier, + [{ role: ChatMessageRole.User, content: [{ type: 'text', value: 'hello' }] }], + {}, + cts.token + ); + await request.result; + + // User config overrides defaults: temperature=0.7 (not 0.5), reasoningEffort='high' (not 'medium') + // Schema default maxTokens=4096 is included since user didn't override it + assert.deepStrictEqual(receivedOptions, { configuration: { temperature: 0.7, reasoningEffort: 'high', maxTokens: 4096 } }); + }); + + test('sendChatRequest passes user config when model has no schema', async function () { + const cts = disposables.add(new CancellationTokenSource()); + const request = await languageModelsService.sendChatRequest( + 'config-vendor/default/model-b', + nullExtensionDescription.identifier, + [{ role: ChatMessageRole.User, content: [{ type: 'text', value: 'hello' }] }], + {}, + cts.token + ); + await request.result; + + assert.deepStrictEqual(receivedOptions, { configuration: { temperature: 0.2 } }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/languageModels.ts b/src/vs/workbench/contrib/chat/test/common/languageModels.ts index 8ead7eab0a9..51ac576ae1a 100644 --- a/src/vs/workbench/contrib/chat/test/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/test/common/languageModels.ts @@ -8,8 +8,9 @@ import { IStringDictionary } from '../../../../../base/common/collections.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../base/common/observable.js'; +import { IAction } from '../../../../../base/common/actions.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; -import { IChatMessage, IModelsControlManifest, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelProviderDescriptor, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; +import { IChatMessage, IModelsControlManifest, ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelChatRequestOptions, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelProviderDescriptor, ILanguageModelsGroup, ILanguageModelsService, IUserFriendlyLanguageModel } from '../../common/languageModels.js'; import { ILanguageModelsProviderGroup } from '../../common/languageModelsConfiguration.js'; export class NullLanguageModelsService implements ILanguageModelsService { @@ -66,8 +67,7 @@ export class NullLanguageModelsService implements ILanguageModelsService { return []; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendChatRequest(identifier: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: { [name: string]: any }, token: CancellationToken): Promise { + sendChatRequest(identifier: string, from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: ILanguageModelChatRequestOptions, token: CancellationToken): Promise { throw new Error('Method not implemented.'); } @@ -75,10 +75,24 @@ export class NullLanguageModelsService implements ILanguageModelsService { throw new Error('Method not implemented.'); } + getModelConfiguration(_modelId: string): IStringDictionary | undefined { + return undefined; + } + + async setModelConfiguration(_modelId: string, _values: IStringDictionary): Promise { + } + + getModelConfigurationActions(_modelId: string): IAction[] { + return []; + } + async configureLanguageModelsProviderGroup(vendorId: string, name?: string): Promise { } + async configureModel(_modelId: string): Promise { + } + async addLanguageModelsProviderGroup(name: string, vendorId: string, configuration: IStringDictionary | undefined): Promise { } diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 68e73f1b7d5..f49d4fbbbd2 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { AsyncEmitter, Emitter } from '../../../../../base/common/event.js'; +import { Emitter } from '../../../../../base/common/event.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; +import { ReadonlyChatSessionOptionsMap, IChatNewSessionRequest, IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta, IChatSessionOptionsChangeEvent, IChatSessionProviderOptionGroup, IChatSessionRequestHistoryItem, IChatSessionsExtensionPoint, IChatSessionsService, ResolvedChatSessionsExtensionPoint, ChatSessionOptionsMap } from '../../common/chatSessionsService.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; import { Target } from '../../common/promptSyntax/promptTypes.js'; @@ -17,8 +17,9 @@ import { Target } from '../../common/promptSyntax/promptTypes.js'; export class MockChatSessionsService implements IChatSessionsService { _serviceBrand: undefined; - private readonly _onDidChangeSessionOptions = new Emitter(); + private readonly _onDidChangeSessionOptions = new Emitter(); readonly onDidChangeSessionOptions = this._onDidChangeSessionOptions.event; + private readonly _onDidChangeItemsProviders = new Emitter<{ readonly chatSessionType: string }>(); readonly onDidChangeItemsProviders = this._onDidChangeItemsProviders.event; @@ -37,15 +38,13 @@ export class MockChatSessionsService implements IChatSessionsService { private readonly _onDidChangeOptionGroups = new Emitter(); readonly onDidChangeOptionGroups = this._onDidChangeOptionGroups.event; - private readonly _onRequestNotifyExtension = new AsyncEmitter(); - readonly onRequestNotifyExtension = this._onRequestNotifyExtension.event; private sessionItemControllers = new Map }>(); private contentProviders = new Map(); private contributions: IChatSessionsExtensionPoint[] = []; private optionGroups = new Map(); - private sessionOptions = new ResourceMap>(); - private inProgress = new Map(); + private sessionOptions = new ResourceMap(); + private inProgress = new Map(); // For testing: allow triggering events fireDidChangeItemsProviders(event: { chatSessionType: string }): void { @@ -126,13 +125,8 @@ export class MockChatSessionsService implements IChatSessionsService { })); } - reportInProgress(chatSessionType: string, count: number): void { - this.inProgress.set(chatSessionType, count); - this._onDidChangeInProgress.fire(); - } - - getInProgress(): { displayName: string; count: number }[] { - return Array.from(this.inProgress.entries()).map(([displayName, count]) => ({ displayName, count })); + getInProgress(): { chatSessionType: string; count: number }[] { + return Array.from(this.inProgress.entries()).map(([chatSessionType, count]) => ({ chatSessionType, count })); } registerChatSessionContentProvider(chatSessionType: string, provider: IChatSessionContentProvider): IDisposable { @@ -173,32 +167,38 @@ export class MockChatSessionsService implements IChatSessionsService { } } - getNewSessionOptionsForSessionType(_chatSessionType: string): Record | undefined { + getNewSessionOptionsForSessionType(_chatSessionType: string): ReadonlyChatSessionOptionsMap | undefined { return undefined; } - setNewSessionOptionsForSessionType(_chatSessionType: string, _options: Record): void { + setNewSessionOptionsForSessionType(_chatSessionType: string, _options: ReadonlyChatSessionOptionsMap): void { // noop } - async notifySessionOptionsChange(sessionResource: URI, updates: ReadonlyArray<{ optionId: string; value: string | IChatSessionProviderOptionItem }>): Promise { - await this._onRequestNotifyExtension.fireAsync({ sessionResource, updates }, CancellationToken.None); - } - - getSessionOptions(sessionResource: URI): Map | undefined { + getSessionOptions(sessionResource: URI): ReadonlyChatSessionOptionsMap | undefined { const options = this.sessionOptions.get(sessionResource); return options && options.size > 0 ? options : undefined; } getSessionOption(sessionResource: URI, optionId: string): string | undefined { - return this.sessionOptions.get(sessionResource)?.get(optionId); + const value = this.sessionOptions.get(sessionResource)?.get(optionId); + return typeof value === 'string' ? value : value?.id; } setSessionOption(sessionResource: URI, optionId: string, value: string): boolean { + return this.updateSessionOptions(sessionResource, new Map([[optionId, value]])); + } + + updateSessionOptions(sessionResource: URI, updates: ReadonlyChatSessionOptionsMap): boolean { if (!this.sessionOptions.has(sessionResource)) { this.sessionOptions.set(sessionResource, new Map()); } - this.sessionOptions.get(sessionResource)!.set(optionId, value); + for (const [optionId, value] of updates) { + this.sessionOptions.get(sessionResource)!.set(optionId, value); + } + + this._onDidChangeSessionOptions.fire({ sessionResource, updates }); + return true; } @@ -222,6 +222,14 @@ export class MockChatSessionsService implements IChatSessionsService { return this.contributions.find(c => c.type === chatSessionType)?.supportsDelegation !== false; } + sessionSupportsFork(_sessionResource: URI): boolean { + return false; + } + + async forkChatSession(_sessionResource: URI, _request: IChatSessionRequestHistoryItem | undefined, _token: CancellationToken): Promise { + throw new Error('Not implemented'); + } + getContentProviderSchemes(): string[] { return Array.from(this.contentProviders.keys()); } 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 e89b504b224..8a3a7439207 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,9 +25,9 @@ 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, IChatRequestModeInfo, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response } from '../../../common/model/chatModel.js'; +import { ChatModel, ChatRequestModel, ChatResponseResource, 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 { ChatRequestQueueKind, IChatService, IChatTerminalToolInvocationData, IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { MockChatService } from '../chatService/mockChatService.js'; @@ -553,6 +553,65 @@ suite('Response', () => { assert.strictEqual(textEditGroups.length, 0, 'Should not have textEditGroup for cell edits'); assert.strictEqual(notebookEditGroups.length, 1, 'Should have notebookEditGroup for cell edits'); }); + + test('external terminal tool updates preserve toolSpecificData when completing an existing invocation', () => { + const response = store.add(new Response([])); + const toolSpecificData: IChatTerminalToolInvocationData = { + kind: 'terminal', + language: 'bash', + commandLine: { original: 'npm test' }, + terminalCommandOutput: { text: 'all green' }, + terminalCommandState: { exitCode: 0 }, + }; + + response.updateContent({ + kind: 'externalToolInvocationUpdate', + toolCallId: 'tool-call-1', + toolName: 'run_in_terminal', + isComplete: false, + invocationMessage: 'Running npm test', + }); + + response.updateContent({ + kind: 'externalToolInvocationUpdate', + toolCallId: 'tool-call-1', + toolName: 'run_in_terminal', + isComplete: true, + pastTenseMessage: 'Ran npm test', + toolSpecificData, + }); + + assert.strictEqual(response.value.length, 1); + assert.strictEqual(response.value[0].kind, 'toolInvocation'); + assert.deepStrictEqual(response.value[0].toolSpecificData, toolSpecificData); + assert.strictEqual(IChatToolInvocation.isComplete(response.value[0]), true); + }); + + test('external terminal tool updates preserve toolSpecificData when first pushed as complete', () => { + const response = store.add(new Response([])); + const toolSpecificData: IChatTerminalToolInvocationData = { + kind: 'terminal', + language: 'bash', + commandLine: { original: 'npm test' }, + terminalCommandOutput: { text: 'all green' }, + terminalCommandState: { exitCode: 0 }, + }; + + response.updateContent({ + kind: 'externalToolInvocationUpdate', + toolCallId: 'tool-call-2', + toolName: 'run_in_terminal', + isComplete: true, + invocationMessage: 'Running npm test', + pastTenseMessage: 'Ran npm test', + toolSpecificData, + }); + + assert.strictEqual(response.value.length, 1); + assert.strictEqual(response.value[0].kind, 'toolInvocation'); + assert.deepStrictEqual(response.value[0].toolSpecificData, toolSpecificData); + assert.strictEqual(IChatToolInvocation.isComplete(response.value[0]), true); + }); }); suite('normalizeSerializableChatData', () => { @@ -1048,3 +1107,52 @@ suite('ChatModel - Pending Requests', () => { assert.strictEqual(pending.sendOptions.attempt, 3); }); }); + +suite('ChatResponseResource', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('createUri roundtrips through parseUri without basename', () => { + const sessionResource = URI.parse('vscode-chat-session://local/session1'); + const uri = ChatResponseResource.createUri(sessionResource, 'call-123', 2); + const parsed = ChatResponseResource.parseUri(uri); + + assert.ok(parsed); + assert.strictEqual(parsed.sessionResource.toString(), sessionResource.toString()); + assert.strictEqual(parsed.toolCallId, 'call-123'); + assert.strictEqual(parsed.index, 2); + }); + + test('createUri roundtrips through parseUri with basename', () => { + const sessionResource = URI.parse('vscode-chat-session://local/session1'); + const uri = ChatResponseResource.createUri(sessionResource, 'call-456', 0, 'file.txt'); + const parsed = ChatResponseResource.parseUri(uri); + + assert.ok(parsed); + assert.strictEqual(parsed.sessionResource.toString(), sessionResource.toString()); + assert.strictEqual(parsed.toolCallId, 'call-456'); + assert.strictEqual(parsed.index, 0); + }); + + test('parseUri rejects paths with fewer than 4 segments', () => { + // path "/tool/callId/0" splits into ['', 'tool', 'callId', '0'] = 4 parts => valid + // path "/tool/callId" splits into ['', 'tool', 'callId'] = 3 parts => invalid + const base = URI.from({ scheme: ChatResponseResource.scheme, authority: 'abc', path: '/tool/callId' }); + assert.strictEqual(ChatResponseResource.parseUri(base), undefined); + + const tooShort = URI.from({ scheme: ChatResponseResource.scheme, authority: 'abc', path: '/tool' }); + assert.strictEqual(ChatResponseResource.parseUri(tooShort), undefined); + + const empty = URI.from({ scheme: ChatResponseResource.scheme, authority: 'abc', path: '/' }); + assert.strictEqual(ChatResponseResource.parseUri(empty), undefined); + }); + + test('parseUri rejects wrong scheme', () => { + const uri = URI.from({ scheme: 'file', path: '/tool/callId/0' }); + assert.strictEqual(ChatResponseResource.parseUri(uri), undefined); + }); + + test('parseUri rejects wrong kind', () => { + const uri = URI.from({ scheme: ChatResponseResource.scheme, authority: 'abc', path: '/notTool/callId/0' }); + assert.strictEqual(ChatResponseResource.parseUri(uri), undefined); + }); +}); 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 5e43f1377df..d66d3e7d785 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 @@ -271,12 +271,17 @@ suite('parsePluginSource', () => { 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 }); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined, path: 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' }); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', path: undefined }); + }); + + test('parses github object source with path', () => { + const result = parsePluginSource({ source: 'github', repo: 'owner/repo', path: 'plugins/my-plugin' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined, path: 'plugins/my-plugin' }); }); test('returns undefined for github source missing repo', () => { @@ -291,6 +296,10 @@ suite('parsePluginSource', () => { assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner/repo', sha: 'abc123' }, undefined, logContext), undefined); }); + test('returns undefined for github source with non-string path', () => { + assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner/repo', path: 42 } as never, 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 }); @@ -364,6 +373,10 @@ suite('getPluginSourceLabel', () => { assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitHub, repo: 'owner/repo' }), 'owner/repo'); }); + test('formats github source with path', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitHub, repo: 'owner/repo', path: 'plugins/foo' }), 'owner/repo/plugins/foo'); + }); + test('formats url source', () => { assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }), 'https://example.com/repo.git'); }); 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 d8d5f2b33ab..f78133dcdba 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 @@ -1456,6 +1456,86 @@ suite('ComputeAutomaticInstructions', () => { assert.equal(xmlContents(skills[1], 'file')[0], getFilePath(`/home/user/.claude/skills/claude-personal/SKILL.md`)); assert.equal(xmlContents(skills[1], 'name')[0], 'claude-personal'); }); + + test('should include skills with missing name, missing description, or mismatched folder name', async () => { + const rootFolderName = 'skills-missing-metadata-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Enable the config for agent skills + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + await mockFiles(fileService, [ + { + // Skill with no name attribute - should use folder name as fallback + path: `${rootFolder}/.claude/skills/no-name-skill/SKILL.md`, + contents: [ + '---', + 'description: \'A skill without a name\'', + '---', + 'Skill content without name', + ] + }, + { + // Skill with no description attribute - should still be included + path: `${rootFolder}/.claude/skills/no-desc-skill/SKILL.md`, + contents: [ + '---', + 'name: \'no-desc-skill\'', + '---', + 'Skill content without description', + ] + }, + { + // Skill where name does not match folder name - should still be included + path: `${rootFolder}/.claude/skills/actual-folder/SKILL.md`, + contents: [ + '---', + 'name: \'mismatched-name\'', + 'description: \'A skill with mismatched name\'', + '---', + 'Skill content with mismatched name', + ] + }, + ]); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatModeKind.Agent, + { 'vscode_readFile': true }, // Enable readFile tool + undefined, + undefined + ); + const variables = new ChatRequestVariableSet(); + + await contextComputer.collect(variables, CancellationToken.None); + + const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v)); + assert.equal(textVariables.length, 1, 'There should be one text variable for skills list'); + + const skillsList = xmlContents(textVariables[0].value, 'skills'); + assert.equal(skillsList.length, 1, 'There should be one skills list'); + + const skills = xmlContents(skillsList[0], 'skill'); + assert.equal(skills.length, 3, 'All three skills should be included despite missing/mismatched metadata'); + + // Skill with missing name should use folder name as fallback + assert.equal(xmlContents(skills[0], 'name')[0], 'no-name-skill'); + assert.equal(xmlContents(skills[0], 'description')[0], 'A skill without a name'); + assert.equal(xmlContents(skills[0], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/no-name-skill/SKILL.md`)); + + // Skill with missing description should still be listed + assert.equal(xmlContents(skills[1], 'name')[0], 'no-desc-skill'); + assert.equal(xmlContents(skills[1], 'description').length, 0, 'Should have no description element'); + assert.equal(xmlContents(skills[1], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/no-desc-skill/SKILL.md`)); + + // Skill with mismatched name should use folder name + assert.equal(xmlContents(skills[2], 'name')[0], 'mismatched-name'); + assert.equal(xmlContents(skills[2], 'description')[0], 'A skill with mismatched name'); + assert.equal(xmlContents(skills[2], 'file')[0], getFilePath(`${rootFolder}/.claude/skills/actual-folder/SKILL.md`)); + }); }); suite('edge cases', () => { 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 495e54702d1..1f4a09bd0d3 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 @@ -2427,11 +2427,11 @@ suite('PromptsService', () => { assert.ok(allResult, 'Should return results when agent skills are enabled'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 4, 'Should find 4 skills total'); + assert.strictEqual(result.length, 5, 'Should find 5 skills total'); // Check project skills (both from .github/skills and .claude/skills) const projectSkills = result.filter(skill => skill.storage === PromptsStorage.local); - assert.strictEqual(projectSkills.length, 2, 'Should find 2 project skills'); + assert.strictEqual(projectSkills.length, 3, 'Should find 3 project skills'); const githubSkill1 = projectSkills.find(skill => skill.name === 'GitHub Skill 1'); assert.ok(githubSkill1, 'Should find GitHub skill 1'); @@ -2443,6 +2443,12 @@ suite('PromptsService', () => { assert.strictEqual(claudeSkill1.description, 'A Claude skill for testing'); assert.strictEqual(claudeSkill1.uri.path, `${rootFolder}/.claude/skills/Claude Skill 1/SKILL.md`); + // The invalid-skill (no name attribute) should now use folder name as fallback + const invalidSkill = projectSkills.find(skill => skill.name === 'invalid-skill'); + assert.ok(invalidSkill, 'Should find invalid-skill using folder name as fallback'); + assert.strictEqual(invalidSkill.description, 'Invalid skill, no name'); + assert.strictEqual(invalidSkill.uri.path, `${rootFolder}/.claude/skills/invalid-skill/SKILL.md`); + // Check personal skills const personalSkills = result.filter(skill => skill.storage === PromptsStorage.user); assert.strictEqual(personalSkills.length, 2, 'Should find 2 personal skills'); @@ -2494,12 +2500,18 @@ suite('PromptsService', () => { const allResult = await service.findAgentSkills(CancellationToken.None); - // Should still return the valid skill, even if one has parsing errors + // Should return both skills - the malformed one uses folder name as fallback assert.ok(allResult, 'Should return results even with parsing errors'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 1, 'Should find 1 valid skill'); - assert.strictEqual(result[0].name, 'Valid Skill'); - assert.strictEqual(result[0].storage, PromptsStorage.local); + assert.strictEqual(result.length, 2, 'Should find 2 skills'); + + const validSkill = result.find(s => s.name === 'Valid Skill'); + assert.ok(validSkill, 'Should find the valid skill'); + assert.strictEqual(validSkill.storage, PromptsStorage.local); + + const invalidSkill = result.find(s => s.name === 'invalid-skill'); + assert.ok(invalidSkill, 'Should find skill with folder name as fallback despite malformed YAML'); + assert.strictEqual(invalidSkill.storage, PromptsStorage.local); }); test('should return empty array when no skills found', async () => { @@ -2736,7 +2748,7 @@ suite('PromptsService', () => { assert.strictEqual(result[0].storage, PromptsStorage.local); }); - test('should skip skills where name does not match folder name', async () => { + test('should include skills where name does not match folder name using folder name as fallback', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); @@ -2753,7 +2765,7 @@ suite('PromptsService', () => { contents: [ '---', 'name: "Correct Skill Name"', - 'description: "This skill should be skipped due to name mismatch"', + 'description: "This skill should use folder name as fallback"', '---', 'Skill content', ], @@ -2775,11 +2787,17 @@ suite('PromptsService', () => { assert.ok(allResult, 'Should return results'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 1, 'Should find only 1 skill (mismatched one skipped)'); - assert.strictEqual(result[0].name, 'Valid Skill', 'Should only find the valid skill'); + assert.strictEqual(result.length, 2, 'Should find both skills'); + + const mismatchedSkill = result.find(s => s.name === 'Correct Skill Name'); + assert.ok(mismatchedSkill, 'Should find skill with folder name as fallback'); + assert.strictEqual(mismatchedSkill.description, 'This skill should use folder name as fallback'); + + const validSkill = result.find(s => s.name === 'Valid Skill'); + assert.ok(validSkill, 'Should find the valid skill'); }); - test('should skip skills with missing name attribute', async () => { + test('should include skills with missing name attribute using folder name as fallback', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); @@ -2815,8 +2833,14 @@ suite('PromptsService', () => { assert.ok(allResult, 'Should return results'); const result = allResult.filter(s => s.storage !== PromptsStorage.internal); - assert.strictEqual(result.length, 1, 'Should find only 1 skill (one without name skipped)'); - assert.strictEqual(result[0].name, 'Valid Named Skill', 'Should only find skill with name attribute'); + assert.strictEqual(result.length, 2, 'Should find both skills'); + + const noNameSkill = result.find(s => s.name === 'no-name-skill'); + assert.ok(noNameSkill, 'Should find skill with folder name as fallback'); + assert.strictEqual(noNameSkill.description, 'This skill has no name attribute'); + + const validSkill = result.find(s => s.name === 'Valid Named Skill'); + assert.ok(validSkill, 'Should find skill with name attribute'); }); test('should include extension-provided skills in findAgentSkills', async () => { @@ -3763,6 +3787,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo from-plugin' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }]); testPluginsObservable.set([plugin], undefined); @@ -3784,6 +3809,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo before' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }]); testPluginsObservable.set([plugin], undefined); @@ -3796,6 +3822,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo after' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }], undefined); const after = await service.getHooks(CancellationToken.None); @@ -3848,6 +3875,7 @@ suite('PromptsService', () => { type: HookType.PreToolUse, originalId: 'plugin-pre-tool-use', hooks: [{ type: 'command', command: 'echo from-plugin' }], + uri: URI.file('/plugins/test-plugin/hooks.json'), }]); testPluginsObservable.set([plugin], undefined); diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_non-silent_slash_command_with_agent_and_no_supportsPromptAttachments.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_non-silent_slash_command_with_agent_and_no_supportsPromptAttachments.0.snap new file mode 100644 index 00000000000..64219fdc9a8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_non-silent_slash_command_with_agent_and_no_supportsPromptAttachments.0.snap @@ -0,0 +1,54 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + agent: { + id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + extensionVersion: undefined, + publisherDisplayName: "", + extensionDisplayName: "", + extensionPublisherId: "", + locations: [ "panel" ], + modes: [ "ask" ], + metadata: { }, + slashCommands: [ + { + name: "subCommand", + description: "" + } + ], + disambiguation: [ ] + }, + kind: "agent" + }, + { + range: { + start: 6, + endExclusive: 16 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 17 + }, + text: " /fix this", + kind: "text" + } + ], + text: "@agent /fix this" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_silent_slash_command_with_agent_and_no_supportsPromptAttachments.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_silent_slash_command_with_agent_and_no_supportsPromptAttachments.0.snap new file mode 100644 index 00000000000..235e281426e --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_silent_slash_command_with_agent_and_no_supportsPromptAttachments.0.snap @@ -0,0 +1,71 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + agent: { + id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + extensionVersion: undefined, + publisherDisplayName: "", + extensionDisplayName: "", + extensionPublisherId: "", + locations: [ "panel" ], + modes: [ "ask" ], + metadata: { }, + slashCommands: [ + { + name: "subCommand", + description: "" + } + ], + disambiguation: [ ] + }, + kind: "agent" + }, + { + range: { + start: 6, + endExclusive: 7 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 8 + }, + text: " ", + kind: "text" + }, + { + range: { + start: 7, + endExclusive: 13 + }, + editorRange: { + startLineNumber: 1, + startColumn: 8, + endLineNumber: 1, + endColumn: 14 + }, + slashCommand: { + command: "clear", + silent: true + }, + kind: "slash" + } + ], + text: "@agent /clear" +} \ No newline at end of file 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 fa6cc7ac065..fb7b45dab7c 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 @@ -408,4 +408,36 @@ suite('ChatRequestParser', () => { }); await assertSnapshot(result); }); + + test('silent slash command with agent and no supportsPromptAttachments', async () => { + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); + instantiationService.stub(IChatAgentService, agentsService); + + const slashCommandService = mockObject()({ _serviceBrand: undefined }); + slashCommandService.getCommands.returns([{ command: 'clear', silent: true }]); + instantiationService.stub(IChatSlashCommandService, slashCommandService); + + parser = instantiationService.createInstance(ChatRequestParser); + const result = parser.parseChatRequest(testSessionUri, '@agent /clear', undefined, { + attachmentCapabilities: { supportsPromptAttachments: false } + }); + await assertSnapshot(result); + }); + + test('non-silent slash command with agent and no supportsPromptAttachments', async () => { + const agentsService = mockObject()({ _serviceBrand: undefined, hasToolsAgent: false, onDidChangeAgents: Event.None }); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); + instantiationService.stub(IChatAgentService, agentsService); + + const slashCommandService = mockObject()({ _serviceBrand: undefined }); + slashCommandService.getCommands.returns([{ command: 'fix' }]); + instantiationService.stub(IChatSlashCommandService, slashCommandService); + + parser = instantiationService.createInstance(ChatRequestParser); + const result = parser.parseChatRequest(testSessionUri, '@agent /fix this', undefined, { + attachmentCapabilities: { supportsPromptAttachments: false } + }); + await assertSnapshot(result); + }); }); 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 79d99695eb5..61035aeab15 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 @@ -11,8 +11,8 @@ import { NullLogService } from '../../../../../../../platform/log/common/log.js' import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { RunSubagentTool } from '../../../../common/tools/builtinTools/runSubagentTool.js'; import { MockLanguageModelToolsService } from '../mockLanguageModelToolsService.js'; -import { IChatAgentService } from '../../../../common/participants/chatAgents.js'; -import { IChatService } from '../../../../common/chatService/chatService.js'; +import { IChatAgentHistoryEntry, IChatAgentRequest, IChatAgentResult, IChatAgentService, UserSelectedTools } from '../../../../common/participants/chatAgents.js'; +import { IChatProgress, 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'; @@ -20,6 +20,9 @@ import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/se import { Target } from '../../../../common/promptSyntax/promptTypes.js'; import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; +import { IToolInvocation, ToolProgress } from '../../../../common/tools/languageModelToolsService.js'; +import { IChatModel } from '../../../../common/model/chatModel.js'; +import { ChatConfiguration } from '../../../../common/constants.js'; suite('RunSubagentTool', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -491,4 +494,142 @@ suite('RunSubagentTool', () => { }); }); }); + + suite('nested subagent depth tracking', () => { + /** + * Creates a RunSubagentTool with mocked services suitable for invoke() testing. + * The returned `capturedRequests` array collects every IChatAgentRequest passed to invokeAgent. + */ + let callIdCounter = 0; + function createInvokableTool(opts: { + maxDepth: number; + capturedRequests: IChatAgentRequest[]; + }) { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const configService = new TestConfigurationService({ + [ChatConfiguration.SubagentsMaxDepth]: opts.maxDepth, + }); + const promptsService = new MockPromptsService(); + + const mockChatAgentService: Pick = { + getDefaultAgent() { + return { id: 'default-agent' } as IChatAgentService extends { getDefaultAgent(...args: infer _A): infer R } ? NonNullable : never; + }, + async invokeAgent(_id: string, request: IChatAgentRequest, _progress: (parts: IChatProgress[]) => void, _history: IChatAgentHistoryEntry[], _token: CancellationToken): Promise { + opts.capturedRequests.push(request); + return {}; + }, + }; + + const mockChatService: Pick = { + getSession() { + return { + getRequests: () => [{ id: 'req-1' }], + acceptResponseProgress: () => { }, + } as unknown as IChatModel; + }, + }; + + const mockInstantiationService: Pick = { + createInstance(..._args: never[]): { collect: () => Promise } { + return { collect: async () => { } }; + }, + }; + + const tool = testDisposables.add(new RunSubagentTool( + mockChatAgentService as IChatAgentService, + mockChatService as IChatService, + mockToolsService, + {} as ILanguageModelsService, + new NullLogService(), + mockToolsService, + configService, + promptsService, + mockInstantiationService as IInstantiationService, + {} as IProductService, + )); + + return { tool, mockChatAgentService }; + } + + function createInvocation(sessionUri: URI, userSelectedTools?: UserSelectedTools): IToolInvocation { + return { + callId: `call-${++callIdCounter}`, + toolId: 'runSubagent', + parameters: { prompt: 'do something', description: 'test' }, + context: { sessionResource: sessionUri }, + userSelectedTools: userSelectedTools ?? { runSubagent: true }, + } as IToolInvocation; + } + + const countTokens = async () => 0; + const noProgress: ToolProgress = { report() { } }; + + test('disables runSubagent tool when maxDepth is 0', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const { tool } = createInvokableTool({ maxDepth: 0, capturedRequests }); + const sessionUri = URI.parse('test://session/depth0'); + + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 1); + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], false); + }); + + test('enables runSubagent tool at depth 0 when maxDepth >= 1', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const { tool } = createInvokableTool({ maxDepth: 3, capturedRequests }); + const sessionUri = URI.parse('test://session/depth-enabled'); + + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 1); + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], true); + }); + + test('disables runSubagent tool when depth reaches maxDepth', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const sessionUri = URI.parse('test://session/depth-limit'); + + // maxDepth=1, so the first invoke (depth 0→1) should allow nesting, + // but the second invoke (depth 1→2) should not since 1+1 <= 1 is false. + const { tool, mockChatAgentService } = createInvokableTool({ maxDepth: 1, capturedRequests }); + + // Simulate nested invocation: the first invoke's invokeAgent callback + // triggers a second invoke on the same tool (same session). + capturedRequests.length = 0; + mockChatAgentService.invokeAgent = async (_id: string, request: IChatAgentRequest) => { + capturedRequests.push(request); + // On the first call (depth 0), simulate a nested subagent call + if (capturedRequests.length === 1) { + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + } + return {}; + }; + + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 2); + // First call at depth 0: should enable (0 + 1 <= 1) + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], true); + // Second call at depth 1: should disable (1 + 1 <= 1 is false) + assert.strictEqual(capturedRequests[1].userSelectedTools?.['runSubagent'], false); + }); + + test('depth is decremented after invoke completes', async () => { + const capturedRequests: IChatAgentRequest[] = []; + const { tool } = createInvokableTool({ maxDepth: 2, capturedRequests }); + const sessionUri = URI.parse('test://session/depth-decrement'); + + // First invoke + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + // Second invoke on same session should start at depth 0 again + await tool.invoke(createInvocation(sessionUri), countTokens, noProgress, CancellationToken.None); + + assert.strictEqual(capturedRequests.length, 2); + // Both should have runSubagent enabled since depth resets after each invoke + assert.strictEqual(capturedRequests[0].userSelectedTools?.['runSubagent'], true); + assert.strictEqual(capturedRequests[1].userSelectedTools?.['runSubagent'], true); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts index 12819789846..a47b17723ce 100644 --- a/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts @@ -11,8 +11,9 @@ import { ResourceMap } from '../../../../../../../base/common/map.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { IFileContent, IReadFileOptions } from '../../../../../../../platform/files/common/files.js'; import { IWebContentExtractorService, WebContentExtractResult } from '../../../../../../../platform/webContentExtractor/common/webContentExtractor.js'; +import { testWorkspace } from '../../../../../../../platform/workspace/test/common/testWorkspace.js'; import { FetchWebPageTool } from '../../../../electron-browser/builtInTools/fetchPageTool.js'; -import { TestFileService } from '../../../../../../test/common/workbenchTestServices.js'; +import { TestContextService, TestFileService } from '../../../../../../test/common/workbenchTestServices.js'; import { MockTrustedDomainService } from '../../../../../url/test/browser/mockTrustedDomainService.js'; import { InternalFetchWebPageToolId } from '../../../../common/tools/builtinTools/tools.js'; import { MockChatService } from '../../../common/chatService/mockChatService.js'; @@ -91,6 +92,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const testUrls = [ @@ -140,6 +142,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(new ResourceMap()), new MockTrustedDomainService([]), new MockChatService(), + new TestContextService(), ); // Test empty array @@ -189,6 +192,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const preparation = await tool.prepareToolInvocation( @@ -203,6 +207,100 @@ suite('FetchWebPageTool', () => { assert.ok(messageText.includes('invalid://invalid'), 'Should mention invalid URL'); }); + test('should not show confirmation dialog for file URIs inside the workspace', async () => { + // Use a workspace rooted at /workspaceRoot + const workspaceRoot = URI.file('/workspaceRoot'); + const workspaceContextService = new TestContextService(testWorkspace(workspaceRoot)); + + const fileContentMap = new ResourceMap([ + [URI.file('/workspaceRoot/plan.md'), 'Plan content'], + [URI.file('/workspaceRoot/subdir/notes.txt'), 'Notes content'], + ]); + + const tool = new FetchWebPageTool( + new TestWebContentExtractorService(new ResourceMap()), + new ExtendedTestFileService(fileContentMap), + new MockTrustedDomainService([]), + new MockChatService(), + workspaceContextService, + ); + + // File inside workspace - should NOT trigger confirmation + const preparation = await tool.prepareToolInvocation( + { parameters: { urls: [URI.file('/workspaceRoot/plan.md').toString()] }, toolCallId: 'test-file-in-ws', chatSessionResource: undefined }, + CancellationToken.None + ); + assert.ok(preparation, 'Should return prepared invocation'); + assert.strictEqual(preparation.confirmationMessages?.title, undefined, 'File inside workspace should not show confirmation dialog'); + assert.strictEqual(preparation.confirmationMessages?.confirmResults, false, 'File inside workspace should not require post-confirmation'); + }); + + test('should show confirmation dialog for file URIs outside the workspace', async () => { + // Use a workspace rooted at /workspaceRoot + const workspaceRoot = URI.file('/workspaceRoot'); + const workspaceContextService = new TestContextService(testWorkspace(workspaceRoot)); + + const fileContentMap = new ResourceMap([ + [URI.file('/tmp/external-plan.md'), 'External plan content'], + ]); + + const tool = new FetchWebPageTool( + new TestWebContentExtractorService(new ResourceMap()), + new ExtendedTestFileService(fileContentMap), + new MockTrustedDomainService([]), + new MockChatService(), + workspaceContextService, + ); + + // File outside workspace - should still trigger confirmation + const preparation = await tool.prepareToolInvocation( + { parameters: { urls: [URI.file('/tmp/external-plan.md').toString()] }, toolCallId: 'test-file-outside-ws', chatSessionResource: undefined }, + CancellationToken.None + ); + assert.ok(preparation, 'Should return prepared invocation'); + assert.ok(preparation.confirmationMessages?.title, 'File outside workspace should show confirmation dialog'); + assert.strictEqual(preparation.confirmationMessages?.confirmResults, true, 'File outside workspace should require post-confirmation'); + }); + + test('workspace file mixed with untrusted web URI: only web URI triggers confirmation', async () => { + const workspaceRoot = URI.file('/workspaceRoot'); + const workspaceContextService = new TestContextService(testWorkspace(workspaceRoot)); + + const webContentMap = new ResourceMap([ + [URI.parse('https://example.com'), 'Web content'] + ]); + const fileContentMap = new ResourceMap([ + [URI.file('/workspaceRoot/plan.md'), 'Plan content'] + ]); + + const tool = new FetchWebPageTool( + new TestWebContentExtractorService(webContentMap), + new ExtendedTestFileService(fileContentMap), + new MockTrustedDomainService([]), // No trusted domains + new MockChatService(), + workspaceContextService, + ); + + // Mix: one untrusted web URI + one workspace file URI + const preparation = await tool.prepareToolInvocation( + { + parameters: { urls: ['https://example.com', URI.file('/workspaceRoot/plan.md').toString()] }, + toolCallId: 'test-mixed', + chatSessionResource: undefined + }, + CancellationToken.None + ); + assert.ok(preparation, 'Should return prepared invocation'); + // Confirmation should only be for the web URI + assert.ok(preparation.confirmationMessages?.title, 'Should show confirmation for untrusted web URI'); + // The confirmation message should mention only the web URI, not the workspace file + const msgValue = typeof preparation.confirmationMessages?.message === 'string' + ? preparation.confirmationMessages.message + : preparation.confirmationMessages?.message?.value ?? ''; + assert.ok(!msgValue.includes('/workspaceRoot/'), 'Confirmation message should not mention workspace file'); + assert.ok(msgValue.includes('example.com'), 'Confirmation message should mention web URI'); + }); + test('should approve when all URLs were mentioned in chat', async () => { const webContentMap = new ResourceMap([ [URI.parse('https://valid.com'), 'Valid content'] @@ -227,6 +325,7 @@ suite('FetchWebPageTool', () => { }; }, }), + new TestContextService(), ); const preparation1 = await tool.prepareToolInvocation( @@ -261,6 +360,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( @@ -309,6 +409,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( @@ -350,6 +451,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( @@ -397,6 +499,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( @@ -463,6 +566,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( @@ -503,6 +607,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( @@ -547,6 +652,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const testUrls = [ @@ -606,6 +712,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(new ResourceMap()), new MockTrustedDomainService([]), new MockChatService(), + new TestContextService(), ); const testUrls = [ @@ -642,6 +749,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const testUrls = [ @@ -684,6 +792,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const testUrls = [ @@ -732,6 +841,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(new ResourceMap()), // Empty - all file , new MockTrustedDomainService([]), new MockChatService(), + new TestContextService(), ); const testUrls = [ @@ -766,6 +876,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(new ResourceMap()), new MockTrustedDomainService([]), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( @@ -792,6 +903,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(fileContentMap), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( @@ -829,6 +941,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(new ResourceMap()), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( @@ -856,6 +969,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(new ResourceMap()), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( @@ -888,6 +1002,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(new ResourceMap()), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( @@ -914,6 +1029,7 @@ suite('FetchWebPageTool', () => { new ExtendedTestFileService(new ResourceMap()), new MockTrustedDomainService(), new MockChatService(), + new TestContextService(), ); const result = await tool.invoke( diff --git a/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts b/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts index c75b3fde56d..ecbb3b1ba44 100644 --- a/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts @@ -137,6 +137,7 @@ suite('debugConfigurationManager', () => { pid: 1, connectionToken: 'token', appRoot: URI.file('/remote/app'), + execPath: '/remote/app/node', tmpDir: URI.file('/remote/tmp'), settingsPath: URI.file('/remote/settings.json'), mcpResource: URI.file('/remote/mcp.json'), diff --git a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts index 2a2ae1cc5d6..55fb2fdf16f 100644 --- a/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts +++ b/src/vs/workbench/contrib/editTelemetry/browser/telemetry/editSourceTrackingImpl.ts @@ -212,6 +212,8 @@ class TrackedDocumentInfo extends Disposable { trigger: EditTelemetryTrigger; languageId: string; statsUuid: string; + conversationId: string | undefined; + requestId: string | undefined; modifiedCount: number; deltaModifiedCount: number; totalModifiedCount: number; @@ -229,6 +231,8 @@ class TrackedDocumentInfo extends Disposable { languageId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The language id of the document.' }; statsUuid: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The unique identifier of the session for which stats are reported. The sourceKey is unique in this session.' }; + conversationId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat conversation identifier when the edit source comes from chat. Sourced from the chat edit session id.' }; + requestId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat request identifier when the edit source comes from chat.' }; trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Indicates why the session ended.' }; @@ -248,6 +252,8 @@ class TrackedDocumentInfo extends Disposable { trigger, languageId: this._doc.document.languageId.get(), statsUuid: statsUuid, + conversationId: repr.props.$$sessionId, + requestId: repr.props.$$requestId, modifiedCount: value, deltaModifiedCount: deltaModifiedCount, totalModifiedCount: data.totalModifiedCharactersInFinalState, diff --git a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts index 2842ef3b7d2..9ca8f603041 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts @@ -985,7 +985,7 @@ export class ExtensionEditor extends EditorPane { this.contentDisposables.add(toDisposable(removeLayoutParticipant)); this.contentDisposables.add(dependenciesTree); - scrollableContent.scanDomNode(); + depLayout(); return Promise.resolve({ focus() { dependenciesTree.domFocus(); } }); } diff --git a/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css b/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css index 8daf6dc9427..7b5cb760b3a 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extensionEditor.css @@ -417,7 +417,8 @@ background: url('loading.svg') center center no-repeat; } -.extension-editor > .body > .content > .monaco-scrollable-element { +.extension-editor > .body > .content > .monaco-scrollable-element, +.extension-editor > .body > .content > .details > .content-container > .monaco-scrollable-element { height: 100%; } @@ -592,7 +593,8 @@ box-sizing: border-box; } -.extension-editor > .body > .content > .monaco-scrollable-element > .subcontent { +.extension-editor > .body > .content > .monaco-scrollable-element > .subcontent, +.extension-editor > .body > .content > .details > .content-container > .monaco-scrollable-element > .subcontent { height: 100%; padding: 20px; overflow-y: scroll; diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index 53918c80b78..8f36c834a3a 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -459,7 +459,7 @@ configurationRegistry.registerConfiguration({ type: 'string', // expression ({ "**/*.js": { "when": "$(basename).js" } }) pattern: '\\w*\\$\\(basename\\)\\w*', default: '$(basename).ext', - description: nls.localize('explorer.autoRevealExclude.when', 'Additional check on the siblings of a matching file. Use $(basename) as variable for the matching file name.') + description: nls.localize('explorer.autoRevealExclude.when', 'Additional check on the siblings of a matching file. Use {0} as variable for the matching file name.', '$(basename)') } } } diff --git a/src/vs/workbench/contrib/git/browser/gitService.ts b/src/vs/workbench/contrib/git/browser/gitService.ts index ca34f506015..f9403bd33f1 100644 --- a/src/vs/workbench/contrib/git/browser/gitService.ts +++ b/src/vs/workbench/contrib/git/browser/gitService.ts @@ -85,4 +85,8 @@ export class GitRepository extends Disposable implements IGitRepository { async diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise { return this.delegate.diffBetweenWithStats(this.rootUri, ref1, ref2, path); } + + async diffBetweenWithStats2(ref: string, path?: string): Promise { + return this.delegate.diffBetweenWithStats2(this.rootUri, ref, path); + } } diff --git a/src/vs/workbench/contrib/git/common/gitService.ts b/src/vs/workbench/contrib/git/common/gitService.ts index ce605402b6f..cbaa386370d 100644 --- a/src/vs/workbench/contrib/git/common/gitService.ts +++ b/src/vs/workbench/contrib/git/common/gitService.ts @@ -74,6 +74,7 @@ export interface IGitRepository { getRefs(query: GitRefQuery, token?: CancellationToken): Promise; diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise; + diffBetweenWithStats2(ref: string, path?: string): Promise; } export interface IGitExtensionDelegate { @@ -82,6 +83,7 @@ export interface IGitExtensionDelegate { getRefs(root: URI, query?: GitRefQuery, token?: CancellationToken): Promise; diffBetweenWithStats(root: URI, ref1: string, ref2: string, path?: string): Promise; + diffBetweenWithStats2(root: URI, ref: string, path?: string): Promise; } export const IGitService = createDecorator('gitService'); diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts index ad4e2f193d6..5c7fbd966fb 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts @@ -29,19 +29,24 @@ import { ResourceSet } from '../../../../base/common/map.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; -import { Limiter } from '../../../../base/common/async.js'; // --- Configuration --- Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'imageCarousel', - title: localize('imageCarouselConfigurationTitle', "Image Carousel"), + title: localize('imageCarouselConfigurationTitle', "Images Preview"), type: 'object', properties: { 'imageCarousel.explorerContextMenu.enabled': { type: 'boolean', - default: false, - markdownDescription: localize('imageCarousel.explorerContextMenu.enabled', "Controls whether the **Open in Image Carousel** option appears in the Explorer context menu. This is an experimental feature."), + default: true, + markdownDescription: localize('imageCarousel.explorerContextMenu.enabled', "Controls whether the **Open in Images Preview** option appears in the Explorer context menu."), + tags: ['experimental'], + }, + 'imageCarousel.chat.enabled': { + type: 'boolean', + default: true, + description: localize('imageCarousel.chat.enabled', "Controls whether clicking an image attachment in chat opens the Images Preview viewer."), tags: ['experimental'], }, } @@ -53,7 +58,7 @@ Registry.as(EditorExtensions.EditorPane).registerEditorPane EditorPaneDescriptor.create( ImageCarouselEditor, ImageCarouselEditor.ID, - localize('imageCarouselEditor', "Image Carousel") + localize('imageCarouselEditor', "Images Preview") ), [ new SyncDescriptor(ImageCarouselEditorInput) @@ -112,7 +117,7 @@ class OpenImageInCarouselAction extends Action2 { constructor() { super({ id: 'workbench.action.chat.openImageInCarousel', - title: localize2('openImageInCarousel', "Open Image in Carousel"), + title: localize2('openImageInCarousel', "Open in Images Preview"), f1: false }); } @@ -129,7 +134,7 @@ class OpenImageInCarouselAction extends Action2 { } else if (isSingleImageArgs(args)) { collection = { id: generateUuid(), - title: args.title ?? localize('imageCarousel.title', "Image Carousel"), + title: args.title ?? localize('imageCarousel.title', "Images Preview"), sections: [{ title: '', images: [{ @@ -175,33 +180,20 @@ async function collectImageFilesFromFolder(fileService: IFileService, folderUri: return imageUris; } -async function readImageFiles(fileService: IFileService, uris: URI[]): Promise { - const limiter = new Limiter(10); - const results = await Promise.all( - uris.map(uri => limiter.queue(async () => { - try { - const content = await fileService.readFile(uri); - const mimeType = getMediaMime(uri.path) ?? 'image/png'; - return { - id: generateUuid(), - name: basename(uri), - mimeType, - data: content.value, - uri, - }; - } catch { - return undefined; - } - })) - ); - return results.filter((r): r is ICarouselImage => r !== undefined); +function createImageEntries(uris: URI[]): ICarouselImage[] { + return uris.map(uri => ({ + id: generateUuid(), + name: basename(uri), + mimeType: getMediaMime(uri.path) ?? 'image/png', + uri, + })); } class OpenImagesInCarouselFromExplorerAction extends Action2 { constructor() { super({ id: 'workbench.action.openImagesInCarousel', - title: localize2('openImagesInCarousel', "Open in Image Carousel"), + title: localize2('openImagesInCarousel', "Open in Images Preview"), f1: false, menu: [{ id: MenuId.ExplorerContext, @@ -292,11 +284,7 @@ class OpenImagesInCarouselFromExplorerAction extends Action2 { return; } - const images = await readImageFiles(fileService, imageUris); - if (images.length === 0) { - notificationService.error(localize('imageReadError', "Could not read the selected images.")); - return; - } + const images = createImageEntries(imageUris); let startIndex = 0; if (startUri) { @@ -308,7 +296,7 @@ class OpenImagesInCarouselFromExplorerAction extends Action2 { const collection: IImageCarouselCollection = { id: generateUuid(), - title: localize('imageCarousel.explorerTitle', "Image Carousel"), + title: localize('imageCarousel.explorerTitle', "Images Preview"), sections: [{ title: '', images, diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts index 56411a6efeb..06274338eec 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts @@ -12,6 +12,7 @@ import { clamp } from '../../../../base/common/numbers.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; @@ -49,13 +50,16 @@ export class ImageCarouselEditor extends EditorPane { private _flatImages: IFlatImageEntry[] = []; private readonly _contentDisposables = this._register(new DisposableStore()); private readonly _imageDisposables = this._register(new DisposableStore()); + private readonly _blobUrlCache = new Map(); private _elements: { root: HTMLElement; imageArea: HTMLElement; mainImageContainer: HTMLElement; mainImage: HTMLImageElement; - caption: HTMLElement; + captionText: HTMLElement; + captionSeparator: HTMLElement; + counter: HTMLElement; prevBtn: HTMLButtonElement; nextBtn: HTMLButtonElement; sectionsContainer: HTMLElement; @@ -66,7 +70,8 @@ export class ImageCarouselEditor extends EditorPane { group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, @IThemeService themeService: IThemeService, - @IStorageService storageService: IStorageService + @IStorageService storageService: IStorageService, + @IFileService private readonly _fileService: IFileService ) { super(ImageCarouselEditor.ID, group, telemetryService, themeService, storageService); } @@ -93,6 +98,7 @@ export class ImageCarouselEditor extends EditorPane { override clearInput(): void { this._contentDisposables.clear(); this._imageDisposables.clear(); + this._revokeCachedBlobUrls(); this._zoomScale = 'fit'; if (this._container) { clearNode(this._container); @@ -112,6 +118,7 @@ export class ImageCarouselEditor extends EditorPane { this._contentDisposables.clear(); this._imageDisposables.clear(); + this._revokeCachedBlobUrls(); clearNode(this._container); if (this._flatImages.length === 0) { @@ -134,7 +141,11 @@ export class ImageCarouselEditor extends EditorPane { ]), ]), h('div.bottom-bar@bottomBar', [ - h('div.image-caption@caption'), + h('div.image-info-bar', [ + h('span.caption-text@captionText'), + h('span.caption-separator@captionSeparator'), + h('span.image-counter@counter'), + ]), h('div.sections-container@sectionsContainer'), ]), ]); @@ -144,7 +155,9 @@ export class ImageCarouselEditor extends EditorPane { imageArea: elements.imageArea, mainImageContainer: elements.mainImageContainer, mainImage: elements.mainImage as HTMLImageElement, - caption: elements.caption, + captionText: elements.captionText, + captionSeparator: elements.captionSeparator, + counter: elements.counter, prevBtn: elements.prevBtn as HTMLButtonElement, nextBtn: elements.nextBtn as HTMLButtonElement, sectionsContainer: elements.sectionsContainer, @@ -255,11 +268,8 @@ export class ImageCarouselEditor extends EditorPane { btn.ariaLabel = localize('imageCarousel.thumbnailLabel', "Image {0} of {1}", currentFlatIndex + 1, this._flatImages.length); const img = thumbnail.img as HTMLImageElement; - const blob = new Blob([image.data.buffer.slice(0)], { type: image.mimeType }); - const url = URL.createObjectURL(blob); - img.src = url; + this._loadBlobUrl(image).then(url => { img.src = url; }); img.alt = image.name; - this._contentDisposables.add({ dispose: () => URL.revokeObjectURL(url) }); this._contentDisposables.add(addDisposableListener(btn, 'click', () => { this._currentIndex = currentFlatIndex; @@ -282,63 +292,137 @@ export class ImageCarouselEditor extends EditorPane { * Update only the changing parts: main image src, caption, button states, thumbnail selection. * No DOM teardown/rebuild — eliminates the blank flash. */ - private updateCurrentImage(): void { + private async updateCurrentImage(): Promise { if (!this._elements) { return; } - // Swap main image blob URL - this._imageDisposables.clear(); - const entry = this._flatImages[this._currentIndex]; + // Capture the navigation index before starting async work so that + // we can discard stale results if the user navigates while loading/decoding. + const navigationIndex = this._currentIndex; + + // Swap main image using cached/lazy-loaded blob URL. + // Pre-decode via decode() before assigning to so the browser + // decodes on a worker thread, avoiding main-thread stalls during commit. + const entry = this._flatImages[navigationIndex]; const currentImage = entry.image; - const blob = new Blob([currentImage.data.buffer.slice(0)], { type: currentImage.mimeType }); - const url = URL.createObjectURL(blob); - this._elements.mainImage.src = url; - this._elements.mainImage.alt = currentImage.name; - this._imageDisposables.add({ dispose: () => URL.revokeObjectURL(url) }); + const url = await this._loadBlobUrl(currentImage); + + // If the user navigated while loading the blob URL, discard this result. + if (this._currentIndex !== navigationIndex) { + return; + } + + const tmp = new Image(); + tmp.src = url; + tmp.decode().then(() => { + // Only apply if user hasn't navigated away during decode + if (this._currentIndex === navigationIndex && this._elements) { + this._elements.mainImage.src = url; + this._elements.mainImage.alt = currentImage.name; + } + }, () => { + // Decode failed (invalid image) — still show src for browser fallback + if (this._currentIndex === navigationIndex && this._elements) { + this._elements.mainImage.src = url; + this._elements.mainImage.alt = currentImage.name; + } + }); // Reset zoom when switching images this._applyZoom('fit'); - // Update caption + // Update info bar: caption + separator + counter if (currentImage.caption) { - this._elements.caption.textContent = currentImage.caption; - this._elements.caption.style.display = ''; + this._elements.captionText.textContent = currentImage.caption; + this._elements.captionText.style.display = ''; + this._elements.captionSeparator.style.display = ''; } else { - this._elements.caption.textContent = ''; - this._elements.caption.style.display = 'none'; + this._elements.captionText.textContent = ''; + this._elements.captionText.style.display = 'none'; + this._elements.captionSeparator.style.display = 'none'; } + this._elements.counter.textContent = localize('imageCarousel.counter', "{0} / {1}", this._currentIndex + 1, this._flatImages.length); // Update button states this._elements.prevBtn.disabled = this._currentIndex === 0; this._elements.nextBtn.disabled = this._currentIndex === this._flatImages.length - 1; - // Update thumbnail selection + // Update thumbnail selection — only toggle active class and + // call getBoundingClientRect on the active thumbnail to avoid + // layout thrashing across all thumbnails on every navigation. for (let i = 0; i < this._thumbnailElements.length; i++) { const isActive = i === this._currentIndex; const thumbnail = this._thumbnailElements[i]; thumbnail.classList.toggle('active', isActive); if (isActive) { thumbnail.setAttribute('aria-current', 'page'); - // Scroll only the thumbnail strip, not the entire editor - const container = this._elements.sectionsContainer; - const containerRect = container.getBoundingClientRect(); - const thumbRect = thumbnail.getBoundingClientRect(); - if (thumbRect.left < containerRect.left) { - container.scrollLeft += thumbRect.left - containerRect.left; - } else if (thumbRect.right > containerRect.right) { - container.scrollLeft += thumbRect.right - containerRect.right; - } } else { thumbnail.removeAttribute('aria-current'); } } + // Scroll the active thumbnail into view without blocking the main thread. + // Using scrollIntoView with 'nearest' avoids forced layout from + // getBoundingClientRect + scrollLeft and is handled efficiently by + // the browser's scroll machinery. + const activeThumbnail = this._thumbnailElements[this._currentIndex]; + if (activeThumbnail) { + activeThumbnail.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + // Update editor title to reflect current section if (this.input instanceof ImageCarouselEditorInput) { const currentSection = this._sections[entry.sectionIndex]; this.input.setName(currentSection.title || this.input.collection.title); } + + // Preload adjacent images for smoother navigation + this._preloadAdjacentImages(); + } + + private async _loadBlobUrl(image: ICarouselImage): Promise { + const cached = this._blobUrlCache.get(image.id); + if (cached) { + return cached; + } + + let buffer: Uint8Array; + if (image.data) { + // Handle both VSBuffer (has .buffer property) and raw Uint8Array from chat attachments + buffer = image.data instanceof Uint8Array ? image.data : image.data.buffer; + } else if (image.uri) { + const content = await this._fileService.readFile(image.uri); + buffer = content.value.buffer; + } else { + return ''; + } + + const blob = new Blob([buffer as Uint8Array], { type: image.mimeType }); + const url = URL.createObjectURL(blob); + this._blobUrlCache.set(image.id, url); + return url; + } + + private _revokeCachedBlobUrls(): void { + for (const url of this._blobUrlCache.values()) { + URL.revokeObjectURL(url); + } + this._blobUrlCache.clear(); + } + + private _preloadAdjacentImages(): void { + for (const idx of [this._currentIndex - 1, this._currentIndex + 1]) { + if (idx >= 0 && idx < this._flatImages.length) { + this._loadBlobUrl(this._flatImages[idx].image).then(url => { + // Pre-decode via decode() so the compositor doesn't block + // the main thread decoding this image during commit. + const img = new Image(); + img.src = url; + img.decode().catch(() => { /* invalid image */ }); + }); + } + } } previous(): void { @@ -420,9 +504,14 @@ export class ImageCarouselEditor extends EditorPane { img.classList.add('scale-to-fit'); img.classList.remove('pixelated'); img.style.zoom = ''; + // Remove zoomed/overflow before scrollTo to avoid an expensive + // synchronous ScrollLayer that blocks the main thread. + const wasZoomed = container.classList.contains('zoomed'); container.classList.remove('zoomed'); container.classList.remove('zoom-out'); - container.scrollTo(0, 0); + if (wasZoomed) { + container.scrollTo(0, 0); + } } else { const scale = clamp(newScale, MIN_SCALE, MAX_SCALE); this._zoomScale = scale; diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts index cda9d2b23d8..c0aaa014ffd 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts @@ -10,7 +10,8 @@ export interface ICarouselImage { readonly id: string; readonly name: string; readonly mimeType: string; - readonly data: VSBuffer; + /** In-memory image data. Omit when the image can be loaded lazily from `uri`. */ + readonly data?: VSBuffer; readonly uri?: URI; readonly source?: string; readonly caption?: string; diff --git a/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css b/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css index abc7fde67df..48194e0238b 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css +++ b/src/vs/workbench/contrib/imageCarousel/browser/media/imageCarousel.css @@ -24,7 +24,6 @@ display: flex; flex-direction: column; height: 100%; - padding: 12px; gap: 8px; overflow: hidden; } @@ -43,8 +42,6 @@ align-items: center; justify-content: center; overflow: hidden; - background-color: var(--vscode-editorWidget-background); - border-radius: 6px; } .image-carousel-editor .main-image-container { @@ -52,7 +49,6 @@ height: 100%; display: flex; overflow: hidden; - padding: 24px 48px; cursor: zoom-in; } @@ -108,8 +104,8 @@ display: flex; align-items: center; justify-content: center; - background: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); border: none; border-radius: 50%; cursor: pointer; @@ -127,8 +123,14 @@ .image-carousel-editor .nav-arrow:hover:not(:disabled) { opacity: 1 !important; - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); + background: var(--vscode-toolbar-activeBackground); + color: var(--vscode-foreground); +} + +.image-carousel-editor .nav-arrow:focus-visible:not(:disabled) { + opacity: 1 !important; + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; } .image-carousel-editor .nav-arrow:disabled { @@ -162,12 +164,23 @@ margin-top: 4px; } -/* Caption */ -.image-carousel-editor .image-caption { +/* Info bar: caption · counter */ +.image-carousel-editor .image-info-bar { text-align: center; color: var(--vscode-descriptionForeground); font-size: 12px; padding: 0 24px; + min-height: 16px; +} + +.image-carousel-editor .caption-separator::before { + content: '\00b7'; + margin: 0 6px; + opacity: 0.6; +} + +.image-carousel-editor .image-counter { + opacity: 0.7; } /* Sections container — flat horizontal thumbnail strip */ diff --git a/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts b/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts index fc95cb68395..c78c14c5df0 100644 --- a/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts +++ b/src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts @@ -361,7 +361,7 @@ suite('OpenImagesInCarouselFromExplorerAction', () => { assert.strictEqual(infoMessages.length, 0, 'Should not show info notification'); }); - test('all image reads failing shows error notification', async () => { + test('images with URIs are passed lazily without reading file contents', async () => { const folderUri = URI.file('/workspace/broken'); const resolveMap = new Map(); @@ -372,8 +372,13 @@ suite('OpenImagesInCarouselFromExplorerAction', () => { ] )); - // No file contents → all readFile calls will fail + // No file contents — with lazy loading, no readFile should be called at action time + let readFileCallCount = 0; stubFileService(resolveMap, new Map()); + instantiationService.stub(IFileService, 'readFile', async () => { + readFileCallCount++; + throw new Error('readFile should not be called'); + }); stubExplorerService([]); stubEditorService(); stubNotificationService(); @@ -384,7 +389,11 @@ suite('OpenImagesInCarouselFromExplorerAction', () => { await instantiationService.invokeFunction(command.handler, folderUri); - assert.strictEqual(openedInputs.length, 0, 'Should not open carousel when all reads fail'); - assert.strictEqual(errorMessages.length, 1, 'Should show error notification for read failures'); + assert.strictEqual(readFileCallCount, 0, 'readFile should not be called during action'); + assert.strictEqual(openedInputs.length, 1, 'Should open carousel with lazy image entries'); + const images = openedInputs[0].input.collection.sections[0].images; + assert.strictEqual(images.length, 2, 'Should include 2 lazy image entries'); + assert.strictEqual(images[0].data, undefined, 'Image data should not be loaded eagerly'); + assert.ok(images[0].uri, 'Image should have a URI for lazy loading'); }); }); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index e5ad9450dc1..acb788bced6 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -17,6 +17,7 @@ import { InlineChatNotebookContribution } from './inlineChatNotebook.js'; import { IWorkbenchContributionsRegistry, registerWorkbenchContribution2, Extensions as WorkbenchExtensions, WorkbenchPhase } from '../../../common/contributions.js'; import { IInlineChatSessionService } from './inlineChatSessionService.js'; import { InlineChatEnabler, InlineChatEscapeToolContribution, InlineChatSessionServiceImpl } from './inlineChatSessionServiceImpl.js'; +import { IInlineChatHistoryService, InlineChatHistoryService } from './inlineChatHistoryService.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { CancelAction, ChatSubmitAction } from '../../chat/browser/actions/chatExecuteActions.js'; import { localize } from '../../../../nls.js'; @@ -36,6 +37,7 @@ registerAction2(InlineChatActions.RephraseInlineChatSessionAction); // --- browser registerSingleton(IInlineChatSessionService, InlineChatSessionServiceImpl, InstantiationType.Delayed); +registerSingleton(IInlineChatHistoryService, InlineChatHistoryService, InstantiationType.Delayed); // --- MENU special --- diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index c613e0d419c..d4bfb599f0c 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -11,7 +11,7 @@ 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, CTX_INLINE_CHAT_TERMINATED, InlineChatConfigKeys, CTX_FIX_DIAGNOSTICS_ENABLED, CTX_INLINE_CHAT_AFFORDANCE_VISIBLE } 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, CTX_INLINE_CHAT_TERMINATED, InlineChatConfigKeys, CTX_FIX_DIAGNOSTICS_ENABLED, CTX_INLINE_CHAT_AFFORDANCE_VISIBLE, CTX_ASK_IN_CHAT_ENABLED } 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'; @@ -53,9 +53,13 @@ export class StartSessionAction extends Action2 { shortTitle: localize2('runShort', 'Inline Chat'), category: AbstractInlineChatAction.category, f1: true, - precondition: ContextKeyExpr.and(inlineChatContextKey, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), + precondition: ContextKeyExpr.and(inlineChatContextKey, ContextKeyExpr.or(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate(), CTX_ASK_IN_CHAT_ENABLED.negate())), keybinding: { - when: EditorContextKeys.focus, + when: ContextKeyExpr.and( + EditorContextKeys.focus, + inlineChatContextKey, + ContextKeyExpr.or(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate(), CTX_ASK_IN_CHAT_ENABLED.negate()) + ), weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyI }, @@ -64,7 +68,7 @@ export class StartSessionAction extends Action2 { id: MenuId.EditorContext, group: '1_chat', order: 3, - when: inlineChatContextKey + when: ContextKeyExpr.and(inlineChatContextKey, ContextKeyExpr.or(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate(), CTX_ASK_IN_CHAT_ENABLED.negate())) }, { id: MenuId.ChatTitleBarMenu, group: 'a_open', @@ -130,7 +134,7 @@ export class StartSessionAction extends Action2 { MenuRegistry.appendMenuItem(MenuId.InlineChatEditorAffordance, { group: '0_chat', order: 1, - when: ContextKeyExpr.and(EditorContextKeys.writable, EditorContextKeys.hasNonEmptySelection, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate(), ChatEntitlementContextKeys.Setup.hidden.negate()), + when: ContextKeyExpr.and(EditorContextKeys.writable, EditorContextKeys.hasNonEmptySelection, ContextKeyExpr.or(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate(), CTX_ASK_IN_CHAT_ENABLED.negate()), ChatEntitlementContextKeys.Setup.hidden.negate()), command: { id: ACTION_START, title: localize('editCode', "Ask for Edits"), @@ -250,6 +254,12 @@ export class FixDiagnosticsAction extends AbstractInlineChatAction { group: '2_chat', order: 1, when: ContextKeyExpr.and(CTX_FIX_DIAGNOSTICS_ENABLED, EditorContextKeys.selectionHasDiagnostics, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), + }, { + id: MenuId.MarkerHoverStatusBar, + group: '1_fix', + order: 1, + when: ContextKeyExpr.and(CTX_FIX_DIAGNOSTICS_ENABLED, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), + precondition: null, }] }); } @@ -449,9 +459,9 @@ export class SubmitInlineChatInputAction extends AbstractInlineChatAction { id: 'inlineChat.submitInput', title: localize2('submitInput', "Send"), icon: Codicon.send, - precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), + precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_INPUT_HAS_TEXT, ContextKeyExpr.or(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate(), CTX_ASK_IN_CHAT_ENABLED.negate())), keybinding: { - when: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate()), + when: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, ContextKeyExpr.or(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate(), CTX_ASK_IN_CHAT_ENABLED.negate())), weight: KeybindingWeight.EditorCore + 10, primary: KeyCode.Enter }, @@ -459,7 +469,7 @@ export class SubmitInlineChatInputAction extends AbstractInlineChatAction { id: MenuId.InlineChatInput, group: '0_main', order: 1, - when: CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate() + when: ContextKeyExpr.or(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT.negate(), CTX_ASK_IN_CHAT_ENABLED.negate()) }] }); } @@ -467,6 +477,7 @@ export class SubmitInlineChatInputAction extends AbstractInlineChatAction { override runInlineChatCommand(_accessor: ServicesAccessor, ctrl: InlineChatController, _editor: ICodeEditor, ..._args: unknown[]): void { const value = ctrl.inputWidget.value; if (value) { + ctrl.inputWidget.addToHistory(value); ctrl.inputWidget.hide(); ctrl.run({ message: value, autoSend: true }); } @@ -502,7 +513,7 @@ export class AskInChatAction extends EditorAction2 { title: localize2('askInChat', 'Ask in Chat'), category: AbstractInlineChatAction.category, f1: true, - precondition: ContextKeyExpr.and(inlineChatContextKey, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT), + precondition: ContextKeyExpr.and(inlineChatContextKey, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_ASK_IN_CHAT_ENABLED), keybinding: { when: EditorContextKeys.focus, weight: KeybindingWeight.WorkbenchContrib, @@ -513,12 +524,12 @@ export class AskInChatAction extends EditorAction2 { id: MenuId.EditorContext, group: '1_chat', order: 3, - when: ContextKeyExpr.and(inlineChatContextKey, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT) + when: ContextKeyExpr.and(inlineChatContextKey, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_ASK_IN_CHAT_ENABLED) }, { id: MenuId.InlineChatEditorAffordance, group: '0_chat', order: 1, - when: ContextKeyExpr.and(EditorContextKeys.hasNonEmptySelection, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT) + when: ContextKeyExpr.and(EditorContextKeys.hasNonEmptySelection, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_ASK_IN_CHAT_ENABLED) }] }); } @@ -564,9 +575,9 @@ export class QueueInChatAction extends AbstractInlineChatAction { id: 'inlineChat.queueInChat', title: localize2('queueInChat', "Queue in Chat"), icon: Codicon.arrowUp, - precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT), + precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_ASK_IN_CHAT_ENABLED), keybinding: { - when: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT), + when: ContextKeyExpr.and(CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_ASK_IN_CHAT_ENABLED), weight: KeybindingWeight.EditorCore + 10, primary: KeyCode.Enter }, @@ -574,7 +585,7 @@ export class QueueInChatAction extends AbstractInlineChatAction { id: MenuId.InlineChatInput, group: '0_main', order: 1, - when: CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, + when: ContextKeyExpr.and(CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_ASK_IN_CHAT_ENABLED), }] }); } @@ -587,6 +598,9 @@ export class QueueInChatAction extends AbstractInlineChatAction { } const value = ctrl.inputWidget.value; + if (value) { + ctrl.inputWidget.addToHistory(value); + } ctrl.inputWidget.hide(); if (!value) { return; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts index e7773395fce..0be9f4f0502 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatEditorAffordance.ts @@ -192,6 +192,19 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi this._hide(); } })); + + this._store.add(this._editor.onDidScrollChange(() => { + const sel = selection.get(); + if (!sel) { + return; + } + const isInViewport = this._isPositionInViewport(); + if (isInViewport && !this._isVisible) { + this._show(sel); + } else if (!isInViewport && this._isVisible) { + this._hide(); + } + })); } private _show(selection: Selection): void { @@ -260,6 +273,30 @@ export class InlineChatEditorAffordance extends Disposable implements IContentWi }; } + private _isPositionInViewport(): boolean { + const widgetPosition = this._position?.position; + if (!widgetPosition) { + return false; + } + + // Check vertical visibility + const visibleRanges = this._editor.getVisibleRanges(); + const isLineVisible = visibleRanges.some(range => + widgetPosition.lineNumber >= range.startLineNumber && widgetPosition.lineNumber <= range.endLineNumber + ); + if (!isLineVisible) { + return false; + } + + // Check horizontal visibility + const scrolledPos = this._editor.getScrolledVisiblePosition(widgetPosition); + if (!scrolledPos) { + return false; + } + const layoutInfo = this._editor.getOptions().get(EditorOption.layoutInfo); + return scrolledPos.left >= 0 && scrolledPos.left <= layoutInfo.width; + } + private _hide(): void { if (this._isVisible) { this._isVisible = false; diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts new file mode 100644 index 00000000000..3b501b8a03e --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatHistoryService.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { HistoryNavigator2 } from '../../../../base/common/history.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +export const IInlineChatHistoryService = createDecorator('IInlineChatHistoryService'); + +export interface IInlineChatHistoryService { + readonly _serviceBrand: undefined; + + addToHistory(value: string): void; + previousValue(): string | undefined; + nextValue(): string | undefined; + isAtEnd(): boolean; + replaceLast(value: string): void; + resetCursor(): void; +} + +const _storageKey = 'inlineChat.history'; +const _capacity = 50; + +export class InlineChatHistoryService extends Disposable implements IInlineChatHistoryService { + declare readonly _serviceBrand: undefined; + + private readonly _history: HistoryNavigator2; + + constructor( + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + + const raw = this._storageService.get(_storageKey, StorageScope.PROFILE); + let entries: string[] = ['']; + if (raw) { + try { + const parsed: string[] = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + entries = parsed; + // Ensure there's always an empty uncommitted entry at the end + if (entries[entries.length - 1] !== '') { + entries.push(''); + } + } + } catch { + // ignore invalid data + } + } + + this._history = new HistoryNavigator2(entries, _capacity); + + this._store.add(this._storageService.onWillSaveState(() => { + this._saveToStorage(); + })); + } + + private _saveToStorage(): void { + const values = [...this._history].filter(v => v.length > 0); + if (values.length === 0) { + this._storageService.remove(_storageKey, StorageScope.PROFILE); + } else { + this._storageService.store(_storageKey, JSON.stringify(values), StorageScope.PROFILE, StorageTarget.USER); + } + } + + addToHistory(value: string): void { + this._history.replaceLast(value); + this._history.add(''); + } + + previousValue(): string | undefined { + return this._history.previous(); + } + + nextValue(): string | undefined { + return this._history.next(); + } + + isAtEnd(): boolean { + return this._history.isAtEnd(); + } + + replaceLast(value: string): void { + this._history.replaceLast(value); + } + + resetCursor(): void { + this._history.resetCursor(); + } +} diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts index 434a4b2e7fe..3675acc488e 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatOverlayWidget.ts @@ -41,6 +41,7 @@ import { getSimpleEditorOptions } from '../../codeEditor/browser/simpleEditorOpt import { PlaceholderTextContribution } from '../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js'; import { IInlineChatSession2 } from './inlineChatSessionService.js'; import { assertType } from '../../../../base/common/types.js'; +import { IInlineChatHistoryService } from './inlineChatHistoryService.js'; /** * Overlay widget that displays a vertical action bar menu. @@ -62,7 +63,6 @@ export class InlineChatInputWidget extends Disposable { private _anchorLeft: number = 0; private _anchorAbove: boolean = false; - constructor( private readonly _editorObs: ObservableCodeEditor, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @@ -70,6 +70,7 @@ export class InlineChatInputWidget extends Disposable { @IInstantiationService instantiationService: IInstantiationService, @IModelService modelService: IModelService, @IConfigurationService configurationService: IConfigurationService, + @IInlineChatHistoryService private readonly _historyService: IInlineChatHistoryService, ) { super(); @@ -161,9 +162,17 @@ export class InlineChatInputWidget extends Disposable { const totalWidth = contentWidth.read(r) + editorPad + toolbarWidth.read(r); const minWidth = 220; const maxWidth = 600; - const clampedWidth = this._input.getOption(EditorOption.wordWrap) === 'on' - ? maxWidth - : Math.max(minWidth, Math.min(totalWidth, maxWidth)); + const midWidth = Math.round(maxWidth / 1.618); + let clampedWidth: number; + if (this._input.getOption(EditorOption.wordWrap) === 'on') { + clampedWidth = maxWidth; + } else if (totalWidth <= minWidth) { + clampedWidth = minWidth; + } else if (totalWidth <= midWidth) { + clampedWidth = midWidth; + } else { + clampedWidth = maxWidth; + } const lineHeight = this._input.getOption(EditorOption.lineHeight); const clampedHeight = Math.min(contentHeight.read(r), (3 * lineHeight)); @@ -214,15 +223,28 @@ export class InlineChatInputWidget extends Disposable { this._store.add(this._input.onDidBlurEditorText(() => inputWidgetFocused.set(false))); this._store.add(toDisposable(() => inputWidgetFocused.reset())); - // Handle key events: ArrowDown to move to actions + // Handle key events: ArrowUp/ArrowDown for history navigation and action bar focus this._store.add(this._input.onKeyDown(e => { - if (e.keyCode === KeyCode.DownArrow && !actionBar.isEmpty()) { + if (e.keyCode === KeyCode.UpArrow) { + const position = this._input.getPosition(); + if (position && position.lineNumber === 1) { + this._showPreviousHistoryValue(); + e.preventDefault(); + e.stopPropagation(); + } + } else if (e.keyCode === KeyCode.DownArrow) { const model = this._input.getModel(); const position = this._input.getPosition(); if (position && position.lineNumber === model.getLineCount()) { - e.preventDefault(); - e.stopPropagation(); - actionBar.focus(0); + if (!this._historyService.isAtEnd()) { + this._showNextHistoryValue(); + e.preventDefault(); + e.stopPropagation(); + } else if (!actionBar.isEmpty()) { + e.preventDefault(); + e.stopPropagation(); + actionBar.focus(0); + } } } })); @@ -254,6 +276,30 @@ export class InlineChatInputWidget extends Disposable { return this._input.getModel().getValue().trim(); } + addToHistory(value: string): void { + this._historyService.addToHistory(value); + } + + private _showPreviousHistoryValue(): void { + if (this._historyService.isAtEnd()) { + this._historyService.replaceLast(this._input.getModel().getValue()); + } + const value = this._historyService.previousValue(); + if (value !== undefined) { + this._input.getModel().setValue(value); + } + } + + private _showNextHistoryValue(): void { + if (this._historyService.isAtEnd()) { + return; + } + const value = this._historyService.nextValue(); + if (value !== undefined) { + this._input.getModel().setValue(value); + } + } + /** * Show the widget at the specified line. * @param lineNumber The line number to anchor the widget to @@ -263,6 +309,9 @@ export class InlineChatInputWidget extends Disposable { show(lineNumber: number, left: number, anchorAbove: boolean, placeholder: string, value?: string): void { this._showStore.clear(); + // Reset history cursor to the end (current uncommitted text) + this._historyService.resetCursor(); + // Clear input state this._input.updateOptions({ wordWrap: 'off', placeholder }); this._input.getModel().setValue(value ?? ''); diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 692399181b6..8832cbe3cc9 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -22,6 +22,7 @@ export const enum InlineChatConfigKeys { Affordance = 'inlineChat.affordance', RenderMode = 'inlineChat.renderMode', FixDiagnostics = 'inlineChat.fixDiagnostics', + AskInChat = 'inlineChat.askInChat', } Registry.as(Extensions.Configuration).registerConfiguration({ @@ -87,7 +88,12 @@ Registry.as(Extensions.Configuration).registerConfigurat mode: 'auto' }, tags: ['experimental'] - } + }, + [InlineChatConfigKeys.AskInChat]: { + description: localize('askInChat', "Controls whether files in a chat editing session use Ask in Chat instead of Inline Chat."), + default: true, + type: 'boolean', + }, } }); @@ -138,6 +144,7 @@ export const CTX_INLINE_CHAT_V2_ENABLED = ContextKeyExpr.or( export const CTX_HOVER_MODE = ContextKeyExpr.equals('config.inlineChat.renderMode', 'hover'); export const CTX_FIX_DIAGNOSTICS_ENABLED = ContextKeyExpr.equals('config.inlineChat.fixDiagnostics', true); +export const CTX_ASK_IN_CHAT_ENABLED = ContextKeyExpr.equals('config.inlineChat.askInChat', true); // --- (selected) action identifier diff --git a/src/vs/workbench/contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.ts b/src/vs/workbench/contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.ts new file mode 100644 index 00000000000..6235b798361 --- /dev/null +++ b/src/vs/workbench/contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INativeHostService } from '../../../../platform/native/common/native.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { URI } from '../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { join } from '../../../../base/common/path.js'; +import { OperatingSystem } from '../../../../base/common/platform.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IKeybindingItem, KeybindingsRegistry } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { KeybindingResolver } from '../../../../platform/keybinding/common/keybindingResolver.js'; +import { ResolvedKeybindingItem } from '../../../../platform/keybinding/common/resolvedKeybindingItem.js'; +import { IKeyboardMapper } from '../../../../platform/keyboardLayout/common/keyboardMapper.js'; +import { IMacLinuxKeyboardMapping, IWindowsKeyboardMapping } from '../../../../platform/keyboardLayout/common/keyboardLayout.js'; +import { MacLinuxKeyboardMapper } from '../../../services/keybinding/common/macLinuxKeyboardMapper.js'; +import { WindowsKeyboardMapper } from '../../../services/keybinding/common/windowsKeyboardMapper.js'; +import { IKeymapInfo, KeymapInfo } from '../../../services/keybinding/common/keymapInfo.js'; +import { EN_US_WIN_LAYOUT } from '../../../services/keybinding/browser/keyboardLayouts/en.win.js'; +import { EN_US_DARWIN_LAYOUT } from '../../../services/keybinding/browser/keyboardLayouts/en.darwin.js'; +import { EN_US_LINUX_LAYOUT } from '../../../services/keybinding/browser/keyboardLayouts/en.linux.js'; +import { KeybindingIO, OutputBuilder } from '../../../services/keybinding/common/keybindingIO.js'; +import { getAllUnboundCommands } from '../../../services/keybinding/browser/unboundCommands.js'; + +export class KeybindingsExportContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.keybindingsExport'; + + constructor( + @INativeEnvironmentService private readonly nativeEnvironmentService: INativeEnvironmentService, + @IFileService private readonly fileService: IFileService, + @INativeHostService private readonly nativeHostService: INativeHostService, + @IProductService private readonly productService: IProductService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + if (this.productService.quality === 'stable') { + return; + } + + const outputPath = this.nativeEnvironmentService.exportDefaultKeybindings; + if (outputPath !== undefined) { + const defaultPath = join(this.nativeEnvironmentService.appRoot, 'doc'); + void this.exportDefaultKeybindingsAndQuit(outputPath || defaultPath); + } + } + + private async exportDefaultKeybindingsAndQuit(outputPath: string): Promise { + try { + const platforms: { os: OperatingSystem; filename: string }[] = [ + { os: OperatingSystem.Windows, filename: 'doc.keybindings.win.json' }, + { os: OperatingSystem.Macintosh, filename: 'doc.keybindings.osx.json' }, + { os: OperatingSystem.Linux, filename: 'doc.keybindings.linux.json' }, + ]; + + for (const { os, filename } of platforms) { + const content = KeybindingsExportContribution._getDefaultKeybindingsContentForOS(os); + const filePath = join(outputPath, filename); + await this.fileService.writeFile(URI.file(filePath), VSBuffer.fromString(content)); + this.logService.info(`[${KeybindingsExportContribution.ID}] Wrote ${filePath}`); + } + + await this.nativeHostService.exit(0); + } catch (error) { + this.logService.error(`[${KeybindingsExportContribution.ID}] Failed to generate default keybindings`, error); + await this.nativeHostService.exit(1); + } + } + + private static _getDefaultKeybindingsContentForOS(os: OperatingSystem): string { + const items = KeybindingsRegistry.getDefaultKeybindingsForOS(os); + const mapper = KeybindingsExportContribution._createKeyboardMapperForOS(os); + const resolved = KeybindingsExportContribution._resolveKeybindingItemsWithMapper(items, mapper); + const resolver = new KeybindingResolver(resolved, [], () => { }); + const defaultKeybindings = resolver.getDefaultKeybindings(); + const boundCommands = resolver.getDefaultBoundCommands(); + return ( + KeybindingsExportContribution._formatDefaultKeybindings(defaultKeybindings) + + '\n\n' + + KeybindingsExportContribution._formatAllCommandsAsComment(boundCommands) + ); + } + + private static _createKeyboardMapperForOS(os: OperatingSystem): IKeyboardMapper { + const layoutMap: Record = { + [OperatingSystem.Windows]: EN_US_WIN_LAYOUT, + [OperatingSystem.Macintosh]: EN_US_DARWIN_LAYOUT, + [OperatingSystem.Linux]: EN_US_LINUX_LAYOUT, + }; + const layout = layoutMap[os]; + const keymapInfo = new KeymapInfo(layout.layout, layout.secondaryLayouts, layout.mapping); + switch (os) { + case OperatingSystem.Windows: + return new WindowsKeyboardMapper(true, keymapInfo.mapping, false); + case OperatingSystem.Macintosh: + return new MacLinuxKeyboardMapper(true, keymapInfo.mapping, false, OperatingSystem.Macintosh); + case OperatingSystem.Linux: + return new MacLinuxKeyboardMapper(true, keymapInfo.mapping, false, OperatingSystem.Linux); + } + } + + private static _resolveKeybindingItemsWithMapper(items: IKeybindingItem[], mapper: IKeyboardMapper): ResolvedKeybindingItem[] { + const result: ResolvedKeybindingItem[] = []; + for (const item of items) { + const when = item.when || undefined; + const keybinding = item.keybinding; + if (!keybinding) { + result.push(new ResolvedKeybindingItem(undefined, item.command, item.commandArgs, when, true, item.extensionId, item.isBuiltinExtension)); + } else { + const resolvedKeybindings = mapper.resolveKeybinding(keybinding); + for (let i = resolvedKeybindings.length - 1; i >= 0; i--) { + result.push(new ResolvedKeybindingItem(resolvedKeybindings[i], item.command, item.commandArgs, when, true, item.extensionId, item.isBuiltinExtension)); + } + } + } + return result; + } + + private static _formatDefaultKeybindings(defaultKeybindings: readonly ResolvedKeybindingItem[]): string { + const out = new OutputBuilder(); + out.writeLine('['); + const lastIndex = defaultKeybindings.length - 1; + defaultKeybindings.forEach((k, index) => { + KeybindingIO.writeKeybindingItem(out, k); + if (index !== lastIndex) { + out.writeLine(','); + } else { + out.writeLine(); + } + }); + out.writeLine(']'); + return out.toString(); + } + + private static _formatAllCommandsAsComment(boundCommands: Map): string { + const unboundCommands = getAllUnboundCommands(boundCommands); + const pretty = unboundCommands.sort().join('\n// - '); + return '// Here are other available commands: ' + '\n// - ' + pretty; + } +} + +registerWorkbenchContribution2( + KeybindingsExportContribution.ID, + KeybindingsExportContribution, + WorkbenchPhase.Eventually, +); diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 7cb6a6efebd..d175c3a067a 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -43,11 +43,17 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { if (!isContributionEnabled(plugin.enablement.read(reader))) { continue; } + const servers = plugin.mcpServerDefinitions.read(reader); + if (servers.length === 0) { + continue; + } + seen.add(plugin.uri); let collectionState = this._collections.get(plugin.uri); if (!collectionState) { - collectionState = this.createCollectionState(plugin); + // note: all plugin servers are currently defined in the same file + collectionState = this.createCollectionState(plugin, servers[0].uri); this._collections.set(plugin.uri, collectionState); } } @@ -60,7 +66,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { })); } - private createCollectionState(plugin: IAgentPlugin) { + private createCollectionState(plugin: IAgentPlugin, manifestURI: URI) { const collectionId = `plugin.${plugin.uri}`; return this._mcpRegistry.registerCollection({ id: collectionId, @@ -72,7 +78,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { serverDefinitions: plugin.mcpServerDefinitions.map(defs => defs.map(d => this._toServerDefinition(collectionId, d)).filter(isDefined)), presentation: { - origin: plugin.uri, + origin: manifestURI, order: McpCollectionSortOrder.Plugin, }, }); diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts index b051bac13ef..c74df032d41 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts @@ -407,9 +407,10 @@ suite.skip('Workbench - MCP - McpTask', () => { // TODO@connor4312 https://githu } test('should resolve when task completes', async () => { + const getTaskResultStub = sinon.stub().resolves({ content: [{ type: 'text', text: 'result' }] }); const mockHandler = upcastPartial({ getTask: sinon.stub().resolves(createTask({ status: 'completed' })), - getTaskResult: sinon.stub().resolves({ content: [{ type: 'text', text: 'result' }] }) + getTaskResult: getTaskResultStub }); const task = store.add(new McpTask(createTask())); @@ -423,7 +424,7 @@ suite.skip('Workbench - MCP - McpTask', () => { // TODO@connor4312 https://githu const result = await task.result; assert.deepStrictEqual(result, { content: [{ type: 'text', text: 'result' }] }); - assert.ok((mockHandler.getTaskResult as sinon.SinonStub).calledWith({ taskId: 'task1' })); + assert.ok(getTaskResultStub.calledWith({ taskId: 'task1' })); }); test('should poll for task updates', async () => { diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index 9e2704a79b6..eaf1dfe189a 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -751,8 +751,8 @@ export function inspectSetting(key: string, target: SettingsTarget, languageFilt return { isConfigured, inspected, targetSelector, inspectedLanguageOverrides, languageSelector: languageFilter }; } -function sanitizeId(id: string): string { - return id.replace(/[\.\/]/, '_'); +export function sanitizeId(id: string): string { + return id.replace(/[\.\/]/g, '_'); } export function settingKeyToDisplayFormat(key: string, groupId: string = '', isLanguageTagSetting: boolean = false): { category: string; label: string } { diff --git a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts index 98421c6ea63..0fd6956d676 100644 --- a/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts +++ b/src/vs/workbench/contrib/preferences/test/browser/settingsTreeModels.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { settingKeyToDisplayFormat, parseQuery, IParsedQuery } from '../../browser/settingsTreeModels.js'; +import { settingKeyToDisplayFormat, parseQuery, IParsedQuery, sanitizeId } from '../../browser/settingsTreeModels.js'; suite('SettingsTree', () => { test('settingKeyToDisplayFormat', () => { @@ -329,5 +329,22 @@ suite('SettingsTree', () => { }); }); + test('sanitizeId replaces all dots and slashes', () => { + assert.deepStrictEqual( + [ + sanitizeId('root.editor.font.size'), + sanitizeId('group/subgroup/setting.key'), + sanitizeId('no-special-chars'), + sanitizeId('single.dot'), + ], + [ + 'root_editor_font_size', + 'group_subgroup_setting_key', + 'no-special-chars', + 'single_dot', + ] + ); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/workbench/contrib/remote/browser/remote.ts b/src/vs/workbench/contrib/remote/browser/remote.ts index 530b0c4ae0f..2d7e4bb96ef 100644 --- a/src/vs/workbench/contrib/remote/browser/remote.ts +++ b/src/vs/workbench/contrib/remote/browser/remote.ts @@ -1003,7 +1003,6 @@ export class RemoteAgentConnectionStatusListener extends Disposable implements I if (e.handled) { logService.info(`Error handled: Not showing a notification for the error.`); - console.log(`Error handled: Not showing a notification for the error.`); } else if (!this._reloadWindowShown) { this._reloadWindowShown = true; dialogService.confirm({ diff --git a/src/vs/workbench/contrib/scm/browser/activity.ts b/src/vs/workbench/contrib/scm/browser/activity.ts index d38a51b0654..1dc0f055c9c 100644 --- a/src/vs/workbench/contrib/scm/browser/activity.ts +++ b/src/vs/workbench/contrib/scm/browser/activity.ts @@ -15,7 +15,6 @@ import { IStatusbarEntry, IStatusbarService, StatusbarAlignment as MainThreadSta import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { EditorResourceAccessor } from '../../../common/editor.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { Iterable } from '../../../../base/common/iterator.js'; import { ITitleService } from '../../../services/title/browser/titleService.js'; import { IEditorGroupContextKeyProvider, IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; @@ -30,7 +29,7 @@ const ActiveRepositoryContextKeys = { }; export class SCMActiveRepositoryController extends Disposable implements IWorkbenchContribution { - private readonly _repositories: IObservable>; + private readonly _visibleRepositories: IObservable; private readonly _activeRepositoryHistoryItemRefName: IObservable; private readonly _countBadgeConfig: IObservable<'all' | 'focused' | 'off'>; private readonly _countBadgeRepositories: IObservable }[]>; @@ -60,9 +59,9 @@ export class SCMActiveRepositoryController extends Disposable implements IWorkbe this._countBadgeConfig = observableConfigValue<'all' | 'focused' | 'off'>('scm.countBadge', 'all', this.configurationService); - this._repositories = observableFromEvent(this, - Event.any(this.scmService.onDidAddRepository, this.scmService.onDidRemoveRepository), - () => Iterable.filter(this.scmService.repositories, r => r.provider.isHidden !== true)); + this._visibleRepositories = observableFromEvent(this, + Event.any(this.scmViewService.onDidChangeVisibleRepositories, this.scmService.onDidAddRepository, this.scmService.onDidRemoveRepository), + () => this.scmViewService.visibleRepositories); this._activeRepositoryHistoryItemRefName = derived(reader => { const activeRepository = this.scmViewService.activeRepository.read(reader); @@ -75,8 +74,8 @@ export class SCMActiveRepositoryController extends Disposable implements IWorkbe this._countBadgeRepositories = derived(this, reader => { switch (this._countBadgeConfig.read(reader)) { case 'all': { - const repositories = this._repositories.read(reader); - return [...Iterable.map(repositories, r => ({ provider: r.provider, resourceCount: this._getRepositoryResourceCount(r) }))]; + const repositories = this._visibleRepositories.read(reader); + return repositories.map(r => ({ provider: r.provider, resourceCount: this._getRepositoryResourceCount(r) })); } case 'focused': { const activeRepository = this.scmViewService.activeRepository.read(reader); diff --git a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts index ab1900fc1db..88c5c132598 100644 --- a/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmHistoryViewPane.ts @@ -1706,7 +1706,7 @@ export class SCMHistoryViewPane extends ViewPane { compact: true, showPointer: true }, - content: new MarkdownString(localize('scmGraphViewOutdated', "Please refresh the graph using the refresh action ($(refresh))."), { supportThemeIcons: true }), + content: new MarkdownString(localize('scmGraphViewOutdated', "Please refresh the graph using the refresh action ({0}).", '$(refresh)'), { supportThemeIcons: true }), position: { hoverPosition: HoverPosition.BELOW } diff --git a/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts b/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts index b10f117bc6e..15cd622c7cd 100644 --- a/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts +++ b/src/vs/workbench/contrib/tasks/browser/taskQuickPick.ts @@ -360,7 +360,7 @@ export class TaskQuickPick extends Disposable { public static getSettingEntry(configurationService: IConfigurationService, type: string): (ITaskTwoLevelQuickPickEntry & { settingType: string }) | undefined { if (configurationService.getValue(`${type}.autoDetect`) === 'off') { return { - label: nls.localize('TaskQuickPick.changeSettingsOptions', "$(gear) {0} task detection is turned off. Enable {1} task detection...", + label: '$(gear) ' + nls.localize('TaskQuickPick.changeSettingsOptions', "{0} task detection is turned off. Enable {1} task detection...", type[0].toUpperCase() + type.slice(1), type), task: null, settingType: type, diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 93a50146bc1..56a037a35e0 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -877,6 +877,8 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { lineDataEventAddon.setOperatingSystem(this._processManager.os); } xterm.raw.options.windowsPty = processTraits.windowsPty; + // Enable reflow cursor to avoid prompt loss: https://github.com/microsoft/vscode/issues/274372 + xterm.raw.options.reflowCursorLine = processTraits?.windowsPty?.backend === 'conpty' && !!this._terminalConfigurationService.config.windowsUseConptyDll; })); this._register(this._processManager.onRestoreCommands(e => this.xterm?.shellIntegration.deserialize(e))); @@ -1129,10 +1131,12 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } // Skip processing by xterm.js of keyboard events that resolve to commands defined in - // the commandsToSkipShell setting. Ensure sendKeybindingsToShell is respected here - // which will disable this special handling and always opt to send the keystroke to the - // shell process - if (!this._terminalConfigurationService.config.sendKeybindingsToShell && resolveResult.kind === ResultKind.KbFound && resolveResult.commandId && this._skipTerminalCommands.some(k => k === resolveResult.commandId)) { + // the commandsToSkipShell setting, or that use the Meta. + // The metaKey check is needed because when a shell like fish enables the kitty + // keyboard protocol, xterm.js encodes Meta-modified keys as CSI u sequences and + // consumes them via preventDefault. The (non-kitty) traditional xterm.js handler already skips + // Meta keys so they bubble up naturally, but the kitty handler does not. + if (!this._terminalConfigurationService.config.sendKeybindingsToShell && resolveResult.kind === ResultKind.KbFound && resolveResult.commandId && (event.metaKey || this._skipTerminalCommands.some(k => k === resolveResult.commandId))) { event.preventDefault(); return false; } @@ -1341,9 +1345,11 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { } async sendText(text: string, shouldExecute: boolean, bracketedPasteMode?: boolean): Promise { + const useBracketedPasteMode = (bracketedPasteMode || /[\r\n]/.test(text)) && this.xterm?.raw.modes.bracketedPasteMode; + // Apply bracketed paste sequences if the terminal has the mode enabled, this will prevent // the text from triggering keybindings and ensure new lines are handled properly - if (bracketedPasteMode && this.xterm?.raw.modes.bracketedPasteMode) { + if (useBracketedPasteMode) { text = `\x1b[200~${text}\x1b[201~`; } diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh index ff974695f69..c5729c39941 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh @@ -197,7 +197,7 @@ fi # Allow verifying $BASH_COMMAND doesn't have aliases resolved via history when the right HISTCONTROL # configuration is used -__vsc_regex_histcontrol=".*(erasedups|ignoreboth|ignoredups).*" +__vsc_regex_histcontrol=".*(erasedups|ignoreboth|ignoredups|ignorespace).*" if [[ "${HISTCONTROL:-}" =~ $__vsc_regex_histcontrol ]]; then __vsc_history_verify=0 else diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 index afeac192274..72a329b8e39 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 @@ -179,11 +179,13 @@ if ($Global:__VSCodeState.IsA11yMode -eq "1") { # Check if the loaded PSReadLine already supports EnableScreenReaderMode $hasScreenReaderParam = (Get-Module -Name PSReadLine) -and (Get-Command Set-PSReadLineOption).Parameters.ContainsKey('EnableScreenReaderMode') - if (-not $hasScreenReaderParam) { + if (-not $hasScreenReaderParam -and $PSVersionTable.PSVersion -ge "7.0") { # The loaded PSReadLine lacks EnableScreenReaderMode (only available in 2.4.4-beta4+). # PowerShell 7.0+ skips autoloading PSReadLine when the OS reports a screen reader active. # When only VS Code's accessibility mode is enabled (no OS screen reader), # it's still loaded and must be removed to load our bundled copy. + # Skip this on Windows PowerShell 5.1 where removing the built-in PSReadLine 2.0.0 + # and replacing it can cause input handling issues (e.g. repeated Enter key presses). if (Get-Module -Name PSReadLine) { Remove-Module PSReadLine -Force } diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index ab230b025f6..2d1dd15ce52 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -634,7 +634,7 @@ const terminalConfiguration: IStringDictionary = { }, [TerminalSettingId.ShellIntegrationTimeout]: { restricted: true, - markdownDescription: localize('terminal.integrated.shellIntegration.timeout', "Configures the duration in milliseconds to wait for shell integration after launch before declaring it's not there. Set to {0} to wait the minimum time (500ms), the default value {1} means the wait time is variable based on whether shell integration injection is enabled and whether it's a remote window. Consider setting this to a small value if you intentionally disabled shell integration, or a large value if your shell starts very slowly.", '`0`', '`-1`'), + markdownDescription: localize('terminal.integrated.shellIntegration.timeout', "Configures the duration in milliseconds to wait for shell integration after launch before declaring it's not there. Set to {0} to skip the wait entirely. The default value {1} uses a variable wait time based on whether shell integration injection is enabled and whether it's a remote window. Values between 1 and 499 are clamped to 500ms. Consider setting this to {0} if you intentionally disabled shell integration, or a large value if your shell starts very slowly.", '`0`', '`-1`'), type: 'integer', minimum: -1, maximum: 60000, diff --git a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts index a724394106a..b797a6d549a 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalEnvironment.ts @@ -425,6 +425,8 @@ export function getShellIntegrationTimeout( if (!isNumber(timeoutValue) || timeoutValue < 0) { timeoutMs = siInjectionEnabled ? 5000 : (isRemote ? 3000 : 2000); + } else if (timeoutValue === 0) { + timeoutMs = 0; } else { timeoutMs = Math.max(timeoutValue, 500); } diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts index 8c3c2d9982a..ebda7abea87 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { deepStrictEqual, strictEqual } from 'assert'; +import { timeout } from '../../../../../base/common/async.js'; import { Event } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; @@ -13,6 +14,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ResultKind } from '../../../../../platform/keybinding/common/keybindingResolver.js'; import { TerminalCapability, type ICwdDetectionCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { TerminalCapabilityStore } from '../../../../../platform/terminal/common/capabilities/terminalCapabilityStore.js'; import { GeneralShellType, ITerminalChildProcess, ITerminalProfile, TitleEventSource, type IShellLaunchConfig, type ITerminalBackend, type ITerminalProcessOptions } from '../../../../../platform/terminal/common/terminal.js'; @@ -123,7 +125,8 @@ suite('Workbench - TerminalInstance', () => { suite('TerminalInstance', () => { let terminalInstance: ITerminalInstance; - test('should create an instance of TerminalInstance with env from default profile', async () => { + + async function createTerminalInstance(): Promise { const instantiationService = workbenchInstantiationService({ configurationService: () => new TestConfigurationService({ files: {}, @@ -134,6 +137,7 @@ suite('Workbench - TerminalInstance', () => { fastScrollSensitivity: 2, mouseWheelScrollSensitivity: 1, unicodeVersion: '6', + commandsToSkipShell: [], shellIntegration: { enabled: true } @@ -146,9 +150,25 @@ suite('Workbench - TerminalInstance', () => { instantiationService.stub(IEnvironmentVariableService, store.add(instantiationService.createInstance(EnvironmentVariableService))); instantiationService.stub(ITerminalInstanceService, store.add(new TestTerminalInstanceService())); instantiationService.stub(ITerminalService, { setNextCommandId: async () => { } } as Partial); - terminalInstance = store.add(instantiationService.createInstance(TerminalInstance, terminalShellTypeContextKey, {})); - // //Wait for the teminalInstance._xtermReadyPromise to resolve - await new Promise(resolve => setTimeout(resolve, 100)); + const instance = store.add(instantiationService.createInstance(TerminalInstance, terminalShellTypeContextKey, {})); + await instance.xtermReadyPromise; + return instance; + } + + async function waitForShellLaunchConfigEnv(instance: ITerminalInstance): Promise { + for (let i = 0; i < 50; i++) { + if (instance.shellLaunchConfig.env) { + return; + } + await timeout(0); + } + + throw new Error('Timed out waiting for shell launch config env'); + } + + test('should create an instance of TerminalInstance with env from default profile', async () => { + terminalInstance = await createTerminalInstance(); + await waitForShellLaunchConfigEnv(terminalInstance); deepStrictEqual(terminalInstance.shellLaunchConfig.env, { TEST: 'TEST' }); }); @@ -192,6 +212,75 @@ suite('Workbench - TerminalInstance', () => { // Verify that the task name is preserved strictEqual(taskTerminal.title, 'Test Task Name', 'Task terminal should preserve API-set title'); }); + + test('should use bracketed paste mode for multiline executed text when available', async () => { + const instance = await createTerminalInstance(); + const writes: string[] = []; + const processManager = (instance as unknown as { _processManager: { write(data: string): Promise } })._processManager; + const originalWrite = processManager.write; + const originalXterm = instance.xterm!; + const testRaw = Object.create(originalXterm.raw) as typeof originalXterm.raw; + Object.defineProperty(testRaw, 'modes', { + value: { + ...originalXterm.raw.modes, + bracketedPasteMode: true + }, + configurable: true + }); + const testXterm = Object.create(originalXterm) as typeof originalXterm; + Object.defineProperty(testXterm, 'raw', { + value: testRaw, + configurable: true + }); + Object.defineProperty(testXterm, 'scrollToBottom', { + value: () => { }, + configurable: true + }); + + processManager.write = async (data: string) => { + writes.push(data); + }; + instance.xterm = testXterm; + + try { + await instance.sendText('echo hello\nworld', true); + } finally { + processManager.write = originalWrite; + instance.xterm = originalXterm; + } + + strictEqual(writes.length, 1); + strictEqual(writes[0].replace(/\x1b/g, '\\x1b').replace(/\r/g, '\\r'), '\\x1b[200~echo hello\\rworld\\x1b[201~\\r'); + }); + + test('custom key event handler should intercept Meta-modified keys that resolve to a command when sendKeybindingsToShell is disabled', async () => { + const instance = await createTerminalInstance(); + const keybindingService = instance['_keybindingService']; + const originalSoftDispatch = keybindingService.softDispatch; + // Simulate Cmd+= resolving to zoomIn. This command is deliberately NOT in + // DEFAULT_COMMANDS_TO_SKIP_SHELL, so only the event.metaKey check can intercept it. + keybindingService.softDispatch = () => ({ kind: ResultKind.KbFound, commandId: 'workbench.action.zoomIn', commandArgs: undefined, isBubble: false }); + + // Capture the inline handler by intercepting its registration on xterm.raw, + // then attach + show the terminal so the handler gets registered. + let capturedHandler: ((e: KeyboardEvent) => boolean) | undefined; + instance.xterm!.raw.attachCustomKeyEventHandler = handler => { capturedHandler = handler; }; + const container = document.createElement('div'); + document.body.appendChild(container); + instance.attachToElement(container); + instance.setVisible(true); + + const event = new KeyboardEvent('keydown', { key: '=', metaKey: true, cancelable: true }); + try { + deepStrictEqual( + { result: capturedHandler?.(event), defaultPrevented: event.defaultPrevented }, + { result: false, defaultPrevented: true } + ); + } finally { + keybindingService.softDispatch = originalSoftDispatch; + container.remove(); + } + }); }); suite('parseExitResult', () => { test('should return no message for exit code = undefined', () => { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts index de2f1ae7056..6de862cb380 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/basicExecuteStrategy.ts @@ -8,12 +8,14 @@ import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { isNumber } from '../../../../../../base/common/types.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import type { ICommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; import { trackIdleOnPrompt, waitForIdle, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js'; import type { IMarker as IXtermMarker } from '@xterm/xterm'; import { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; -import { createAltBufferPromise, setupRecreatingStartMarker } from './strategyHelpers.js'; +import { createAltBufferPromise, setupRecreatingStartMarker, stripCommandEchoAndPrompt } from './strategyHelpers.js'; +import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; /** * This strategy is used when shell integration is enabled, but rich command detection was not @@ -49,6 +51,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute private readonly _instance: ITerminalInstance, private readonly _hasReceivedUserInput: () => boolean, private readonly _commandDetection: ICommandDetectionCapability, + @IConfigurationService private readonly _configurationService: IConfigurationService, @ITerminalLogService private readonly _logService: ITerminalLogService, ) { super(); @@ -58,7 +61,9 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute const store = new DisposableStore(); try { - const idlePromptPromise = trackIdleOnPrompt(this._instance, 1000, store); + const idlePollInterval = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdlePollInterval) ?? 1000; + + const idlePromptPromise = trackIdleOnPrompt(this._instance, idlePollInterval, store, idlePollInterval); const onDone = Promise.race([ Event.toPromise(this._commandDetection.onCommandFinished, store).then(e => { // When shell integration is basic, it means that the end execution event is @@ -82,7 +87,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute }), // A longer idle prompt event is used here as a catch all for unexpected cases where // the end event doesn't fire for some reason. - trackIdleOnPrompt(this._instance, 3000, store).then(() => { + trackIdleOnPrompt(this._instance, idlePollInterval * 3, store, idlePollInterval).then(() => { this._log('onDone long idle prompt'); }), ]); @@ -97,9 +102,9 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute // Wait for the terminal to idle before executing the command this._log('Waiting for idle'); - await waitForIdle(this._instance.onData, 1000); + await waitForIdle(this._instance.onData, idlePollInterval); - setupRecreatingStartMarker( + const markerRecreation = setupRecreatingStartMarker( xterm, this._startMarker, m => this._onDidCreateStartMarker.fire(m), @@ -123,6 +128,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute // ^C being sent and also to return the exit code of 130 when from the shell when that // occurs. this._log(`Executing command line \`${commandLine}\``); + markerRecreation.dispose(); this._instance.sendText(commandLine, true); // Wait for the next end execution event - note that this may not correspond to the actual @@ -150,7 +156,7 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute // Wait for the terminal to idle this._log('Waiting for idle'); - await waitForIdle(this._instance.onData, 1000); + await waitForIdle(this._instance.onData, idlePollInterval); if (token.isCancellationRequested) { throw new CancellationError(); } @@ -163,13 +169,19 @@ export class BasicExecuteStrategy extends Disposable implements ITerminalExecute const commandOutput = finishedCommand?.getOutput(); if (commandOutput !== undefined) { this._log('Fetched output via finished command'); - output = commandOutput; + output = stripCommandEchoAndPrompt(commandOutput, commandLine, this._log.bind(this)); } } if (output === undefined) { try { output = xterm.getContentsAsText(this._startMarker.value, endMarker); this._log('Fetched output via markers'); + + // The marker-based output includes the command echo and trailing + // prompt lines. Strip them to isolate the actual command output. + if (output !== undefined) { + output = stripCommandEchoAndPrompt(output, commandLine, this._log.bind(this)); + } } catch { this._log('Failed to fetch output via markers'); additionalInformationLines.push('Failed to retrieve command output'); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts index f95f297a958..805223b03f6 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/executeStrategy.ts @@ -160,6 +160,7 @@ export async function trackIdleOnPrompt( instance: ITerminalInstance, idleDurationMs: number, store: DisposableStore, + promptFallbackMs?: number, ): Promise { const idleOnPrompt = new DeferredPromise(); const onData = instance.onData; @@ -176,7 +177,7 @@ export async function trackIdleOnPrompt( } state = TerminalState.PromptAfterExecuting; scheduler.schedule(); - }, 1000)); + }, promptFallbackMs ?? 1000)); // Only schedule when a prompt sequence (A) is seen after an execute sequence (C). This prevents // cases where the command is executed before the prompt is written. While not perfect, sitting // on an A without a C following shortly after is a very good indicator that the command is done diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts index ddc2a7f8939..f72379411ca 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/noneExecuteStrategy.ts @@ -7,11 +7,13 @@ import type { CancellationToken } from '../../../../../../base/common/cancellati import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; import { waitForIdle, waitForIdleWithPromptHeuristics, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js'; import type { IMarker as IXtermMarker } from '@xterm/xterm'; import { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; -import { createAltBufferPromise, setupRecreatingStartMarker } from './strategyHelpers.js'; +import { createAltBufferPromise, setupRecreatingStartMarker, stripCommandEchoAndPrompt } from './strategyHelpers.js'; +import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; /** * This strategy is used when no shell integration is available. There are very few extension APIs @@ -30,6 +32,7 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS constructor( private readonly _instance: ITerminalInstance, private readonly _hasReceivedUserInput: () => boolean, + @IConfigurationService private readonly _configurationService: IConfigurationService, @ITerminalLogService private readonly _logService: ITerminalLogService, ) { super(); @@ -50,14 +53,16 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS } const alternateBufferPromise = createAltBufferPromise(xterm, store, this._log.bind(this)); + const idlePollInterval = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdlePollInterval) ?? 1000; + // Wait for the terminal to idle before executing the command this._log('Waiting for idle'); - await waitForIdle(this._instance.onData, 1000); + await waitForIdle(this._instance.onData, idlePollInterval); if (token.isCancellationRequested) { throw new CancellationError(); } - setupRecreatingStartMarker( + const markerRecreation = setupRecreatingStartMarker( xterm, this._startMarker, m => this._onDidCreateStartMarker.fire(m), @@ -77,12 +82,45 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS // is used as sending ctrl+c before a shell is initialized (eg. PSReadLine) can result // in failure (https://github.com/microsoft/vscode/issues/258989) this._log(`Executing command line \`${commandLine}\``); + markerRecreation.dispose(); + const startLine = this._startMarker.value?.line; this._instance.sendText(commandLine, true); + // Wait for the cursor to move past the command line before + // starting idle detection. Without this, the idle poll may + // resolve immediately on the existing prompt if the shell + // hasn't started processing the command yet. + if (startLine !== undefined) { + this._log('Waiting for cursor to move past start line'); + const cursorMovedPromise = new Promise(resolve => { + const check = () => { + const buffer = xterm.raw.buffer.active; + const cursorLine = buffer.baseY + buffer.cursorY; + if (cursorLine > startLine) { + resolve(); + } + }; + const listener = this._instance.onData(() => check()); + store.add(listener); + check(); + }); + + const cursorMoveTimeout = new Promise<'timeout'>(resolve => { + const handle = setTimeout(() => resolve('timeout'), 5000); + store.add({ dispose: () => clearTimeout(handle) }); + }); + + const raceResult = await Promise.race([cursorMovedPromise, cursorMoveTimeout]); + if (raceResult === 'timeout') { + this._log('Cursor did not move past start line before timeout, proceeding with idle detection'); + } + } + + // Assume the command is done when it's idle this._log('Waiting for idle with prompt heuristics'); const promptResultOrAltBuffer = await Promise.race([ - waitForIdleWithPromptHeuristics(this._instance.onData, this._instance, 1000, 10000), + waitForIdleWithPromptHeuristics(this._instance.onData, this._instance, idlePollInterval, idlePollInterval * 10), alternateBufferPromise.then(() => 'alternateBuffer' as const) ]); if (promptResultOrAltBuffer === 'alternateBuffer') { @@ -109,10 +147,24 @@ export class NoneExecuteStrategy extends Disposable implements ITerminalExecuteS try { output = xterm.getContentsAsText(this._startMarker.value, endMarker); this._log('Fetched output via markers'); + + // The marker-based output includes the command echo (the line where the + // command was typed) and the next prompt line. Strip them to isolate + // only the actual command output. The first line always contains the + // command echo (since the start marker is placed at the cursor before + // sendText), and trailing lines that look like shell prompts are removed. + if (output !== undefined) { + output = stripCommandEchoAndPrompt(output, commandLine, this._log.bind(this)); + } } catch { this._log('Failed to fetch output via markers'); additionalInformationLines.push('Failed to retrieve command output'); } + + if (output !== undefined && output.trim().length === 0) { + additionalInformationLines.push('Command produced no output'); + } + return { output, additionalInformation: additionalInformationLines.length > 0 ? additionalInformationLines.join('\n') : undefined, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts index b68f7a499d4..8d97c97f859 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/richExecuteStrategy.ts @@ -8,12 +8,15 @@ import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { isNumber } from '../../../../../../base/common/types.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { isCI } from '../../../../../../base/common/platform.js'; import type { ICommandDetectionCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js'; import { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { trackIdleOnPrompt, type ITerminalExecuteStrategy, type ITerminalExecuteStrategyResult } from './executeStrategy.js'; import type { IMarker as IXtermMarker } from '@xterm/xterm'; -import { createAltBufferPromise, setupRecreatingStartMarker } from './strategyHelpers.js'; +import { createAltBufferPromise, setupRecreatingStartMarker, stripCommandEchoAndPrompt } from './strategyHelpers.js'; +import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; /** * This strategy is used when the terminal has rich shell integration/command detection is @@ -32,6 +35,7 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS constructor( private readonly _instance: ITerminalInstance, private readonly _commandDetection: ICommandDetectionCapability, + @IConfigurationService private readonly _configurationService: IConfigurationService, @ITerminalLogService private readonly _logService: ITerminalLogService, ) { super(); @@ -48,6 +52,8 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS } const alternateBufferPromise = createAltBufferPromise(xterm, store, this._log.bind(this)); + const idlePollInterval = this._configurationService.getValue(TerminalChatAgentToolsSettingId.IdlePollInterval) ?? 1000; + const onDone = Promise.race([ Event.toPromise(this._commandDetection.onCommandFinished, store).then(e => { this._log('onDone via end event'); @@ -63,12 +69,12 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS this._log('onDone via terminal disposal'); return { type: 'disposal' } as const; }), - trackIdleOnPrompt(this._instance, 1000, store).then(() => { + trackIdleOnPrompt(this._instance, idlePollInterval, store, idlePollInterval).then(() => { this._log('onDone via idle prompt'); }), ]); - setupRecreatingStartMarker( + const markerRecreation = setupRecreatingStartMarker( xterm, this._startMarker, m => this._onDidCreateStartMarker.fire(m), @@ -78,6 +84,7 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS // Execute the command this._log(`Executing command line \`${commandLine}\``); + markerRecreation.dispose(); this._instance.runCommand(commandLine, true, commandId); // Wait for the terminal to idle @@ -109,13 +116,23 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS const commandOutput = finishedCommand?.getOutput(); if (commandOutput !== undefined) { this._log('Fetched output via finished command'); - output = commandOutput; + // On some platforms (e.g. Windows/PowerShell), shell integration + // markers can misfire and getOutput() includes the command echo. + // Strip it defensively — the function is a no-op when the output + // is already clean. + output = stripCommandEchoAndPrompt(commandOutput, commandLine, this._log.bind(this)); } } if (output === undefined) { try { output = xterm.getContentsAsText(this._startMarker.value, endMarker); this._log('Fetched output via markers'); + + // The marker-based output includes the command echo and trailing + // prompt lines. Strip them to isolate the actual command output. + if (output !== undefined) { + output = stripCommandEchoAndPrompt(output, commandLine, this._log.bind(this)); + } } catch { this._log('Failed to fetch output via markers'); additionalInformationLines.push('Failed to retrieve command output'); @@ -142,6 +159,11 @@ export class RichExecuteStrategy extends Disposable implements ITerminalExecuteS } private _log(message: string) { - this._logService.debug(`RunInTerminalTool#Rich: ${message}`); + const msg = `RunInTerminalTool#Rich: ${message}`; + if (isCI) { + this._logService.info(msg); + } else { + this._logService.debug(msg); + } } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts index 5c63b233ec2..c372a8c78af 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/executeStrategy/strategyHelpers.ts @@ -18,7 +18,7 @@ export function setupRecreatingStartMarker( fire: (marker: IXtermMarker | undefined) => void, store: DisposableStore, log?: (message: string) => void, -): void { +): IDisposable { const markerListener = new MutableDisposable(); const recreateStartMarker = () => { if (store.isDisposed) { @@ -43,6 +43,12 @@ export function setupRecreatingStartMarker( fire(undefined); })); store.add(startMarker); + + // Return a disposable that stops the recreation loop without clearing + // the current marker. Callers should dispose this before sending a + // command so that prompt re-renders (e.g. PSReadLine transient prompts) + // don't move the start marker past the command output. + return toDisposable(() => markerListener.dispose()); } export function createAltBufferPromise( @@ -70,3 +76,204 @@ export function createAltBufferPromise( return deferred.p; } + +/** + * Strips the command echo and trailing prompt lines from marker-based terminal output. + * Without shell integration (or when `getOutput()` is unavailable), `getContentsAsText` + * captures the entire terminal buffer between the start and end markers, which includes: + * 1. The command echo line (what `sendText` wrote) + * 2. The actual command output + * 3. The next shell prompt line(s) + * + * This function removes (1) and (3) to isolate the actual output. + */ +export function stripCommandEchoAndPrompt(output: string, commandLine: string, log?: (message: string) => void): string { + log?.(`stripCommandEchoAndPrompt input: output length=${output.length}, commandLine length=${commandLine.length}`); + + const result = _stripCommandEchoAndPromptOnce(output, commandLine, log); + + // After stripping the first command echo and trailing prompt, the remaining + // content may still contain the command re-echoed by the shell (prompt + echo). + // This happens when the terminal buffer captures both the raw sendText output + // and the shell's subsequent prompt + command echo. If the command appears again + // in the remaining text, strip it one more time. + if (result.trim().length > 0 && findCommandEcho(result, commandLine)) { + return _stripCommandEchoAndPromptOnce(result, commandLine, log); + } + + return result; +} + +function _stripCommandEchoAndPromptOnce(output: string, commandLine: string, log?: (message: string) => void): string { + // Strip leading lines that are part of the command echo using findCommandEcho. + // Allow suffix matching to handle partial command echoes from getOutput() + // where the prompt line is not included. + const echoResult = findCommandEcho(output, commandLine, /*allowSuffixMatch*/ true); + const lines = echoResult ? echoResult.linesAfter : output.split('\n'); + const startIndex = 0; + + // Use evidence from the prompt prefix (content before the command echo) + // to narrow down which trailing prompt patterns to check. + const promptBefore = echoResult?.contentBefore ?? ''; + const isUnixAt = /\w+@[\w.-]+:/.test(promptBefore); + const isUnixHost = !isUnixAt && /[\w.-]+:\S/.test(promptBefore); + const isUnix = isUnixAt || isUnixHost; + const isPowerShell = /^PS\s/i.test(promptBefore); + const isCmd = !isPowerShell && /^[A-Z]:\\/.test(promptBefore); + const isStarship = /\u276f/.test(promptBefore); + const isPython = />>>/.test(promptBefore); + const knownPrompt = isUnix || isPowerShell || isCmd || isStarship || isPython; + + // Strip trailing lines that are part of the next shell prompt. Prompts may + // span multiple lines due to terminal column wrapping. We strip from the + // bottom any line that matches a known prompt pattern. Patterns are + // intentionally anchored and specific to avoid stripping legitimate output + // that happens to end with characters like $, #, %, or >. + let endIndex = lines.length; + let trailingStrippedCount = 0; + const maxTrailingPromptLines = 2; + while (endIndex > startIndex) { + const line = lines[endIndex - 1].trimEnd(); + if (line.length === 0) { + endIndex--; + continue; + } + if (trailingStrippedCount >= maxTrailingPromptLines) { + break; + } + + // Complete (self-contained) prompt patterns: these have a recognizable + // prefix and a trailing marker ($, #, >). After stripping one complete + // prompt line, stop — lines above it are command output, not wrapped + // prompt continuation lines. + const isCompletePrompt = + // Bash/zsh: user@host:path ending with $ or # + // e.g., "user@host:~/src $ " or "root@server:/var/log# " + ((!knownPrompt || isUnixAt) && /^\s*\w+@[\w.-]+:.*[#$]\s*$/.test(line)) || + // hostname:path user$ or hostname:path user# + // e.g., "dsm12-be220-abc:testWorkspace runner$" + ((!knownPrompt || isUnixHost) && /^\s*[\w.-]+:\S.*\s\w+[#$]\s*$/.test(line)) || + // PowerShell: PS C:\path> + ((!knownPrompt || isPowerShell) && /^PS\s+[A-Z]:\\.*>\s*$/.test(line)) || + // Windows cmd: C:\path> + ((!knownPrompt || isCmd) && /^[A-Z]:\\.*>\s*$/.test(line)) || + // Starship prompt character + // allow-any-unicode-next-line + ((!knownPrompt || isStarship) && /\u276f\s*$/.test(line)) || + // Python REPL + ((!knownPrompt || isPython) && /^>>>\s*$/.test(line)); + + // Fragment/partial prompt patterns: these represent pieces of a prompt + // that wraps across multiple terminal lines due to column width. + const isPromptFragment = + // Wrapped fragment ending with $ or # (e.g. "er$", "ts/testWorkspace$") + ((!knownPrompt || isUnix) && /^\s*[\w/.-]+[#$]\s*$/.test(line)) || + // Bracketed prompt start: [ hostname:/path or [ user@host:/path + // e.g., "[ alex@MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test" + // e.g., "[W007DV9PF9-1:~/vss/_work/1/s/extensions/vscode-api-tests/testWorkspace] cloudte" + ((!knownPrompt || isUnix) && /^\[\s*[\w.-]+(@[\w.-]+)?:[~\/]/.test(line)) || + // Wrapped continuation: user@host:path or hostname:path (no trailing $) + // Only matched after we've already stripped a prompt fragment below. + // e.g., "cloudtest@host:/mnt/vss/.../vscode-api-tes" or "dsm12-abc:testWorkspace runn" + ((!knownPrompt || isUnix) && trailingStrippedCount > 0 && /^\s*[\w][-\w.]*(@[\w.-]+)?:\S/.test(line)) || + // Bracketed prompt end: ...] $ or ...] # + // e.g., "s/testWorkspace (main**) ] $ " + ((!knownPrompt || isUnix) && /\]\s*[#$]\s*$/.test(line)); + + if (isCompletePrompt) { + endIndex--; + trailingStrippedCount++; + break; // Complete prompt = nothing above can be prompt wrap + } else if (isPromptFragment) { + endIndex--; + trailingStrippedCount++; + } else { + break; + } + } + + const result = lines.slice(startIndex, endIndex).join('\n'); + log?.(`stripCommandEchoAndPrompt result: length=${result.length} (startIndex=${startIndex}, endIndex=${endIndex}, totalLines=${lines.length})`); + return result; +} + +export function findCommandEcho(output: string, commandLine: string, allowSuffixMatch?: boolean): { contentBefore: string; linesAfter: string[] } | undefined { + const trimmedCommand = commandLine.trim(); + if (trimmedCommand.length === 0) { + return undefined; + } + + // Strip newlines from the output so we can find the command as a + // contiguous substring even when terminal wrapping splits it across lines. + const { strippedOutput, indexMapping } = stripNewLinesAndBuildMapping(output); + const matchIndex = strippedOutput.indexOf(trimmedCommand); + + let matchEndInStripped: number; + let contentBefore: string; + + if (matchIndex !== -1) { + // Full command found in the output + contentBefore = strippedOutput.substring(0, matchIndex).trim(); + matchEndInStripped = matchIndex + trimmedCommand.length - 1; + } else if (allowSuffixMatch) { + // If the full command wasn't found, check if the output starts with a + // suffix of the command. This happens when getOutput() doesn't include + // the prompt line, so only the wrapped continuation of the command echo + // appears at the beginning of the output. + let suffixLen = 0; + for (let len = trimmedCommand.length - 1; len >= 1; len--) { + const suffix = trimmedCommand.substring(trimmedCommand.length - len); + if (strippedOutput.startsWith(suffix)) { + // Require the suffix to start mid-word in the command (not at + // a word boundary). A word-boundary match like "MARKER_123" + // matching the tail of "echo MARKER_123" is almost certainly + // actual output, not a wrapped command continuation. + const charBefore = trimmedCommand[trimmedCommand.length - len - 1]; + if (charBefore !== undefined && charBefore !== ' ' && charBefore !== '\t') { + suffixLen = len; + } + break; + } + } + if (suffixLen === 0) { + return undefined; + } + contentBefore = ''; + matchEndInStripped = suffixLen - 1; + } else { + return undefined; + } + + // Map the match end back to the original output position and determine + // which line it falls on to split linesAfter. + const originalEnd = indexMapping[matchEndInStripped]; + + const lines = output.split('\n'); + let echoEndLine = 0; + let offset = 0; + for (let i = 0; i < lines.length; i++) { + const lineEnd = offset + lines[i].length; // excludes the \n + if (offset <= originalEnd && originalEnd <= lineEnd) { + echoEndLine = i + 1; + break; + } + offset = lineEnd + 1; // +1 for the \n + } + + return { + contentBefore, + linesAfter: lines.slice(echoEndLine), + }; +} + +export function stripNewLinesAndBuildMapping(output: string): { strippedOutput: string; indexMapping: number[] } { + const indexMapping: number[] = []; + const strippedChars: string[] = []; + for (let i = 0; i < output.length; i++) { + if (output[i] !== '\n') { + strippedChars.push(output[i]); + indexMapping.push(i); + } + } + return { strippedOutput: strippedChars.join(''), indexMapping }; +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts index d7e2dea9c4e..5d1e90193ab 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; -import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { isNumber } from '../../../../../base/common/types.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; @@ -32,6 +32,7 @@ import { CreateAndRunTaskTool, CreateAndRunTaskToolData } from './tools/task/cre import { GetTaskOutputTool, GetTaskOutputToolData } from './tools/task/getTaskOutputTool.js'; import { RunTaskTool, RunTaskToolData } from './tools/task/runTaskTool.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { ITrustedDomainService } from '../../../url/common/trustedDomainService.js'; import { ITerminalSandboxService, TerminalSandboxService } from '../common/terminalSandboxService.js'; // #region Services @@ -75,64 +76,102 @@ class OutputLocationMigrationContribution extends Disposable implements IWorkben } registerWorkbenchContribution2(OutputLocationMigrationContribution.ID, OutputLocationMigrationContribution, WorkbenchPhase.Eventually); -class ChatAgentToolsContribution extends Disposable implements IWorkbenchContribution { +export class ChatAgentToolsContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'terminal.chatAgentTools'; + private readonly _runInTerminalToolRegistration = this._register(new MutableDisposable()); + private _runInTerminalToolRegistrationVersion = 0; + constructor( - @IInstantiationService instantiationService: IInstantiationService, - @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ITrustedDomainService private readonly _trustedDomainService: ITrustedDomainService, ) { super(); // #region Terminal - const confirmTerminalCommandTool = instantiationService.createInstance(ConfirmTerminalCommandTool); - this._register(toolsService.registerTool(ConfirmTerminalCommandToolData, confirmTerminalCommandTool)); - const getTerminalOutputTool = instantiationService.createInstance(GetTerminalOutputTool); - this._register(toolsService.registerTool(GetTerminalOutputToolData, getTerminalOutputTool)); - this._register(toolsService.executeToolSet.addTool(GetTerminalOutputToolData)); + const confirmTerminalCommandTool = _instantiationService.createInstance(ConfirmTerminalCommandTool); + this._register(_toolsService.registerTool(ConfirmTerminalCommandToolData, confirmTerminalCommandTool)); + const getTerminalOutputTool = _instantiationService.createInstance(GetTerminalOutputTool); + this._register(_toolsService.registerTool(GetTerminalOutputToolData, getTerminalOutputTool)); + this._register(_toolsService.executeToolSet.addTool(GetTerminalOutputToolData)); - const awaitTerminalTool = instantiationService.createInstance(AwaitTerminalTool); - this._register(toolsService.registerTool(AwaitTerminalToolData, awaitTerminalTool)); - this._register(toolsService.executeToolSet.addTool(AwaitTerminalToolData)); + const awaitTerminalTool = _instantiationService.createInstance(AwaitTerminalTool); + this._register(_toolsService.registerTool(AwaitTerminalToolData, awaitTerminalTool)); + this._register(_toolsService.executeToolSet.addTool(AwaitTerminalToolData)); - const killTerminalTool = instantiationService.createInstance(KillTerminalTool); - this._register(toolsService.registerTool(KillTerminalToolData, killTerminalTool)); - this._register(toolsService.executeToolSet.addTool(KillTerminalToolData)); + const killTerminalTool = _instantiationService.createInstance(KillTerminalTool); + this._register(_toolsService.registerTool(KillTerminalToolData, killTerminalTool)); + this._register(_toolsService.executeToolSet.addTool(KillTerminalToolData)); - instantiationService.invokeFunction(createRunInTerminalToolData).then(runInTerminalToolData => { - const runInTerminalTool = instantiationService.createInstance(RunInTerminalTool); - this._register(toolsService.registerTool(runInTerminalToolData, runInTerminalTool)); - this._register(toolsService.executeToolSet.addTool(runInTerminalToolData)); - }); + this._registerRunInTerminalTool(); - const getTerminalSelectionTool = instantiationService.createInstance(GetTerminalSelectionTool); - this._register(toolsService.registerTool(GetTerminalSelectionToolData, getTerminalSelectionTool)); + const getTerminalSelectionTool = _instantiationService.createInstance(GetTerminalSelectionTool); + this._register(_toolsService.registerTool(GetTerminalSelectionToolData, getTerminalSelectionTool)); - const getTerminalLastCommandTool = instantiationService.createInstance(GetTerminalLastCommandTool); - this._register(toolsService.registerTool(GetTerminalLastCommandToolData, getTerminalLastCommandTool)); + const getTerminalLastCommandTool = _instantiationService.createInstance(GetTerminalLastCommandTool); + this._register(_toolsService.registerTool(GetTerminalLastCommandToolData, getTerminalLastCommandTool)); - this._register(toolsService.readToolSet.addTool(GetTerminalSelectionToolData)); - this._register(toolsService.readToolSet.addTool(GetTerminalLastCommandToolData)); + this._register(_toolsService.readToolSet.addTool(GetTerminalSelectionToolData)); + this._register(_toolsService.readToolSet.addTool(GetTerminalLastCommandToolData)); // #endregion // #region Tasks - const runTaskTool = instantiationService.createInstance(RunTaskTool); - this._register(toolsService.registerTool(RunTaskToolData, runTaskTool)); + const runTaskTool = _instantiationService.createInstance(RunTaskTool); + this._register(_toolsService.registerTool(RunTaskToolData, runTaskTool)); - const getTaskOutputTool = instantiationService.createInstance(GetTaskOutputTool); - this._register(toolsService.registerTool(GetTaskOutputToolData, getTaskOutputTool)); + const getTaskOutputTool = _instantiationService.createInstance(GetTaskOutputTool); + this._register(_toolsService.registerTool(GetTaskOutputToolData, getTaskOutputTool)); - const createAndRunTaskTool = instantiationService.createInstance(CreateAndRunTaskTool); - this._register(toolsService.registerTool(CreateAndRunTaskToolData, createAndRunTaskTool)); - this._register(toolsService.executeToolSet.addTool(RunTaskToolData)); - this._register(toolsService.executeToolSet.addTool(CreateAndRunTaskToolData)); - this._register(toolsService.readToolSet.addTool(GetTaskOutputToolData)); + const createAndRunTaskTool = _instantiationService.createInstance(CreateAndRunTaskTool); + this._register(_toolsService.registerTool(CreateAndRunTaskToolData, createAndRunTaskTool)); + this._register(_toolsService.executeToolSet.addTool(RunTaskToolData)); + this._register(_toolsService.executeToolSet.addTool(CreateAndRunTaskToolData)); + this._register(_toolsService.readToolSet.addTool(GetTaskOutputToolData)); // #endregion + + // Re-register run_in_terminal tool when sandbox-related settings change, + // so the tool description and input schema stay in sync with the current + // sandbox state. + this._register(this._configurationService.onDidChangeConfiguration(e => { + if ( + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled) || + e.affectsConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) + ) { + this._registerRunInTerminalTool(); + } + })); + this._register(this._trustedDomainService.onDidChangeTrustedDomains(() => { + this._registerRunInTerminalTool(); + })); + } + + private _runInTerminalTool: RunInTerminalTool | undefined; + + private _registerRunInTerminalTool(): void { + const version = ++this._runInTerminalToolRegistrationVersion; + this._instantiationService.invokeFunction(createRunInTerminalToolData).then(runInTerminalToolData => { + if (this._store.isDisposed || version !== this._runInTerminalToolRegistrationVersion) { + return; + } + if (!this._runInTerminalTool) { + this._runInTerminalTool = this._register(this._instantiationService.createInstance(RunInTerminalTool)); + } + // Dispose old registration first so registerToolData doesn't throw + // "already registered" for the same tool ID. + this._runInTerminalToolRegistration.value = undefined; + const store = new DisposableStore(); + store.add(this._toolsService.registerToolData(runInTerminalToolData)); + store.add(this._toolsService.registerToolImplementation(runInTerminalToolData.id, this._runInTerminalTool)); + store.add(this._toolsService.executeToolSet.addTool(runInTerminalToolData)); + this._runInTerminalToolRegistration.value = store; + }); } } registerWorkbenchContribution2(ChatAgentToolsContribution.ID, ChatAgentToolsContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts index 599f0f93b8c..b4fab0650d3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineAnalyzer.ts @@ -45,6 +45,7 @@ export interface ICommandLineAnalyzerOptions { treeSitterLanguage: TreeSitterCommandParserLanguage; terminalToolSessionId: string; chatSessionResource: URI | undefined; + requiresUnsandboxConfirmation?: boolean; } export interface ICommandLineAnalyzerResult { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts index 39ed6639c52..7d75a0d38cb 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineAnalyzer/commandLineSandboxAnalyzer.ts @@ -22,7 +22,7 @@ export class CommandLineSandboxAnalyzer extends Disposable implements ICommandLi } return { isAutoApproveAllowed: true, - forceAutoApproval: true, + forceAutoApproval: _options.requiresUnsandboxConfirmation ? false : true, }; } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLinePresenter/sandboxedCommandLinePresenter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLinePresenter/sandboxedCommandLinePresenter.ts index d2b094e6aec..06c7776bf46 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLinePresenter/sandboxedCommandLinePresenter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLinePresenter/sandboxedCommandLinePresenter.ts @@ -8,8 +8,9 @@ import type { ICommandLinePresenter, ICommandLinePresenterOptions, ICommandLineP /** * Command line presenter for sandboxed commands. - * Extracts the original command from the sandbox wrapper for cleaner display, - * while the actual sandboxed command runs unchanged. + * Returns the display form of the command (provided via {@link ICommandLineRewriterResult.forDisplay} + * from the rewriter pipeline) for cleaner presentation, while the actual sandboxed command runs + * unchanged. */ export class SandboxedCommandLinePresenter implements ICommandLinePresenter { constructor( @@ -22,7 +23,7 @@ export class SandboxedCommandLinePresenter implements ICommandLinePresenter { return undefined; } return { - commandLine: options.commandLine.original ?? options.commandLine.forDisplay, + commandLine: options.commandLine.forDisplay, processOtherPresenters: true }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts index 3a3c4a3c955..2c1dbc6acc0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts @@ -17,6 +17,7 @@ export interface ICommandLineRewriterOptions { cwd: URI | undefined; shell: string; os: OperatingSystem; + requestUnsandboxedExecution?: boolean; } export interface ICommandLineRewriterResult { @@ -24,4 +25,5 @@ export interface ICommandLineRewriterResult { reasoning: string; //for scenarios where we want to show a different command in the chat UI than what is actually run in the terminal forDisplay?: string; + isSandboxWrapped?: boolean; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts index 030afe5a76a..db6e9610526 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts @@ -15,6 +15,10 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi } async rewrite(options: ICommandLineRewriterOptions): Promise { + if (options.requestUnsandboxedExecution) { + return undefined; + } + if (!(await this._sandboxService.isEnabled())) { return undefined; } @@ -30,7 +34,8 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi return { rewritten: wrappedCommand, reasoning: 'Wrapped command for sandbox execution', - forDisplay: options.commandLine, // show the command that is passed as input. In this case, the output from CommandLinePreventHistoryRewriter + forDisplay: options.commandLine, // show the command that is passed as input (after prior rewrites like cd prefix stripping) + isSandboxWrapped: true, }; } } 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 43fd4311221..e3b8bc31ed3 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 @@ -1069,8 +1069,14 @@ const taskFinishMessages = [ // "Press any key to close the terminal." (with exit code placeholder removed for matching) localize('exitCode.closeTerminal', "Press any key to close the terminal."), localize('exitCode.reuseTerminal', "Press any key to close the terminal."), + // Punctuation variant: "The terminal will be reused by tasks. Press any key to close." + localize('reuseTerminal.pressClose', "The terminal will be reused by tasks. Press any key to close."), ]; +const normalizedTaskFinishMessages = taskFinishMessages.map(msg => + msg.replace(/[\s.,:;!?"'`()[\]{}<>\-_/\\]+/g, '').toLowerCase() +); + /** * Detects VS Code's specific task completion messages like: * - "Press any key to close the terminal." @@ -1080,9 +1086,9 @@ const taskFinishMessages = [ * that can split words across lines (e.g., "t\no" instead of "to"). */ export function detectsVSCodeTaskFinishMessage(cursorLine: string): boolean { - // Remove all whitespace to handle line wrapping that splits words mid-word - const normalized = cursorLine.replace(/\s/g, '').toLowerCase(); - return taskFinishMessages.some(msg => normalized.includes(msg.replace(/\s/g, '').toLowerCase())); + // Be tolerant to whitespace, punctuation, and line wrapping that can split words mid-word. + const compact = cursorLine.replace(/[\s.,:;!?"'`()[\]{}<>\-_/\\]+/g, '').toLowerCase(); + return normalizedTaskFinishMessages.some(msg => compact.includes(msg)); } /** diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/outputAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/outputAnalyzer.ts index 0490f5e332f..dcb1580e466 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/outputAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/outputAnalyzer.ts @@ -7,6 +7,7 @@ export interface IOutputAnalyzerOptions { readonly exitCode: number | undefined; readonly exitResult: string; readonly commandLine: string; + readonly isSandboxWrapped: boolean; } export interface IOutputAnalyzer { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts index 65a0a565068..1093ebfcabd 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts @@ -58,6 +58,10 @@ export const ConfirmTerminalCommandToolData: IToolData = { }; export class ConfirmTerminalCommandTool extends RunInTerminalTool { + override get _enableCommandLineSandboxRewriting() { + return false; + } + override async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { const preparedInvocation = await super.prepareToolInvocation(context, token); if (preparedInvocation) { 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 e8fa387a450..4d54022acb7 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -72,15 +72,16 @@ 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'; +import { ITerminalSandboxService, type ITerminalSandboxResolvedNetworkDomains } from '../../common/terminalSandboxService.js'; // #region Tool data const TOOL_REFERENCE_NAME = 'runInTerminal'; const LEGACY_TOOL_REFERENCE_FULL_NAMES = ['runCommands/runInTerminal']; -function createPowerShellModelDescription(shell: string): string { +function createPowerShellModelDescription(shell: string, isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { const isWinPwsh = isWindowsPowerShell(shell); - return [ + const parts = [ `This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`, '', 'Command Execution:', @@ -105,6 +106,13 @@ function createPowerShellModelDescription(shell: string): string { '- For long-running tasks (e.g., servers), set isBackground=true', '- Returns a terminal ID for checking status and runtime later', '- Use Start-Job for background PowerShell jobs', + ]; + + if (isSandboxEnabled) { + parts.push(...createSandboxLines(networkDomains)); + } + + parts.push( '', 'Output Management:', '- Output is automatically truncated if longer than 60KB to prevent context overflow', @@ -120,10 +128,39 @@ function createPowerShellModelDescription(shell: string): string { '- Use Test-Path to check file/directory existence', '- Be specific with Select-Object properties to avoid excessive output', '- Avoid printing credentials unless absolutely required', - ].join('\n'); + ); + + return parts.join('\n'); } -const genericDescription = ` +function createSandboxLines(networkDomains?: ITerminalSandboxResolvedNetworkDomains): string[] { + const lines = [ + '', + 'Sandboxing:', + '- ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default', + '- When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided', + '- Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox', + '- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true. Do NOT ask the user for permission — setting this flag automatically shows a confirmation prompt to the user', + '- Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. \'Operation not permitted\' errors, network failures, or file access errors, etc', + '- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason explaining why the command needs unsandboxed access', + ]; + if (networkDomains) { + const deniedSet = new Set(networkDomains.deniedDomains); + const effectiveAllowed = networkDomains.allowedDomains.filter(d => !deniedSet.has(d)); + if (effectiveAllowed.length === 0) { + lines.push('- All network access is blocked in the sandbox'); + } else { + lines.push(`- Only the following domains are accessible in the sandbox (all other network access is blocked): ${effectiveAllowed.join(', ')}`); + } + if (networkDomains.deniedDomains.length > 0) { + lines.push(`- The following domains are explicitly blocked in the sandbox: ${networkDomains.deniedDomains.join(', ')}`); + } + } + return lines; +} + +function createGenericDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { + const parts = [` Command Execution: - Use && to chain simple commands on one line - Prefer pipelines | over temporary files for data flow @@ -143,7 +180,13 @@ Program Execution: Background Processes: - For long-running tasks (e.g., servers), set isBackground=true -- Returns a terminal ID for checking status and runtime later +- Returns a terminal ID for checking status and runtime later`]; + + if (isSandboxEnabled) { + parts.push(createSandboxLines(networkDomains).join('\n')); + } + + parts.push(` Output Management: - Output is automatically truncated if longer than 60KB to prevent context overflow @@ -155,22 +198,25 @@ Best Practices: - Quote variables: "$var" instead of $var to handle spaces - Use find with -exec or xargs for file operations - Be specific with commands to avoid excessive output -- Avoid printing credentials unless absolutely required`; +- Avoid printing credentials unless absolutely required`); -function createBashModelDescription(): string { + return parts.join(''); +} + +function createBashModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent bash terminal session, preserving environment variables, working directory, and other context across multiple commands.', - genericDescription, + createGenericDescription(isSandboxEnabled, networkDomains), '- Use [[ ]] for conditional tests instead of [ ]', '- Prefer $() over backticks for command substitution', '- Use set -e at start of complex commands to exit on errors' ].join('\n'); } -function createZshModelDescription(): string { +function createZshModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent zsh terminal session, preserving environment variables, working directory, and other context across multiple commands.', - genericDescription, + createGenericDescription(isSandboxEnabled, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use [[ ]] for conditional tests instead of [ ]', @@ -180,10 +226,10 @@ function createZshModelDescription(): string { ].join('\n'); } -function createFishModelDescription(): string { +function createFishModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent fish terminal session, preserving environment variables, working directory, and other context across multiple commands.', - genericDescription, + createGenericDescription(isSandboxEnabled, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use test expressions for conditionals (no [[ ]] syntax)', @@ -198,20 +244,26 @@ export async function createRunInTerminalToolData( accessor: ServicesAccessor ): Promise { const instantiationService = accessor.get(IInstantiationService); + const terminalSandboxService = accessor.get(ITerminalSandboxService); const profileFetcher = instantiationService.createInstance(TerminalProfileFetcher); - const shell = await profileFetcher.getCopilotShell(); - const os = await profileFetcher.osBackend; + const [shell, os, isSandboxEnabled] = await Promise.all([ + profileFetcher.getCopilotShell(), + profileFetcher.osBackend, + terminalSandboxService.isEnabled(), + ]); + + const networkDomains = isSandboxEnabled ? terminalSandboxService.getResolvedNetworkDomains() : undefined; let modelDescription: string; if (shell && os && isPowerShell(shell, os)) { - modelDescription = createPowerShellModelDescription(shell); + modelDescription = createPowerShellModelDescription(shell, isSandboxEnabled, networkDomains); } else if (shell && os && isZsh(shell, os)) { - modelDescription = createZshModelDescription(); + modelDescription = createZshModelDescription(isSandboxEnabled, networkDomains); } else if (shell && os && isFish(shell, os)) { - modelDescription = createFishModelDescription(); + modelDescription = createFishModelDescription(isSandboxEnabled, networkDomains); } else { - modelDescription = createBashModelDescription(); + modelDescription = createBashModelDescription(isSandboxEnabled, networkDomains); } return { @@ -222,7 +274,7 @@ export async function createRunInTerminalToolData( modelDescription, userDescription: localize('runInTerminalTool.userDescription', 'Run commands in the terminal'), source: ToolDataSource.Internal, - icon: Codicon.terminal, + icon: isSandboxEnabled ? Codicon.terminalSecure : Codicon.terminal, inputSchema: { type: 'object', properties: { @@ -244,8 +296,18 @@ export async function createRunInTerminalToolData( }, timeout: { type: 'number', - description: 'An optional timeout in milliseconds. When provided, the tool will stop tracking the command after this duration and return the output collected so far. Be conservative with the timeout duration, give enough time that the command would complete on a low-end machine. Use 0 for no timeout. If it\'s not clear how long the command will take then use 0 to avoid prematurely terminating it, never guess too low.', + description: 'An optional timeout in milliseconds. When provided, the tool will stop tracking the command after this duration and return the output collected so far with a timeout indicator. Be conservative with the timeout duration, give enough time that the command would complete on a low-end machine. Use 0 for no timeout. If it\'s not clear how long the command will take then use 0 to avoid prematurely terminating it, never guess too low.', }, + ...isSandboxEnabled ? { + requestUnsandboxedExecution: { + type: 'boolean', + description: 'Request that this command run outside the terminal sandbox. Only set this when the command clearly needs unsandboxed access. The user will be prompted before the command runs unsandboxed.' + }, + requestUnsandboxedExecutionReason: { + type: 'string', + description: 'A short explanation of why this command must run outside the terminal sandbox. Only provide this when requestUnsandboxedExecution is true.' + }, + } : {}, }, required: [ 'command', @@ -279,6 +341,8 @@ export interface IRunInTerminalInputParams { goal: string; isBackground: boolean; timeout?: number; + requestUnsandboxedExecution?: boolean; + requestUnsandboxedExecutionReason?: string; } /** @@ -367,7 +431,16 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { RunInTerminalTool._activeExecutions.delete(id); return true; } - + /** + * Controls whether this tool wires up sandbox-specific command-line + * behavior, including both the {@link CommandLineSandboxRewriter} and the + * {@link CommandLineSandboxAnalyzer}. This is separate from + * ITerminalSandboxService.isEnabled(), which reports whether terminal + * sandboxing is currently enabled for the running window. + */ + protected get _enableCommandLineSandboxRewriting() { + return true; + } constructor( @IChatService protected readonly _chatService: IChatService, @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -381,6 +454,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { @ITerminalChatService private readonly _terminalChatService: ITerminalChatService, @ITerminalLogService private readonly _logService: ITerminalLogService, @ITerminalService private readonly _terminalService: ITerminalService, + @ITerminalSandboxService private readonly _terminalSandboxService: ITerminalSandboxService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @IAgentSessionsService private readonly _agentSessionsService: IAgentSessionsService, @@ -398,14 +472,20 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._commandLineRewriters = [ this._register(this._instantiationService.createInstance(CommandLineCdPrefixRewriter)), this._register(this._instantiationService.createInstance(CommandLinePwshChainOperatorRewriter, this._treeSitterCommandParser)), - this._register(this._instantiationService.createInstance(CommandLinePreventHistoryRewriter)), - this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter)), ]; + if (this._enableCommandLineSandboxRewriting) { + this._commandLineRewriters.push(this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter))); + } + // PreventHistoryRewriter must be last so the leading space is applied to the final + // command, including any sandbox wrapping. + this._commandLineRewriters.push(this._register(this._instantiationService.createInstance(CommandLinePreventHistoryRewriter))); this._commandLineAnalyzers = [ this._register(this._instantiationService.createInstance(CommandLineFileWriteAnalyzer, this._treeSitterCommandParser, (message, args) => this._logService.info(`RunInTerminalTool#CommandLineFileWriteAnalyzer: ${message}`, args))), this._register(this._instantiationService.createInstance(CommandLineAutoApproveAnalyzer, this._treeSitterCommandParser, this._telemetry, (message, args) => this._logService.info(`RunInTerminalTool#CommandLineAutoApproveAnalyzer: ${message}`, args))), - this._register(this._instantiationService.createInstance(CommandLineSandboxAnalyzer)), ]; + if (this._enableCommandLineSandboxRewriting) { + this._commandLineAnalyzers.push(this._register(this._instantiationService.createInstance(CommandLineSandboxAnalyzer))); + } this._commandLinePresenters = [ this._instantiationService.createInstance(SandboxedCommandLinePresenter), new NodeCommandLinePresenter(), @@ -466,7 +546,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { instance = toolTerminal.instance; } } - const [os, shell, cwd] = await Promise.all([ + const [os, shell, cwd, isTerminalSandboxEnabled] = await Promise.all([ this._osBackend, this._profileFetcher.getCopilotShell(), (async () => { @@ -477,9 +557,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { cwd = workspaceFolder?.uri; } return cwd; - })() + })(), + this._terminalSandboxService.isEnabled() ]); const language = os === OperatingSystem.Windows ? 'pwsh' : 'sh'; + const requiresUnsandboxConfirmation = isTerminalSandboxEnabled && args.requestUnsandboxedExecution === true; const terminalToolSessionId = generateUuid(); // Generate a custom command ID to link the command between renderer and pty host @@ -487,16 +569,21 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let rewrittenCommand: string | undefined = args.command; let forDisplayCommand: string | undefined = undefined; + let isSandboxWrapped = false; for (const rewriter of this._commandLineRewriters) { const rewriteResult = await rewriter.rewrite({ commandLine: rewrittenCommand, cwd, shell, - os + os, + requestUnsandboxedExecution: requiresUnsandboxConfirmation, }); if (rewriteResult) { rewrittenCommand = rewriteResult.rewritten; - forDisplayCommand = rewriteResult.forDisplay; + forDisplayCommand = rewriteResult.forDisplay ?? forDisplayCommand; + if (rewriteResult.isSandboxWrapped) { + isSandboxWrapped = true; + } this._logService.info(`RunInTerminalTool: Command rewritten by ${rewriter.constructor.name}: ${rewriteResult.reasoning}`); } } @@ -509,10 +596,13 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { original: args.command, toolEdited: rewrittenCommand === args.command ? undefined : rewrittenCommand, forDisplay: forDisplayCommand ?? normalizeTerminalCommandForDisplay(rewrittenCommand ?? args.command), + isSandboxWrapped, }, cwd, language, isBackground: args.isBackground, + requestUnsandboxedExecution: requiresUnsandboxConfirmation, + requestUnsandboxedExecutionReason: args.requestUnsandboxedExecutionReason, }; // HACK: Exit early if there's an alternative recommendation, this is a little hacky but @@ -558,6 +648,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { treeSitterLanguage: isPowerShell(shell, os) ? TreeSitterCommandParserLanguage.PowerShell : TreeSitterCommandParserLanguage.Bash, terminalToolSessionId, chatSessionResource, + requiresUnsandboxConfirmation, }; const commandLineAnalyzerResults = await Promise.all(this._commandLineAnalyzers.map(e => e.analyze(commandLineAnalyzerOptions))); @@ -667,6 +758,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } } + if (requiresUnsandboxConfirmation) { + confirmationTitle = args.isBackground + ? localize('runInTerminal.unsandboxed.background', "Run `{0}` command outside the sandbox in background?", shellType) + : localize('runInTerminal.unsandboxed', "Run `{0}` command outside the sandbox?", shellType); + } + // 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); @@ -695,10 +792,20 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // If forceConfirmationReason is set, always show confirmation regardless of auto-approval const shouldShowConfirmation = (!isFinalAutoApproved && !isSessionAutoApproved) || context.forceConfirmationReason !== undefined; + const confirmationMessage = requiresUnsandboxConfirmation + ? new MarkdownString(localize( + 'runInTerminal.unsandboxed.confirmationMessage', + "Explanation: {0}\n\nGoal: {1}\n\nReason for leaving the sandbox: {2}", + args.explanation, + args.goal, + args.requestUnsandboxedExecutionReason || localize('runInTerminal.unsandboxed.confirmationMessage.defaultReason', "The model indicated that this command needs unsandboxed access.") + )) + : new MarkdownString(localize('runInTerminal.confirmationMessage', "Explanation: {0}\n\nGoal: {1}", args.explanation, args.goal)); const confirmationMessages = shouldShowConfirmation ? { title: confirmationTitle, - message: new MarkdownString(localize('runInTerminal.confirmationMessage', "Explanation: {0}\n\nGoal: {1}", args.explanation, args.goal)), + message: confirmationMessage, disclaimer, + allowAutoConfirm: undefined, terminalCustomActions: customActions, } : undefined; @@ -707,12 +814,17 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ? rawDisplayCommand.substring(0, 77) + '...' : rawDisplayCommand; const escapedDisplayCommand = escapeMarkdownSyntaxTokens(displayCommand); - const invocationMessage = args.isBackground - ? new MarkdownString(localize('runInTerminal.invocation.background', "Running `{0}` in background", escapedDisplayCommand)) - : new MarkdownString(localize('runInTerminal.invocation', "Running `{0}`", escapedDisplayCommand)); + const invocationMessage = toolSpecificData.commandLine.isSandboxWrapped + ? args.isBackground + ? new MarkdownString(localize('runInTerminal.invocation.sandbox.background', "Running `{0}` in sandbox in background", escapedDisplayCommand)) + : new MarkdownString(localize('runInTerminal.invocation.sandbox', "Running `{0}` in sandbox", escapedDisplayCommand)) + : args.isBackground + ? new MarkdownString(localize('runInTerminal.invocation.background', "Running `{0}` in background", escapedDisplayCommand)) + : new MarkdownString(localize('runInTerminal.invocation', "Running `{0}`", escapedDisplayCommand)); return { invocationMessage, + icon: toolSpecificData.commandLine.isSandboxWrapped ? Codicon.terminalSecure : Codicon.terminal, confirmationMessages, toolSpecificData, }; @@ -778,9 +890,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const didToolEditCommand = ( !didUserEditCommand && toolSpecificData.commandLine.toolEdited !== undefined && - toolSpecificData.commandLine.toolEdited !== toolSpecificData.commandLine.original + toolSpecificData.commandLine.toolEdited !== toolSpecificData.commandLine.original && + // Only consider it a meaningful edit if the display form also differs from the + // original. Cosmetic rewrites like prepending a space to prevent shell history + // should not trigger the "tool simplified the command" note. + normalizeTerminalCommandForDisplay(toolSpecificData.commandLine.toolEdited).trim() !== normalizeTerminalCommandForDisplay(toolSpecificData.commandLine.original).trim() ); + const didSandboxWrapCommand = toolSpecificData.commandLine.isSandboxWrapped === true; + if (token.isCancellationRequested) { throw new CancellationError(); } @@ -826,6 +944,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let didTimeout = false; let didMoveToBackground = args.isBackground; let timeoutPromise: CancelablePromise | undefined; + let timeoutRacePromise: Promise<{ type: 'timeout' }> | undefined; let outputMonitor: OutputMonitor | undefined; let pollingResult: IPollingResult & { pollDurationMs: number } | undefined; const executeCancellation = store.add(new CancellationTokenSource(token)); @@ -836,12 +955,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const shouldEnforceTimeout = this._configurationService.getValue(TerminalChatAgentToolsSettingId.EnforceTimeoutFromModel) === true; if (shouldEnforceTimeout) { timeoutPromise = timeout(timeoutValue); - timeoutPromise.then(() => { - if (!executeCancellation.token.isCancellationRequested) { - didTimeout = true; - executeCancellation.cancel(); - } - }); + timeoutRacePromise = timeoutPromise.then( + () => ({ type: 'timeout' as const }) + ).catch(() => ({ type: 'timeout' as const })); } } @@ -880,7 +996,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (toolTerminal.shellIntegrationQuality === ShellIntegrationQuality.None) { toolResultMessage = '$(info) Enable [shell integration](https://code.visualstudio.com/docs/terminal/shell-integration) to improve command detection'; } - this._logService.debug(`RunInTerminalTool: Using \`${execution.strategy.type}\` execute strategy for command \`${command}\``); + this._logService.info(`RunInTerminalTool: Using \`${execution.strategy.type}\` execute strategy for command \`${command}\``); store.add(execution); RunInTerminalTool._activeExecutions.set(termId, execution); @@ -924,19 +1040,32 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { state.timestamp = state.timestamp ?? timingStart; toolSpecificData.terminalCommandState = state; + // if the command is wrapped in a sandbox, we will not show the command. This is because the sandbox may add additional commands that are not relevant to the user, and the output will provide more context about what is running. let resultText = ( - didUserEditCommand - ? `Note: The user manually edited the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` - : didToolEditCommand - ? `Note: The tool simplified the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` - : `Command is running in terminal with ID=${termId}` + didSandboxWrapCommand ? `Command is now running in terminal with ID=${termId}` + : didUserEditCommand + ? `Note: The user manually edited the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` + : didToolEditCommand + ? `Note: The tool simplified the command to \`${command}\`, and that command is now running in terminal with ID=${termId}` + : `Command is running in terminal with ID=${termId}` ); + const backgroundOutput = pollingResult?.modelOutputEvalResponse ?? pollingResult?.output; + const outputAnalyzerMessage = backgroundOutput + ? await this._getOutputAnalyzerMessage(undefined, backgroundOutput, command, didSandboxWrapCommand) + : undefined; if (pollingResult && pollingResult.modelOutputEvalResponse) { - resultText += `\n\ The command became idle with output:\n${pollingResult.modelOutputEvalResponse}`; + resultText += `\n\ The command became idle with output:\n`; + if (outputAnalyzerMessage) { + resultText += `${outputAnalyzerMessage}\n`; + } + resultText += pollingResult.modelOutputEvalResponse; } else if (pollingResult) { - resultText += `\n\ The command is still running, with output:\n${pollingResult.output}`; + resultText += `\n\ The command is still running, with output:\n`; + if (outputAnalyzerMessage) { + resultText += `${outputAnalyzerMessage}\n`; + } + resultText += pollingResult.output; } - const endCwd = await toolTerminal.instance.getCwdResource(); return { toolMetadata: { @@ -951,10 +1080,14 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } else { // Foreground mode: race execution completion against continue in background - const raceResult = await Promise.race([ + const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' }>[] = [ executionPromise.then(result => ({ type: 'completed' as const, result })), continueInBackgroundPromise.then(() => ({ type: 'background' as const })) - ]); + ]; + if (timeoutRacePromise) { + raceCandidates.push(timeoutRacePromise); + } + const raceResult = await Promise.race(raceCandidates); if (raceResult.type === 'background') { // Moved to background - execution continues running, just return current output @@ -963,6 +1096,18 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const backgroundOutput = execution.getOutput(); outputLineCount = backgroundOutput ? count(backgroundOutput.trim(), '\n') + 1 : 0; terminalResult = backgroundOutput; + } else if (raceResult.type === 'timeout') { + // Timeout reached - return partial output and keep terminal alive as background. + this._logService.debug(`RunInTerminalTool: Timeout reached, returning output collected so far`); + error = 'timeout'; + didTimeout = true; + didMoveToBackground = true; + toolTerminal.isBackground = true; + this._sessionTerminalAssociations.delete(chatSessionResource); + await this._associateProcessIdWithSession(toolTerminal.instance, chatSessionResource, termId, toolTerminal.shellIntegrationQuality, true); + const timeoutOutput = execution.getOutput(); + outputLineCount = timeoutOutput ? count(timeoutOutput.trim(), '\n') + 1 : 0; + terminalResult = timeoutOutput ?? ''; } else { const executeResult = raceResult.result; // Reset user input state after command execution completes @@ -1005,7 +1150,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { toolSpecificData.terminalCommandState = state; } - this._logService.debug(`RunInTerminalTool: Finished \`${execution.strategy.type}\` execute strategy with exitCode \`${executeResult.exitCode}\`, result.length \`${executeResult.output?.length}\`, error \`${executeResult.error}\``); + this._logService.info(`RunInTerminalTool: Finished \`${execution.strategy.type}\` execute strategy with exitCode \`${executeResult.exitCode}\`, result.length \`${executeResult.output?.length}\`, error \`${executeResult.error}\``); outputLineCount = executeResult.output === undefined ? 0 : count(executeResult.output.trim(), '\n') + 1; exitCode = executeResult.exitCode; error = executeResult.error; @@ -1026,6 +1171,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (didTimeout && e instanceof CancellationError) { this._logService.debug(`RunInTerminalTool: Timeout reached, returning output collected so far`); error = 'timeout'; + didMoveToBackground = true; + toolTerminal.isBackground = true; + this._sessionTerminalAssociations.delete(chatSessionResource); const timeoutOutput = getOutput(toolTerminal.instance, undefined); outputLineCount = timeoutOutput ? count(timeoutOutput.trim(), '\n') + 1 : 0; terminalResult = timeoutOutput ?? ''; @@ -1099,22 +1247,20 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } const resultText: string[] = []; - if (didUserEditCommand) { - resultText.push(`Note: The user manually edited the command to \`${command}\`, and this is the output of running that command instead:\n`); - } else if (didToolEditCommand) { - resultText.push(`Note: The tool simplified the command to \`${command}\`, and this is the output of running that command instead:\n`); - } - if (didMoveToBackground && !args.isBackground) { - resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); - } - let outputAnalyzerMessage: string | undefined; - for (const analyzer of this._outputAnalyzers) { - const message = await analyzer.analyze({ exitCode, exitResult: terminalResult, commandLine: command }); - if (message) { - outputAnalyzerMessage = message; - break; + if (!didSandboxWrapCommand) { + if (didUserEditCommand) { + resultText.push(`Note: The user manually edited the command to \`${command}\`, and this is the output of running that command instead:\n`); + } else if (didToolEditCommand) { + resultText.push(`Note: The tool simplified the command to \`${command}\`, and this is the output of running that command instead:\n`); + } + if (didMoveToBackground && !args.isBackground) { + resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); } } + if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { + resultText.push(`Note: Command timed out after ${timeoutValue}ms. Output collected so far is shown below and the command may still be running in terminal ID ${termId}.\n\n`); + } + const outputAnalyzerMessage = await this._getOutputAnalyzerMessage(exitCode, terminalResult, command, didSandboxWrapCommand); if (outputAnalyzerMessage) { resultText.push(`${outputAnalyzerMessage}\n`); } @@ -1131,6 +1277,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { exitCode: exitCode, id: termId, cwd: endCwd?.toString(), + timedOut: didTimeout || undefined, + timeoutMs: didTimeout ? timeoutValue : undefined, }, toolResultDetails: isError ? { input: command, @@ -1147,6 +1295,17 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } + private async _getOutputAnalyzerMessage(exitCode: number | undefined, exitResult: string, commandLine: string, isSandboxWrapped: boolean): Promise { + for (const analyzer of this._outputAnalyzers) { + const message = await analyzer.analyze({ exitCode, exitResult, commandLine, isSandboxWrapped }); + if (message) { + return message; + } + } + + return undefined; + } + private static readonly _maxImageFileSize = 5 * 1024 * 1024; /** @@ -1595,6 +1754,15 @@ export class TerminalProfileFetcher { }; } + // Force bash over sh as sh doesn't have shell integration + if (defaultProfile.path === '/bin/sh') { + return { + ...defaultProfile, + path: '/bin/bash', + profileName: 'bash', + }; + } + // Setting icon: undefined allows the system to use the default AI terminal icon (not overridden or removed) return { ...defaultProfile, icon: undefined }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts index adb2d2dd24e..8b1687f1d35 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sandboxOutputAnalyzer.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../../../nls.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { OperatingSystem } from '../../../../../../base/common/platform.js'; import { ITerminalSandboxService } from '../../common/terminalSandboxService.js'; import type { IOutputAnalyzer, IOutputAnalyzerOptions } from './outputAnalyzer.js'; import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; @@ -17,19 +17,50 @@ export class SandboxOutputAnalyzer extends Disposable implements IOutputAnalyzer } async analyze(options: IOutputAnalyzerOptions): Promise { - if (options.exitCode === undefined || options.exitCode === 0) { - return undefined; - } - if (!(await this._sandboxService.isEnabled())) { + if (!options.isSandboxWrapped) { return undefined; } - return localize( - 'runInTerminalTool.sandboxCommandFailed', - "Command failed while running in sandboxed mode. Use the command result to determine the scenario. If the issue is filesystem permissions, update allowWrite in {0} (Linux) or {1} (macOS). If the issue is domain/network related, add the required domains to {2}.allowedDomains.", - TerminalChatAgentToolsSettingId.TerminalSandboxLinuxFileSystem, - TerminalChatAgentToolsSettingId.TerminalSandboxMacFileSystem, - TerminalChatAgentToolsSettingId.TerminalSandboxNetwork - ); + const knownFailure = options.exitCode !== undefined && options.exitCode !== 0; + const suspectedFailure = !knownFailure && options.exitCode === undefined && this._outputLooksSandboxBlocked(options.exitResult); + + if (!knownFailure && !suspectedFailure) { + return undefined; + } + + const os = await this._sandboxService.getOS(); + const fileSystemSetting = os === OperatingSystem.Linux + ? TerminalChatAgentToolsSettingId.TerminalSandboxLinuxFileSystem + : TerminalChatAgentToolsSettingId.TerminalSandboxMacFileSystem; + + const prefix = knownFailure + ? 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:' + : 'Command ran in sandboxed mode and may have been blocked by the sandbox. If the command failed due to sandboxing:'; + return `${prefix} +- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${fileSystemSetting}, or to add required domains to ${TerminalChatAgentToolsSettingId.TerminalSandboxNetwork}.allowedDomains. +- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user — setting this flag automatically shows a confirmation prompt to the user. + +Here is the output of the command:\n`; + } + + /** + * Checks whether the command output contains strings that typically indicate + * the sandbox blocked the operation. Used when exit code is unavailable. + * + * The output may contain newlines inserted by terminal wrapping, so we + * strip them before testing. + */ + private _outputLooksSandboxBlocked(output: string): boolean { + return outputLooksSandboxBlocked(output); } } + +/** + * Checks whether the command output contains strings that typically indicate + * the sandbox blocked the operation. The output may contain newlines inserted + * by terminal wrapping, so we strip them before testing. + */ +export function outputLooksSandboxBlocked(output: string): boolean { + const normalized = output.replace(/\n/g, ' '); + return /Operation not permitted|Permission denied|Read-only file system|sandbox-exec|bwrap|sandbox_violation/i.test(normalized); +} diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index f50dfea219f..e55ce632fd1 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -26,6 +26,7 @@ export const enum TerminalChatAgentToolsSettingId { TerminalSandboxMacFileSystem = 'chat.tools.terminal.sandbox.macFileSystem', PreventShellHistory = 'chat.tools.terminal.preventShellHistory', EnforceTimeoutFromModel = 'chat.tools.terminal.enforceTimeoutFromModel', + IdlePollInterval = 'chat.tools.terminal.idlePollInterval', TerminalProfileLinux = 'chat.tools.terminal.terminalProfile.linux', TerminalProfileMacOs = 'chat.tools.terminal.terminalProfile.osx', @@ -435,13 +436,20 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary('terminalSandboxService'); +export interface ITerminalSandboxResolvedNetworkDomains { + allowedDomains: string[]; + deniedDomains: string[]; +} + export interface ITerminalSandboxService { readonly _serviceBrand: undefined; isEnabled(): Promise; + getOS(): Promise; wrapCommand(command: string): string; getSandboxConfigPath(forceRefresh?: boolean): Promise; getTempDir(): URI | undefined; setNeedsForceUpdateConfigFile(): void; + getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains; } export class TerminalSandboxService extends Disposable implements ITerminalSandboxService { @@ -48,6 +59,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb private _appRoot: string; private _os: OperatingSystem = OS; private _defaultWritePaths: string[] = ['~/.npm']; + private static readonly _sandboxTempDirName = 'tmp'; constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -56,6 +68,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb @ILogService private readonly _logService: ILogService, @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, @ITrustedDomainService private readonly _trustedDomainService: ITrustedDomainService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IProductService private readonly _productService: IProductService, + @ILifecycleService private readonly _lifecycleService: ILifecycleService, ) { super(); this._appRoot = dirname(FileAccess.asFileUri('').path); @@ -80,17 +95,37 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb this._register(this._trustedDomainService.onDidChangeTrustedDomains(() => { this.setNeedsForceUpdateConfigFile(); })); + + this._register(this._workspaceContextService.onDidChangeWorkspaceFolders(() => { + this.setNeedsForceUpdateConfigFile(); + })); + + this._register(this._lifecycleService.onWillShutdown(e => { + if (!this._tempDir) { + return; + } + e.join(this._cleanupSandboxTempDir(), { + id: 'join.deleteFilesInSandboxTempDir', + label: localize('deleteFilesInSandboxTempDir', "Delete Files in Sandbox Temp Dir"), + order: WillShutdownJoinerOrder.Default + }); + })); } public async isEnabled(): Promise { - this._remoteEnvDetails = await this._remoteEnvDetailsPromise; - this._os = this._remoteEnvDetails ? this._remoteEnvDetails.os : OS; - if (this._os === OperatingSystem.Windows) { + const os = await this.getOS(); + if (os === OperatingSystem.Windows) { return false; } return this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled); } + public async getOS(): Promise { + this._remoteEnvDetails = await this._remoteEnvDetailsPromise; + this._os = this._remoteEnvDetails ? this._remoteEnvDetails.os : OS; + return this._os; + } + public wrapCommand(command: string): string { if (!this._sandboxConfigPath || !this._tempDir) { throw new Error('Sandbox config path or temp dir not initialized'); @@ -107,7 +142,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb // Use ELECTRON_RUN_AS_NODE=1 to make Electron executable behave as Node.js // TMPDIR must be set as environment variable before the command // Quote shell arguments so the wrapped command cannot break out of the outer shell. - const wrappedCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(command)}`; + const wrappedCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" CLAUDE_TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(command)}`; if (this._remoteEnvDetails) { return `${wrappedCommand}`; } @@ -142,9 +177,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb this._srtPathResolved = true; const remoteEnv = this._remoteEnvDetails || await this._remoteEnvDetailsPromise; if (remoteEnv) { - this._appRoot = remoteEnv.appRoot.path; - this._execPath = this._pathJoin(this._appRoot, 'node'); + this._execPath = remoteEnv.execPath; } this._srtPath = this._pathJoin(this._appRoot, 'node_modules', '@anthropic-ai', 'sandbox-runtime', 'dist', 'cli.js'); this._rgPath = this._pathJoin(this._appRoot, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg'); @@ -164,9 +198,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb ? this._configurationService.getValue<{ denyRead?: string[]; allowWrite?: string[]; denyWrite?: string[] }>(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])]; + const linuxAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(linuxFileSystemSetting.allowWrite); + const macAllowWrite = this._updateAllowWritePathsWithWorkspaceFolders(macFileSystemSetting.allowWrite); let allowedDomains = networkSetting.allowedDomains ?? []; if (networkSetting.allowTrustedDomains) { @@ -201,13 +234,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb if (await this.isEnabled()) { this._needsForceUpdateConfigFile = true; const remoteEnv = this._remoteEnvDetails || await this._remoteEnvDetailsPromise; - if (remoteEnv) { - this._tempDir = remoteEnv.tmpDir; - } else { - const environmentService = this._environmentService as IEnvironmentService & { tmpDir?: URI }; - this._tempDir = environmentService.tmpDir; - } + this._tempDir = this._getSandboxTempDirPath(remoteEnv); if (this._tempDir) { + await this._fileService.createFolder(this._tempDir); this._defaultWritePaths.push(this._tempDir.path); } if (!this._tempDir) { @@ -216,6 +245,51 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } } + private async _cleanupSandboxTempDir(): Promise { + if (!this._tempDir) { + return; + } + try { + await this._fileService.del(this._tempDir, { recursive: true, useTrash: false }); + } catch (error) { + this._logService.warn('TerminalSandboxService: Failed to delete sandbox temp dir', error); + } + } + + private _getSandboxTempDirPath(remoteEnv: IRemoteAgentEnvironment | null): URI | undefined { + const sandboxTempDirName = this._getSandboxWindowTempDirName(); + if (remoteEnv?.userHome) { + const sandboxRoot = URI.joinPath(remoteEnv.userHome, this._productService.serverDataFolderName ?? this._productService.dataFolderName, TerminalSandboxService._sandboxTempDirName); + return sandboxTempDirName ? URI.joinPath(sandboxRoot, sandboxTempDirName) : sandboxRoot; + } + + const nativeEnv = this._environmentService as IEnvironmentService & { userHome?: URI }; + if (nativeEnv.userHome) { + const sandboxRoot = URI.joinPath(nativeEnv.userHome, this._productService.dataFolderName, TerminalSandboxService._sandboxTempDirName); + return sandboxTempDirName ? URI.joinPath(sandboxRoot, sandboxTempDirName) : sandboxRoot; + } + + return undefined; + } + + private _getSandboxWindowTempDirName(): string | undefined { + const workbenchEnv = this._environmentService as IEnvironmentService & { window?: { id?: number } }; + const windowId = workbenchEnv.window?.id; + return typeof windowId === 'number' ? `tmp_vscode_${windowId}` : undefined; + } + + public getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains { + const networkSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) ?? {}; + let allowedDomains = networkSetting.allowedDomains ?? []; + if (networkSetting.allowTrustedDomains) { + allowedDomains = this._addTrustedDomainsToAllowedDomains(allowedDomains); + } + return { + allowedDomains, + deniedDomains: networkSetting.deniedDomains ?? [] + }; + } + private _addTrustedDomainsToAllowedDomains(allowedDomains: string[]): string[] { const allowedDomainsSet = new Set(allowedDomains); for (const domain of this._trustedDomainService.trustedDomains) { @@ -230,4 +304,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } return Array.from(allowedDomainsSet); } + + private _updateAllowWritePathsWithWorkspaceFolders(configuredAllowWrite: string[] | undefined): string[] { + const workspaceFolderPaths = this._workspaceContextService.getWorkspace().folders.map(folder => folder.uri.path); + return [...new Set([...workspaceFolderPaths, ...this._defaultWritePaths, ...(configuredAllowWrite ?? [])])]; + } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts new file mode 100644 index 00000000000..5618680c591 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/noneExecuteStrategy.test.ts @@ -0,0 +1,125 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import type { ITerminalLogService } from '../../../../../../platform/terminal/common/terminal.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; +import { NoneExecuteStrategy } from '../../browser/executeStrategy/noneExecuteStrategy.js'; +import type { ITerminalInstance } from '../../../../terminal/browser/terminal.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; + +suite('NoneExecuteStrategy', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createLogService(): ITerminalLogService { + return new class extends NullLogService { readonly _logBrand = undefined; }; + } + + /** + * Creates a mock terminal instance and xterm for testing NoneExecuteStrategy. + * + * @param contentsAsText The text that `xterm.getContentsAsText()` will return (simulates + * the terminal buffer content between the start and end markers) + * @param cursorLineText The text at the cursor line, used by prompt detection heuristics + */ + function createMockTerminalAndXterm(contentsAsText: string, cursorLineText: string): { + instance: ITerminalInstance; + onDataEmitter: Emitter; + } { + const onDataEmitter = store.add(new Emitter()); + const activeBuffer = {}; + const alternateBuffer = {}; // different object → not alt buffer + + const mockXterm = { + raw: { + registerMarker: () => ({ + line: 0, + isDisposed: false, + onDispose: Event.None, + dispose: () => { }, + }), + buffer: { + active: { + ...activeBuffer, + baseY: 0, + cursorY: 1, + getLine: () => ({ + translateToString: () => cursorLineText, + }), + }, + alternate: alternateBuffer, + onBufferChange: () => ({ dispose: () => { } }), + }, + onWriteParsed: Event.None, + }, + getContentsAsText: () => contentsAsText, + }; + + const mockInstance = { + xtermReadyPromise: Promise.resolve(mockXterm), + onData: onDataEmitter.event, + sendText: () => { }, + } as unknown as ITerminalInstance; + + return { instance: mockInstance, onDataEmitter }; + } + + test('should report "Command produced no output" when output is empty', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // Simulate a command that produces no output. Between the start and end markers, + // getContentsAsText returns only whitespace (no actual command output). + const { instance } = createMockTerminalAndXterm( + ' \n \n ', // only whitespace between markers + 'user@host:~$ ' // prompt at cursor line → triggers prompt detection + ); + + const logService = createLogService(); + const configService = new TestConfigurationService(); + const strategy = store.add(new NoneExecuteStrategy(instance, () => false, configService, logService)); + const cts = store.add(new CancellationTokenSource()); + + const result = await strategy.execute('echo test', cts.token); + + assert.strictEqual(result.additionalInformation, 'Command produced no output'); + })); + + test('should not leak sandbox command echo as output when command produces no output', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + // This simulates the exact scenario from issue #303531: + // A sandboxed command produces no output, but getContentsAsText returns the + // prompt + sandbox-wrapped command echo + next prompt line. + const promptLine = '[ user@host:~/src (main) ] $ '; + const sandboxCommandEcho = 'ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/app/node_modules/@vscode/ripgrep/bin" ' + + 'TMPDIR="/var/folders/bb/_8jjjyy971x2frm3nr3g7m4r0000gn/T" ' + + '"/app/Contents/MacOS/Code - Insiders" "/app/Contents/Resources/app/node_modules/@anthropic-ai/sandbox-runtime/dist/cli.js" ' + + '--settings "/var/folders/bb/_8jjjyy971x2frm3nr3g7m4r0000gn/T/vscode-sandbox-settings.json" ' + + '-c \' git diff 0e5d5949d13f..2c357a926df6 -- \'\\\'\'src/foo.ts\'\\\'\' | grep -A3 -B3 \'\\\'\'someFunc\'\\\'\'\''; + const terminalContent = `${promptLine}${sandboxCommandEcho}\n${' '.repeat(80)}\n${promptLine}`; + + const { instance } = createMockTerminalAndXterm( + terminalContent, + promptLine // prompt at cursor line → triggers prompt detection + ); + + const logService = createLogService(); + const configService = new TestConfigurationService(); + const strategy = store.add(new NoneExecuteStrategy(instance, () => false, configService, logService)); + const cts = store.add(new CancellationTokenSource()); + + const result = await strategy.execute( + 'git diff 0e5d5949d13f..2c357a926df6 -- \'src/foo.ts\' | grep -A3 -B3 \'someFunc\'', + cts.token + ); + + // The output should NOT contain sandbox wrapper artifacts + assert.strictEqual(result.output?.includes('sandbox-runtime') ?? false, false, 'Output should not leak sandbox-runtime path'); + assert.strictEqual(result.output?.includes('ELECTRON_RUN_AS_NODE') ?? false, false, 'Output should not leak ELECTRON_RUN_AS_NODE'); + + // Should report that the command produced no output + assert.strictEqual(result.additionalInformation, 'Command produced no output'); + })); +}); 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 f4f34ecd4e9..be398e10c0b 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 @@ -452,6 +452,7 @@ suite('OutputMonitor', () => { test('detects VS Code task completion messages', () => { assert.strictEqual(detectsVSCodeTaskFinishMessage('Press any key to close the terminal.'), true); assert.strictEqual(detectsVSCodeTaskFinishMessage('Terminal will be reused by tasks, press any key to close it.'), true); + assert.strictEqual(detectsVSCodeTaskFinishMessage('The terminal will be reused by tasks. Press any key to close. Please provide the required input to the terminal.'), true); // Case insensitive assert.strictEqual(detectsVSCodeTaskFinishMessage('press any key to close the terminal.'), true); assert.strictEqual(detectsVSCodeTaskFinishMessage('PRESS ANY KEY TO CLOSE THE TERMINAL.'), true); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxOutputAnalyzer.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxOutputAnalyzer.test.ts new file mode 100644 index 00000000000..2349129f7f5 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxOutputAnalyzer.test.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { strictEqual } from 'assert'; +import { outputLooksSandboxBlocked } from '../../browser/tools/sandboxOutputAnalyzer.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; + +suite('outputLooksSandboxBlocked', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const positives: [string, string][] = [ + ['macOS sandbox file write', '/bin/bash: /tmp/test.txt: Operation not permitted'], + ['Linux sandbox file write', '/usr/bin/bash: /tmp/test.txt: Read-only file system'], + ['Permission denied', 'bash: ./script.sh: Permission denied'], + ['sandbox-exec reference', 'sandbox-exec: some error occurred'], + ['bwrap reference', 'bwrap: error setting up namespace'], + ['sandbox_violation', 'sandbox_violation: deny(1) file-write-create /tmp/foo'], + ['case insensitive', '/bin/bash: OPERATION NOT PERMITTED'], + ['wrapped across lines', '/bin/bash: Operation not\npermitted'], + ]; + + for (const [label, output] of positives) { + test(`detects: ${label}`, () => { + strictEqual(outputLooksSandboxBlocked(output), true); + }); + } + + const negatives: [string, string][] = [ + ['normal output', 'hello world'], + ['empty output', ''], + ['unrelated error', 'Error: ENOENT: no such file or directory'], + ]; + + for (const [label, output] of negatives) { + test(`ignores: ${label}`, () => { + strictEqual(outputLooksSandboxBlocked(output), false); + }); + } +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts index b1a8fed729a..e3e2cca256f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts @@ -56,6 +56,17 @@ suite('SandboxedCommandLinePresenter', () => { strictEqual(result.languageDisplayName, undefined); }); + test('should use forDisplay over original when both are provided', async () => { + const presenter = createPresenter(); + const result = await presenter.present({ + commandLine: { original: 'cd /some/path && ls -lh', forDisplay: 'ls -lh' }, + shell: 'bash', + os: OperatingSystem.Linux + }); + ok(result); + strictEqual(result.commandLine, 'ls -lh'); + }); + test('should return undefined when sandboxing is disabled', async () => { const presenter = createPresenter(false); const result = await presenter.present({ diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts new file mode 100644 index 00000000000..8e22ff4a36e --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/strategyHelpers.test.ts @@ -0,0 +1,564 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { stripCommandEchoAndPrompt } from '../../browser/executeStrategy/strategyHelpers.js'; + +suite('stripCommandEchoAndPrompt', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('strips single-line command echo and trailing prompt', () => { + const output = [ + 'user@host:~/src $ echo hello', + 'hello', + 'user@host:~/src $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); + + test('strips command echo with zsh-style prompt (] $ )', () => { + const output = [ + 's/testWorkspace (main**) ] $ true', + '[ alex@Alexandrus-MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test', + 's/testWorkspace (main**) ] $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('preserves actual command output between echo and prompt', () => { + const output = [ + 's/testWorkspace (main**) ] $ echo MARKER_123', + 'MARKER_123', + '[ alex@host:/some/path', + 's/testWorkspace (main**) ] $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo MARKER_123'), + 'MARKER_123' + ); + }); + + test('preserves multi-line command output', () => { + const output = [ + 'user@host:~ $ echo line1 && echo line2 && echo line3', + 'line1', + 'line2', + 'line3', + 'user@host:~ $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo line1 && echo line2 && echo line3'), + 'line1\nline2\nline3' + ); + }); + + test('handles empty output (no-output command)', () => { + const output = [ + 's/testWorkspace (main**) ] $ true', + '[ alex@host:/Users/alex/src/vscode4/extensions/vscode-api-test', + 's/testWorkspace (main**) ] $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('strips sandbox-wrapped command echo (long wrapped lines)', () => { + const sandboxCommand = 'ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/app/rg/bin" TMPDIR="/tmp/sandbox" "/app/sandbox-runtime/dist/cli.js" --settings "/tmp/sandbox-settings.json" -c \'curl -s https://example.com\''; + const output = [ + 's/testWorkspace (main**) ] $ ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/app/rg/bin" T', + 'MPDIR="/tmp/sandbox" "/app/sandbox-runtime/dist/cli.js" --settings "/tmp/sand', + 'box-settings.json" -c \'curl -s https://example.com\'', + '[ alex@host:/Users/alex/src/vscode4/extensions/vscode-api-test', + 's/testWorkspace (main**) ] $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, sandboxCommand), + '' + ); + }); + + test('strips trailing prompt with various prompt styles', () => { + // bash user@host:path $ + assert.strictEqual( + stripCommandEchoAndPrompt( + ['user@host:~ $ echo hello', 'hello', 'user@host:~ $ '].join('\n'), + 'echo hello' + ), + 'hello', + 'Failed for bash $ prompt' + ); + // root user@host:path # + assert.strictEqual( + stripCommandEchoAndPrompt( + ['root@server:/var/log# echo hello', 'hello', 'root@server:/var/log# '].join('\n'), + 'echo hello' + ), + 'hello', + 'Failed for root # prompt' + ); + // bracketed prompt ending with ] $ + assert.strictEqual( + stripCommandEchoAndPrompt( + ['s/workspace ] $ echo hello', 'hello', 's/workspace ] $ '].join('\n'), + 'echo hello' + ), + 'hello', + 'Failed for bracketed ] $ prompt' + ); + // PowerShell PS C:\> + assert.strictEqual( + stripCommandEchoAndPrompt( + ['PS C:\\Users\\test> echo hello', 'hello', 'PS C:\\Users\\test>'].join('\n'), + 'echo hello' + ), + 'hello', + 'Failed for PowerShell prompt' + ); + }); + + test('does not strip output lines ending with prompt-like characters', () => { + // Output ending with % (e.g. percentage) + assert.strictEqual( + stripCommandEchoAndPrompt( + ['user@host:~ $ echo "100%"', '100%', 'user@host:~ $ '].join('\n'), + 'echo "100%"' + ), + '100%', + 'Should not strip line ending with %' + ); + // Output ending with > (e.g. HTML or comparison) + assert.strictEqual( + stripCommandEchoAndPrompt( + ['user@host:~ $ echo "
"', '
', 'user@host:~ $ '].join('\n'), + 'echo "
"' + ), + '
', + 'Should not strip line ending with >' + ); + // Output ending with # (e.g. comment marker) + assert.strictEqual( + stripCommandEchoAndPrompt( + ['user@host:~ $ echo "item #"', 'item #', 'user@host:~ $ '].join('\n'), + 'echo "item #"' + ), + 'item #', + 'Should not strip line ending with #' + ); + }); + + test('handles command with leading space (history prevention)', () => { + const output = [ + 'user@host:~ $ echo hello', + 'hello', + 'user@host:~ $ ', + ].join('\n'); + + // The command has a leading space (from CommandLinePreventHistoryRewriter) + assert.strictEqual( + stripCommandEchoAndPrompt(output, ' echo hello'), + 'hello' + ); + }); + + test('does not strip actual output lines that happen to contain prompt chars', () => { + const output = [ + 'user@host:~ $ echo "price is $5"', + 'price is $5', + 'user@host:~ $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo "price is $5"'), + 'price is $5' + ); + }); + + test('handles output with no trailing prompt (e.g. command still running)', () => { + const output = [ + 'user@host:~ $ echo hello', + 'hello', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); + + test('handles output with only the command echo and no prompt', () => { + const output = 'user@host:~ $ true'; + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('handles empty string input', () => { + assert.strictEqual( + stripCommandEchoAndPrompt('', 'echo hello'), + '' + ); + }); + + test('handles bash -c subshell command echo', () => { + const output = [ + 's/testWorkspace (main**) ] $ bash -c "exit 42"', + '[ alex@host:/Users/alex/src/vscode4/extensions/vscode-api-test', + 's/testWorkspace (main**) ] $ ', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'bash -c "exit 42"'), + '' + ); + }); + + test('strips wrapped prompt lines with user@hostname pattern', () => { + const output = [ + 'user@host:~ $ echo hi', + 'hi', + '[ alex@Alexandrus-MacBook-Pro:/very/long/path/that/wraps/across/terminal/col', + 'umns/in/the/test/workspace ] $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hi'), + 'hi' + ); + }); + + test('handles PowerShell-style prompt (PS C:\\>)', () => { + const output = [ + 'PS C:\\Users\\test> echo hello', + 'hello', + 'PS C:\\Users\\test>', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); + + test('strips stale prompt fragments and ^C residue before command echo', () => { + // Simulates CI environment where previous ^C produces stale prompt + // fragments before the actual command echo line + const output = [ + 'ts/testWorkspace$ ^C', + 'cloudtest@5ac6b023c000000:/mnt/vss/_work/vscode/vscode/extensions/vscode-api-tes', + 'ts/testWorkspace$ echo MARKER_123', + 'MARKER_123', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo MARKER_123'), + 'MARKER_123' + ); + }); + + test('strips stale prompt fragments for no-output command', () => { + const output = [ + 'ts/testWorkspace$ ^C', + 'cloudtest@5ac6b023c000000:/mnt/vss/_work/vscode/vscode/extensions/vscode-api-tes', + 'ts/testWorkspace$ true', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('strips stale prompt fragments for multi-line output', () => { + const output = [ + 'ts/testWorkspace$ ^C', + 'cloudtest@5ac6b023c000000:/mnt/vss/_work/vscode/vscode/extensions/vscode-api-tes', + 'ts/testWorkspace$ echo M1 && echo M2 && echo M3', + 'M1', + 'M2', + 'M3', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo M1 && echo M2 && echo M3'), + 'M1\nM2\nM3' + ); + }); + + test('strips trailing prompt without @ (hostname:path user$)', () => { + const output = [ + 'dsm12-be220-abc:testWorkspace runner$ echo hello', + 'hello', + 'dsm12-be220-abc:testWorkspace runner$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); + + test('strips wrapped trailing prompt without @ (hostname:path + fragment$)', () => { + const output = [ + 'dsm12-be220-abc:testWorkspace runner$ echo hello', + 'hello', + 'dsm12-be220-8627ea7f-2c5a-40cd-8ba1-bf324bb4f59a-DA35C080942E:testWorkspace runn', + 'er$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); + + test('strips wrapped trailing prompt with path-like fragment (ts/testWorkspace$)', () => { + const output = [ + 'user@host:~ $ echo hello', + 'hello', + 'cloudtest@d4b0d881c000000:/mnt/vss/_work/vscode/vscode/extensions/vscode-api-tes', + 'ts/testWorkspace$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo hello'), + 'hello' + ); + }); + + test('strips trailing prompt fragment for no-output command', () => { + const output = [ + 'dsm12-be220-abc:testWorkspace runner$ true', + 'dsm12-be220-8627ea7f-2c5a-40cd-8ba1-bf324bb4f59a-DA35C080942E:testWorkspace runn', + 'er$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('strips mid-word wrapped command continuation (PowerShell/Windows)', () => { + // PowerShell wraps "echo MARKER_123_ECHO" across lines at column boundary + const output = [ + 'PS D:\\a\\_work\\vscode\\testWorkspace> echo MARK', + 'ER_123_ECHO', + 'MARKER_123_ECHO', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo MARKER_123_ECHO'), + 'MARKER_123_ECHO' + ); + }); + + test('strips PowerShell prompt from getOutput() result', () => { + // When shell integration markers misfire, getOutput() includes the prompt + command + const output = 'PS D:\\a\\_work\\vscode\\testWorkspace> cmd /c exit 42'; + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'cmd /c exit 42'), + '' + ); + }); + + test('strips partial command echo (suffix from wrapped getOutput)', () => { + // When getOutput() doesn't include the prompt line, only the wrapped + // continuation of the command echo appears at the start of the output. + const output = [ + '90741 ; echo M2_1774133190741 ; echo M3_1774133190741', + 'M1_1774133190741', + 'M2_1774133190741', + 'M3_1774133190741', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo M1_1774133190741 ; echo M2_1774133190741 ; echo M3_1774133190741'), + 'M1_1774133190741\nM2_1774133190741\nM3_1774133190741' + ); + }); + + test('strips bracketed prompt without @ (hostname:path format)', () => { + // macOS CI prompt: [hostname:path] username$ (wrapped so username is truncated) + const output = [ + '[W007DV9PF9-1:~/vss/_work/1/s/extensions/vscode-api-tests/testWorkspace] cloudte', + 'st$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('strips bracketed prompt without @ (single line, no trailing $)', () => { + // When the terminal captures just the prompt (no-output command) + const output = '[W007DV9PF9-1:~/vss/_work/1/s/extensions/vscode-api-tests/testWorkspace] cloudte'; + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'true'), + '' + ); + }); + + test('strips bracketed prompt without @ with command echo', () => { + const output = [ + '[W007DV9PF9-1:~/vss/_work] cloudtest$ echo MARKER_123', + 'MARKER_123', + '[W007DV9PF9-1:~/vss/_work] cloudtest$', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo MARKER_123'), + 'MARKER_123' + ); + }); + + test('strips sandbox-wrapped command echo with error output and trailing prompt', () => { + const commandLine = 'ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/Users/alex/src/vscode4/node_modules/@vscode/ripgrep/bin" TMPDIR="/Users/alex/.vscode-oss-dev/tmp" CLAUDE_TMPDIR="/Users/alex/.vscode-oss-dev/tmp" "/Users/alex/src/vscode4/node_modules/@anthropic-ai/sandbox-runtime/dist/cli.js" --settings "/Users/alex/.vscode-oss-dev/tmp/vscode-sandbox-settings-cf5b6232-825b-4f4c-8902-32a8591007fd.json" -c \' echo "SANDBOX_TMP_1774127409076" > /tmp/SANDBOX_TMP_1774127409076.txt\''; + const output = [ + 'ELECTRON_RUN_AS_NODE=1 PATH="$PATH:/Users/alex/src/vscode4/node_modules/@vscode/', + 'ripgrep/bin" TMPDIR="/Users/alex/.vscode-oss-dev/tmp" CLAUDE_TMPDIR="/Users/alex', + '/.vscode-oss-dev/tmp" "/Users/alex/src/vscode4/node_modules/@anthropic-ai/sandbo', + 'x-runtime/dist/cli.js" --settings "/Users/alex/.vscode-oss-dev/tmp/vscode-sandbo', + 'x-settings-cf5b6232-825b-4f4c-8902-32a8591007fd.json" -c \' echo "SANDBOX_TMP_177', + '4127409076" > /tmp/SANDBOX_TMP_1774127409076.txt\'', + '[ alex@Alexandrus-MacBook-Pro:/Users/alex/src/vscode4/extensions/vscode-api-test', + 's/testWorkspace (alexdima/fix-303531-sandbox-no-output-leak**) ] $ ELECTRON_RUN_', + 'AS_NODE=1 PATH="$PATH:/Users/alex/src/vscode4/node_modules/@vscode/ripgrep/bin" ', + 'TMPDIR="/Users/alex/.vscode-oss-dev/tmp" CLAUDE_TMPDIR="/Users/alex/.vscode-oss-', + 'dev/tmp" "/Users/alex/src/vscode4/node_modules/@anthropic-ai/sandbox-runtime/dis', + 't/cli.js" --settings "/Users/alex/.vscode-oss-dev/tmp/vscode-sandbox-settings-cf', + '5b6232-825b-4f4c-8902-32a8591007fd.json" -c \' echo "SANDBOX_TMP_1774127409076" >', + ' /tmp/SANDBOX_TMP_1774127409076.txt\'', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, commandLine), + '' + ); + }); + + // --- Adversarial tests: output that looks like prompts --- + // These verify that realistic output is NOT falsely stripped. + + suite('adversarial: output resembling prompts', () => { + + test('output ending with $ is preserved (not confused with wrapped prompt)', () => { + const output = [ + 'user@host:~ $ echo \'test$\'', + 'test$', + 'user@host:~ $', + ].join('\n'); + + // 'user@host:~ $' is a complete prompt → stripped and loop stops. + // 'test$' is preserved because nothing above a complete prompt is stripped. + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo \'test$\''), + 'test$' + ); + }); + + test('output ending with # is preserved (not confused with wrapped prompt)', () => { + const output = [ + 'user@host:~ $ echo \'div#\'', + 'div#', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo \'div#\''), + 'div#' + ); + }); + + test('bracketed log output [tag:~/path] is preserved', () => { + const output = [ + 'user@host:~ $ node build.js', + '[build:~/dist] compiled successfully', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'node build.js'), + '[build:~/dist] compiled successfully' + ); + }); + + test('output containing user@host:path ending with # is preserved', () => { + const output = [ + 'user@host:~ $ cat /etc/motd', + 'admin@server:~/docs #', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'cat /etc/motd'), + 'admin@server:~/docs #' + ); + }); + + test('output ending with ] $ is preserved', () => { + const output = [ + 'user@host:~ $ echo \'values: [a, b] $\'', + 'values: [a, b] $', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'echo \'values: [a, b] $\''), + 'values: [a, b] $' + ); + }); + + test('multiple prompt-like output lines are all preserved', () => { + // Complete prompt at the bottom stops stripping immediately, + // so all prompt-like output lines above are preserved. + const output = [ + 'user@host:~ $ cat prompts.txt', + 'admin@server:~/docs $', + 'root@box:/var/log #', + 'test@dev:~ $', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, 'cat prompts.txt'), + 'admin@server:~/docs $\nroot@box:/var/log #\ntest@dev:~ $' + ); + }); + + test('multi-line output where last line has $ after non-word chars is preserved', () => { + const output = [ + 'user@host:~ $ ./report.sh', + 'Revenue: 1000', + 'Currency: USD$', + 'user@host:~ $', + ].join('\n'); + + assert.strictEqual( + stripCommandEchoAndPrompt(output, './report.sh'), + 'Revenue: 1000\nCurrency: USD$' + ); + }); + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index fdc4ab166ab..33d74053c2b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -6,12 +6,14 @@ import { strictEqual, ok } from 'assert'; 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 { TestLifecycleService, workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { TestProductService } from '../../../../../test/common/workbenchTestServices.js'; import { TerminalSandboxService } from '../../common/terminalSandboxService.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; import { ITrustedDomainService } from '../../../../url/common/trustedDomainService.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -21,6 +23,9 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { OperatingSystem } from '../../../../../../base/common/platform.js'; import { IRemoteAgentEnvironment } from '../../../../../../platform/remote/common/remoteAgentEnvironment.js'; +import { IWorkspace, IWorkspaceContextService, IWorkspaceFolder, IWorkspaceFoldersChangeEvent, IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js'; +import { testWorkspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; +import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; suite('TerminalSandboxService - allowTrustedDomains', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -29,7 +34,13 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { let configurationService: TestConfigurationService; let trustedDomainService: MockTrustedDomainService; let fileService: MockFileService; + let lifecycleService: TestLifecycleService; + let workspaceContextService: MockWorkspaceContextService; + let productService: IProductService; let createdFiles: Map; + let createdFolders: string[]; + let deletedFolders: string[]; + const windowId = 7; class MockTrustedDomainService implements ITrustedDomainService { _serviceBrand: undefined; @@ -47,6 +58,15 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { createdFiles.set(uri.path, contentString); return {}; } + + async createFolder(uri: URI): Promise { + createdFolders.push(uri.path); + return {}; + } + + async del(uri: URI): Promise { + deletedFolders.push(uri.path); + } } class MockRemoteAgentService { @@ -57,6 +77,7 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { os: OperatingSystem.Linux, tmpDir: URI.file('/tmp'), appRoot: URI.file('/app'), + execPath: '/app/node', pid: 1234, connectionToken: 'test-token', settingsPath: URI.file('/settings'), @@ -79,12 +100,70 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { } } + class MockWorkspaceContextService implements IWorkspaceContextService { + _serviceBrand: undefined; + readonly onDidChangeWorkbenchState = Event.None; + readonly onDidChangeWorkspaceName = Event.None; + readonly onWillChangeWorkspaceFolders = Event.None; + private readonly _onDidChangeWorkspaceFolders = new Emitter(); + readonly onDidChangeWorkspaceFolders: Event = this._onDidChangeWorkspaceFolders.event; + private _workspace: IWorkspace = testWorkspace(); + + getCompleteWorkspace(): Promise { + return Promise.resolve(this._workspace); + } + + getWorkspace(): IWorkspace { + return this._workspace; + } + + getWorkbenchState(): WorkbenchState { + return this._workspace.folders.length > 0 ? WorkbenchState.FOLDER : WorkbenchState.EMPTY; + } + + getWorkspaceFolder(_resource: URI): IWorkspaceFolder | null { + return null; + } + + isCurrentWorkspace(_workspaceIdOrFolder: IWorkspaceIdentifier | ISingleFolderWorkspaceIdentifier | URI): boolean { + return false; + } + + isInsideWorkspace(_resource: URI): boolean { + return false; + } + + hasWorkspaceData(): boolean { + return this._workspace.folders.length > 0; + } + + setWorkspaceFolders(folders: URI[]): void { + const previousFolders = this._workspace.folders; + this._workspace = testWorkspace(...folders); + this._onDidChangeWorkspaceFolders.fire({ + added: this._workspace.folders.filter(folder => !previousFolders.some(previousFolder => previousFolder.uri.toString() === folder.uri.toString())), + removed: previousFolders.filter(folder => !this._workspace.folders.some(nextFolder => nextFolder.uri.toString() === folder.uri.toString())), + changed: [] + }); + } + } + setup(() => { createdFiles = new Map(); + createdFolders = []; + deletedFolders = []; instantiationService = workbenchInstantiationService({}, store); configurationService = new TestConfigurationService(); trustedDomainService = new MockTrustedDomainService(); fileService = new MockFileService(); + lifecycleService = store.add(new TestLifecycleService()); + workspaceContextService = new MockWorkspaceContextService(); + productService = { + ...TestProductService, + dataFolderName: '.test-data', + serverDataFolderName: '.test-server-data' + }; + workspaceContextService.setWorkspaceFolders([URI.file('/workspace-one')]); // Setup default configuration configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, true); @@ -96,14 +175,18 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IFileService, fileService); - instantiationService.stub(IEnvironmentService, { + instantiationService.stub(IEnvironmentService, { _serviceBrand: undefined, tmpDir: URI.file('/tmp'), - execPath: '/usr/bin/node' + execPath: '/usr/bin/node', + window: { id: windowId } }); instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IProductService, productService); instantiationService.stub(IRemoteAgentService, new MockRemoteAgentService()); instantiationService.stub(ITrustedDomainService, trustedDomainService); + instantiationService.stub(IWorkspaceContextService, workspaceContextService); + instantiationService.stub(ILifecycleService, lifecycleService); }); test('should filter out sole wildcard (*) from trusted domains', async () => { @@ -255,6 +338,60 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { strictEqual(config.network.allowedDomains.length, 0, 'Should have no domains (* filtered out)'); }); + test('should refresh allowWrite paths when workspace folders change', async () => { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxLinuxFileSystem, { + allowWrite: ['/configured/path'], + denyRead: [], + denyWrite: [] + }); + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const initialConfigContent = createdFiles.get(configPath); + ok(initialConfigContent, 'Config file should be created for the initial workspace folders'); + + const initialConfig = JSON.parse(initialConfigContent); + ok(initialConfig.filesystem.allowWrite.includes('/workspace-one'), 'Initial config should include the original workspace folder'); + ok(initialConfig.filesystem.allowWrite.includes('/configured/path'), 'Initial config should include configured allowWrite paths'); + + workspaceContextService.setWorkspaceFolders([URI.file('/workspace-two')]); + + const refreshedConfigPath = await sandboxService.getSandboxConfigPath(); + strictEqual(refreshedConfigPath, configPath, 'Config path should stay stable when the config is refreshed'); + + const refreshedConfigContent = createdFiles.get(configPath); + ok(refreshedConfigContent, 'Config file should be rewritten after workspace folders change'); + + const refreshedConfig = JSON.parse(refreshedConfigContent); + ok(refreshedConfig.filesystem.allowWrite.includes('/workspace-two'), 'Refreshed config should include the updated workspace folder'); + ok(!refreshedConfig.filesystem.allowWrite.includes('/workspace-one'), 'Refreshed config should remove the old workspace folder'); + ok(refreshedConfig.filesystem.allowWrite.includes('/configured/path'), 'Refreshed config should preserve configured allowWrite paths'); + }); + + test('should create sandbox temp dir under the server data folder', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + const expectedTempDir = URI.joinPath(URI.file('/home/user'), productService.serverDataFolderName ?? productService.dataFolderName, 'tmp', `tmp_vscode_${windowId}`); + + strictEqual(sandboxService.getTempDir()?.path, expectedTempDir.path, 'Sandbox temp dir should live under the server data folder'); + strictEqual(createdFolders[0], expectedTempDir.path, 'Sandbox temp dir should be created before writing the config'); + ok(configPath?.startsWith(expectedTempDir.path), 'Sandbox config file should be written inside the sandbox temp dir'); + }); + + test('should delete sandbox temp dir on shutdown', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + const expectedTempDir = URI.joinPath(URI.file('/home/user'), productService.serverDataFolderName ?? productService.dataFolderName, 'tmp', `tmp_vscode_${windowId}`); + + lifecycleService.fireShutdown(); + await Promise.all(lifecycleService.shutdownJoiners); + + strictEqual(lifecycleService.shutdownJoiners.length, 1, 'Shutdown should register a temp-dir cleanup joiner'); + strictEqual(deletedFolders[0], expectedTempDir.path, 'Shutdown should delete the sandbox temp dir'); + }); + test('should add ripgrep bin directory to PATH when wrapping command', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts index 46fe1a19203..9a679676dd3 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts @@ -78,4 +78,28 @@ suite('CommandLineSandboxRewriter', () => { strictEqual(result?.reasoning, 'Wrapped command for sandbox execution'); deepStrictEqual(calls, ['getSandboxConfigPath', 'wrapCommand']); }); + + test('does not wrap command when sandbox bypass was explicitly requested', async () => { + const calls: string[] = []; + stubSandboxService({ + isEnabled: async () => true, + wrapCommand: command => { + calls.push(`wrap:${command}`); + return `wrapped:${command}`; + }, + getSandboxConfigPath: async () => { + calls.push('config'); + return '/tmp/sandbox.json'; + }, + }); + + const rewriter = store.add(instantiationService.createInstance(CommandLineSandboxRewriter)); + const result = await rewriter.rewrite({ + ...createRewriteOptions('echo hello'), + requestUnsandboxedExecution: true, + }); + + strictEqual(result, undefined); + deepStrictEqual(calls, []); + }); }); 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 04e2e0fc523..7b3f39ad074 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 @@ -6,7 +6,7 @@ import { ok, strictEqual } from 'assert'; import { Separator } from '../../../../../../base/common/actions.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; -import { Emitter } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isLinux, isWindows, OperatingSystem } from '../../../../../../base/common/platform.js'; import { count } from '../../../../../../base/common/strings.js'; @@ -33,16 +33,22 @@ import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/wi import { IChatService, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js'; import { ITerminalSandboxService } from '../../common/terminalSandboxService.js'; -import { ILanguageModelToolsService, IPreparedToolInvocation, IToolInvocationPreparationContext, type ToolConfirmationAction } from '../../../../chat/common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocationPreparationContext, ToolDataSource, ToolSet, type ToolConfirmationAction } from '../../../../chat/common/tools/languageModelToolsService.js'; import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js'; -import { RunInTerminalTool, type IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js'; +import { createRunInTerminalToolData, RunInTerminalTool, type IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js'; 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'; +import { isDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ITrustedDomainService } from '../../../../url/common/trustedDomainService.js'; +import { ChatAgentToolsContribution } from '../../browser/terminal.chatAgentTools.contribution.js'; +import { TerminalToolId } from '../../browser/tools/toolIds.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; class TestRunInTerminalTool extends RunInTerminalTool { protected override _osBackend: Promise = Promise.resolve(OperatingSystem.Windows); @@ -67,6 +73,8 @@ suite('RunInTerminalTool', () => { let terminalServiceDisposeEmitter: Emitter; let chatServiceDisposeEmitter: Emitter<{ sessionResource: URI[]; reason: 'cleared' }>; let chatSessionArchivedEmitter: Emitter; + let sandboxEnabled: boolean; + let terminalSandboxService: ITerminalSandboxService; let runInTerminalTool: TestRunInTerminalTool; @@ -81,6 +89,7 @@ suite('RunInTerminalTool', () => { setConfig(TerminalChatAgentToolsSettingId.EnableAutoApprove, true); setConfig(TerminalChatAgentToolsSettingId.BlockDetectedFileWrites, 'outsideWorkspace'); + sandboxEnabled = false; terminalServiceDisposeEmitter = new Emitter(); chatServiceDisposeEmitter = new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>(); chatSessionArchivedEmitter = new Emitter(); @@ -105,14 +114,17 @@ suite('RunInTerminalTool', () => { instantiationService.stub(IHistoryService, { getLastActiveWorkspaceRoot: () => undefined }); - instantiationService.stub(ITerminalSandboxService, { + terminalSandboxService = { _serviceBrand: undefined, - isEnabled: async () => false, - wrapCommand: command => command, - getSandboxConfigPath: async () => undefined, + isEnabled: async () => sandboxEnabled, + wrapCommand: (command: string) => `sandbox:${command}`, + getSandboxConfigPath: async () => sandboxEnabled ? '/tmp/sandbox.json' : undefined, getTempDir: () => undefined, - setNeedsForceUpdateConfigFile: () => { } - }); + setNeedsForceUpdateConfigFile: () => { }, + getOS: async () => OperatingSystem.Linux, + getResolvedNetworkDomains: () => ({ allowedDomains: [], deniedDomains: [] }), + }; + instantiationService.stub(ITerminalSandboxService, terminalSandboxService); const treeSitterLibraryService = store.add(instantiationService.createInstance(TreeSitterLibraryService)); treeSitterLibraryService.isTest = true; @@ -198,6 +210,92 @@ suite('RunInTerminalTool', () => { } } + suite('sandbox invocation messaging', () => { + test('should instruct models to use $TMPDIR instead of /tmp when sandboxed', async () => { + sandboxEnabled = true; + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + + ok(toolData.modelDescription?.includes('must utilize the $TMPDIR environment variable'), 'Expected sandboxed tool description to require $TMPDIR usage'); + ok(toolData.modelDescription?.includes('The /tmp directory is not guaranteed to be accessible or writable and must be avoided'), 'Expected sandboxed tool description to discourage /tmp usage'); + }); + + test('should include requestUnsandboxedExecution in schema when sandbox is enabled', async () => { + sandboxEnabled = true; + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + const properties = toolData.inputSchema?.properties as Record | undefined; + + ok(properties?.['requestUnsandboxedExecution'], 'Expected requestUnsandboxedExecution in schema when sandbox is enabled'); + ok(properties?.['requestUnsandboxedExecutionReason'], 'Expected requestUnsandboxedExecutionReason in schema when sandbox is enabled'); + }); + + test('should not include requestUnsandboxedExecution in schema when sandbox is disabled', async () => { + sandboxEnabled = false; + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + const properties = toolData.inputSchema?.properties as Record | undefined; + + ok(!properties?.['requestUnsandboxedExecution'], 'Expected no requestUnsandboxedExecution in schema when sandbox is disabled'); + ok(!properties?.['requestUnsandboxedExecutionReason'], 'Expected no requestUnsandboxedExecutionReason in schema when sandbox is disabled'); + }); + + test('should reflect sandbox setting changes in tool data', async () => { + sandboxEnabled = false; + + const toolDataBefore = await instantiationService.invokeFunction(createRunInTerminalToolData); + const propertiesBefore = toolDataBefore.inputSchema?.properties as Record | undefined; + ok(!propertiesBefore?.['requestUnsandboxedExecution'], 'Expected no requestUnsandboxedExecution before enabling sandbox'); + + sandboxEnabled = true; + + const toolDataAfter = await instantiationService.invokeFunction(createRunInTerminalToolData); + const propertiesAfter = toolDataAfter.inputSchema?.properties as Record | undefined; + ok(propertiesAfter?.['requestUnsandboxedExecution'], 'Expected requestUnsandboxedExecution after enabling sandbox'); + ok(toolDataAfter.modelDescription?.includes('Sandboxing:'), 'Expected sandbox instructions in description after enabling sandbox'); + }); + + test('should include allowed and denied network domains in model description', async () => { + sandboxEnabled = true; + terminalSandboxService.getResolvedNetworkDomains = () => ({ + allowedDomains: ['github.com', 'npmjs.org'], + deniedDomains: ['evil.com'], + }); + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + + ok(toolData.modelDescription?.includes('github.com, npmjs.org'), 'Expected allowed domains in description'); + ok(toolData.modelDescription?.includes('evil.com'), 'Expected denied domains in description'); + }); + + test('should exclude denied domains from effective allowed list', async () => { + sandboxEnabled = true; + terminalSandboxService.getResolvedNetworkDomains = () => ({ + allowedDomains: ['github.com', 'evil.com', 'npmjs.org'], + deniedDomains: ['evil.com'], + }); + + const toolData = await instantiationService.invokeFunction(createRunInTerminalToolData); + + ok(toolData.modelDescription?.includes('github.com, npmjs.org'), 'Expected effective allowed list without denied domain'); + ok(!toolData.modelDescription?.includes('accessible in the sandbox (all other network access is blocked): github.com, evil.com'), 'Expected denied domain removed from allowed list'); + }); + + test('should use sandbox labels when command is sandbox wrapped', async () => { + terminalSandboxService.isEnabled = async () => true; + terminalSandboxService.getSandboxConfigPath = async () => '/tmp/vscode-sandbox-settings.json'; + terminalSandboxService.wrapCommand = (command: string) => `sandbox-runtime ${command}`; + + const preparedInvocation = await executeToolTest({ command: 'echo hello' }); + + ok(preparedInvocation, 'Expected prepared invocation to be defined'); + strictEqual((preparedInvocation.invocationMessage as IMarkdownString).value, 'Running `echo hello` in sandbox'); + + const terminalData = preparedInvocation.toolSpecificData as IChatTerminalToolInvocationData; + strictEqual(terminalData.commandLine.isSandboxWrapped, true); + }); + }); + suite('default auto-approve rules', () => { const defaults = terminalChatAgentToolsConfiguration[TerminalChatAgentToolsSettingId.AutoApprove].default as Record; @@ -435,6 +533,43 @@ suite('RunInTerminalTool', () => { }); }); + suite('sandbox bypass requests', () => { + test('should force confirmation for explicit unsandboxed execution requests', async () => { + sandboxEnabled = true; + runInTerminalTool.setBackendOs(OperatingSystem.Linux); + + const result = await executeToolTest({ + requestUnsandboxedExecution: true, + requestUnsandboxedExecutionReason: 'Needs network access outside the sandbox', + }); + + assertConfirmationRequired(result, 'Run `bash` command outside the sandbox?'); + strictEqual(result?.confirmationMessages?.allowAutoConfirm, undefined); + const terminalData = result?.toolSpecificData as IChatTerminalToolInvocationData; + strictEqual(terminalData.requestUnsandboxedExecution, true); + strictEqual(terminalData.requestUnsandboxedExecutionReason, 'Needs network access outside the sandbox'); + strictEqual(terminalData.commandLine.toolEdited, undefined); + + const confirmationMessage = result?.confirmationMessages?.message; + ok(confirmationMessage && typeof confirmationMessage !== 'string'); + if (!confirmationMessage || typeof confirmationMessage === 'string') { + throw new Error('Expected markdown confirmation message'); + } + ok(confirmationMessage.value.includes('Reason for leaving the sandbox: Needs network access outside the sandbox')); + + strictEqual(result?.confirmationMessages?.disclaimer, undefined); + const actions = result?.confirmationMessages?.terminalCustomActions; + ok(actions, 'Expected custom actions to be defined'); + strictEqual(actions.length, 11); + ok(!isSeparator(actions[0])); + strictEqual(actions[0].label, 'Allow `echo …` in this Session'); + ok(!isSeparator(actions[4])); + strictEqual(actions[4].label, 'Allow Exact Command Line in this Session'); + ok(!isSeparator(actions[10])); + strictEqual(actions[10].label, 'Configure Auto Approve...'); + }); + }); + suite('prepareToolInvocation - auto approval behavior', () => { test('should auto-approve commands in allow list', async () => { @@ -1621,4 +1756,238 @@ suite('RunInTerminalTool', () => { } }); }); + + suite('ConfirmTerminalCommandTool', () => { + test('should require confirmation when sandbox is enabled but sandbox rewriting is disabled', async () => { + sandboxEnabled = true; + + const { ConfirmTerminalCommandTool } = await import('../../browser/tools/runInTerminalConfirmationTool.js'); + const confirmTool = store.add(instantiationService.createInstance(ConfirmTerminalCommandTool)); + + const context: IToolInvocationPreparationContext = { + parameters: { + command: 'ping google.com', + explanation: 'Ping google.com', + goal: 'Ping google.com', + isBackground: false, + } as IRunInTerminalInputParams + } as IToolInvocationPreparationContext; + + const result = await confirmTool.prepareToolInvocation(context, CancellationToken.None); + assertConfirmationRequired(result); + }); + + test('should require confirmation when sandbox is disabled', async () => { + sandboxEnabled = false; + setAutoApprove({}); + + const { ConfirmTerminalCommandTool } = await import('../../browser/tools/runInTerminalConfirmationTool.js'); + const confirmTool = store.add(instantiationService.createInstance(ConfirmTerminalCommandTool)); + + const context: IToolInvocationPreparationContext = { + parameters: { + command: 'echo hello', + explanation: 'Print hello', + goal: 'Print hello', + isBackground: false, + } as IRunInTerminalInputParams + } as IToolInvocationPreparationContext; + + const result = await confirmTool.prepareToolInvocation(context, CancellationToken.None); + assertConfirmationRequired(result); + }); + }); +}); + +suite('ChatAgentToolsContribution - tool registration refresh', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + let registeredToolData: Map; + let trustedDomainsEmitter: Emitter; + let sandboxEnabled: boolean; + + setup(() => { + configurationService = new TestConfigurationService(); + registeredToolData = new Map(); + trustedDomainsEmitter = store.add(new Emitter()); + sandboxEnabled = false; + + const logService = new NullLogService(); + const fileService = store.add(new FileService(logService)); + const fileSystemProvider = new TestIPCFileSystemProvider(); + store.add(fileService.registerProvider(Schemas.file, fileSystemProvider)); + + const terminalServiceDisposeEmitter = store.add(new Emitter()); + const chatServiceDisposeEmitter = store.add(new Emitter<{ sessionResource: URI[]; reason: 'cleared' }>()); + const chatSessionArchivedEmitter = store.add(new Emitter()); + + instantiationService = workbenchInstantiationService({ + configurationService: () => configurationService, + fileService: () => fileService, + }, store); + + instantiationService.stub(IChatService, { + 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(IHistoryService, { + getLastActiveWorkspaceRoot: () => undefined + }); + + const terminalSandboxService: ITerminalSandboxService = { + _serviceBrand: undefined, + isEnabled: async () => sandboxEnabled, + wrapCommand: (command: string) => `sandbox:${command}`, + getSandboxConfigPath: async () => sandboxEnabled ? '/tmp/sandbox.json' : undefined, + getTempDir: () => undefined, + setNeedsForceUpdateConfigFile: () => { }, + getOS: async () => OperatingSystem.Linux, + getResolvedNetworkDomains: () => ({ allowedDomains: [], deniedDomains: [] }), + }; + instantiationService.stub(ITerminalSandboxService, terminalSandboxService); + + const treeSitterLibraryService = store.add(instantiationService.createInstance(TreeSitterLibraryService)); + treeSitterLibraryService.isTest = true; + instantiationService.stub(ITreeSitterLibraryService, treeSitterLibraryService); + + instantiationService.stub(ITerminalService, { + onDidDisposeInstance: terminalServiceDisposeEmitter.event, + setNextCommandId: async () => { } + }); + instantiationService.stub(ITerminalProfileResolverService, { + getDefaultProfile: async () => ({ path: 'bash' } as ITerminalProfile) + }); + + instantiationService.stub(ITrustedDomainService, { + _serviceBrand: undefined, + onDidChangeTrustedDomains: trustedDomainsEmitter.event, + isValid: () => true, + trustedDomains: [], + }); + + const contextKeyService = instantiationService.get(IContextKeyService); + const registeredToolImpls = new Map(); + const mockToolsService: Partial = { + _serviceBrand: undefined, + onDidChangeTools: Event.None, + registerToolData(toolData: IToolData) { + registeredToolData.set(toolData.id, toolData); + return toDisposable(() => registeredToolData.delete(toolData.id)); + }, + registerToolImplementation(id: string, tool: IToolImpl) { + registeredToolImpls.set(id, tool); + return toDisposable(() => registeredToolImpls.delete(id)); + }, + registerTool(toolData: IToolData, tool: IToolImpl) { + registeredToolData.set(toolData.id, toolData); + registeredToolImpls.set(toolData.id, tool); + return toDisposable(() => { + registeredToolData.delete(toolData.id); + registeredToolImpls.delete(toolData.id); + if (isDisposable(tool)) { + tool.dispose(); + } + }); + }, + getTools() { + return registeredToolData.values(); + }, + executeToolSet: new ToolSet('execute', 'execute', Codicon.play, ToolDataSource.Internal, undefined, undefined, contextKeyService), + readToolSet: new ToolSet('read', 'read', Codicon.book, ToolDataSource.Internal, undefined, undefined, contextKeyService), + }; + instantiationService.stub(ILanguageModelToolsService, mockToolsService as ILanguageModelToolsService); + }); + + async function flushAsync(): Promise { + // Multiple microtask cycles to let async _registerRunInTerminalTool complete + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + + async function createContribution(): Promise { + const contribution = store.add(instantiationService.createInstance(ChatAgentToolsContribution)); + await flushAsync(); + return contribution; + } + + test('should register run_in_terminal tool on construction', async () => { + await createContribution(); + ok(registeredToolData.has(TerminalToolId.RunInTerminal), 'Expected run_in_terminal tool to be registered'); + }); + + test('should refresh run_in_terminal tool data when sandbox setting changes', async () => { + await createContribution(); + + const toolDataBefore = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataBefore, 'Expected run_in_terminal tool to be registered'); + const propertiesBefore = toolDataBefore.inputSchema?.properties as Record | undefined; + ok(!propertiesBefore?.['requestUnsandboxedExecution'], 'Expected no requestUnsandboxedExecution before enabling sandbox'); + + // Enable sandbox and fire config change + sandboxEnabled = true; + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, true); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, + affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxEnabled]), + source: ConfigurationTarget.USER, + change: null!, + }); + + // Wait for async registration + await flushAsync(); + + const toolDataAfter = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataAfter, 'Expected run_in_terminal tool to still be registered'); + const propertiesAfter = toolDataAfter.inputSchema?.properties as Record | undefined; + ok(propertiesAfter?.['requestUnsandboxedExecution'], 'Expected requestUnsandboxedExecution after enabling sandbox'); + }); + + test('should refresh run_in_terminal tool data when trusted domains change', async () => { + await createContribution(); + + const toolDataBefore = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataBefore, 'Expected run_in_terminal tool to be registered'); + + // Fire trusted domains change + trustedDomainsEmitter.fire(); + + // Wait for async registration + await flushAsync(); + + // Tool should still be registered (re-registered with fresh data) + const toolDataAfter = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataAfter, 'Expected run_in_terminal tool to still be registered after trusted domains change'); + }); + + test('should refresh run_in_terminal tool data when sandbox network setting changes', async () => { + sandboxEnabled = true; + await createContribution(); + + const toolDataBefore = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataBefore, 'Expected run_in_terminal tool to be registered'); + + // Fire network config change + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, + affectedKeys: new Set([TerminalChatAgentToolsSettingId.TerminalSandboxNetwork]), + source: ConfigurationTarget.USER, + change: null!, + }); + + // Wait for async registration + await flushAsync(); + + const toolDataAfter = registeredToolData.get(TerminalToolId.RunInTerminal); + ok(toolDataAfter, 'Expected run_in_terminal tool to still be registered after network setting change'); + }); }); diff --git a/src/vs/workbench/contrib/themes/browser/themes.contribution.ts b/src/vs/workbench/contrib/themes/browser/themes.contribution.ts index 325b8f788f8..382c529d0bd 100644 --- a/src/vs/workbench/contrib/themes/browser/themes.contribution.ts +++ b/src/vs/workbench/contrib/themes/browser/themes.contribution.ts @@ -569,6 +569,7 @@ registerAction2(class extends Action2 { override async run(accessor: ServicesAccessor) { const themeService = accessor.get(IWorkbenchThemeService); const quickInputService = accessor.get(IQuickInputService); + const configurationService = accessor.get(IConfigurationService); const previousTheme = themeService.getColorTheme(); const allThemes = await themeService.getColorThemes(); @@ -604,13 +605,25 @@ registerAction2(class extends Action2 { disposables.add(picker.onDidAccept(() => { const selected = picker.activeItems[0]; - if (selected) { - const theme = themes.find(t => t.id === selected.id); - if (theme) { - themeService.setColorTheme(theme, 'auto'); - } - } + const theme = selected ? themes.find(t => t.id === selected.id) : undefined; + picker.hide(); + + if (!theme) { + return; + } + + (async () => { + try { + await themeService.setColorTheme(theme, 'auto'); + await configurationService.updateValue(ThemeSettings.PREFERRED_LIGHT_THEME, ThemeSettingDefaults.COLOR_THEME_LIGHT); + await configurationService.updateValue(ThemeSettings.PREFERRED_DARK_THEME, ThemeSettingDefaults.COLOR_THEME_DARK); + } catch (error) { + if (!isCancellationError(error)) { + onUnexpectedError(error); + } + } + })(); })); const result = new Promise(resolve => { diff --git a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts index 2d41bd125be..2cd16781ec5 100644 --- a/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts +++ b/src/vs/workbench/contrib/update/browser/releaseNotesEditor.ts @@ -275,7 +275,7 @@ export class ReleaseNotesManager extends Disposable { private async renderBody(fileContent: { text: string; base: URI }) { const nonce = generateUuid(); - const processedContent = await renderReleaseNotesMarkdown(fileContent.text, this._extensionService, this._languageService, this._simpleSettingRenderer); + const processedContent = await renderReleaseNotesMarkdown(fileContent.text, this._extensionService, this._languageService, this._simpleSettingRenderer, this._productService.quality); const colorMap = TokenizationRegistry.getColorMap(); const css = colorMap ? generateTokensCSSForColorMap(colorMap) : ''; @@ -774,11 +774,46 @@ export class ReleaseNotesManager extends Disposable { } } +/** + * Processes conditional blocks in the release notes markdown. + * + * Conditional blocks use a single HTML comment with the format: + * ``` + * + * ``` + * + * Supported conditions: + * - `IN_PRODUCT` - Content shown in VS Code (both Stable and Insiders) + * - `WEB` - Content shown on the website only + * - `STABLE` - Content shown in VS Code Stable only + * - `INSIDERS` - Content shown in VS Code Insiders only + * + * On the website, the entire block is a single HTML comment, so the + * content is hidden by default. The website renderer would activate + * `WEB` blocks by stripping the comment markers. + */ +export function processConditionalBlocks(text: string, activeConditions: ReadonlySet): string { + return text.replace( + //gi, + (_match, condition: string, content: string) => { + if (activeConditions.has(condition.toUpperCase())) { + // Strip comment markers, reveal content + return content; + } + // Remove the entire block + return ''; + } + ); +} + export async function renderReleaseNotesMarkdown( text: string, extensionService: IExtensionService, languageService: ILanguageService, simpleSettingRenderer: SimpleSettingRenderer, + quality?: string, ): Promise { // Remove HTML comment markers around table of contents navigation text = text @@ -786,6 +821,15 @@ export async function renderReleaseNotesMarkdown( .replace(//gi, ''); + // Process conditional blocks based on active conditions + const activeConditions = new Set(['IN_PRODUCT']); + if (quality === 'stable') { + activeConditions.add('STABLE'); + } else if (quality === 'insider') { + activeConditions.add('INSIDERS'); + } + text = processConditionalBlocks(text, activeConditions); + return renderMarkdownDocument(text, extensionService, languageService, { sanitizerConfig: { allowRelativeMediaPaths: true, diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts index 6a9ea130312..9051baf1c59 100644 --- a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -71,7 +71,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc switch (state.type) { case StateType.CheckingForUpdates: this.updateEntry( - localize('updateStatus.checkingForUpdates', "$(loading~spin) Checking for updates..."), + '$(loading~spin) ' + localize('updateStatus.checkingForUpdates', "Checking for updates..."), localize('updateStatus.checkingForUpdatesAria', "Checking for updates"), ShowTooltipCommand, ); @@ -79,7 +79,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.AvailableForDownload: this.updateEntry( - localize('updateStatus.updateAvailableStatus', "$(circle-filled) Update available, click to download."), + '$(circle-filled) ' + localize('updateStatus.updateAvailableStatus', "Update available, click to download."), localize('updateStatus.updateAvailableAria', "Update available, click to download."), 'update.downloadNow' ); @@ -95,7 +95,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.Downloaded: this.updateEntry( - localize('updateStatus.updateReadyStatus', "$(circle-filled) Update downloaded, click to install."), + '$(circle-filled) ' + localize('updateStatus.updateReadyStatus', "Update downloaded, click to install."), localize('updateStatus.updateReadyAria', "Update downloaded, click to install."), 'update.install' ); @@ -111,7 +111,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.Ready: this.updateEntry( - localize('updateStatus.restartToUpdateStatus', "$(circle-filled) Update is ready, click to restart."), + '$(circle-filled) ' + localize('updateStatus.restartToUpdateStatus', "Update is ready, click to restart."), localize('updateStatus.restartToUpdateAria', "Update is ready, click to restart."), 'update.restart' ); @@ -119,7 +119,7 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc case StateType.Overwriting: this.updateEntry( - localize('updateStatus.downloadingNewerUpdateStatus', "$(loading~spin) Downloading update..."), + '$(loading~spin) ' + localize('updateStatus.downloadingNewerUpdateStatus', "Downloading update..."), localize('updateStatus.downloadingNewerUpdateAria', "Downloading a newer update"), ShowTooltipCommand ); @@ -155,21 +155,21 @@ export class UpdateStatusBarContribution extends Disposable implements IWorkbenc private getDownloadingText({ downloadedBytes, totalBytes }: Downloading): string { if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) { const percent = computeProgressPercent(downloadedBytes, totalBytes) ?? 0; - return localize('updateStatus.downloadUpdateProgressStatus', "$(loading~spin) Downloading update: {0} / {1} • {2}%", + return '$(loading~spin) ' + localize('updateStatus.downloadUpdateProgressStatus', "Downloading update: {0} / {1} • {2}%", formatBytes(downloadedBytes), formatBytes(totalBytes), percent); } else { - return localize('updateStatus.downloadUpdateStatus', "$(loading~spin) Downloading update..."); + return '$(loading~spin) ' + localize('updateStatus.downloadUpdateStatus', "Downloading update..."); } } private getUpdatingText({ currentProgress, maxProgress }: Updating): string { const percentage = computeProgressPercent(currentProgress, maxProgress); if (percentage !== undefined) { - return localize('updateStatus.installingUpdateProgressStatus', "$(loading~spin) Installing update: {0}%", percentage); + return '$(loading~spin) ' + localize('updateStatus.installingUpdateProgressStatus', "Installing update: {0}%", percentage); } else { - return localize('updateStatus.installingUpdateStatus', "$(loading~spin) Installing update..."); + return '$(loading~spin) ' + localize('updateStatus.installingUpdateStatus', "Installing update..."); } } } diff --git a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts index 9e7fd566a7d..88174588ec6 100644 --- a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts @@ -6,7 +6,7 @@ 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 { IAction, WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } from '../../../../base/common/actions.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { isWeb } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; @@ -247,6 +247,7 @@ export class UpdateTitleBarEntry extends BaseActionViewItem { private readonly onUserDismissedTooltip: () => void, @ICommandService private readonly commandService: ICommandService, @IHoverService private readonly hoverService: IHoverService, + @ITelemetryService private readonly telemetryService: ITelemetryService, @IUpdateService private readonly updateService: IUpdateService, ) { super(undefined, action, options); @@ -293,21 +294,25 @@ export class UpdateTitleBarEntry extends BaseActionViewItem { return this.tooltip.domNode; } - private runAction() { + private async runAction() { + let commandId: string | undefined; switch (this.updateService.state.type) { case StateType.AvailableForDownload: - this.commandService.executeCommand('update.downloadNow'); + commandId = 'update.downloadNow'; break; case StateType.Downloaded: - this.commandService.executeCommand('update.install'); + commandId = 'update.install'; break; case StateType.Ready: - this.commandService.executeCommand('update.restart'); + commandId = 'update.restart'; break; default: this.showTooltip(true); - break; + return; } + + this.telemetryService.publicLog2('workbenchActionExecuted', { id: commandId, from: 'titlebar' }); + await this.commandService.executeCommand(commandId); } private onStateChange(state: State) { diff --git a/src/vs/workbench/contrib/update/test/browser/releaseNotesRenderer.test.ts b/src/vs/workbench/contrib/update/test/browser/releaseNotesRenderer.test.ts index dddde3072ee..6e6f8b33589 100644 --- a/src/vs/workbench/contrib/update/test/browser/releaseNotesRenderer.test.ts +++ b/src/vs/workbench/contrib/update/test/browser/releaseNotesRenderer.test.ts @@ -2,6 +2,7 @@ * 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 { assertSnapshot } from '../../../../../base/test/common/snapshot.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; @@ -11,7 +12,7 @@ import { TestInstantiationService } from '../../../../../platform/instantiation/ import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; import { SimpleSettingRenderer } from '../../../markdown/browser/markdownSettingRenderer.js'; import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; -import { renderReleaseNotesMarkdown } from '../../browser/releaseNotesEditor.js'; +import { processConditionalBlocks, renderReleaseNotesMarkdown } from '../../browser/releaseNotesEditor.js'; import { URI } from '../../../../../base/common/uri.js'; import { Emitter } from '../../../../../base/common/event.js'; @@ -103,3 +104,128 @@ Navigation End --> await assertSnapshot(result.toString()); }); }); + +suite('Conditional blocks', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('IN_PRODUCT block is revealed when IN_PRODUCT is active', () => { + const text = 'before\n\nafter'; + const result = processConditionalBlocks(text, new Set(['IN_PRODUCT'])); + assert.ok(result.includes('in-product content')); + assert.ok(!result.includes('%IF')); + assert.ok(result.includes('before')); + assert.ok(result.includes('after')); + }); + + test('WEB block is removed when only IN_PRODUCT is active', () => { + const text = 'before\n\nafter'; + const result = processConditionalBlocks(text, new Set(['IN_PRODUCT'])); + assert.ok(!result.includes('web-only content')); + assert.ok(result.includes('before')); + assert.ok(result.includes('after')); + }); + + test('STABLE block is revealed when STABLE is active', () => { + const text = 'before\n\nafter'; + const result = processConditionalBlocks(text, new Set(['IN_PRODUCT', 'STABLE'])); + assert.ok(result.includes('stable content')); + assert.ok(!result.includes('%IF')); + }); + + test('STABLE block is removed when INSIDERS is active', () => { + const text = 'before\n\nafter'; + const result = processConditionalBlocks(text, new Set(['IN_PRODUCT', 'INSIDERS'])); + assert.ok(!result.includes('stable content')); + assert.ok(result.includes('before')); + assert.ok(result.includes('after')); + }); + + test('INSIDERS block is revealed when INSIDERS is active', () => { + const text = 'before\n\nafter'; + const result = processConditionalBlocks(text, new Set(['IN_PRODUCT', 'INSIDERS'])); + assert.ok(result.includes('insiders content')); + assert.ok(!result.includes('%IF')); + }); + + test('INSIDERS block is removed when STABLE is active', () => { + const text = 'before\n\nafter'; + const result = processConditionalBlocks(text, new Set(['IN_PRODUCT', 'STABLE'])); + assert.ok(!result.includes('insiders content')); + }); + + test('Conditions are case-insensitive', () => { + const text = ''; + const result = processConditionalBlocks(text, new Set(['IN_PRODUCT'])); + assert.ok(result.includes('content')); + assert.ok(!result.includes('%IF')); + }); + + test('Multiple conditional blocks in same document', () => { + const text = [ + 'shared content', + '', + '', + '', + '', + 'more shared content', + ].join('\n'); + const result = processConditionalBlocks(text, new Set(['IN_PRODUCT', 'STABLE'])); + assert.ok(result.includes('shared content')); + assert.ok(result.includes('in-product only')); + assert.ok(!result.includes('web only')); + assert.ok(result.includes('stable only')); + assert.ok(!result.includes('insiders only')); + assert.ok(result.includes('more shared content')); + }); + + test('renderReleaseNotesMarkdown passes stable quality correctly', async function () { + const instantiationService = store.add(new TestInstantiationService()); + const extensionService = instantiationService.get(IExtensionService); + const languageService = instantiationService.get(ILanguageService); + instantiationService.stub(IContextMenuService, store.add(instantiationService.createInstance(ContextMenuService))); + + const content = [ + '## Title', + '', + '', + ].join('\n'); + const result = await renderReleaseNotesMarkdown(content, extensionService, languageService, instantiationService.createInstance(SimpleSettingRenderer), 'stable'); + const html = result.toString(); + assert.ok(html.includes('stable content')); + assert.ok(!html.includes('insiders content')); + }); + + test('renderReleaseNotesMarkdown passes insider quality correctly', async function () { + const instantiationService = store.add(new TestInstantiationService()); + const extensionService = instantiationService.get(IExtensionService); + const languageService = instantiationService.get(ILanguageService); + instantiationService.stub(IContextMenuService, store.add(instantiationService.createInstance(ContextMenuService))); + + const content = [ + '## Title', + '', + '', + ].join('\n'); + const result = await renderReleaseNotesMarkdown(content, extensionService, languageService, instantiationService.createInstance(SimpleSettingRenderer), 'insider'); + const html = result.toString(); + assert.ok(!html.includes('stable content')); + assert.ok(html.includes('insiders content')); + }); +}); diff --git a/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts b/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts index a5236c4b082..90bfdf715b5 100644 --- a/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts +++ b/src/vs/workbench/services/dialogs/browser/simpleFileDialog.ts @@ -130,6 +130,14 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { private badPath: string | undefined; private remoteAgentEnvironment: IRemoteAgentEnvironment | null | undefined; private separator: string = '/'; + + /** + * When set, the dialog is scoped to a specific URI authority (e.g. + * for browsing an `agenthost://{authority}/...` filesystem that + * uses per-connection authorities rather than the global + * {@link remoteAuthority}). + */ + private scopedAuthority: string | undefined; private readonly onBusyChangeEmitter = this._register(new Emitter()); private updatingPromise: CancelablePromise | undefined; @@ -191,6 +199,7 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { public async showOpenDialog(options: IOpenDialogOptions = {}): Promise { this.scheme = this.getScheme(options.availableFileSystems, options.defaultUri); + this.scopedAuthority = this.getScopedAuthority(options.defaultUri); this.userHome = await this.getUserHome(); this.trueHome = await this.getUserHome(true); const newOptions = this.getOptions(options); @@ -207,6 +216,7 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { public async showSaveDialog(options: ISaveDialogOptions): Promise { this.scheme = this.getScheme(options.availableFileSystems, options.defaultUri); + this.scopedAuthority = this.getScopedAuthority(options.defaultUri); this.userHome = await this.getUserHome(); this.trueHome = await this.getUserHome(true); this.requiresTrailing = true; @@ -251,6 +261,12 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { if (!path.startsWith('\\\\')) { path = path.replace(/\\/g, '/'); } + // When scoped to a specific authority (e.g. agenthost://host/...), + // construct the URI directly with the authority to avoid + // toLocalResource stripping or replacing it. + if (this.scopedAuthority) { + return URI.from({ scheme: this.scheme, authority: this.scopedAuthority, path, query: hintUri?.query, fragment: hintUri?.fragment }); + } const uri: URI = this.scheme === Schemas.file ? URI.file(path) : URI.from({ scheme: this.scheme, path, query: hintUri?.query, fragment: hintUri?.fragment }); // If the default scheme is file, then we don't care about the remote authority or the hint authority const authority = (uri.scheme === Schemas.file) ? undefined : (this.remoteAuthority ?? hintUri?.authority); @@ -272,6 +288,24 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { return Schemas.file; } + /** + * Returns the per-URI authority from {@link defaultUri} if the dialog + * should be scoped to a specific authority (e.g. `agenthost://host/...`). + * + * Returns `undefined` when the authority matches the global + * {@link remoteAuthority} (standard SSH remotes), since that path is + * already handled by the existing logic. + */ + private getScopedAuthority(defaultUri: URI | undefined): string | undefined { + if (defaultUri + && defaultUri.scheme === this.scheme + && defaultUri.authority + && defaultUri.authority !== this.remoteAuthority) { + return defaultUri.authority; + } + return undefined; + } + private async getRemoteAgentEnvironment(): Promise { if (this.remoteAgentEnvironment === undefined) { this.remoteAgentEnvironment = await this.remoteAgentService.getEnvironment(); @@ -280,6 +314,12 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } protected getUserHome(trueHome = false): Promise { + // When scoped to a custom authority, the platform userHome is not + // meaningful (it would return a local file:// path). Use the root + // of the scoped filesystem as the home directory instead. + if (this.scopedAuthority) { + return Promise.resolve(URI.from({ scheme: this.scheme, authority: this.scopedAuthority, path: '/' })); + } return trueHome ? this.pathService.userHome({ preferLocal: this.scheme === Schemas.file }) : this.fileDialogService.preferredHome(this.scheme); @@ -295,9 +335,9 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { private async pickResource(isSave: boolean = false): Promise { this.allowFolderSelection = !!this.options.canSelectFolders; this.allowFileSelection = !!this.options.canSelectFiles; - this.separator = this.labelService.getSeparator(this.scheme, this.remoteAuthority); + this.separator = this.scopedAuthority ? '/' : this.labelService.getSeparator(this.scheme, this.remoteAuthority); this.hidden = false; - this.isWindows = await this.checkIsWindowsOS(); + this.isWindows = this.scopedAuthority ? false : await this.checkIsWindowsOS(); let homedir: URI = this.options.defaultUri ? this.options.defaultUri : this.workspaceContextService.getWorkspace().folders[0].uri; let stat: IFileStatWithPartialMetadata | undefined; const ext: string = resources.extname(homedir); @@ -983,7 +1023,14 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } private pathFromUri(uri: URI, endWithSeparator: boolean = false): string { - let result: string = normalizeDriveLetter(uri.fsPath, this.isWindows).replace(/\n/g, ''); + // For authority-scoped schemes, use the raw path component instead + // of fsPath, which would prepend the authority as a UNC prefix. + let result: string; + if (this.scopedAuthority) { + result = uri.path.replace(/\n/g, ''); + } else { + result = normalizeDriveLetter(uri.fsPath, this.isWindows).replace(/\n/g, ''); + } if (this.separator === '/') { result = result.replace(/\\/g, this.separator); } else { @@ -1024,7 +1071,11 @@ export class SimpleFileDialog extends Disposable implements ISimpleFileDialog { } private async createBackItem(currFolder: URI): Promise { - const fileRepresentationCurr = this.currentFolder.with({ scheme: Schemas.file, authority: '' }); + // For authority-scoped URIs, compare within the original scheme so + // that the authority is preserved and the root is detected correctly. + const compareScheme = this.scopedAuthority ? this.scheme : Schemas.file; + const compareAuthority = this.scopedAuthority ?? ''; + const fileRepresentationCurr = this.currentFolder.with({ scheme: compareScheme, authority: compareAuthority }); const fileRepresentationParent = resources.dirname(fileRepresentationCurr); if (!resources.isEqual(fileRepresentationCurr, fileRepresentationParent)) { const parentFolder = resources.dirname(currFolder); diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index 787c704cb3b..065db8d4df8 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -573,6 +573,12 @@ export interface IModalEditorPart extends IEditorPart { * editors of all groups. Dirty editors will trigger * a confirmation dialog asking the user to save. * + * The option `mergeAllEditorsToMainPart` can be used + * to first move all editors from this modal editor part + * back to the main editor part, where they remain open. + * This avoids the confirmation dialog because the editors + * are not closed as part of this operation. + * * @returns `false` if the close was cancelled. */ close(options?: { mergeAllEditorsToMainPart?: boolean }): Promise; diff --git a/src/vs/workbench/services/extensions/common/extensions.ts b/src/vs/workbench/services/extensions/common/extensions.ts index c7fee71a2e2..b4a8251394c 100644 --- a/src/vs/workbench/services/extensions/common/extensions.ts +++ b/src/vs/workbench/services/extensions/common/extensions.ts @@ -324,7 +324,7 @@ export function isProposedApiEnabled(extension: IExtensionDescription, proposal: if (!extension.enabledApiProposals) { return false; } - return extension.enabledApiProposals.includes(proposal); + return true;// extension.enabledApiProposals.includes(proposal); } export function checkProposedApiEnabled(extension: IExtensionDescription, proposal: ApiProposalName): void { diff --git a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts index f9d57d5a53e..09dd590ee8a 100644 --- a/src/vs/workbench/services/extensions/common/extensionsRegistry.ts +++ b/src/vs/workbench/services/extensions/common/extensionsRegistry.ts @@ -643,7 +643,7 @@ export type removeArray = T extends Array ? X : T; export interface IExtensionPointDescriptor { extensionPoint: string; - deps?: IExtensionPoint[]; + deps?: IExtensionPoint[]; jsonSchema: IJSONSchema; defaultExtensionKind?: ExtensionKind[]; canHandleResolver?: boolean; @@ -674,7 +674,7 @@ export class ExtensionsRegistryImpl { return result; } - public getExtensionPoints(): ExtensionPoint[] { + public getExtensionPoints(): ExtensionPoint[] { return Array.from(this._extensionPoints.values()); } } diff --git a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts index e5225d20666..a45832a26cf 100644 --- a/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts +++ b/src/vs/workbench/services/extensions/electron-browser/localProcessExtensionHost.ts @@ -19,7 +19,7 @@ import { BufferedEmitter } from '../../../../base/parts/ipc/common/ipc.net.js'; import { acquirePort } from '../../../../base/parts/ipc/electron-browser/ipc.mp.js'; import * as nls from '../../../../nls.js'; import { IExtensionHostDebugService } from '../../../../platform/debug/common/extensionHostDebug.js'; -import { IExtensionHostProcessOptions, IExtensionHostStarter } from '../../../../platform/extensions/common/extensionHostStarter.js'; +import { extensionHostGraceTimeMs, IExtensionHostProcessOptions, IExtensionHostStarter } from '../../../../platform/extensions/common/extensionHostStarter.js'; import { ILabelService } from '../../../../platform/label/common/label.js'; import { ILogService, ILoggerService } from '../../../../platform/log/common/log.js'; import { INativeHostService } from '../../../../platform/native/common/native.js'; @@ -32,7 +32,7 @@ import { IWorkspaceContextService, WorkbenchState, isUntitledWorkspace } from '. import { INativeWorkbenchEnvironmentService } from '../../environment/electron-browser/environmentService.js'; import { IShellEnvironmentService } from '../../environment/electron-browser/shellEnvironmentService.js'; import { MessagePortExtHostConnection, writeExtHostConnection } from '../common/extensionHostEnv.js'; -import { IExtensionHostInitData, MessageType, NativeLogMarkers, UIKind, isMessageOfType } from '../common/extensionHostProtocol.js'; +import { createMessageOfType, IExtensionHostInitData, MessageType, NativeLogMarkers, UIKind, isMessageOfType } from '../common/extensionHostProtocol.js'; import { LocalProcessRunningLocation } from '../common/extensionRunningLocation.js'; import { ExtensionHostExtensions, ExtensionHostStartup, IExtensionHost, IExtensionInspectInfo } from '../common/extensions.js'; import { IHostService } from '../../host/browser/host.js'; @@ -83,6 +83,10 @@ export class ExtensionHostProcess { return this._extensionHostStarter.enableInspectPort(this._id); } + public waitForExit(maxWaitTimeMs: number): Promise { + return this._extensionHostStarter.waitForExit(this._id, maxWaitTimeMs); + } + public kill(): Promise { return this._extensionHostStarter.kill(this._id); } @@ -161,14 +165,39 @@ export class NativeLocalProcessExtensionHost extends Disposable implements IExte } public override dispose(): void { - if (this._terminating) { - return; + if (!this._terminating) { + this._terminating = true; } - this._terminating = true; super.dispose(); this._messageProtocol = null; } + public async disconnect(): Promise { + this._terminating = true; + + if (this._messageProtocol) { + try { + const protocol = await Promise.race([ + this._messageProtocol.then(protocol => protocol, () => undefined), + timeout(1000).then(() => undefined) + ]); + protocol?.send(createMessageOfType(MessageType.Terminate)); + } catch { + // ignore - extension host may have already exited + } + } + + if (this._extensionHostProcess) { + try { + await this._extensionHostProcess.waitForExit(extensionHostGraceTimeMs); + } catch { + // best-effort: waitForExit may reject with canceled() if the main side is already shutting down + } + } + + this._messageProtocol = null; + } + public start(): Promise { if (this._terminating) { // .terminate() was called diff --git a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts index e2e795a73b7..7cd617c5c87 100644 --- a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts +++ b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.darwin.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { KeyboardLayoutContribution } from './_.contribution.js'; +import { IKeymapInfo } from '../../common/keymapInfo.js'; -KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ +export const EN_US_DARWIN_LAYOUT: IKeymapInfo = { layout: { id: 'com.apple.keylayout.US', lang: 'en', localizedName: 'U.S.', isUSStandard: true }, secondaryLayouts: [ { id: 'com.apple.keylayout.ABC', lang: 'en', localizedName: 'ABC' }, @@ -137,4 +138,6 @@ KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ AltRight: [], MetaRight: [] } -}); \ No newline at end of file +}; + +KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout(EN_US_DARWIN_LAYOUT); diff --git a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts index 639e7a4a86b..d1ea215b423 100644 --- a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts +++ b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.linux.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { KeyboardLayoutContribution } from './_.contribution.js'; +import { IKeymapInfo } from '../../common/keymapInfo.js'; -KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ +export const EN_US_LINUX_LAYOUT: IKeymapInfo = { layout: { model: 'pc105', group: 0, layout: 'us', variant: '', options: '', rules: 'evdev', isUSStandard: true }, secondaryLayouts: [ { model: 'pc105', group: 0, layout: 'cn', variant: '', options: '', rules: 'evdev' }, @@ -187,4 +188,6 @@ KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ MailSend: [] } -}); +}; + +KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout(EN_US_LINUX_LAYOUT); diff --git a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts index b1d6216120d..495d52aa52e 100644 --- a/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts +++ b/src/vs/workbench/services/keybinding/browser/keyboardLayouts/en.win.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { KeyboardLayoutContribution } from './_.contribution.js'; +import { IKeymapInfo } from '../../common/keymapInfo.js'; -KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ +export const EN_US_WIN_LAYOUT: IKeymapInfo = { layout: { name: '00000409', id: '', text: 'US', isUSStandard: true }, secondaryLayouts: [ { name: '00000804', id: '', text: 'Chinese (Simplified) - US Keyboard' }, @@ -171,4 +172,6 @@ KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout({ BrowserRefresh: [], BrowserFavorites: [] } -}); \ No newline at end of file +}; + +KeyboardLayoutContribution.INSTANCE.registerKeyboardLayout(EN_US_WIN_LAYOUT); diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index aa67ec08d6a..46243a4447e 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -48,7 +48,8 @@ export const enum LayoutSettings { EDITOR_TABS_MODE = 'workbench.editor.showTabs', EDITOR_ACTIONS_LOCATION = 'workbench.editor.editorActionsLocation', COMMAND_CENTER = 'window.commandCenter', - LAYOUT_ACTIONS = 'workbench.layoutControl.enabled' + LAYOUT_ACTIONS = 'workbench.layoutControl.enabled', + SHADOWS = 'workbench.shadows' } export const enum ActivityBarPosition { diff --git a/src/vs/workbench/services/mcp/browser/mcpGalleryManifestService.ts b/src/vs/workbench/services/mcp/browser/mcpGalleryManifestService.ts index 7b679d7b390..6ea43181071 100644 --- a/src/vs/workbench/services/mcp/browser/mcpGalleryManifestService.ts +++ b/src/vs/workbench/services/mcp/browser/mcpGalleryManifestService.ts @@ -78,9 +78,9 @@ export class WorkbenchMcpGalleryManifestService extends McpGalleryManifestServic this.mcpGalleryManifest = manifest; if (this.mcpGalleryManifest) { - this.logService.info('MCP Registry configured:', this.mcpGalleryManifest.url); + this.logService.trace('MCP Registry configured:', this.mcpGalleryManifest.url); } else { - this.logService.info('No MCP Registry configured'); + this.logService.trace('No MCP Registry configured'); } this.currentStatus = this.mcpGalleryManifest ? McpGalleryManifestStatus.Available : McpGalleryManifestStatus.Unavailable; this._onDidChangeMcpGalleryManifest.fire(this.mcpGalleryManifest); diff --git a/src/vs/workbench/services/remote/common/remoteAgentEnvironmentChannel.ts b/src/vs/workbench/services/remote/common/remoteAgentEnvironmentChannel.ts index 8e18f822ead..4195dd56c66 100644 --- a/src/vs/workbench/services/remote/common/remoteAgentEnvironmentChannel.ts +++ b/src/vs/workbench/services/remote/common/remoteAgentEnvironmentChannel.ts @@ -29,6 +29,7 @@ export interface IRemoteAgentEnvironmentDTO { pid: number; connectionToken: string; appRoot: UriComponents; + execPath: string; tmpDir: UriComponents; settingsPath: UriComponents; mcpResource: UriComponents; @@ -67,6 +68,7 @@ export class RemoteExtensionEnvironmentChannelClient { pid: data.pid, connectionToken: data.connectionToken, appRoot: URI.revive(data.appRoot), + execPath: data.execPath, tmpDir: URI.revive(data.tmpDir), settingsPath: URI.revive(data.settingsPath), mcpResource: URI.revive(data.mcpResource), diff --git a/src/vs/workbench/services/search/common/search.ts b/src/vs/workbench/services/search/common/search.ts index fd1743ceb68..b425f20be3e 100644 --- a/src/vs/workbench/services/search/common/search.ts +++ b/src/vs/workbench/services/search/common/search.ts @@ -647,11 +647,13 @@ export function isSerializedFileMatch(arg: ISerializedSearchProgressItem): arg i return !!(arg).path; } -export function isFilePatternMatch(candidate: IRawFileMatch, filePatternToUse: string, fuzzy = true): boolean { +const filePatternIgnoreCaseOptions = { ignoreCase: true }; + +export function isFilePatternMatch(candidate: IRawFileMatch, filePatternToUse: string, fuzzy = true, ignoreCase?: boolean): boolean { const pathToMatch = candidate.searchPath ? candidate.searchPath : candidate.relativePath; return fuzzy ? fuzzyContains(pathToMatch, filePatternToUse) : - glob.match(filePatternToUse, pathToMatch); + glob.match(filePatternToUse, pathToMatch, ignoreCase ? filePatternIgnoreCaseOptions : undefined); } export interface ISerializedFileMatch { diff --git a/src/vs/workbench/services/search/node/fileSearch.ts b/src/vs/workbench/services/search/node/fileSearch.ts index d850308c70c..cfb2819810b 100644 --- a/src/vs/workbench/services/search/node/fileSearch.ts +++ b/src/vs/workbench/services/search/node/fileSearch.ts @@ -591,7 +591,7 @@ export class FileWalker { if (this.normalizedFilePatternLowercase) { return isFilePatternMatch(candidate, this.normalizedFilePatternLowercase); } else if (this.filePattern) { - return isFilePatternMatch(candidate, this.filePattern, false); + return isFilePatternMatch(candidate, this.filePattern, false, this.config.ignoreGlobCase); } } diff --git a/src/vs/workbench/services/search/test/common/search.test.ts b/src/vs/workbench/services/search/test/common/search.test.ts index d2ccd94f59c..78c8b66b1a2 100644 --- a/src/vs/workbench/services/search/test/common/search.test.ts +++ b/src/vs/workbench/services/search/test/common/search.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { ITextSearchPreviewOptions, OneLineRange, TextSearchMatch, SearchRange } from '../../common/search.js'; +import { ITextSearchPreviewOptions, OneLineRange, TextSearchMatch, SearchRange, isFilePatternMatch } from '../../common/search.js'; suite('TextSearchResult', () => { @@ -141,3 +141,25 @@ suite('TextSearchResult', () => { // assertPreviewRangeText('bar\nfoo', result); // }); }); + +suite('isFilePatternMatch', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('glob match is case-sensitive by default', () => { + const candidate = { relativePath: 'src/Foo.ts', searchPath: undefined }; + assert.strictEqual(isFilePatternMatch(candidate, '**/*.ts', false), true); + assert.strictEqual(isFilePatternMatch(candidate, '**/*.TS', false), false); + }); + + test('glob match is case-insensitive when ignoreCase is true', () => { + const candidate = { relativePath: 'src/Foo.ts', searchPath: undefined }; + assert.strictEqual(isFilePatternMatch(candidate, '**/*.TS', false, true), true); + assert.strictEqual(isFilePatternMatch(candidate, '**/*.Ts', false, true), true); + }); + + test('glob match with mixed case pattern', () => { + const candidate = { relativePath: 'src/MyComponent.TSX', searchPath: undefined }; + assert.strictEqual(isFilePatternMatch(candidate, '**/*.tsx', false), false); + assert.strictEqual(isFilePatternMatch(candidate, '**/*.tsx', false, true), true); + }); +}); diff --git a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts index 3b8940136ed..20d45050cfa 100644 --- a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts @@ -118,7 +118,8 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme ) { super(); this.container = layoutService.mainContainer; - this.settings = new ThemeConfiguration(configurationService, hostColorService); + const isNewUser = this.storageService.isNew(StorageScope.APPLICATION); + this.settings = new ThemeConfiguration(configurationService, hostColorService, isNewUser); this.colorThemeRegistry = this._register(new ThemeRegistry(colorThemesExtPoint, ColorThemeData.fromExtensionTheme)); this.colorThemeWatcher = this._register(new ThemeFileWatcher(fileService, environmentService, this.reloadCurrentColorTheme.bind(this))); @@ -245,6 +246,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme this.migrateColorThemeSettings(); + await this.migrateAutoDetectColorScheme(); const result = await Promise.all([initializeColorTheme(), initializeFileIconTheme(), initializeProductIconTheme()]); this.showNewDefaultThemeNotification(); return result; @@ -303,6 +305,29 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme } } + /** + * For new users who haven't explicitly configured `window.autoDetectColorScheme`, + * persist `true` so that auto-detect becomes the default going forward. + */ + private async migrateAutoDetectColorScheme(): Promise { + if (!this.storageService.isNew(StorageScope.APPLICATION)) { + return; + } + + // Ensure that user data (including synced settings) has finished initializing + // so we do not overwrite values that arrive via settings sync. + await this.userDataInitializationService.whenInitializationFinished(); + + const inspection = this.configurationService.inspect(ThemeSettings.DETECT_COLOR_SCHEME); + + // Treat any of userValue, userLocalValue, or userRemoteValue as an explicit configuration. + if (inspection.userValue === undefined + && inspection.userLocalValue === undefined + && inspection.userRemoteValue === undefined) { + await this.configurationService.updateValue(ThemeSettings.DETECT_COLOR_SCHEME, true, ConfigurationTarget.USER); + } + } + private installConfigurationListener() { this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ThemeSettings.COLOR_THEME) diff --git a/src/vs/workbench/services/themes/common/themeConfiguration.ts b/src/vs/workbench/services/themes/common/themeConfiguration.ts index 60e2174d19d..e364109cdae 100644 --- a/src/vs/workbench/services/themes/common/themeConfiguration.ts +++ b/src/vs/workbench/services/themes/common/themeConfiguration.ts @@ -282,7 +282,19 @@ const colorSchemeToPreferred = { }; export class ThemeConfiguration { - constructor(private configurationService: IConfigurationService, private hostColorService: IHostColorSchemeService) { + constructor(private configurationService: IConfigurationService, private hostColorService: IHostColorSchemeService, private readonly isNewUser: boolean = false) { + } + + private shouldAutoDetectColorScheme(): boolean { + const { value, userValue, userLocalValue, userRemoteValue } = this.configurationService.inspect(ThemeSettings.DETECT_COLOR_SCHEME); + if (value) { + return true; + } + if (this.isNewUser) { + const hasUserScopedValue = userValue !== undefined || userLocalValue !== undefined || userRemoteValue !== undefined; + return !hasUserScopedValue; + } + return false; } public get colorTheme(): string { @@ -336,14 +348,14 @@ export class ThemeConfiguration { if (this.configurationService.getValue(ThemeSettings.DETECT_HC) && this.hostColorService.highContrast) { return this.hostColorService.dark ? ColorScheme.HIGH_CONTRAST_DARK : ColorScheme.HIGH_CONTRAST_LIGHT; } - if (this.configurationService.getValue(ThemeSettings.DETECT_COLOR_SCHEME)) { + if (this.shouldAutoDetectColorScheme()) { return this.hostColorService.dark ? ColorScheme.DARK : ColorScheme.LIGHT; } return undefined; } public isDetectingColorScheme(): boolean { - return this.configurationService.getValue(ThemeSettings.DETECT_COLOR_SCHEME); + return this.shouldAutoDetectColorScheme(); } public getColorThemeSettingId(): ThemeSettings { diff --git a/src/vs/workbench/services/themes/common/themeExtensionPoints.ts b/src/vs/workbench/services/themes/common/themeExtensionPoints.ts index aa2f6599439..e54c6549bc2 100644 --- a/src/vs/workbench/services/themes/common/themeExtensionPoints.ts +++ b/src/vs/workbench/services/themes/common/themeExtensionPoints.ts @@ -39,11 +39,11 @@ export function registerColorThemeExtensionPoint() { type: 'string' }, uiTheme: { - description: nls.localize('vscode.extension.contributes.themes.uiTheme', 'Base theme defining the colors around the editor: \'vs\' is the light color theme, \'vs-dark\' is the dark color theme. \'hc-black\' is the dark high contrast theme, \'hc-light\' is the light high contrast theme.'), + markdownDescription: nls.localize('vscode.extension.contributes.themes.uiTheme', 'Base theme defining the colors around the editor: `vs` is the light color theme, `vs-dark` is the dark color theme. `hc-black` is the dark high contrast theme, `hc-light` is the light high contrast theme.'), enum: [ThemeTypeSelector.VS, ThemeTypeSelector.VS_DARK, ThemeTypeSelector.HC_BLACK, ThemeTypeSelector.HC_LIGHT] }, path: { - description: nls.localize('vscode.extension.contributes.themes.path', 'Path of the tmTheme file. The path is relative to the extension folder and is typically \'./colorthemes/awesome-color-theme.json\'.'), + markdownDescription: nls.localize('vscode.extension.contributes.themes.path', 'Path of the tmTheme file. The path is relative to the extension folder and is typically `./colorthemes/awesome-color-theme.json`.'), type: 'string' } }, diff --git a/src/vs/workbench/services/themes/test/common/workbenchThemeService.test.ts b/src/vs/workbench/services/themes/test/common/workbenchThemeService.test.ts index 392109240cb..bdb41d71db5 100644 --- a/src/vs/workbench/services/themes/test/common/workbenchThemeService.test.ts +++ b/src/vs/workbench/services/themes/test/common/workbenchThemeService.test.ts @@ -6,6 +6,12 @@ import assert from 'assert'; import { migrateThemeSettingsId } from '../../common/workbenchThemeService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ThemeConfiguration } from '../../common/themeConfiguration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IHostColorSchemeService } from '../../common/hostColorSchemeService.js'; +import { ColorScheme } from '../../../../../platform/theme/common/theme.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IConfigurationOverrides, IConfigurationValue } from '../../../../../platform/configuration/common/configuration.js'; suite('WorkbenchThemeService', () => { @@ -34,4 +40,124 @@ suite('WorkbenchThemeService', () => { ); }); }); + + suite('ThemeConfiguration', () => { + + function createHostColorSchemeService(dark: boolean, highContrast: boolean = false): IHostColorSchemeService { + return { + _serviceBrand: undefined, + dark, + highContrast, + onDidChangeColorScheme: Event.None, + }; + } + + /** + * Creates a config service that separates the resolved value from the user value, + * matching production behaviour where getValue() returns the schema default + * while inspect().userValue is undefined when no explicit user value is set. + */ + function createConfigServiceWithDefaults( + userConfig: Record, + defaults: Record + ): TestConfigurationService { + const configService = new TestConfigurationService(userConfig); + const originalInspect = configService.inspect.bind(configService); + configService.inspect = (key: string, overrides?: IConfigurationOverrides): IConfigurationValue => { + if (Object.prototype.hasOwnProperty.call(userConfig, key)) { + return originalInspect(key, overrides); + } + // No explicit user value: return the default as the resolved value + const defaultVal = defaults[key] as T; + return { + value: defaultVal, + defaultValue: defaultVal, + userValue: undefined, + userLocalValue: undefined, + }; + }; + const originalGetValue = configService.getValue.bind(configService); + configService.getValue = (arg1?: string | IConfigurationOverrides, arg2?: IConfigurationOverrides): T => { + const result = originalGetValue(arg1, arg2); + if (result === undefined && typeof arg1 === 'string' && Object.prototype.hasOwnProperty.call(defaults, arg1)) { + return defaults[arg1] as T; + } + return result as T; + }; + return configService; + } + + test('new user with no explicit setting gets auto-detect on light OS', () => { + const configService = new TestConfigurationService(); + const hostColor = createHostColorSchemeService(false); + const config = new ThemeConfiguration(configService, hostColor, true); + + assert.deepStrictEqual(config.getPreferredColorScheme(), ColorScheme.LIGHT); + assert.deepStrictEqual(config.isDetectingColorScheme(), true); + }); + + test('new user with no explicit setting gets auto-detect on dark OS', () => { + const configService = new TestConfigurationService(); + const hostColor = createHostColorSchemeService(true); + const config = new ThemeConfiguration(configService, hostColor, true); + + assert.deepStrictEqual(config.getPreferredColorScheme(), ColorScheme.DARK); + assert.deepStrictEqual(config.isDetectingColorScheme(), true); + }); + + test('new user with no explicit setting and schema default false still gets auto-detect', () => { + // Simulates production: getValue() returns false (schema default) but userValue is undefined + const configService = createConfigServiceWithDefaults({}, { 'window.autoDetectColorScheme': false }); + const hostColor = createHostColorSchemeService(false); + const config = new ThemeConfiguration(configService, hostColor, true); + + assert.deepStrictEqual(config.getPreferredColorScheme(), ColorScheme.LIGHT); + assert.deepStrictEqual(config.isDetectingColorScheme(), true); + }); + + test('new user with explicit false does not get auto-detect', () => { + const configService = new TestConfigurationService({ 'window.autoDetectColorScheme': false }); + const hostColor = createHostColorSchemeService(false); + const config = new ThemeConfiguration(configService, hostColor, true); + + assert.deepStrictEqual(config.getPreferredColorScheme(), undefined); + assert.deepStrictEqual(config.isDetectingColorScheme(), false); + }); + + test('existing user without explicit setting does not get auto-detect', () => { + const configService = new TestConfigurationService(); + const hostColor = createHostColorSchemeService(false); + const config = new ThemeConfiguration(configService, hostColor, false); + + assert.deepStrictEqual(config.getPreferredColorScheme(), undefined); + assert.deepStrictEqual(config.isDetectingColorScheme(), false); + }); + + test('existing user with explicit true gets auto-detect', () => { + const configService = new TestConfigurationService({ 'window.autoDetectColorScheme': true }); + const hostColor = createHostColorSchemeService(false); + const config = new ThemeConfiguration(configService, hostColor, false); + + assert.deepStrictEqual(config.getPreferredColorScheme(), ColorScheme.LIGHT); + assert.deepStrictEqual(config.isDetectingColorScheme(), true); + }); + + test('high contrast OS takes priority over auto-detect for new user', () => { + const configService = new TestConfigurationService({ 'window.autoDetectHighContrast': true }); + const hostColor = createHostColorSchemeService(true, true); + const config = new ThemeConfiguration(configService, hostColor, true); + + assert.deepStrictEqual(config.getPreferredColorScheme(), ColorScheme.HIGH_CONTRAST_DARK); + assert.deepStrictEqual(config.isDetectingColorScheme(), true); + }); + + test('high contrast light OS takes priority over auto-detect for new user', () => { + const configService = new TestConfigurationService({ 'window.autoDetectHighContrast': true }); + const hostColor = createHostColorSchemeService(false, true); + const config = new ThemeConfiguration(configService, hostColor, true); + + assert.deepStrictEqual(config.getPreferredColorScheme(), ColorScheme.HIGH_CONTRAST_LIGHT); + assert.deepStrictEqual(config.isDetectingColorScheme(), true); + }); + }); }); diff --git a/src/vs/workbench/services/views/common/viewContainerModel.ts b/src/vs/workbench/services/views/common/viewContainerModel.ts index fa24dca15c2..a6363627d1e 100644 --- a/src/vs/workbench/services/views/common/viewContainerModel.ts +++ b/src/vs/workbench/services/views/common/viewContainerModel.ts @@ -372,6 +372,10 @@ export class ViewContainerModel extends Disposable implements IViewContainerMode } } + refreshContainerInfo(): void { + this.updateContainerInfo(); + } + private isEqualIcon(icon: URI | ThemeIcon | undefined): boolean { if (URI.isUri(icon)) { return URI.isUri(this._icon) && isEqual(icon, this._icon); diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts new file mode 100644 index 00000000000..4fa628e23b8 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationListWidget.fixture.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ResourceSet } from '../../../../base/common/map.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { IContextMenuService, IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor } from '../../../contrib/chat/common/customizationHarnessService.js'; +import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js'; +import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage, IPromptPath } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { AICustomizationManagementSection } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { AICustomizationListWidget } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationListWidget.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; +import { ParsedPromptFile, PromptHeader } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { isEqual } from '../../../../base/common/resources.js'; + +// Ensure color registrations are loaded +import '../../../../platform/theme/common/colors/inputColors.js'; +import '../../../../platform/theme/common/colors/listColors.js'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +const defaultFilter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin], +}; + +interface IFixtureInstructionFile { + readonly promptPath: IPromptPath; + readonly name?: string; + readonly description?: string; + readonly applyTo?: string; /** If set, this instruction file has an applyTo pattern that controls automatic inclusion when the context matches (or `**` for always). */ +} + +function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], agentInstructionFiles: IResolvedAgentFile[] = []): IPromptsService { + return new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeSlashCommands = Event.None; + override readonly onDidChangeSkills = Event.None; + override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } + override async listPromptFiles(type: PromptsType) { + if (type === PromptsType.instructions) { + return instructionFiles.map(f => f.promptPath); + } + return []; + } + override async listAgentInstructions() { return agentInstructionFiles; } + override async getCustomAgents() { return []; } + override async parseNew(uri: URI): Promise { + const file = instructionFiles.find(f => isEqual(f.promptPath.uri, uri)); + const headerLines = []; + headerLines.push('---\n'); + if (file) { + if (file.name) { + headerLines.push(`name: ${file.name}\n`); + } + if (file.description) { + headerLines.push(`description: ${file.description}\n`); + } + if (file.applyTo) { + headerLines.push(`applyTo: "${file.applyTo}"\n`); + } + } + headerLines.push('---\n'); + const header = new PromptHeader( + new Range(2, 1, headerLines.length, 1), + uri, + headerLines + ); + return new ParsedPromptFile(uri, header); + } + }(); +} + +function createMockWorkspaceService(): IAICustomizationWorkspaceService { + const activeProjectRoot = observableValue('mockActiveProjectRoot', URI.file('/workspace')); + return new class extends mock() { + override readonly isSessionsWindow = false; + override readonly activeProjectRoot = activeProjectRoot; + override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); + override getActiveProjectRoot() { return URI.file('/workspace'); } + override getStorageSourceFilter() { return defaultFilter; } + }(); +} + +function createMockHarnessService(): ICustomizationHarnessService { + const descriptor = createVSCodeHarnessDescriptor([PromptsStorage.extension]); + return new class extends mock() { + override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override readonly availableHarnesses = observableValue('harnesses', [descriptor]); + override getStorageSourceFilter() { return defaultFilter; } + override getActiveDescriptor() { return descriptor; } + }(); +} + +function createMockWorkspaceContextService(): IWorkspaceContextService { + return new class extends mock() { + override readonly onDidChangeWorkspaceFolders = Event.None; + override getWorkspace(): IWorkspace { + return { id: 'test', folders: [] }; + } + }(); +} + +// ============================================================================ +// Render helper +// ============================================================================ + +async function renderInstructionsTab(ctx: ComponentFixtureContext, instructionFiles: IFixtureInstructionFile[], agentInstructionFiles: IResolvedAgentFile[] = []): Promise { + const width = 500; + const height = 400; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const contextMenuService = new class extends mock() { + override onDidShowContextMenu = Event.None; + override onDidHideContextMenu = Event.None; + override showContextMenu(): void { } + }; + + const contextViewService = new class extends mock() { + override anchorAlignment = 0; + override showContextView() { return { close: () => { } }; } + override hideContextView(): void { } + override getContextViewElement(): HTMLElement { return ctx.container; } + override layout(): void { } + }; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + reg.defineInstance(IContextMenuService, contextMenuService); + reg.defineInstance(IContextViewService, contextViewService); + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IPromptsService, createMockPromptsService(instructionFiles, agentInstructionFiles)); + reg.defineInstance(IAICustomizationWorkspaceService, createMockWorkspaceService()); + reg.defineInstance(ICustomizationHarnessService, createMockHarnessService()); + reg.defineInstance(IWorkspaceContextService, createMockWorkspaceContextService()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = observableValue('plugins', []); + }()); + reg.defineInstance(IFileService, new class extends mock() { + override readonly onDidFilesChange = Event.None; + }()); + reg.defineInstance(IPathService, new class extends mock() { + override readonly defaultUriScheme = 'file'; + override userHome(): URI; + override userHome(): Promise; + override userHome(): URI | Promise { return URI.file('/home/dev'); } + }()); + }, + }); + + const widget = ctx.disposableStore.add( + instantiationService.createInstance(AICustomizationListWidget) + ); + ctx.container.appendChild(widget.element); + await widget.setSection(AICustomizationManagementSection.Instructions); + widget.layout(height, width); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { + + InstructionsTabWithItems: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderInstructionsTab(ctx, [ + // Always-active instructions (no applyTo) + { promptPath: { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'Coding Standards', description: 'Repository-wide coding standards' }, + { promptPath: { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions }, name: 'My Style', description: 'Personal coding style preferences' }, + // Always-included instruction (applyTo: **) + { promptPath: { uri: URI.file('/workspace/.github/instructions/general-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'General Guidelines', description: 'General development guidelines', applyTo: '**' }, + // On-demand instructions (with applyTo pattern) + { promptPath: { uri: URI.file('/workspace/.github/instructions/testing-guidelines.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'Testing Guidelines', description: 'Testing best practices', applyTo: '**/*.test.ts' }, + { promptPath: { uri: URI.file('/workspace/.github/instructions/security-review.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }, name: 'Security Review', description: 'Security review checklist', applyTo: 'src/auth/**' }, + { promptPath: { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.extension, type: PromptsType.instructions, extension: undefined!, source: undefined! }, name: 'TypeScript Rules', description: 'TypeScript conventions', applyTo: '**/*.ts' }, + ], [ + // Agent instruction files (AGENTS.md, copilot-instructions.md) + { uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentFileType.agentsMd }, + { uri: URI.file('/workspace/.github/copilot-instructions.md'), realPath: undefined, type: AgentFileType.copilotInstructionsMd }, + ]), + }), + + InstructionsTabEmpty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderInstructionsTab(ctx, []), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts new file mode 100644 index 00000000000..f57c42abd49 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/aiCustomizationManagementEditor.fixture.ts @@ -0,0 +1,353 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Dimension } from '../../../../base/browser/dom.js'; +import { IRenderedMarkdown } from '../../../../base/browser/markdownRenderer.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; +import { ResourceMap, ResourceSet } from '../../../../base/common/map.js'; +import { constObservable, observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; +import { IRequestService } from '../../../../platform/request/common/request.js'; +import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { IWorkspace, IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; +import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; +import { IWorkingCopyService } from '../../../services/workingCopy/common/workingCopyService.js'; +import { IWebviewService } from '../../../contrib/webview/browser/webview.js'; +import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { CustomizationHarness, ICustomizationHarnessService, IHarnessDescriptor, createVSCodeHarnessDescriptor, createClaudeHarnessDescriptor, createCliHarnessDescriptor, getCliUserRoots, getClaudeUserRoots } from '../../../contrib/chat/common/customizationHarnessService.js'; +import { PromptsType } from '../../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { IPromptsService, IResolvedAgentFile, AgentFileType, PromptsStorage } from '../../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { ParsedPromptFile } from '../../../contrib/chat/common/promptSyntax/promptFileParser.js'; +import { IAgentPluginService } from '../../../contrib/chat/common/plugins/agentPluginService.js'; +import { IPluginMarketplaceService } from '../../../contrib/chat/common/plugins/pluginMarketplaceService.js'; +import { IPluginInstallService } from '../../../contrib/chat/common/plugins/pluginInstallService.js'; +import { AICustomizationManagementEditor } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; +import { AICustomizationManagementEditorInput } from '../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; +import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpServerInstallState } from '../../../contrib/mcp/common/mcpTypes.js'; +import { IMcpRegistry } from '../../../contrib/mcp/common/mcpRegistryTypes.js'; +import { IWorkbenchLocalMcpServer, LocalMcpServerScope } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; + +// Ensure theme colors & widget CSS are loaded +import '../../../../platform/theme/common/colors/inputColors.js'; +import '../../../../platform/theme/common/colors/listColors.js'; +import '../../../contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +const userHome = URI.file('/home/dev'); +const BUILTIN_STORAGE = 'builtin'; + +interface IFixtureFile { + readonly uri: URI; + readonly storage: PromptsStorage; + readonly type: PromptsType; + readonly name?: string; + readonly description?: string; + readonly applyTo?: string; +} + +function createMockEditorGroup(): IEditorGroup { + return new class extends mock() { + override windowId = mainWindow.vscodeWindowId; + }(); +} + +function createMockPromptsService(files: IFixtureFile[], agentInstructions: IResolvedAgentFile[]): IPromptsService { + const applyToMap = new ResourceMap(); + const descriptionMap = new ResourceMap(); + for (const f of files) { applyToMap.set(f.uri, f.applyTo); descriptionMap.set(f.uri, f.description); } + return new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeSlashCommands = Event.None; + override readonly onDidChangeSkills = Event.None; + override readonly onDidChangeInstructions = Event.None; + override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } + override async listPromptFiles(type: PromptsType) { + return files.filter(f => f.type === type).map(f => ({ + uri: f.uri, storage: f.storage as PromptsStorage.local, type: f.type, name: f.name, description: f.description, + })); + } + override async listAgentInstructions() { return agentInstructions; } + override async getCustomAgents() { + return files.filter(f => f.type === PromptsType.agent).map(a => ({ + uri: a.uri, name: a.name ?? 'agent', description: a.description, storage: a.storage, + source: { storage: a.storage }, + })) as never[]; + } + override async parseNew(uri: URI, _token: CancellationToken): Promise { + const header = { + get applyTo() { return applyToMap.get(uri); }, + get description() { return descriptionMap.get(uri); }, + }; + return new ParsedPromptFile(uri, header as never); + } + override async getSourceFolders() { return [] as never[]; } + override async findAgentSkills() { return [] as never[]; } + override async getPromptSlashCommands() { return [] as never[]; } + }(); +} + +function createMockHarnessService(activeHarness: CustomizationHarness, descriptors: readonly IHarnessDescriptor[]): ICustomizationHarnessService { + const active = observableValue('activeHarness', activeHarness); + return new class extends mock() { + override readonly activeHarness = active; + override readonly availableHarnesses = constObservable(descriptors); + override getStorageSourceFilter(type: PromptsType) { + const d = descriptors.find(h => h.id === active.get()) ?? descriptors[0]; + return d.getStorageSourceFilter(type); + } + override getActiveDescriptor() { + return descriptors.find(h => h.id === active.get()) ?? descriptors[0]; + } + override setActiveHarness(id: CustomizationHarness) { active.set(id, undefined); } + }(); +} + +function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScope, description?: string): IWorkbenchMcpServer { + return new class extends mock() { + override readonly id = id; + override readonly name = id; + override readonly label = label; + override readonly description = description ?? ''; + override readonly installState = McpServerInstallState.Installed; + override readonly local = new class extends mock() { + override readonly id = id; + override readonly scope = scope; + }(); + }(); +} + +// ============================================================================ +// Realistic test data — a project that has Copilot + Claude customizations +// ============================================================================ + +const allFiles: IFixtureFile[] = [ + // Copilot instructions + { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' }, + { uri: URI.file('/workspace/.github/instructions/testing.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Testing best practices', applyTo: '**/*.test.ts' }, + { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style' }, + // Claude rules + { uri: URI.file('/workspace/.claude/rules/code-style.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Code Style', description: 'Claude code style rules' }, + { uri: URI.file('/workspace/.claude/rules/testing.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Claude testing conventions' }, + { uri: URI.file('/home/dev/.claude/rules/personal.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Personal', description: 'Personal rules' }, + // Agents + { uri: URI.file('/workspace/.github/agents/reviewer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Reviewer', description: 'Code review agent' }, + { uri: URI.file('/workspace/.github/agents/documenter.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Documenter', description: 'Documentation agent' }, + { uri: URI.file('/workspace/.claude/agents/planner.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' }, + // Skills + { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Deploy', description: 'Deployment automation' }, + { uri: URI.file('/workspace/.github/skills/refactor/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Refactor', description: 'Code refactoring patterns' }, + // Prompts + { uri: URI.file('/workspace/.github/prompts/explain.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Explain', description: 'Explain selected code' }, + { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Review', description: 'Review changes' }, +]; + +const agentInstructions: IResolvedAgentFile[] = [ + { uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentFileType.agentsMd }, + { uri: URI.file('/workspace/CLAUDE.md'), realPath: undefined, type: AgentFileType.claudeMd }, + { uri: URI.file('/workspace/.github/copilot-instructions.md'), realPath: undefined, type: AgentFileType.copilotInstructionsMd }, +]; + +const mcpWorkspaceServers = [ + makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access'), + makeLocalMcpServer('mcp-github', 'GitHub', LocalMcpServerScope.Workspace, 'GitHub API'), +]; +const mcpUserServers = [ + makeLocalMcpServer('mcp-web-search', 'Web Search', LocalMcpServerScope.User, 'Search the web'), +]; +const mcpRuntimeServers = [ + { definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(2), connectionState: constObservable({ state: 2 }) }, +]; + +interface IRenderEditorOptions { + readonly harness: CustomizationHarness; + readonly isSessionsWindow?: boolean; + readonly managementSections?: readonly AICustomizationManagementSection[]; + readonly availableHarnesses?: readonly IHarnessDescriptor[]; +} + +// ============================================================================ +// Render helper — creates the full management editor +// ============================================================================ + +async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditorOptions): Promise { + const width = 900; + const height = 600; + ctx.container.style.width = `${width}px`; + ctx.container.style.height = `${height}px`; + + const isSessionsWindow = options.isSessionsWindow ?? false; + const managementSections = options.managementSections ?? [ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Skills, + AICustomizationManagementSection.Instructions, + AICustomizationManagementSection.Hooks, + AICustomizationManagementSection.Prompts, + AICustomizationManagementSection.McpServers, + AICustomizationManagementSection.Plugins, + ]; + const availableHarnesses = options.availableHarnesses ?? [ + createVSCodeHarnessDescriptor([PromptsStorage.extension]), + createCliHarnessDescriptor(getCliUserRoots(userHome), []), + createClaudeHarnessDescriptor(getClaudeUserRoots(userHome), []), + ]; + + const allMcpServers = [...mcpWorkspaceServers, ...mcpUserServers]; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + const harnessService = createMockHarnessService(options.harness, availableHarnesses); + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(IPromptsService, createMockPromptsService(allFiles, agentInstructions)); + reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { + override readonly isSessionsWindow = isSessionsWindow; + override readonly activeProjectRoot = observableValue('root', URI.file('/workspace')); + override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); + override getActiveProjectRoot() { return URI.file('/workspace'); } + override getStorageSourceFilter(type: PromptsType) { return harnessService.getStorageSourceFilter(type); } + override clearOverrideProjectRoot() { } + override setOverrideProjectRoot() { } + override readonly managementSections = managementSections; + override async generateCustomization() { } + }()); + reg.defineInstance(ICustomizationHarnessService, harnessService); + reg.defineInstance(IWorkspaceContextService, new class extends mock() { + override readonly onDidChangeWorkspaceFolders = Event.None; + override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; } + override getWorkbenchState(): WorkbenchState { return WorkbenchState.WORKSPACE; } + }()); + reg.defineInstance(IFileService, new class extends mock() { + override readonly onDidFilesChange = Event.None; + }()); + reg.defineInstance(IPathService, new class extends mock() { + override readonly defaultUriScheme = 'file'; + override userHome(): URI; + override userHome(): Promise; + override userHome(): URI | Promise { return userHome; } + }()); + reg.defineInstance(ITextModelService, new class extends mock() { }()); + reg.defineInstance(IWorkingCopyService, new class extends mock() { + override readonly onDidChangeDirty = Event.None; + }()); + reg.defineInstance(IFileDialogService, new class extends mock() { }()); + reg.defineInstance(IExtensionService, new class extends mock() { }()); + reg.defineInstance(IQuickInputService, new class extends mock() { }()); + reg.defineInstance(IRequestService, new class extends mock() { }()); + reg.defineInstance(IMarkdownRendererService, new class extends mock() { + override render() { + const rendered: IRenderedMarkdown = { + element: DOM.$('span'), + dispose() { }, + }; + return rendered; + } + }()); + reg.defineInstance(IWebviewService, new class extends mock() { }()); + reg.defineInstance(IMcpWorkbenchService, new class extends mock() { + override readonly onChange = Event.None; + override readonly onReset = Event.None; + override readonly local = allMcpServers; + override async queryLocal() { return allMcpServers; } + override canInstall() { return true as const; } + }()); + reg.defineInstance(IMcpService, new class extends mock() { + override readonly servers = constObservable(mcpRuntimeServers as never[]); + }()); + reg.defineInstance(IMcpRegistry, new class extends mock() { + override readonly collections = constObservable([]); + override readonly delegates = constObservable([]); + override readonly onDidChangeInputs = Event.None; + }()); + reg.defineInstance(IAgentPluginService, new class extends mock() { + override readonly plugins = constObservable([]); + override readonly enablementModel = undefined as never; + }()); + reg.defineInstance(IPluginMarketplaceService, new class extends mock() { + override readonly installedPlugins = constObservable([]); + override readonly onDidChangeMarketplaces = Event.None; + }()); + reg.defineInstance(IPluginInstallService, new class extends mock() { }()); + }, + }); + + const editor = ctx.disposableStore.add( + instantiationService.createInstance(AICustomizationManagementEditor, createMockEditorGroup()) + ); + editor.create(ctx.container); + editor.layout(new Dimension(width, height)); + + // setInput may fail on unmocked service calls — catch to still show the editor shell + try { + await editor.setInput(AICustomizationManagementEditorInput.getOrCreate(), undefined, {}, CancellationToken.None); + } catch { + // Expected in fixture — some services are partially mocked + } +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { + + // Full editor with Local (VS Code) harness — all sections visible, harness dropdown, + // Generate buttons, AGENTS.md shortcut, all storage groups + LocalHarness: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode }), + }), + + // Full editor with Copilot CLI harness — no prompts section, CLI-specific + // root files and instruction filtering under .github/.copilot paths. + CliHarness: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { harness: CustomizationHarness.CLI }), + }), + + // Full editor with Claude harness — Prompts+Plugins hidden, Agents visible, + // "Add CLAUDE.md" button, "New Rule" dropdown, instruction filtering, bridged MCP badge + ClaudeHarness: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { harness: CustomizationHarness.Claude }), + }), + + // Sessions-window variant of the full editor with workspace override UX + // and sessions section ordering. + Sessions: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.CLI, + isSessionsWindow: true, + availableHarnesses: [ + createCliHarnessDescriptor(getCliUserRoots(userHome), [BUILTIN_STORAGE]), + ], + managementSections: [ + AICustomizationManagementSection.Agents, + AICustomizationManagementSection.Skills, + AICustomizationManagementSection.Instructions, + AICustomizationManagementSection.Prompts, + AICustomizationManagementSection.Hooks, + AICustomizationManagementSection.McpServers, + AICustomizationManagementSection.Plugins, + ], + }), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chatArtifacts.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chatArtifacts.fixture.ts new file mode 100644 index 00000000000..46fe3e31a70 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chatArtifacts.fixture.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../base/common/event.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; +import { ChatArtifactsWidget } from '../../../contrib/chat/browser/widget/chatArtifactsWidget.js'; +import { IChatArtifact, IChatArtifactsService } from '../../../contrib/chat/common/tools/chatArtifactsService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; + +import '../../../contrib/chat/browser/widget/media/chat.css'; + +function createMockArtifactsService(artifacts: IChatArtifact[]): IChatArtifactsService { + const obs = observableValue('artifacts', artifacts); + return new class extends mock() { + override readonly onDidUpdateArtifacts = Event.None; + override getArtifacts() { return artifacts; } + override setArtifacts() { } + override migrateArtifacts() { } + override artifacts() { return obs; } + }(); +} + +function renderArtifactsWidget(context: ComponentFixtureContext, artifacts: IChatArtifact[]): void { + const { container, disposableStore } = context; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: context.theme, + additionalServices: (reg) => { + reg.define(IListService, ListService); + reg.defineInstance(IChatArtifactsService, createMockArtifactsService(artifacts)); + reg.defineInstance(IFileService, new class extends mock() { override onDidFilesChange = Event.None; override onDidRunOperation = Event.None; }()); + }, + }); + + const widget = disposableStore.add(instantiationService.createInstance(ChatArtifactsWidget)); + widget.render(URI.parse('chat-session:test-session')); + + container.style.width = '400px'; + container.style.padding = '8px'; + container.appendChild(widget.domNode); +} + +function renderInChatInputPart(context: ComponentFixtureContext, artifacts: IChatArtifact[]): void { + const { container, disposableStore } = context; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: context.theme, + additionalServices: (reg) => { + reg.define(IListService, ListService); + reg.defineInstance(IChatArtifactsService, createMockArtifactsService(artifacts)); + reg.defineInstance(IFileService, new class extends mock() { override onDidFilesChange = Event.None; override onDidRunOperation = Event.None; }()); + }, + }); + + container.style.width = '500px'; + container.classList.add('monaco-workbench'); + + const session = dom.$('.interactive-session'); + container.appendChild(session); + + const inputPart = dom.h('.interactive-input-part', [ + dom.h('.chat-artifacts-widget-container@artifactsContainer'), + dom.h('.interactive-input-and-side-toolbar', [ + dom.h('.chat-input-container', [ + dom.h('.chat-editor-container@editorContainer'), + ]), + ]), + ]); + session.appendChild(inputPart.root); + + inputPart.editorContainer.style.height = '44px'; + + const widget = disposableStore.add(instantiationService.createInstance(ChatArtifactsWidget)); + widget.render(URI.parse('chat-session:test-session')); + inputPart.artifactsContainer.appendChild(widget.domNode); +} + +// ============================================================================ +// Sample artifacts +// ============================================================================ + +const singleArtifact: IChatArtifact[] = [ + { label: 'Dev Server', uri: 'http://localhost:3000', type: 'devServer' }, +]; + +const multipleArtifacts: IChatArtifact[] = [ + { label: 'Dev Server', uri: 'http://localhost:3000', type: 'devServer' }, + { label: 'Screenshot of login page', uri: 'file:///tmp/screenshot.png', type: 'screenshot' }, + { label: 'Implementation Plan', uri: 'file:///tmp/plan.md', type: 'plan' }, +]; + +const manyArtifacts: IChatArtifact[] = [ + { label: 'Dev Server', uri: 'http://localhost:3000', type: 'devServer' }, + { label: 'Screenshot 1', uri: 'file:///tmp/s1.png', type: 'screenshot' }, + { label: 'Screenshot 2', uri: 'file:///tmp/s2.png', type: 'screenshot' }, + { label: 'Plan', uri: 'file:///tmp/plan.md', type: 'plan' }, + { label: 'API Docs', uri: 'http://localhost:3000/docs', type: undefined }, +]; + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'chat/artifacts/' }, { + SingleArtifact: defineComponentFixture({ + render: context => renderArtifactsWidget(context, singleArtifact), + }), + + MultipleArtifacts: defineComponentFixture({ + render: context => renderArtifactsWidget(context, multipleArtifacts), + }), + + ManyArtifacts: defineComponentFixture({ + render: context => renderArtifactsWidget(context, manyArtifacts), + }), + + InChatInputSingle: defineComponentFixture({ + render: context => renderInChatInputPart(context, singleArtifact), + }), + + InChatInputMultiple: defineComponentFixture({ + render: context => renderInChatInputPart(context, multipleArtifacts), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chatInput.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chatInput.fixture.ts new file mode 100644 index 00000000000..2705eb47e21 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chatInput.fixture.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 { Emitter, Event } from '../../../../base/common/event.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { IMenuService, IMenu, MenuId, MenuItemAction, IMenuItem } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js'; +import { IDecorationsService } from '../../../services/decorations/common/decorations.js'; +import { ITextFileService } from '../../../services/textfile/common/textfiles.js'; +import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; +import { IChatWidgetHistoryService } from '../../../contrib/chat/common/widget/chatWidgetHistoryService.js'; +import { IChatContextPickService } from '../../../contrib/chat/browser/attachments/chatContextPickService.js'; +import { IWorkspaceContextService, IWorkspace } from '../../../../platform/workspace/common/workspace.js'; +import { IViewDescriptorService } from '../../../common/views.js'; +import { IChatWidget } from '../../../contrib/chat/browser/chat.js'; +import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IChatAttachmentResolveService } from '../../../contrib/chat/browser/attachments/chatAttachmentResolveService.js'; +import { IChatAttachmentWidgetRegistry } from '../../../contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.js'; +import { IChatContextService } from '../../../contrib/chat/browser/contextContrib/chatContextService.js'; +import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../contrib/chat/browser/widget/input/chatInputPart.js'; +import { IChatArtifactsService } from '../../../contrib/chat/common/tools/chatArtifactsService.js'; +import { ChatEditingSessionState, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../contrib/chat/common/editing/chatEditingService.js'; +import { IChatRequestDisablement } from '../../../contrib/chat/common/model/chatModel.js'; +import { IChatTodo, IChatTodoListService } from '../../../contrib/chat/common/tools/chatTodoListService.js'; +import { ChatAgentLocation, ChatConfiguration } from '../../../contrib/chat/common/constants.js'; +import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; +import { IChatModeService } from '../../../contrib/chat/common/chatModes.js'; +import { IChatService } from '../../../contrib/chat/common/chatService/chatService.js'; +import { IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; +import { ILanguageModelsService } from '../../../contrib/chat/common/languageModels.js'; +import { IChatAgentService } from '../../../contrib/chat/common/participants/chatAgents.js'; +import { ILanguageModelToolsService } from '../../../contrib/chat/common/tools/languageModelToolsService.js'; +import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; +import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; +import { INotebookDocumentService } from '../../../services/notebook/common/notebookDocumentService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; + +import '../../../contrib/chat/browser/widget/media/chat.css'; + +class FixtureMenuService implements IMenuService { + declare readonly _serviceBrand: undefined; + private readonly _items = new Map(); + constructor( + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @ICommandService private readonly _commandService: ICommandService, + ) { } + addItem(menuId: MenuId, item: IMenuItem): void { + const key = menuId.id; + let items = this._items.get(key); + if (!items) { + items = []; + this._items.set(key, items); + } + items.push(item); + } + createMenu(id: MenuId): IMenu { + const actions: [string, MenuItemAction[]][] = []; + for (const item of this._items.get(id.id) ?? []) { + const group = item.group ?? ''; + let entry = actions.find(a => a[0] === group); + if (!entry) { + entry = [group, []]; + actions.push(entry); + } + entry[1].push(new MenuItemAction(item.command, item.alt, {}, undefined, undefined, this._contextKeyService, this._commandService)); + } + return { onDidChange: Event.None, dispose() { }, getActions: () => actions }; + } + getMenuActions() { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + +interface ChatInputFixtureOptions { + readonly artifacts?: readonly { label: string; uri: string; type: 'devServer' | 'screenshot' | 'plan' | undefined }[]; + readonly editingSession?: IChatEditingSession; + readonly todos?: IChatTodo[]; +} + +async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: ChatInputFixtureOptions = {}): Promise { + const { container, disposableStore } = context; + const { artifacts = [], editingSession, todos = [] } = fixtureOptions; + const artifactsObs = observableValue('artifacts', artifacts); + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: context.theme, + additionalServices: (reg) => { + reg.define(IMenuService, FixtureMenuService); + registerWorkbenchServices(reg); + // eslint-disable-next-line local/code-no-dangerous-type-assertions + reg.defineInstance(ITextModelService, new class extends mock() { override async createModelReference() { return { object: { textEditorModel: null }, dispose() { } } as unknown as Awaited>; } }()); + reg.defineInstance(IDecorationsService, new class extends mock() { override onDidChangeDecorations = Event.None; }()); + reg.defineInstance(ITextFileService, new class extends mock() { override readonly untitled = new class extends mock() { override readonly onDidChangeLabel = Event.None; }(); }()); + reg.defineInstance(ILanguageModelsService, new class extends mock() { override onDidChangeLanguageModels = Event.None; override getLanguageModelIds() { return []; } }()); + reg.defineInstance(IFileService, new class extends mock() { override onDidFilesChange = Event.None; override onDidRunOperation = Event.None; }()); + reg.defineInstance(IEditorService, new class extends mock() { override onDidActiveEditorChange = Event.None; }()); + reg.defineInstance(IChatAgentService, new class extends mock() { override onDidChangeAgents = Event.None; override getAgents() { return []; } override getActivatedAgents() { return []; } }()); + reg.defineInstance(ISharedWebContentExtractorService, new class extends mock() { }()); + reg.defineInstance(IWorkbenchAssignmentService, new class extends mock() { override async getCurrentExperiments() { return []; } override async getTreatment() { return undefined; } override onDidRefetchAssignments = Event.None; }()); + reg.defineInstance(IChatEntitlementService, new class extends mock() { }()); + reg.defineInstance(IChatModeService, new class extends mock() { override readonly onDidChangeChatModes = Event.None; override getModes() { return { builtin: [], custom: [] }; } override findModeById() { return undefined; } }()); + reg.defineInstance(ILanguageModelToolsService, new class extends mock() { override onDidChangeTools = Event.None; override getTools() { return []; } }()); + reg.defineInstance(IChatService, new class extends mock() { override onDidSubmitRequest = Event.None; }()); + reg.defineInstance(IChatSessionsService, new class extends mock() { override getAllChatSessionContributions() { return []; } override readonly onDidChangeSessionOptions = Event.None; override readonly onDidChangeOptionGroups = Event.None; override readonly onDidChangeAvailability = Event.None; }()); + reg.defineInstance(IChatContextService, new class extends mock() { }()); + reg.defineInstance(IAgentSessionsService, new class extends mock() { override readonly model = new class extends mock() { override readonly onDidChangeSessions = Event.None; }(); }()); + reg.defineInstance(IWorkspaceContextService, new class extends mock() { override onDidChangeWorkspaceFolders = Event.None; override getWorkspace(): IWorkspace { return { id: '', folders: [], configuration: undefined }; } }()); + reg.defineInstance(IWorkbenchLayoutService, new class extends mock() { override onDidChangePartVisibility = Event.None; override onDidChangeWindowMaximized = Event.None; override isVisible() { return true; } }()); + reg.defineInstance(IViewDescriptorService, new class extends mock() { override onDidChangeLocation = Event.None; }()); + reg.defineInstance(IChatAttachmentWidgetRegistry, new class extends mock() { }()); + reg.defineInstance(IChatAttachmentResolveService, new class extends mock() { }()); + reg.defineInstance(IExtensionService, new class extends mock() { override readonly onDidChangeExtensions = Event.None; }()); + reg.defineInstance(IPathService, new class extends mock() { }()); + reg.defineInstance(IChatWidgetHistoryService, new class extends mock() { override getHistory() { return []; } override readonly onDidChangeHistory = Event.None; }()); + reg.defineInstance(IChatContextPickService, new class extends mock() { }()); + reg.defineInstance(IListService, new ListService()); + reg.defineInstance(INotebookDocumentService, new class extends mock() { }()); + reg.defineInstance(IActionWidgetService, new class extends mock() { override show() { } override hide() { } override get isVisible() { return false; } }()); + reg.defineInstance(IProductService, new class extends mock() { }()); + reg.defineInstance(IUpdateService, new class extends mock() { override onStateChange = Event.None; override get state() { return { type: StateType.Uninitialized as const }; } }()); + reg.defineInstance(IUriIdentityService, new class extends mock() { }()); + reg.defineInstance(IChatArtifactsService, new class extends mock() { + override readonly onDidUpdateArtifacts = Event.None; + override getArtifacts() { return [...artifacts]; } + override setArtifacts() { } + override migrateArtifacts() { } + override artifacts() { return artifactsObs; } + }()); + reg.defineInstance(IChatTodoListService, new class extends mock() { + override readonly onDidUpdateTodos = Event.None; + override getTodos() { return [...todos]; } + override setTodos() { } + override migrateTodos() { } + }()); + }, + }); + + if (artifacts.length > 0) { + const configService = instantiationService.get(IConfigurationService) as TestConfigurationService; + await configService.setUserConfiguration(ChatConfiguration.ArtifactsEnabled, true); + } + + container.style.width = '500px'; + container.style.backgroundColor = 'var(--vscode-sideBar-background, var(--vscode-editor-background))'; + container.classList.add('monaco-workbench'); + + const session = document.createElement('div'); + session.classList.add('interactive-session'); + container.appendChild(session); + + const menuService = instantiationService.get(IMenuService) as FixtureMenuService; + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.attachContext', title: '+', icon: Codicon.add }, group: 'navigation', order: -1 }); + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.openModePicker', title: 'Agent' }, group: 'navigation', order: 1 }); + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.openModelPicker', title: 'GPT-5.3-Codex' }, group: 'navigation', order: 3 }); + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.configureTools', title: '', icon: Codicon.settingsGear }, group: 'navigation', order: 100 }); + menuService.addItem(MenuId.ChatExecute, { command: { id: 'workbench.action.chat.submit', title: 'Send', icon: Codicon.arrowUp }, group: 'navigation', order: 4 }); + menuService.addItem(MenuId.ChatInputSecondary, { command: { id: 'workbench.action.chat.openSessionTargetPicker', title: 'Local' }, group: 'navigation', order: 0 }); + menuService.addItem(MenuId.ChatInputSecondary, { command: { id: 'workbench.action.chat.openPermissionPicker', title: 'Default Approvals' }, group: 'navigation', order: 10 }); + + const options: IChatInputPartOptions = { + renderFollowups: false, + renderInputToolbarBelowInput: false, + renderWorkingSet: !!editingSession, + menus: { executeToolbar: MenuId.ChatExecute, telemetrySource: 'fixture' }, + widgetViewKindTag: 'view', + inputEditorMinLines: 2, + }; + const styles: IChatInputStyles = { + overlayBackground: 'var(--vscode-editor-background)', + listForeground: 'var(--vscode-foreground)', + listBackground: 'var(--vscode-editor-background)', + }; + + try { + const inputPart = disposableStore.add(instantiationService.createInstance(ChatInputPart, ChatAgentLocation.Chat, options, styles, false)); + const mockWidget = new class extends mock() { + override readonly onDidChangeViewModel = new Emitter().event; + override readonly viewModel = undefined; + override readonly contribs = []; + override readonly location = ChatAgentLocation.Chat; + override readonly viewContext = {}; + }(); + + inputPart.render(session, '', mockWidget); + inputPart.layout(500); + await new Promise(r => setTimeout(r, 100)); + inputPart.layout(500); + inputPart.renderArtifactsWidget(URI.parse('chat-session:test-session')); + await inputPart.renderChatTodoListWidget(URI.parse('chat-session:test-session')); + await new Promise(r => setTimeout(r, 50)); + + if (editingSession) { + inputPart.renderChatEditingSessionState(editingSession); + await new Promise(r => setTimeout(r, 50)); + inputPart.layout(500); + } + } catch (e) { + const err = document.createElement('pre'); + err.style.cssText = 'color:red;font-size:11px;white-space:pre-wrap'; + err.textContent = `Render error: ${e instanceof Error ? e.message : String(e)}`; + session.appendChild(err); + } +} + +const sampleArtifacts = [ + { label: 'Dev Server', uri: 'http://localhost:3000', type: 'devServer' as const }, + { label: 'Screenshot', uri: 'file:///tmp/screenshot.png', type: 'screenshot' as const }, + { label: 'Plan', uri: 'file:///tmp/plan.md', type: 'plan' as const }, +]; + +function createMockEditingSession(files: { uri: string; added: number; removed: number }[]): IChatEditingSession { + const entries = files.map(f => { + const entry = new class extends mock() { + override readonly entryId = f.uri; + override readonly modifiedURI = URI.parse(f.uri); + override readonly originalURI = URI.parse(f.uri); + override readonly state = observableValue('state', ModifiedFileEntryState.Modified); + override readonly linesAdded = observableValue('linesAdded', f.added); + override readonly linesRemoved = observableValue('linesRemoved', f.removed); + override readonly lastModifyingRequestId = 'request-1'; + override readonly changesCount = observableValue('changesCount', 1); + override readonly isCurrentlyBeingModifiedBy = observableValue('isCurrentlyBeingModifiedBy', undefined); + override readonly lastModifyingResponse = observableValue('lastModifyingResponse', undefined); + override readonly rewriteRatio = observableValue('rewriteRatio', 0); + override readonly waitsForLastEdits = observableValue('waitsForLastEdits', false); + override readonly reviewMode = observableValue('reviewMode', false); + override readonly autoAcceptController = observableValue('autoAcceptController', undefined); + }(); + return entry; + }); + + return new class extends mock() { + override readonly isGlobalEditingSession = false; + override readonly chatSessionResource = URI.parse('chat-session:test-session'); + override readonly onDidDispose = Event.None; + override readonly state = observableValue('state', ChatEditingSessionState.Idle); + override readonly entries = observableValue('entries', entries); + override readonly requestDisablement = observableValue('requestDisablement', []); + }(); +} + +const sampleTodos: IChatTodo[] = [ + { id: 1, title: 'Set up project structure', status: 'completed' }, + { id: 2, title: 'Implement auth service', status: 'in-progress' }, + { id: 3, title: 'Add unit tests', status: 'not-started' }, +]; + +export default defineThemedFixtureGroup({ path: 'chat/input/' }, { + Default: defineComponentFixture({ render: context => renderChatInput(context) }), + WithArtifacts: defineComponentFixture({ render: context => renderChatInput(context, { artifacts: sampleArtifacts }) }), + WithFileChanges: defineComponentFixture({ + render: context => renderChatInput(context, { editingSession: createMockEditingSession([{ uri: 'file:///workspace/src/fibon.ts', added: 21, removed: 1 }]) }) + }), + WithTodos: defineComponentFixture({ + render: context => renderChatInput(context, { todos: sampleTodos }) + }), + WithTodosAndFileChanges: defineComponentFixture({ + render: context => renderChatInput(context, { todos: sampleTodos, editingSession: createMockEditingSession([{ uri: 'file:///workspace/src/fibon.ts', added: 21, removed: 1 }]) }) + }), + WithArtifactsAndFileChanges: defineComponentFixture({ + render: context => renderChatInput(context, { artifacts: sampleArtifacts, editingSession: createMockEditingSession([{ uri: 'file:///workspace/src/fibon.ts', added: 21, removed: 1 }]) }) + }), + Full: defineComponentFixture({ + render: context => renderChatInput(context, { + artifacts: sampleArtifacts, + editingSession: createMockEditingSession([{ uri: 'file:///workspace/src/fibon.ts', added: 21, removed: 1 }]), + todos: sampleTodos, + }) + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chatTerminalCollapsible.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chatTerminalCollapsible.fixture.ts new file mode 100644 index 00000000000..d4a8c16439d --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chatTerminalCollapsible.fixture.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../base/common/event.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { mock, upcastPartial } from '../../../../base/test/common/mock.js'; +import type { IChatContentPartRenderContext, InlineTextModelCollection } from '../../../contrib/chat/browser/widget/chatContentParts/chatContentParts.js'; +import type { IChatResponseViewModel } from '../../../contrib/chat/common/model/chatViewModel.js'; +import { ChatTerminalThinkingCollapsibleWrapper } from '../../../contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; + +import '../../../contrib/chat/browser/widget/media/chat.css'; + +function createMockContext(): IChatContentPartRenderContext { + return { + element: new class extends mock() { }(), + elementIndex: 0, + container: document.createElement('div'), + content: [], + contentIndex: 0, + editorPool: undefined!, + codeBlockStartIndex: 0, + treeStartIndex: 0, + diffEditorPool: undefined!, + codeBlockModelCollection: undefined!, + currentWidth: observableValue('currentWidth', 400), + onDidChangeVisibility: Event.None, + inlineTextModels: upcastPartial({}), + }; +} + +function renderCollapsible(context: ComponentFixtureContext, commandText: string, isSandboxWrapped: boolean, isComplete: boolean): void { + const { container, disposableStore } = context; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: context.theme, + }); + + container.style.width = '500px'; + container.style.padding = '8px'; + container.classList.add('monaco-workbench'); + + const session = dom.$('.interactive-session'); + container.appendChild(session); + + const contentElement = dom.$('.chat-terminal-output-placeholder'); + contentElement.textContent = '(terminal output would appear here)'; + contentElement.style.padding = '8px'; + contentElement.style.color = 'var(--vscode-descriptionForeground)'; + + const wrapper = disposableStore.add(instantiationService.createInstance( + ChatTerminalThinkingCollapsibleWrapper, + commandText, + isSandboxWrapped, + contentElement, + createMockContext(), + false, + isComplete, + )); + + session.appendChild(wrapper.domNode); +} + +export default defineThemedFixtureGroup({ path: 'chat/terminalCollapsible/' }, { + 'Ran - simple command': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'ls -lh', false, true), + }), + 'Running - simple command': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'ls -lh', false, false), + }), + 'Ran sandbox - simple command': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'ls -lh', true, true), + }), + 'Running sandbox - simple command': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'ls -lh', true, false), + }), + 'Ran - special chars': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'grep -rn "hello" ./src --include="*.ts"', false, true), + }), + 'Ran sandbox - special chars': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'grep -rn "hello" ./src --include="*.ts"', true, true), + }), + 'Ran - backticks': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'echo `date` && echo `hostname`', false, true), + }), + 'Ran sandbox - backticks': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'echo `date` && echo `hostname`', true, true), + }), + 'Ran sandbox - powershell backticks': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'Get-Process | Where-Object {$_.Name -eq `"notepad`"}', true, true), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/imageCarousel.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/imageCarousel.fixture.ts new file mode 100644 index 00000000000..613a14c5ae0 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/imageCarousel.fixture.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Dimension } from '../../../../base/browser/dom.js'; +import { mainWindow } from '../../../../base/browser/window.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; +import { ImageCarouselEditor } from '../../../contrib/imageCarousel/browser/imageCarouselEditor.js'; +import { ImageCarouselEditorInput } from '../../../contrib/imageCarousel/browser/imageCarouselEditorInput.js'; +import { ICarouselImage, IImageCarouselCollection } from '../../../contrib/imageCarousel/browser/imageCarouselTypes.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils.js'; +import '../../../contrib/imageCarousel/browser/media/imageCarousel.css'; + +function createSolidPng(r: number, g: number, b: number, width: number = 64, height: number = 64): VSBuffer { + const canvas = mainWindow.document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + ctx.fillRect(0, 0, width, height); + + const dataUrl = canvas.toDataURL('image/png'); + const base64 = dataUrl.split(',')[1]; + return VSBuffer.wrap(Uint8Array.from(atob(base64), c => c.charCodeAt(0))); +} + +function createTestImages(): ICarouselImage[] { + return [ + { id: 'img-1', name: 'Red', mimeType: 'image/png', data: createSolidPng(220, 50, 50), caption: 'A red image' }, + { id: 'img-2', name: 'Green', mimeType: 'image/png', data: createSolidPng(50, 180, 50), caption: 'A green image' }, + { id: 'img-3', name: 'Blue', mimeType: 'image/png', data: createSolidPng(50, 80, 220) }, + { id: 'img-4', name: 'Yellow', mimeType: 'image/png', data: createSolidPng(230, 210, 50), caption: 'A yellow image' }, + { id: 'img-5', name: 'Purple', mimeType: 'image/png', data: createSolidPng(150, 50, 200) }, + ]; +} + +function createMockEditorGroup(): IEditorGroup { + return new class extends mock() { + override windowId = mainWindow.vscodeWindowId; + }(); +} + +async function renderCarousel(context: ComponentFixtureContext, collection: IImageCarouselCollection, startIndex: number = 0): Promise { + const { container, disposableStore, theme } = context; + + container.style.width = '600px'; + container.style.height = '500px'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + }); + + const editor = disposableStore.add( + instantiationService.createInstance(ImageCarouselEditor, createMockEditorGroup()) + ); + editor.create(container); + editor.layout(new Dimension(600, 500)); + + const input = new ImageCarouselEditorInput(collection, startIndex); + await editor.setInput(input, undefined, {}, CancellationToken.None); +} + +function singleSectionCollection(): IImageCarouselCollection { + return { + id: 'fixture-single', + title: 'Test Carousel', + sections: [{ title: 'All Images', images: createTestImages() }], + }; +} + +function multiSectionCollection(): IImageCarouselCollection { + const images = createTestImages(); + return { + id: 'fixture-multi', + title: 'Multi-Section Carousel', + sections: [ + { title: 'Warm Colors', images: [images[0], images[3]] }, + { title: 'Cool Colors', images: [images[2], images[4]] }, + { title: 'Nature', images: [images[1]] }, + ], + }; +} + +function singleImageCollection(): IImageCarouselCollection { + const images = createTestImages(); + return { + id: 'fixture-single-image', + title: 'Single Image', + sections: [{ title: '', images: [images[0]] }], + }; +} + +export default defineThemedFixtureGroup({ path: 'imageCarousel/' }, { + SingleSection: defineComponentFixture({ + render: ctx => renderCarousel(ctx, singleSectionCollection()), + }), + SingleSectionMiddleImage: defineComponentFixture({ + render: ctx => renderCarousel(ctx, singleSectionCollection(), 2), + }), + MultipleSections: defineComponentFixture({ + render: ctx => renderCarousel(ctx, multiSectionCollection()), + }), + SingleImage: defineComponentFixture({ + render: ctx => renderCarousel(ctx, singleImageCollection()), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/peekReference.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/peekReference.fixture.ts new file mode 100644 index 00000000000..a9b2b7f5230 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/peekReference.fixture.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mock } from '../../../../base/test/common/mock.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from './fixtureUtils.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { LayoutData, ReferenceWidget } from '../../../../editor/contrib/gotoSymbol/browser/peek/referencesWidget.js'; +import { ReferencesModel } from '../../../../editor/contrib/gotoSymbol/browser/referencesModel.js'; +import * as peekView from '../../../../editor/contrib/peekView/browser/peekView.js'; +import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; +import { IListService, ListService } from '../../../../platform/list/browser/listService.js'; +import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; + +import '../../../../editor/contrib/peekView/browser/media/peekViewWidget.css'; +import '../../../../editor/contrib/gotoSymbol/browser/peek/referencesWidget.css'; +import '../../../../base/browser/ui/codicons/codiconStyles.js'; + +const SAMPLE_CODE = `import { readFile, writeFile } from 'fs'; + +function processFile(path: string): Promise { + return new Promise((resolve, reject) => { + readFile(path, 'utf8', (err, data) => { + if (err) { + reject(err); + return; + } + resolve(data.toUpperCase()); + }); + }); +} + +async function main() { + const result = await processFile('./input.txt'); + await writeFile('./output.txt', result); + console.log('Done processing file'); +} + +main(); +`; + +function renderPeekReference({ container, disposableStore, theme }: ComponentFixtureContext): void { + container.style.width = '700px'; + container.style.height = '400px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.defineInstance(peekView.IPeekViewService, new class extends mock() { + declare readonly _serviceBrand: undefined; + override addExclusiveWidget(_editor: ICodeEditor, _widget: peekView.PeekViewWidget) { } + }); + reg.defineInstance(ITextModelService, new class extends mock() { + declare readonly _serviceBrand: undefined; + override async createModelReference(): Promise { + throw new Error('Not implemented in fixture'); + } + override canHandleResource() { return false; } + override registerTextModelContentProvider() { return { dispose: () => { } }; } + }); + }, + }); + + const uri = URI.parse('inmemory://peek-fixture.ts'); + const textModel = disposableStore.add(createTextModel( + instantiationService, + SAMPLE_CODE, + uri, + 'typescript' + )); + + const editorWidgetOptions: ICodeEditorWidgetOptions = { + contributions: [] + }; + + const editor = disposableStore.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { + automaticLayout: true, + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + fontSize: 14, + cursorBlinking: 'solid', + }, + editorWidgetOptions + )); + + editor.setModel(textModel); + editor.focus(); + + const layoutData: LayoutData = { ratio: 0.7, heightInLines: 10 }; + + const referenceWidget = instantiationService.createInstance( + ReferenceWidget, + editor, + true, + layoutData, + ); + disposableStore.add(referenceWidget); + + const range = { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 21 }; + referenceWidget.setTitle('processFile'); + referenceWidget.setMetaTitle('3 references'); + referenceWidget.show(range); + + const links = [ + { uri, range: { startLineNumber: 3, startColumn: 10, endLineNumber: 3, endColumn: 21 } }, + { uri, range: { startLineNumber: 16, startColumn: 26, endLineNumber: 16, endColumn: 37 } }, + { uri, range: { startLineNumber: 20, startColumn: 1, endLineNumber: 20, endColumn: 5 } }, + ]; + + const model = new ReferencesModel(links, 'processFile'); + disposableStore.add(model); + referenceWidget.setModel(model); +} + +export default defineThemedFixtureGroup({ + PeekReferences: defineComponentFixture({ + render: renderPeekReference, + }), +}); diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 36414b341ff..5a5c36698c1 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -179,7 +179,6 @@ import './contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.js'; // Chat import './contrib/chat/electron-browser/chat.contribution.js'; -import './contrib/chat/electron-browser/agentHost.contribution.js'; // Encryption import './contrib/encryption/electron-browser/encryption.contribution.js'; @@ -193,6 +192,9 @@ import './contrib/mcp/electron-browser/mcp.contribution.js'; // Policy Export import './contrib/policyExport/electron-browser/policyExport.contribution.js'; +// Keybindings Export +import './contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.js'; + //#endregion diff --git a/src/vscode-dts/vscode.proposed.chatDebug.d.ts b/src/vscode-dts/vscode.proposed.chatDebug.d.ts index ed91aa806a1..06729600819 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: 3 +// version: 4 declare module 'vscode' { /** @@ -595,13 +595,77 @@ declare module 'vscode' { constructor(requestName: string); } + /** + * Structured hook execution content for a resolved chat debug event, + * containing the hook type, command, input, output, and result for rich rendering. + */ + export class ChatDebugEventHookContent { + /** + * The type of hook that was executed (e.g., "PreToolUse", "PostToolUse", "Stop"). + */ + hookType: string; + + /** + * The shell command that was executed. + */ + command?: string; + + /** + * The outcome of the hook execution. + */ + result?: ChatDebugHookResult; + + /** + * How long the hook took to complete, in milliseconds. + */ + durationInMillis?: number; + + /** + * The serialized JSON input passed to the hook via stdin. + */ + input?: string; + + /** + * The serialized output (stdout/stderr) returned by the hook. + */ + output?: string; + + /** + * An error message, if the hook failed. + */ + errorMessage?: string; + + /** + * The raw exit code from the hook process, if it failed. + */ + exitCode?: number; + + /** + * Create a new ChatDebugEventHookContent. + * @param hookType The type of hook that was executed. + */ + constructor(hookType: string); + } + + /** + * The result of a hook execution. + */ + export enum ChatDebugHookResult { + /** The hook executed successfully (exit code 0). */ + Success = 0, + /** The hook returned a blocking error (exit code 2). */ + Error = 1, + /** The hook returned a non-blocking warning (other non-zero exit codes). */ + NonBlockingError = 2 + } + /** * Union of all resolved event content types. * Extensions may also return {@link ChatDebugUserMessageEvent} or * {@link ChatDebugAgentResponseEvent} from resolve, which will be * automatically converted to structured message content. */ - export type ChatDebugResolvedEventContent = ChatDebugEventTextContent | ChatDebugEventMessageContent | ChatDebugEventToolCallContent | ChatDebugEventModelTurnContent | ChatDebugUserMessageEvent | ChatDebugAgentResponseEvent; + export type ChatDebugResolvedEventContent = ChatDebugEventTextContent | ChatDebugEventMessageContent | ChatDebugEventToolCallContent | ChatDebugEventModelTurnContent | ChatDebugEventHookContent | ChatDebugUserMessageEvent | ChatDebugAgentResponseEvent; /** * Union of all chat debug event types. Each type is a class, diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index b19b106205b..c20711a3305 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -17,6 +17,15 @@ declare module 'vscode' { * `undefined` if the request was initiated by other functionality in the editor. */ readonly requestInitiator: string; + + /** + * Per-model configuration provided by the user. This contains values configured + * in the user's language models configuration file, validated against the model's + * {@linkcode LanguageModelChatInformation.configurationSchema configurationSchema}. + */ + readonly modelConfiguration?: { + readonly [key: string]: any; + }; } /** @@ -67,6 +76,14 @@ declare module 'vscode' { readonly statusIcon?: ThemeIcon; + /** + * An optional JSON schema describing the configuration options for this model. + * When set, users can specify per-model configuration in their language models + * configuration file. The configured values are merged into the request options + * when sending chat requests to this model. + */ + readonly configurationSchema?: LanguageModelConfigurationSchema; + /** * When set, this model is only shown in the model picker for the specified chat session type. * Models with this property are excluded from the general model picker and only appear @@ -98,6 +115,28 @@ declare module 'vscode' { export type LanguageModelResponsePart2 = LanguageModelResponsePart | LanguageModelDataPart | LanguageModelThinkingPart; + /** + * A [JSON Schema](https://json-schema.org) describing configuration options for a language model. + * Each property in `properties` defines a configurable option using standard JSON Schema fields + * plus additional display hints. + */ + export type LanguageModelConfigurationSchema = { + readonly properties?: { + readonly [key: string]: Record & { + /** + * Human-readable labels for enum values, shown instead of the raw values. + * Must have the same length and order as `enum`. + */ + readonly enumItemLabels?: string[]; + /** + * The group this property belongs to. When set to `'navigation'`, the property + * is shown as a primary action in the model picker. + */ + readonly group?: string; + }; + }; + }; + export interface LanguageModelChatProvider { provideLanguageModelChatInformation(options: PrepareLanguageModelChatModelOptions, token: CancellationToken): ProviderResult; provideLanguageModelChatResponse(model: T, messages: readonly LanguageModelChatRequestMessage[], options: ProvideLanguageModelChatResponseOptions, progress: Progress, token: CancellationToken): Thenable; @@ -115,4 +154,16 @@ declare module 'vscode' { readonly [key: string]: any; }; } + + export interface ChatRequest { + /** + * Per-model configuration provided by the user. Contains resolved values based on the model's + * {@linkcode LanguageModelChatInformation.configurationSchema configurationSchema}, + * with user overrides applied on top of schema defaults. + * + * This is the same data that is sent as {@linkcode ProvideLanguageModelChatResponseOptions.configuration} + * when the model is invoked via the language model API. + */ + readonly modelConfiguration?: { readonly [key: string]: any }; + } } diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index efc76e3cc70..96adb309671 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -108,6 +108,20 @@ declare module 'vscode' { */ export type ChatSessionItemControllerNewItemHandler = (context: ChatSessionItemControllerNewItemHandlerContext, token: CancellationToken) => Thenable; + /** + * Extension callback invoked to fork an existing chat session item managed by a {@linkcode ChatSessionItemController}. + * + * The handler should create a new session on the provider's backend and + * return the new {@link ChatSessionItem} representing the forked session. + * + * @param sessionResource The resource of the chat session being forked. + * @param request The request turn that marks the fork point. The forked session includes all turns + * upto this request turn and excludes this request turn itself. If undefined, fork the full session. + * @param token A cancellation token. + * @returns The forked session item. + */ + export type ChatSessionItemControllerForkHandler = (sessionResource: Uri, request: ChatRequestTurn2 | undefined, token: CancellationToken) => Thenable | ChatSessionItem; + /** * Manages chat sessions for a specific chat session type */ @@ -145,6 +159,14 @@ declare module 'vscode' { */ newChatSessionItemHandler?: ChatSessionItemControllerNewItemHandler; + /** + * Invoked when an existing chat session is forked. + * + * When both this handler and {@linkcode ChatSession.forkHandler} are registered, + * this handler takes precedence. + */ + forkHandler?: ChatSessionItemControllerForkHandler; + /** * Fired when an item's archived state changes. */ @@ -399,6 +421,22 @@ declare module 'vscode' { // TODO: Revisit this to align with code. // TODO: pass in options? readonly requestHandler: ChatRequestHandler | undefined; + + /** + * Handles a request to fork the session. + * + * The handler should create a new session on the provider's backend and + * return the new {@link ChatSessionItem} representing the forked session. + * + * @deprecated Use {@linkcode ChatSessionItemController.forkHandler} instead. This remains supported for backwards compatibility. + * + * @param sessionResource The resource of the chat session being forked. + * @param request The request turn that marks the fork point. The forked session includes all turns + * upto this request turn and excludes this request turn itself. If undefined, fork the full session. + * @param token A cancellation token. + * @returns The forked session item. + */ + readonly forkHandler?: ChatSessionItemControllerForkHandler; } /** diff --git a/src/vscode-dts/vscode.proposed.findFiles2.d.ts b/src/vscode-dts/vscode.proposed.findFiles2.d.ts index 8e7c11874b4..af324e90719 100644 --- a/src/vscode-dts/vscode.proposed.findFiles2.d.ts +++ b/src/vscode-dts/vscode.proposed.findFiles2.d.ts @@ -71,6 +71,12 @@ declare module 'vscode' { * For more info, see the setting description for `search.followSymlinks`. */ followSymlinks?: boolean; + + /** + * Whether glob patterns should be matched case-insensitively. + * Defaults to `false`. + */ + caseInsensitive?: boolean; } /** diff --git a/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts b/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts index c7c7c9507a5..4575ba17d0c 100644 --- a/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts +++ b/src/vscode-dts/vscode.proposed.findTextInFiles2.d.ts @@ -91,6 +91,12 @@ declare module 'vscode' { */ followSymlinks?: boolean; + /** + * Whether glob patterns should be matched case-insensitively. + * Defaults to `false`. + */ + caseInsensitive?: boolean; + /** * Interpret files using this encoding. * See the vscode setting `"files.encoding"` diff --git a/src/vscode-dts/vscode.proposed.toolInvocationApproveCombination.d.ts b/src/vscode-dts/vscode.proposed.toolInvocationApproveCombination.d.ts new file mode 100644 index 00000000000..070cf51ef40 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.toolInvocationApproveCombination.d.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @alexr00 https://github.com/microsoft/vscode/issues/302393 + +declare module 'vscode' { + + export interface LanguageModelToolConfirmationMessages { + /** + * When set, a button will be shown allowing the user to approve this particular + * combination of tool and arguments. The value is shown as the label for the + * approval option. + * + * For example, a tool that reads files could set this to `"Allow reading 'foo.txt'"`, + * so that the user can approve that specific file without approving all invocations + * of the tool. + */ + approveCombination?: string | MarkdownString; + } +} diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - backticks/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - backticks/Dark.png new file mode 100644 index 00000000000..85c40d12f6b --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - backticks/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ae8dab0fa799afaadf186c93436bca9ac39c36b99b8f9846ed0d7284494be93 +size 2288 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - backticks/Light.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - backticks/Light.png new file mode 100644 index 00000000000..cfb849214ea --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - backticks/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89f27caedd1e45d22e90243691bcc0153195d121dc2c2bc58be4a1d50ace828f +size 2229 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - simple command/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - simple command/Dark.png new file mode 100644 index 00000000000..539388c741b --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - simple command/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:793aa216b116d5b028adf16db5e8780f7b844bfbdd38657f943c6427de3e320b +size 945 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - simple command/Light.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - simple command/Light.png new file mode 100644 index 00000000000..e2b960147c1 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - simple command/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6500291e4787ee83dbe70cf44c1b6cb0c5c96f4311cf865cba26ce86a92581b4 +size 938 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - special chars/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - special chars/Dark.png new file mode 100644 index 00000000000..3b72ed4acaa --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - special chars/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e88d70bf076040be9311a96a39717699e59e2bd86a9b2ca7e32b2424bfbf273 +size 2442 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - special chars/Light.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - special chars/Light.png new file mode 100644 index 00000000000..0abd61d3e4e --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran - special chars/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f267b5737a1cbfeddd000065fa66735c058cdaa090b60445f583db282eb4787d +size 2359 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - backticks/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - backticks/Dark.png new file mode 100644 index 00000000000..a37c755179f --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - backticks/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae563a7c9f939d3fefa4a30e91e1a514ae0de7b881813827110b62def6bd05ef +size 3061 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - backticks/Light.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - backticks/Light.png new file mode 100644 index 00000000000..7d27474905c --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - backticks/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d87074216734a0e6a15683060271590bb6ec594dca7ca84c6ae3b0180df2a66 +size 2988 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - powershell backticks/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - powershell backticks/Dark.png new file mode 100644 index 00000000000..77d8e86be22 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - powershell backticks/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dce5773e95961f082c034816bc63fbf79b8cedecaec9750840fe483d4855f32f +size 4476 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - powershell backticks/Light.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - powershell backticks/Light.png new file mode 100644 index 00000000000..04745d73624 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - powershell backticks/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a05a111b62dbdd2f355f31f08a2c5c6d3731a1ce9607d95e66c42e092a1f099 +size 4354 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - simple command/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - simple command/Dark.png new file mode 100644 index 00000000000..9eb5f8b26b1 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - simple command/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eda8e2a9eeb0b53fee694039861e868d40b711c4f26b95748605f0a14a8a7811 +size 1759 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - simple command/Light.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - simple command/Light.png new file mode 100644 index 00000000000..8abf08a2728 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - simple command/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d71509185136eced7961758fde339f267f38966a4e35939c8fa11fde64e641e2 +size 1757 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - special chars/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - special chars/Dark.png new file mode 100644 index 00000000000..975089fc9b6 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - special chars/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2a089a8a290b9ccb52d42cdd987ca8ccccdbb0a47394e82f0d6e40b447663a7 +size 3156 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - special chars/Light.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - special chars/Light.png new file mode 100644 index 00000000000..71f232f4d62 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Ran sandbox - special chars/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:927527224e48e702d81b44beedc1a2395c638dbaaf952d1a1c926d576d2bf15f +size 3152 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running - simple command/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running - simple command/Dark.png new file mode 100644 index 00000000000..263fa55733e --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running - simple command/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3647c8c57337952dd845f9151b3ddfbb074857e4036d76dd6c7d7beda734c676 +size 1170 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running - simple command/Light.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running - simple command/Light.png new file mode 100644 index 00000000000..04b6944c1a2 --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running - simple command/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0008e3885e2e6bf569757ace983c635feb9da86d470f2455e4a6884d7d22b6e +size 1139 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running sandbox - simple command/Dark.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running sandbox - simple command/Dark.png new file mode 100644 index 00000000000..760ab24627d --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running sandbox - simple command/Dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a1f340aaeaa112ab63fa6a6048fc76618097114ffbf9ada8a4e8f7736df5c5dd +size 1932 diff --git a/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running sandbox - simple command/Light.png b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running sandbox - simple command/Light.png new file mode 100644 index 00000000000..7f869c9bceb --- /dev/null +++ b/test/componentFixtures/.screenshots/baseline/chat/terminalCollapsible/chatTerminalCollapsible/Running sandbox - simple command/Light.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3a5c44027dd8659083344aa7f15acd02d2e59d53913b4ca58e5345204470f0d +size 1893 diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 62eee018d06..632dae2a534 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -141,10 +141,19 @@ export class TestContext { public error(message: string): never { const line = `[${new Date().toISOString()}] ERROR: ${message}`; this.consoleOutputs.push(line); - console.error(line); + console.error(`##vso[task.logissue type=error]${line}`); throw new Error(message); } + /** + * Logs a warning message with a timestamp. + */ + public warn(message: string) { + const line = `[${new Date().toISOString()}] WARNING: ${message}`; + this.consoleOutputs.push(line); + console.warn(`##vso[task.logissue type=warning]${line}`); + } + /** * Creates a new temporary directory and returns its path. */ @@ -220,7 +229,7 @@ export class TestContext { fs.rmSync(dir, { recursive: true, force: true }); this.log(`Deleted temp directory: ${dir}`); } catch (error) { - this.log(`Failed to delete temp directory: ${dir}: ${error}`); + this.warn(`Failed to delete temp directory: ${dir}: ${error}`); } } this.tempDirs.clear(); @@ -229,7 +238,7 @@ export class TestContext { try { this.deleteWslDir(dir); } catch (error) { - this.log(`Failed to delete WSL temp directory: ${dir}: ${error}`); + this.warn(`Failed to delete WSL temp directory: ${dir}: ${error}`); } } this.wslTempDirs.clear(); @@ -247,7 +256,7 @@ export class TestContext { for (let attempt = 0; attempt < maxRetries; attempt++) { if (attempt > 0) { const delay = Math.pow(2, attempt - 1) * 1000; - this.log(`Retrying fetch (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`); + this.warn(`Retrying fetch (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`); await new Promise(resolve => setTimeout(resolve, delay)); } @@ -266,7 +275,7 @@ export class TestContext { return response as Response & { body: NodeJS.ReadableStream }; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); - this.log(`Fetch attempt ${attempt + 1} failed: ${lastError.message}`); + this.warn(`Fetch attempt ${attempt + 1} failed: ${lastError.message}`); } } @@ -1091,7 +1100,7 @@ export class TestContext { await page.screenshot({ path: screenshotPath, fullPage: true }); this.log(`Screenshot saved to: ${screenshotPath}`); } catch (e) { - this.log(`Failed to capture screenshot: ${e instanceof Error ? e.message : String(e)}`); + this.warn(`Failed to capture screenshot: ${e instanceof Error ? e.message : String(e)}`); } } diff --git a/test/sanity/src/uiTest.ts b/test/sanity/src/uiTest.ts index 65f8b14a49e..96733481e96 100644 --- a/test/sanity/src/uiTest.ts +++ b/test/sanity/src/uiTest.ts @@ -138,7 +138,7 @@ export class UITest { const extensionItem = page.locator('.extension-list-item').getByText(/^GitHub Pull Requests$/); const messageContainer = page.locator('.extensions-viewlet .message-container:not(.hidden)').first(); - for (let attempt = 0; attempt < 3; attempt++) { + for (let attempt = 0; attempt < 5; attempt++) { const result = await Promise.race([ extensionItem.waitFor().then(() => 'found' as const), messageContainer.waitFor().then(() => 'message' as const), @@ -149,20 +149,33 @@ export class UITest { } const message = await messageContainer.locator('.message').innerText(); - this.context.log(`Marketplace message: ${message} (attempt ${attempt + 1}/3), clicking Refresh`); + this.context.log(`Marketplace message: ${message} (attempt ${attempt + 1}/5), clicking Refresh`); await page.getByRole('button', { name: 'Refresh' }).click(); - await messageContainer.waitFor({ state: 'hidden', timeout: 30_000 }); + await page.waitForTimeout(5_000); } await extensionItem.waitFor(); - this.context.log('Clicking Install on the first extension in the list'); - const installButton = page.locator('.extension-action:not(.disabled)', { hasText: /Install/ }).first(); - await installButton.waitFor(); - await installButton.click(); + for (let attempt = 0; attempt < 3; attempt++) { + try { + this.context.log(`Clicking Install on the first extension in the list (attempt ${attempt + 1}/3)`); + const installButton = page.locator('.extension-action:not(.disabled)', { hasText: /Install/ }).first(); + await installButton.click(); - this.context.log('Waiting for extension to be installed'); - await page.getByRole('button', { name: 'Uninstall' }).first().waitFor({ timeout: 5 * 60_000 }); + this.context.log('Waiting for extension to be installed'); + const uninstallButton = page.getByRole('button', { name: 'Uninstall' }).first(); + const installed = await uninstallButton.waitFor({ timeout: 5 * 60_000 }).then(() => true, () => false); + if (installed) { + return; + } + } catch (error) { + this.context.log(`Extension install attempt ${attempt + 1}/3 failed: ${error instanceof Error ? error.message : String(error)}`); + } + + this.context.log('Extension install may have failed, retrying'); + } + + throw new Error('Failed to install extension after 3 attempts'); } /** diff --git a/test/smoke/extensions/vscode-smoketest-ext-host/extension.js b/test/smoke/extensions/vscode-smoketest-ext-host/extension.js index e69899a6b29..c1219497701 100644 --- a/test/smoke/extensions/vscode-smoketest-ext-host/extension.js +++ b/test/smoke/extensions/vscode-smoketest-ext-host/extension.js @@ -16,6 +16,16 @@ let deactivateMarkerFile; * @param {vscode.ExtensionContext} context */ function activate(context) { + // Record extension host pid on every activation so smoke tests can validate + // that a new extension host process was started after a restart action. + try { + const pid = String(process.pid); + const activationPidFile = path.join(os.tmpdir(), 'vscode-ext-host-pid-on-activate.txt'); + fs.writeFileSync(activationPidFile, pid, 'utf-8'); + } catch { + // Ignore errors in smoke helper setup. + } + // This is used to verify that the extension host process is properly killed // when window reloads even if the extension host is blocked // Refs: https://github.com/microsoft/vscode/issues/291346 @@ -23,12 +33,9 @@ function activate(context) { vscode.commands.registerCommand('smoketest.getExtensionHostPidAndBlock', (delayMs = 100, durationMs = 60000) => { const pid = process.pid; - // Write PID file to workspace folder if available, otherwise temp dir + // Write PID file to temp dir to avoid polluting workspace search results // Note: filename must match name in extension-host-restart.test.ts - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - const pidFile = workspaceFolder - ? path.join(workspaceFolder, 'vscode-ext-host-pid.txt') - : path.join(os.tmpdir(), 'vscode-ext-host-pid.txt'); + const pidFile = path.join(os.tmpdir(), 'vscode-ext-host-pid.txt'); setTimeout(() => { fs.writeFileSync(pidFile, String(pid), 'utf-8'); @@ -57,13 +64,8 @@ function activate(context) { context.subscriptions.push( vscode.commands.registerCommand('smoketest.setupGracefulDeactivation', () => { const pid = process.pid; - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - const pidFile = workspaceFolder - ? path.join(workspaceFolder, 'vscode-ext-host-pid-graceful.txt') - : path.join(os.tmpdir(), 'vscode-ext-host-pid-graceful.txt'); - deactivateMarkerFile = workspaceFolder - ? path.join(workspaceFolder, 'vscode-ext-host-deactivated.txt') - : path.join(os.tmpdir(), 'vscode-ext-host-deactivated.txt'); + const pidFile = path.join(os.tmpdir(), 'vscode-ext-host-pid-graceful.txt'); + deactivateMarkerFile = path.join(os.tmpdir(), 'vscode-ext-host-deactivated.txt'); // Write PID file immediately so test knows the extension is ready fs.writeFileSync(pidFile, String(pid), 'utf-8'); diff --git a/test/smoke/src/areas/extensions/extension-host-restart.test.ts b/test/smoke/src/areas/extensions/extension-host-restart.test.ts index 6528e61abe3..b26ccad39da 100644 --- a/test/smoke/src/areas/extensions/extension-host-restart.test.ts +++ b/test/smoke/src/areas/extensions/extension-host-restart.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { Application, Logger } from '../../../../automation'; import { installAllHandlers, timeout } from '../../utils'; @@ -30,7 +31,7 @@ export function setup(logger: Logger) { this.timeout(60_000); const app = this.app as Application; - const pidFile = path.join(app.workspacePathOrFolder, 'vscode-ext-host-pid.txt'); + const pidFile = path.join(os.tmpdir(), 'vscode-ext-host-pid.txt'); if (fs.existsSync(pidFile)) { fs.unlinkSync(pidFile); @@ -78,8 +79,8 @@ export function setup(logger: Logger) { this.timeout(60_000); const app = this.app as Application; - const pidFile = path.join(app.workspacePathOrFolder, 'vscode-ext-host-pid-graceful.txt'); - const markerFile = path.join(app.workspacePathOrFolder, 'vscode-ext-host-deactivated.txt'); + const pidFile = path.join(os.tmpdir(), 'vscode-ext-host-pid-graceful.txt'); + const markerFile = path.join(os.tmpdir(), 'vscode-ext-host-deactivated.txt'); // Clean up any existing files if (fs.existsSync(pidFile)) { @@ -139,5 +140,79 @@ export function setup(logger: Logger) { logger.log('Extension host was properly terminated after graceful deactivation'); }); + + it('kills blocked extension host on restart extension host (issue #296681)', async function () { + this.timeout(90_000); + + const app = this.app as Application; + const pidFile = path.join(os.tmpdir(), 'vscode-ext-host-pid.txt'); + const activationPidFile = path.join(os.tmpdir(), 'vscode-ext-host-pid-on-activate.txt'); + + if (fs.existsSync(pidFile)) { + fs.unlinkSync(pidFile); + } + + await app.workbench.quickaccess.runCommand('smoketest.getExtensionHostPidAndBlock'); + + let retries = 0; + while (!fs.existsSync(pidFile) && retries < 20) { + await timeout(500); + retries++; + } + + if (!fs.existsSync(pidFile)) { + throw new Error('PID file was not created - extension may not have activated'); + } + + const oldPid = parseInt(fs.readFileSync(pidFile, 'utf-8'), 10); + logger.log(`Old extension host PID: ${oldPid}`); + + if (fs.existsSync(activationPidFile)) { + fs.unlinkSync(activationPidFile); + } + + await app.workbench.quickaccess.runCommand('Developer: Restart Extension Host', { keepOpen: true }); + + const maxWaitMs = 10_000; + const pollIntervalMs = 500; + let waitedMs = 0; + + let newPid: number | undefined; + while (waitedMs < maxWaitMs) { + if (fs.existsSync(activationPidFile)) { + const pidText = fs.readFileSync(activationPidFile, 'utf-8').trim(); + const parsedPid = parseInt(pidText, 10); + if (!Number.isNaN(parsedPid) && parsedPid !== oldPid) { + newPid = parsedPid; + break; + } + } + + await timeout(pollIntervalMs); + waitedMs += pollIntervalMs; + } + + if (!newPid) { + throw new Error(`New extension host PID was not observed after restart (waited ${maxWaitMs}ms)`); + } + + if (newPid === oldPid) { + throw new Error(`Extension host PID did not change after restart (pid: ${oldPid})`); + } + + logger.log(`New extension host PID observed: ${newPid}`); + + waitedMs = 0; + while (processExists(oldPid) && waitedMs < maxWaitMs) { + await timeout(pollIntervalMs); + waitedMs += pollIntervalMs; + } + + if (processExists(oldPid)) { + throw new Error(`Old extension host ${oldPid} still running after restart (waited ${maxWaitMs}ms)`); + } + + logger.log(`Extension host restarted successfully (old: ${oldPid}, new: ${newPid})`); + }); }); }